feat(oauth-provider): let extensions validate their client registration metadata (#11767)

This commit is contained in:
Gustavo Valverde
2026-10-15 04:58:52 +01:00
committed by GitHub
parent 5fd850f926
commit 2417879cf6
18 changed files with 1038 additions and 57 deletions
@@ -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.
@@ -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.
+1 -1
View File
@@ -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"
{
+25 -1
View File
@@ -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.
@@ -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<Scope[]>,
);
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<string, unknown> = {};
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<number>((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<string, unknown>) => {
document = next;
},
stop: () =>
new Promise<void>((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<string, unknown>) =>
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();
}
});
});
+2
View File
@@ -135,6 +135,7 @@ export async function fetchClientMetadataDocument(
clientIdUrl: string,
cimdOptions: CimdOptions,
validators?: { etag?: string; lastModified?: string },
extensionFields?: ReadonlySet<string>,
): Promise<ClientMetadataDocumentResult> {
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 ?? []) {
+292 -1
View File
@@ -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<string> {
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<SchemaClient<Scope[]>>({
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");
});
});
+2
View File
@@ -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) {
@@ -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);
});
});
@@ -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<string>;
}
/**
@@ -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)
@@ -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<string, unknown> | undefined,
): Record<string, unknown> | undefined {
@@ -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");
});
});
+60
View File
@@ -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<Scope[]>,
): boolean {
return getOAuthProviderExtensions(opts).some(
(extension) => extension.clientMetadata,
);
}
/** Registration fields that extensions define, stored in the client's `metadata`. */
export function getExtensionClientMetadataFields(
opts: OAuthOptions<Scope[]>,
): Set<string> {
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<Scope[]>,
client: OAuthClientRegistrationMetadata,
metadata: Record<string, unknown> | undefined,
): Promise<void> {
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<OAuthClientRegistrationMetadata, "metadata">,
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.
+2
View File
@@ -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,
+4 -1
View File
@@ -1493,7 +1493,10 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(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",
@@ -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"
+170 -50
View File
@@ -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<OAuthClient["jwks"]>) {
}
}
/**
* 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<string, unknown>,
opts: OAuthOptions<Scope[]>,
): {
body: ClientRegistrationRequest;
metadata: Record<string, unknown> | 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<Scope[]>,
): Record<string, unknown> | 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<Scope[]>,
): Promise<OAuthClientRegistrationResponse> {
const body = ctx.body as ClientRegistrationRequest;
const { body, metadata } = splitRegistrationRequest(
ctx.body as Record<string, unknown>,
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<Scope[]>;
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<SchemaClient<Scope[]>>({
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<SchemaClient<Scope[]>>({
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<string>,
storedMetadata: SchemaClient<Scope[]>["metadata"] | undefined,
documentMetadata: Record<string, unknown> | undefined,
): Record<string, unknown> | 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<Scope[]> | 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;
@@ -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<void>;
};
}
export interface OAuthClientMetadataExtensionInput {
/**
* The client's core registration metadata after provider defaults. Read
* extension fields from `metadata`, not from here.
*/
client: Omit<OAuthClientRegistrationMetadata, "metadata">;
/**
* The client's `metadata` as it will be stored, with reserved client field
* names removed. Extension fields are read from here.
*/
metadata: Record<string, unknown>;
}
/**