refactor: 🔥 Remove plugin functionality, move OpenID plugin to core

This commit is contained in:
Jesse Wierzbinski 2025-07-07 05:52:11 +02:00
parent 278bf960cb
commit b5e9e35427
No known key found for this signature in database
45 changed files with 1502 additions and 2304 deletions

View file

@ -452,24 +452,15 @@ log_level = "info" # For console output
# environment = "production" # environment = "production"
# log_level = "info" # log_level = "info"
[plugins] [authentication]
# Whether to automatically load all plugins in the plugins directory
autoload = true
# Override for autoload
[plugins.overrides]
enabled = []
disabled = []
[plugins.config."@versia/openid"]
# If enabled, Versia will require users to log in with an OpenID provider # If enabled, Versia will require users to log in with an OpenID provider
forced = false forced_openid = false
# Allow registration with OpenID providers # Allow registration with OpenID providers
# If signups.registration is false, it will only be possible to register with OpenID # 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 # Run Versia Server with those values missing to generate a new key
public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8=" public = "MCowBQYDK2VwAyEAfyZx8r98gVHtdH5EF1NYrBeChOXkt50mqiwKO2TX0f8="
private = "MC4CAQAwBQYDK2VwBCIEILDi1g7+bwNjBBvL4CRWHZpCFBR2m2OPCot62Wr+TCbq" 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 # 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): # Authentik for example uses regex so it can be set to (regex):
# <base_url>/oauth/sso/<provider_id>/callback.* # <base_url>/oauth/sso/<provider_id>/callback.*
# [[plugins.config."@versia/openid".providers]] # [[authentication.openid_providers]]
# name = "CPlusPatch ID" # name = "CPlusPatch ID"
# id = "cpluspatch-id" # id = "cpluspatch-id"
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof) # This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)

View file

@ -6,7 +6,6 @@
"cli", "cli",
"federation", "federation",
"config", "config",
"plugin",
"worker", "worker",
"media", "media",
"packages/client", "packages/client",

View file

@ -458,25 +458,15 @@ log_level = "info" # For console output
# environment = "production" # environment = "production"
# log_level = "info" # log_level = "info"
[authentication]
[plugins]
# Whether to automatically load all plugins in the plugins directory
autoload = true
# Override for autoload
[plugins.overrides]
enabled = []
disabled = []
[plugins.config."@versia/openid"]
# If enabled, Versia will require users to log in with an OpenID provider # If enabled, Versia will require users to log in with an OpenID provider
forced = false forced_openid = false
# Allow registration with OpenID providers # Allow registration with OpenID providers
# If signups.registration is false, it will only be possible to register with OpenID # 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 # Run Versia Server with those values missing to generate a new key
# public = "" # public = ""
# private = "" # private = ""
@ -488,7 +478,7 @@ allow_registration = true
# The asterisk is important, as it allows for any query parameters to be passed # 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): # Authentik for example uses regex so it can be set to (regex):
# <base_url>/oauth/sso/<provider_id>/callback.* # <base_url>/oauth/sso/<provider_id>/callback.*
# [[plugins.config."@versia/openid".providers]] # [[authentication.openid_providers]]
# name = "CPlusPatch ID" # name = "CPlusPatch ID"
# id = "cpluspatch-id" # id = "cpluspatch-id"
# This MUST match the provider's issuer URI, including the trailing slash (or lack thereof) # This MUST match the provider's issuer URI, including the trailing slash (or lack thereof)

View file

@ -123,6 +123,7 @@
"build": "bun run --filter \"*\" build && bun run build.ts", "build": "bun run --filter \"*\" build && bun run build.ts",
"detect-circular": "bunx madge --circular --extensions ts ./", "detect-circular": "bunx madge --circular --extensions ts ./",
"update-nix-hashes": "bash scripts/update-nix.sh", "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-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", "run-worker": "bun run build worker && cd dist && ln -s ../config config && bun run worker.js",
"dev": "bun run --hot api.ts", "dev": "bun run --hot api.ts",

View file

@ -1,9 +1,6 @@
import { join } from "node:path";
import { Scalar } from "@scalar/hono-api-reference"; import { Scalar } from "@scalar/hono-api-reference";
import { config } from "@versia-server/config"; import { config } from "@versia-server/config";
import { ApiError } from "@versia-server/kit"; import { ApiError } from "@versia-server/kit";
import { serverLogger } from "@versia-server/logging";
import chalk from "chalk";
import { Hono } from "hono"; import { Hono } from "hono";
import { serveStatic } from "hono/bun"; import { serveStatic } from "hono/bun";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
@ -20,7 +17,6 @@ import { boundaryCheck } from "./middlewares/boundary-check.ts";
import { ipBans } from "./middlewares/ip-bans.ts"; import { ipBans } from "./middlewares/ip-bans.ts";
import { logger } from "./middlewares/logger.ts"; import { logger } from "./middlewares/logger.ts";
import { rateLimit } from "./middlewares/rate-limit.ts"; import { rateLimit } from "./middlewares/rate-limit.ts";
import { PluginLoader } from "./plugin-loader.ts";
import { routes } from "./routes.ts"; import { routes } from "./routes.ts";
export const appFactory = async (): Promise<Hono<HonoEnv>> => { export const appFactory = async (): Promise<Hono<HonoEnv>> => {
@ -104,27 +100,6 @@ export const appFactory = async (): Promise<Hono<HonoEnv>> => {
route.default(app); 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, { const openApiSpecs = await generateSpecs(app, {
documentation: { documentation: {
info: { info: {

View file

@ -1,4 +1,3 @@
import { readdir } from "node:fs/promises";
import { $, build } from "bun"; import { $, build } from "bun";
import manifest from "./package.json" with { type: "json" }; import manifest from "./package.json" with { type: "json" };
import { routes } from "./routes.ts"; import { routes } from "./routes.ts";
@ -7,18 +6,11 @@ console.log("Building...");
await $`rm -rf dist && mkdir dist`; await $`rm -rf dist && mkdir dist`;
// Get all directories under the plugins/ directory
const pluginDirs = await readdir("plugins", { withFileTypes: true });
await build({ await build({
entrypoints: [ entrypoints: [
...Object.values(manifest.exports).map((entry) => entry.import), ...Object.values(manifest.exports).map((entry) => entry.import),
// Force Bun to include endpoints // Force Bun to include endpoints
...Object.values(routes), ...Object.values(routes),
// Include all plugins
...pluginDirs
.filter((dir) => dir.isDirectory())
.map((dir) => `plugins/${dir.name}/index.ts`),
], ],
outdir: "dist", outdir: "dist",
target: "bun", target: "bun",
@ -37,9 +29,6 @@ await build({
console.log("Copying files..."); console.log("Copying files...");
// Copy plugin manifests
await $`cp plugins/openid/manifest.json dist/plugins/openid/manifest.json`;
await $`mkdir -p dist/node_modules`; await $`mkdir -p dist/node_modules`;
// Copy bull-board to dist // Copy bull-board to dist

View file

@ -36,7 +36,6 @@
"scripts": { "scripts": {
"dev": "bun run --hot index.ts", "dev": "bun run --hot index.ts",
"build": "bun run build.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:dev": "vitepress dev docs",
"docs:build": "vitepress build docs", "docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs" "docs:preview": "vitepress preview docs"

View file

@ -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<string[]>} - An array of directory names.
*/
private static async getDirectories(dir: string): Promise<string[]> {
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<string | undefined>} - The manifest file name if found, otherwise undefined.
*/
private static async findManifestFile(
dir: string,
): Promise<string | undefined> {
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<boolean>} - True if the entrypoint file is found, otherwise false.
*/
private static async hasEntrypoint(dir: string): Promise<boolean> {
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<unknown>} - 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<unknown> {
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<string[]>} - An array of plugin directories.
*/
public static async findPlugins(dir: string): Promise<string[]> {
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<Manifest>} - 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<Manifest> {
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<Plugin<ZodTypeAny>>} - 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<Plugin<ZodTypeAny>> {
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<ZodTypeAny> }[]> {
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<ZodTypeAny>;
}[],
app: Hono<HonoEnv>,
): Promise<void> {
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.<plugin-name>",
)}`;
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`)}`;
}
}
}

View file

@ -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;

View file

@ -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"
}
}

View file

@ -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());
},
),
);

View file

@ -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,
);
},
),
);
};

View file

@ -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(),
);
},
);
});
};

View file

@ -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);
},
);
});
};

View file

@ -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()}`,
);
},
);
});
};

View file

@ -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,
);
},
);
});
};

View file

@ -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);
},
);
});
};

View file

@ -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()}`,
);
},
);
});
};

View file

@ -97,30 +97,7 @@ export default apiRoute((app) =>
handleZodError, handleZodError,
), ),
async (context) => { async (context) => {
const oidcConfig = config.plugins?.config?.["@versia/openid"] as if (config.authentication.forced_openid) {
| {
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) {
return returnError( return returnError(
context, context,
"invalid_request", "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 // Generate JWT
const jwt = await new SignJWT({ const jwt = await new SignJWT({
sub: user.id, sub: user.id,
@ -185,7 +153,7 @@ export default apiRoute((app) =>
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const application = await Application.fromClientId(client_id); const application = await Application.fromClientId(client_id);

View file

@ -8,16 +8,6 @@ import { SignJWT } from "jose";
import { randomString } from "@/math"; import { randomString } from "@/math";
const { deleteUsers, tokens, users } = await getTestUsers(1); 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({ const application = await Application.insert({
id: randomUUIDv7(), id: randomUUIDv7(),
@ -44,7 +34,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -115,7 +105,7 @@ describe("/oauth/authorize", () => {
aud: application.data.clientId, aud: application.data.clientId,
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -157,7 +147,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -197,7 +187,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response2 = await fakeRequest("/oauth/authorize", { const response2 = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -242,7 +232,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -286,7 +276,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -328,7 +318,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",
@ -370,7 +360,7 @@ describe("/oauth/authorize", () => {
nbf: Math.floor(Date.now() / 1000), nbf: Math.floor(Date.now() / 1000),
}) })
.setProtectedHeader({ alg: "EdDSA" }) .setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey); .sign(config.authentication.keys.private);
const response = await fakeRequest("/oauth/authorize", { const response = await fakeRequest("/oauth/authorize", {
method: "POST", method: "POST",

View file

@ -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());
},
),
);

View file

@ -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);
},
);
});

View file

@ -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()}`,
);
},
);
});

View file

@ -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(),
);
},
);
});

View file

@ -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,
);
},
);
});

View file

@ -48,17 +48,6 @@ export default apiRoute((app) =>
const knownDomainsCount = await Instance.getCount(); 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( const content = await markdownToHtml(
config.instance.extended_description_path?.content ?? config.instance.extended_description_path?.content ??
"This is a [Versia](https://versia.pub) server with the default extended description.", "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", version: "4.3.0-alpha.3+glitch",
versia_version: version, versia_version: version,
// TODO: Put into plugin directly
sso: { sso: {
forced: oidcConfig?.forced ?? false, forced: config.authentication.forced_openid,
providers: providers: config.authentication.openid_providers.map(
oidcConfig?.providers?.map((p) => ({ (p) => ({
name: p.name, name: p.name,
icon: p.icon, icon: p.icon?.href,
id: p.id, id: p.id,
})) ?? [], }),
),
}, },
contact_account: (contactAccount as User)?.toApi(), contact_account: (contactAccount as User)?.toApi(),
} satisfies z.infer<typeof InstanceV1Schema>); } satisfies z.infer<typeof InstanceV1Schema>);

View file

@ -7,7 +7,6 @@ afterAll(async () => {
await deleteUsers(); await deleteUsers();
}); });
// /api/v1/sso/:id
describe("/api/v1/sso/:id", () => { describe("/api/v1/sso/:id", () => {
test("should not find unknown issuer", async () => { test("should not find unknown issuer", async () => {
const response = await fakeRequest("/api/v1/sso/unknown", { const response = await fakeRequest("/api/v1/sso/unknown", {

View file

@ -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);
},
);
});

View file

@ -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()}`,
);
},
);
});

View file

@ -39,17 +39,6 @@ export default apiRoute((app) =>
30 * 24 * 60 * 60 * 1000, 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 // TODO: fill in more values
return context.json({ return context.json({
domain: config.http.base_url.hostname, domain: config.http.base_url.hostname,
@ -162,13 +151,14 @@ export default apiRoute((app) =>
hint: r.hint, hint: r.hint,
})), })),
sso: { sso: {
forced: oidcConfig?.forced ?? false, forced: config.authentication.forced_openid,
providers: providers: config.authentication.openid_providers.map(
oidcConfig?.providers?.map((p) => ({ (p) => ({
name: p.name, name: p.name,
icon: p.icon, icon: p.icon?.href,
id: p.id, id: p.id,
})) ?? [], }),
),
}, },
}); });
}, },

View file

@ -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,
);
},
);
});

View file

@ -792,20 +792,22 @@ export const ConfigSchema = z
federation: z.boolean().default(false), federation: z.boolean().default(false),
}) })
.optional(), .optional(),
plugins: z.strictObject({ authentication: z.strictObject({
autoload: z.boolean().default(true), forced_openid: z.boolean().default(false),
overrides: z openid_providers: z
.strictObject({ .array(
enabled: z.array(z.string()).default([]), z.strictObject({
disabled: z.array(z.string()).default([]), name: z.string().min(1),
}) id: z.string().min(1),
.refine( url: z.string().min(1),
// Only one of enabled or disabled can be set client_id: z.string().min(1),
(arg) => client_secret: sensitiveString,
arg.enabled.length === 0 || arg.disabled.length === 0, icon: url.optional(),
"Only one of enabled or disabled can be set", }),
), )
config: z.record(z.string(), z.any()).optional(), .default([]),
openid_registration: z.boolean().default(true),
keys: keyPair,
}), }),
}) })
.refine( .refine(

View file

@ -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;

View file

@ -1,9 +0,0 @@
export enum Hooks {
Request = "request",
Response = "response",
}
export type ServerHooks = {
[Hooks.Request]: (request: Request) => Request;
[Hooks.Response]: (response: Response) => Response;
};

View file

@ -1,4 +1 @@
export { ApiError } from "./api-error.ts"; export { ApiError } from "./api-error.ts";
export { Hooks } from "./hooks.ts";
export { Plugin } from "./plugin.ts";
export { type Manifest, manifestSchema } from "./schema.ts";

View file

@ -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`);

View file

@ -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#"
}

View file

@ -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<ConfigType extends z.ZodTypeAny> = HonoEnv & {
Variables: {
pluginConfig: z.infer<ConfigType>;
};
};
export class Plugin<ConfigSchema extends z.ZodTypeAny> {
private readonly handlers: Partial<ServerHooks> = {};
// biome-ignore lint/nursery/useReadonlyClassProperties: biome is wrong lol
private store: z.infer<ConfigSchema> | null = null;
private readonly routes: {
path: string;
fn: (app: Hono<HonoPluginEnv<ConfigSchema>>) => void;
}[] = [];
public constructor(private readonly configSchema: ConfigSchema) {}
public get middleware(): MiddlewareHandler<HonoPluginEnv<ConfigSchema>> {
// Middleware that adds the plugin's configuration to the request object
return createMiddleware<HonoPluginEnv<ConfigSchema>>(
async (context, next) => {
context.set("pluginConfig", this.getConfig());
await next();
},
);
}
public registerRoute(
path: string,
fn: (app: Hono<HonoPluginEnv<ConfigSchema>>) => 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<ConfigSchema>): Promise<void> {
try {
this.store = await this.configSchema.parseAsync(config);
} catch (error) {
throw fromZodError(error as ZodError);
}
}
protected _addToApp(app: Hono<HonoEnv>): void {
for (const route of this.routes) {
app.use(route.path, this.middleware);
route.fn(app as unknown as Hono<HonoPluginEnv<ConfigSchema>>);
}
}
public registerHandler<HookName extends keyof ServerHooks>(
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<ConfigSchema> {
if (!this.store) {
throw new Error("Configuration has not been loaded yet.");
}
return this.store;
}
}

View file

@ -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<A, B> = Exclude<A, B> | Exclude<B, A>;
assert<TypeEqualityGuard<Manifest, z.infer<typeof manifestSchema>>>();

View file

@ -135,10 +135,6 @@ await configure({
category: ["logtape", "meta"], category: ["logtape", "meta"],
lowestLevel: "error", lowestLevel: "error",
}, },
{
category: "plugin",
sinks: getSinkNames(),
},
], ],
}); });
@ -151,4 +147,3 @@ export const federationMessagingLogger = getLogger(["federation", "messaging"]);
export const databaseLogger = getLogger("database"); export const databaseLogger = getLogger("database");
export const webfingerLogger = getLogger("webfinger"); export const webfingerLogger = getLogger("webfinger");
export const sonicLogger = getLogger("sonic"); export const sonicLogger = getLogger("sonic");
export const pluginLogger = getLogger("plugin");

View file

@ -1,7 +1,10 @@
import { createBullBoard } from "@bull-board/api"; import { createBullBoard } from "@bull-board/api";
import { BullMQAdapter } from "@bull-board/api/bullMQAdapter"; import { BullMQAdapter } from "@bull-board/api/bullMQAdapter";
import { HonoAdapter } from "@bull-board/hono"; import { HonoAdapter } from "@bull-board/hono";
import { RolePermission } from "@versia/client/schemas";
import { config } from "@versia-server/config"; 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 { deliveryQueue } from "@versia-server/kit/queues/delivery";
import { fetchQueue } from "@versia-server/kit/queues/fetch"; import { fetchQueue } from "@versia-server/kit/queues/fetch";
import { inboxQueue } from "@versia-server/kit/queues/inbox"; 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 { relationshipQueue } from "@versia-server/kit/queues/relationships";
import type { Hono } from "hono"; import type { Hono } from "hono";
import { serveStatic } from "hono/bun"; 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 type { HonoEnv } from "~/types/api";
import pkg from "../package.json" with { type: "json" }; import pkg from "../package.json" with { type: "json" };
@ -44,4 +50,54 @@ export const applyToHono = (app: Hono<HonoEnv>): void => {
serverAdapter.setBasePath("/admin/queues"); serverAdapter.setBasePath("/admin/queues");
app.route("/admin/queues", serverAdapter.registerPlugin()); 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();
});
}; };