From 2417879cf6b2cb5c97ab679b83df0038760e045d Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 15 Oct 2026 04:58:52 +0100 Subject: [PATCH] feat(oauth-provider): let extensions validate their client registration metadata (#11767) --- .changeset/cimd-extension-client-metadata.md | 5 + ...auth-provider-client-metadata-extension.md | 9 + docs/content/docs/plugins/cimd.mdx | 2 +- docs/content/docs/plugins/oauth-provider.mdx | 26 +- .../e2e/client-metadata-extension.spec.ts | 210 +++++++++++++ packages/cimd/src/client-store.ts | 2 + packages/cimd/src/flows.test.ts | 293 +++++++++++++++++- packages/cimd/src/resolver.ts | 2 + .../src/validate-metadata-document.test.ts | 34 ++ .../cimd/src/validate-metadata-document.ts | 18 +- .../oauth-provider/src/client-metadata.ts | 4 + .../oauth-provider/src/extensions.test.ts | 150 +++++++++ packages/oauth-provider/src/extensions.ts | 60 ++++ packages/oauth-provider/src/internal.ts | 2 + packages/oauth-provider/src/oauth.ts | 5 +- .../src/oauthClient/endpoints.ts | 16 +- packages/oauth-provider/src/register.ts | 220 ++++++++++--- packages/oauth-provider/src/types/index.ts | 37 +++ 18 files changed, 1038 insertions(+), 57 deletions(-) create mode 100644 .changeset/cimd-extension-client-metadata.md create mode 100644 .changeset/oauth-provider-client-metadata-extension.md create mode 100644 e2e/integration/vanilla-node/e2e/client-metadata-extension.spec.ts diff --git a/.changeset/cimd-extension-client-metadata.md b/.changeset/cimd-extension-client-metadata.md new file mode 100644 index 0000000000..979b3d2ae9 --- /dev/null +++ b/.changeset/cimd-extension-client-metadata.md @@ -0,0 +1,5 @@ +--- +"@better-auth/cimd": minor +--- + +Metadata documents can now carry registration fields that an OAuth provider extension declares, such as CIBA's `backchannel_token_delivery_mode`. They are stored in the client's `metadata` and validated by the extension. A refresh replaces them, so a field removed from the document is removed from the client. Other unknown members are still ignored. Setups without such an extension are unaffected. diff --git a/.changeset/oauth-provider-client-metadata-extension.md b/.changeset/oauth-provider-client-metadata-extension.md new file mode 100644 index 0000000000..cfafc2da92 --- /dev/null +++ b/.changeset/oauth-provider-client-metadata-extension.md @@ -0,0 +1,9 @@ +--- +"@better-auth/oauth-provider": minor +--- + +Plugins that extend `oauthProvider()` with `extendOAuthProvider()` can now define their own client registration fields with `clientMetadata`. A client with invalid values for those fields is rejected with `invalid_client_metadata` when it registers through `/oauth2/register` or a client metadata document, or when an admin creates or updates it, instead of when it first uses them. + +Dynamic registration and client metadata documents now accept the fields an extension declares as top-level fields and store them in the client's `metadata`. They still ignore all other unrecognized fields. Registration in setups without such an extension is unchanged. + +Updating a client with an empty `metadata` object now clears its stored metadata. Previously the update left the old metadata in place. diff --git a/docs/content/docs/plugins/cimd.mdx b/docs/content/docs/plugins/cimd.mdx index a1ed9da7fa..fdb5cb64b2 100644 --- a/docs/content/docs/plugins/cimd.mdx +++ b/docs/content/docs/plugins/cimd.mdx @@ -183,7 +183,7 @@ Generic CIMD validation follows draft-02: * `client_uri`, `logo_uri`, `tos_uri`, and `policy_uri` must be credential-free public HTTP(S) URLs. Better Auth does not fetch or render remote logos. * `backchannel_logout_uri` and `backchannel_logout_session_required` are rejected because they would authorize an OP-side POST outside the injected GET transport. * Server-owned fields, including resource links and administrative controls, cannot be supplied by the document. -* Unknown members are ignored and never persisted. Nonstandard camel-case storage aliases and both spellings of client-credentials scope authority are stripped; recognized credential, privilege, and server-control fields remain fatal. +* Unknown members are ignored and never persisted, except fields an OAuth provider [extension declares](/docs/plugins/oauth-provider#client-registration-metadata), which are stored in the client's `metadata` and validated by that extension. Nonstandard camel-case storage aliases and both spellings of client-credentials scope authority are stripped; recognized credential, privilege, and server-control fields remain fatal. ```json title="https://client.example.com/client-metadata.json" { diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 142955faa7..42d68c5de5 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -1712,7 +1712,7 @@ Some clients (notably MCP clients) need to connect to your authorization server ### Provider extensions -OAuth companion plugins can extend the provider without changing OAuth Provider core. Use `extendOAuthProvider()` from a plugin `init()` hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, and client-id discovery sources. The [`@better-auth/cimd`](/docs/plugins/cimd) plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique `id` that is persisted as client provenance and can provide `fetchClientMetadataResource` for resources owned by that discovery, such as a CIMD client's `jwks_uri`. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed. +OAuth companion plugins can extend the provider without changing OAuth Provider core. Use `extendOAuthProvider()` from a plugin `init()` hook to add token grants, assertion-based client authentication methods, additive discovery metadata, token or UserInfo claims, client registration metadata, and client-id discovery sources. The [`@better-auth/cimd`](/docs/plugins/cimd) plugin uses this same surface to contribute its URL-based client discovery. A discovery source provides a stable, globally unique `id` that is persisted as client provenance and can provide `fetchClientMetadataResource` for resources owned by that discovery, such as a CIMD client's `jwks_uri`. Changing the ID requires migrating owned client rows; removing the matching discovery makes those clients fail closed. ```ts title="custom-oauth-extension.ts" import type { BetterAuthPlugin } from "better-auth"; @@ -1753,6 +1753,30 @@ Extension contributions follow two disciplines: A claims contributor can add new claim names but never replaces an identity, authentication-context, reserved RFC 9068, or other provider-owned claim. To advertise the claim names an extension emits, set `advertisedMetadata.claims_supported`: the provider owns `claims_supported` and does not infer it from contributors. +#### Client registration metadata + +An extension that defines its own client registration fields, such as CIBA's `backchannel_token_delivery_mode`, declares them in `clientMetadata`. The fields are stored in the client's `metadata`, and `validate` runs whenever a client is registered through `/oauth2/register` or a client metadata document, or created or updated by an admin, so a bad value is rejected with `invalid_client_metadata` at registration rather than when the client first uses it. + +```ts +extendOAuthProvider(ctx, { + clientMetadata: { + fields: ["example_mode"], + validate: ({ metadata }) => { + if (metadata.example_mode !== undefined && metadata.example_mode !== "ok") { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: "example_mode must be ok", + }); + } + }, + }, +}); +``` + +Dynamic registration ignores top-level fields it does not recognize (RFC 7591 §2). Fields an extension declares are the exception: a registrant sends them as top-level fields and the provider stores them in `metadata`. A registrant cannot set any other `metadata` key. An admin sets the same fields inside `metadata`. An extension cannot declare a core client field such as `redirect_uris` or a server-owned field such as `disabled`, `reference_id`, or `client_credentials_scopes`; the provider rejects these at setup. `validate` receives the core registration metadata after defaults (`client`) and the client's `metadata`; read extension fields from `metadata`. + +A [client metadata document](/docs/plugins/cimd) carries declared fields the same way, as top-level members of the document. The document owns those fields: each refresh replaces them in `metadata`, removing any it no longer declares, and keeps other `metadata` keys, such as ones an admin set. + #### Provider capabilities outside a grant A grant handler receives a `provider` capability surface (`getClient`, `authenticateClient`, `issueTokens`, `hashToken`, `validateAccessToken`, `requireActiveAccessToken`). A plugin that needs those from its own endpoints (a back-channel authorization endpoint, a polling endpoint) obtains the same object with `getOAuthProviderApi(ctx, opts, grantType?)`, so it can resolve a client or verify a token without reaching into provider internals. Use `validateAccessToken` for introspection-style flows that can handle inactive payloads, and `requireActiveAccessToken` for protected-resource endpoints that should reject inactive or unknown tokens with an OAuth bearer challenge. Pass the grant type to mint tokens away from the token endpoint; omit it for read-only use, in which case `issueTokens` throws rather than mint an unlabeled grant. diff --git a/e2e/integration/vanilla-node/e2e/client-metadata-extension.spec.ts b/e2e/integration/vanilla-node/e2e/client-metadata-extension.spec.ts new file mode 100644 index 0000000000..22530b362b --- /dev/null +++ b/e2e/integration/vanilla-node/e2e/client-metadata-extension.spec.ts @@ -0,0 +1,210 @@ +import type { Server } from "node:http"; +import { createServer } from "node:http"; +import { cimd } from "@better-auth/cimd"; +import type { OAuthOptions, Scope } from "@better-auth/oauth-provider"; +import { + extendOAuthProvider, + getOAuthProviderApi, + oauthProvider, +} from "@better-auth/oauth-provider"; +import { expect, test } from "@playwright/test"; +import type { BetterAuthPlugin } from "better-auth"; +import { APIError, createAuthEndpoint } from "better-auth/api"; +import { jwt } from "better-auth/plugins/jwt"; +import { setupServer } from "./utils"; + +const FIELD = "example_delivery_mode"; +const SUPPORTED_MODES = ["poll", "ping"]; +const CLIENT_ID_URL = "https://agent.example.test/client-metadata.json"; + +/** + * A companion plugin shaped like CIBA: it declares a registration field, + * validates it at registration, and reads it back at request time through + * the provider API, the way CIBA's backchannel endpoint does. + */ +const exampleExtension = { + id: "example-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientMetadata: { + fields: [FIELD], + validate: ({ metadata }) => { + const mode = metadata[FIELD]; + if (mode !== undefined && !SUPPORTED_MODES.includes(String(mode))) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `${FIELD} must be one of: poll, ping`, + }); + } + }, + }, + }); + }, + endpoints: { + exampleClientMode: createAuthEndpoint( + "/example/client-mode", + { method: "GET" }, + async (ctx) => { + const clientId = (ctx.query as { client_id?: string } | undefined) + ?.client_id; + if (!clientId) throw new APIError("BAD_REQUEST"); + const provider = ctx.context.getPlugin("oauth-provider"); + const api = getOAuthProviderApi( + ctx, + provider?.options as OAuthOptions, + ); + const client = await api.getClient(clientId); + const metadata = + typeof client?.metadata === "string" + ? JSON.parse(client.metadata) + : client?.metadata; + return ctx.json({ mode: metadata?.[FIELD] ?? null }); + }, + ), + }, +} satisfies BetterAuthPlugin; + +/** The client app: a separate HTTP server hosting its metadata document. */ +async function startClientApp() { + let document: Record = {}; + const server: Server = createServer((req, res) => { + if (req.url !== "/client-metadata.json") { + res.statusCode = 404; + res.end(); + return; + } + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(document)); + }); + const port = await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => { + const address = server.address(); + if (address && typeof address === "object") resolve(address.port); + else reject(new Error("Failed to get client app port")); + }); + }); + return { + port, + publish: (next: Record) => { + document = next; + }, + stop: () => + new Promise((resolve, reject) => { + server.closeAllConnections(); + server.close((err) => (err ? reject(err) : resolve())); + }), + }; +} + +test.describe("extension client metadata over HTTP", () => { + test("declared fields survive dynamic registration and metadata documents", async () => { + const clientApp = await startClientApp(); + const { port, stop } = await setupServer( + { + baseURL: { allowedHosts: ["127.0.0.1:*"], protocol: "http" }, + plugins: [ + jwt(), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + allowDynamicClientRegistration: true, + allowUnauthenticatedClientRegistration: true, + }), + cimd({ + metadataRevalidationInterval: 0, + metadataFetchPolicy: { minimumFetchInterval: 0 }, + // The client_id stays a spec-valid https URL; the operator's + // fetcher routes it to the client app running locally. + fetchClientMetadataResource: (input, init) => + fetch( + String(input).replace( + "https://agent.example.test", + `http://127.0.0.1:${clientApp.port}`, + ), + init, + ), + }), + exampleExtension, + ], + }, + { disableTestUser: true }, + ).catch(async (error: unknown) => { + // The finally below only covers a started auth server. + await clientApp.stop(); + throw error; + }); + const authBaseURL = `http://127.0.0.1:${port}/api/auth`; + const readMode = async (clientId: string) => { + const response = await fetch( + `${authBaseURL}/example/client-mode?client_id=${encodeURIComponent(clientId)}`, + ); + return { status: response.status, body: await response.json() }; + }; + const register = (body: Record) => + fetch(`${authBaseURL}/oauth2/register`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + redirect_uris: ["https://agent.example.test/callback"], + token_endpoint_auth_method: "none", + ...body, + }), + }); + + try { + // Dynamic registration keeps the declared field, drops the rest. + const registered = await register({ + [FIELD]: "ping", + metadata: { injected: true }, + unknown_field: "ignored", + }); + expect(registered.status).toBe(201); + const client = await registered.json(); + expect(client[FIELD]).toBe("ping"); + expect(client.injected).toBeUndefined(); + expect(client.unknown_field).toBeUndefined(); + expect(await readMode(client.client_id)).toEqual({ + status: 200, + body: { mode: "ping" }, + }); + + const rejected = await register({ [FIELD]: "push" }); + expect(rejected.status).toBe(400); + expect(await rejected.json()).toMatchObject({ + error: "invalid_client_metadata", + }); + + // A metadata document hosted by the client app carries the field. + const document = { + client_id: CLIENT_ID_URL, + client_name: "Example Agent", + redirect_uris: ["https://agent.example.test/callback"], + token_endpoint_auth_method: "none", + grant_types: ["authorization_code"], + response_types: ["code"], + }; + clientApp.publish({ ...document, [FIELD]: "poll" }); + expect(await readMode(CLIENT_ID_URL)).toEqual({ + status: 200, + body: { mode: "poll" }, + }); + + // A refreshed document that drops the field removes it. + clientApp.publish(document); + expect(await readMode(CLIENT_ID_URL)).toEqual({ + status: 200, + body: { mode: null }, + }); + + // An invalid value in the document is rejected at registration. + clientApp.publish({ ...document, [FIELD]: "push" }); + const invalid = await readMode(CLIENT_ID_URL); + expect(invalid.status).toBe(400); + expect(invalid.body).toMatchObject({ error: "invalid_client_metadata" }); + } finally { + await stop(); + await clientApp.stop(); + } + }); +}); diff --git a/packages/cimd/src/client-store.ts b/packages/cimd/src/client-store.ts index c83da99b30..5dde3bb1e9 100644 --- a/packages/cimd/src/client-store.ts +++ b/packages/cimd/src/client-store.ts @@ -135,6 +135,7 @@ export async function fetchClientMetadataDocument( clientIdUrl: string, cimdOptions: CimdOptions, validators?: { etag?: string; lastModified?: string }, + extensionFields?: ReadonlySet, ): Promise { const urlError = validateClientIdUrl(clientIdUrl); if (urlError) throw invalidClient(urlError); @@ -242,6 +243,7 @@ export async function fetchClientMetadataDocument( const validation = validateCimdMetadata(clientIdUrl, rawMetadata, { originBoundFields: cimdOptions.originBoundFields, metadataProfile: cimdOptions.metadataProfile, + extensionFields, }); if (!validation.valid) throw invalidClient(validation.error); for (const warning of validation.warnings ?? []) { diff --git a/packages/cimd/src/flows.test.ts b/packages/cimd/src/flows.test.ts index 8746124b67..e68f3a5a24 100644 --- a/packages/cimd/src/flows.test.ts +++ b/packages/cimd/src/flows.test.ts @@ -3,12 +3,17 @@ import { signPrivateKeyJwtClientAssertion, } from "@better-auth/core/oauth2"; import type { SchemaClient, Scope } from "@better-auth/oauth-provider"; -import { oauthProvider } from "@better-auth/oauth-provider"; +import { + extendOAuthProvider, + oauthProvider, +} from "@better-auth/oauth-provider"; import { oauthProviderClient } from "@better-auth/oauth-provider/client"; import { createAuthClient } from "better-auth/client"; import { toNodeHandler } from "better-auth/node"; import { jwt } from "better-auth/plugins/jwt"; import { getTestInstance } from "better-auth/test"; +import type { BetterAuthPlugin } from "better-auth/types"; +import { APIError } from "better-call"; import type { Listener } from "listhen"; import { listen } from "listhen"; import { @@ -856,6 +861,7 @@ describe("CIMD - refresh preserves admin-set flags", async () => { disabled: true, skipConsent: true, enableEndSession: true, + metadata: JSON.stringify({ note: "admin" }), }, }); @@ -888,6 +894,13 @@ describe("CIMD - refresh preserves admin-set flags", async () => { enableEndSession: true, clientCredentialsScopes: ["service:read"], }); + // Without extension fields, a refresh leaves metadata as the admin set it. + const refreshedMetadata: unknown = afterRefresh?.metadata; + expect( + typeof refreshedMetadata === "string" + ? JSON.parse(refreshedMetadata) + : refreshedMetadata, + ).toEqual({ note: "admin" }); expect(onClientRefreshed).toHaveBeenCalledWith( expect.objectContaining({ client: expect.objectContaining({ @@ -1029,3 +1042,281 @@ describe("CIMD - metadata document URL policy", async () => { expect(redirect).toContain("/consent"); }); }); + +describe("CIMD - extension client metadata", async () => { + const authServerBaseUrl = "http://localhost:3104"; + const clientMetadataUrl = + "https://mcp-client-extension.example.com/client-metadata.json"; + const redirectUri = "http://localhost:5104/callback"; + const metadataDocument = { + client_id: clientMetadataUrl, + client_name: "Extension Metadata Client", + redirect_uris: [redirectUri], + token_endpoint_auth_method: "none", + application_type: "native", + grant_types: ["authorization_code"], + response_types: ["code"], + }; + + const { + auth: authorizationServer, + signInWithTestUser, + customFetchImpl, + } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt(), + oauthProvider({ loginPage: "/login", consentPage: "/consent" }), + cimd({ + metadataRevalidationInterval: 0, + metadataFetchPolicy: { minimumFetchInterval: 0 }, + fetchClientMetadataResource: (input, init) => + globalThis.fetch(input, init), + }), + { + id: "client-metadata-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientMetadata: { + fields: ["example_mode"], + validate: ({ metadata }) => { + const mode = metadata.example_mode; + if (mode !== undefined && mode !== "ok" && mode !== "alt") { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: "example_mode must be ok or alt", + }); + } + // An admin-owned key the declared field must agree with. + const allowed = metadata.allowed_modes; + if ( + Array.isArray(allowed) && + mode !== undefined && + !allowed.includes(mode) + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: "example_mode is not in allowed_modes", + }); + } + }, + }, + }); + }, + } satisfies BetterAuthPlugin, + ], + }); + const { headers } = await signInWithTestUser(); + const authedClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { customFetchImpl, headers }, + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + async function authorize(): Promise { + let redirect = ""; + await authedClient.$fetch( + buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri), + { + method: "GET", + onError(ctx) { + redirect = ctx.response.headers.get("Location") || ""; + }, + }, + ); + return redirect; + } + + async function storedMetadata() { + const ctx = await authorizationServer.$context; + const client = await ctx.adapter.findOne>({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + }); + const metadata: unknown = client?.metadata; + return typeof metadata === "string" ? JSON.parse(metadata) : metadata; + } + + it("stores declared fields from the document and replaces them on refresh", async () => { + stubMetadataFetch(clientMetadataUrl, { + ...metadataDocument, + example_mode: "ok", + unknown_field: "ignored", + }); + expect(await authorize()).toContain("/consent"); + expect(await storedMetadata()).toEqual({ example_mode: "ok" }); + + // An admin-set key survives a refresh; the document owns example_mode. + const ctx = await authorizationServer.$context; + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + update: { + metadata: JSON.stringify({ example_mode: "ok", note: "admin" }), + }, + }); + vi.unstubAllGlobals(); + stubMetadataFetch(clientMetadataUrl, metadataDocument); + expect(await authorize()).toContain("/consent"); + expect(await storedMetadata()).toEqual({ note: "admin" }); + }); + + it("keeps an admin change made while the refresh fetch is in flight", async () => { + stubMetadataFetch(clientMetadataUrl, { + ...metadataDocument, + example_mode: "ok", + }); + expect(await authorize()).toContain("/consent"); + + // The refresh reads the client before fetching; an admin writes during + // the fetch, and the refresh must not restore the value it read earlier. + const ctx = await authorizationServer.$context; + const originalFetch = globalThis.fetch.bind(globalThis); + vi.unstubAllGlobals(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requested = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (requested !== clientMetadataUrl) return originalFetch(input, init); + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + update: { + metadata: JSON.stringify({ + example_mode: "ok", + note: "during fetch", + }), + }, + }); + return new Response(JSON.stringify(metadataDocument), { + status: 200, + headers: { "content-type": "application/json" }, + }); + }), + ); + expect(await authorize()).toContain("/consent"); + expect(await storedMetadata()).toEqual({ note: "during fetch" }); + }); + + it("accepts a refresh that only the current row makes valid", async () => { + stubMetadataFetch(clientMetadataUrl, { + ...metadataDocument, + example_mode: "ok", + }); + expect(await authorize()).toContain("/consent"); + const ctx = await authorizationServer.$context; + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + update: { + metadata: JSON.stringify({ example_mode: "ok", allowed_modes: ["ok"] }), + }, + }); + + // During the fetch an admin widens allowed_modes while the document + // switches to "alt". The row read before the fetch would reject "alt". + const originalFetch = globalThis.fetch.bind(globalThis); + vi.unstubAllGlobals(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requested = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (requested !== clientMetadataUrl) return originalFetch(input, init); + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + update: { + metadata: JSON.stringify({ + example_mode: "ok", + allowed_modes: ["ok", "alt"], + }), + }, + }); + return new Response( + JSON.stringify({ ...metadataDocument, example_mode: "alt" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); + expect(await authorize()).toContain("/consent"); + expect(await storedMetadata()).toEqual({ + example_mode: "alt", + allowed_modes: ["ok", "alt"], + }); + }); + + it("validates the merged metadata a refresh saves, not the earlier read", async () => { + stubMetadataFetch(clientMetadataUrl, { + ...metadataDocument, + example_mode: "ok", + }); + expect(await authorize()).toContain("/consent"); + + // During the fetch an admin narrows allowed_modes while the document + // switches to "alt". Each change is valid alone; together they are not. + const ctx = await authorizationServer.$context; + const originalFetch = globalThis.fetch.bind(globalThis); + vi.unstubAllGlobals(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const requested = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : input.url; + if (requested !== clientMetadataUrl) return originalFetch(input, init); + await ctx.adapter.update({ + model: "oauthClient", + where: [{ field: "clientId", value: clientMetadataUrl }], + update: { + metadata: JSON.stringify({ + example_mode: "ok", + allowed_modes: ["ok"], + }), + }, + }); + return new Response( + JSON.stringify({ ...metadataDocument, example_mode: "alt" }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }), + ); + const response = await authedClient.$fetch( + buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri), + { method: "GET" }, + ); + expect(JSON.stringify(response)).toContain("invalid_client_metadata"); + expect(await storedMetadata()).toEqual({ + example_mode: "ok", + allowed_modes: ["ok"], + }); + }); + + it("rejects a document with an invalid declared field", async () => { + stubMetadataFetch(clientMetadataUrl, { + ...metadataDocument, + example_mode: "bad", + }); + const response = await authedClient.$fetch<{ error?: string }>( + buildAuthorizeUrl(authServerBaseUrl, clientMetadataUrl, redirectUri), + { method: "GET" }, + ); + expect(JSON.stringify(response)).toContain("invalid_client_metadata"); + }); +}); diff --git a/packages/cimd/src/resolver.ts b/packages/cimd/src/resolver.ts index 869dc1ad52..d49e72a0db 100644 --- a/packages/cimd/src/resolver.ts +++ b/packages/cimd/src/resolver.ts @@ -6,6 +6,7 @@ import type { SchemaClient, Scope, } from "@better-auth/oauth-provider"; +import { getExtensionClientMetadataFields } from "@better-auth/oauth-provider/internal"; import { toExpJWT } from "better-auth/plugins"; import { APIError } from "better-call"; import type { MetadataDocumentResponseCacheHeaders } from "./client-store"; @@ -505,6 +506,7 @@ export function createCimdResolver(cimdOptions: CimdOptions): CimdResolver { lastModified: cachedEntry.lastModified, } : undefined, + getExtensionClientMetadataFields(oauthOptions), ); if (fetched.status === "not-modified" && !cachedEntry) { diff --git a/packages/cimd/src/validate-metadata-document.test.ts b/packages/cimd/src/validate-metadata-document.test.ts index 6b956e582a..03e0de3724 100644 --- a/packages/cimd/src/validate-metadata-document.test.ts +++ b/packages/cimd/src/validate-metadata-document.test.ts @@ -1031,3 +1031,37 @@ describe("validateCimdMetadata", () => { expect(result.error).toContain("logo_uri"); }); }); + +describe("validateCimdMetadata extension fields", () => { + const url = "https://client.example.com/metadata.json"; + const field = "backchannel_client_notification_endpoint"; + const options = { + extensionFields: new Set([field]), + originBoundFields: [field], + }; + + it("keeps a declared field and ignores other unknown members", () => { + const result = validateCimdMetadata( + url, + validMetadata(url, { + [field]: "https://client.example.com/ciba", + unknown_field: "ignored", + }), + options, + ); + expect(result.valid).toBe(true); + expect(result.valid && result.metadata).toMatchObject({ + [field]: "https://client.example.com/ciba", + }); + expect(result.valid && "unknown_field" in result.metadata).toBe(false); + }); + + it("applies origin-bound rules to a declared field", () => { + const result = validateCimdMetadata( + url, + validMetadata(url, { [field]: "https://attacker.example.net/ciba" }), + options, + ); + expect(result.valid).toBe(false); + }); +}); diff --git a/packages/cimd/src/validate-metadata-document.ts b/packages/cimd/src/validate-metadata-document.ts index f7db75d315..643b5bb01d 100644 --- a/packages/cimd/src/validate-metadata-document.ts +++ b/packages/cimd/src/validate-metadata-document.ts @@ -41,6 +41,13 @@ export type CimdMetadataValidationResult = export interface CimdMetadataValidationOptions { originBoundFields?: readonly string[]; metadataProfile?: CimdMetadataProfile; + /** + * Registration fields that OAuth provider extensions declare, such as + * CIBA's `backchannel_token_delivery_mode`. They are kept in the validated + * metadata instead of being ignored as unknown members; the provider + * validates them when it registers the client. + */ + extensionFields?: ReadonlySet; } /** @@ -184,7 +191,16 @@ export function validateCimdMetadata( }; } - const doc = parsedMetadata.data; + // Declared extension fields join the parsed document before the checks + // below, so origin-bound rules apply to them too. + const doc = { + ...Object.fromEntries( + Object.entries(raw).filter(([field]) => + options.extensionFields?.has(field), + ), + ), + ...parsedMetadata.data, + }; const warnings: string[] = []; // §4.1: client_id MUST equal the fetch URL (simple string comparison) diff --git a/packages/oauth-provider/src/client-metadata.ts b/packages/oauth-provider/src/client-metadata.ts index 8272becdad..527eacc5ad 100644 --- a/packages/oauth-provider/src/client-metadata.ts +++ b/packages/oauth-provider/src/client-metadata.ts @@ -144,6 +144,10 @@ export function isForbiddenCimdClientMetadataField(field: string): boolean { return CIMD_FORBIDDEN_SERVER_FIELD_NAMES.has(field); } +export function isReservedOAuthClientMetadataField(field: string): boolean { + return OPAQUE_METADATA_RESERVED_FIELDS.has(field); +} + export function stripReservedOAuthClientMetadataExtensions( metadata: Record | undefined, ): Record | undefined { diff --git a/packages/oauth-provider/src/extensions.test.ts b/packages/oauth-provider/src/extensions.test.ts index c275c1022e..d056c45a84 100644 --- a/packages/oauth-provider/src/extensions.test.ts +++ b/packages/oauth-provider/src/extensions.test.ts @@ -1302,3 +1302,153 @@ describe("oauth-provider extensions", async () => { }); }); }); + +describe("oauth-provider client metadata extensions", async () => { + const baseURL = "http://localhost:3000"; + const redirectUri = "https://client.example.com/callback"; + const invalid = { body: { error: "invalid_client_metadata" } }; + + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + allowDynamicClientRegistration: true, + }), + { + id: "client-metadata-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientMetadata: { + fields: ["example_mode"], + validate: ({ client, metadata }) => { + // Extension fields must reach validators only through `metadata`. + if ( + "metadata" in client || + "example_mode" in client || + "admin_note" in client + ) { + throw new Error("extension fields leaked into client"); + } + if ( + metadata.example_mode !== undefined && + metadata.example_mode !== "ok" + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: "example_mode must be ok", + }); + } + }, + }, + }); + }, + } satisfies BetterAuthPlugin, + ], + }); + const { headers } = await signInWithTestUser(); + + it("stores only declared fields from dynamic registration", async () => { + const body = { + redirect_uris: [redirectUri], + example_mode: "ok", + disabled: true, + metadata: { injected: true }, + unknown_field: "ignored", + }; + const client = await auth.api.registerOAuthClient({ headers, body }); + expect(Reflect.get(client, "example_mode")).toBe("ok"); + expect(client.disabled).not.toBe(true); + expect(Reflect.get(client, "injected")).toBeUndefined(); + expect(Reflect.get(client, "unknown_field")).toBeUndefined(); + }); + + it("rejects invalid declared fields at dynamic registration", async () => { + const body = { redirect_uris: [redirectUri], example_mode: "bad" }; + await expect( + auth.api.registerOAuthClient({ headers, body }), + ).rejects.toMatchObject(invalid); + }); + + it("rejects invalid metadata at admin create and update", async () => { + await expect( + auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + metadata: { example_mode: "bad" }, + }, + }), + ).rejects.toMatchObject(invalid); + + const client = await auth.api.adminCreateOAuthClient({ + headers, + body: { redirect_uris: [redirectUri], metadata: { example_mode: "ok" } }, + }); + await expect( + auth.api.adminUpdateOAuthClient({ + headers, + body: { + client_id: client.client_id, + update: { metadata: { example_mode: "bad" } }, + }, + }), + ).rejects.toMatchObject(invalid); + }); + + it("keeps undeclared stored metadata off the client on admin update", async () => { + const client = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + metadata: { example_mode: "ok", admin_note: "kept" }, + }, + }); + const updated = await auth.api.adminUpdateOAuthClient({ + headers, + body: { + client_id: client.client_id, + update: { client_name: "Renamed" }, + }, + }); + expect(updated.client_name).toBe("Renamed"); + }); + + it("clears stored metadata when an admin update replaces it with an empty object", async () => { + const client = await auth.api.adminCreateOAuthClient({ + headers, + body: { redirect_uris: [redirectUri], metadata: { example_mode: "ok" } }, + }); + await auth.api.adminUpdateOAuthClient({ + headers, + body: { client_id: client.client_id, update: { metadata: {} } }, + }); + const stored = await auth.api.getOAuthClient({ + headers, + query: { client_id: client.client_id }, + }); + expect(Reflect.get(stored, "example_mode")).toBeUndefined(); + }); + + it("rejects an extension that claims a core client field", async () => { + await expect( + getTestInstance({ + baseURL, + plugins: [ + jwt({ jwt: { issuer: baseURL } }), + oauthProvider({ loginPage: "/login", consentPage: "/consent" }), + { + id: "reserved-client-metadata-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientMetadata: { fields: ["redirect_uris"] }, + }); + }, + } satisfies BetterAuthPlugin, + ], + }), + ).rejects.toThrow("client metadata field is reserved: redirect_uris"); + }); +}); diff --git a/packages/oauth-provider/src/extensions.ts b/packages/oauth-provider/src/extensions.ts index 506c4ce298..f082f5578d 100644 --- a/packages/oauth-provider/src/extensions.ts +++ b/packages/oauth-provider/src/extensions.ts @@ -2,10 +2,16 @@ import type { AuthContext, GenericEndpointContext } from "@better-auth/core"; import { logger } from "@better-auth/core/env"; import { BetterAuthError } from "@better-auth/core/error"; import { CLIENT_ASSERTION_TYPE } from "@better-auth/core/oauth2"; +import { + isReservedOAuthClientMetadataField, + stripReservedOAuthClientMetadataExtensions, +} from "./client-metadata"; import type { ClientDiscovery, OAuthClaimExtensionInput, OAuthClientAuthenticationStrategy, + OAuthClientMetadataExtensionInput, + OAuthClientRegistrationMetadata, OAuthMetadataExtensionInput, OAuthOptions, OAuthProviderExtension, @@ -129,6 +135,14 @@ function collectExtensionKeys( for (const clientDiscoveryId of clientDiscoveryIds) { assertNonEmptyExtensionValue("client discovery id", clientDiscoveryId); } + for (const field of extension.clientMetadata?.fields ?? []) { + assertNonEmptyExtensionValue("client metadata field", field); + if (isReservedOAuthClientMetadataField(field)) { + throw new BetterAuthError( + `OAuth Provider extension client metadata field is reserved: ${field}`, + ); + } + } return { grantTypes, authMethods, assertionTypes, clientDiscoveryIds }; } @@ -399,6 +413,52 @@ export function collectExtensionUserInfoClaims( ); } +/** Whether any extension declares client metadata fields or a validator. */ +export function hasExtensionClientMetadata( + opts: OAuthOptions, +): boolean { + return getOAuthProviderExtensions(opts).some( + (extension) => extension.clientMetadata, + ); +} + +/** Registration fields that extensions define, stored in the client's `metadata`. */ +export function getExtensionClientMetadataFields( + opts: OAuthOptions, +): Set { + return new Set( + getOAuthProviderExtensions(opts).flatMap( + (extension) => extension.clientMetadata?.fields ?? [], + ), + ); +} + +/** + * Runs every extension's client metadata validator, in registration order. + * Extensions read their fields from `metadata` only, so `metadata` and + * declared fields are removed from the client they receive; a metadata + * document carries declared fields at its top level. + */ +export async function validateExtensionClientMetadata( + opts: OAuthOptions, + client: OAuthClientRegistrationMetadata, + metadata: Record | undefined, +): Promise { + const { metadata: _metadata, ...coreClient } = client; + const extensionFields = getExtensionClientMetadataFields(opts); + const input: OAuthClientMetadataExtensionInput = { + client: Object.fromEntries( + Object.entries(coreClient).filter( + ([field]) => !extensionFields.has(field), + ), + ) as Omit, + metadata: stripReservedOAuthClientMetadataExtensions(metadata) ?? {}, + }; + for (const extension of getOAuthProviderExtensions(opts)) { + await extension.clientMetadata?.validate?.(input); + } +} + /** * Whether any registered extension contributes UserInfo claims. Lets the * UserInfo endpoint skip loading the client when nothing needs it. diff --git a/packages/oauth-provider/src/internal.ts b/packages/oauth-provider/src/internal.ts index 5a7d714348..8bedefea95 100644 --- a/packages/oauth-provider/src/internal.ts +++ b/packages/oauth-provider/src/internal.ts @@ -11,7 +11,9 @@ export { validatePublicClientJwks, } from "./client-jwks"; export { isForbiddenCimdClientMetadataField } from "./client-metadata"; +export { getExtensionClientMetadataFields } from "./extensions"; export { + assertPublicRoutableHttpsUri, type OAuthClientRegistrationResult, type RegisterClientMetadataDocumentInput, registerClientMetadataDocument, diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 92bd2622cf..7b518574f0 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -1493,7 +1493,10 @@ export const oauthProvider = >(options: O) => { "/oauth2/register", { method: "POST", - body: clientRegistrationRequestSchema, + // Loose so extension-defined fields reach the handler, which keeps + // those and drops every other unrecognized field (RFC 7591 §2). + // The declared type stays strict. + body: clientRegistrationRequestSchema.loose() as unknown as typeof clientRegistrationRequestSchema, errorCodesByField: { redirect_uris: "invalid_redirect_uri", post_logout_redirect_uris: "invalid_redirect_uri", diff --git a/packages/oauth-provider/src/oauthClient/endpoints.ts b/packages/oauth-provider/src/oauthClient/endpoints.ts index 26fab0f8ef..587461ec94 100644 --- a/packages/oauth-provider/src/oauthClient/endpoints.ts +++ b/packages/oauth-provider/src/oauthClient/endpoints.ts @@ -11,7 +11,7 @@ import type { Scope, } from "../types"; import type { GrantType } from "../types/oauth"; -import { getClient, storeClientSecret } from "../utils"; +import { getClient, parseClientMetadata, storeClientSecret } from "../utils"; import { normalizeClientCredentialsScopes, validateClientCredentialsScopes, @@ -340,7 +340,14 @@ export async function updateClientEndpoint( } } - const updatedMetadata = { ...schemaToOAuth(client), ...updates }; + const updatedMetadata = { + // Converted without its metadata, which schemaToOAuth would spread onto + // the client; the effective metadata is passed explicitly below. + ...schemaToOAuth({ ...client, metadata: undefined }), + ...updates, + // An update's `metadata` replaces the stored object. + metadata: updates.metadata ?? parseClientMetadata(client.metadata), + }; if (clearsBackchannelLogout) { updatedMetadata.backchannel_logout_uri = undefined; updatedMetadata.backchannel_logout_session_required = undefined; @@ -365,6 +372,11 @@ export async function updateClientEndpoint( schemaUpdates.frontchannelLogoutUri = null; schemaUpdates.frontchannelLogoutSessionRequired = null; } + // oauthToSchema drops a replacement that is empty after stripping reserved + // keys; clear the column so storage matches what was validated. + if (updates.metadata !== undefined && schemaUpdates.metadata === undefined) { + schemaUpdates.metadata = null; + } if ( !finalGrantTypes.includes("client_credentials") || finalTokenEndpointAuthMethod === "none" diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index c9764e2967..1b5ec398e6 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -11,9 +11,12 @@ import { toExpJWT } from "better-auth/plugins"; import { validatePublicClientJwks } from "./client-jwks"; import { stripReservedOAuthClientMetadataExtensions } from "./client-metadata"; import { + getExtensionClientMetadataFields, getSupportedAuthMethods, getSupportedGrantTypes, + hasExtensionClientMetadata, isExtensionTokenEndpointAuthMethod, + validateExtensionClientMetadata, } from "./extensions"; import { normalizeClientCredentialsScopes, @@ -32,6 +35,7 @@ import type { Scope, } from "./types"; import type { GrantType, OAuthClient } from "./types/oauth"; +import { clientRegistrationRequestSchema } from "./types/zod"; import { parseClientMetadata, storeClientSecret } from "./utils"; import { isPrivateHostname } from "./utils/client-assertion"; import { authorizeInitialAccessToken } from "./utils/initial-access-token"; @@ -274,11 +278,86 @@ function assertValidRegistrationJwks(jwks: NonNullable) { } } +/** + * Rejects a URI the server will POST to unless it is an absolute https URL + * with no fragment or credentials on a publicly routable host. Back-channel + * endpoints use it as an SSRF guard, since the server, not the browser, calls + * them. + */ +export function assertPublicRoutableHttpsUri(field: string, uri: string): void { + const reject = (description: string): never => { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `${field} ${description}`, + }); + }; + let url: URL; + try { + url = new URL(uri); + } catch { + return reject("must be an absolute URL"); + } + // Check the raw value rather than `url.hash`, which is empty for a bare + // trailing `#` and would let that fragment delimiter through. + if (uri.includes("#")) reject("must not include a fragment component"); + if (url.protocol !== "https:") reject("must use https"); + if (url.username || url.password) reject("must not contain credentials"); + if (isPrivateHostname(url.hostname)) { + reject("must not point to a private or reserved address"); + } +} + +const REGISTRATION_REQUEST_FIELDS = new Set( + Object.keys(clientRegistrationRequestSchema.shape), +); + +/** + * Splits a dynamic registration request into its core fields and the fields + * extensions define, which are stored in the client's `metadata`. Any other + * unrecognized field is ignored (RFC 7591 §2), so a registrant cannot set + * server-only fields or arbitrary `metadata`. + */ +function splitRegistrationRequest( + input: Record, + opts: OAuthOptions, +): { + body: ClientRegistrationRequest; + metadata: Record | undefined; +} { + const body = Object.fromEntries( + Object.entries(input).filter(([field]) => + REGISTRATION_REQUEST_FIELDS.has(field), + ), + ); + return { + body: body as ClientRegistrationRequest, + metadata: pickExtensionClientMetadata(input, opts), + }; +} + +/** + * The top-level fields of a registration that extensions declare, which are + * stored in the client's `metadata`. + */ +function pickExtensionClientMetadata( + input: object, + opts: OAuthOptions, +): Record | undefined { + const extensionFields = getExtensionClientMetadataFields(opts); + const metadata = Object.fromEntries( + Object.entries(input).filter(([field]) => extensionFields.has(field)), + ); + return Object.keys(metadata).length ? metadata : undefined; +} + export async function registerEndpoint( ctx: GenericEndpointContext, opts: OAuthOptions, ): Promise { - const body = ctx.body as ClientRegistrationRequest; + const { body, metadata } = splitRegistrationRequest( + ctx.body as Record, + opts, + ); if (!opts.allowDynamicClientRegistration) { throw new APIError("FORBIDDEN", { @@ -352,7 +431,7 @@ export async function registerEndpoint( }) : undefined); const response = await createOAuthClientRegistration(ctx, opts, { - metadata: body, + metadata: { ...body, metadata }, registrationSource: "dynamic", userId: referenceId ? undefined : session?.session.userId, referenceId, @@ -368,6 +447,11 @@ export async function checkOAuthClient( settings?: { registrationSource?: "dynamic" | "managed" | "clientMetadataDocument"; ctx?: GenericEndpointContext; + /** + * Skip extension validators because the caller runs them on the + * metadata it saves, as a metadata document refresh does. + */ + skipExtensionValidation?: boolean; }, ) { const isClientMetadataDocument = @@ -664,47 +748,10 @@ export async function checkOAuthClient( "backchannel_logout_uri requires the jwt plugin (disableJwtPlugin must be false)", }); } - let url: URL; - try { - url = new URL(clientWithDefaults.backchannel_logout_uri); - } catch { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: "backchannel_logout_uri must be an absolute URL", - }); - } - // Spec §2.2: "The backchannel_logout_uri MUST NOT include a fragment - // component." Check the raw value rather than `url.hash`, which is empty - // for a bare trailing `#` and would let that fragment delimiter through. - if (clientWithDefaults.backchannel_logout_uri.includes("#")) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: - "backchannel_logout_uri must not include a fragment component", - }); - } - if (url.protocol !== "https:") { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: "backchannel_logout_uri must use https", - }); - } - if (url.username || url.password) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: - "backchannel_logout_uri must not contain credentials", - }); - } - // SSRF guard: the OP issues an outbound POST to this URI on every - // session end, so every target must be publicly routable. - if (isPrivateHostname(url.hostname)) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: - "backchannel_logout_uri must not point to a private or reserved address", - }); - } + assertPublicRoutableHttpsUri( + "backchannel_logout_uri", + clientWithDefaults.backchannel_logout_uri, + ); } // The browser, not the OP, requests this URI, so the back-channel SSRF @@ -756,6 +803,14 @@ export async function checkOAuthClient( }); } } + + if (!settings?.skipExtensionValidation) { + await validateExtensionClientMetadata( + opts, + clientWithDefaults, + client.metadata, + ); + } } interface CreateOAuthClientRegistrationBaseInput { @@ -884,6 +939,10 @@ async function persistOAuthClientRegistration( await checkOAuthClient(body, opts, { registrationSource: input.registrationSource, ctx, + // A refresh validates the merge with the current row before saving. + skipExtensionValidation: + input.registrationSource === "clientMetadataDocument" && + Boolean(input.existingClient), }); // Generate clientId and clientSecret based on its type @@ -990,6 +1049,32 @@ async function persistOAuthClientRegistration( let storedClient: SchemaClient; let created = false; if (existingClient) { + // Merge with the row as it is now, inside the transaction, rather + // than as read before the document fetch, so admin changes made + // meanwhile are kept, and validate the result before saving it. + // Without extensions, a refresh leaves metadata alone. + let metadata: string | null | undefined; + if (hasExtensionClientMetadata(opts)) { + const extensionFields = getExtensionClientMetadataFields(opts); + const currentClient = await adapter.findOne>({ + model: clientModel, + where: [{ field: "clientId", value: clientId }], + }); + const merged = stripReservedOAuthClientMetadataExtensions( + mergeDocumentExtensionMetadata( + extensionFields, + currentClient?.metadata, + body.metadata, + ), + ); + await validateExtensionClientMetadata(opts, body, merged); + if (extensionFields.size > 0) { + metadata = + merged && Object.keys(merged).length > 0 + ? JSON.stringify(merged) + : null; + } + } const updatedClient = await adapter.update>({ model: clientModel, where: [ @@ -1001,6 +1086,7 @@ async function persistOAuthClientRegistration( ], update: { ...schema, + metadata, disabled: existingClient.disabled, skipConsent: existingClient.skipConsent, enableEndSession: existingClient.enableEndSession, @@ -1151,6 +1237,29 @@ function assertClientDiscoveryOwnership( }); } +/** + * The `metadata` a metadata document registration stores. The document owns the + * fields extensions declare, so a refresh replaces them, including removing + * ones it no longer declares. Other stored keys, such as ones an admin set, are + * kept. + */ +function mergeDocumentExtensionMetadata( + extensionFields: ReadonlySet, + storedMetadata: SchemaClient["metadata"] | undefined, + documentMetadata: Record | undefined, +): Record | undefined { + const owned = ([field]: [string, unknown]) => extensionFields.has(field); + const metadata = { + ...Object.fromEntries( + Object.entries(parseClientMetadata(storedMetadata) ?? {}).filter( + (entry) => !owned(entry), + ), + ), + ...Object.fromEntries(Object.entries(documentMetadata ?? {}).filter(owned)), + }; + return Object.keys(metadata).length ? metadata : undefined; +} + /** * First-party persistence seam for a validated Client ID Metadata Document. * @@ -1187,19 +1296,29 @@ export async function registerClientMetadataDocument( "Client ID Metadata Document clients cannot use a shared secret", }); } - const registrationInput = { + const toRegistrationInput = ( + existingClient: SchemaClient | undefined, + ) => ({ ...input, + existingClient, metadata: { ...input.metadata, + // Only the declared fields; a refresh merges them with the current + // row when it saves. + metadata: pickExtensionClientMetadata(input.metadata, opts), token_endpoint_auth_method: input.metadata.token_endpoint_auth_method ?? "none", client_secret: undefined, client_secret_expires_at: undefined, }, registrationSource: "clientMetadataDocument" as const, - }; + }); try { - return await persistOAuthClientRegistration(ctx, opts, registrationInput); + return await persistOAuthClientRegistration( + ctx, + opts, + toRegistrationInput(input.existingClient), + ); } catch (error) { if (!isClientRegistrationCollision(error)) throw error; const clientModel = opts.schema?.oauthClient?.modelName ?? "oauthClient"; @@ -1229,10 +1348,11 @@ export async function registerClientMetadataDocument( if (!exactLink) throw error.cause; } try { - return await persistOAuthClientRegistration(ctx, opts, { - ...registrationInput, - existingClient: currentClient, - }); + return await persistOAuthClientRegistration( + ctx, + opts, + toRegistrationInput(currentClient), + ); } catch (retryError) { if (isClientRegistrationCollision(retryError)) { throw retryError.cause; diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 30ea257e8b..b5b9de7a0b 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -2,6 +2,7 @@ import type { GenericEndpointContext, LiteralString } from "@better-auth/core"; import type { JWSAlgorithms } from "better-auth/plugins"; import type { InferOptionSchema, Session, User } from "better-auth/types"; import type { JWTPayload } from "jose"; +import type { OAuthClientRegistrationMetadata } from "../register"; import type { schema } from "../schema"; import type { Awaitable } from "./helpers"; import type { @@ -28,6 +29,7 @@ export type { TokenType, } from "./oauth"; export type { ClientRegistrationRequest } from "./zod"; +export type { OAuthClientRegistrationMetadata }; export type StoreTokenType = | "access_token" @@ -480,6 +482,41 @@ export interface OAuthProviderExtension { * registry, an attestation header) contributes it here. */ clientDiscovery?: ClientDiscovery | ClientDiscovery[]; + /** + * Client registration metadata the extension defines, such as CIBA's + * `backchannel_token_delivery_mode`. Extension fields live in the client's + * `metadata`. + */ + clientMetadata?: { + /** + * Top-level registration fields the extension defines. Dynamic client + * registration (RFC 7591 §2) and client metadata documents (CIMD) both + * ignore unrecognized fields; they keep these and store them in the + * client's `metadata`, where admin-created clients put them too. Core + * and server-owned client fields cannot be claimed. + */ + fields?: string[]; + /** + * Validates the extension's fields when a client is registered through + * dynamic registration or a client metadata document, or created or + * updated by an admin. Throw an `APIError` with `invalid_client_metadata` + * to reject the client. + */ + validate?: (input: OAuthClientMetadataExtensionInput) => Awaitable; + }; +} + +export interface OAuthClientMetadataExtensionInput { + /** + * The client's core registration metadata after provider defaults. Read + * extension fields from `metadata`, not from here. + */ + client: Omit; + /** + * The client's `metadata` as it will be stored, with reserved client field + * names removed. Extension fields are read from here. + */ + metadata: Record; } /**