refactor(plugin): ♻️ Move parts of OpenID logic to plugin

This commit is contained in:
Jesse Wierzbinski 2024-08-29 20:32:04 +02:00
parent 69d7d50239
commit d51bae52c6
No known key found for this signature in database
17 changed files with 494 additions and 395 deletions

View file

@ -157,7 +157,7 @@ export default apiRoute((app) =>
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
"Ed25519",
false,
["sign"],

View file

@ -1,367 +0,0 @@
import { apiRoute, applyConfig, handleZodError, jsonOrForm } from "@/api";
import { randomString } from "@/math";
import { sentry } from "@/sentry";
import { zValidator } from "@hono/zod-validator";
import type { Context } from "hono";
import { SignJWT, jwtVerify } from "jose";
import { z } from "zod";
import { TokenType } from "~/classes/functions/token";
import { db } from "~/drizzle/db";
import { RolePermissions, Tokens } from "~/drizzle/schema";
import { config } from "~/packages/config-manager";
import { User } from "~/packages/database-interface/user";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/oauth/authorize",
auth: {
required: false,
},
});
export const schemas = {
query: z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z.coerce
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
json: z.object({
scope: z.string().optional(),
redirect_uri: z
.string()
.url()
.optional()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
}),
};
const returnError = (
context: Context,
data: object,
error: string,
description: string,
) => {
const searchParams = new URLSearchParams();
// Add all data that is not undefined except email and password
for (const [key, value] of Object.entries(data)) {
if (key !== "email" && key !== "password" && value !== undefined) {
searchParams.append(key, value);
}
}
searchParams.append("error", error);
searchParams.append("error_description", description);
return context.redirect(
`${config.frontend.routes.login}?${searchParams.toString()}`,
);
};
export default apiRoute((app) =>
app.on(
meta.allowedMethods,
meta.route,
jsonOrForm(),
zValidator("query", schemas.query, handleZodError),
zValidator("json", schemas.json, handleZodError),
async (context) => {
const { scope, redirect_uri, response_type, client_id, state } =
context.req.valid("json");
const body = context.req.valid("json");
const cookie = context.req.header("Cookie");
if (!cookie) {
return returnError(
context,
body,
"invalid_request",
"No cookies were sent with the request",
);
}
const jwt = cookie
.split(";")
.find((c) => c.trim().startsWith("jwt="))
?.split("=")[1];
if (!jwt) {
return returnError(
context,
body,
"invalid_request",
"No jwt cookie was sent in the request",
);
}
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
true,
["sign"],
);
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
true,
["verify"],
);
const result = await jwtVerify(jwt, publicKey, {
algorithms: ["EdDSA"],
issuer: new URL(config.http.base_url).origin,
audience: client_id,
}).catch((e) => {
console.error(e);
sentry?.captureException(e);
return null;
});
if (!result) {
return returnError(
context,
body,
"invalid_request",
"Invalid JWT, could not verify",
);
}
const payload = result.payload;
if (!payload.sub) {
return returnError(
context,
body,
"invalid_request",
"Invalid sub",
);
}
if (!payload.aud) {
return returnError(
context,
body,
"invalid_request",
"Invalid aud",
);
}
if (!payload.exp) {
return returnError(
context,
body,
"invalid_request",
"Invalid exp",
);
}
// Check if the user is authenticated
const user = await User.fromId(payload.sub);
if (!user) {
return returnError(
context,
body,
"invalid_request",
"Invalid sub",
);
}
if (!user.hasPermission(RolePermissions.OAuth)) {
return returnError(
context,
body,
"invalid_request",
`User is missing the ${RolePermissions.OAuth} permission`,
);
}
const responseTypes = response_type.split(" ");
const asksCode = responseTypes.includes("code");
const asksToken = responseTypes.includes("token");
const asksIdToken = responseTypes.includes("id_token");
if (!(asksCode || asksToken || asksIdToken)) {
return returnError(
context,
body,
"invalid_request",
"Invalid response_type, must ask for code, token, or id_token",
);
}
if (asksCode && !redirect_uri) {
return returnError(
context,
body,
"invalid_request",
"Redirect URI is required for code flow (can be urn:ietf:wg:oauth:2.0:oob)",
);
}
/* if (asksCode && !code_challenge)
return returnError(
"invalid_request",
"Code challenge is required for code flow",
);
if (asksCode && !code_challenge_method)
return returnError(
"invalid_request",
"Code challenge method is required for code flow",
); */
// Authenticate the user
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application) {
return returnError(
context,
body,
"invalid_client",
"Invalid client_id or client_secret",
);
}
if (application.redirectUri !== redirect_uri) {
return returnError(
context,
body,
"invalid_request",
"Redirect URI does not match client_id",
);
}
// Validate scopes, they can either be equal or a subset of the application's scopes
const applicationScopes = application.scopes.split(" ");
if (
scope &&
!scope.split(" ").every((s) => applicationScopes.includes(s))
) {
return returnError(
context,
body,
"invalid_scope",
"Invalid scope",
);
}
// Generate tokens
const code = randomString(256, "base64url");
// Handle the requested scopes
let idTokenPayload = {};
const scopeIncludesOpenId = scope?.split(" ").includes("openid");
const scopeIncludesProfile = scope?.split(" ").includes("profile");
const scopeIncludesEmail = scope?.split(" ").includes("email");
if (scope) {
if (scopeIncludesOpenId) {
// Include the standard OpenID claims
idTokenPayload = {
...idTokenPayload,
sub: user.id,
aud: client_id,
iss: new URL(config.http.base_url).origin,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60 * 60,
};
}
if (scopeIncludesProfile) {
// Include the user's profile information
idTokenPayload = {
...idTokenPayload,
name: user.data.displayName,
preferred_username: user.data.username,
picture: user.getAvatarUrl(config),
updated_at: new Date(user.data.updatedAt).toISOString(),
};
}
if (scopeIncludesEmail) {
// Include the user's email address
idTokenPayload = {
...idTokenPayload,
email: user.data.email,
// TODO: Add verification system
email_verified: true,
};
}
}
const idToken = await new SignJWT(idTokenPayload)
.setProtectedHeader({
alg: "EdDSA",
})
.sign(privateKey);
await db.insert(Tokens).values({
accessToken: randomString(64, "base64url"),
code: code,
scope: scope ?? application.scopes,
tokenType: TokenType.Bearer,
applicationId: application.id,
redirectUri: redirect_uri ?? application.redirectUri,
expiresAt: new Date(
Date.now() + 60 * 60 * 24 * 14,
).toISOString(),
idToken:
scopeIncludesOpenId ||
scopeIncludesEmail ||
scopeIncludesProfile
? idToken
: null,
clientId: client_id,
userId: user.id,
});
// Redirect to the client
const redirectUri =
redirect_uri === "urn:ietf:wg:oauth:2.0:oob"
? new URL("/oauth/code", config.http.base_url)
: new URL(redirect_uri ?? application.redirectUri);
const searchParams = new URLSearchParams({
code: code,
});
if (state) {
searchParams.append("state", state);
}
redirectUri.search = searchParams.toString();
return context.redirect(redirectUri.toString());
},
),
);

View file

@ -257,7 +257,7 @@ export default apiRoute((app) =>
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
"Ed25519",
false,
["sign"],

View file

@ -18,7 +18,7 @@ export default apiRoute((app) =>
app.on(meta.allowedMethods, meta.route, async (context) => {
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
"Ed25519",
true,
["verify"],

16
app.ts
View file

@ -1,6 +1,7 @@
import { handleZodError } from "@/api";
import { sentry } from "@/sentry";
import { cors } from "@hono/hono/cors";
import { createMiddleware } from "@hono/hono/factory";
import { prettyJSON } from "@hono/hono/pretty-json";
import { secureHeaders } from "@hono/hono/secure-headers";
import { swaggerUI } from "@hono/swagger-ui";
@ -9,6 +10,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
*/ import { getLogger } from "@logtape/logtape";
import pkg from "~/package.json" with { type: "application/json" };
import { config } from "~/packages/config-manager/index";
import plugin from "~/plugins/openid";
import { agentBans } from "./middlewares/agent-bans";
import { bait } from "./middlewares/bait";
import { boundaryCheck } from "./middlewares/boundary-check";
@ -83,6 +85,14 @@ export const appFactory = async () => {
credentials: true,
}),
);
app.use(
createMiddleware<HonoEnv>(async (context, next) => {
context.set("config", config);
await next();
}),
);
/* app.use("*", registerMetrics);
app.get("/metrics", printMetrics); */
// Disabled as federation now checks for this
@ -100,6 +110,12 @@ export const appFactory = async () => {
route.default(app);
}
// @ts-expect-error We check if the keys are valid before this is called
// biome-ignore lint/complexity/useLiteralKeys: loadConfig is a private method
plugin["_loadConfig"](config.oidc);
// biome-ignore lint/complexity/useLiteralKeys: AddToApp is a private method
plugin["_addToApp"](app);
app.doc31("/openapi.json", {
openapi: "3.1.0",
info: {

View file

@ -1,3 +1,4 @@
import { readdir } from "node:fs/promises";
import { $ } from "bun";
import ora from "ora";
import { routes } from "~/routes";
@ -6,12 +7,19 @@ const buildSpinner = ora("Building").start();
await $`rm -rf dist && mkdir dist`;
// Get all directories under the plugins/ directory
const pluginDirs = await readdir("plugins", { withFileTypes: true });
await Bun.build({
entrypoints: [
"index.ts",
"cli/index.ts",
// Force Bun to include endpoints
...Object.values(routes),
// Include all plugins
...pluginDirs
.filter((dir) => dir.isDirectory())
.map((dir) => `plugins/${dir.name}/index.ts`),
],
outdir: "dist",
target: "bun",

BIN
bun.lockb

Binary file not shown.

View file

@ -237,22 +237,21 @@
},
"default": []
},
"jwt_key": {
"anyOf": [
{
"keys": {
"type": "object",
"properties": {
"public": {
"type": "string",
"minLength": 3,
"pattern": "\\;",
"default": ""
"minLength": 1
},
{
"private": {
"type": "string",
"const": ""
}
]
"minLength": 1
}
},
"additionalProperties": false
}
},
"required": ["jwt_key"],
"additionalProperties": false
},
"http": {

View file

@ -15,7 +15,7 @@
"icon": "https://github.com/versia-pub/server",
"license": "AGPL-3.0-or-later",
"keywords": ["federated", "activitypub", "bun"],
"workspaces": ["packages/*"],
"workspaces": ["packages/plugin-kit"],
"maintainers": [
{
"email": "contact@cpluspatch.com",

View file

@ -122,7 +122,12 @@ export const configValidator = z.object({
}),
)
.default([]),
jwt_key: z.string().min(3).includes(";").default("").or(z.literal("")),
keys: z
.object({
public: z.string().min(1).optional(),
private: z.string().min(1).optional(),
})
.optional(),
}),
http: z.object({
base_url: z.string().min(1).default("http://versia.social"),

View file

@ -1,5 +1,6 @@
import { Plugin } from "./plugin";
import { Hooks } from "./hooks";
import { Plugin, PluginConfigManager } from "./plugin";
import type { Manifest } from "./schema";
export type { Manifest };
export { Plugin };
export { Plugin, PluginConfigManager, Hooks };

View file

@ -1,5 +1,5 @@
{
"name": "@versia-org/kit",
"name": "@versia/kit",
"module": "index.ts",
"type": "module",
"version": "0.0.0",

View file

@ -1,10 +1,23 @@
import { createMiddleware } from "@hono/hono/factory";
import type { OpenAPIHono } from "@hono/zod-openapi";
import type { z } from "zod";
import { type ZodError, fromZodError } from "zod-validation-error";
import type { HonoEnv } from "~/types/api";
import type { ServerHooks } from "./hooks";
import { type Manifest, manifestSchema } from "./schema";
export type HonoPluginEnv<ConfigType extends z.ZodTypeAny> = HonoEnv & {
Variables: {
pluginConfig: z.infer<ConfigType>;
};
};
export class Plugin<ConfigSchema extends z.ZodTypeAny> {
private handlers: Partial<ServerHooks> = {};
private routes: {
path: string;
fn: (app: OpenAPIHono<HonoPluginEnv<ConfigSchema>>) => void;
}[] = [];
constructor(
private manifest: Manifest,
@ -13,20 +26,49 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
this.validateManifest(manifest);
}
get middleware() {
// Middleware that adds the plugin's configuration to the request object
return createMiddleware<HonoPluginEnv<ConfigSchema>>(
async (context, next) => {
context.set("pluginConfig", this.configManager.getConfig());
await next();
},
);
}
public getManifest() {
return this.manifest;
}
public registerRoute(
path: string,
fn: (app: OpenAPIHono<HonoPluginEnv<ConfigSchema>>) => void,
) {
this.routes.push({
path,
fn,
});
}
/**
* Loads the plugin's configuration from the Versia Server configuration file.
* This will be called when the plugin is loaded.
* @param config Values the user has set in the configuration file.
*/
protected _loadConfig(config: z.infer<ConfigSchema>) {
protected _loadConfig(config: z.input<ConfigSchema>) {
// biome-ignore lint/complexity/useLiteralKeys: Private method
this.configManager["_load"](config);
}
protected _addToApp(app: OpenAPIHono<HonoEnv>) {
for (const route of this.routes) {
app.use(route.path, this.middleware);
route.fn(
app as unknown as OpenAPIHono<HonoPluginEnv<ConfigSchema>>,
);
}
}
public registerHandler<HookName extends keyof ServerHooks>(
hook: HookName,
handler: ServerHooks[HookName],
@ -56,6 +98,7 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
* Handles loading, defining, and managing the plugin's configuration.
* Plugins can define their own configuration schema, which is then used to
* load it from the user's configuration file.
* @param schema The Zod schema that defines the configuration.
*/
export class PluginConfigManager<Schema extends z.ZodTypeAny> {
private store: z.infer<Schema> | null;
@ -69,10 +112,10 @@ export class PluginConfigManager<Schema extends z.ZodTypeAny> {
* This will be called when the plugin is loaded.
* @param config Values the user has set in the configuration file.
*/
protected _load(config: z.infer<Schema>) {
protected async _load(config: z.infer<Schema>) {
// Check if the configuration is valid
try {
this.schema.parse(config);
this.store = await this.schema.parseAsync(config);
} catch (error) {
throw fromZodError(error as ZodError);
}
@ -82,6 +125,10 @@ export class PluginConfigManager<Schema extends z.ZodTypeAny> {
* Returns the internal configuration object.
*/
public getConfig() {
if (!this.store) {
throw new Error("Configuration has not been loaded yet.");
}
return this.store;
}
}

77
plugins/openid/index.ts Normal file
View file

@ -0,0 +1,77 @@
import { Hooks, type Manifest, Plugin, PluginConfigManager } from "@versia/kit";
import { z } from "zod";
import authorizeRoute from "./routes/authorize";
const myManifest: Manifest = {
name: "@versia/openid",
description: "OpenID authentication.",
version: "0.1.0",
};
const configManager = new PluginConfigManager(
z.object({
forced: z.boolean().default(false),
allow_registration: z.boolean().default(true),
providers: z
.array(
z.object({
name: z.string().min(1),
id: z.string().min(1),
url: z.string().min(1),
client_id: z.string().min(1),
client_secret: z.string().min(1),
icon: z.string().min(1).optional(),
}),
)
.default([]),
keys: z.object({
public: z
.string()
.min(1)
.transform(async (v) => {
try {
return await crypto.subtle.importKey(
"spki",
Buffer.from(v, "base64"),
"Ed25519",
true,
["verify"],
);
} catch {
throw new Error(
"Public key at oidc.keys.public is invalid",
);
}
}),
private: z
.string()
.min(1)
.transform(async (v) => {
try {
return await crypto.subtle.importKey(
"pkcs8",
Buffer.from(v, "base64"),
"Ed25519",
true,
["sign"],
);
} catch {
throw new Error(
"Private key at oidc.keys.private is invalid",
);
}
}),
}),
}),
);
const plugin = new Plugin(myManifest, configManager);
plugin.registerHandler(Hooks.Response, (req) => {
console.info("Request received:", req);
return req;
});
authorizeRoute(plugin);
export type PluginType = typeof plugin;
export default plugin;

View file

@ -0,0 +1,309 @@
import { auth, jsonOrForm } from "@/api";
import { randomString } from "@/math";
import { type JWTPayload, SignJWT, jwtVerify } from "jose";
import { JOSEError } from "jose/errors";
import { z } from "zod";
import { TokenType } from "~/classes/functions/token";
import { db } from "~/drizzle/db";
import { RolePermissions, Tokens } from "~/drizzle/schema";
import { User } from "~/packages/database-interface/user";
import type { PluginType } from "../index";
const schemas = {
query: z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z.coerce
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
}),
json: z
.object({
scope: z.string().optional(),
redirect_uri: z
.string()
.url()
.optional()
.or(z.literal("urn:ietf:wg:oauth:2.0:oob")),
response_type: z.enum([
"code",
"token",
"none",
"id_token",
"code id_token",
"code token",
"token id_token",
"code token id_token",
]),
client_id: z.string(),
state: z.string().optional(),
code_challenge: z.string().optional(),
code_challenge_method: z.enum(["plain", "S256"]).optional(),
})
.refine(
// Check if redirect_uri is valid for code flow
(data) =>
data.response_type.includes("code") ? data.redirect_uri : true,
"redirect_uri is required for code flow",
),
// Disable for Mastodon API compatibility
/* .refine(
// Check if code_challenge is valid for code flow
(data) =>
data.response_type.includes("code")
? data.code_challenge
: true,
"code_challenge is required for code flow",
), */
cookies: z.object({
jwt: z.string(),
}),
};
export default (plugin: PluginType) =>
plugin.registerRoute("/oauth/authorize", (app) =>
app.openapi(
{
method: "post",
path: "/oauth/authorize",
middleware: [
auth({
required: false,
}),
jsonOrForm(),
plugin.middleware,
],
responses: {
302: {
description: "Redirect to the application",
},
},
request: {
query: schemas.query,
body: {
content: {
"application/json": {
schema: schemas.json,
},
"application/x-www-form-urlencoded": {
schema: schemas.json,
},
"multipart/form-data": {
schema: schemas.json,
},
},
},
cookies: schemas.cookies,
},
},
async (context) => {
const { scope, redirect_uri, client_id, state } =
context.req.valid("json");
const { jwt } = context.req.valid("cookie");
const { keys } = context.get("pluginConfig");
const errorSearchParams = new URLSearchParams(
Object.fromEntries(
Object.entries(context.req.valid("json")).filter(
([k, v]) =>
v !== undefined &&
k !== "password" &&
k !== "email",
),
),
);
const result = await jwtVerify(jwt, keys.public, {
algorithms: ["EdDSA"],
audience: client_id,
issuer: new URL(context.get("config").http.base_url).origin,
}).catch((error) => {
if (error instanceof JOSEError) {
return null;
}
throw error;
});
if (!result) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"Invalid JWT, could not verify",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
const {
payload: { aud, sub, exp },
} = result;
if (!(aud && sub && exp)) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"Invalid JWT, missing required fields (aud, sub, exp)",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
const user = await User.fromId(sub);
if (!user) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"Invalid JWT, could not find associated user",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
if (!user.hasPermission(RolePermissions.OAuth)) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
`User is missing the required permission ${RolePermissions.OAuth}`,
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"Invalid client_id: no associated application found",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
if (application.redirectUri !== redirect_uri) {
errorSearchParams.append("error", "invalid_request");
errorSearchParams.append(
"error_description",
"Invalid redirect_uri: does not match application's redirect_uri",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
// Check that scopes are a subset of the application's scopes
if (
scope &&
!scope
.split(" ")
.every((s) => application.scopes.includes(s))
) {
errorSearchParams.append("error", "invalid_scope");
errorSearchParams.append(
"error_description",
"Invalid scope: not a subset of the application's scopes",
);
return context.redirect(
`${context.get("config").frontend.routes.login}?${errorSearchParams.toString()}`,
);
}
const code = randomString(256, "base64url");
let payload: JWTPayload = {};
if (scope) {
if (scope.split(" ").includes("openid")) {
payload = {
...payload,
sub: user.id,
iss: new URL(context.get("config").http.base_url)
.origin,
aud: client_id,
exp: Math.floor(Date.now() / 1000) + 60 * 60,
iat: Math.floor(Date.now() / 1000),
nbf: Math.floor(Date.now() / 1000),
};
}
if (scope.split(" ").includes("profile")) {
payload = {
...payload,
name: user.data.displayName,
preferred_username: user.data.username,
picture: user.getAvatarUrl(context.get("config")),
updated_at: new Date(
user.data.updatedAt,
).toISOString(),
};
}
if (scope.split(" ").includes("email")) {
payload = {
...payload,
email: user.data.email,
// TODO: Add verification system
email_verified: true,
};
}
}
const idToken = await new SignJWT(payload)
.setProtectedHeader({ alg: "EdDSA" })
.sign(keys.private);
await db.insert(Tokens).values({
accessToken: randomString(64, "base64url"),
code: code,
scope: scope ?? application.scopes,
tokenType: TokenType.Bearer,
applicationId: application.id,
redirectUri: redirect_uri ?? application.redirectUri,
expiresAt: new Date(
Date.now() + 60 * 60 * 24 * 14,
).toISOString(),
idToken: ["profile", "email", "openid"].some((s) =>
scope?.split(" ").includes(s),
)
? idToken
: null,
clientId: client_id,
userId: user.id,
});
const redirectUri =
redirect_uri === "urn:ietf:wg:oauth:2.0:oob"
? new URL(
"/oauth/code",
context.get("config").http.base_url,
)
: new URL(redirect_uri ?? application.redirectUri);
redirectUri.searchParams.append("code", code);
state && redirectUri.searchParams.append("state", state);
return context.redirect(redirectUri.toString());
},
),
);

View file

@ -14,6 +14,7 @@ import type {
import { z } from "zod";
import type { Application } from "~/classes/functions/application";
import type { RolePermissions } from "~/drizzle/schema";
import type { Config } from "~/packages/config-manager";
import type { User as DatabaseUser } from "~/packages/database-interface/user";
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
@ -51,6 +52,7 @@ export const ErrorSchema = z.object({
export type HonoEnv = {
Variables: {
config: Config;
auth: {
user: DatabaseUser | null;
token: string | null;

View file

@ -78,9 +78,9 @@ const checkChallengeConfig = async (config: Config) => {
const checkOidcConfig = async (config: Config) => {
const logger = getLogger("server");
if (!config.oidc.jwt_key) {
logger.fatal`The JWT private key is not set in the config`;
logger.fatal`Below is a generated key for you to copy in the config at oidc.jwt_key`;
if (!(config.oidc.keys?.private && config.oidc.keys?.public)) {
logger.fatal`The OpenID keys are not set in the config`;
logger.fatal`Below are generated key for you to copy in the config at oidc.keys`;
// Generate a key for them
const keys = await crypto.subtle.generateKey("Ed25519", true, [
@ -96,7 +96,9 @@ const checkOidcConfig = async (config: Config) => {
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
logger.fatal`Generated key: ${chalk.gray(`${privateKey};${publicKey}`)}`;
logger.fatal`Generated keys:`;
logger.fatal`Private key: ${chalk.gray(privateKey)}`;
logger.fatal`Public key: ${chalk.gray(publicKey)}`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);
@ -106,7 +108,7 @@ const checkOidcConfig = async (config: Config) => {
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
"Ed25519",
false,
["sign"],
@ -117,7 +119,7 @@ const checkOidcConfig = async (config: Config) => {
const publicKey = await crypto.subtle
.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
"Ed25519",
false,
["verify"],
@ -125,7 +127,7 @@ const checkOidcConfig = async (config: Config) => {
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
logger.fatal`The JWT key could not be imported! You may generate a new one by removing the old one from the config and restarting the server (this will invalidate all current JWTs).`;
logger.fatal`The OpenID keys could not be imported! You may generate a new one by removing the old ones from config and restarting the server (this will invalidate all current JWTs).`;
// Hang until Ctrl+C is pressed
await Bun.sleep(Number.POSITIVE_INFINITY);