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 5f43cdad..1c6ec156 100755 Binary files a/bun.lockb and b/bun.lockb differ 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);