From d51bae52c60472db27d12694d848a45a2b1d0320 Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Thu, 29 Aug 2024 20:32:04 +0200 Subject: [PATCH] refactor(plugin): :recycle: Move parts of OpenID logic to plugin --- api/api/auth/login/index.ts | 2 +- api/oauth/authorize/index.ts | 367 ------------------------ api/oauth/sso/:issuer/callback/index.ts | 2 +- api/well-known/jwks/index.ts | 2 +- app.ts | 16 ++ build.ts | 8 + bun.lockb | Bin 282612 -> 282604 bytes config/config.schema.json | 19 +- package.json | 2 +- packages/config-manager/config.type.ts | 7 +- packages/plugin-kit/index.ts | 5 +- packages/plugin-kit/package.json | 2 +- packages/plugin-kit/plugin.ts | 53 +++- plugins/openid/index.ts | 77 +++++ plugins/openid/routes/authorize.ts | 309 ++++++++++++++++++++ types/api.ts | 2 + utils/init.ts | 16 +- 17 files changed, 494 insertions(+), 395 deletions(-) delete mode 100644 api/oauth/authorize/index.ts create mode 100644 plugins/openid/index.ts create mode 100644 plugins/openid/routes/authorize.ts diff --git a/api/api/auth/login/index.ts b/api/api/auth/login/index.ts index bafecd6e..816646e8 100644 --- a/api/api/auth/login/index.ts +++ b/api/api/auth/login/index.ts @@ -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"], diff --git a/api/oauth/authorize/index.ts b/api/oauth/authorize/index.ts deleted file mode 100644 index dad3d718..00000000 --- a/api/oauth/authorize/index.ts +++ /dev/null @@ -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()); - }, - ), -); diff --git a/api/oauth/sso/:issuer/callback/index.ts b/api/oauth/sso/:issuer/callback/index.ts index 9a951193..d92917ad 100644 --- a/api/oauth/sso/:issuer/callback/index.ts +++ b/api/oauth/sso/:issuer/callback/index.ts @@ -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"], diff --git a/api/well-known/jwks/index.ts b/api/well-known/jwks/index.ts index 0ad56e12..fdb55906 100644 --- a/api/well-known/jwks/index.ts +++ b/api/well-known/jwks/index.ts @@ -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"], diff --git a/app.ts b/app.ts index f2b5ffce..0c4f713c 100644 --- a/app.ts +++ b/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(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: { diff --git a/build.ts b/build.ts index 3b3ebe94..d911bf12 100644 --- a/build.ts +++ b/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", diff --git a/bun.lockb b/bun.lockb index 5f43cdad9b0a16b44b61a1c5a32947f355e765d5..1c6ec1568de24fb47658ba0f19211459b59867d4 100755 GIT binary patch delta 27093 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 P$_^J;JXy1@t~>q%N0-xc diff --git a/config/config.schema.json b/config/config.schema.json index b6d18a95..613f1076 100644 --- a/config/config.schema.json +++ b/config/config.schema.json @@ -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": { diff --git a/package.json b/package.json index 5b729b79..c786cf9a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/config-manager/config.type.ts b/packages/config-manager/config.type.ts index f83636fa..5b41543b 100644 --- a/packages/config-manager/config.type.ts +++ b/packages/config-manager/config.type.ts @@ -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"), diff --git a/packages/plugin-kit/index.ts b/packages/plugin-kit/index.ts index 06a6774e..b9ef9b08 100644 --- a/packages/plugin-kit/index.ts +++ b/packages/plugin-kit/index.ts @@ -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 }; diff --git a/packages/plugin-kit/package.json b/packages/plugin-kit/package.json index 51000e0a..7821777f 100644 --- a/packages/plugin-kit/package.json +++ b/packages/plugin-kit/package.json @@ -1,5 +1,5 @@ { - "name": "@versia-org/kit", + "name": "@versia/kit", "module": "index.ts", "type": "module", "version": "0.0.0", diff --git a/packages/plugin-kit/plugin.ts b/packages/plugin-kit/plugin.ts index 96987c19..4aa1604a 100644 --- a/packages/plugin-kit/plugin.ts +++ b/packages/plugin-kit/plugin.ts @@ -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 = HonoEnv & { + Variables: { + pluginConfig: z.infer; + }; +}; + export class Plugin { private handlers: Partial = {}; + private routes: { + path: string; + fn: (app: OpenAPIHono>) => void; + }[] = []; constructor( private manifest: Manifest, @@ -13,20 +26,49 @@ export class Plugin { this.validateManifest(manifest); } + get middleware() { + // Middleware that adds the plugin's configuration to the request object + return createMiddleware>( + async (context, next) => { + context.set("pluginConfig", this.configManager.getConfig()); + await next(); + }, + ); + } + public getManifest() { return this.manifest; } + public registerRoute( + path: string, + fn: (app: OpenAPIHono>) => 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) { + protected _loadConfig(config: z.input) { // biome-ignore lint/complexity/useLiteralKeys: Private method this.configManager["_load"](config); } + protected _addToApp(app: OpenAPIHono) { + for (const route of this.routes) { + app.use(route.path, this.middleware); + route.fn( + app as unknown as OpenAPIHono>, + ); + } + } + public registerHandler( hook: HookName, handler: ServerHooks[HookName], @@ -56,6 +98,7 @@ export class Plugin { * 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 { private store: z.infer | null; @@ -69,10 +112,10 @@ export class PluginConfigManager { * 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) { + protected async _load(config: z.infer) { // 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 { * Returns the internal configuration object. */ public getConfig() { + if (!this.store) { + throw new Error("Configuration has not been loaded yet."); + } + return this.store; } } diff --git a/plugins/openid/index.ts b/plugins/openid/index.ts new file mode 100644 index 00000000..9d827979 --- /dev/null +++ b/plugins/openid/index.ts @@ -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; diff --git a/plugins/openid/routes/authorize.ts b/plugins/openid/routes/authorize.ts new file mode 100644 index 00000000..a934ad66 --- /dev/null +++ b/plugins/openid/routes/authorize.ts @@ -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()); + }, + ), + ); diff --git a/types/api.ts b/types/api.ts index 1062a7c3..c4b94a05 100644 --- a/types/api.ts +++ b/types/api.ts @@ -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; diff --git a/utils/init.ts b/utils/init.ts index f3c8da3e..48cd78da 100644 --- a/utils/init.ts +++ b/utils/init.ts @@ -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);