mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 11:24:57 -05:00
feat(oauth-provider): let extensions validate their client registration metadata (#11767)
This commit is contained in:
@@ -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.
|
||||
@@ -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"
|
||||
{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 ?? []) {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user