diff --git a/src/content/docs.md b/src/content/docs.md index b538fce..49b60b1 100644 --- a/src/content/docs.md +++ b/src/content/docs.md @@ -76,6 +76,8 @@ Alternatively, you can publish redirect URIs as HTML `` tags: > **Security:** If your `redirect_uri` uses a different host than your `client_id`, you MUST publish `redirect_uris` in your client metadata. This prevents unauthorized apps from hijacking your client_id. +> **Client ID Metadata Document (CIMD):** This URL-published metadata follows the OAuth Client ID Metadata Document draft. A few rules apply: the `client_id` in the document must exactly match the URL it's fetched from, the document must be under 5 KB, and it must not request a shared-secret `token_endpoint_auth_method` (like `client_secret_post`) — public URL-based clients have no secret. If you need a client secret, use [dynamic registration](#dynamic-client-registration-rfc-7591) instead. + ### for users You'll need an invite code to create an account. Once registered: diff --git a/src/lib/oauth/client-metadata.ts b/src/lib/oauth/client-metadata.ts index ef4fb3b..33d75e0 100644 --- a/src/lib/oauth/client-metadata.ts +++ b/src/lib/oauth/client-metadata.ts @@ -8,9 +8,76 @@ export interface ClientMetadata { client_uri?: string; logo_uri?: string; redirect_uris?: string[]; + token_endpoint_auth_method?: string; + [key: string]: unknown; +} + +// Per the Client ID Metadata Document draft, a public client publishing +// metadata at its URL must not claim a shared-secret auth method. +const SECRET_AUTH_METHODS = new Set([ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt", +]); + +// Draft recommends bounding the metadata document size. +const MAX_METADATA_BYTES = 5 * 1024; + +// Validate a fetched metadata document against the client_id URL it came +// from. Pure so it's directly testable without a live fetch. +export function validateMetadataDocument( + text: string, + clientId: string, +): { success: boolean; metadata?: ClientMetadata; error?: string } { + if (text.length > MAX_METADATA_BYTES) { + return { success: false, error: "Client metadata document too large" }; + } + + let metadata: ClientMetadata; + try { + metadata = JSON.parse(text) as ClientMetadata; + } catch { + return { success: false, error: "Invalid JSON in client metadata" }; + } + + if (metadata.client_id && metadata.client_id !== clientId) { + return { + success: false, + error: "client_id in metadata does not match URL", + }; + } + + // Public clients publishing metadata must not request a shared-secret + // auth method; there's no registration step to establish one. + if ( + metadata.token_endpoint_auth_method && + SECRET_AUTH_METHODS.has(metadata.token_endpoint_auth_method) + ) { + return { + success: false, + error: `token_endpoint_auth_method "${metadata.token_endpoint_auth_method}" is not allowed for URL-based clients`, + }; + } + + // Validate metadata URL fields to prevent SSRF via later fetches + if (metadata.logo_uri) { + const logoValidation = validateExternalURL(metadata.logo_uri); + if (!logoValidation.safe) { + delete metadata.logo_uri; + } + } + + if (metadata.client_uri) { + const clientUriValidation = validateExternalURL(metadata.client_uri); + if (!clientUriValidation.safe) { + delete metadata.client_uri; + } + } + + return { success: true, metadata }; } -// Fetch client metadata from client_id URL (with SSRF protection) export async function fetchClientMetadata(clientId: string): Promise<{ success: boolean; metadata?: ClientMetadata; @@ -52,35 +119,8 @@ export async function fetchClientMetadata(clientId: string): Promise<{ const contentType = response.headers.get("content-type") || ""; if (contentType.includes("application/json")) { - try { - const metadata = (await response.json()) as ClientMetadata; - - if (metadata.client_id && metadata.client_id !== clientId) { - return { - success: false, - error: "client_id in metadata does not match URL", - }; - } - - // Validate metadata URL fields to prevent SSRF via later fetches - if (metadata.logo_uri) { - const logoValidation = validateExternalURL(metadata.logo_uri); - if (!logoValidation.safe) { - delete metadata.logo_uri; - } - } - - if (metadata.client_uri) { - const clientUriValidation = validateExternalURL(metadata.client_uri); - if (!clientUriValidation.safe) { - delete metadata.client_uri; - } - } - - return { success: true, metadata }; - } catch { - return { success: false, error: "Invalid JSON in client metadata" }; - } + const text = await response.text(); + return validateMetadataDocument(text, clientId); } // HTML: look for tags diff --git a/test/client-metadata.test.ts b/test/client-metadata.test.ts new file mode 100644 index 0000000..e34ee84 --- /dev/null +++ b/test/client-metadata.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, test } from "bun:test"; +import { validateMetadataDocument } from "../src/lib/oauth/client-metadata"; + +const CID = "https://app.example/"; + +function doc(fields: Record): string { + return JSON.stringify({ client_id: CID, ...fields }); +} + +describe("validateMetadataDocument (Client ID Metadata Document)", () => { + test("accepts a valid document", () => { + const res = validateMetadataDocument( + doc({ client_name: "My App", redirect_uris: ["https://app.example/cb"] }), + CID, + ); + expect(res.success).toBe(true); + expect(res.metadata?.client_name).toBe("My App"); + }); + + test("rejects client_id mismatch", () => { + const res = validateMetadataDocument(doc({}), "https://other.example/"); + expect(res.success).toBe(false); + expect(res.error).toContain("does not match"); + }); + + test("rejects secret-based token_endpoint_auth_method", () => { + for (const method of [ + "client_secret_post", + "client_secret_basic", + "client_secret_jwt", + "private_key_jwt", + ]) { + const res = validateMetadataDocument( + doc({ token_endpoint_auth_method: method }), + CID, + ); + expect(res.success).toBe(false); + expect(res.error).toContain("not allowed"); + } + }); + + test("allows token_endpoint_auth_method none", () => { + const res = validateMetadataDocument( + doc({ token_endpoint_auth_method: "none" }), + CID, + ); + expect(res.success).toBe(true); + }); + + test("rejects oversized documents", () => { + const big = doc({ pad: "x".repeat(6000) }); + const res = validateMetadataDocument(big, CID); + expect(res.success).toBe(false); + expect(res.error).toContain("too large"); + }); + + test("rejects invalid JSON", () => { + const res = validateMetadataDocument("{ not json", CID); + expect(res.success).toBe(false); + expect(res.error).toContain("Invalid JSON"); + }); + + test("strips unsafe logo_uri and client_uri", () => { + const res = validateMetadataDocument( + doc({ + logo_uri: "http://169.254.169.254/latest", + client_uri: "file:///etc/passwd", + }), + CID, + ); + expect(res.success).toBe(true); + expect(res.metadata?.logo_uri).toBeUndefined(); + expect(res.metadata?.client_uri).toBeUndefined(); + }); +});