mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 00:18:19 +01:00
refactor: 🔥 Remove plugin functionality, move OpenID plugin to core
This commit is contained in:
parent
278bf960cb
commit
b5e9e35427
19
.github/config.workflow.toml
vendored
19
.github/config.workflow.toml
vendored
|
|
@ -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)
|
||||||
|
|
|
||||||
1
.vscode/settings.json
vendored
1
.vscode/settings.json
vendored
|
|
@ -6,7 +6,6 @@
|
||||||
"cli",
|
"cli",
|
||||||
"federation",
|
"federation",
|
||||||
"config",
|
"config",
|
||||||
"plugin",
|
|
||||||
"worker",
|
"worker",
|
||||||
"media",
|
"media",
|
||||||
"packages/client",
|
"packages/client",
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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`)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -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(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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()}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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()}`,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
277
packages/api/routes/api/oauth/authorize.ts
Normal file
277
packages/api/routes/api/oauth/authorize.ts
Normal 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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
87
packages/api/routes/api/oauth/revoke.ts
Normal file
87
packages/api/routes/api/oauth/revoke.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
130
packages/api/routes/api/oauth/sso.ts
Normal file
130
packages/api/routes/api/oauth/sso.ts
Normal 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()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
341
packages/api/routes/api/oauth/sso/[issuer]/callback.ts
Normal file
341
packages/api/routes/api/oauth/sso/[issuer]/callback.ts
Normal 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(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
190
packages/api/routes/api/oauth/token.ts
Normal file
190
packages/api/routes/api/oauth/token.ts
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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>);
|
||||||
|
|
|
||||||
|
|
@ -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", {
|
||||||
147
packages/api/routes/api/v1/sso/:id/index.ts
Normal file
147
packages/api/routes/api/v1/sso/:id/index.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
163
packages/api/routes/api/v1/sso/index.ts
Normal file
163
packages/api/routes/api/v1/sso/index.ts
Normal 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()}`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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,
|
||||||
})) ?? [],
|
}),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
62
packages/api/routes/well-known/jwks.ts
Normal file
62
packages/api/routes/well-known/jwks.ts
Normal 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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
|
|
@ -1,9 +0,0 @@
|
||||||
export enum Hooks {
|
|
||||||
Request = "request",
|
|
||||||
Response = "response",
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ServerHooks = {
|
|
||||||
[Hooks.Request]: (request: Request) => Request;
|
|
||||||
[Hooks.Response]: (response: Response) => Response;
|
|
||||||
};
|
|
||||||
|
|
@ -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";
|
|
||||||
|
|
|
||||||
|
|
@ -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`);
|
|
||||||
|
|
@ -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#"
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>>>();
|
|
||||||
|
|
@ -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");
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue