From b5e9e35427cca682861d916bc5a4f668383965df Mon Sep 17 00:00:00 2001 From: Jesse Wierzbinski Date: Mon, 7 Jul 2025 05:52:11 +0200 Subject: [PATCH] refactor: :fire: Remove plugin functionality, move OpenID plugin to core --- .github/config.workflow.toml | 19 +- .vscode/settings.json | 1 - config/config.example.toml | 20 +- package.json | 1 + packages/api/app.ts | 25 -- packages/api/build.ts | 11 - packages/api/package.json | 1 - packages/api/plugin-loader.ts | 256 ------------- packages/api/plugins/openid/index.ts | 105 ------ packages/api/plugins/openid/manifest.json | 17 - .../api/plugins/openid/routes/authorize.ts | 276 -------------- packages/api/plugins/openid/routes/jwks.ts | 67 ---- .../plugins/openid/routes/oauth/callback.ts | 349 ------------------ .../api/plugins/openid/routes/oauth/revoke.ts | 91 ----- .../api/plugins/openid/routes/oauth/sso.ts | 136 ------- .../api/plugins/openid/routes/oauth/token.ts | 205 ---------- .../plugins/openid/routes/sso/:id/index.ts | 151 -------- .../api/plugins/openid/routes/sso/index.ts | 170 --------- packages/api/routes/api/auth/login/index.ts | 36 +- .../api/oauth}/authorize.test.ts | 26 +- packages/api/routes/api/oauth/authorize.ts | 277 ++++++++++++++ .../api}/oauth/revoke.test.ts | 0 packages/api/routes/api/oauth/revoke.ts | 87 +++++ packages/api/routes/api/oauth/sso.ts | 130 +++++++ .../routes/api/oauth/sso/[issuer]/callback.ts | 341 +++++++++++++++++ .../routes => routes/api}/oauth/token.test.ts | 0 packages/api/routes/api/oauth/token.ts | 190 ++++++++++ packages/api/routes/api/v1/instance/index.ts | 23 +- .../api/v1}/sso/:id/index.test.ts | 1 - packages/api/routes/api/v1/sso/:id/index.ts | 147 ++++++++ .../api/v1}/sso/index.test.ts | 0 packages/api/routes/api/v1/sso/index.ts | 163 ++++++++ packages/api/routes/api/v2/instance/index.ts | 22 +- .../routes => routes/well-known}/jwks.test.ts | 0 packages/api/routes/well-known/jwks.ts | 62 ++++ packages/config/index.ts | 30 +- packages/kit/example.ts | 16 - packages/kit/hooks.ts | 9 - packages/kit/index.ts | 3 - packages/kit/json-schema.ts | 6 - packages/kit/manifest.schema.json | 84 ----- packages/kit/plugin.ts | 90 ----- packages/kit/schema.ts | 101 ----- packages/logging/index.ts | 5 - utils/bull-board.ts | 56 +++ 45 files changed, 1502 insertions(+), 2304 deletions(-) delete mode 100644 packages/api/plugin-loader.ts delete mode 100644 packages/api/plugins/openid/index.ts delete mode 100644 packages/api/plugins/openid/manifest.json delete mode 100644 packages/api/plugins/openid/routes/authorize.ts delete mode 100644 packages/api/plugins/openid/routes/jwks.ts delete mode 100644 packages/api/plugins/openid/routes/oauth/callback.ts delete mode 100644 packages/api/plugins/openid/routes/oauth/revoke.ts delete mode 100644 packages/api/plugins/openid/routes/oauth/sso.ts delete mode 100644 packages/api/plugins/openid/routes/oauth/token.ts delete mode 100644 packages/api/plugins/openid/routes/sso/:id/index.ts delete mode 100644 packages/api/plugins/openid/routes/sso/index.ts rename packages/api/{plugins/openid/routes => routes/api/oauth}/authorize.test.ts (96%) create mode 100644 packages/api/routes/api/oauth/authorize.ts rename packages/api/{plugins/openid/routes => routes/api}/oauth/revoke.test.ts (100%) create mode 100644 packages/api/routes/api/oauth/revoke.ts create mode 100644 packages/api/routes/api/oauth/sso.ts create mode 100644 packages/api/routes/api/oauth/sso/[issuer]/callback.ts rename packages/api/{plugins/openid/routes => routes/api}/oauth/token.test.ts (100%) create mode 100644 packages/api/routes/api/oauth/token.ts rename packages/api/{plugins/openid/routes => routes/api/v1}/sso/:id/index.test.ts (98%) create mode 100644 packages/api/routes/api/v1/sso/:id/index.ts rename packages/api/{plugins/openid/routes => routes/api/v1}/sso/index.test.ts (100%) create mode 100644 packages/api/routes/api/v1/sso/index.ts rename packages/api/{plugins/openid/routes => routes/well-known}/jwks.test.ts (100%) create mode 100644 packages/api/routes/well-known/jwks.ts delete mode 100644 packages/kit/example.ts delete mode 100644 packages/kit/hooks.ts delete mode 100644 packages/kit/json-schema.ts delete mode 100644 packages/kit/manifest.schema.json delete mode 100644 packages/kit/plugin.ts delete mode 100644 packages/kit/schema.ts diff --git a/.github/config.workflow.toml b/.github/config.workflow.toml index 2a933b98..22a15529 100644 --- a/.github/config.workflow.toml +++ b/.github/config.workflow.toml @@ -452,24 +452,15 @@ log_level = "info" # For console output # environment = "production" # log_level = "info" -[plugins] -# Whether to automatically load all plugins in the plugins directory -autoload = true - -# Override for autoload -[plugins.overrides] -enabled = [] -disabled = [] - -[plugins.config."@versia/openid"] +[authentication] # If enabled, Versia will require users to log in with an OpenID provider -forced = false +forced_openid = false # Allow registration with OpenID providers # If signups.registration is false, it will only be possible to register with OpenID -allow_registration = true +openid_registration = true -[plugins.config."@versia/openid".keys] +[authentication.keys] # Run Versia Server with those values missing to generate a new key public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8=" private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq" @@ -481,7 +472,7 @@ private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq" # The asterisk is important, as it allows for any query parameters to be passed # Authentik for example uses regex so it can be set to (regex): # /oauth/sso//callback.* -# [[plugins.config."@versia/openid".providers]] +# [[authentication.openid_providers]] # name = "CPlusPatch ID" # id = "cpluspatch-id" # This MUST match the provider's issuer URI, including the trailing slash (or lack thereof) diff --git a/.vscode/settings.json b/.vscode/settings.json index 71bb21b1..4ddcbab6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,7 +6,6 @@ "cli", "federation", "config", - "plugin", "worker", "media", "packages/client", diff --git a/config/config.example.toml b/config/config.example.toml index ac825c0b..f19574a6 100644 --- a/config/config.example.toml +++ b/config/config.example.toml @@ -458,25 +458,15 @@ log_level = "info" # For console output # environment = "production" # log_level = "info" - -[plugins] -# Whether to automatically load all plugins in the plugins directory -autoload = true - -# Override for autoload -[plugins.overrides] -enabled = [] -disabled = [] - -[plugins.config."@versia/openid"] +[authentication] # If enabled, Versia will require users to log in with an OpenID provider -forced = false +forced_openid = false # Allow registration with OpenID providers # If signups.registration is false, it will only be possible to register with OpenID -allow_registration = true +openid_registration = true -# [plugins.config."@versia/openid".keys] +# [authentication.keys] # Run Versia Server with those values missing to generate a new key # public = "" # private = "" @@ -488,7 +478,7 @@ allow_registration = true # The asterisk is important, as it allows for any query parameters to be passed # Authentik for example uses regex so it can be set to (regex): # /oauth/sso//callback.* -# [[plugins.config."@versia/openid".providers]] +# [[authentication.openid_providers]] # name = "CPlusPatch ID" # id = "cpluspatch-id" # This MUST match the provider's issuer URI, including the trailing slash (or lack thereof) diff --git a/package.json b/package.json index 3177a42b..306ffc4d 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "build": "bun run --filter \"*\" build && bun run build.ts", "detect-circular": "bunx madge --circular --extensions ts ./", "update-nix-hashes": "bash scripts/update-nix.sh", + "schema:generate": "bun run packages/config/to-json-schema.ts > config/config.schema.json", "run-api": "bun run build api && cd dist && ln -s ../config config && bun run api.js", "run-worker": "bun run build worker && cd dist && ln -s ../config config && bun run worker.js", "dev": "bun run --hot api.ts", diff --git a/packages/api/app.ts b/packages/api/app.ts index 7d422a43..872d8a9c 100644 --- a/packages/api/app.ts +++ b/packages/api/app.ts @@ -1,9 +1,6 @@ -import { join } from "node:path"; import { Scalar } from "@scalar/hono-api-reference"; import { config } from "@versia-server/config"; import { ApiError } from "@versia-server/kit"; -import { serverLogger } from "@versia-server/logging"; -import chalk from "chalk"; import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; @@ -20,7 +17,6 @@ import { boundaryCheck } from "./middlewares/boundary-check.ts"; import { ipBans } from "./middlewares/ip-bans.ts"; import { logger } from "./middlewares/logger.ts"; import { rateLimit } from "./middlewares/rate-limit.ts"; -import { PluginLoader } from "./plugin-loader.ts"; import { routes } from "./routes.ts"; export const appFactory = async (): Promise> => { @@ -104,27 +100,6 @@ export const appFactory = async (): Promise> => { route.default(app); } - serverLogger.info`Loading plugins`; - - const time1 = performance.now(); - - const loader = new PluginLoader(); - - const plugins = await loader.loadPlugins( - join(import.meta.dir, "plugins"), - config.plugins?.autoload ?? true, - config.plugins?.overrides.enabled, - config.plugins?.overrides.disabled, - ); - - await PluginLoader.addToApp(plugins, app); - - const time2 = performance.now(); - - serverLogger.info`Plugins loaded in ${`${chalk.gray( - (time2 - time1).toFixed(2), - )}ms`}`; - const openApiSpecs = await generateSpecs(app, { documentation: { info: { diff --git a/packages/api/build.ts b/packages/api/build.ts index 57ef52cd..515d2c41 100644 --- a/packages/api/build.ts +++ b/packages/api/build.ts @@ -1,4 +1,3 @@ -import { readdir } from "node:fs/promises"; import { $, build } from "bun"; import manifest from "./package.json" with { type: "json" }; import { routes } from "./routes.ts"; @@ -7,18 +6,11 @@ console.log("Building..."); await $`rm -rf dist && mkdir dist`; -// Get all directories under the plugins/ directory -const pluginDirs = await readdir("plugins", { withFileTypes: true }); - await build({ entrypoints: [ ...Object.values(manifest.exports).map((entry) => entry.import), // 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", @@ -37,9 +29,6 @@ await build({ console.log("Copying files..."); -// Copy plugin manifests -await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`; - await $`mkdir -p dist/node_modules`; // Copy bull-board to dist diff --git a/packages/api/package.json b/packages/api/package.json index 667217f0..2e50ca7b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -36,7 +36,6 @@ "scripts": { "dev": "bun run --hot index.ts", "build": "bun run build.ts", - "schema:generate": "bun run classes/config/to-json-schema.ts > config/config.schema.json && bun run packages/kit/json-schema.ts > packages/kit/manifest.schema.json", "docs:dev": "vitepress dev docs", "docs:build": "vitepress build docs", "docs:preview": "vitepress preview docs" diff --git a/packages/api/plugin-loader.ts b/packages/api/plugin-loader.ts deleted file mode 100644 index 213fe771..00000000 --- a/packages/api/plugin-loader.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { readdir } from "node:fs/promises"; -import { config } from "@versia-server/config"; -import { type Manifest, manifestSchema, Plugin } from "@versia-server/kit"; -import { pluginLogger, serverLogger } from "@versia-server/logging"; -import { file, sleep } from "bun"; -import chalk from "chalk"; -import { parseJSON5, parseJSONC } from "confbox"; -import type { Hono } from "hono"; -import type { ZodTypeAny } from "zod/v4"; -import { fromZodError, type ValidationError } from "zod-validation-error"; -import type { HonoEnv } from "~/types/api"; - -/** - * Class to manage plugins. - */ -export class PluginLoader { - /** - * Get all directories in a given directory. - * @param {string} dir - The directory to search. - * @returns {Promise} - An array of directory names. - */ - private static async getDirectories(dir: string): Promise { - const files = await readdir(dir, { withFileTypes: true }); - return files.filter((f) => f.isDirectory()).map((f) => f.name); - } - - /** - * Find the manifest file in a given directory. - * @param {string} dir - The directory to search. - * @returns {Promise} - The manifest file name if found, otherwise undefined. - */ - private static async findManifestFile( - dir: string, - ): Promise { - const files = await readdir(dir); - return files.find((f) => f.match(/^manifest\.(json|json5|jsonc)$/)); - } - - /** - * Check if a directory has an entrypoint file (index.{ts,js}). - * @param {string} dir - The directory to search. - * @returns {Promise} - True if the entrypoint file is found, otherwise false. - */ - private static async hasEntrypoint(dir: string): Promise { - const files = await readdir(dir); - return files.includes("index.ts") || files.includes("index.js"); - } - - /** - * Parse the manifest file based on its type. - * @param {string} manifestPath - The path to the manifest file. - * @param {string} manifestFile - The manifest file name. - * @returns {Promise} - The parsed manifest content. - * @throws Will throw an error if the manifest file cannot be parsed. - */ - private static async parseManifestFile( - manifestPath: string, - manifestFile: string, - ): Promise { - const manifestText = await file(manifestPath).text(); - - try { - if (manifestFile.endsWith(".json")) { - return JSON.parse(manifestText); - } - if (manifestFile.endsWith(".json5")) { - return parseJSON5(manifestText); - } - if (manifestFile.endsWith(".jsonc")) { - return parseJSONC(manifestText); - } - - throw new Error(`Unsupported manifest file type: ${manifestFile}`); - } catch (e) { - pluginLogger.fatal`Could not parse plugin manifest ${chalk.blue(manifestPath)} as ${manifestFile.split(".").pop()?.toUpperCase()}.`; - throw e; - } - } - - /** - * Find all direct subdirectories with a valid manifest file and entrypoint (index.{ts,js}). - * @param {string} dir - The directory to search. - * @returns {Promise} - An array of plugin directories. - */ - public static async findPlugins(dir: string): Promise { - const directories = await PluginLoader.getDirectories(dir); - const plugins: string[] = []; - - for (const directory of directories) { - const manifestFile = await PluginLoader.findManifestFile( - `${dir}/${directory}`, - ); - if ( - manifestFile && - (await PluginLoader.hasEntrypoint(`${dir}/${directory}`)) - ) { - plugins.push(directory); - } - } - - return plugins; - } - - /** - * Parse the manifest file of a plugin. - * @param {string} dir - The directory containing the plugin. - * @param {string} plugin - The plugin directory name. - * @returns {Promise} - The parsed manifest object. - * @throws Will throw an error if the manifest file is missing or invalid. - */ - public static async parseManifest( - dir: string, - plugin: string, - ): Promise { - const manifestFile = await PluginLoader.findManifestFile( - `${dir}/${plugin}`, - ); - - if (!manifestFile) { - throw new Error(`Plugin ${plugin} is missing a manifest file`); - } - - const manifestPath = `${dir}/${plugin}/${manifestFile}`; - const manifest = await PluginLoader.parseManifestFile( - manifestPath, - manifestFile, - ); - - const result = await manifestSchema.safeParseAsync(manifest); - - if (!result.success) { - pluginLogger.fatal`Plugin manifest ${chalk.blue(manifestPath)} is invalid.`; - throw fromZodError(result.error); - } - - return result.data; - } - - /** - * Loads an entrypoint's default export and check if it's a Plugin. - * @param {string} dir - The directory containing the entrypoint. - * @param {string} entrypoint - The entrypoint file name. - * @returns {Promise>} - The loaded Plugin instance. - * @throws Will throw an error if the entrypoint's default export is not a Plugin. - */ - public static async loadPlugin( - dir: string, - entrypoint: string, - ): Promise> { - const plugin = (await import(`${dir}/${entrypoint}`)).default; - - if (plugin instanceof Plugin) { - return plugin; - } - - pluginLogger.fatal`Default export of entrypoint ${chalk.blue(entrypoint)} at ${chalk.blue(dir)} is not a Plugin.`; - - throw new Error("Entrypoint is not a Plugin"); - } - - /** - * Load all plugins in a given directory. - * @param {string} dir - The directory to search. - * @returns An array of objects containing the manifest and plugin instance. - */ - public async loadPlugins( - dir: string, - autoload: boolean, - enabled?: string[], - disabled?: string[], - ): Promise<{ manifest: Manifest; plugin: Plugin }[]> { - const plugins = await PluginLoader.findPlugins(dir); - - const enabledOn = (enabled?.length ?? 0) > 0; - const disabledOn = (disabled?.length ?? 0) > 0; - - if (enabledOn && disabledOn) { - pluginLogger.fatal`Both enabled and disabled lists are specified. Only one of them can be used.`; - throw new Error("Invalid configuration"); - } - - return Promise.all( - plugins.map(async (plugin) => { - const manifest = await PluginLoader.parseManifest(dir, plugin); - - // If autoload is disabled, only load plugins explicitly enabled - if ( - !(autoload || enabledOn || enabled?.includes(manifest.name)) - ) { - return null; - } - - // If enabled is specified, only load plugins in the enabled list - // If disabled is specified, only load plugins not in the disabled list - if (enabledOn && !enabled?.includes(manifest.name)) { - return null; - } - - if (disabled?.includes(manifest.name)) { - return null; - } - - const pluginInstance = await PluginLoader.loadPlugin( - dir, - `${plugin}/index`, - ); - - return { manifest, plugin: pluginInstance }; - }), - ).then((data) => data.filter((d) => d !== null)); - } - - public static async addToApp( - plugins: { - manifest: Manifest; - plugin: Plugin; - }[], - app: Hono, - ): Promise { - for (const data of plugins) { - serverLogger.info`Loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} ${chalk.gray(`[${plugins.indexOf(data) + 1}/${plugins.length}]`)}`; - - const time1 = performance.now(); - - try { - // biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method - await data.plugin["_loadConfig"]( - config.plugins?.config?.[data.manifest.name], - ); - } catch (e) { - serverLogger.fatal`Error encountered while loading plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright(data.manifest.version)} configuration.`; - serverLogger.fatal`This is due to invalid, missing or incomplete configuration.`; - serverLogger.fatal`Put your configuration at ${chalk.blueBright( - "plugins.config.", - )}`; - serverLogger.fatal`Here is the error message, please fix the configuration file accordingly:`; - serverLogger.fatal`${(e as ValidationError).message}`; - - await sleep(Number.POSITIVE_INFINITY); - } - - const time2 = performance.now(); - - // biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method - await data.plugin["_addToApp"](app); - - const time3 = performance.now(); - - serverLogger.info`Plugin ${chalk.blueBright(data.manifest.name)} ${chalk.blueBright( - data.manifest.version, - )} loaded in ${chalk.gray( - `${(time2 - time1).toFixed(2)}ms`, - )} and added to app in ${chalk.gray(`${(time3 - time2).toFixed(2)}ms`)}`; - } - } -} diff --git a/packages/api/plugins/openid/index.ts b/packages/api/plugins/openid/index.ts deleted file mode 100644 index b7daed32..00000000 --- a/packages/api/plugins/openid/index.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { RolePermission } from "@versia/client/schemas"; -import { keyPair, sensitiveString, url } from "@versia-server/config"; -import { ApiError, Hooks, Plugin } from "@versia-server/kit"; -import { User } from "@versia-server/kit/db"; -import { getCookie } from "hono/cookie"; -import { jwtVerify } from "jose"; -import { JOSEError, JWTExpired } from "jose/errors"; -import { z } from "zod/v4"; -import authorizeRoute from "./routes/authorize.ts"; -import jwksRoute from "./routes/jwks.ts"; -import ssoLoginCallbackRoute from "./routes/oauth/callback.ts"; -import tokenRevokeRoute from "./routes/oauth/revoke.ts"; -import ssoLoginRoute from "./routes/oauth/sso.ts"; -import tokenRoute from "./routes/oauth/token.ts"; -import ssoIdRoute from "./routes/sso/:id/index.ts"; -import ssoRoute from "./routes/sso/index.ts"; - -const configSchema = 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: sensitiveString, - icon: url.optional(), - }), - ) - .default([]), - keys: keyPair, -}); - -const plugin = new Plugin(configSchema); - -// Test hook for screenshots -plugin.registerHandler(Hooks.Response, (req) => { - console.info("Request received:", req); - return req; -}); - -authorizeRoute(plugin); -ssoRoute(plugin); -ssoIdRoute(plugin); -tokenRoute(plugin); -tokenRevokeRoute(plugin); -jwksRoute(plugin); -ssoLoginRoute(plugin); -ssoLoginCallbackRoute(plugin); - -plugin.registerRoute("/admin/queues/api/*", (app) => { - // Check for JWT when accessing the admin panel - app.use("/admin/queues/api/*", async (context, next) => { - const jwtCookie = getCookie(context, "jwt"); - - if (!jwtCookie) { - throw new ApiError(401, "Missing JWT cookie"); - } - - const { keys } = context.get("pluginConfig"); - - const result = await jwtVerify(jwtCookie, keys.public, { - algorithms: ["EdDSA"], - issuer: new URL(context.get("config").http.base_url).origin, - }).catch((error) => { - if (error instanceof JOSEError) { - return error; - } - - throw error; - }); - - if (result instanceof JOSEError) { - if (result instanceof JWTExpired) { - throw new ApiError(401, "JWT has expired"); - } - - throw new ApiError(401, "Invalid JWT"); - } - - const { - payload: { sub }, - } = result; - - if (!sub) { - throw new ApiError(401, "Invalid JWT (no sub)"); - } - - const user = await User.fromId(sub); - - if (!user?.hasPermission(RolePermission.ManageInstanceFederation)) { - throw new ApiError( - 403, - `Missing '${RolePermission.ManageInstanceFederation}' permission`, - ); - } - - await next(); - }); -}); - -export type PluginType = typeof plugin; -export default plugin; diff --git a/packages/api/plugins/openid/manifest.json b/packages/api/plugins/openid/manifest.json deleted file mode 100644 index f6219620..00000000 --- a/packages/api/plugins/openid/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/versia-pub/server/refs/heads/main/packages/kit/manifest.schema.json", - "name": "@versia/openid", - "description": "OpenID authentication.", - "version": "0.1.0", - "authors": [ - { - "name": "Jesse Wierzbinski", - "email": "contact@cpluspatch.com", - "url": "https://cpluspatch.com" - } - ], - "repository": { - "type": "git", - "url": "https://github.com/versia-pub/server.git" - } -} diff --git a/packages/api/plugins/openid/routes/authorize.ts b/packages/api/plugins/openid/routes/authorize.ts deleted file mode 100644 index bb18310b..00000000 --- a/packages/api/plugins/openid/routes/authorize.ts +++ /dev/null @@ -1,276 +0,0 @@ -import { RolePermission } from "@versia/client/schemas"; -import { auth, handleZodError, jsonOrForm } from "@versia-server/kit/api"; -import { Application, Token, User } from "@versia-server/kit/db"; -import { randomUUIDv7 } from "bun"; -import { describeRoute, validator } from "hono-openapi"; -import { type JWTPayload, jwtVerify, SignJWT } from "jose"; -import { JOSEError } from "jose/errors"; -import { z } from "zod/v4"; -import { randomString } from "@/math"; -import { errorRedirect, errors } from "../errors.ts"; -import type { PluginType } from "../index.ts"; - -export default (plugin: PluginType): void => - plugin.registerRoute("/oauth/authorize", (app) => - app.post( - "/oauth/authorize", - describeRoute({ - summary: "Main OpenID authorization endpoint", - tags: ["OpenID"], - responses: { - 302: { - description: "Redirect to the application", - }, - }, - }), - plugin.middleware, - auth({ - auth: false, - }), - jsonOrForm(), - validator( - "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), - }), - handleZodError, - ), - validator( - "json", - z - .object({ - scope: z.string().optional(), - redirect_uri: z - .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", - ), */ - handleZodError, - ), - validator( - "cookie", - z.object({ - jwt: z.string(), - }), - handleZodError, - ), - 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( - context.req.valid("json"), - ); - - 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) { - return errorRedirect( - context, - errors.InvalidJWT, - errorSearchParams, - ); - } - - const { - payload: { aud, sub, exp }, - } = result; - - if (!(aud && sub && exp)) { - return errorRedirect( - context, - errors.MissingJWTFields, - errorSearchParams, - ); - } - - if (!z.uuid().safeParse(sub).success) { - return errorRedirect( - context, - errors.InvalidSub, - errorSearchParams, - ); - } - - const user = await User.fromId(sub); - - if (!user) { - return errorRedirect( - context, - errors.UserNotFound, - errorSearchParams, - ); - } - - if (!user.hasPermission(RolePermission.OAuth)) { - return errorRedirect( - context, - errors.MissingOauthPermission, - errorSearchParams, - ); - } - - const application = await Application.fromClientId(client_id); - - if (!application) { - return errorRedirect( - context, - errors.MissingApplication, - errorSearchParams, - ); - } - - if (application.data.redirectUri !== redirect_uri) { - return errorRedirect( - context, - errors.InvalidRedirectUri, - errorSearchParams, - ); - } - - // Check that scopes are a subset of the application's scopes - if ( - scope && - !scope - .split(" ") - .every((s) => application.data.scopes.includes(s)) - ) { - return errorRedirect( - context, - errors.InvalidScope, - errorSearchParams, - ); - } - - 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().href, - 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 Token.insert({ - id: randomUUIDv7(), - accessToken: randomString(64, "base64url"), - code, - scope: scope ?? application.data.scopes, - tokenType: "Bearer", - applicationId: application.id, - redirectUri: redirect_uri ?? application.data.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.data.redirectUri); - - redirectUri.searchParams.append("code", code); - state && redirectUri.searchParams.append("state", state); - - return context.redirect(redirectUri.toString()); - }, - ), - ); diff --git a/packages/api/plugins/openid/routes/jwks.ts b/packages/api/plugins/openid/routes/jwks.ts deleted file mode 100644 index 31896563..00000000 --- a/packages/api/plugins/openid/routes/jwks.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { auth } from "@versia-server/kit/api"; -import { describeRoute, resolver } from "hono-openapi"; -import { exportJWK } from "jose"; -import { z } from "zod/v4"; -import type { PluginType } from "../index.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/.well-known/jwks", (app) => - app.get( - "/.well-known/jwks", - describeRoute({ - summary: "JWK Set", - tags: ["OpenID"], - responses: { - 200: { - description: "JWK Set", - content: { - "application/json": { - schema: resolver( - z.object({ - keys: z.array( - z.object({ - kty: z.string().optional(), - use: z.string(), - alg: z.string(), - kid: z.string(), - crv: z.string().optional(), - x: z.string().optional(), - y: z.string().optional(), - }), - ), - }), - ), - }, - }, - }, - }, - }), - auth({ - auth: false, - }), - plugin.middleware, - async (context) => { - const jwk = await exportJWK( - context.get("pluginConfig").keys?.public, - ); - - // Remove the private key 💀 - jwk.d = undefined; - - return context.json( - { - keys: [ - { - ...jwk, - use: "sig", - alg: "EdDSA", - kid: "1", - }, - ], - }, - 200, - ); - }, - ), - ); -}; diff --git a/packages/api/plugins/openid/routes/oauth/callback.ts b/packages/api/plugins/openid/routes/oauth/callback.ts deleted file mode 100644 index 60db45e7..00000000 --- a/packages/api/plugins/openid/routes/oauth/callback.ts +++ /dev/null @@ -1,349 +0,0 @@ -import { - Account as AccountSchema, - RolePermission, - zBoolean, -} from "@versia/client/schemas"; -import { ApiError } from "@versia-server/kit"; -import { handleZodError } from "@versia-server/kit/api"; -import { db, Media, Token, User } from "@versia-server/kit/db"; -import { searchManager } from "@versia-server/kit/search"; -import { OpenIdAccounts, Users } from "@versia-server/kit/tables"; -import { randomUUIDv7 } from "bun"; -import { and, eq, isNull, type SQL } from "drizzle-orm"; -import { setCookie } from "hono/cookie"; -import { describeRoute, validator } from "hono-openapi"; -import { SignJWT } from "jose"; -import { z } from "zod/v4"; -import { randomString } from "@/math.ts"; -import type { PluginType } from "../../index.ts"; -import { automaticOidcFlow } from "../../utils.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/oauth/sso/{issuer}/callback", (app) => { - app.get( - "/oauth/sso/:issuer/callback", - describeRoute({ - summary: "SSO callback", - tags: ["OpenID"], - description: - "After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code", - responses: { - 302: { - description: - "Redirect to frontend's consent route, or redirect to login page with error", - }, - }, - }), - plugin.middleware, - validator( - "param", - z.object({ - issuer: z.string(), - }), - handleZodError, - ), - validator( - "query", - z.object({ - client_id: z.string().optional(), - flow: z.string(), - link: zBoolean.optional(), - user_id: z.uuid().optional(), - }), - handleZodError, - ), - async (context) => { - const currentUrl = new URL(context.req.url); - const redirectUrl = new URL(context.req.url); - - // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https - // Looking at you, Traefik - if ( - new URL(context.get("config").http.base_url).protocol === - "https:" && - currentUrl.protocol === "http:" - ) { - currentUrl.protocol = "https:"; - redirectUrl.protocol = "https:"; - } - - // Remove state query parameter from URL - currentUrl.searchParams.delete("state"); - redirectUrl.searchParams.delete("state"); - // Remove issuer query parameter from URL (can cause redirect URI mismatches) - redirectUrl.searchParams.delete("iss"); - redirectUrl.searchParams.delete("code"); - const { issuer: issuerParam } = context.req.valid("param"); - const { - flow: flowId, - user_id, - link, - } = context.req.valid("query"); - - const issuer = context - .get("pluginConfig") - .providers.find((provider) => provider.id === issuerParam); - - if (!issuer) { - throw new ApiError(404, "Issuer not found"); - } - - const userInfo = await automaticOidcFlow( - issuer, - flowId, - currentUrl, - redirectUrl, - (error, message, flow) => { - const errorSearchParams = new URLSearchParams( - Object.entries({ - redirect_uri: flow?.application?.redirectUri, - client_id: flow?.application?.clientId, - response_type: "code", - scope: flow?.application?.scopes, - }).filter(([_, value]) => value !== undefined) as [ - string, - string, - ][], - ); - - errorSearchParams.append("error", error); - errorSearchParams.append("error_description", message); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - }, - ); - - if (userInfo instanceof Response) { - return userInfo; - } - - const { sub, email, preferred_username, picture } = - userInfo.userInfo; - const flow = userInfo.flow; - - const errorSearchParams = new URLSearchParams( - Object.entries({ - redirect_uri: flow.application?.redirectUri, - client_id: flow.application?.clientId, - response_type: "code", - scope: flow.application?.scopes, - }).filter(([_, value]) => value !== undefined) as [ - string, - string, - ][], - ); - - // If linking account - if (link && user_id) { - // Check if userId is equal to application.clientId - if (!flow.application?.clientId.startsWith(user_id)) { - return context.redirect( - `${context.get("config").http.base_url}${ - context.get("config").frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linking_error: - "Account linking error", - oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`, - })}`, - ); - } - - // Check if account is already linked - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account): SQL | undefined => - and( - eq(account.serverId, sub), - eq(account.issuerId, issuer.id), - ), - }); - - if (account) { - return context.redirect( - `${context.get("config").http.base_url}${ - context.get("config").frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linking_error: - "Account already linked", - oidc_account_linking_error_message: - "This account has already been linked to this OpenID Connect provider.", - })}`, - ); - } - - // Link the account - await db.insert(OpenIdAccounts).values({ - id: randomUUIDv7(), - serverId: sub, - issuerId: issuer.id, - userId: user_id, - }); - - return context.redirect( - `${context.get("config").http.base_url}${ - context.get("config").frontend.routes.home - }?${new URLSearchParams({ - oidc_account_linked: "true", - })}`, - ); - } - - let userId = ( - await db.query.OpenIdAccounts.findFirst({ - where: (account): SQL | undefined => - and( - eq(account.serverId, sub), - eq(account.issuerId, issuer.id), - ), - }) - )?.userId; - - if (!userId) { - // Register new user - if (context.get("pluginConfig").allow_registration) { - let username = - preferred_username ?? - email?.split("@")[0] ?? - randomString(8, "hex"); - - const usernameValidator = - AccountSchema.shape.username.refine( - async (value) => - !(await User.fromSql( - and( - eq(Users.username, value), - isNull(Users.instanceId), - ), - )), - ); - - try { - await usernameValidator.parseAsync(username); - } catch { - username = randomString(8, "hex"); - } - - const doesEmailExist = email - ? !!(await User.fromSql(eq(Users.email, email))) - : false; - - const avatar = picture - ? await Media.fromUrl(new URL(picture)) - : null; - - // Create new user - const user = await User.register(username, { - email: doesEmailExist ? undefined : email, - avatar: avatar ?? undefined, - }); - - // Add to search index - await searchManager.addUser(user); - - // Link account - await db.insert(OpenIdAccounts).values({ - id: randomUUIDv7(), - serverId: sub, - issuerId: issuer.id, - userId: user.id, - }); - - userId = user.id; - } else { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "No user found with that account", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - } - - const user = await User.fromId(userId); - - if (!user) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "No user found with that account", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - if (!user.hasPermission(RolePermission.OAuth)) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - `User does not have the '${RolePermission.OAuth}' permission`, - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - if (!flow.application) { - throw new ApiError(500, "Application not found"); - } - - const code = randomString(32, "hex"); - - await Token.insert({ - id: randomUUIDv7(), - accessToken: randomString(64, "base64url"), - code, - scope: flow.application.scopes, - tokenType: "Bearer", - userId: user.id, - applicationId: flow.application.id, - }); - - // Generate JWT - const jwt = await new SignJWT({ - sub: user.id, - iss: new URL(context.get("config").http.base_url).origin, - aud: flow.application.clientId, - exp: Math.floor(Date.now() / 1000) + 60 * 60, - iat: Math.floor(Date.now() / 1000), - nbf: Math.floor(Date.now() / 1000), - }) - .setProtectedHeader({ alg: "EdDSA" }) - .sign(context.get("pluginConfig").keys?.private); - - // Redirect back to application - setCookie(context, "jwt", jwt, { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - // 2 weeks - maxAge: 60 * 60 * 24 * 14, - }); - - return context.redirect( - new URL( - `${context.get("config").frontend.routes.consent}?${new URLSearchParams( - { - redirect_uri: flow.application.redirectUri, - code, - client_id: flow.application.clientId, - application: flow.application.name, - website: flow.application.website ?? "", - scope: flow.application.scopes, - response_type: "code", - }, - ).toString()}`, - context.get("config").http.base_url, - ).toString(), - ); - }, - ); - }); -}; diff --git a/packages/api/plugins/openid/routes/oauth/revoke.ts b/packages/api/plugins/openid/routes/oauth/revoke.ts deleted file mode 100644 index 958cd2b5..00000000 --- a/packages/api/plugins/openid/routes/oauth/revoke.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { handleZodError, jsonOrForm } from "@versia-server/kit/api"; -import { db, Token } from "@versia-server/kit/db"; -import { Tokens } from "@versia-server/kit/tables"; -import { and, eq } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod/v4"; -import type { PluginType } from "../../index.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/oauth/revoke", (app) => { - app.post( - "/oauth/revoke", - describeRoute({ - summary: "Revoke token", - tags: ["OpenID"], - responses: { - 200: { - description: "Token deleted", - content: { - "application/json": { - schema: resolver(z.object({})), - }, - }, - }, - 401: { - description: "Authorization error", - content: { - "application/json": { - schema: resolver( - z.object({ - error: z.string(), - error_description: z.string(), - }), - ), - }, - }, - }, - }, - }), - jsonOrForm(), - plugin.middleware, - validator( - "json", - z.object({ - client_id: z.string(), - client_secret: z.string(), - token: z.string().optional(), - }), - handleZodError, - ), - async (context) => { - const { client_id, client_secret, token } = - context.req.valid("json"); - - const foundToken = await Token.fromSql( - and( - eq(Tokens.accessToken, token ?? ""), - eq(Tokens.clientId, client_id), - ), - ); - - if (!(foundToken && token)) { - return context.json( - { - error: "unauthorized_client", - error_description: - "You are not authorized to revoke this token", - }, - 401, - ); - } - - // Check if the client secret is correct - if (foundToken.data.application?.secret !== client_secret) { - return context.json( - { - error: "unauthorized_client", - error_description: - "You are not authorized to revoke this token", - }, - 401, - ); - } - - await db.delete(Tokens).where(eq(Tokens.accessToken, token)); - - return context.json({}, 200); - }, - ); - }); -}; diff --git a/packages/api/plugins/openid/routes/oauth/sso.ts b/packages/api/plugins/openid/routes/oauth/sso.ts deleted file mode 100644 index 73f7ea35..00000000 --- a/packages/api/plugins/openid/routes/oauth/sso.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { handleZodError } from "@versia-server/kit/api"; -import { Application, db } from "@versia-server/kit/db"; -import { OpenIdLoginFlows } from "@versia-server/kit/tables"; -import { randomUUIDv7 } from "bun"; -import { describeRoute, validator } from "hono-openapi"; -import { - calculatePKCECodeChallenge, - discoveryRequest, - generateRandomCodeVerifier, - processDiscoveryResponse, -} from "oauth4webapi"; -import { z } from "zod/v4"; -import type { PluginType } from "../../index.ts"; -import { oauthRedirectUri } from "../../utils.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/oauth/sso", (app) => { - app.get( - "/oauth/sso", - describeRoute({ - summary: "Initiate SSO login flow", - tags: ["OpenID"], - responses: { - 302: { - description: - "Redirect to SSO login, or redirect to login page with error", - }, - }, - }), - plugin.middleware, - validator( - "query", - z.object({ - issuer: z.string(), - client_id: z.string().optional(), - redirect_uri: z.url().optional(), - scope: z.string().optional(), - response_type: z.enum(["code"]).optional(), - }), - handleZodError, - ), - async (context) => { - // This is the Versia client's client_id, not the external OAuth provider's client_id - const { issuer: issuerId, client_id } = - context.req.valid("query"); - - const errorSearchParams = new URLSearchParams( - context.req.valid("query"), - ); - - if (!client_id || client_id === "undefined") { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "client_id is required", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - const issuer = context - .get("pluginConfig") - .providers.find((provider) => provider.id === issuerId); - - if (!issuer) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "issuer is invalid", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - const issuerUrl = new URL(issuer.url); - - const authServer = await discoveryRequest(issuerUrl, { - algorithm: "oidc", - }).then((res) => processDiscoveryResponse(issuerUrl, res)); - - const codeVerifier = generateRandomCodeVerifier(); - - const application = await Application.fromClientId(client_id); - - if (!application) { - errorSearchParams.append("error", "invalid_request"); - errorSearchParams.append( - "error_description", - "client_id is invalid", - ); - - return context.redirect( - `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, - ); - } - - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - id: randomUUIDv7(), - codeVerifier, - applicationId: application.id, - issuerId, - }) - .returning() - )[0]; - - const codeChallenge = - await calculatePKCECodeChallenge(codeVerifier); - - return context.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams( - { - client_id: issuer.client_id, - redirect_uri: `${oauthRedirectUri( - context.get("config").http.base_url, - issuerId, - )}?flow=${newFlow.id}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }, - ).toString()}`, - ); - }, - ); - }); -}; diff --git a/packages/api/plugins/openid/routes/oauth/token.ts b/packages/api/plugins/openid/routes/oauth/token.ts deleted file mode 100644 index 9fe2ebf6..00000000 --- a/packages/api/plugins/openid/routes/oauth/token.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { handleZodError, jsonOrForm } from "@versia-server/kit/api"; -import { Application, Token } from "@versia-server/kit/db"; -import { Tokens } from "@versia-server/kit/tables"; -import { and, eq } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod/v4"; -import type { PluginType } from "../../index.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/oauth/token", (app) => { - app.post( - "/oauth/token", - describeRoute({ - summary: "Get token", - tags: ["OpenID"], - responses: { - 200: { - description: "Token", - content: { - "application/json": { - schema: resolver( - z.object({ - access_token: z.string(), - token_type: z.string(), - expires_in: z - .number() - .optional() - .nullable(), - id_token: z - .string() - .optional() - .nullable(), - refresh_token: z - .string() - .optional() - .nullable(), - scope: z.string().optional(), - created_at: z.number(), - }), - ), - }, - }, - }, - 401: { - description: "Authorization error", - content: { - "application/json": { - schema: resolver( - z.object({ - error: z.string(), - error_description: z.string(), - }), - ), - }, - }, - }, - }, - }), - jsonOrForm(), - plugin.middleware, - validator( - "json", - z.object({ - code: z.string().optional(), - code_verifier: z.string().optional(), - grant_type: z - .enum([ - "authorization_code", - "refresh_token", - "client_credentials", - "password", - "urn:ietf:params:oauth:grant-type:device_code", - "urn:ietf:params:oauth:grant-type:token-exchange", - "urn:ietf:params:oauth:grant-type:saml2-bearer", - "urn:openid:params:grant-type:ciba", - ]) - .default("authorization_code"), - client_id: z.string().optional(), - client_secret: z.string().optional(), - username: z.string().trim().optional(), - password: z.string().trim().optional(), - redirect_uri: z.url().optional(), - refresh_token: z.string().optional(), - scope: z.string().optional(), - assertion: z.string().optional(), - audience: z.string().optional(), - subject_token_type: z.string().optional(), - subject_token: z.string().optional(), - actor_token_type: z.string().optional(), - actor_token: z.string().optional(), - auth_req_id: z.string().optional(), - }), - handleZodError, - ), - async (context) => { - const { - grant_type, - code, - redirect_uri, - client_id, - client_secret, - } = context.req.valid("json"); - - switch (grant_type) { - case "authorization_code": { - if (!code) { - return context.json( - { - error: "invalid_request", - error_description: "Code is required", - }, - 401, - ); - } - - if (!redirect_uri) { - return context.json( - { - error: "invalid_request", - error_description: - "Redirect URI is required", - }, - 401, - ); - } - - if (!client_id) { - return context.json( - { - error: "invalid_request", - error_description: "Client ID is required", - }, - 401, - ); - } - - // Verify the client_secret - const client = - await Application.fromClientId(client_id); - - if (!client || client.data.secret !== client_secret) { - return context.json( - { - error: "invalid_client", - error_description: - "Invalid client credentials", - }, - 401, - ); - } - - const token = await Token.fromSql( - and( - eq(Tokens.code, code), - eq(Tokens.redirectUri, decodeURI(redirect_uri)), - eq(Tokens.clientId, client_id), - ), - ); - - if (!token) { - return context.json( - { - error: "invalid_grant", - error_description: "Code not found", - }, - 401, - ); - } - - // Invalidate the code - await token.update({ code: null }); - - return context.json( - { - ...token.toApi(), - expires_in: token.data.expiresAt - ? Math.floor( - (new Date( - token.data.expiresAt, - ).getTime() - - Date.now()) / - 1000, - ) - : null, - id_token: token.data.idToken, - refresh_token: null, - }, - 200, - ); - } - - default: - } - - return context.json( - { - error: "unsupported_grant_type", - error_description: "Unsupported grant type", - }, - 401, - ); - }, - ); - }); -}; diff --git a/packages/api/plugins/openid/routes/sso/:id/index.ts b/packages/api/plugins/openid/routes/sso/:id/index.ts deleted file mode 100644 index 7669882a..00000000 --- a/packages/api/plugins/openid/routes/sso/:id/index.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { RolePermission } from "@versia/client/schemas"; -import { ApiError } from "@versia-server/kit"; -import { auth, handleZodError } from "@versia-server/kit/api"; -import { db } from "@versia-server/kit/db"; -import { OpenIdAccounts } from "@versia-server/kit/tables"; -import { and, eq, type SQL } from "drizzle-orm"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { z } from "zod/v4"; -import type { PluginType } from "../../../index.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/api/v1/sso/:id", (app) => { - app.get( - "/api/v1/sso/:id", - describeRoute({ - summary: "Get linked account", - tags: ["SSO"], - responses: { - 200: { - description: "Linked account", - content: { - "application/json": { - schema: resolver( - z.object({ - id: z.string(), - name: z.string(), - icon: z.string().optional(), - }), - ), - }, - }, - }, - 404: ApiError.accountNotFound().schema, - }, - }), - auth({ - auth: true, - permissions: [RolePermission.OAuth], - }), - plugin.middleware, - validator("param", z.object({ id: z.string() }), handleZodError), - async (context) => { - const { id: issuerId } = context.req.valid("param"); - const { user } = context.get("auth"); - - const issuer = context - .get("pluginConfig") - .providers.find((provider) => provider.id === issuerId); - - if (!issuer) { - return context.json( - { - error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, - }, - 404, - ); - } - - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account): SQL | undefined => - and( - eq(account.userId, user.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - throw new ApiError( - 404, - "Account not found or is not linked to this issuer", - ); - } - - return context.json( - { - id: issuer.id, - name: issuer.name, - icon: issuer.icon?.proxied, - }, - 200, - ); - }, - ); - - app.delete( - "/api/v1/sso/:id", - describeRoute({ - summary: "Unlink account", - tags: ["SSO"], - responses: { - 204: { - description: "Account unlinked", - }, - 404: { - description: "Account not found", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - auth({ - auth: true, - permissions: [RolePermission.OAuth], - }), - plugin.middleware, - validator("param", z.object({ id: z.string() }), handleZodError), - async (context) => { - const { id: issuerId } = context.req.valid("param"); - const { user } = context.get("auth"); - - // Check if issuer exists - const issuer = context - .get("pluginConfig") - .providers.find((provider) => provider.id === issuerId); - - if (!issuer) { - return context.json( - { - error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, - }, - 404, - ); - } - - const account = await db.query.OpenIdAccounts.findFirst({ - where: (account): SQL | undefined => - and( - eq(account.userId, user.id), - eq(account.issuerId, issuerId), - ), - }); - - if (!account) { - throw new ApiError( - 404, - "Account not found or is not linked to this issuer", - ); - } - - await db - .delete(OpenIdAccounts) - .where(eq(OpenIdAccounts.id, account.id)); - - return context.body(null, 204); - }, - ); - }); -}; diff --git a/packages/api/plugins/openid/routes/sso/index.ts b/packages/api/plugins/openid/routes/sso/index.ts deleted file mode 100644 index b2746db7..00000000 --- a/packages/api/plugins/openid/routes/sso/index.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { RolePermission } from "@versia/client/schemas"; -import { ApiError } from "@versia-server/kit"; -import { auth, handleZodError } from "@versia-server/kit/api"; -import { Application, db } from "@versia-server/kit/db"; -import { OpenIdLoginFlows } from "@versia-server/kit/tables"; -import { randomUUIDv7 } from "bun"; -import { describeRoute, resolver, validator } from "hono-openapi"; -import { - calculatePKCECodeChallenge, - generateRandomCodeVerifier, -} from "oauth4webapi"; -import { z } from "zod/v4"; -import type { PluginType } from "../../index.ts"; -import { oauthDiscoveryRequest, oauthRedirectUri } from "../../utils.ts"; - -export default (plugin: PluginType): void => { - plugin.registerRoute("/api/v1/sso", (app) => { - app.get( - "/api/v1/sso", - describeRoute({ - summary: "Get linked accounts", - tags: ["SSO"], - responses: { - 200: { - description: "Linked accounts", - content: { - "application/json": { - schema: resolver( - z.array( - z.object({ - id: z.string(), - name: z.string(), - icon: z.string().optional(), - }), - ), - ), - }, - }, - }, - }, - }), - auth({ - auth: true, - permissions: [RolePermission.OAuth], - }), - plugin.middleware, - async (context) => { - const { user } = context.get("auth"); - - const linkedAccounts = await user.getLinkedOidcAccounts( - context.get("pluginConfig").providers, - ); - - return context.json( - linkedAccounts.map((account) => ({ - id: account.id, - name: account.name, - icon: account.icon, - })), - 200, - ); - }, - ); - - app.post( - "/api/v1/sso", - describeRoute({ - summary: "Link account", - tags: ["SSO"], - responses: { - 302: { - description: "Redirect to OpenID provider", - }, - 404: { - description: "Issuer not found", - content: { - "application/json": { - schema: resolver(ApiError.zodSchema), - }, - }, - }, - }, - }), - auth({ - auth: true, - permissions: [RolePermission.OAuth], - }), - plugin.middleware, - validator("json", z.object({ issuer: z.string() }), handleZodError), - async (context) => { - const { user } = context.get("auth"); - - const { issuer: issuerId } = context.req.valid("json"); - - const issuer = context - .get("pluginConfig") - .providers.find((provider) => provider.id === issuerId); - - if (!issuer) { - return context.json( - { - error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, - }, - 404, - ); - } - - const authServer = await oauthDiscoveryRequest( - new URL(issuer.url), - ); - - const codeVerifier = generateRandomCodeVerifier(); - - const redirectUri = oauthRedirectUri( - context.get("config").http.base_url, - issuerId, - ); - - const application = await Application.insert({ - id: randomUUIDv7(), - clientId: - user.id + - Buffer.from( - crypto.getRandomValues(new Uint8Array(32)), - ).toString("base64"), - name: "Versia", - redirectUri: redirectUri.toString(), - scopes: "openid profile email", - secret: "", - }); - - // Store into database - const newFlow = ( - await db - .insert(OpenIdLoginFlows) - .values({ - id: randomUUIDv7(), - codeVerifier, - issuerId, - applicationId: application.id, - }) - .returning() - )[0]; - - const codeChallenge = - await calculatePKCECodeChallenge(codeVerifier); - - return context.redirect( - `${authServer.authorization_endpoint}?${new URLSearchParams( - { - client_id: issuer.client_id, - redirect_uri: `${redirectUri}?${new URLSearchParams( - { - flow: newFlow.id, - link: "true", - user_id: user.id, - }, - )}`, - response_type: "code", - scope: "openid profile email", - // PKCE - code_challenge_method: "S256", - code_challenge: codeChallenge, - }, - ).toString()}`, - ); - }, - ); - }); -}; diff --git a/packages/api/routes/api/auth/login/index.ts b/packages/api/routes/api/auth/login/index.ts index dd32644e..1a3c9d19 100644 --- a/packages/api/routes/api/auth/login/index.ts +++ b/packages/api/routes/api/auth/login/index.ts @@ -97,30 +97,7 @@ export default apiRoute((app) => handleZodError, ), async (context) => { - const oidcConfig = config.plugins?.config?.["@versia/openid"] as - | { - forced: boolean; - providers: { - id: string; - name: string; - icon: string; - }[]; - keys: { - private: string; - public: string; - }; - } - | undefined; - - if (!oidcConfig) { - return returnError( - context, - "invalid_request", - "The OpenID Connect plugin is not enabled on this instance. Cannot process login request.", - ); - } - - if (oidcConfig?.forced) { + if (config.authentication.forced_openid) { return returnError( context, "invalid_request", @@ -166,15 +143,6 @@ export default apiRoute((app) => ); } - // Try and import the key - const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from(oidcConfig?.keys?.private ?? "", "base64"), - "Ed25519", - false, - ["sign"], - ); - // Generate JWT const jwt = await new SignJWT({ sub: user.id, @@ -185,7 +153,7 @@ export default apiRoute((app) => nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const application = await Application.fromClientId(client_id); diff --git a/packages/api/plugins/openid/routes/authorize.test.ts b/packages/api/routes/api/oauth/authorize.test.ts similarity index 96% rename from packages/api/plugins/openid/routes/authorize.test.ts rename to packages/api/routes/api/oauth/authorize.test.ts index 943ee586..3c002d12 100644 --- a/packages/api/plugins/openid/routes/authorize.test.ts +++ b/packages/api/routes/api/oauth/authorize.test.ts @@ -8,16 +8,6 @@ import { SignJWT } from "jose"; import { randomString } from "@/math"; const { deleteUsers, tokens, users } = await getTestUsers(1); -const privateKey = await crypto.subtle.importKey( - "pkcs8", - Buffer.from( - config.plugins?.config?.["@versia/openid"].keys.private, - "base64", - ), - "Ed25519", - false, - ["sign"], -); const application = await Application.insert({ id: randomUUIDv7(), @@ -44,7 +34,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -115,7 +105,7 @@ describe("/oauth/authorize", () => { aud: application.data.clientId, }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -157,7 +147,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -197,7 +187,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response2 = await fakeRequest("/oauth/authorize", { method: "POST", @@ -242,7 +232,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -286,7 +276,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -328,7 +318,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", @@ -370,7 +360,7 @@ describe("/oauth/authorize", () => { nbf: Math.floor(Date.now() / 1000), }) .setProtectedHeader({ alg: "EdDSA" }) - .sign(privateKey); + .sign(config.authentication.keys.private); const response = await fakeRequest("/oauth/authorize", { method: "POST", diff --git a/packages/api/routes/api/oauth/authorize.ts b/packages/api/routes/api/oauth/authorize.ts new file mode 100644 index 00000000..92e83657 --- /dev/null +++ b/packages/api/routes/api/oauth/authorize.ts @@ -0,0 +1,277 @@ +import { RolePermission } from "@versia/client/schemas"; +import { config } from "@versia-server/config"; +import { + apiRoute, + auth, + handleZodError, + jsonOrForm, +} from "@versia-server/kit/api"; +import { Application, Token, User } from "@versia-server/kit/db"; +import { randomUUIDv7 } from "bun"; +import { describeRoute, validator } from "hono-openapi"; +import { type JWTPayload, jwtVerify, SignJWT } from "jose"; +import { JOSEError } from "jose/errors"; +import { z } from "zod/v4"; +import { randomString } from "@/math"; +import { errorRedirect, errors } from "../../../plugins/openid/errors.ts"; + +export default apiRoute((app) => + app.post( + "/oauth/authorize", + describeRoute({ + summary: "Main OpenID authorization endpoint", + tags: ["OpenID"], + responses: { + 302: { + description: "Redirect to the application", + }, + }, + }), + auth({ + auth: false, + }), + jsonOrForm(), + validator( + "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), + }), + handleZodError, + ), + validator( + "json", + z + .object({ + scope: z.string().optional(), + redirect_uri: z + .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", + ), */ + handleZodError, + ), + validator( + "cookie", + z.object({ + jwt: z.string(), + }), + handleZodError, + ), + async (context) => { + const { scope, redirect_uri, client_id, state } = + context.req.valid("json"); + + const { jwt } = context.req.valid("cookie"); + + const errorSearchParams = new URLSearchParams( + context.req.valid("json"), + ); + + const result = await jwtVerify( + jwt, + config.authentication.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) { + return errorRedirect( + context, + errors.InvalidJWT, + errorSearchParams, + ); + } + + const { + payload: { aud, sub, exp }, + } = result; + + if (!(aud && sub && exp)) { + return errorRedirect( + context, + errors.MissingJWTFields, + errorSearchParams, + ); + } + + if (!z.uuid().safeParse(sub).success) { + return errorRedirect( + context, + errors.InvalidSub, + errorSearchParams, + ); + } + + const user = await User.fromId(sub); + + if (!user) { + return errorRedirect( + context, + errors.UserNotFound, + errorSearchParams, + ); + } + + if (!user.hasPermission(RolePermission.OAuth)) { + return errorRedirect( + context, + errors.MissingOauthPermission, + errorSearchParams, + ); + } + + const application = await Application.fromClientId(client_id); + + if (!application) { + return errorRedirect( + context, + errors.MissingApplication, + errorSearchParams, + ); + } + + if (application.data.redirectUri !== redirect_uri) { + return errorRedirect( + context, + errors.InvalidRedirectUri, + errorSearchParams, + ); + } + + // Check that scopes are a subset of the application's scopes + if ( + scope && + !scope + .split(" ") + .every((s) => application.data.scopes.includes(s)) + ) { + return errorRedirect( + context, + errors.InvalidScope, + errorSearchParams, + ); + } + + 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().href, + 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(config.authentication.keys.private); + + await Token.insert({ + id: randomUUIDv7(), + accessToken: randomString(64, "base64url"), + code, + scope: scope ?? application.data.scopes, + tokenType: "Bearer", + applicationId: application.id, + redirectUri: redirect_uri ?? application.data.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.data.redirectUri); + + redirectUri.searchParams.append("code", code); + state && redirectUri.searchParams.append("state", state); + + return context.redirect(redirectUri.toString()); + }, + ), +); diff --git a/packages/api/plugins/openid/routes/oauth/revoke.test.ts b/packages/api/routes/api/oauth/revoke.test.ts similarity index 100% rename from packages/api/plugins/openid/routes/oauth/revoke.test.ts rename to packages/api/routes/api/oauth/revoke.test.ts diff --git a/packages/api/routes/api/oauth/revoke.ts b/packages/api/routes/api/oauth/revoke.ts new file mode 100644 index 00000000..36b0a4f2 --- /dev/null +++ b/packages/api/routes/api/oauth/revoke.ts @@ -0,0 +1,87 @@ +import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api"; +import { db, Token } from "@versia-server/kit/db"; +import { Tokens } from "@versia-server/kit/tables"; +import { and, eq } from "drizzle-orm"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { z } from "zod/v4"; + +export default apiRoute((app) => { + app.post( + "/oauth/revoke", + describeRoute({ + summary: "Revoke token", + tags: ["OpenID"], + responses: { + 200: { + description: "Token deleted", + content: { + "application/json": { + schema: resolver(z.object({})), + }, + }, + }, + 401: { + description: "Authorization error", + content: { + "application/json": { + schema: resolver( + z.object({ + error: z.string(), + error_description: z.string(), + }), + ), + }, + }, + }, + }, + }), + jsonOrForm(), + validator( + "json", + z.object({ + client_id: z.string(), + client_secret: z.string(), + token: z.string().optional(), + }), + handleZodError, + ), + async (context) => { + const { client_id, client_secret, token } = + context.req.valid("json"); + + const foundToken = await Token.fromSql( + and( + eq(Tokens.accessToken, token ?? ""), + eq(Tokens.clientId, client_id), + ), + ); + + if (!(foundToken && token)) { + return context.json( + { + error: "unauthorized_client", + error_description: + "You are not authorized to revoke this token", + }, + 401, + ); + } + + // Check if the client secret is correct + if (foundToken.data.application?.secret !== client_secret) { + return context.json( + { + error: "unauthorized_client", + error_description: + "You are not authorized to revoke this token", + }, + 401, + ); + } + + await db.delete(Tokens).where(eq(Tokens.accessToken, token)); + + return context.json({}, 200); + }, + ); +}); diff --git a/packages/api/routes/api/oauth/sso.ts b/packages/api/routes/api/oauth/sso.ts new file mode 100644 index 00000000..b58bcfe0 --- /dev/null +++ b/packages/api/routes/api/oauth/sso.ts @@ -0,0 +1,130 @@ +import { config } from "@versia-server/config"; +import { apiRoute, handleZodError } from "@versia-server/kit/api"; +import { Application, db } from "@versia-server/kit/db"; +import { OpenIdLoginFlows } from "@versia-server/kit/tables"; +import { randomUUIDv7 } from "bun"; +import { describeRoute, validator } from "hono-openapi"; +import { + calculatePKCECodeChallenge, + discoveryRequest, + generateRandomCodeVerifier, + processDiscoveryResponse, +} from "oauth4webapi"; +import { z } from "zod/v4"; +import { oauthRedirectUri } from "../../../plugins/openid/utils.ts"; + +export default apiRoute((app) => { + app.get( + "/oauth/sso", + describeRoute({ + summary: "Initiate SSO login flow", + tags: ["OpenID"], + responses: { + 302: { + description: + "Redirect to SSO login, or redirect to login page with error", + }, + }, + }), + validator( + "query", + z.object({ + issuer: z.string(), + client_id: z.string().optional(), + redirect_uri: z.url().optional(), + scope: z.string().optional(), + response_type: z.enum(["code"]).optional(), + }), + handleZodError, + ), + async (context) => { + // This is the Versia client's client_id, not the external OAuth provider's client_id + const { issuer: issuerId, client_id } = context.req.valid("query"); + + const errorSearchParams = new URLSearchParams( + context.req.valid("query"), + ); + + if (!client_id || client_id === "undefined") { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "client_id is required", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "issuer is invalid", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + const issuerUrl = new URL(issuer.url); + + const authServer = await discoveryRequest(issuerUrl, { + algorithm: "oidc", + }).then((res) => processDiscoveryResponse(issuerUrl, res)); + + const codeVerifier = generateRandomCodeVerifier(); + + const application = await Application.fromClientId(client_id); + + if (!application) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "client_id is invalid", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + id: randomUUIDv7(), + codeVerifier, + applicationId: application.id, + issuerId, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return context.redirect( + `${authServer.authorization_endpoint}?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${oauthRedirectUri( + context.get("config").http.base_url, + issuerId, + )}?flow=${newFlow.id}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + ); + }, + ); +}); diff --git a/packages/api/routes/api/oauth/sso/[issuer]/callback.ts b/packages/api/routes/api/oauth/sso/[issuer]/callback.ts new file mode 100644 index 00000000..b9c1a706 --- /dev/null +++ b/packages/api/routes/api/oauth/sso/[issuer]/callback.ts @@ -0,0 +1,341 @@ +import { + Account as AccountSchema, + RolePermission, + zBoolean, +} from "@versia/client/schemas"; +import { config } from "@versia-server/config"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, handleZodError } from "@versia-server/kit/api"; +import { db, Media, Token, User } from "@versia-server/kit/db"; +import { searchManager } from "@versia-server/kit/search"; +import { OpenIdAccounts, Users } from "@versia-server/kit/tables"; +import { randomUUIDv7 } from "bun"; +import { and, eq, isNull, type SQL } from "drizzle-orm"; +import { setCookie } from "hono/cookie"; +import { describeRoute, validator } from "hono-openapi"; +import { SignJWT } from "jose"; +import { z } from "zod/v4"; +import { randomString } from "@/math.ts"; +import { automaticOidcFlow } from "../../../../../plugins/openid/utils.ts"; + +export default apiRoute((app) => { + app.get( + "/oauth/sso/:issuer/callback", + describeRoute({ + summary: "SSO callback", + tags: ["OpenID"], + description: + "After the user has authenticated to an external OpenID provider, they are redirected here to complete the OAuth flow and get a code", + responses: { + 302: { + description: + "Redirect to frontend's consent route, or redirect to login page with error", + }, + }, + }), + validator( + "param", + z.object({ + issuer: z.string(), + }), + handleZodError, + ), + validator( + "query", + z.object({ + client_id: z.string().optional(), + flow: z.string(), + link: zBoolean.optional(), + user_id: z.uuid().optional(), + }), + handleZodError, + ), + async (context) => { + const currentUrl = new URL(context.req.url); + const redirectUrl = new URL(context.req.url); + + // Correct some reverse proxies incorrectly setting the protocol as http, even if the original request was https + // Looking at you, Traefik + if ( + new URL(context.get("config").http.base_url).protocol === + "https:" && + currentUrl.protocol === "http:" + ) { + currentUrl.protocol = "https:"; + redirectUrl.protocol = "https:"; + } + + // Remove state query parameter from URL + currentUrl.searchParams.delete("state"); + redirectUrl.searchParams.delete("state"); + // Remove issuer query parameter from URL (can cause redirect URI mismatches) + redirectUrl.searchParams.delete("iss"); + redirectUrl.searchParams.delete("code"); + const { issuer: issuerParam } = context.req.valid("param"); + const { flow: flowId, user_id, link } = context.req.valid("query"); + + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerParam, + ); + + if (!issuer) { + throw new ApiError(404, "Issuer not found"); + } + + const userInfo = await automaticOidcFlow( + issuer, + flowId, + currentUrl, + redirectUrl, + (error, message, flow) => { + const errorSearchParams = new URLSearchParams( + Object.entries({ + redirect_uri: flow?.application?.redirectUri, + client_id: flow?.application?.clientId, + response_type: "code", + scope: flow?.application?.scopes, + }).filter(([_, value]) => value !== undefined) as [ + string, + string, + ][], + ); + + errorSearchParams.append("error", error); + errorSearchParams.append("error_description", message); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + }, + ); + + if (userInfo instanceof Response) { + return userInfo; + } + + const { sub, email, preferred_username, picture } = + userInfo.userInfo; + const flow = userInfo.flow; + + const errorSearchParams = new URLSearchParams( + Object.entries({ + redirect_uri: flow.application?.redirectUri, + client_id: flow.application?.clientId, + response_type: "code", + scope: flow.application?.scopes, + }).filter(([_, value]) => value !== undefined) as [ + string, + string, + ][], + ); + + // If linking account + if (link && user_id) { + // Check if userId is equal to application.clientId + if (!flow.application?.clientId.startsWith(user_id)) { + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linking_error: "Account linking error", + oidc_account_linking_error_message: `User ID does not match application client ID (${user_id} != ${flow.application?.clientId})`, + })}`, + ); + } + + // Check if account is already linked + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account): SQL | undefined => + and( + eq(account.serverId, sub), + eq(account.issuerId, issuer.id), + ), + }); + + if (account) { + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linking_error: + "Account already linked", + oidc_account_linking_error_message: + "This account has already been linked to this OpenID Connect provider.", + })}`, + ); + } + + // Link the account + await db.insert(OpenIdAccounts).values({ + id: randomUUIDv7(), + serverId: sub, + issuerId: issuer.id, + userId: user_id, + }); + + return context.redirect( + `${context.get("config").http.base_url}${ + context.get("config").frontend.routes.home + }?${new URLSearchParams({ + oidc_account_linked: "true", + })}`, + ); + } + + let userId = ( + await db.query.OpenIdAccounts.findFirst({ + where: (account): SQL | undefined => + and( + eq(account.serverId, sub), + eq(account.issuerId, issuer.id), + ), + }) + )?.userId; + + if (!userId) { + // Register new user + if (config.authentication.openid_registration) { + let username = + preferred_username ?? + email?.split("@")[0] ?? + randomString(8, "hex"); + + const usernameValidator = + AccountSchema.shape.username.refine( + async (value) => + !(await User.fromSql( + and( + eq(Users.username, value), + isNull(Users.instanceId), + ), + )), + ); + + try { + await usernameValidator.parseAsync(username); + } catch { + username = randomString(8, "hex"); + } + + const doesEmailExist = email + ? !!(await User.fromSql(eq(Users.email, email))) + : false; + + const avatar = picture + ? await Media.fromUrl(new URL(picture)) + : null; + + // Create new user + const user = await User.register(username, { + email: doesEmailExist ? undefined : email, + avatar: avatar ?? undefined, + }); + + // Add to search index + await searchManager.addUser(user); + + // Link account + await db.insert(OpenIdAccounts).values({ + id: randomUUIDv7(), + serverId: sub, + issuerId: issuer.id, + userId: user.id, + }); + + userId = user.id; + } else { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "No user found with that account", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + } + + const user = await User.fromId(userId); + + if (!user) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + "No user found with that account", + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + if (!user.hasPermission(RolePermission.OAuth)) { + errorSearchParams.append("error", "invalid_request"); + errorSearchParams.append( + "error_description", + `User does not have the '${RolePermission.OAuth}' permission`, + ); + + return context.redirect( + `${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`, + ); + } + + if (!flow.application) { + throw new ApiError(500, "Application not found"); + } + + const code = randomString(32, "hex"); + + await Token.insert({ + id: randomUUIDv7(), + accessToken: randomString(64, "base64url"), + code, + scope: flow.application.scopes, + tokenType: "Bearer", + userId: user.id, + applicationId: flow.application.id, + }); + + // Generate JWT + const jwt = await new SignJWT({ + sub: user.id, + iss: new URL(context.get("config").http.base_url).origin, + aud: flow.application.clientId, + exp: Math.floor(Date.now() / 1000) + 60 * 60, + iat: Math.floor(Date.now() / 1000), + nbf: Math.floor(Date.now() / 1000), + }) + .setProtectedHeader({ alg: "EdDSA" }) + .sign(config.authentication.keys.private); + + // Redirect back to application + setCookie(context, "jwt", jwt, { + httpOnly: true, + secure: true, + sameSite: "strict", + path: "/", + // 2 weeks + maxAge: 60 * 60 * 24 * 14, + }); + + return context.redirect( + new URL( + `${context.get("config").frontend.routes.consent}?${new URLSearchParams( + { + redirect_uri: flow.application.redirectUri, + code, + client_id: flow.application.clientId, + application: flow.application.name, + website: flow.application.website ?? "", + scope: flow.application.scopes, + response_type: "code", + }, + ).toString()}`, + context.get("config").http.base_url, + ).toString(), + ); + }, + ); +}); diff --git a/packages/api/plugins/openid/routes/oauth/token.test.ts b/packages/api/routes/api/oauth/token.test.ts similarity index 100% rename from packages/api/plugins/openid/routes/oauth/token.test.ts rename to packages/api/routes/api/oauth/token.test.ts diff --git a/packages/api/routes/api/oauth/token.ts b/packages/api/routes/api/oauth/token.ts new file mode 100644 index 00000000..cd333ee6 --- /dev/null +++ b/packages/api/routes/api/oauth/token.ts @@ -0,0 +1,190 @@ +import { apiRoute, handleZodError, jsonOrForm } from "@versia-server/kit/api"; +import { Application, Token } from "@versia-server/kit/db"; +import { Tokens } from "@versia-server/kit/tables"; +import { and, eq } from "drizzle-orm"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { z } from "zod/v4"; + +export default apiRoute((app) => { + app.post( + "/oauth/token", + describeRoute({ + summary: "Get token", + tags: ["OpenID"], + responses: { + 200: { + description: "Token", + content: { + "application/json": { + schema: resolver( + z.object({ + access_token: z.string(), + token_type: z.string(), + expires_in: z + .number() + .optional() + .nullable(), + id_token: z.string().optional().nullable(), + refresh_token: z + .string() + .optional() + .nullable(), + scope: z.string().optional(), + created_at: z.number(), + }), + ), + }, + }, + }, + 401: { + description: "Authorization error", + content: { + "application/json": { + schema: resolver( + z.object({ + error: z.string(), + error_description: z.string(), + }), + ), + }, + }, + }, + }, + }), + jsonOrForm(), + validator( + "json", + z.object({ + code: z.string().optional(), + code_verifier: z.string().optional(), + grant_type: z + .enum([ + "authorization_code", + "refresh_token", + "client_credentials", + "password", + "urn:ietf:params:oauth:grant-type:device_code", + "urn:ietf:params:oauth:grant-type:token-exchange", + "urn:ietf:params:oauth:grant-type:saml2-bearer", + "urn:openid:params:grant-type:ciba", + ]) + .default("authorization_code"), + client_id: z.string().optional(), + client_secret: z.string().optional(), + username: z.string().trim().optional(), + password: z.string().trim().optional(), + redirect_uri: z.url().optional(), + refresh_token: z.string().optional(), + scope: z.string().optional(), + assertion: z.string().optional(), + audience: z.string().optional(), + subject_token_type: z.string().optional(), + subject_token: z.string().optional(), + actor_token_type: z.string().optional(), + actor_token: z.string().optional(), + auth_req_id: z.string().optional(), + }), + handleZodError, + ), + async (context) => { + const { grant_type, code, redirect_uri, client_id, client_secret } = + context.req.valid("json"); + + switch (grant_type) { + case "authorization_code": { + if (!code) { + return context.json( + { + error: "invalid_request", + error_description: "Code is required", + }, + 401, + ); + } + + if (!redirect_uri) { + return context.json( + { + error: "invalid_request", + error_description: "Redirect URI is required", + }, + 401, + ); + } + + if (!client_id) { + return context.json( + { + error: "invalid_request", + error_description: "Client ID is required", + }, + 401, + ); + } + + // Verify the client_secret + const client = await Application.fromClientId(client_id); + + if (!client || client.data.secret !== client_secret) { + return context.json( + { + error: "invalid_client", + error_description: "Invalid client credentials", + }, + 401, + ); + } + + const token = await Token.fromSql( + and( + eq(Tokens.code, code), + eq(Tokens.redirectUri, decodeURI(redirect_uri)), + eq(Tokens.clientId, client_id), + ), + ); + + if (!token) { + return context.json( + { + error: "invalid_grant", + error_description: "Code not found", + }, + 401, + ); + } + + // Invalidate the code + await token.update({ code: null }); + + return context.json( + { + ...token.toApi(), + expires_in: token.data.expiresAt + ? Math.floor( + (new Date( + token.data.expiresAt, + ).getTime() - + Date.now()) / + 1000, + ) + : null, + id_token: token.data.idToken, + refresh_token: null, + }, + 200, + ); + } + + default: + } + + return context.json( + { + error: "unsupported_grant_type", + error_description: "Unsupported grant type", + }, + 401, + ); + }, + ); +}); diff --git a/packages/api/routes/api/v1/instance/index.ts b/packages/api/routes/api/v1/instance/index.ts index 7abf34c0..73b89d29 100644 --- a/packages/api/routes/api/v1/instance/index.ts +++ b/packages/api/routes/api/v1/instance/index.ts @@ -48,17 +48,6 @@ export default apiRoute((app) => const knownDomainsCount = await Instance.getCount(); - const oidcConfig = config.plugins?.config?.["@versia/openid"] as - | { - forced?: boolean; - providers?: { - id: string; - name: string; - icon?: string; - }[]; - } - | undefined; - const content = await markdownToHtml( config.instance.extended_description_path?.content ?? "This is a [Versia](https://versia.pub) server with the default extended description.", @@ -121,15 +110,15 @@ export default apiRoute((app) => }, version: "4.3.0-alpha.3+glitch", versia_version: version, - // TODO: Put into plugin directly sso: { - forced: oidcConfig?.forced ?? false, - providers: - oidcConfig?.providers?.map((p) => ({ + forced: config.authentication.forced_openid, + providers: config.authentication.openid_providers.map( + (p) => ({ name: p.name, - icon: p.icon, + icon: p.icon?.href, id: p.id, - })) ?? [], + }), + ), }, contact_account: (contactAccount as User)?.toApi(), } satisfies z.infer); diff --git a/packages/api/plugins/openid/routes/sso/:id/index.test.ts b/packages/api/routes/api/v1/sso/:id/index.test.ts similarity index 98% rename from packages/api/plugins/openid/routes/sso/:id/index.test.ts rename to packages/api/routes/api/v1/sso/:id/index.test.ts index 2e2ccf5e..1c2337c2 100644 --- a/packages/api/plugins/openid/routes/sso/:id/index.test.ts +++ b/packages/api/routes/api/v1/sso/:id/index.test.ts @@ -7,7 +7,6 @@ afterAll(async () => { await deleteUsers(); }); -// /api/v1/sso/:id describe("/api/v1/sso/:id", () => { test("should not find unknown issuer", async () => { const response = await fakeRequest("/api/v1/sso/unknown", { diff --git a/packages/api/routes/api/v1/sso/:id/index.ts b/packages/api/routes/api/v1/sso/:id/index.ts new file mode 100644 index 00000000..67c09e04 --- /dev/null +++ b/packages/api/routes/api/v1/sso/:id/index.ts @@ -0,0 +1,147 @@ +import { RolePermission } from "@versia/client/schemas"; +import { config } from "@versia-server/config"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; +import { db } from "@versia-server/kit/db"; +import { OpenIdAccounts } from "@versia-server/kit/tables"; +import { and, eq, type SQL } from "drizzle-orm"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { z } from "zod/v4"; + +export default apiRoute((app) => { + app.get( + "/api/v1/sso/:id", + describeRoute({ + summary: "Get linked account", + tags: ["SSO"], + responses: { + 200: { + description: "Linked account", + content: { + "application/json": { + schema: resolver( + z.object({ + id: z.string(), + name: z.string(), + icon: z.string().optional(), + }), + ), + }, + }, + }, + 404: ApiError.accountNotFound().schema, + }, + }), + auth({ + auth: true, + permissions: [RolePermission.OAuth], + }), + validator("param", z.object({ id: z.string() }), handleZodError), + async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return context.json( + { + error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, + }, + 404, + ); + } + + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account): SQL | undefined => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + throw new ApiError( + 404, + "Account not found or is not linked to this issuer", + ); + } + + return context.json( + { + id: issuer.id, + name: issuer.name, + icon: issuer.icon?.proxied, + }, + 200, + ); + }, + ); + + app.delete( + "/api/v1/sso/:id", + describeRoute({ + summary: "Unlink account", + tags: ["SSO"], + responses: { + 204: { + description: "Account unlinked", + }, + 404: { + description: "Account not found", + content: { + "application/json": { + schema: resolver(ApiError.zodSchema), + }, + }, + }, + }, + }), + auth({ + auth: true, + permissions: [RolePermission.OAuth], + }), + validator("param", z.object({ id: z.string() }), handleZodError), + async (context) => { + const { id: issuerId } = context.req.valid("param"); + const { user } = context.get("auth"); + + // Check if issuer exists + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return context.json( + { + error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, + }, + 404, + ); + } + + const account = await db.query.OpenIdAccounts.findFirst({ + where: (account): SQL | undefined => + and( + eq(account.userId, user.id), + eq(account.issuerId, issuerId), + ), + }); + + if (!account) { + throw new ApiError( + 404, + "Account not found or is not linked to this issuer", + ); + } + + await db + .delete(OpenIdAccounts) + .where(eq(OpenIdAccounts.id, account.id)); + + return context.body(null, 204); + }, + ); +}); diff --git a/packages/api/plugins/openid/routes/sso/index.test.ts b/packages/api/routes/api/v1/sso/index.test.ts similarity index 100% rename from packages/api/plugins/openid/routes/sso/index.test.ts rename to packages/api/routes/api/v1/sso/index.test.ts diff --git a/packages/api/routes/api/v1/sso/index.ts b/packages/api/routes/api/v1/sso/index.ts new file mode 100644 index 00000000..86baa4b0 --- /dev/null +++ b/packages/api/routes/api/v1/sso/index.ts @@ -0,0 +1,163 @@ +import { RolePermission } from "@versia/client/schemas"; +import { config } from "@versia-server/config"; +import { ApiError } from "@versia-server/kit"; +import { apiRoute, auth, handleZodError } from "@versia-server/kit/api"; +import { Application, db } from "@versia-server/kit/db"; +import { OpenIdLoginFlows } from "@versia-server/kit/tables"; +import { randomUUIDv7 } from "bun"; +import { describeRoute, resolver, validator } from "hono-openapi"; +import { + calculatePKCECodeChallenge, + generateRandomCodeVerifier, +} from "oauth4webapi"; +import { z } from "zod/v4"; +import { + oauthDiscoveryRequest, + oauthRedirectUri, +} from "../../../../plugins/openid/utils.ts"; + +export default apiRoute((app) => { + app.get( + "/api/v1/sso", + describeRoute({ + summary: "Get linked accounts", + tags: ["SSO"], + responses: { + 200: { + description: "Linked accounts", + content: { + "application/json": { + schema: resolver( + z.array( + z.object({ + id: z.string(), + name: z.string(), + icon: z.string().optional(), + }), + ), + ), + }, + }, + }, + }, + }), + auth({ + auth: true, + permissions: [RolePermission.OAuth], + }), + async (context) => { + const { user } = context.get("auth"); + + const linkedAccounts = await user.getLinkedOidcAccounts( + config.authentication.openid_providers, + ); + + return context.json( + linkedAccounts.map((account) => ({ + id: account.id, + name: account.name, + icon: account.icon, + })), + 200, + ); + }, + ); + + app.post( + "/api/v1/sso", + describeRoute({ + summary: "Link account", + tags: ["SSO"], + responses: { + 302: { + description: "Redirect to OpenID provider", + }, + 404: { + description: "Issuer not found", + content: { + "application/json": { + schema: resolver(ApiError.zodSchema), + }, + }, + }, + }, + }), + auth({ + auth: true, + permissions: [RolePermission.OAuth], + }), + validator("json", z.object({ issuer: z.string() }), handleZodError), + async (context) => { + const { user } = context.get("auth"); + + const { issuer: issuerId } = context.req.valid("json"); + + const issuer = config.authentication.openid_providers.find( + (provider) => provider.id === issuerId, + ); + + if (!issuer) { + return context.json( + { + error: `Issuer with ID ${issuerId} not found in instance's OpenID configuration`, + }, + 404, + ); + } + + const authServer = await oauthDiscoveryRequest(new URL(issuer.url)); + + const codeVerifier = generateRandomCodeVerifier(); + + const redirectUri = oauthRedirectUri( + context.get("config").http.base_url, + issuerId, + ); + + const application = await Application.insert({ + id: randomUUIDv7(), + clientId: + user.id + + Buffer.from( + crypto.getRandomValues(new Uint8Array(32)), + ).toString("base64"), + name: "Versia", + redirectUri: redirectUri.toString(), + scopes: "openid profile email", + secret: "", + }); + + // Store into database + const newFlow = ( + await db + .insert(OpenIdLoginFlows) + .values({ + id: randomUUIDv7(), + codeVerifier, + issuerId, + applicationId: application.id, + }) + .returning() + )[0]; + + const codeChallenge = + await calculatePKCECodeChallenge(codeVerifier); + + return context.redirect( + `${authServer.authorization_endpoint}?${new URLSearchParams({ + client_id: issuer.client_id, + redirect_uri: `${redirectUri}?${new URLSearchParams({ + flow: newFlow.id, + link: "true", + user_id: user.id, + })}`, + response_type: "code", + scope: "openid profile email", + // PKCE + code_challenge_method: "S256", + code_challenge: codeChallenge, + }).toString()}`, + ); + }, + ); +}); diff --git a/packages/api/routes/api/v2/instance/index.ts b/packages/api/routes/api/v2/instance/index.ts index c464bc53..18150104 100644 --- a/packages/api/routes/api/v2/instance/index.ts +++ b/packages/api/routes/api/v2/instance/index.ts @@ -39,17 +39,6 @@ export default apiRoute((app) => 30 * 24 * 60 * 60 * 1000, ); - const oidcConfig = config.plugins?.config?.["@versia/openid"] as - | { - forced?: boolean; - providers?: { - id: string; - name: string; - icon?: string; - }[]; - } - | undefined; - // TODO: fill in more values return context.json({ domain: config.http.base_url.hostname, @@ -162,13 +151,14 @@ export default apiRoute((app) => hint: r.hint, })), sso: { - forced: oidcConfig?.forced ?? false, - providers: - oidcConfig?.providers?.map((p) => ({ + forced: config.authentication.forced_openid, + providers: config.authentication.openid_providers.map( + (p) => ({ name: p.name, - icon: p.icon, + icon: p.icon?.href, id: p.id, - })) ?? [], + }), + ), }, }); }, diff --git a/packages/api/plugins/openid/routes/jwks.test.ts b/packages/api/routes/well-known/jwks.test.ts similarity index 100% rename from packages/api/plugins/openid/routes/jwks.test.ts rename to packages/api/routes/well-known/jwks.test.ts diff --git a/packages/api/routes/well-known/jwks.ts b/packages/api/routes/well-known/jwks.ts new file mode 100644 index 00000000..a8b182a3 --- /dev/null +++ b/packages/api/routes/well-known/jwks.ts @@ -0,0 +1,62 @@ +import { config } from "@versia-server/config"; +import { apiRoute, auth } from "@versia-server/kit/api"; +import { describeRoute, resolver } from "hono-openapi"; +import { exportJWK } from "jose"; +import { z } from "zod/v4"; + +export default apiRoute((app) => { + app.get( + "/.well-known/jwks", + describeRoute({ + summary: "JWK Set", + tags: ["OpenID"], + responses: { + 200: { + description: "JWK Set", + content: { + "application/json": { + schema: resolver( + z.object({ + keys: z.array( + z.object({ + kty: z.string().optional(), + use: z.string(), + alg: z.string(), + kid: z.string(), + crv: z.string().optional(), + x: z.string().optional(), + y: z.string().optional(), + }), + ), + }), + ), + }, + }, + }, + }, + }), + auth({ + auth: false, + }), + async (context) => { + const jwk = await exportJWK(config.authentication.keys.private); + + // Remove the private key 💀 + jwk.d = undefined; + + return context.json( + { + keys: [ + { + ...jwk, + use: "sig", + alg: "EdDSA", + kid: "1", + }, + ], + }, + 200, + ); + }, + ); +}); diff --git a/packages/config/index.ts b/packages/config/index.ts index 31b17271..f4b50b96 100644 --- a/packages/config/index.ts +++ b/packages/config/index.ts @@ -792,20 +792,22 @@ export const ConfigSchema = z federation: z.boolean().default(false), }) .optional(), - plugins: z.strictObject({ - autoload: z.boolean().default(true), - overrides: z - .strictObject({ - enabled: z.array(z.string()).default([]), - disabled: z.array(z.string()).default([]), - }) - .refine( - // Only one of enabled or disabled can be set - (arg) => - arg.enabled.length === 0 || arg.disabled.length === 0, - "Only one of enabled or disabled can be set", - ), - config: z.record(z.string(), z.any()).optional(), + authentication: z.strictObject({ + forced_openid: z.boolean().default(false), + openid_providers: z + .array( + z.strictObject({ + name: z.string().min(1), + id: z.string().min(1), + url: z.string().min(1), + client_id: z.string().min(1), + client_secret: sensitiveString, + icon: url.optional(), + }), + ) + .default([]), + openid_registration: z.boolean().default(true), + keys: keyPair, }), }) .refine( diff --git a/packages/kit/example.ts b/packages/kit/example.ts deleted file mode 100644 index 1b1119e6..00000000 --- a/packages/kit/example.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { z } from "zod/v4"; -import { Hooks } from "./hooks.ts"; -import { Plugin } from "./plugin.ts"; - -const myPlugin = new Plugin( - z.object({ - apiKey: z.string(), - }), -); - -myPlugin.registerHandler(Hooks.Response, (req) => { - console.info("Request received:", req); - return req; -}); - -export default myPlugin; diff --git a/packages/kit/hooks.ts b/packages/kit/hooks.ts deleted file mode 100644 index 10d6b232..00000000 --- a/packages/kit/hooks.ts +++ /dev/null @@ -1,9 +0,0 @@ -export enum Hooks { - Request = "request", - Response = "response", -} - -export type ServerHooks = { - [Hooks.Request]: (request: Request) => Request; - [Hooks.Response]: (response: Response) => Response; -}; diff --git a/packages/kit/index.ts b/packages/kit/index.ts index a25e611b..1c5e8357 100644 --- a/packages/kit/index.ts +++ b/packages/kit/index.ts @@ -1,4 +1 @@ export { ApiError } from "./api-error.ts"; -export { Hooks } from "./hooks.ts"; -export { Plugin } from "./plugin.ts"; -export { type Manifest, manifestSchema } from "./schema.ts"; diff --git a/packages/kit/json-schema.ts b/packages/kit/json-schema.ts deleted file mode 100644 index 20d791c6..00000000 --- a/packages/kit/json-schema.ts +++ /dev/null @@ -1,6 +0,0 @@ -import * as z from "zod/v4"; -import { manifestSchema } from "./schema.ts"; - -const jsonSchema = z.toJSONSchema(manifestSchema); - -console.write(`${JSON.stringify(jsonSchema, null, 4)}\n`); diff --git a/packages/kit/manifest.schema.json b/packages/kit/manifest.schema.json deleted file mode 100644 index b2a82d74..00000000 --- a/packages/kit/manifest.schema.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "type": "object", - "properties": { - "$schema": { - "type": "string" - }, - "name": { - "type": "string", - "minLength": 3, - "maxLength": 100 - }, - "version": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-((?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\\+([0-9a-zA-Z-]+(?:\\.[0-9a-zA-Z-]+)*))?$" - }, - "description": { - "type": "string", - "minLength": 1, - "maxLength": 4096 - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 100 - }, - "email": { - "type": "string", - "format": "email" - }, - "url": { - "type": "string", - "format": "uri" - } - }, - "required": ["name"], - "additionalProperties": false - } - }, - "repository": { - "type": "object", - "properties": { - "type": { - "type": "string", - "enum": [ - "git", - "svn", - "mercurial", - "bzr", - "darcs", - "mtn", - "cvs", - "fossil", - "bazaar", - "arch", - "tla", - "archie", - "monotone", - "perforce", - "sourcevault", - "plastic", - "clearcase", - "accurev", - "surroundscm", - "bitkeeper", - "other" - ] - }, - "url": { - "type": "string", - "format": "uri" - } - }, - "additionalProperties": false - } - }, - "required": ["name", "version", "description"], - "additionalProperties": false, - "$schema": "http://json-schema.org/draft-07/schema#" -} diff --git a/packages/kit/plugin.ts b/packages/kit/plugin.ts deleted file mode 100644 index 43eee5be..00000000 --- a/packages/kit/plugin.ts +++ /dev/null @@ -1,90 +0,0 @@ -import type { Hono, MiddlewareHandler } from "hono"; -import { createMiddleware } from "hono/factory"; -import type { z } from "zod/v4"; -import { fromZodError, type ZodError } from "zod-validation-error"; -import type { HonoEnv } from "~/types/api"; -import type { ServerHooks } from "./hooks.ts"; - -export type HonoPluginEnv = HonoEnv & { - Variables: { - pluginConfig: z.infer; - }; -}; - -export class Plugin { - private readonly handlers: Partial = {}; - // biome-ignore lint/nursery/useReadonlyClassProperties: biome is wrong lol - private store: z.infer | null = null; - private readonly routes: { - path: string; - fn: (app: Hono>) => void; - }[] = []; - - public constructor(private readonly configSchema: ConfigSchema) {} - - public get middleware(): MiddlewareHandler> { - // Middleware that adds the plugin's configuration to the request object - return createMiddleware>( - async (context, next) => { - context.set("pluginConfig", this.getConfig()); - await next(); - }, - ); - } - - public registerRoute( - path: string, - fn: (app: Hono>) => void, - ): 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 async _loadConfig(config: z.input): Promise { - try { - this.store = await this.configSchema.parseAsync(config); - } catch (error) { - throw fromZodError(error as ZodError); - } - } - - protected _addToApp(app: Hono): void { - for (const route of this.routes) { - app.use(route.path, this.middleware); - route.fn(app as unknown as Hono>); - } - } - - public registerHandler( - hook: HookName, - handler: ServerHooks[HookName], - ): void { - this.handlers[hook] = handler; - } - - public static [Symbol.hasInstance](instance: unknown): boolean { - return ( - typeof instance === "object" && - instance !== null && - "registerHandler" in instance - ); - } - - /** - * Returns the internal configuration object. - */ - private getConfig(): z.infer { - if (!this.store) { - throw new Error("Configuration has not been loaded yet."); - } - - return this.store; - } -} diff --git a/packages/kit/schema.ts b/packages/kit/schema.ts deleted file mode 100644 index 473211a8..00000000 --- a/packages/kit/schema.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { z } from "zod/v4"; - -export const manifestSchema = z.object({ - // biome-ignore lint/style/useNamingConvention: JSON schema requires this to be $schema - $schema: z.string().optional(), - name: z.string().min(3).max(100), - version: z - .string() - .regex( - /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/gm, - "Version must be valid SemVer string", - ), - description: z.string().min(1).max(4096), - authors: z - .array( - z.object({ - name: z.string().min(1).max(100), - email: z.email().optional(), - url: z.url().optional(), - }), - ) - .optional(), - repository: z - .object({ - type: z - .enum([ - "git", - "svn", - "mercurial", - "bzr", - "darcs", - "mtn", - "cvs", - "fossil", - "bazaar", - "arch", - "tla", - "archie", - "monotone", - "perforce", - "sourcevault", - "plastic", - "clearcase", - "accurev", - "surroundscm", - "bitkeeper", - "other", - ]) - .optional(), - url: z.url().optional(), - }) - .optional(), -}); - -export type Manifest = { - name: string; - version: string; - description: string; - authors?: - | { - name: string; - email?: string | undefined; - url?: string | undefined; - }[] - | undefined; - repository?: - | { - type?: - | "git" - | "svn" - | "mercurial" - | "bzr" - | "darcs" - | "mtn" - | "cvs" - | "fossil" - | "bazaar" - | "arch" - | "tla" - | "archie" - | "monotone" - | "perforce" - | "sourcevault" - | "plastic" - | "clearcase" - | "accurev" - | "surroundscm" - | "bitkeeper" - | "other" - | undefined; - url?: string | undefined; - } - | undefined; -}; - -// This is a type guard to ensure that the schema and the type are in sync -function assert<_T extends never>() { - // ... -} -type TypeEqualityGuard = Exclude | Exclude; -assert>>(); diff --git a/packages/logging/index.ts b/packages/logging/index.ts index 0ed43128..e9141ccc 100644 --- a/packages/logging/index.ts +++ b/packages/logging/index.ts @@ -135,10 +135,6 @@ await configure({ category: ["logtape", "meta"], lowestLevel: "error", }, - { - category: "plugin", - sinks: getSinkNames(), - }, ], }); @@ -151,4 +147,3 @@ export const federationMessagingLogger = getLogger(["federation", "messaging"]); export const databaseLogger = getLogger("database"); export const webfingerLogger = getLogger("webfinger"); export const sonicLogger = getLogger("sonic"); -export const pluginLogger = getLogger("plugin"); diff --git a/utils/bull-board.ts b/utils/bull-board.ts index 5d35e33d..56603839 100644 --- a/utils/bull-board.ts +++ b/utils/bull-board.ts @@ -1,7 +1,10 @@ import { createBullBoard } from "@bull-board/api"; import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; import { HonoAdapter } from "@bull-board/hono"; +import { RolePermission } from "@versia/client/schemas"; import { config } from "@versia-server/config"; +import { ApiError } from "@versia-server/kit"; +import { User } from "@versia-server/kit/db"; import { deliveryQueue } from "@versia-server/kit/queues/delivery"; import { fetchQueue } from "@versia-server/kit/queues/fetch"; import { inboxQueue } from "@versia-server/kit/queues/inbox"; @@ -10,6 +13,9 @@ import { pushQueue } from "@versia-server/kit/queues/push"; import { relationshipQueue } from "@versia-server/kit/queues/relationships"; import type { Hono } from "hono"; import { serveStatic } from "hono/bun"; +import { getCookie } from "hono/cookie"; +import { jwtVerify } from "jose"; +import { JOSEError, JWTExpired } from "jose/errors"; import type { HonoEnv } from "~/types/api"; import pkg from "../package.json" with { type: "json" }; @@ -44,4 +50,54 @@ export const applyToHono = (app: Hono): void => { serverAdapter.setBasePath("/admin/queues"); app.route("/admin/queues", serverAdapter.registerPlugin()); + + app.use("/admin/queues/api/*", async (context, next) => { + const jwtCookie = getCookie(context, "jwt"); + + if (!jwtCookie) { + throw new ApiError(401, "Missing JWT cookie"); + } + + const result = await jwtVerify( + jwtCookie, + config.authentication.keys.public, + { + algorithms: ["EdDSA"], + issuer: new URL(context.get("config").http.base_url).origin, + }, + ).catch((error) => { + if (error instanceof JOSEError) { + return error; + } + + throw error; + }); + + if (result instanceof JOSEError) { + if (result instanceof JWTExpired) { + throw new ApiError(401, "JWT has expired"); + } + + throw new ApiError(401, "Invalid JWT"); + } + + const { + payload: { sub }, + } = result; + + if (!sub) { + throw new ApiError(401, "Invalid JWT (no sub)"); + } + + const user = await User.fromId(sub); + + if (!user?.hasPermission(RolePermission.ManageInstanceFederation)) { + throw new ApiError( + 403, + `Missing '${RolePermission.ManageInstanceFederation}' permission`, + ); + } + + await next(); + }); };