mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 08:28:19 +01:00
refactor(plugin): ♻️ Move parts of OpenID logic to plugin
This commit is contained in:
parent
69d7d50239
commit
d51bae52c6
|
|
@ -157,7 +157,7 @@ export default apiRoute((app) =>
|
||||||
// Try and import the key
|
// Try and import the key
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const privateKey = await crypto.subtle.importKey(
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["sign"],
|
["sign"],
|
||||||
|
|
|
||||||
|
|
@ -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());
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
@ -257,7 +257,7 @@ export default apiRoute((app) =>
|
||||||
// Try and import the key
|
// Try and import the key
|
||||||
const privateKey = await crypto.subtle.importKey(
|
const privateKey = await crypto.subtle.importKey(
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["sign"],
|
["sign"],
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ export default apiRoute((app) =>
|
||||||
app.on(meta.allowedMethods, meta.route, async (context) => {
|
app.on(meta.allowedMethods, meta.route, async (context) => {
|
||||||
const publicKey = await crypto.subtle.importKey(
|
const publicKey = await crypto.subtle.importKey(
|
||||||
"spki",
|
"spki",
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
true,
|
true,
|
||||||
["verify"],
|
["verify"],
|
||||||
|
|
|
||||||
16
app.ts
16
app.ts
|
|
@ -1,6 +1,7 @@
|
||||||
import { handleZodError } from "@/api";
|
import { handleZodError } from "@/api";
|
||||||
import { sentry } from "@/sentry";
|
import { sentry } from "@/sentry";
|
||||||
import { cors } from "@hono/hono/cors";
|
import { cors } from "@hono/hono/cors";
|
||||||
|
import { createMiddleware } from "@hono/hono/factory";
|
||||||
import { prettyJSON } from "@hono/hono/pretty-json";
|
import { prettyJSON } from "@hono/hono/pretty-json";
|
||||||
import { secureHeaders } from "@hono/hono/secure-headers";
|
import { secureHeaders } from "@hono/hono/secure-headers";
|
||||||
import { swaggerUI } from "@hono/swagger-ui";
|
import { swaggerUI } from "@hono/swagger-ui";
|
||||||
|
|
@ -9,6 +10,7 @@ import { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
*/ import { getLogger } from "@logtape/logtape";
|
*/ import { getLogger } from "@logtape/logtape";
|
||||||
import pkg from "~/package.json" with { type: "application/json" };
|
import pkg from "~/package.json" with { type: "application/json" };
|
||||||
import { config } from "~/packages/config-manager/index";
|
import { config } from "~/packages/config-manager/index";
|
||||||
|
import plugin from "~/plugins/openid";
|
||||||
import { agentBans } from "./middlewares/agent-bans";
|
import { agentBans } from "./middlewares/agent-bans";
|
||||||
import { bait } from "./middlewares/bait";
|
import { bait } from "./middlewares/bait";
|
||||||
import { boundaryCheck } from "./middlewares/boundary-check";
|
import { boundaryCheck } from "./middlewares/boundary-check";
|
||||||
|
|
@ -83,6 +85,14 @@ export const appFactory = async () => {
|
||||||
credentials: true,
|
credentials: true,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
app.use(
|
||||||
|
createMiddleware<HonoEnv>(async (context, next) => {
|
||||||
|
context.set("config", config);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
/* app.use("*", registerMetrics);
|
/* app.use("*", registerMetrics);
|
||||||
app.get("/metrics", printMetrics); */
|
app.get("/metrics", printMetrics); */
|
||||||
// Disabled as federation now checks for this
|
// Disabled as federation now checks for this
|
||||||
|
|
@ -100,6 +110,12 @@ export const appFactory = async () => {
|
||||||
route.default(app);
|
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", {
|
app.doc31("/openapi.json", {
|
||||||
openapi: "3.1.0",
|
openapi: "3.1.0",
|
||||||
info: {
|
info: {
|
||||||
|
|
|
||||||
8
build.ts
8
build.ts
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { readdir } from "node:fs/promises";
|
||||||
import { $ } from "bun";
|
import { $ } from "bun";
|
||||||
import ora from "ora";
|
import ora from "ora";
|
||||||
import { routes } from "~/routes";
|
import { routes } from "~/routes";
|
||||||
|
|
@ -6,12 +7,19 @@ const buildSpinner = ora("Building").start();
|
||||||
|
|
||||||
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 Bun.build({
|
await Bun.build({
|
||||||
entrypoints: [
|
entrypoints: [
|
||||||
"index.ts",
|
"index.ts",
|
||||||
"cli/index.ts",
|
"cli/index.ts",
|
||||||
// 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",
|
||||||
|
|
|
||||||
|
|
@ -237,22 +237,21 @@
|
||||||
},
|
},
|
||||||
"default": []
|
"default": []
|
||||||
},
|
},
|
||||||
"jwt_key": {
|
"keys": {
|
||||||
"anyOf": [
|
"type": "object",
|
||||||
{
|
"properties": {
|
||||||
|
"public": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"minLength": 3,
|
"minLength": 1
|
||||||
"pattern": "\\;",
|
|
||||||
"default": ""
|
|
||||||
},
|
},
|
||||||
{
|
"private": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"const": ""
|
"minLength": 1
|
||||||
}
|
}
|
||||||
]
|
},
|
||||||
|
"additionalProperties": false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["jwt_key"],
|
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"http": {
|
"http": {
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@
|
||||||
"icon": "https://github.com/versia-pub/server",
|
"icon": "https://github.com/versia-pub/server",
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"keywords": ["federated", "activitypub", "bun"],
|
"keywords": ["federated", "activitypub", "bun"],
|
||||||
"workspaces": ["packages/*"],
|
"workspaces": ["packages/plugin-kit"],
|
||||||
"maintainers": [
|
"maintainers": [
|
||||||
{
|
{
|
||||||
"email": "contact@cpluspatch.com",
|
"email": "contact@cpluspatch.com",
|
||||||
|
|
|
||||||
|
|
@ -122,7 +122,12 @@ export const configValidator = z.object({
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.default([]),
|
.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({
|
http: z.object({
|
||||||
base_url: z.string().min(1).default("http://versia.social"),
|
base_url: z.string().min(1).default("http://versia.social"),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Plugin } from "./plugin";
|
import { Hooks } from "./hooks";
|
||||||
|
import { Plugin, PluginConfigManager } from "./plugin";
|
||||||
import type { Manifest } from "./schema";
|
import type { Manifest } from "./schema";
|
||||||
|
|
||||||
export type { Manifest };
|
export type { Manifest };
|
||||||
export { Plugin };
|
export { Plugin, PluginConfigManager, Hooks };
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"name": "@versia-org/kit",
|
"name": "@versia/kit",
|
||||||
"module": "index.ts",
|
"module": "index.ts",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,23 @@
|
||||||
|
import { createMiddleware } from "@hono/hono/factory";
|
||||||
|
import type { OpenAPIHono } from "@hono/zod-openapi";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import { type ZodError, fromZodError } from "zod-validation-error";
|
import { type ZodError, fromZodError } from "zod-validation-error";
|
||||||
|
import type { HonoEnv } from "~/types/api";
|
||||||
import type { ServerHooks } from "./hooks";
|
import type { ServerHooks } from "./hooks";
|
||||||
import { type Manifest, manifestSchema } from "./schema";
|
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> {
|
export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
||||||
private handlers: Partial<ServerHooks> = {};
|
private handlers: Partial<ServerHooks> = {};
|
||||||
|
private routes: {
|
||||||
|
path: string;
|
||||||
|
fn: (app: OpenAPIHono<HonoPluginEnv<ConfigSchema>>) => void;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private manifest: Manifest,
|
private manifest: Manifest,
|
||||||
|
|
@ -13,20 +26,49 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
||||||
this.validateManifest(manifest);
|
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() {
|
public getManifest() {
|
||||||
return this.manifest;
|
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.
|
* Loads the plugin's configuration from the Versia Server configuration file.
|
||||||
* This will be called when the plugin is loaded.
|
* This will be called when the plugin is loaded.
|
||||||
* @param config Values the user has set in the configuration file.
|
* @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
|
// biome-ignore lint/complexity/useLiteralKeys: Private method
|
||||||
this.configManager["_load"](config);
|
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>(
|
public registerHandler<HookName extends keyof ServerHooks>(
|
||||||
hook: HookName,
|
hook: HookName,
|
||||||
handler: ServerHooks[HookName],
|
handler: ServerHooks[HookName],
|
||||||
|
|
@ -56,6 +98,7 @@ export class Plugin<ConfigSchema extends z.ZodTypeAny> {
|
||||||
* Handles loading, defining, and managing the plugin's configuration.
|
* Handles loading, defining, and managing the plugin's configuration.
|
||||||
* Plugins can define their own configuration schema, which is then used to
|
* Plugins can define their own configuration schema, which is then used to
|
||||||
* load it from the user's configuration file.
|
* load it from the user's configuration file.
|
||||||
|
* @param schema The Zod schema that defines the configuration.
|
||||||
*/
|
*/
|
||||||
export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
||||||
private store: z.infer<Schema> | null;
|
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.
|
* This will be called when the plugin is loaded.
|
||||||
* @param config Values the user has set in the configuration file.
|
* @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
|
// Check if the configuration is valid
|
||||||
try {
|
try {
|
||||||
this.schema.parse(config);
|
this.store = await this.schema.parseAsync(config);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw fromZodError(error as ZodError);
|
throw fromZodError(error as ZodError);
|
||||||
}
|
}
|
||||||
|
|
@ -82,6 +125,10 @@ export class PluginConfigManager<Schema extends z.ZodTypeAny> {
|
||||||
* Returns the internal configuration object.
|
* Returns the internal configuration object.
|
||||||
*/
|
*/
|
||||||
public getConfig() {
|
public getConfig() {
|
||||||
|
if (!this.store) {
|
||||||
|
throw new Error("Configuration has not been loaded yet.");
|
||||||
|
}
|
||||||
|
|
||||||
return this.store;
|
return this.store;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
77
plugins/openid/index.ts
Normal file
77
plugins/openid/index.ts
Normal 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;
|
||||||
309
plugins/openid/routes/authorize.ts
Normal file
309
plugins/openid/routes/authorize.ts
Normal 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());
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Application } from "~/classes/functions/application";
|
import type { Application } from "~/classes/functions/application";
|
||||||
import type { RolePermissions } from "~/drizzle/schema";
|
import type { RolePermissions } from "~/drizzle/schema";
|
||||||
|
import type { Config } from "~/packages/config-manager";
|
||||||
import type { User as DatabaseUser } from "~/packages/database-interface/user";
|
import type { User as DatabaseUser } from "~/packages/database-interface/user";
|
||||||
|
|
||||||
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
export type HttpVerb = "GET" | "POST" | "PUT" | "DELETE" | "PATCH" | "OPTIONS";
|
||||||
|
|
@ -51,6 +52,7 @@ export const ErrorSchema = z.object({
|
||||||
|
|
||||||
export type HonoEnv = {
|
export type HonoEnv = {
|
||||||
Variables: {
|
Variables: {
|
||||||
|
config: Config;
|
||||||
auth: {
|
auth: {
|
||||||
user: DatabaseUser | null;
|
user: DatabaseUser | null;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
|
|
|
||||||
|
|
@ -78,9 +78,9 @@ const checkChallengeConfig = async (config: Config) => {
|
||||||
const checkOidcConfig = async (config: Config) => {
|
const checkOidcConfig = async (config: Config) => {
|
||||||
const logger = getLogger("server");
|
const logger = getLogger("server");
|
||||||
|
|
||||||
if (!config.oidc.jwt_key) {
|
if (!(config.oidc.keys?.private && config.oidc.keys?.public)) {
|
||||||
logger.fatal`The JWT private key is not set in the config`;
|
logger.fatal`The OpenID keys are not set in the config`;
|
||||||
logger.fatal`Below is a generated key for you to copy in the config at oidc.jwt_key`;
|
logger.fatal`Below are generated key for you to copy in the config at oidc.keys`;
|
||||||
|
|
||||||
// Generate a key for them
|
// Generate a key for them
|
||||||
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
const keys = await crypto.subtle.generateKey("Ed25519", true, [
|
||||||
|
|
@ -96,7 +96,9 @@ const checkOidcConfig = async (config: Config) => {
|
||||||
await crypto.subtle.exportKey("spki", keys.publicKey),
|
await crypto.subtle.exportKey("spki", keys.publicKey),
|
||||||
).toString("base64");
|
).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
|
// Hang until Ctrl+C is pressed
|
||||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
|
@ -106,7 +108,7 @@ const checkOidcConfig = async (config: Config) => {
|
||||||
const privateKey = await crypto.subtle
|
const privateKey = await crypto.subtle
|
||||||
.importKey(
|
.importKey(
|
||||||
"pkcs8",
|
"pkcs8",
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
|
Buffer.from(config.oidc.keys?.private ?? "", "base64"),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["sign"],
|
["sign"],
|
||||||
|
|
@ -117,7 +119,7 @@ const checkOidcConfig = async (config: Config) => {
|
||||||
const publicKey = await crypto.subtle
|
const publicKey = await crypto.subtle
|
||||||
.importKey(
|
.importKey(
|
||||||
"spki",
|
"spki",
|
||||||
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
|
Buffer.from(config.oidc.keys?.public ?? "", "base64"),
|
||||||
"Ed25519",
|
"Ed25519",
|
||||||
false,
|
false,
|
||||||
["verify"],
|
["verify"],
|
||||||
|
|
@ -125,7 +127,7 @@ const checkOidcConfig = async (config: Config) => {
|
||||||
.catch((e) => e as Error);
|
.catch((e) => e as Error);
|
||||||
|
|
||||||
if (privateKey instanceof Error || publicKey instanceof 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
|
// Hang until Ctrl+C is pressed
|
||||||
await Bun.sleep(Number.POSITIVE_INFINITY);
|
await Bun.sleep(Number.POSITIVE_INFINITY);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue