mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(plugin): ♻️ Move parts of OpenID logic to plugin
This commit is contained in:
parent
69d7d50239
commit
d51bae52c6
|
|
@ -157,7 +157,7 @@ export default apiRoute((app) =>
|
|||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
|
|
|
|||
|
|
@ -1,367 +0,0 @@
|
|||
import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { sentry } from "@/sentry";
|
||||
import { zValidator } from "@hono/zod-validator";
|
||||
import type { Context } from "hono";
|
||||
import { SignJWT, jwtVerify } from "jose";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~/classes/functions/token";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions, Tokens } from "~/drizzle/schema";
|
||||
import { config } from "~/packages/config-manager";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
|
||||
export const meta = applyConfig({
|
||||
allowedMethods: ["POST"],
|
||||
ratelimits: {
|
||||
max: 4,
|
||||
duration: 60,
|
||||
},
|
||||
route: "/oauth/authorize",
|
||||
auth: {
|
||||
required: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const schemas = {
|
||||
query: z.object({
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
json: z.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
}),
|
||||
};
|
||||
|
||||
const returnError = (
|
||||
context: Context,
|
||||
data: object,
|
||||
error: string,
|
||||
description: string,
|
||||
) => {
|
||||
const searchParams = new URLSearchParams();
|
||||
|
||||
// Add all data that is not undefined except email and password
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (key !== "email" && key !== "password" && value !== undefined) {
|
||||
searchParams.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
searchParams.append("error", error);
|
||||
searchParams.append("error_description", description);
|
||||
|
||||
return context.redirect(
|
||||
`${config.frontend.routes.login}?${searchParams.toString()}`,
|
||||
);
|
||||
};
|
||||
|
||||
export default apiRoute((app) =>
|
||||
app.on(
|
||||
meta.allowedMethods,
|
||||
meta.route,
|
||||
jsonOrForm(),
|
||||
zValidator("query", schemas.query, handleZodError),
|
||||
zValidator("json", schemas.json, handleZodError),
|
||||
async (context) => {
|
||||
const { scope, redirect_uri, response_type, client_id, state } =
|
||||
context.req.valid("json");
|
||||
|
||||
const body = context.req.valid("json");
|
||||
|
||||
const cookie = context.req.header("Cookie");
|
||||
|
||||
if (!cookie) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"No cookies were sent with the request",
|
||||
);
|
||||
}
|
||||
|
||||
const jwt = cookie
|
||||
.split(";")
|
||||
.find((c) => c.trim().startsWith("jwt="))
|
||||
?.split("=")[1];
|
||||
|
||||
if (!jwt) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"No jwt cookie was sent in the request",
|
||||
);
|
||||
}
|
||||
|
||||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||
"Ed25519",
|
||||
true,
|
||||
["sign"],
|
||||
);
|
||||
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
||||
"Ed25519",
|
||||
true,
|
||||
["verify"],
|
||||
);
|
||||
|
||||
const result = await jwtVerify(jwt, publicKey, {
|
||||
algorithms: ["EdDSA"],
|
||||
issuer: new URL(config.http.base_url).origin,
|
||||
audience: client_id,
|
||||
}).catch((e) => {
|
||||
console.error(e);
|
||||
sentry?.captureException(e);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid JWT, could not verify",
|
||||
);
|
||||
}
|
||||
|
||||
const payload = result.payload;
|
||||
|
||||
if (!payload.sub) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid sub",
|
||||
);
|
||||
}
|
||||
if (!payload.aud) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid aud",
|
||||
);
|
||||
}
|
||||
if (!payload.exp) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid exp",
|
||||
);
|
||||
}
|
||||
|
||||
// Check if the user is authenticated
|
||||
const user = await User.fromId(payload.sub);
|
||||
|
||||
if (!user) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid sub",
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.hasPermission(RolePermissions.OAuth)) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
`User is missing the ${RolePermissions.OAuth} permission`,
|
||||
);
|
||||
}
|
||||
|
||||
const responseTypes = response_type.split(" ");
|
||||
|
||||
const asksCode = responseTypes.includes("code");
|
||||
const asksToken = responseTypes.includes("token");
|
||||
const asksIdToken = responseTypes.includes("id_token");
|
||||
|
||||
if (!(asksCode || asksToken || asksIdToken)) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Invalid response_type, must ask for code, token, or id_token",
|
||||
);
|
||||
}
|
||||
|
||||
if (asksCode && !redirect_uri) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Redirect URI is required for code flow (can be urn:ietf:wg:oauth:2.0:oob)",
|
||||
);
|
||||
}
|
||||
|
||||
/* if (asksCode && !code_challenge)
|
||||
return returnError(
|
||||
"invalid_request",
|
||||
"Code challenge is required for code flow",
|
||||
);
|
||||
|
||||
if (asksCode && !code_challenge_method)
|
||||
return returnError(
|
||||
"invalid_request",
|
||||
"Code challenge method is required for code flow",
|
||||
); */
|
||||
|
||||
// Authenticate the user
|
||||
const application = await db.query.Applications.findFirst({
|
||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||
});
|
||||
|
||||
if (!application) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_client",
|
||||
"Invalid client_id or client_secret",
|
||||
);
|
||||
}
|
||||
|
||||
if (application.redirectUri !== redirect_uri) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_request",
|
||||
"Redirect URI does not match client_id",
|
||||
);
|
||||
}
|
||||
|
||||
// Validate scopes, they can either be equal or a subset of the application's scopes
|
||||
const applicationScopes = application.scopes.split(" ");
|
||||
|
||||
if (
|
||||
scope &&
|
||||
!scope.split(" ").every((s) => applicationScopes.includes(s))
|
||||
) {
|
||||
return returnError(
|
||||
context,
|
||||
body,
|
||||
"invalid_scope",
|
||||
"Invalid scope",
|
||||
);
|
||||
}
|
||||
|
||||
// Generate tokens
|
||||
const code = randomString(256, "base64url");
|
||||
|
||||
// Handle the requested scopes
|
||||
let idTokenPayload = {};
|
||||
const scopeIncludesOpenId = scope?.split(" ").includes("openid");
|
||||
const scopeIncludesProfile = scope?.split(" ").includes("profile");
|
||||
const scopeIncludesEmail = scope?.split(" ").includes("email");
|
||||
if (scope) {
|
||||
if (scopeIncludesOpenId) {
|
||||
// Include the standard OpenID claims
|
||||
idTokenPayload = {
|
||||
...idTokenPayload,
|
||||
sub: user.id,
|
||||
aud: client_id,
|
||||
iss: new URL(config.http.base_url).origin,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
};
|
||||
}
|
||||
if (scopeIncludesProfile) {
|
||||
// Include the user's profile information
|
||||
idTokenPayload = {
|
||||
...idTokenPayload,
|
||||
name: user.data.displayName,
|
||||
preferred_username: user.data.username,
|
||||
picture: user.getAvatarUrl(config),
|
||||
updated_at: new Date(user.data.updatedAt).toISOString(),
|
||||
};
|
||||
}
|
||||
if (scopeIncludesEmail) {
|
||||
// Include the user's email address
|
||||
idTokenPayload = {
|
||||
...idTokenPayload,
|
||||
email: user.data.email,
|
||||
// TODO: Add verification system
|
||||
email_verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const idToken = await new SignJWT(idTokenPayload)
|
||||
.setProtectedHeader({
|
||||
alg: "EdDSA",
|
||||
})
|
||||
.sign(privateKey);
|
||||
|
||||
await db.insert(Tokens).values({
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: code,
|
||||
scope: scope ?? application.scopes,
|
||||
tokenType: TokenType.Bearer,
|
||||
applicationId: application.id,
|
||||
redirectUri: redirect_uri ?? application.redirectUri,
|
||||
expiresAt: new Date(
|
||||
Date.now() + 60 * 60 * 24 * 14,
|
||||
).toISOString(),
|
||||
idToken:
|
||||
scopeIncludesOpenId ||
|
||||
scopeIncludesEmail ||
|
||||
scopeIncludesProfile
|
||||
? idToken
|
||||
: null,
|
||||
clientId: client_id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
// Redirect to the client
|
||||
const redirectUri =
|
||||
redirect_uri === "urn:ietf:wg:oauth:2.0:oob"
|
||||
? new URL("/oauth/code", config.http.base_url)
|
||||
: new URL(redirect_uri ?? application.redirectUri);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
code: code,
|
||||
});
|
||||
|
||||
if (state) {
|
||||
searchParams.append("state", state);
|
||||
}
|
||||
|
||||
redirectUri.search = searchParams.toString();
|
||||
|
||||
return context.redirect(redirectUri.toString());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -257,7 +257,7 @@ export default apiRoute((app) =>
|
|||
// Try and import the key
|
||||
const privateKey = await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export default apiRoute((app) =>
|
|||
app.on(meta.allowedMethods, meta.route, async (context) => {
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
||||
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
||||
"Ed25519",
|
||||
true,
|
||||
["verify"],
|
||||
|
|
|
|||
16
app.ts
16
app.ts
|
|
@ -1,6 +1,7 @@
|
|||
import { handleZodError } from "@/api";
|
||||
import { sentry } from "@/sentry";
|
||||
import { cors } from "@hono/hono/cors";
|
||||
import { createMiddleware } from "@hono/hono/factory";
|
||||
import { prettyJSON } from "@hono/hono/pretty-json";
|
||||
import { secureHeaders } from "@hono/hono/secure-headers";
|
||||
import { swaggerUI } from "@hono/swagger-ui";
|
||||
|
|
@ -9,6 +10,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
|||
*/ import { getLogger } from "@logtape/logtape";
|
||||
import pkg from "~/package.json" with { type: "application/json" };
|
||||
import { config } from "~/packages/config-manager/index";
|
||||
import plugin from "~/plugins/openid";
|
||||
import { agentBans } from "./middlewares/agent-bans";
|
||||
import { bait } from "./middlewares/bait";
|
||||
import { boundaryCheck } from "./middlewares/boundary-check";
|
||||
|
|
@ -83,6 +85,14 @@ export const appFactory = async () => {
|
|||
credentials: true,
|
||||
}),
|
||||
);
|
||||
app.use(
|
||||
createMiddleware<HonoEnv>(async (context, next) => {
|
||||
context.set("config", config);
|
||||
|
||||
await next();
|
||||
}),
|
||||
);
|
||||
|
||||
/* app.use("*", registerMetrics);
|
||||
app.get("/metrics", printMetrics); */
|
||||
// Disabled as federation now checks for this
|
||||
|
|
@ -100,6 +110,12 @@ export const appFactory = async () => {
|
|||
route.default(app);
|
||||
}
|
||||
|
||||
// @ts-expect-error We check if the keys are valid before this is called
|
||||
// biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method
|
||||
plugin["_loadConfig"](config.oidc);
|
||||
// biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method
|
||||
plugin["_addToApp"](app);
|
||||
|
||||
app.doc31("/openapi.json", {
|
||||
openapi: "3.1.0",
|
||||
info: {
|
||||
|
|
|
|||
8
build.ts
8
build.ts
|
|
@ -1,3 +1,4 @@
|
|||
import { readdir } from "node:fs/promises";
|
||||
import { $ } from "bun";
|
||||
import ora from "ora";
|
||||
import { routes } from "~/routes";
|
||||
|
|
@ -6,12 +7,19 @@ const buildSpinner = ora("Building").start();
|
|||
|
||||
await $`rm -rf dist && mkdir dist`;
|
||||
|
||||
// Get all directories under the plugins/ directory
|
||||
const pluginDirs = await readdir("plugins", { withFileTypes: true });
|
||||
|
||||
await Bun.build({
|
||||
entrypoints: [
|
||||
"index.ts",
|
||||
"cli/index.ts",
|
||||
// Force Bun to include endpoints
|
||||
...Object.values(routes),
|
||||
// Include all plugins
|
||||
...pluginDirs
|
||||
.filter((dir) => dir.isDirectory())
|
||||
.map((dir) => `plugins/${dir.name}/index.ts`),
|
||||
],
|
||||
outdir: "dist",
|
||||
target: "bun",
|
||||
|
|
|
|||
|
|
@ -237,22 +237,21 @@
|
|||
},
|
||||
"default": []
|
||||
},
|
||||
"jwt_key": {
|
||||
"anyOf": [
|
||||
{
|
||||
"keys": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"public": {
|
||||
"type": "string",
|
||||
"minLength": 3,
|
||||
"pattern": "\\;",
|
||||
"default": ""
|
||||
"minLength": 1
|
||||
},
|
||||
{
|
||||
"private": {
|
||||
"type": "string",
|
||||
"const": ""
|
||||
"minLength": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"required": ["jwt_key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"http": {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@
|
|||
"icon": "https://github.com/versia-pub/server",
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"keywords": ["federated", "activitypub", "bun"],
|
||||
"workspaces": ["packages/*"],
|
||||
"workspaces": ["packages/plugin-kit"],
|
||||
"maintainers": [
|
||||
{
|
||||
"email": "contact@cpluspatch.com",
|
||||
|
|
|
|||
|
|
@ -122,7 +122,12 @@ export const configValidator = z.object({
|
|||
}),
|
||||
)
|
||||
.default([]),
|
||||
jwt_key: z.string().min(3).includes(";").default("").or(z.literal("")),
|
||||
keys: z
|
||||
.object({
|
||||
public: z.string().min(1).optional(),
|
||||
private: z.string().min(1).optional(),
|
||||
})
|
||||
.optional(),
|
||||
}),
|
||||
http: z.object({
|
||||
base_url: z.string().min(1).default("http://versia.social"),
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Plugin } from "./plugin";
|
||||
import { Hooks } from "./hooks";
|
||||
import { Plugin, PluginConfigManager } from "./plugin";
|
||||
import type { Manifest } from "./schema";
|
||||
|
||||
export type { Manifest };
|
||||
export { Plugin };
|
||||
export { Plugin, PluginConfigManager, Hooks };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"name": "@versia-org/kit",
|
||||
"name": "@versia/kit",
|
||||
"module": "index.ts",
|
||||
"type": "module",
|
||||
"version": "0.0.0",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,23 @@
|
|||
import { createMiddleware } from "@hono/hono/factory";
|
||||
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||
import type { z } from "zod";
|
||||
import { type ZodError, fromZodError } from "zod-validation-error";
|
||||
import type { HonoEnv } from "~/types/api";
|
||||
import type { ServerHooks } from "./hooks";
|
||||
import { type Manifest, manifestSchema } from "./schema";
|
||||
|
||||
export type HonoPluginEnv<ConfigType extends z.ZodTypeAny> = HonoEnv & {
|
||||
Variables: {
|
||||
pluginConfig: z.infer<ConfigType>;
|
||||
};
|
||||
};
|
||||
|
||||
export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
||||
private handlers: Partial<ServerHooks> = {};
|
||||
private routes: {
|
||||
path: string;
|
||||
fn: (app: OpenAPIHono<HonoPluginEnv<ConfigSchema>>) => void;
|
||||
}[] = [];
|
||||
|
||||
constructor(
|
||||
private manifest: Manifest,
|
||||
|
|
@ -13,20 +26,49 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
|||
this.validateManifest(manifest);
|
||||
}
|
||||
|
||||
get middleware() {
|
||||
// Middleware that adds the plugin's configuration to the request object
|
||||
return createMiddleware<HonoPluginEnv<ConfigSchema>>(
|
||||
async (context, next) => {
|
||||
context.set("pluginConfig", this.configManager.getConfig());
|
||||
await next();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public getManifest() {
|
||||
return this.manifest;
|
||||
}
|
||||
|
||||
public registerRoute(
|
||||
path: string,
|
||||
fn: (app: OpenAPIHono<HonoPluginEnv<ConfigSchema>>) => void,
|
||||
) {
|
||||
this.routes.push({
|
||||
path,
|
||||
fn,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the plugin's configuration from the Versia Server configuration file.
|
||||
* This will be called when the plugin is loaded.
|
||||
* @param config Values the user has set in the configuration file.
|
||||
*/
|
||||
protected _loadConfig(config: z.infer<ConfigSchema>) {
|
||||
protected _loadConfig(config: z.input<ConfigSchema>) {
|
||||
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||
this.configManager["_load"](config);
|
||||
}
|
||||
|
||||
protected _addToApp(app: OpenAPIHono<HonoEnv>) {
|
||||
for (const route of this.routes) {
|
||||
app.use(route.path, this.middleware);
|
||||
route.fn(
|
||||
app as unknown as OpenAPIHono<HonoPluginEnv<ConfigSchema>>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public registerHandler<HookName extends keyof ServerHooks>(
|
||||
hook: HookName,
|
||||
handler: ServerHooks[HookName],
|
||||
|
|
@ -56,6 +98,7 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
|||
* Handles loading, defining, and managing the plugin's configuration.
|
||||
* Plugins can define their own configuration schema, which is then used to
|
||||
* load it from the user's configuration file.
|
||||
* @param schema The Zod schema that defines the configuration.
|
||||
*/
|
||||
export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
||||
private store: z.infer<Schema> | null;
|
||||
|
|
@ -69,10 +112,10 @@ export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
|||
* This will be called when the plugin is loaded.
|
||||
* @param config Values the user has set in the configuration file.
|
||||
*/
|
||||
protected _load(config: z.infer<Schema>) {
|
||||
protected async _load(config: z.infer<Schema>) {
|
||||
// Check if the configuration is valid
|
||||
try {
|
||||
this.schema.parse(config);
|
||||
this.store = await this.schema.parseAsync(config);
|
||||
} catch (error) {
|
||||
throw fromZodError(error as ZodError);
|
||||
}
|
||||
|
|
@ -82,6 +125,10 @@ export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
|||
* Returns the internal configuration object.
|
||||
*/
|
||||
public getConfig() {
|
||||
if (!this.store) {
|
||||
throw new Error("Configuration has not been loaded yet.");
|
||||
}
|
||||
|
||||
return this.store;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
77
plugins/openid/index.ts
Normal file
77
plugins/openid/index.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { Hooks, type Manifest, Plugin, PluginConfigManager } from "@versia/kit";
|
||||
import { z } from "zod";
|
||||
import authorizeRoute from "./routes/authorize";
|
||||
|
||||
const myManifest: Manifest = {
|
||||
name: "@versia/openid",
|
||||
description: "OpenID authentication.",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
const configManager = new PluginConfigManager(
|
||||
z.object({
|
||||
forced: z.boolean().default(false),
|
||||
allow_registration: z.boolean().default(true),
|
||||
providers: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1),
|
||||
id: z.string().min(1),
|
||||
url: z.string().min(1),
|
||||
client_id: z.string().min(1),
|
||||
client_secret: z.string().min(1),
|
||||
icon: z.string().min(1).optional(),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
keys: z.object({
|
||||
public: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform(async (v) => {
|
||||
try {
|
||||
return await crypto.subtle.importKey(
|
||||
"spki",
|
||||
Buffer.from(v, "base64"),
|
||||
"Ed25519",
|
||||
true,
|
||||
["verify"],
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Public key at oidc.keys.public is invalid",
|
||||
);
|
||||
}
|
||||
}),
|
||||
private: z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform(async (v) => {
|
||||
try {
|
||||
return await crypto.subtle.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(v, "base64"),
|
||||
"Ed25519",
|
||||
true,
|
||||
["sign"],
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Private key at oidc.keys.private is invalid",
|
||||
);
|
||||
}
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const plugin = new Plugin(myManifest, configManager);
|
||||
|
||||
plugin.registerHandler(Hooks.Response, (req) => {
|
||||
console.info("Request received:", req);
|
||||
return req;
|
||||
});
|
||||
authorizeRoute(plugin);
|
||||
|
||||
export type PluginType = typeof plugin;
|
||||
export default plugin;
|
||||
309
plugins/openid/routes/authorize.ts
Normal file
309
plugins/openid/routes/authorize.ts
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { auth, jsonOrForm } from "@/api";
|
||||
import { randomString } from "@/math";
|
||||
import { type JWTPayload, SignJWT, jwtVerify } from "jose";
|
||||
import { JOSEError } from "jose/errors";
|
||||
import { z } from "zod";
|
||||
import { TokenType } from "~/classes/functions/token";
|
||||
import { db } from "~/drizzle/db";
|
||||
import { RolePermissions, Tokens } from "~/drizzle/schema";
|
||||
import { User } from "~/packages/database-interface/user";
|
||||
import type { PluginType } from "../index";
|
||||
|
||||
const schemas = {
|
||||
query: z.object({
|
||||
prompt: z
|
||||
.enum(["none", "login", "consent", "select_account"])
|
||||
.optional()
|
||||
.default("none"),
|
||||
max_age: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.optional()
|
||||
.default(60 * 60 * 24 * 7),
|
||||
}),
|
||||
json: z
|
||||
.object({
|
||||
scope: z.string().optional(),
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.url()
|
||||
.optional()
|
||||
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
|
||||
response_type: z.enum([
|
||||
"code",
|
||||
"token",
|
||||
"none",
|
||||
"id_token",
|
||||
"code id_token",
|
||||
"code token",
|
||||
"token id_token",
|
||||
"code token id_token",
|
||||
]),
|
||||
client_id: z.string(),
|
||||
state: z.string().optional(),
|
||||
code_challenge: z.string().optional(),
|
||||
code_challenge_method: z.enum(["plain", "S256"]).optional(),
|
||||
})
|
||||
.refine(
|
||||
// Check if redirect_uri is valid for code flow
|
||||
(data) =>
|
||||
data.response_type.includes("code") ? data.redirect_uri : true,
|
||||
"redirect_uri is required for code flow",
|
||||
),
|
||||
// Disable for Mastodon API compatibility
|
||||
/* .refine(
|
||||
// Check if code_challenge is valid for code flow
|
||||
(data) =>
|
||||
data.response_type.includes("code")
|
||||
? data.code_challenge
|
||||
: true,
|
||||
"code_challenge is required for code flow",
|
||||
), */
|
||||
cookies: z.object({
|
||||
jwt: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
export default (plugin: PluginType) =>
|
||||
plugin.registerRoute("/oauth/authorize", (app) =>
|
||||
app.openapi(
|
||||
{
|
||||
method: "post",
|
||||
path: "/oauth/authorize",
|
||||
middleware: [
|
||||
auth({
|
||||
required: false,
|
||||
}),
|
||||
jsonOrForm(),
|
||||
plugin.middleware,
|
||||
],
|
||||
responses: {
|
||||
302: {
|
||||
description: "Redirect to the application",
|
||||
},
|
||||
},
|
||||
request: {
|
||||
query: schemas.query,
|
||||
body: {
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: schemas.json,
|
||||
},
|
||||
"application/x-www-form-urlencoded": {
|
||||
schema: schemas.json,
|
||||
},
|
||||
"multipart/form-data": {
|
||||
schema: schemas.json,
|
||||
},
|
||||
},
|
||||
},
|
||||
cookies: schemas.cookies,
|
||||
},
|
||||
},
|
||||
async (context) => {
|
||||
const { scope, redirect_uri, client_id, state } =
|
||||
context.req.valid("json");
|
||||
|
||||
const { jwt } = context.req.valid("cookie");
|
||||
|
||||
const { keys } = context.get("pluginConfig");
|
||||
|
||||
const errorSearchParams = new URLSearchParams(
|
||||
Object.fromEntries(
|
||||
Object.entries(context.req.valid("json")).filter(
|
||||
([k, v]) =>
|
||||
v !== undefined &&
|
||||
k !== "password" &&
|
||||
k !== "email",
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await jwtVerify(jwt, keys.public, {
|
||||
algorithms: ["EdDSA"],
|
||||
audience: client_id,
|
||||
issuer: new URL(context.get("config").http.base_url).origin,
|
||||
}).catch((error) => {
|
||||
if (error instanceof JOSEError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw error;
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, could not verify",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
payload: { aud, sub, exp },
|
||||
} = result;
|
||||
|
||||
if (!(aud && sub && exp)) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, missing required fields (aud, sub, exp)",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const user = await User.fromId(sub);
|
||||
|
||||
if (!user) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid JWT, could not find associated user",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!user.hasPermission(RolePermissions.OAuth)) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
`User is missing the required permission ${RolePermissions.OAuth}`,
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const application = await db.query.Applications.findFirst({
|
||||
where: (app, { eq }) => eq(app.clientId, client_id),
|
||||
});
|
||||
|
||||
if (!application) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid client_id: no associated application found",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (application.redirectUri !== redirect_uri) {
|
||||
errorSearchParams.append("error", "invalid_request");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid redirect_uri: does not match application's redirect_uri",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check that scopes are a subset of the application's scopes
|
||||
if (
|
||||
scope &&
|
||||
!scope
|
||||
.split(" ")
|
||||
.every((s) => application.scopes.includes(s))
|
||||
) {
|
||||
errorSearchParams.append("error", "invalid_scope");
|
||||
errorSearchParams.append(
|
||||
"error_description",
|
||||
"Invalid scope: not a subset of the application's scopes",
|
||||
);
|
||||
|
||||
return context.redirect(
|
||||
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const code = randomString(256, "base64url");
|
||||
|
||||
let payload: JWTPayload = {};
|
||||
|
||||
if (scope) {
|
||||
if (scope.split(" ").includes("openid")) {
|
||||
payload = {
|
||||
...payload,
|
||||
sub: user.id,
|
||||
iss: new URL(context.get("config").http.base_url)
|
||||
.origin,
|
||||
aud: client_id,
|
||||
exp: Math.floor(Date.now() / 1000) + 60 * 60,
|
||||
iat: Math.floor(Date.now() / 1000),
|
||||
nbf: Math.floor(Date.now() / 1000),
|
||||
};
|
||||
}
|
||||
if (scope.split(" ").includes("profile")) {
|
||||
payload = {
|
||||
...payload,
|
||||
name: user.data.displayName,
|
||||
preferred_username: user.data.username,
|
||||
picture: user.getAvatarUrl(context.get("config")),
|
||||
updated_at: new Date(
|
||||
user.data.updatedAt,
|
||||
).toISOString(),
|
||||
};
|
||||
}
|
||||
if (scope.split(" ").includes("email")) {
|
||||
payload = {
|
||||
...payload,
|
||||
email: user.data.email,
|
||||
// TODO: Add verification system
|
||||
email_verified: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const idToken = await new SignJWT(payload)
|
||||
.setProtectedHeader({ alg: "EdDSA" })
|
||||
.sign(keys.private);
|
||||
|
||||
await db.insert(Tokens).values({
|
||||
accessToken: randomString(64, "base64url"),
|
||||
code: code,
|
||||
scope: scope ?? application.scopes,
|
||||
tokenType: TokenType.Bearer,
|
||||
applicationId: application.id,
|
||||
redirectUri: redirect_uri ?? application.redirectUri,
|
||||
expiresAt: new Date(
|
||||
Date.now() + 60 * 60 * 24 * 14,
|
||||
).toISOString(),
|
||||
idToken: ["profile", "email", "openid"].some((s) =>
|
||||
scope?.split(" ").includes(s),
|
||||
)
|
||||
? idToken
|
||||
: null,
|
||||
clientId: client_id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const redirectUri =
|
||||
redirect_uri === "urn:ietf:wg:oauth:2.0:oob"
|
||||
? new URL(
|
||||
"/oauth/code",
|
||||
context.get("config").http.base_url,
|
||||
)
|
||||
: new URL(redirect_uri ?? application.redirectUri);
|
||||
|
||||
redirectUri.searchParams.append("code", code);
|
||||
state && redirectUri.searchParams.append("state", state);
|
||||
|
||||
return context.redirect(redirectUri.toString());
|
||||
},
|
||||
),
|
||||
);
|
||||
|
|
@ -14,6 +14,7 @@ import type {
|
|||
import { z } from "zod";
|
||||
import type { Application } from "~/classes/functions/application";
|
||||
import type { RolePermissions } from "~/drizzle/schema";
|
||||
import type { Config } from "~/packages/config-manager";
|
||||
import type { User as DatabaseUser } from "~/packages/database-interface/user";
|
||||
|
||||
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
||||
|
|
@ -51,6 +52,7 @@ export const ErrorSchema = z.object({
|
|||
|
||||
export type HonoEnv = {
|
||||
Variables: {
|
||||
config: Config;
|
||||
auth: {
|
||||
user: DatabaseUser | null;
|
||||
token: string | null;
|
||||
|
|
|
|||
|
|
@ -78,9 +78,9 @@ const checkChallengeConfig = async (config: Config) => {
|
|||
const checkOidcConfig = async (config: Config) => {
|
||||
const logger = getLogger("server");
|
||||
|
||||
if (!config.oidc.jwt_key) {
|
||||
logger.fatal`The JWT private key is not set in the config`;
|
||||
logger.fatal`Below is a generated key for you to copy in the config at oidc.jwt_key`;
|
||||
if (!(config.oidc.keys?.private && config.oidc.keys?.public)) {
|
||||
logger.fatal`The OpenID keys are not set in the config`;
|
||||
logger.fatal`Below are generated key for you to copy in the config at oidc.keys`;
|
||||
|
||||
// Generate a key for them
|
||||
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||
|
|
@ -96,7 +96,9 @@ const checkOidcConfig = async (config: Config) => {
|
|||
await crypto.subtle.exportKey("spki", keys.publicKey),
|
||||
).toString("base64");
|
||||
|
||||
logger.fatal`Generated key: ${chalk.gray(`${privateKey};${publicKey}`)}`;
|
||||
logger.fatal`Generated keys:`;
|
||||
logger.fatal`Private key: ${chalk.gray(privateKey)}`;
|
||||
logger.fatal`Public key: ${chalk.gray(publicKey)}`;
|
||||
|
||||
// Hang until Ctrl+C is pressed
|
||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||
|
|
@ -106,7 +108,7 @@ const checkOidcConfig = async (config: Config) => {
|
|||
const privateKey = await crypto.subtle
|
||||
.importKey(
|
||||
"pkcs8",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
||||
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["sign"],
|
||||
|
|
@ -117,7 +119,7 @@ const checkOidcConfig = async (config: Config) => {
|
|||
const publicKey = await crypto.subtle
|
||||
.importKey(
|
||||
"spki",
|
||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
||||
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
||||
"Ed25519",
|
||||
false,
|
||||
["verify"],
|
||||
|
|
@ -125,7 +127,7 @@ const checkOidcConfig = async (config: Config) => {
|
|||
.catch((e) => e as Error);
|
||||
|
||||
if (privateKey instanceof Error || publicKey instanceof Error) {
|
||||
logger.fatal`The JWT key could not be imported! You may generate a new one by removing the old one from the config and restarting the server (this will invalidate all current JWTs).`;
|
||||
logger.fatal`The OpenID keys could not be imported! You may generate a new one by removing the old ones from config and restarting the server (this will invalidate all current JWTs).`;
|
||||
|
||||
// Hang until Ctrl+C is pressed
|
||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||
|
|
|
|||
Loading…
Reference in a new issue