mirror of
https://github.com/versia-pub/server.git
synced 2025-12-06 16:38:19 +01:00
feat(api): ✨ Make Lysand a full OAuth2/OpenID Connect provider as well as still Mastodon compatible
This commit is contained in:
parent
f9f4a99cb9
commit
5cb48b2f3b
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
3
drizzle/0016_keen_mindworm.sql
Normal file
3
drizzle/0016_keen_mindworm.sql
Normal 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 '';
|
||||||
2
drizzle/0017_dusty_black_knight.sql
Normal file
2
drizzle/0017_dusty_black_knight.sql
Normal 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);
|
||||||
2
drizzle/0018_rapid_hairball.sql
Normal file
2
drizzle/0018_rapid_hairball.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE "Tokens" ALTER COLUMN "client_id" SET DEFAULT '';
|
||||||
|
ALTER TABLE "Tokens" ALTER COLUMN "redirect_uri" SET DEFAULT '';
|
||||||
1
drizzle/0019_mushy_lorna_dane.sql
Normal file
1
drizzle/0019_mushy_lorna_dane.sql
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE "Tokens" ADD COLUMN "id_token" text;
|
||||||
1781
drizzle/meta/0016_snapshot.json
Normal file
1781
drizzle/meta/0016_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1983
drizzle/meta/0017_snapshot.json
Normal file
1983
drizzle/meta/0017_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1787
drizzle/meta/0018_snapshot.json
Normal file
1787
drizzle/meta/0018_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
1991
drizzle/meta/0019_snapshot.json
Normal file
1991
drizzle/meta/0019_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
67
index.ts
67
index.ts
|
|
@ -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(
|
||||||
|
|
|
||||||
203
package.json
203
package.json
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
16
server.ts
16
server.ts
|
|
@ -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 (
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
289
server/api/oauth/authorize/index.ts
Normal file
289
server/api/oauth/authorize/index.ts
Normal 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",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -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");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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"],
|
||||||
|
|
|
||||||
42
server/api/well-known/jwks/index.ts
Normal file
42
server/api/well-known/jwks/index.ts
Normal 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",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
32
server/api/well-known/openid-configuration/index.ts
Normal file
32
server/api/well-known/openid-configuration/index.ts
Normal 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"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue