diff --git a/package-lock.json b/package-lock.json index b159421..00433e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,8 @@ "typescript": "^5.9.3" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "@types/node": "^25.8.0" }, "engines": { "node": ">=22.12.0" @@ -2260,6 +2261,16 @@ "@types/unist": "*" } }, + "node_modules/@types/node": { + "version": "25.8.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz", + "integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -5619,6 +5630,13 @@ "node": ">=18.17" } }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/unicode-segmenter": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.14.5.tgz", diff --git a/package.json b/package.json index 3bf01b2..41d6e57 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "typescript": "^5.9.3" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "@types/node": "^25.8.0" } } diff --git a/src/lib/opensocial/keys.ts b/src/lib/opensocial/keys.ts new file mode 100644 index 0000000..336ae50 --- /dev/null +++ b/src/lib/opensocial/keys.ts @@ -0,0 +1,54 @@ +import { createPrivateKey, createPublicKey, type KeyObject } from "node:crypto"; + +const DEFAULT_KID = "opensocial-cimd-1"; + +function loadPrivatePem(): string { + const b64 = process.env.OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64; + if (b64 && b64.length > 0) { + return Buffer.from(b64, "base64").toString("utf-8"); + } + const pem = process.env.OPENSOCIAL_CIMD_PRIVATE_KEY_PEM; + if (pem && pem.length > 0) { + return pem.includes("\\n") ? pem.replaceAll("\\n", "\n") : pem; + } + throw new Error( + "OPENSOCIAL_CIMD_PRIVATE_KEY_BASE64 (or _PEM) not set. " + + "Generate one with: `node scripts/generate-cimd-key.mjs`", + ); +} + +let cachedPrivate: KeyObject | null = null; +export function getPrivateKey(): KeyObject { + if (!cachedPrivate) cachedPrivate = createPrivateKey(loadPrivatePem()); + return cachedPrivate; +} + +let cachedPublicJwk: PublicJwk | null = null; +export function getPublicJwk(): PublicJwk { + if (cachedPublicJwk) return cachedPublicJwk; + const pub = createPublicKey(getPrivateKey()); + const jwk = pub.export({ format: "jwk" }) as { + kty: string; + crv?: string; + x?: string; + }; + cachedPublicJwk = { + ...jwk, + kid: getKid(), + use: "sig", + } as PublicJwk; + return cachedPublicJwk; +} + +export function getKid(): string { + const kid = process.env.OPENSOCIAL_CIMD_KID; + return kid && kid.length > 0 ? kid : DEFAULT_KID; +} + +export interface PublicJwk { + kty: string; + crv?: string; + x?: string; + kid: string; + use: "sig"; +} diff --git a/src/pages/.well-known/client-metadata.json.ts b/src/pages/.well-known/client-metadata.json.ts new file mode 100644 index 0000000..ecd43c0 --- /dev/null +++ b/src/pages/.well-known/client-metadata.json.ts @@ -0,0 +1,25 @@ +import type { APIRoute } from "astro"; + +import { getPublicJwk } from "../../lib/opensocial/keys"; + +export const prerender = false; + +const CLIENT_ID = + "https://atmosphere.community/.well-known/client-metadata.json"; +const CLIENT_NAME = "atmosphere.community"; + +export const GET: APIRoute = () => { + const jwk = getPublicJwk(); + const body = { + client_id: CLIENT_ID, + client_name: CLIENT_NAME, + jwks: { keys: [jwk] }, + }; + return new Response(JSON.stringify(body), { + status: 200, + headers: { + "Content-Type": "application/json", + "Cache-Control": "public, max-age=300", + }, + }); +};