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();
+ });
+});