feat(api): Make Lysand a full OAuth2/OpenID Connect provider as well as still Mastodon compatible

This commit is contained in:
Jesse Wierzbinski 2024-04-17 22:42:12 -10:00
parent f9f4a99cb9
commit 5cb48b2f3b
No known key found for this signature in database
29 changed files with 8466 additions and 279 deletions

BIN
bun.lockb

Binary file not shown.

View file

@ -48,6 +48,10 @@ rules = [
"Don't post illegal content", "Don't post illegal content",
] ]
[oidc]
# Run Lysand with this value missing to generate a new key
jwt_key = ""
# Delete this section if you don't want to use custom OAuth providers # Delete this section if you don't want to use custom OAuth providers
# This is an example configuration # This is an example configuration
# The provider MUST support OpenID Connect with .well-known discovery # The provider MUST support OpenID Connect with .well-known discovery

View file

@ -19,7 +19,7 @@ export const objectToInboxRequest = async (
const privateKey = await crypto.subtle.importKey( const privateKey = await crypto.subtle.importKey(
"pkcs8", "pkcs8",
Uint8Array.from(atob(author.privateKey ?? ""), (c) => c.charCodeAt(0)), Buffer.from(author.privateKey ?? "", "base64"),
"Ed25519", "Ed25519",
false, false,
["sign"], ["sign"],

View file

@ -729,22 +729,13 @@ export const generateUserKeys = async () => {
"verify", "verify",
]); ]);
const privateKey = btoa( const privateKey = Buffer.from(
String.fromCharCode.apply(null, [ await crypto.subtle.exportKey("pkcs8", keys.privateKey),
...new Uint8Array( ).toString("base64");
// jesus help me what do these letters mean
await crypto.subtle.exportKey("pkcs8", keys.privateKey), const publicKey = Buffer.from(
), await crypto.subtle.exportKey("spki", keys.publicKey),
]), ).toString("base64");
);
const publicKey = btoa(
String.fromCharCode(
...new Uint8Array(
// why is exporting a key so hard
await crypto.subtle.exportKey("spki", keys.publicKey),
),
),
);
// Add header, footer and newlines later on // Add header, footer and newlines later on
// These keys are base64 encrypted // These keys are base64 encrypted

View file

@ -0,0 +1,3 @@
ALTER TABLE "Applications" RENAME COLUMN "redirect_uris" TO "redirect_uri";--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "client_id" text NOT NULL DEFAULT '';--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "redirect_uri" text NOT NULL DEFAULT '';

View file

@ -0,0 +1,2 @@
ALTER TABLE "Tokens" ALTER COLUMN "code" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "Tokens" ADD COLUMN "expires_at" timestamp(3);

View file

@ -0,0 +1,2 @@
ALTER TABLE "Tokens" ALTER COLUMN "client_id" SET DEFAULT '';
ALTER TABLE "Tokens" ALTER COLUMN "redirect_uri" SET DEFAULT '';

View file

@ -0,0 +1 @@
ALTER TABLE "Tokens" ADD COLUMN "id_token" text;

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -113,6 +113,34 @@
"when": 1713399438164, "when": 1713399438164,
"tag": "0015_easy_mojo", "tag": "0015_easy_mojo",
"breakpoints": true "breakpoints": true
},
{
"idx": 16,
"version": "5",
"when": 1713413369623,
"tag": "0016_keen_mindworm",
"breakpoints": true
},
{
"idx": 17,
"version": "5",
"when": 1713417089150,
"tag": "0017_dusty_black_knight",
"breakpoints": true
},
{
"idx": 18,
"version": "5",
"when": 1713418575392,
"tag": "0018_rapid_hairball",
"breakpoints": true
},
{
"idx": 19,
"version": "5",
"when": 1713421706451,
"tag": "0019_mushy_lorna_dane",
"breakpoints": true
} }
] ]
} }

View file

@ -187,7 +187,7 @@ export const Applications = pgTable(
clientId: text("client_id").notNull(), clientId: text("client_id").notNull(),
secret: text("secret").notNull(), secret: text("secret").notNull(),
scopes: text("scopes").notNull(), scopes: text("scopes").notNull(),
redirectUris: text("redirect_uris").notNull(), redirectUri: text("redirect_uri").notNull(),
}, },
(table) => { (table) => {
return { return {
@ -206,10 +206,14 @@ export const Tokens = pgTable("Tokens", {
tokenType: text("token_type").notNull(), tokenType: text("token_type").notNull(),
scope: text("scope").notNull(), scope: text("scope").notNull(),
accessToken: text("access_token").notNull(), accessToken: text("access_token").notNull(),
code: text("code").notNull(), code: text("code"),
expiresAt: timestamp("expires_at", { precision: 3, mode: "string" }),
createdAt: timestamp("created_at", { precision: 3, mode: "string" }) createdAt: timestamp("created_at", { precision: 3, mode: "string" })
.defaultNow() .defaultNow()
.notNull(), .notNull(),
clientId: text("client_id").notNull().default(""),
redirectUri: text("redirect_uri").notNull().default(""),
idToken: text("id_token"),
userId: uuid("userId") userId: uuid("userId")
.references(() => Users.id, { .references(() => Users.id, {
onDelete: "cascade", onDelete: "cascade",

View file

@ -43,6 +43,73 @@ try {
process.exit(1); process.exit(1);
} }
if (isEntry) {
// Check if JWT private key is set in config
if (!config.oidc.jwt_key) {
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"The JWT private key is not set in the config",
);
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"Below is a generated key for you to copy in the config at oidc.jwt_private_key",
);
// Generate a key for them
const keys = await crypto.subtle.generateKey("Ed25519", true, [
"sign",
"verify",
]);
const privateKey = Buffer.from(
await crypto.subtle.exportKey("pkcs8", keys.privateKey),
).toString("base64");
const publicKey = Buffer.from(
await crypto.subtle.exportKey("spki", keys.publicKey),
).toString("base64");
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
`${privateKey};${publicKey}`,
);
process.exit(1);
}
// Try and import the key
const privateKey = await crypto.subtle
.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
false,
["sign"],
)
.catch((e) => e as Error);
// Try and import the key
const publicKey = await crypto.subtle
.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
false,
["verify"],
)
.catch((e) => e as Error);
if (privateKey instanceof Error || publicKey instanceof Error) {
await dualServerLogger.log(
LogLevel.CRITICAL,
"Server",
"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).",
);
process.exit(1);
}
}
const server = createServer(config, dualServerLogger, true); const server = createServer(config, dualServerLogger, true);
await dualServerLogger.log( await dualServerLogger.log(

View file

@ -1,104 +1,105 @@
{ {
"name": "lysand", "name": "lysand",
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"version": "0.4.0", "version": "0.4.0",
"description": "A project to build a federated social network", "description": "A project to build a federated social network",
"author": { "author": {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
"name": "CPlusPatch", "name": "CPlusPatch",
"url": "https://cpluspatch.com" "url": "https://cpluspatch.com"
}, },
"bugs": { "bugs": {
"url": "https://github.com/lysand-org/lysand/issues" "url": "https://github.com/lysand-org/lysand/issues"
}, },
"icon": "https://github.com/lysand-org/lysand", "icon": "https://github.com/lysand-org/lysand",
"license": "AGPL-3.0", "license": "AGPL-3.0",
"keywords": ["federated", "activitypub", "bun"], "keywords": ["federated", "activitypub", "bun"],
"workspaces": ["packages/*"], "workspaces": ["packages/*"],
"maintainers": [ "maintainers": [
{ {
"email": "contact@cpluspatch.com", "email": "contact@cpluspatch.com",
"name": "CPlusPatch", "name": "CPlusPatch",
"url": "https://cpluspatch.com" "url": "https://cpluspatch.com"
}
],
"repository": {
"type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"vite:dev": "bunx --bun vite pages",
"vite:build": "bunx --bun vite build pages",
"fe:dev": "bun --bun nuxt dev packages/frontend",
"fe:build": "bun --bun nuxt build packages/frontend",
"fe:analyze": "bun --bun nuxt analyze packages/frontend",
"start": "NITRO_PORT=5173 bun run dist/frontend/server/index.mjs & NODE_ENV=production bun run dist/index.js --prod",
"lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bun run build.ts",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@fortawesome/fontawesome-common-types",
"@fortawesome/free-regular-svg-icons",
"@fortawesome/free-solid-svg-icons",
"es5-ext",
"esbuild",
"json-editor-vue",
"msgpackr-extract",
"nuxt-app",
"sharp",
"vue-demi"
],
"devDependencies": {
"@biomejs/biome": "^1.7.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@types/mime-types": "^2.1.4",
"@types/pg": "^8.11.5",
"bun-types": "latest",
"drizzle-kit": "^0.20.14",
"typescript": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@json2csv/plainjs": "^7.0.6",
"blurhash": "^2.0.5",
"bullmq": "^5.7.1",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"drizzle-orm": "^0.30.7",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"magic-regexp": "^0.8.0",
"marked": "^12.0.1",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "^0.38.0",
"mime-types": "^2.1.35",
"oauth4webapi": "^2.4.0",
"pg": "^8.11.5",
"request-parser": "workspace:*",
"sharp": "^0.33.3",
"zod": "^3.22.4"
} }
],
"repository": {
"type": "git",
"url": "git+https://github.com/lysand-org/lysand.git"
},
"private": true,
"scripts": {
"dev": "bun run --watch index.ts",
"vite:dev": "bunx --bun vite pages",
"vite:build": "bunx --bun vite build pages",
"fe:dev": "bun --bun nuxt dev packages/frontend",
"fe:build": "bun --bun nuxt build packages/frontend",
"fe:analyze": "bun --bun nuxt analyze packages/frontend",
"start": "NITRO_PORT=5173 bun run dist/frontend/server/index.mjs & NODE_ENV=production bun run dist/index.js --prod",
"lint": "bunx --bun eslint --config .eslintrc.cjs --ext .ts .",
"prod-build": "bun run build.ts",
"benchmark:timeline": "bun run benchmarks/timelines.ts",
"cloc": "cloc . --exclude-dir node_modules,dist,.output,.nuxt,meta,logs --exclude-ext sql,log",
"cli": "bun run cli.ts"
},
"trustedDependencies": [
"@biomejs/biome",
"@fortawesome/fontawesome-common-types",
"@fortawesome/free-regular-svg-icons",
"@fortawesome/free-solid-svg-icons",
"es5-ext",
"esbuild",
"json-editor-vue",
"msgpackr-extract",
"nuxt-app",
"sharp",
"vue-demi"
],
"devDependencies": {
"@biomejs/biome": "^1.7.0",
"@types/cli-table": "^0.3.4",
"@types/html-to-text": "^9.0.4",
"@types/ioredis": "^5.0.0",
"@types/jsonld": "^1.5.13",
"@types/mime-types": "^2.1.4",
"@types/pg": "^8.11.5",
"bun-types": "latest",
"drizzle-kit": "^0.20.14",
"typescript": "latest"
},
"peerDependencies": {
"typescript": "^5.3.2"
},
"dependencies": {
"@json2csv/plainjs": "^7.0.6",
"blurhash": "^2.0.5",
"bullmq": "^5.7.1",
"chalk": "^5.3.0",
"cli-parser": "workspace:*",
"cli-table": "^0.3.11",
"config-manager": "workspace:*",
"drizzle-orm": "^0.30.7",
"extract-zip": "^2.0.1",
"html-to-text": "^9.0.5",
"ioredis": "^5.3.2",
"ip-matching": "^2.1.2",
"iso-639-1": "^3.1.0",
"isomorphic-dompurify": "latest",
"jose": "^5.2.4",
"linkify-html": "^4.1.3",
"linkify-string": "^4.1.3",
"linkifyjs": "^4.1.3",
"log-manager": "workspace:*",
"magic-regexp": "^0.8.0",
"marked": "^12.0.1",
"media-manager": "workspace:*",
"megalodon": "^10.0.0",
"meilisearch": "^0.38.0",
"mime-types": "^2.1.35",
"oauth4webapi": "^2.4.0",
"pg": "^8.11.5",
"request-parser": "workspace:*",
"sharp": "^0.33.3",
"zod": "^3.22.4"
}
} }

View file

@ -89,6 +89,8 @@ export interface Config {
client_secret: string; client_secret: string;
icon: string; icon: string;
}[]; }[];
jwt_key: string;
}; };
http: { http: {
@ -447,6 +449,7 @@ export const defaultConfig: Config = {
}, },
oidc: { oidc: {
providers: [], providers: [],
jwt_key: "",
}, },
http: { http: {
base_url: "https://lysand.social", base_url: "https://lysand.social",

View file

@ -137,23 +137,14 @@ export class RequestParser {
* @throws Error if body is invalid * @throws Error if body is invalid
*/ */
private async parseFormUrlencoded<T>(): Promise<Partial<T>> { private async parseFormUrlencoded<T>(): Promise<Partial<T>> {
const formData = await this.request.formData(); const parsed = parse(await this.request.text(), {
const result: Partial<T> = {}; parseArrays: true,
interpretNumericEntities: true,
});
for (const [key, value] of formData.entries()) { return castBooleanObject(
if (key.endsWith("[]")) { parsed as PossiblyRecursiveObject,
const arrayKey = key.slice(0, -2) as keyof T; ) as Partial<T>;
if (!result[arrayKey]) {
result[arrayKey] = [] as T[keyof T];
}
(result[arrayKey] as FormDataEntryValue[]).push(value);
} else {
result[key as keyof T] = value as T[keyof T];
}
}
return result;
} }
/** /**

View file

@ -19,8 +19,8 @@ for (const [route, path] of Object.entries(routes)) {
export { routes }; export { routes };
export const matchRoute = (url: string) => { export const matchRoute = (request: Request) => {
const route = routeMatcher.match(url); const route = routeMatcher.match(request);
return route ?? null; return route ?? null;
}; };

View file

@ -1,5 +1,6 @@
import { dualLogger } from "@loggers"; import { dualLogger } from "@loggers";
import { errorResponse, response } from "@response"; import { errorResponse, response } from "@response";
import type { MatchedRoute } from "bun";
import type { Config } from "config-manager"; import type { Config } from "config-manager";
import { matches } from "ip-matching"; import { matches } from "ip-matching";
import type { LogManager, MultiLogManager } from "log-manager"; import type { LogManager, MultiLogManager } from "log-manager";
@ -129,10 +130,19 @@ export const createServer = (
// If route is .well-known, remove dot because the filesystem router can't handle dots for some reason // If route is .well-known, remove dot because the filesystem router can't handle dots for some reason
const matchedRoute = matchRoute( const matchedRoute = matchRoute(
req.url.replace(".well-known", "well-known"), new Request(req.url.replace(".well-known", "well-known"), {
method: req.method,
}),
); );
if (matchedRoute?.filePath && matchedRoute.name !== "/[...404]") { if (
matchedRoute?.filePath &&
matchedRoute.name !== "/[...404]" &&
!(
new URL(req.url).pathname.startsWith("/oauth/authorize") &&
req.method === "GET"
)
) {
return await processRoute(matchedRoute, req, logger); return await processRoute(matchedRoute, req, logger);
} }
@ -164,8 +174,6 @@ export const createServer = (
return null; return null;
}); });
console.log(proxy);
if (!proxy || proxy.status === 404) { if (!proxy || proxy.status === 404) {
if (config.frontend.glitch.enabled) { if (config.frontend.glitch.enabled) {
return ( return (

View file

@ -1,10 +1,13 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig } from "@api";
import { z } from "zod"; import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User"; import { findFirstUser } from "~database/entities/User";
import { SignJWT } from "jose";
import { config } from "~packages/config-manager";
import { errorResponse, response } from "@response";
import { stringify } from "qs";
import { fromZodError } from "zod-validation-error";
import { RequestParser } from "~packages/request-parser";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -20,76 +23,139 @@ export const meta = applyConfig({
export const schema = z.object({ export const schema = z.object({
email: z.string().email(), email: z.string().email(),
password: z.string().max(100).min(3), password: z.string().min(2).max(100),
}); });
export const querySchema = z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
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(),
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
});
const returnError = (query: object, error: string, description: string) =>
response(null, 302, {
Location: `/oauth/authorize?${stringify({
...query,
error,
error_description: description,
})}`,
});
/** /**
* OAuth Code flow * Login flow
*/ */
export default apiRoute<typeof meta, typeof schema>( export default apiRoute(async (req, matchedRoute, extraData) => {
async (req, matchedRoute, extraData) => { const { email, password } = extraData.parsedRequest;
const scopes = (matchedRoute.query.scope || "")
.replaceAll("+", " ")
.split(" ");
const redirect_uri = matchedRoute.query.redirect_uri;
const response_type = matchedRoute.query.response_type;
const client_id = matchedRoute.query.client_id;
const { email, password } = extraData.parsedRequest; if (!email || !password)
return returnError(
const redirectToLogin = (error: string) => extraData.parsedRequest,
Response.redirect( "invalid_request",
`/oauth/authorize?${new URLSearchParams({ "Missing email or password",
...matchedRoute.query,
error: encodeURIComponent(error),
}).toString()}`,
302,
);
if (response_type !== "code")
return redirectToLogin("Invalid response_type");
if (!email || !password)
return redirectToLogin("Invalid username or password");
const user = await findFirstUser({
where: (user, { eq }) => eq(user.email, email),
});
if (
!user ||
!(await Bun.password.verify(password, user.password || ""))
)
return redirectToLogin("Invalid username or password");
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application) return redirectToLogin("Invalid client_id");
const code = randomBytes(32).toString("hex");
await db.insert(Tokens).values({
accessToken: randomBytes(64).toString("base64url"),
code: code,
scope: scopes.join(" "),
tokenType: TokenType.BEARER,
applicationId: application.id,
userId: user.id,
});
// Redirect to OAuth confirmation screen
return Response.redirect(
`/oauth/redirect?${new URLSearchParams({
redirect_uri,
code,
client_id,
application: application.name,
website: application.website ?? "",
scope: scopes.join(" "),
}).toString()}`,
302,
); );
},
); // Find user
const user = await findFirstUser({
where: (user, { eq }) => eq(user.email, email),
});
if (!user || !(await Bun.password.verify(password, user.password || "")))
return returnError(
extraData.parsedRequest,
"invalid_request",
"Invalid email or password",
);
const parsedQuery = await new RequestParser(
new Request(req.url),
).toObject();
if (!parsedQuery) {
return errorResponse("Invalid query", 400);
}
const parsingResult = querySchema.safeParse(parsedQuery);
if (parsingResult && !parsingResult.success) {
// Return a 422 error with the first error message
return errorResponse(fromZodError(parsingResult.error).toString(), 422);
}
const { client_id } = parsingResult.data;
// Try and import the key
const privateKey = await crypto.subtle.importKey(
"pkcs8",
Buffer.from(config.oidc.jwt_key.split(";")[0], "base64"),
"Ed25519",
false,
["sign"],
);
// Generate JWT
const jwt = await new SignJWT({
sub: user.id,
iss: new URL(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),
})
.setProtectedHeader({ alg: "EdDSA" })
.sign(privateKey);
const application = await db.query.Applications.findFirst({
where: (app, { eq }) => eq(app.clientId, client_id),
});
if (!application) {
return errorResponse("Invalid application", 400);
}
const searchParams = new URLSearchParams({
application: application.name,
client_secret: application.secret,
});
if (application.website)
searchParams.append("website", application.website);
// Add all data that is not undefined
for (const [key, value] of Object.entries(parsingResult.data)) {
if (value !== undefined) searchParams.append(key, String(value));
}
// Redirect to OAuth authorize with JWT
return response(null, 302, {
Location: new URL(
`/oauth/redirect?${searchParams.toString()}`,
config.http.base_url,
).toString(),
// Set cookie with JWT
"Set-Cookie": `jwt=${jwt}; HttpOnly; Secure; SameSite=Strict; Path=/; Max-Age=${
60 * 60
}`,
});
});

View file

@ -37,7 +37,7 @@ export default apiRoute<typeof meta, typeof schema>(
.insert(Applications) .insert(Applications)
.values({ .values({
name: client_name || "", name: client_name || "",
redirectUris: redirect_uris || "", redirectUri: redirect_uris || "",
scopes: scopes || "read", scopes: scopes || "read",
website: website || null, website: website || null,
clientId: randomBytes(32).toString("base64url"), clientId: randomBytes(32).toString("base64url"),
@ -52,7 +52,7 @@ export default apiRoute<typeof meta, typeof schema>(
website: app.website, website: app.website,
client_id: app.clientId, client_id: app.clientId,
client_secret: app.secret, client_secret: app.secret,
redirect_uri: app.redirectUris, redirect_uri: app.redirectUri,
vapid_link: app.vapidKey, vapid_link: app.vapidKey,
}); });
}, },

View file

@ -31,7 +31,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
name: application.name, name: application.name,
website: application.website, website: application.website,
vapid_key: application.vapidKey, vapid_key: application.vapidKey,
redirect_uris: application.redirectUris, redirect_uris: application.redirectUri,
scopes: application.scopes, scopes: application.scopes,
}); });
}); });

View file

@ -0,0 +1,289 @@
import { randomBytes } from "node:crypto";
import { apiRoute, applyConfig, idValidator } from "@api";
import { z } from "zod";
import { TokenType } from "~database/entities/Token";
import { findFirstUser } from "~database/entities/User";
import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { response } from "@response";
import { jwtVerify, SignJWT } from "jose";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["POST"],
ratelimits: {
max: 4,
duration: 60,
},
route: "/oauth/authorize",
auth: {
required: false,
},
});
export const schema = z.object({
scope: z.string().optional(),
redirect_uri: z.string().url().optional(),
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(),
});
export const querySchema = z.object({
prompt: z
.enum(["none", "login", "consent", "select_account"])
.optional()
.default("none"),
max_age: z
.number()
.int()
.optional()
.default(60 * 60 * 24 * 7),
});
const returnError = (error: string, description: string) =>
response(null, 302, {
Location: new URL(
`/oauth/authorize?${new URLSearchParams({
error: error,
error_description: description,
}).toString()}`,
config.http.base_url,
).toString(),
});
/**
* OIDC Authorization
*/
export default apiRoute<typeof meta, typeof schema>(
async (req, matchedRoute, extraData) => {
const {
scope,
redirect_uri,
response_type,
client_id,
state,
code_challenge,
code_challenge_method,
} = extraData.parsedRequest;
const cookie = req.headers.get("Cookie");
if (!cookie)
return returnError(
"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(
"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);
return null;
});
if (!result)
return returnError(
"invalid_request",
"Invalid JWT, could not verify",
);
const payload = result.payload;
if (!payload.sub) return returnError("invalid_request", "Invalid sub");
if (!payload.aud) return returnError("invalid_request", "Invalid aud");
if (!payload.exp) return returnError("invalid_request", "Invalid exp");
// Check if the user is authenticated
const user = await findFirstUser({
where: (user, { eq }) => eq(user.id, payload.sub ?? ""),
});
if (!user) return returnError("invalid_request", "Invalid sub");
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(
"invalid_request",
"Invalid response_type, must ask for code, token, or id_token",
);
if (asksCode && !redirect_uri)
return returnError(
"invalid_request",
"Redirect URI is required for code flow",
);
/* 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(
"invalid_client",
"Invalid client_id or client_secret",
);
if (application.redirectUri !== redirect_uri)
return returnError(
"invalid_request",
"Redirect URI does not match client_id",
);
/* if (application.slate !== slate)
return returnError("invalid_request", "Invalid slate"); */
// 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("invalid_scope", "Invalid scope");
// Generate tokens
const code = randomBytes(256).toString("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) {
const scopes = scope.split(" ");
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.displayName,
preferred_username: user.username,
picture: user.avatar,
updated_at: new Date(user.updatedAt).toISOString(),
};
}
if (scopeIncludesEmail) {
// Include the user's email address
idTokenPayload = {
...idTokenPayload,
email: user.email,
email_verified: true,
};
}
}
const idToken = await new SignJWT(idTokenPayload)
.setProtectedHeader({
alg: "EdDSA",
})
.sign(privateKey);
await db.insert(Tokens).values({
accessToken: randomBytes(64).toString("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 = new URL(redirect_uri ?? application.redirectUri);
const searchParams = new URLSearchParams({
code: code,
scope: scope ?? application.scopes,
token_type: "Bearer",
client_id: client_id,
});
if (state) searchParams.set("state", state);
return response(null, 302, {
Location: `${redirectUri.origin}${
redirectUri.pathname
}?${searchParams.toString()}`,
"Cache-Control": "no-store",
Pragma: "no-cache",
});
},
);

View file

@ -1,7 +1,10 @@
import { apiRoute, applyConfig } from "@api"; import { apiRoute, applyConfig, idValidator } from "@api";
import { errorResponse, jsonResponse } from "@response"; import { jsonResponse } from "@response";
import { eq } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "~drizzle/db"; import { db } from "~drizzle/db";
import { Tokens } from "~drizzle/schema";
import { config } from "~packages/config-manager";
export const meta = applyConfig({ export const meta = applyConfig({
allowedMethods: ["POST"], allowedMethods: ["POST"],
@ -16,14 +19,43 @@ export const meta = applyConfig({
}); });
export const schema = z.object({ export const schema = z.object({
grant_type: z.string(), code: z.string().optional(),
code: z.string(), code_verifier: z.string().optional(),
redirect_uri: z.string().url(), grant_type: z.enum([
client_id: z.string(), "authorization_code",
client_secret: z.string(), "refresh_token",
scope: z.string(), "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",
]),
client_id: z.string().optional(),
client_secret: z.string().optional(),
username: z.string().optional(),
password: z.string().optional(),
redirect_uri: z.string().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(),
}); });
const returnError = (error: string, description: string) =>
jsonResponse(
{
error,
error_description: description,
},
401,
);
/** /**
* Allows getting token from OAuth code * Allows getting token from OAuth code
*/ */
@ -33,50 +65,78 @@ export default apiRoute<typeof meta, typeof schema>(
grant_type, grant_type,
code, code,
redirect_uri, redirect_uri,
scope,
client_id, client_id,
client_secret, client_secret,
scope,
} = extraData.parsedRequest; } = extraData.parsedRequest;
if (grant_type !== "authorization_code") switch (grant_type) {
return errorResponse( case "authorization_code": {
"Invalid grant type (try 'authorization_code')", if (!code) {
422, return returnError("invalid_request", "Code is required");
); }
// Get associated token if (!redirect_uri) {
const application = await db.query.Applications.findFirst({ return returnError(
where: (application, { eq, and }) => "invalid_request",
and( "Redirect URI is required",
eq(application.clientId, client_id), );
eq(application.secret, client_secret), }
eq(application.redirectUris, redirect_uri),
eq(application.scopes, scope?.replaceAll("+", " ")),
),
});
if (!application) if (!client_id) {
return errorResponse( return returnError(
"Invalid client credentials (missing application)", "invalid_client",
401, "Client ID is required",
); );
}
const token = await db.query.Tokens.findFirst({ // Verify the client_secret
where: (token, { eq }) => const client = await db.query.Applications.findFirst({
eq(token.code, code) && eq(token.applicationId, application.id), where: (application, { eq }) =>
}); eq(application.clientId, client_id),
});
if (!token) if (!client || client.secret !== client_secret) {
return errorResponse( return returnError(
"Invalid access token or client credentials", "invalid_client",
401, "Invalid client credentials",
); );
}
return jsonResponse({ const token = await db.query.Tokens.findFirst({
access_token: token.accessToken, where: (token, { eq, and }) =>
token_type: token.tokenType, and(
scope: token.scope, eq(token.code, code),
created_at: new Date(token.createdAt).getTime(), eq(token.redirectUri, redirect_uri),
}); eq(token.clientId, client_id),
),
});
if (!token) {
return returnError("invalid_grant", "Code not found");
}
// Invalidate the code
await db
.update(Tokens)
.set({ code: null })
.where(eq(Tokens.id, token.id));
return jsonResponse({
access_token: token.accessToken,
token_type: "Bearer",
expires_in: token.expiresAt
? (new Date(token.expiresAt).getTime() - Date.now()) /
1000
: null,
id_token: token.idToken,
refresh_token: null,
scope: token.scope,
created_at: new Date(token.createdAt).toISOString(),
});
}
}
return returnError("unsupported_grant_type", "Unsupported grant type");
}, },
); );

View file

@ -86,7 +86,7 @@ export default apiRoute(async (req, matchedRoute, extraData) => {
const public_key = await crypto.subtle.importKey( const public_key = await crypto.subtle.importKey(
"spki", "spki",
Uint8Array.from(atob(sender.publicKey), (c) => c.charCodeAt(0)), Buffer.from(sender.publicKey, "base64"),
"Ed25519", "Ed25519",
false, false,
["verify"], ["verify"],

View file

@ -0,0 +1,42 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { config } from "~packages/config-manager";
import { exportJWK, createRemoteJWKSet } from "jose";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 30,
max: 60,
},
route: "/.well-known/jwks",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const publicKey = await crypto.subtle.importKey(
"spki",
Buffer.from(config.oidc.jwt_key.split(";")[1], "base64"),
"Ed25519",
true,
["verify"],
);
const jwk = await exportJWK(publicKey);
// Remove the private key
jwk.d = undefined;
return jsonResponse({
keys: [
{
...jwk,
use: "sig",
alg: "EdDSA",
kid: "1",
},
],
});
});

View file

@ -0,0 +1,32 @@
import { apiRoute, applyConfig } from "@api";
import { jsonResponse } from "@response";
import { config } from "~packages/config-manager";
export const meta = applyConfig({
allowedMethods: ["GET"],
auth: {
required: false,
},
ratelimits: {
duration: 30,
max: 60,
},
route: "/.well-known/openid-configuration",
});
export default apiRoute(async (req, matchedRoute, extraData) => {
const base_url = new URL(config.http.base_url);
return jsonResponse({
issuer: base_url.origin.toString(),
authorization_endpoint: `${base_url.origin}/oauth/authorize`,
token_endpoint: `${base_url.origin}/oauth/token`,
userinfo_endpoint: `${base_url.origin}/api/v1/accounts/verify_credentials`,
jwks_uri: `${base_url.origin}/.well-known/jwks`,
response_types_supported: ["code"],
subject_types_supported: ["public"],
id_token_signing_alg_values_supported: ["EdDSA"],
scopes_supported: ["openid", "profile", "email"],
token_endpoint_auth_methods_supported: ["client_secret_basic"],
claims_supported: ["sub"],
});
});

View file

@ -13,6 +13,7 @@ const base_url = "http://lysand.localhost:8080"; //config.http.base_url;
let client_id: string; let client_id: string;
let client_secret: string; let client_secret: string;
let code: string; let code: string;
let jwt: string;
let token: APIToken; let token: APIToken;
const { users, passwords, deleteUsers } = await getTestUsers(1); const { users, passwords, deleteUsers } = await getTestUsers(1);
@ -57,7 +58,7 @@ describe("POST /api/v1/apps/", () => {
}); });
describe("POST /api/auth/login/", () => { describe("POST /api/auth/login/", () => {
test("should get a code", async () => { test("should get a JWT", async () => {
const formData = new FormData(); const formData = new FormData();
formData.append("email", users[0]?.email ?? ""); formData.append("email", users[0]?.email ?? "");
@ -77,33 +78,80 @@ describe("POST /api/auth/login/", () => {
); );
expect(response.status).toBe(302); expect(response.status).toBe(302);
expect(response.headers.get("Location")).toMatch( expect(response.headers.get("location")).toBeDefined();
/^\/oauth\/redirect\?redirect_uri=https%3A%2F%2Fexample.com&code=[a-f0-9]+&client_id=[a-zA-Z0-9_-]+&application=Test\+Application&website=https%3A%2F%2Fexample.com&scope=read\+write$/, const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
); );
code = expect(locationHeader.pathname).toBe("/oauth/redirect");
new URL( expect(locationHeader.searchParams.get("client_id")).toBe(client_id);
response.headers.get("Location") ?? "", expect(locationHeader.searchParams.get("redirect_uri")).toBe(
"http://lysand.localhost:8080", "https://example.com",
).searchParams.get("code") ?? ""; );
expect(locationHeader.searchParams.get("response_type")).toBe("code");
expect(locationHeader.searchParams.get("scope")).toBe("read write");
expect(response.headers.get("Set-Cookie")).toMatch(/jwt=[^;]+;/);
jwt =
response.headers.get("Set-Cookie")?.match(/jwt=([^;]+);/)?.[1] ??
"";
});
});
describe("POST /oauth/authorize/", () => {
test("should get a code", async () => {
const response = await sendTestRequest(
new Request(wrapRelativeUrl("/oauth/authorize", base_url), {
method: "POST",
headers: {
Cookie: `jwt=${jwt}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
client_id,
client_secret,
redirect_uri: "https://example.com",
response_type: "code",
scope: "read write",
max_age: "604800",
}),
}),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBeDefined();
const locationHeader = new URL(
response.headers.get("Location") ?? "",
"",
);
expect(locationHeader.origin).toBe("https://example.com");
expect(locationHeader.searchParams.get("client_id")).toBe(client_id);
expect(locationHeader.searchParams.get("scope")).toBe("read write");
code = locationHeader.searchParams.get("code") ?? "";
}); });
}); });
describe("POST /oauth/token/", () => { describe("POST /oauth/token/", () => {
test("should get an access token", async () => { test("should get an access token", async () => {
const formData = new FormData();
formData.append("grant_type", "authorization_code");
formData.append("code", code);
formData.append("redirect_uri", "https://example.com");
formData.append("client_id", client_id);
formData.append("client_secret", client_secret);
formData.append("scope", "read+write");
const response = await sendTestRequest( const response = await sendTestRequest(
new Request(wrapRelativeUrl("/oauth/token/", base_url), { new Request(wrapRelativeUrl("/oauth/token/", base_url), {
method: "POST", method: "POST",
body: formData, headers: {
Authorization: `Bearer ${jwt}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: "https://example.com",
client_id,
client_secret,
scope: "read write",
}),
}), }),
); );
@ -115,7 +163,10 @@ describe("POST /oauth/token/", () => {
access_token: expect.any(String), access_token: expect.any(String),
token_type: "Bearer", token_type: "Bearer",
scope: "read write", scope: "read write",
created_at: expect.any(Number), created_at: expect.any(String),
expires_in: expect.any(Number),
id_token: null,
refresh_token: null,
}); });
token = json; token = json;