diff --git a/.changeset/cimd-plugin.md b/.changeset/cimd-plugin.md index 6ffa9da386..4f9fbfe331 100644 --- a/.changeset/cimd-plugin.md +++ b/.changeset/cimd-plugin.md @@ -30,20 +30,22 @@ The `allowFetch` pre-fetch gate lets operators add origin allowlists, per-host r Admin-controlled fields (`disabled`, `skipConsent`, `enableEndSession`) are preserved across refreshes so admin decisions survive document updates. -### `@better-auth/oauth-provider`: new `clientDiscovery` option +### `@better-auth/oauth-provider`: `clientDiscovery` extension field ```ts import type { ClientDiscovery } from "@better-auth/oauth-provider"; oauthProvider({ - clientDiscovery: [ + extensions: [ { - id: "my-resolver", - matches: (clientId) => clientId.startsWith("custom://"), - resolve: async (ctx, clientId, existing) => { - // create, refresh, or return null to pass through + clientDiscovery: { + id: "my-resolver", + matches: (clientId) => clientId.startsWith("custom://"), + resolve: async (ctx, clientId, existing) => { + // create, refresh, or return null to pass through + }, + discoveryMetadata: { custom_flow_supported: true }, }, - discoveryMetadata: { custom_flow_supported: true }, }, ], }); @@ -51,7 +53,7 @@ oauthProvider({ `clientDiscovery` accepts a single `ClientDiscovery` or an array. `getClient()` walks the entries in order after the database lookup; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` fields that are merged into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses. -Plugins like `@better-auth/cimd` append an entry here at init time, so multiple discoveries can coexist. +Plugins like `@better-auth/cimd` contribute an entry through the extension surface at init time, so multiple discoveries can coexist. The `checkOAuthClient` and `oauthToSchema` helpers are now exported for plugins that create client records directly. diff --git a/.changeset/oauth-provider-extension-surface.md b/.changeset/oauth-provider-extension-surface.md new file mode 100644 index 0000000000..560e1e0671 --- /dev/null +++ b/.changeset/oauth-provider-extension-surface.md @@ -0,0 +1,11 @@ +--- +"@better-auth/oauth-provider": minor +--- + +Add an OAuth Provider extension surface so companion plugins can register token grants, assertion-based client authentication methods, additive discovery metadata, access-token/ID-token/UserInfo claim contributors, and client-id discovery sources without changing provider core for each OAuth RFC. Register through `extendOAuthProvider(ctx, extension)` in a plugin `init()` hook. + +Contributions are guarded: grant types, auth methods, and assertion types must be disjoint across extensions; metadata and claims are additive and never override authorization-server core. Assertion-based client authentication methods can reuse the exported `consumeClientAssertion` helper for the RFC 7523 audience, lifetime, and `jti` replay checks. + +Plugins reach provider capabilities through one surface. A grant handler receives a `provider` (resolve a client, issue tokens, hash or look up a token, verify a token), and a plugin's own endpoints obtain the same object with `getOAuthProviderApi(ctx, opts, grantType?)`. An issued token can be sender-constrained by passing a `confirmation` (RFC 7800 `cnf`) to `issueTokens` or returning it from a client-authentication strategy; the authorization server owns `cnf`, so a claim contributor cannot set it. + +Client-id discovery is contributed through this surface: a plugin that resolves clients from an external source registers it with `extendOAuthProvider(ctx, { clientDiscovery })`, or composes one directly via `oauthProvider({ extensions: [{ clientDiscovery }] })`. diff --git a/docs/content/docs/plugins/cimd.mdx b/docs/content/docs/plugins/cimd.mdx index 9a41c6d2ff..3d5ad35ce9 100644 --- a/docs/content/docs/plugins/cimd.mdx +++ b/docs/content/docs/plugins/cimd.mdx @@ -9,7 +9,7 @@ description: Unauthenticated dynamic client discovery for Better Auth's OAuth pr This is the mechanism [MCP](https://modelcontextprotocol.io/specification/draft/basic/authorization#client-id-metadata-documents-flow) uses so authorization servers can discover clients on the fly. -The plugin extends [`@better-auth/oauth-provider`](./oauth-provider) by appending a `ClientDiscovery` entry to its `clientDiscovery` option, so every OAuth endpoint that authenticates a client (authorize, token, introspect, revoke, userinfo, PAR, logout) works seamlessly with URL-format `client_id` values. +The plugin extends [`@better-auth/oauth-provider`](./oauth-provider) by contributing a `ClientDiscovery` entry through its extension surface, so every OAuth endpoint that authenticates a client (authorize, token, introspect, revoke, userinfo, PAR, logout) works seamlessly with URL-format `client_id` values. ## Installation @@ -179,7 +179,7 @@ The plugin ships defense in depth against common abuses of unauthenticated clien ## Composition with `clientDiscovery` -Under the hood, `cimd()` appends a `ClientDiscovery` entry to `oauthProvider`'s `clientDiscovery` option. If you prefer to wire it explicitly, or want to compose CIMD with other discovery implementations, use the `cimdClientDiscovery` factory: +Under the hood, `cimd()` contributes a `ClientDiscovery` entry through `oauthProvider`'s extension surface. If you prefer to wire it explicitly, or want to compose CIMD with other discovery implementations, use the `cimdClientDiscovery` factory: ```ts title="auth.ts" import { oauthProvider } from "@better-auth/oauth-provider"; @@ -189,16 +189,20 @@ export const auth = betterAuth({ plugins: [ jwt(), oauthProvider({ - clientDiscovery: [ - cimdClientDiscovery({ refreshRate: "60m" }), - // other discovery implementations can go here + extensions: [ + { + clientDiscovery: [ + cimdClientDiscovery({ refreshRate: "60m" }), + // other discovery implementations can go here + ], + }, ], }), ], }); ``` -The `clientDiscovery` option accepts a single `ClientDiscovery` or an array. Entries are consulted in order after the database lookup in `getClient()`; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` that the server merges into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses. +The `clientDiscovery` extension field accepts a single `ClientDiscovery` or an array. Entries are consulted in order after the database lookup in `getClient()`; the first entry whose `matches()` returns `true` and whose `resolve()` returns a non-null client wins. Each entry can also contribute `discoveryMetadata` that the server merges into `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` responses. ## Related diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 5e5db46597..7bd319090a 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -1337,6 +1337,91 @@ Some clients (notably MCP clients) need to connect to your authorization server * **`allowUnauthenticatedClientRegistration`**: lets anonymous callers hit `/oauth2/register` to create a public client at request time. * **[`@better-auth/cimd`](/docs/plugins/cimd)**: an optional plugin that lets clients identify themselves by hosting a metadata document at an HTTPS URL. The URL itself becomes the `client_id`; the server fetches and validates the document. This is the pattern MCP calls [Client ID Metadata Documents](https://datatracker.ietf.org/doc/draft-ietf-oauth-client-id-metadata-document/). +### 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. + +```ts title="custom-oauth-extension.ts" +import type { BetterAuthPlugin } from "better-auth"; +import { extendOAuthProvider } from "@better-auth/oauth-provider"; + +export const customOAuthExtension = () => + ({ + id: "custom-oauth-extension", + init(ctx) { + extendOAuthProvider(ctx, { + grants: { + "urn:example:params:oauth:grant-type:custom": async ({ + grantType, + provider, + }) => { + const { client } = await provider.authenticateClient({ grantType }); + return provider.issueTokens({ + client, + scopes: ["openid"], + tokenResponse: { + issued_token_type: + "urn:ietf:params:oauth:token-type:access_token", + }, + }); + }, + }, + metadata: () => ({ + custom_grant_supported: true, + }), + }); + }, + }) satisfies BetterAuthPlugin; +``` + +Extension contributions follow two disciplines: + +* **Dispatched kinds** (`grants`, `clientAuthentication`) must be disjoint across extensions. Registering a grant type, `token_endpoint_auth_method`, or `client_assertion_type` that another extension already registered is rejected at setup, so a contribution can never be silently shadowed. Extension grants and auth methods are advertised in discovery automatically. +* **Additive kinds** (`metadata`, `claims`) never override authorization-server core. A metadata field the provider already owns (`issuer`, `token_endpoint`, `grant_types_supported`, the authentication-method lists, ...) is kept, and a key two extensions both contribute resolves to the first-registered extension. + +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. + +#### Provider capabilities outside a grant + +A grant handler receives a `provider` capability surface (`getClient`, `authenticateClient`, `issueTokens`, `hashToken`, `validateAccessToken`). 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. 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. + +To sender-constrain an issued token (RFC 7800 `cnf`), pass `confirmation` to `issueTokens`, or set it on the authenticated client a `clientAuthentication` strategy returns. The provider stamps it as the access token's `cnf` and marks the response `token_type` accordingly. `cnf` is authorization-server-owned and cannot be set through a claim contributor. + +#### Client authentication obligations + +A `clientAuthentication` strategy verifies the assertion against its own key source and returns the registered client. After verifying the signature it must enforce the same assertion hygiene the built-in `private_key_jwt` method enforces, or the provider will accept a forged or replayed assertion. Use the exported `consumeClientAssertion` helper to bind the assertion to the endpoint audience, require a bounded lifetime, and reject `jti` replays: + +```ts +import { consumeClientAssertion } from "@better-auth/oauth-provider"; + +authenticate: async ({ ctx, opts, assertion, expectedAudience }) => { + const payload = await verifyAssertionSignature(assertion); // your key source + await consumeClientAssertion(ctx, opts, { + // Scopes the replay tombstone; the same jti may recur across distinct + // methods or clients but never within one. + namespace: `urn:example:attestation:${payload.sub}`, + payload, + expectedAudience: expectedAudience!, + }); + return { + clientId: payload.sub as string, + client: await loadRegisteredClient(ctx, opts, payload.sub), + }; +}; +``` + +#### Claim precedence + +Three claim surfaces resolve contributions in a fixed order. Across all three, third-party extension claims are strictly additive, while the operator's own first-party callbacks may override identity claims; subject identity is always pinned by the provider. + +| Token | Order (lowest to highest authority) | Always provider-owned | +| ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Access token | extension `claims.accessToken` \< per-issuance `accessTokenClaims` \< `customAccessTokenClaims` \< per-resource `customClaims` | reserved RFC 9068 names (`iss`, `sub`, `aud`, `exp`, `iat`, `jti`, `client_id`, `scope`, `auth_time`, `acr`, `amr`), stripped before signing | +| ID token | base identity claims \< `auth_time`/`acr` \< `customIdTokenClaims`; extension and per-issuance `idTokenClaims` are additive (fill absent keys only) | `at_hash`, `iss`, `sub`, `aud`, `nonce`, `iat`, `exp`, `sid` | +| UserInfo | base identity claims \< extension `claims.userInfo` (additive only) \< `customUserInfoClaims` | `sub` (re-pinned last) | + +Per-issuance `accessTokenClaims` are JWT-only: an opaque access token persists no per-issuance claims, so they do not reappear at introspection. A claim that must be visible at opaque-token introspection belongs in a grant-type-stable `claims.accessToken` contributor, which the introspection path re-derives. + ### Organizations OAuth Clients are tied to either a user or `reference_id` at registration and is immutable. If you are utilizing the [organization plugin](/docs/plugins/organization), you must ensure that the [`activeOrganizationId`](/docs/plugins/organization#active-organization) is set on your active session when you create new clients. diff --git a/packages/cimd/src/cimd.test.ts b/packages/cimd/src/cimd.test.ts index 55287be201..5c7a7d85ae 100644 --- a/packages/cimd/src/cimd.test.ts +++ b/packages/cimd/src/cimd.test.ts @@ -205,7 +205,7 @@ describe("Client ID Metadata Document - integration", async () => { it("should advertise client_id_metadata_document_supported in discovery", async () => { const config = - (await authorizationServer.api.getOAuthServerConfig()) as Record< + (await authorizationServer.api.getOAuthServerConfig()) as unknown as Record< string, unknown >; diff --git a/packages/cimd/src/index.ts b/packages/cimd/src/index.ts index 6ac862cfb0..681dea08f4 100644 --- a/packages/cimd/src/index.ts +++ b/packages/cimd/src/index.ts @@ -1,5 +1,5 @@ -import { BetterAuthError } from "@better-auth/core/error"; -import type { ClientDiscovery, Scope } from "@better-auth/oauth-provider"; +import type { ClientDiscovery } from "@better-auth/oauth-provider"; +import { extendOAuthProvider } from "@better-auth/oauth-provider"; import type { BetterAuthPlugin } from "better-auth"; import { createCimdResolver } from "./resolver"; import type { CimdOptions } from "./types"; @@ -17,14 +17,14 @@ declare module "@better-auth/core" { /** * Build a {@link ClientDiscovery} for Client ID Metadata Documents. * - * Users who prefer explicit composition can pass the result directly to - * `oauthProvider({ clientDiscovery })`; most users should install the - * {@link cimd} plugin instead, which appends this discovery to whatever - * is already configured. + * Users who prefer explicit composition can contribute the result through + * `oauthProvider({ extensions: [{ clientDiscovery }] })`; most users should + * install the {@link cimd} plugin instead, which contributes this discovery + * alongside whatever else is configured. */ export function cimdClientDiscovery( options: CimdOptions = {}, -): ClientDiscovery { +): ClientDiscovery { const resolver = createCimdResolver(options); return { id: "cimd", @@ -52,18 +52,7 @@ export const cimd = (options: CimdOptions = {}) => { id: "cimd", version: PACKAGE_VERSION, init(ctx) { - const provider = ctx.getPlugin("oauth-provider"); - if (!provider) { - throw new BetterAuthError( - "The cimd plugin requires the oauth-provider plugin.", - ); - } - const existing = provider.options.clientDiscovery; - provider.options.clientDiscovery = Array.isArray(existing) - ? [...existing, discovery] - : existing - ? [existing, discovery] - : discovery; + extendOAuthProvider(ctx, { clientDiscovery: discovery }); }, } satisfies BetterAuthPlugin; }; diff --git a/packages/cimd/src/resolver.ts b/packages/cimd/src/resolver.ts index 8730cb47f0..c39fbd328c 100644 --- a/packages/cimd/src/resolver.ts +++ b/packages/cimd/src/resolver.ts @@ -70,8 +70,9 @@ function isStale( * Build the `resolve` function for a CIMD {@link ClientDiscovery}. * * Exposed for advanced composition. Most users should call - * {@link cimdClientDiscovery} (to pass a complete discovery to - * `oauthProvider({ clientDiscovery })`) or install the `cimd()` plugin. + * {@link cimdClientDiscovery} (to contribute a complete discovery through + * `oauthProvider({ extensions: [{ clientDiscovery }] })`) or install the + * `cimd()` plugin. */ export function createCimdResolver( cimdOptions: CimdOptions = {}, diff --git a/packages/oauth-provider/src/claims.test.ts b/packages/oauth-provider/src/claims.test.ts index d95833dbbb..29602ddb0d 100644 --- a/packages/oauth-provider/src/claims.test.ts +++ b/packages/oauth-provider/src/claims.test.ts @@ -1,7 +1,8 @@ +import type { GenericEndpointContext } from "@better-auth/core"; import { logger } from "@better-auth/core/env"; import { describe, expect, it, vi } from "vitest"; import { resolveAccessTokenClaims } from "./claims"; -import type { OAuthOptions, Scope } from "./types"; +import type { OAuthOptions, SchemaClient, Scope } from "./types"; function optsWith( customAccessTokenClaims?: OAuthOptions["customAccessTokenClaims"], @@ -9,12 +10,19 @@ function optsWith( return { customAccessTokenClaims } as unknown as OAuthOptions; } +// These cases exercise the merge/strip/precedence logic with no extensions +// configured, so `ctx` and `client` are never dereferenced; typed stubs satisfy +// the resolver's input contract without a live request. const baseInput = { + ctx: {} as unknown as GenericEndpointContext, + client: {} as unknown as SchemaClient, user: undefined, scopes: ["openid"], resources: undefined, referenceId: undefined, metadata: undefined, + grantType: undefined, + perRequestClaims: undefined, }; describe("resolveAccessTokenClaims", () => { @@ -63,7 +71,7 @@ describe("resolveAccessTokenClaims", () => { }); expect(warnSpy).toHaveBeenCalledOnce(); const [message] = warnSpy.mock.calls[0] ?? []; - expect(String(message)).toMatch(/stripped reserved RFC 9068 claim/i); + expect(String(message)).toMatch(/stripped reserved access-token claim/i); expect(String(message)).toMatch(/jti/); expect(String(message)).toMatch(/iss/); } finally { @@ -74,11 +82,11 @@ describe("resolveAccessTokenClaims", () => { it("hands derivable token context to customAccessTokenClaims", async () => { let received: Record | undefined; await resolveAccessTokenClaims({ + ...baseInput, opts: optsWith((info) => { received = info as Record; return {}; }), - user: undefined, scopes: ["openid", "email"], resources: ["https://api.example.com"], referenceId: "ref-1", diff --git a/packages/oauth-provider/src/claims.ts b/packages/oauth-provider/src/claims.ts index fdbe96bc90..bec174c4a3 100644 --- a/packages/oauth-provider/src/claims.ts +++ b/packages/oauth-provider/src/claims.ts @@ -1,17 +1,22 @@ +import type { GenericEndpointContext } from "@better-auth/core"; import { logger } from "@better-auth/core/env"; import type { User } from "better-auth/types"; -import type { OAuthOptions, Scope } from "./types"; +import { collectExtensionAccessTokenClaims } from "./extensions"; +import type { OAuthOptions, SchemaClient, Scope } from "./types"; +import type { GrantType } from "./types/oauth"; /** - * Claim names reserved by RFC 9068 §2.2 for OAuth 2.0 JWT-formatted access - * tokens. No claim source (the `customAccessTokenClaims` plugin option or a - * resource row's `customClaims`) can override these: the authorization server - * is the only source of truth for issuer identity, subject, audience, lifetime, - * scope, authentication context, and the token's stable ID. + * Claim names the authorization server owns on a JWT access token, which no + * claim source (the `customAccessTokenClaims` plugin option, an extension + * contributor, or a resource row's `customClaims`) may override. The AS is the + * only source of truth for issuer identity, subject, audience, lifetime, scope, + * authentication context, the token's stable ID, and its sender-constraint + * (`cnf`). * - * @see RFC 9068 §2.2 (Header and Data Structures) + * @see RFC 9068 §2.2 (registered access-token claims) + * @see RFC 7800 / RFC 9449 §6 (`cnf` confirmation — the token's bound key) */ -const RESERVED_RFC9068_CLAIMS = new Set([ +const RESERVED_ACCESS_TOKEN_CLAIMS = new Set([ "iss", "sub", "aud", @@ -23,10 +28,11 @@ const RESERVED_RFC9068_CLAIMS = new Set([ "auth_time", "acr", "amr", + "cnf", ]); /** - * Returns a copy of `claims` with reserved RFC 9068 names removed. Emits a + * Returns a copy of `claims` with reserved AS-owned names removed. Emits a * `warn` naming the stripped keys when any were present (never silently * dropped: surfacing the override attempt matters more than minimizing log * noise). Stable iteration order (`Object.entries`) is preserved so token-debug @@ -39,7 +45,7 @@ function stripReservedClaims( const stripped: string[] = []; const safe: Record = {}; for (const [key, value] of Object.entries(claims)) { - if (RESERVED_RFC9068_CLAIMS.has(key)) { + if (RESERVED_ACCESS_TOKEN_CLAIMS.has(key)) { stripped.push(key); continue; } @@ -47,7 +53,7 @@ function stripReservedClaims( } if (stripped.length > 0) { logger.warn( - `oauth-provider: stripped reserved RFC 9068 claim name(s) from access-token claims: ${stripped.join( + `oauth-provider: stripped reserved access-token claim name(s): ${stripped.join( ", ", )}. The AS owns these claim values.`, ); @@ -62,15 +68,28 @@ function stripReservedClaims( * stored opaque-token row). */ export interface AccessTokenClaimsInput { + ctx: GenericEndpointContext; opts: OAuthOptions; /** Token subject; `null`/`undefined` for `client_credentials`. */ user: User | null | undefined; + client: SchemaClient; /** Effective (post resource-allowlist narrowing) scopes. */ scopes: string[]; resources: string[] | undefined; referenceId: string | undefined; /** Parsed client metadata, as returned by `parseClientMetadata`. */ - metadata: object | undefined; + metadata: Record | undefined; + /** + * Grant type passed to extension claim contributors. Pass `undefined` at + * introspection: the opaque-token row does not persist the grant, so + * contributed access-token claims must be grant-type-stable. + */ + grantType: GrantType | undefined; + /** + * Per-issuance claims a grant handler supplied via `accessTokenClaims`. + * Available only at issuance; `undefined` at introspection. + */ + perRequestClaims: Record | undefined; /** Per-resource `customClaims` from `resolveResourcePolicy` (raw, not yet stripped). */ resourcePolicyClaims: Record; } @@ -82,7 +101,8 @@ export interface AccessTokenClaimsInput { * reserved RFC 9068 names are stripped unconditionally here so no caller can * forget to. * - * Precedence, lowest to highest: plugin `customAccessTokenClaims` < per-resource + * Precedence, lowest to highest: extension contributors < per-issuance + * `accessTokenClaims` < plugin `customAccessTokenClaims` < per-resource * `customClaims`. Reserved names are removed from the merged result. * * Returns only the enriched claims; the caller stamps the AS-owned claims @@ -92,17 +112,43 @@ export interface AccessTokenClaimsInput { export async function resolveAccessTokenClaims( input: AccessTokenClaimsInput, ): Promise> { - const pluginClaims = input.opts.customAccessTokenClaims - ? await input.opts.customAccessTokenClaims({ - user: input.user, - scopes: input.scopes, - resources: input.resources, - referenceId: input.referenceId, - metadata: input.metadata, + const { + ctx, + opts, + user, + client, + scopes, + resources, + referenceId, + metadata, + grantType, + perRequestClaims, + resourcePolicyClaims, + } = input; + const extensionClaims = await collectExtensionAccessTokenClaims(opts, { + ctx, + opts, + user, + client, + scopes, + grantType, + referenceId, + resources, + metadata, + }); + const pluginClaims = opts.customAccessTokenClaims + ? await opts.customAccessTokenClaims({ + user, + scopes, + resources, + referenceId, + metadata, }) : {}; return stripReservedClaims({ + ...extensionClaims, + ...(perRequestClaims ?? {}), ...pluginClaims, - ...input.resourcePolicyClaims, + ...resourcePolicyClaims, }); } diff --git a/packages/oauth-provider/src/extensions.test.ts b/packages/oauth-provider/src/extensions.test.ts new file mode 100644 index 0000000000..95a3c15f05 --- /dev/null +++ b/packages/oauth-provider/src/extensions.test.ts @@ -0,0 +1,1138 @@ +import { APIError } from "better-auth/api"; +import { createAuthClient } from "better-auth/client"; +import { CLIENT_ASSERTION_TYPE } from "better-auth/oauth2"; +import { jwt } from "better-auth/plugins/jwt"; +import { getTestInstance } from "better-auth/test"; +import type { BetterAuthPlugin, User } from "better-auth/types"; +import { decodeJwt } from "jose"; +import { describe, expect, it } from "vitest"; +import { oauthProviderClient } from "./client"; +import { extendOAuthProvider } from "./extensions"; +import { oauthProvider } from "./oauth"; +import { getOAuthProviderApi } from "./token"; +import type { + ClientDiscovery, + OAuthProviderExtension, + SchemaClient, + Scope, +} from "./types"; +import { validateClientCredentials } from "./utils"; +import { consumeClientAssertion } from "./utils/client-assertion"; + +describe("oauth-provider extensions", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const resource = "https://vc.example.com/credential"; + const redirectUri = "https://client.example.com/callback"; + const extensionGrant = "urn:better-auth:test:grant"; + const extensionOpaqueGrant = "urn:better-auth:test:opaque-grant"; + const extensionOpaqueBoundGrant = "urn:better-auth:test:opaque-bound-grant"; + const extensionAuthMethod = "test_attestation_jwt"; + const extensionAssertionType = + "urn:better-auth:test:client-assertion-type:test-attestation"; + let grantUser: User | undefined; + let observedCustomParam: string | undefined; + const clientDiscovery = { + id: "test-client-discovery", + matches: () => false, + resolve: () => null, + discoveryMetadata: { + issuer: "https://malicious-discovery.example.com", + token_endpoint: "https://malicious-discovery.example.com/token", + client_id_metadata_document_supported: true, + }, + } satisfies ClientDiscovery; + + const extensionPlugin = { + id: "test-oauth-extension", + init(ctx) { + extendOAuthProvider(ctx, { + grants: { + [extensionGrant]: async ({ ctx, grantType, provider }) => { + observedCustomParam = (ctx.body as { custom_param?: string }) + .custom_param; + if (!grantUser) { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "test user is not ready", + }); + } + const { client, confirmation } = await provider.authenticateClient({ + scopes: ["openid", "email", "vc"], + grantType, + }); + return provider.issueTokens({ + client, + scopes: ["openid", "email", "vc"], + user: grantUser, + resources: [resource], + // Forward the confirmation the auth strategy proved. + confirmation, + accessTokenClaims: { + grant_claim: "grant-access", + client_id: "malicious-client", + }, + idTokenClaims: { + grant_id_claim: "grant-id", + sub: "malicious-sub", + // Collides with the extension idToken contributor below; + // the per-issuance value must win. + order_probe: "grant", + }, + tokenResponse: { + issued_token_type: "urn:better-auth:test:access_token", + }, + }); + }, + // Issues an opaque access token (no resource -> no audience), so + // introspection re-derives extension claims through the resolver + // instead of returning a signed JWT payload verbatim. + [extensionOpaqueGrant]: async ({ grantType, provider }) => { + if (!grantUser) { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "test user is not ready", + }); + } + const { client } = await provider.authenticateClient({ + scopes: ["openid", "email", "vc"], + grantType, + }); + return provider.issueTokens({ + client, + scopes: ["openid", "email", "vc"], + user: grantUser, + // Per-issuance claims are JWT-only; an opaque token persists + // none, so this must be absent at introspection. + accessTokenClaims: { opaque_per_issuance: "jwt-only" }, + }); + }, + // Attempts a sender-constraint on an opaque token (no resource). + // The provider must fail closed rather than mint an unbound token. + [extensionOpaqueBoundGrant]: async ({ grantType, provider }) => { + if (!grantUser) { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "test user is not ready", + }); + } + const { client } = await provider.authenticateClient({ + scopes: ["openid", "email", "vc"], + grantType, + }); + return provider.issueTokens({ + client, + scopes: ["openid", "email", "vc"], + user: grantUser, + confirmation: { jkt: "should-fail" }, + }); + }, + }, + clientAuthentication: { + [extensionAuthMethod]: { + assertionTypes: [extensionAssertionType], + authenticate: async ({ ctx, assertion, clientId }) => { + if (!clientId || assertion !== `assertion:${clientId}`) { + throw new APIError("UNAUTHORIZED", { + error: "invalid_client", + error_description: "invalid test assertion", + }); + } + const client = await ctx.context.adapter.findOne< + SchemaClient + >({ + model: "oauthClient", + where: [{ field: "clientId", value: clientId }], + }); + if (!client) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client", + error_description: "missing client", + }); + } + return { + clientId, + client, + // A sender-constraint the strategy proved (wallet + // attestation); the grant forwards it to issueTokens. + confirmation: { jkt: "wallet-attested-jkt" }, + }; + }, + }, + }, + metadata: () => ({ + issuer: "https://malicious.example.com", + credential_issuer: "https://vc.example.com", + }), + claims: { + accessToken: () => ({ + extension_access_claim: "extension-access", + client_id: "malicious-extension-client", + cnf: { jkt: "forged-by-extension" }, + }), + idToken: () => ({ + extension_id_claim: "extension-id", + sub: "malicious-extension-sub", + email: "evil@malicious.example", + order_probe: "extension", + }), + userInfo: () => ({ + extension_userinfo_claim: "extension-userinfo", + email: undefined, + sub: "malicious-extension-sub", + }), + }, + clientDiscovery, + }); + }, + } satisfies BetterAuthPlugin; + + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + resources: [resource], + enforcePerClientResources: false, + allowDynamicClientRegistration: true, + scopes: ["openid", "profile", "email", "offline_access", "vc"], + customUserInfoClaims: () => ({ + custom_userinfo_claim: "custom-userinfo", + sub: "malicious-custom-sub", // re-pinned by the endpoint + email_verified: true, // first-party override of a base claim + }), + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + extensionPlugin, + ], + }); + const { headers, user } = await signInWithTestUser(); + grantUser = user; + const client = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: authServerBaseUrl, + fetchOptions: { + customFetchImpl, + }, + }); + + it("advertises extension metadata without overriding core metadata", async () => { + const metadata = (await auth.api.getOpenIdConfig()) as unknown as Record< + string, + unknown + >; + expect(metadata.issuer).toBe(authServerBaseUrl); + expect(metadata.token_endpoint).not.toBe( + "https://malicious-discovery.example.com/token", + ); + expect(metadata.credential_issuer).toBe("https://vc.example.com"); + expect(metadata.client_id_metadata_document_supported).toBe(true); + expect(metadata.grant_types_supported).toContain(extensionGrant); + expect(metadata.token_endpoint_auth_methods_supported).toContain( + extensionAuthMethod, + ); + expect(metadata.introspection_endpoint_auth_methods_supported).toContain( + extensionAuthMethod, + ); + expect(metadata.revocation_endpoint_auth_methods_supported).toContain( + extensionAuthMethod, + ); + }); + + it("registers extension grants and auth methods through normal client creation", async () => { + const adminClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + scope: "openid email vc", + type: "web", + }, + }); + expect(adminClient?.client_id).toBeDefined(); + expect(adminClient?.client_secret).toBeUndefined(); + expect(adminClient?.token_endpoint_auth_method).toBe(extensionAuthMethod); + expect(adminClient?.grant_types).toEqual([extensionGrant]); + expect(adminClient?.redirect_uris).toEqual([]); + expect(adminClient?.response_types).toBeUndefined(); + + const registeredClient = await auth.api.registerOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + scope: "openid email vc", + type: "web", + }, + }); + expect(registeredClient?.client_id).toBeDefined(); + expect(registeredClient?.client_secret).toBeUndefined(); + expect(registeredClient?.token_endpoint_auth_method).toBe( + extensionAuthMethod, + ); + expect(registeredClient?.redirect_uris).toEqual([]); + expect(registeredClient?.response_types).toBeUndefined(); + }); + + it("rejects code response type without authorization code grant", async () => { + let error: unknown; + try { + await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + response_types: ["code"], + scope: "openid email vc", + type: "web", + }, + }); + } catch (e) { + error = e; + } + expect((error as { statusCode?: number } | undefined)?.statusCode).toBe( + 400, + ); + expect( + (error as { body?: { error_description?: string } } | undefined)?.body + ?.error_description, + ).toContain("authorization_code"); + }); + + it("validates jwks_uri for extension auth-method clients", async () => { + let error: unknown; + try { + await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + jwks_uri: "https://127.0.0.1/jwks", + scope: "openid email vc", + type: "web", + }, + }); + } catch (e) { + error = e; + } + expect((error as { statusCode?: number } | undefined)?.statusCode).toBe( + 400, + ); + expect( + (error as { body?: { error_description?: string } } | undefined)?.body + ?.error_description, + ).toContain("private or reserved address"); + }); + + it("rejects extension auth-method clients carrying both jwks and jwks_uri", async () => { + let error: unknown; + try { + await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + jwks: [{ kty: "RSA", n: "test", e: "test-exponent" }], + jwks_uri: "https://client.example.com/jwks", + scope: "openid email vc", + type: "web", + }, + }); + } catch (e) { + error = e; + } + expect((error as { statusCode?: number } | undefined)?.statusCode).toBe( + 400, + ); + expect( + (error as { body?: { error_description?: string } } | undefined)?.body + ?.error_description, + ).toContain("mutually exclusive"); + }); + + it("rejects empty grant type registration", async () => { + const response = await client.$fetch("/oauth2/register", { + method: "POST", + headers, + body: { + grant_types: [], + redirect_uris: [redirectUri], + }, + }); + expect(response.error?.status).toBe(400); + }); + + it("rejects assertion auth for legacy clients with omitted auth method", async () => { + const legacyClient = { + clientId: "legacy-default-client", + public: false, + } as SchemaClient; + await expect( + validateClientCredentials( + {} as Parameters[0], + {} as Parameters[1], + legacyClient.clientId, + undefined, + undefined, + legacyClient, + undefined, + extensionAuthMethod, + ), + ).rejects.toMatchObject({ + statusCode: 400, + body: { + error: "invalid_client", + error_description: `client registered for client_secret_basic cannot use ${extensionAuthMethod}`, + }, + }); + }); + + it("rejects invalid direct extension options during provider setup", () => { + expect(() => + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + extensions: [ + { + grants: { + password: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test grant", + }); + }, + }, + }, + ], + }), + ).toThrow("grant type must be an absolute URI"); + }); + + it("registers an extension at most once per extension object", () => { + const provider = oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + }); + const ctx = { + getPlugin: (id: string) => (id === "oauth-provider" ? provider : null), + } as unknown as Parameters[0]; + const extension = { + grants: { + [extensionGrant]: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test grant", + }); + }, + }, + } satisfies OAuthProviderExtension; + extendOAuthProvider(ctx, extension); + // Re-running init() with the same extension object must not double-register. + extendOAuthProvider(ctx, extension); + expect( + (provider.options as { extensions?: unknown[] }).extensions, + ).toHaveLength(1); + }); + + it("dispatches extension grants through shared token issuance", async () => { + const oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [`${redirectUri}/token`], + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + scope: "openid email vc", + type: "web", + }, + }); + expect(oauthClient?.client_id).toBeDefined(); + + const tokenResponse = await client.$fetch<{ + access_token: string; + id_token: string; + issued_token_type: string; + scope: string; + token_type: string; + }>("/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: extensionGrant, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + custom_param: "preserved", + resource, + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }); + expect(tokenResponse.error).toBeNull(); + expect(tokenResponse.data?.issued_token_type).toBe( + "urn:better-auth:test:access_token", + ); + expect(tokenResponse.data?.scope).toBe("openid email vc"); + expect(observedCustomParam).toBe("preserved"); + + const accessTokenPayload = decodeJwt(tokenResponse.data!.access_token); + expect(accessTokenPayload.extension_access_claim).toBe("extension-access"); + expect(accessTokenPayload.grant_claim).toBe("grant-access"); + expect(accessTokenPayload.client_id).toBe(oauthClient!.client_id); + expect(accessTokenPayload.aud).toContain(resource); + // The auth strategy proved a confirmation, the grant forwarded it through + // authenticateClient, and it is stamped as `cnf`, sender-constraining the + // token. `cnf` is AS-owned, so the extension's forged `cnf` is stripped and + // the strategy's confirmation is the only one present. + expect(accessTokenPayload.cnf).toEqual({ jkt: "wallet-attested-jkt" }); + expect(tokenResponse.data?.token_type).toBe("DPoP"); + + const idTokenPayload = decodeJwt(tokenResponse.data!.id_token); + expect(idTokenPayload.extension_id_claim).toBe("extension-id"); + expect(idTokenPayload.grant_id_claim).toBe("grant-id"); + expect(idTokenPayload.sub).toBe(user.id); + // Extension id-token claims are additive: they cannot replace the user's + // identity claims. + expect(idTokenPayload.email).toBe(user.email); + expect(idTokenPayload.email).not.toBe("evil@malicious.example"); + // Per-issuance idTokenClaims win a collision against an extension idToken + // contributor (extension < per-issuance), matching the access-token ladder. + expect(idTokenPayload.order_probe).toBe("grant"); + + const userInfo = await client.$fetch>( + "/oauth2/userinfo", + { + headers: { + authorization: `Bearer ${tokenResponse.data!.access_token}`, + }, + }, + ); + expect(userInfo.error).toBeNull(); + expect(userInfo.data?.sub).toBe(user.id); + expect(userInfo.data?.email).toBe(user.email); + expect(userInfo.data?.extension_userinfo_claim).toBe("extension-userinfo"); + expect(userInfo.data?.custom_userinfo_claim).toBe("custom-userinfo"); + // First-party customUserInfoClaims may override a provider base claim, + // unlike an additive third-party extension claim. `sub` stays re-pinned. + expect(userInfo.data?.email_verified).toBe(true); + + const introspection = await client.$fetch>( + "/oauth2/introspect", + { + method: "POST", + body: new URLSearchParams({ + token: tokenResponse.data!.access_token, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }, + ); + expect(introspection.error).toBeNull(); + expect(introspection.data?.active).toBe(true); + expect(introspection.data?.extension_access_claim).toBe("extension-access"); + }); + + it("rejects unsupported extension grant types before credential handling", async () => { + const response = await client.$fetch("/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: "urn:better-auth:test:missing-grant", + }), + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + }); + expect(response.error?.status).toBe(400); + expect((response.error as { error?: string } | undefined)?.error).toBe( + "unsupported_grant_type", + ); + }); + + it("rejects extension grant keys that are not absolute URIs", async () => { + const invalidExtensionPlugin = { + id: "invalid-oauth-extension", + init(ctx) { + extendOAuthProvider(ctx, { + grants: { + custom: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test grant", + }); + }, + }, + }); + }, + } satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + invalidExtensionPlugin, + ], + }), + ).rejects.toThrow("grant type must be an absolute URI"); + }); + + it("rejects extension auth method keys reserved by built-in client authentication", async () => { + const invalidExtensionPlugin = { + id: "invalid-oauth-auth-method-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientAuthentication: { + private_key_jwt: { + assertionTypes: [extensionAssertionType], + authenticate: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test authentication", + }); + }, + }, + }, + }); + }, + } satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + invalidExtensionPlugin, + ], + }), + ).rejects.toThrow( + "token_endpoint_auth_method is reserved: private_key_jwt", + ); + }); + + it("rejects extension assertion types reserved by private_key_jwt", async () => { + const invalidExtensionPlugin = { + id: "invalid-oauth-assertion-type-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientAuthentication: { + [extensionAuthMethod]: { + assertionTypes: [CLIENT_ASSERTION_TYPE], + authenticate: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test authentication", + }); + }, + }, + }, + }); + }, + } satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + invalidExtensionPlugin, + ], + }), + ).rejects.toThrow( + `client_assertion_type is reserved: ${CLIENT_ASSERTION_TYPE}`, + ); + }); + + it("rejects extension auth methods without assertion types", async () => { + const invalidExtensionPlugin = { + id: "invalid-oauth-empty-assertion-types-extension", + init(ctx) { + extendOAuthProvider(ctx, { + clientAuthentication: { + [extensionAuthMethod]: { + assertionTypes: [], + authenticate: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test authentication", + }); + }, + }, + }, + }); + }, + } satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + invalidExtensionPlugin, + ], + }), + ).rejects.toThrow("client_assertion_type list cannot be empty"); + }); + + it("rejects two extensions registering the same grant type", async () => { + const sharedGrant = "urn:better-auth:test:shared-grant"; + const makeExtensionPlugin = (id: string) => + ({ + id, + init(ctx) { + extendOAuthProvider(ctx, { + grants: { + [sharedGrant]: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test grant", + }); + }, + }, + }); + }, + }) satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + makeExtensionPlugin("ext-a"), + makeExtensionPlugin("ext-b"), + ], + }), + ).rejects.toThrow("register grant type"); + }); + + it("rejects two extensions registering the same auth method", async () => { + const sharedMethod = "shared_attestation_jwt"; + const makeAuthExtensionPlugin = (id: string, assertionType: string) => + ({ + id, + init(ctx) { + extendOAuthProvider(ctx, { + clientAuthentication: { + [sharedMethod]: { + assertionTypes: [assertionType], + authenticate: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test authentication", + }); + }, + }, + }, + }); + }, + }) satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + makeAuthExtensionPlugin("auth-ext-a", "urn:better-auth:test:a"), + makeAuthExtensionPlugin("auth-ext-b", "urn:better-auth:test:b"), + ], + }), + ).rejects.toThrow("register token_endpoint_auth_method"); + }); + + it("rejects two extensions registering the same assertion type", async () => { + const sharedAssertion = "urn:better-auth:test:shared-assertion"; + const makeAssertionExtensionPlugin = (id: string, method: string) => + ({ + id, + init(ctx) { + extendOAuthProvider(ctx, { + clientAuthentication: { + [method]: { + assertionTypes: [sharedAssertion], + authenticate: async () => { + throw new APIError("BAD_REQUEST", { + error: "invalid_request", + error_description: "unreachable test authentication", + }); + }, + }, + }, + }); + }, + }) satisfies BetterAuthPlugin; + await expect( + getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + makeAssertionExtensionPlugin("assert-ext-a", "method_a_jwt"), + makeAssertionExtensionPlugin("assert-ext-b", "method_b_jwt"), + ], + }), + ).rejects.toThrow("register client_assertion_type"); + }); + + it("resolves a metadata key collision to the first-registered extension", async () => { + const makeMetadataExtensionPlugin = (id: string, value: string) => + ({ + id, + init(ctx) { + extendOAuthProvider(ctx, { + metadata: () => ({ shared_metadata_field: value }), + }); + }, + }) satisfies BetterAuthPlugin; + const instance = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + makeMetadataExtensionPlugin("meta-ext-a", "first"), + makeMetadataExtensionPlugin("meta-ext-b", "second"), + ], + }); + const metadata = + (await instance.auth.api.getOpenIdConfig()) as unknown as Record< + string, + unknown + >; + expect(metadata.shared_metadata_field).toBe("first"); + }); + + it("re-derives extension access-token claims on opaque-token introspection", async () => { + const oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionOpaqueGrant], + scope: "openid email vc", + type: "web", + }, + }); + const tokenResponse = await client.$fetch<{ access_token: string }>( + "/oauth2/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: extensionOpaqueGrant, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }, + ); + expect(tokenResponse.error).toBeNull(); + // Opaque, not a JWT: a random string with no dot-delimited JWT segments. + expect(tokenResponse.data!.access_token).not.toContain("."); + + const introspection = await client.$fetch>( + "/oauth2/introspect", + { + method: "POST", + body: new URLSearchParams({ + token: tokenResponse.data!.access_token, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }, + ); + expect(introspection.error).toBeNull(); + expect(introspection.data?.active).toBe(true); + // Re-derived through the claim authority, not stored on the opaque row. + expect(introspection.data?.extension_access_claim).toBe("extension-access"); + // The contributor tried to set the reserved client_id; the AS owns it. + expect(introspection.data?.client_id).toBe(oauthClient!.client_id); + // Per-issuance extras are JWT-only and must not reappear on opaque tokens. + expect(introspection.data?.opaque_per_issuance).toBeUndefined(); + }); + + it("surfaces an extension assertion rejection as invalid_client", async () => { + const oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionGrant], + scope: "openid email vc", + type: "web", + }, + }); + const response = await client.$fetch("/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: extensionGrant, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: "assertion:wrong", + resource, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + expect(response.error?.status).toBe(401); + expect((response.error as { error?: string } | undefined)?.error).toBe( + "invalid_client", + ); + }); + + it("authenticates an extension assertion on the revoke endpoint", async () => { + const oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionOpaqueGrant], + scope: "openid email vc", + type: "web", + }, + }); + const tokenResponse = await client.$fetch<{ access_token: string }>( + "/oauth2/token", + { + method: "POST", + body: new URLSearchParams({ + grant_type: extensionOpaqueGrant, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }, + ); + expect(tokenResponse.error).toBeNull(); + const accessToken = tokenResponse.data!.access_token; + + const revoke = await client.$fetch("/oauth2/revoke", { + method: "POST", + body: new URLSearchParams({ + token: accessToken, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + // No invalid_client: the extension assertion authenticated on /revoke. + expect(revoke.error).toBeNull(); + + const introspection = await client.$fetch>( + "/oauth2/introspect", + { + method: "POST", + body: new URLSearchParams({ + token: accessToken, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }, + ); + // The revoked token is gone: introspection reports it inactive or unknown, + // never active. + expect(introspection.data?.active).not.toBe(true); + }); + + it("consumeClientAssertion rejects an already-expired assertion", async () => { + const now = Math.floor(Date.now() / 1000); + const audience = `${authServerBaseUrl}/oauth2/token`; + // The expiry check runs before any adapter access, so a minimal ctx is enough. + await expect( + consumeClientAssertion( + { + context: { baseURL: authServerBaseUrl }, + } as Parameters[0], + {} as Parameters[1], + { + namespace: "test:expired", + payload: { aud: audience, exp: now - 10, jti: "expired-jti" }, + expectedAudience: audience, + }, + ), + ).rejects.toMatchObject({ + statusCode: 400, + body: { + error: "invalid_client", + error_description: "client assertion has expired", + }, + }); + }); + + it("fails closed when confirmation is supplied for an opaque token", async () => { + const oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + token_endpoint_auth_method: extensionAuthMethod, + grant_types: [extensionOpaqueBoundGrant], + scope: "openid email vc", + type: "web", + }, + }); + const response = await client.$fetch("/oauth2/token", { + method: "POST", + body: new URLSearchParams({ + grant_type: extensionOpaqueBoundGrant, + client_id: oauthClient!.client_id, + client_assertion_type: extensionAssertionType, + client_assertion: `assertion:${oauthClient!.client_id}`, + }), + headers: { "content-type": "application/x-www-form-urlencoded" }, + }); + // Must not silently mint an unbound (Bearer) token a caller believes is + // sender-constrained: an opaque token cannot carry the constraint yet. + expect(response.error?.status).toBe(500); + }); + + it("authenticateClient binds the assertion audience to the served endpoint", async () => { + let observedAudience: string | undefined; + const opts = { + extensions: [ + { + clientAuthentication: { + audience_probe_method: { + assertionTypes: ["urn:better-auth:test:audience-probe"], + authenticate: async ({ + expectedAudience, + }: { + expectedAudience?: string; + }) => { + observedAudience = expectedAudience; + // Stop before client validation; we only assert the audience. + throw new APIError("BAD_REQUEST", { + error: "invalid_client", + error_description: "audience recorded", + }); + }, + }, + }, + }, + ], + } as unknown as Parameters[1]; + const ctx = { + path: "/oauth2/bc-authorize", + context: { baseURL: authServerBaseUrl }, + body: { + client_id: "probe-client", + client_assertion: "probe-assertion", + client_assertion_type: "urn:better-auth:test:audience-probe", + }, + request: { headers: new Headers() }, + } as unknown as Parameters[0]; + await expect( + getOAuthProviderApi( + ctx, + opts, + "urn:better-auth:test:grant", + ).authenticateClient(), + ).rejects.toBeDefined(); + // The audience is the endpoint serving the request, not a hardcoded token + // endpoint, so the same assertion cannot be replayed at another endpoint. + expect(observedAudience).toBe(`${authServerBaseUrl}/oauth2/bc-authorize`); + }); + + it("getOAuthProviderApi.issueTokens requires a bound grant type", () => { + const api = getOAuthProviderApi( + { + context: { baseURL: authServerBaseUrl }, + } as Parameters[0], + {} as Parameters[1], + ); + // No grantType bound (the accessor was obtained for non-issuing + // capabilities): issuing must fail loudly rather than mislabel the grant. + let thrown: unknown; + try { + api.issueTokens({ + client: { clientId: "x" } as SchemaClient, + scopes: [], + }); + } catch (error) { + thrown = error; + } + expect(thrown).toMatchObject({ + body: { error_description: expect.stringContaining("grant type") }, + }); + }); +}); diff --git a/packages/oauth-provider/src/extensions.ts b/packages/oauth-provider/src/extensions.ts new file mode 100644 index 0000000000..785db164ff --- /dev/null +++ b/packages/oauth-provider/src/extensions.ts @@ -0,0 +1,398 @@ +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 type { + ClientDiscovery, + OAuthClaimExtensionInput, + OAuthClientAuthenticationStrategy, + OAuthMetadataExtensionInput, + OAuthOptions, + OAuthProviderExtension, + OAuthUserInfoExtensionInput, + Scope, +} from "./types"; +import type { Awaitable } from "./types/helpers"; +import type { + AuthMethod, + AuthServerMetadata, + BuiltInGrantType, + GrantType, + OIDCMetadata, + TokenEndpointAuthMethod, +} from "./types/oauth"; + +const DEFAULT_GRANT_TYPES = [ + "authorization_code", + "client_credentials", + "refresh_token", +] as const satisfies BuiltInGrantType[]; + +const BUILT_IN_CONFIDENTIAL_AUTH_METHODS = [ + "client_secret_basic", + "client_secret_post", + "private_key_jwt", +] as const satisfies AuthMethod[]; + +const RESERVED_TOKEN_ENDPOINT_AUTH_METHODS = [ + "none", + ...BUILT_IN_CONFIDENTIAL_AUTH_METHODS, +] as const satisfies TokenEndpointAuthMethod[]; + +const RESERVED_TOKEN_ENDPOINT_AUTH_METHOD_SET = new Set( + RESERVED_TOKEN_ENDPOINT_AUTH_METHODS, +); + +function assertNonEmptyExtensionValue(name: string, value: string) { + if (value.trim().length > 0) return; + throw new BetterAuthError(`OAuth Provider extension ${name} cannot be empty`); +} + +function assertAbsoluteUri(name: string, value: string) { + assertNonEmptyExtensionValue(name, value); + let url: URL | undefined; + try { + url = new URL(value); + } catch { + url = undefined; + } + if (url?.protocol) return; + throw new BetterAuthError( + `OAuth Provider extension ${name} must be an absolute URI: ${value}`, + ); +} + +function assertExtensionGrantType(grantType: string) { + assertAbsoluteUri("grant type", grantType); +} + +function assertExtensionTokenEndpointAuthMethod(method: string) { + assertNonEmptyExtensionValue("token_endpoint_auth_method", method); + if (!RESERVED_TOKEN_ENDPOINT_AUTH_METHOD_SET.has(method)) return; + throw new BetterAuthError( + `OAuth Provider extension token_endpoint_auth_method is reserved: ${method}`, + ); +} + +function assertExtensionClientAssertionType(assertionType: string) { + assertAbsoluteUri("client_assertion_type", assertionType); + if (assertionType !== CLIENT_ASSERTION_TYPE) return; + throw new BetterAuthError( + `OAuth Provider extension client_assertion_type is reserved: ${assertionType}`, + ); +} + +interface ExtensionKeys { + grantTypes: string[]; + authMethods: string[]; + assertionTypes: string[]; +} + +/** + * Validates one extension's dispatched keys (grant types, auth methods, + * assertion types) and returns them for the cross-extension disjointness check. + * Throws on a non-absolute grant/assertion URI, a reserved auth-method name, or + * an empty assertion-type list. + */ +function collectExtensionKeys( + extension: OAuthProviderExtension, +): ExtensionKeys { + const grantTypes = Object.keys(extension.grants ?? {}); + for (const grantType of grantTypes) { + assertExtensionGrantType(grantType); + } + const authMethods: string[] = []; + const assertionTypes: string[] = []; + for (const [method, strategy] of Object.entries( + extension.clientAuthentication ?? {}, + )) { + assertExtensionTokenEndpointAuthMethod(method); + authMethods.push(method); + const methodAssertionTypes = strategy.assertionTypes ?? [method]; + if (methodAssertionTypes.length === 0) { + throw new BetterAuthError( + `OAuth Provider extension client_assertion_type list cannot be empty for ${method}`, + ); + } + for (const assertionType of methodAssertionTypes) { + assertExtensionClientAssertionType(assertionType); + assertionTypes.push(assertionType); + } + } + return { grantTypes, authMethods, assertionTypes }; +} + +function assertNoDuplicateAcrossExtensions(label: string, values: string[]) { + const seen = new Set(); + for (const value of values) { + if (seen.has(value)) { + throw new BetterAuthError( + `OAuth Provider extensions register ${label} "${value}" more than once. Extension contributions must be disjoint.`, + ); + } + seen.add(value); + } +} + +/** + * Validates every extension and rejects two extensions registering the same + * grant type, auth method, or assertion type: otherwise the first would win and + * the second be silently unreachable. Runs at setup over the whole list; + * extensions number in the single digits, so a full re-scan per registration is + * cheaper than the bookkeeping to cache it. + */ +export function validateOAuthProviderExtensions( + extensions: OAuthProviderExtension[] | undefined, +) { + const keys = (extensions ?? []).map(collectExtensionKeys); + assertNoDuplicateAcrossExtensions( + "grant type", + keys.flatMap((k) => k.grantTypes), + ); + assertNoDuplicateAcrossExtensions( + "token_endpoint_auth_method", + keys.flatMap((k) => k.authMethods), + ); + assertNoDuplicateAcrossExtensions( + "client_assertion_type", + keys.flatMap((k) => k.assertionTypes), + ); +} + +function getOAuthProviderExtensions( + opts: OAuthOptions, +): OAuthProviderExtension[] { + return opts.extensions ?? []; +} + +/** + * Flattens the client-id discovery sources contributed by every registered + * extension into a single ordered list. `getClient()` consults them in order; + * the metadata endpoints merge their `discoveryMetadata`. + */ +export function getClientDiscoveries( + opts: OAuthOptions, +): ClientDiscovery[] { + return getOAuthProviderExtensions(opts).flatMap((extension) => { + const discovery = extension.clientDiscovery; + if (!discovery) return []; + return Array.isArray(discovery) ? discovery : [discovery]; + }); +} + +/** + * Registers an {@link OAuthProviderExtension} with the OAuth Provider plugin + * from a companion plugin's `init()` hook. An extension can add token grants, + * assertion-based client authentication methods, additive discovery metadata, + * access-token / ID-token / UserInfo claims, and client-id discovery, without + * forking provider core. + * + * Call this once, at `init()` time. It is idempotent in the same `extension` + * object, so re-running a plugin's `init()` (for example when one plugin factory + * result is shared across two `betterAuth()` instances) does not register it + * twice. It throws if the oauth-provider plugin is not installed, if a grant + * type or assertion type is not an absolute URI, if a client authentication + * method reuses a built-in name, or if the extension registers a grant type, + * auth method, or assertion type that another extension already registered + * (contributions must be disjoint). + * + * @example + * ```ts + * init(ctx) { + * extendOAuthProvider(ctx, { + * grants: { "urn:example:grant": async ({ provider }) => provider.issueTokens(...) }, + * }); + * } + * ``` + */ +export function extendOAuthProvider( + ctx: AuthContext, + extension: OAuthProviderExtension, +) { + const provider = ctx.getPlugin("oauth-provider"); + if (!provider) { + throw new BetterAuthError( + "extendOAuthProvider requires the oauth-provider plugin.", + ); + } + const existing = provider.options.extensions ?? []; + if (existing.includes(extension)) return; + const extensions = [...existing, extension]; + validateOAuthProviderExtensions(extensions); + provider.options.extensions = extensions; +} + +function getExtensionGrantTypes(opts: OAuthOptions): GrantType[] { + return getOAuthProviderExtensions(opts).flatMap((extension) => + Object.keys(extension.grants ?? {}), + ); +} + +export function getSupportedGrantTypes( + opts: OAuthOptions, +): GrantType[] { + return Array.from( + new Set([ + ...(opts.grantTypes ?? DEFAULT_GRANT_TYPES), + ...getExtensionGrantTypes(opts), + ]), + ); +} + +export function getExtensionGrantHandler( + opts: OAuthOptions, + grantType: GrantType, +) { + for (const extension of getOAuthProviderExtensions(opts)) { + const handler = extension.grants?.[grantType]; + if (handler) return handler; + } + return undefined; +} + +function getExtensionTokenEndpointAuthMethods( + opts: OAuthOptions, +): TokenEndpointAuthMethod[] { + return getOAuthProviderExtensions(opts).flatMap((extension) => + Object.keys(extension.clientAuthentication ?? {}), + ); +} + +/** + * Confidential and extension client-authentication methods the provider + * supports. Pass `includeNone` to prepend `"none"` for the token endpoint and + * DCR, where public clients are allowed; the introspection and revocation + * endpoints, which never accept public clients, omit it (the default). + */ +export function getSupportedAuthMethods( + opts: OAuthOptions, + settings?: { includeNone?: boolean }, +): TokenEndpointAuthMethod[] { + return Array.from( + new Set([ + ...(settings?.includeNone ? (["none"] as TokenEndpointAuthMethod[]) : []), + ...BUILT_IN_CONFIDENTIAL_AUTH_METHODS, + ...getExtensionTokenEndpointAuthMethods(opts), + ]), + ); +} + +export function isExtensionTokenEndpointAuthMethod( + opts: OAuthOptions, + method: TokenEndpointAuthMethod | undefined, +) { + return method + ? getExtensionTokenEndpointAuthMethods(opts).includes(method) + : false; +} + +export function getExtensionClientAuthenticationStrategy( + opts: OAuthOptions, + assertionType: string, +): + | { + method: TokenEndpointAuthMethod; + strategy: OAuthClientAuthenticationStrategy; + } + | undefined { + if (assertionType === CLIENT_ASSERTION_TYPE) return undefined; + for (const extension of getOAuthProviderExtensions(opts)) { + const strategies = extension.clientAuthentication ?? {}; + for (const [method, strategy] of Object.entries(strategies)) { + const assertionTypes = strategy.assertionTypes ?? [method]; + if (assertionTypes.includes(assertionType)) { + return { method, strategy }; + } + } + } + return undefined; +} + +/** + * Merges each registered extension's `metadata()` contribution into `document`, + * first-wins: the provider owns every key it already wrote, so an extension can + * add fields but never override core. Each contributor sees the base `document`, + * not the running accumulation, so contributions stay order-independent. + */ +export function applyOAuthProviderMetadataExtensions< + T extends AuthServerMetadata | OIDCMetadata, +>( + ctx: GenericEndpointContext, + opts: OAuthOptions, + type: OAuthMetadataExtensionInput["type"], + document: T, +): T { + const next: Record = { ...document }; + for (const extension of getOAuthProviderExtensions(opts)) { + const contribution = extension.metadata?.({ ctx, opts, type, document }); + for (const [key, value] of Object.entries(contribution ?? {})) { + if (!(key in next)) { + next[key] = value; + } + } + } + return next as T; +} + +async function collectClaims( + opts: OAuthOptions, + run: ( + extension: OAuthProviderExtension, + ) => Awaitable | undefined>, +) { + const claims: Record = {}; + // First contributor wins a key, matching metadata extensions. Extensions are + // expected to contribute disjoint claims, so a collision is a + // misconfiguration: keep the first value and warn rather than silently + // shadow the later contributor. + for (const extension of getOAuthProviderExtensions(opts)) { + const contribution = (await run(extension)) ?? {}; + for (const [key, value] of Object.entries(contribution)) { + if (key in claims) { + logger.warn( + `oauth-provider: two extensions contributed the claim "${key}"; keeping the first-registered value.`, + ); + continue; + } + claims[key] = value; + } + } + return claims; +} + +export function collectExtensionAccessTokenClaims( + opts: OAuthOptions, + input: OAuthClaimExtensionInput, +) { + return collectClaims(opts, (extension) => + extension.claims?.accessToken?.(input), + ); +} + +export function collectExtensionIdTokenClaims( + opts: OAuthOptions, + input: OAuthClaimExtensionInput, +) { + return collectClaims(opts, (extension) => extension.claims?.idToken?.(input)); +} + +export function collectExtensionUserInfoClaims( + opts: OAuthOptions, + input: OAuthUserInfoExtensionInput, +) { + return collectClaims(opts, (extension) => + extension.claims?.userInfo?.(input), + ); +} + +/** + * Whether any registered extension contributes UserInfo claims. Lets the + * UserInfo endpoint skip loading the client when nothing needs it. + */ +export function hasUserInfoClaimExtension( + opts: OAuthOptions, +): boolean { + return getOAuthProviderExtensions(opts).some( + (extension) => extension.claims?.userInfo, + ); +} diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index d233d52bc6..fad0fc8858 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -1,7 +1,9 @@ export { getIssuer } from "./authorize"; +export { extendOAuthProvider } from "./extensions"; export { authServerMetadata, metadataResponse, + oauthAuthorizationServerMetadata, oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata, oidcServerMetadata, @@ -21,6 +23,8 @@ export type { } from "./oauth-endpoint"; export { checkOAuthClient, oauthToSchema } from "./register"; export { raiseResourceServerChallenge } from "./resource-challenge"; +export { getOAuthProviderApi } from "./token"; export type * from "./types"; export type { OAuthClient, ResourceServerMetadata } from "./types/oauth"; export { ResourceUriSchema } from "./types/zod"; +export { consumeClientAssertion } from "./utils/client-assertion"; diff --git a/packages/oauth-provider/src/introspect.ts b/packages/oauth-provider/src/introspect.ts index 12b9602076..cbc17ba69b 100644 --- a/packages/oauth-provider/src/introspect.ts +++ b/packages/oauth-provider/src/introspect.ts @@ -275,6 +275,10 @@ async function validateOpaqueAccessToken( }; } + const resources = Array.isArray(accessToken.resources) + ? accessToken.resources + : undefined; + let client: SchemaClient | null | undefined; if (accessToken.clientId) { client = await getClient(ctx, opts, accessToken.clientId); @@ -289,9 +293,7 @@ async function validateOpaqueAccessToken( !(await isIntrospectionAuthorized(ctx, opts, { introspectingClientId: clientId, issuerClientId: accessToken.clientId, - audienceResources: Array.isArray(accessToken.resources) - ? accessToken.resources - : [], + audienceResources: resources ?? [], })) ) { return { active: false }; @@ -322,9 +324,6 @@ async function validateOpaqueAccessToken( if (accessToken.userId) { user = await ctx.context.internalAdapter.findUserById(accessToken?.userId); } - const resources = Array.isArray(accessToken.resources) - ? accessToken.resources - : undefined; const userInfoEndpoint = `${ctx.context.baseURL}/oauth2/userinfo`; // Deleting a resource row revokes the tokens bound to it: introspection @@ -355,15 +354,24 @@ async function validateOpaqueAccessToken( const resourcePolicyClaims = resources?.length ? await getResourceCustomClaims(ctx, opts, resources) : {}; - const accessTokenClaims = await resolveAccessTokenClaims({ - opts, - user, - scopes: accessToken.scopes ?? [], - resources, - referenceId: accessToken.referenceId, - metadata: parseClientMetadata(client?.metadata), - resourcePolicyClaims, - }); + // `grantType` and per-issuance extras are not persisted on the opaque row, + // so extension claims are re-derived grant-type-stable and per-request + // extras are JWT-only (see `resolveAccessTokenClaims`). + const accessTokenClaims = client + ? await resolveAccessTokenClaims({ + ctx, + opts, + user, + client, + scopes: accessToken.scopes ?? [], + grantType: undefined, + resources, + referenceId: accessToken.referenceId, + metadata: parseClientMetadata(client.metadata), + perRequestClaims: undefined, + resourcePolicyClaims, + }) + : {}; // Return the access token in introspection format // https://datatracker.ietf.org/doc/html/rfc7662#section-2.2 @@ -577,6 +585,7 @@ export async function introspectEndpoint( clientId: client_id, clientSecret: client_secret, preVerifiedClient, + authMethod, } = destructureCredentials(credentials); if (!client_id || (!client_secret && !preVerifiedClient)) { @@ -605,6 +614,8 @@ export async function introspectEndpoint( client_secret, undefined, preVerifiedClient, + undefined, + authMethod, ); try { diff --git a/packages/oauth-provider/src/metadata.ts b/packages/oauth-provider/src/metadata.ts index 9b5105e4c8..c9d9834f03 100644 --- a/packages/oauth-provider/src/metadata.ts +++ b/packages/oauth-provider/src/metadata.ts @@ -2,6 +2,12 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { PRIVATE_KEY_JWT_SIGNING_ALGORITHMS } from "@better-auth/core/oauth2"; import type { JWSAlgorithms, JwtOptions } from "better-auth/plugins"; import { validateIssuerUrl } from "./authorize"; +import { + applyOAuthProviderMetadataExtensions, + getClientDiscoveries, + getSupportedAuthMethods, + getSupportedGrantTypes, +} from "./extensions"; import type { OAuthOptions, Scope } from "./types"; import type { AuthServerMetadata, @@ -9,11 +15,7 @@ import type { OIDCMetadata, TokenEndpointAuthMethod, } from "./types/oauth"; -import { - getJwtPlugin, - mergeDiscoveryMetadata, - toClientDiscoveryArray, -} from "./utils"; +import { getJwtPlugin, mergeDiscoveryMetadata } from "./utils"; export function authServerMetadata( ctx: GenericEndpointContext, @@ -23,6 +25,8 @@ export function authServerMetadata( dynamic_client_registration_supported?: boolean; public_client_supported?: boolean; grant_types_supported?: GrantType[]; + token_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; + endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; jwt_disabled?: boolean; }, ) { @@ -55,30 +59,33 @@ export function authServerMetadata( "client_credentials", "refresh_token", ], - token_endpoint_auth_methods_supported: [ - ...(overrides?.public_client_supported - ? (["none"] satisfies TokenEndpointAuthMethod[]) - : []), - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ], + token_endpoint_auth_methods_supported: + overrides?.token_endpoint_auth_methods_supported ?? [ + ...(overrides?.public_client_supported + ? (["none"] satisfies TokenEndpointAuthMethod[]) + : []), + "client_secret_basic", + "client_secret_post", + "private_key_jwt", + ], token_endpoint_auth_signing_alg_values_supported: [ ...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, ], - introspection_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ], + introspection_endpoint_auth_methods_supported: + overrides?.endpoint_auth_methods_supported ?? [ + "client_secret_basic", + "client_secret_post", + "private_key_jwt", + ], introspection_endpoint_auth_signing_alg_values_supported: [ ...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, ], - revocation_endpoint_auth_methods_supported: [ - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ], + revocation_endpoint_auth_methods_supported: + overrides?.endpoint_auth_methods_supported ?? [ + "client_secret_basic", + "client_secret_post", + "private_key_jwt", + ], revocation_endpoint_auth_signing_alg_values_supported: [ ...PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, ], @@ -90,27 +97,64 @@ export function authServerMetadata( return metadata; } +/** + * Builds the authorization-server metadata shared by the + * `/.well-known/oauth-authorization-server` and `/.well-known/openid-configuration` + * responses, plus the inputs both need to finish their own document. + */ +function buildAuthServerMetadata( + ctx: GenericEndpointContext, + opts: OAuthOptions, +) { + const jwtPluginOptions = opts.disableJwtPlugin + ? undefined + : getJwtPlugin(ctx.context).options; + const clientDiscoveries = getClientDiscoveries(opts); + // Any contributed `clientDiscovery` implicitly produces public clients + // (CIMD, wallet attestation, etc.), so it flips `public_client_supported` + // and the advertised `"none"` auth method alongside unauthenticated DCR. + const publicClientSupported = + opts.allowUnauthenticatedClientRegistration || clientDiscoveries.length > 0; + const authMetadata = authServerMetadata(ctx, jwtPluginOptions, { + scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes, + dynamic_client_registration_supported: opts.allowDynamicClientRegistration, + public_client_supported: publicClientSupported, + grant_types_supported: getSupportedGrantTypes(opts), + token_endpoint_auth_methods_supported: getSupportedAuthMethods(opts, { + includeNone: publicClientSupported, + }), + endpoint_auth_methods_supported: getSupportedAuthMethods(opts), + jwt_disabled: opts.disableJwtPlugin, + }); + return { jwtPluginOptions, clientDiscoveries, authMetadata }; +} + +export function oauthAuthorizationServerMetadata( + ctx: GenericEndpointContext, + opts: OAuthOptions, +): AuthServerMetadata { + const { clientDiscoveries, authMetadata } = buildAuthServerMetadata( + ctx, + opts, + ); + return applyOAuthProviderMetadataExtensions( + ctx, + opts, + "oauth-authorization-server", + { + ...mergeDiscoveryMetadata(clientDiscoveries), + ...authMetadata, + }, + ); +} + export function oidcServerMetadata( ctx: GenericEndpointContext, opts: OAuthOptions & { claims?: string[] }, ) { const baseURL = ctx.context.baseURL; - const jwtPluginOptions = opts.disableJwtPlugin - ? undefined - : getJwtPlugin(ctx.context).options; - const authMetadata = authServerMetadata(ctx, jwtPluginOptions, { - scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes, - dynamic_client_registration_supported: opts.allowDynamicClientRegistration, - // `public_client_supported` flips `"none"` into the advertised auth - // methods. Any configured `clientDiscovery` implicitly produces public - // clients (CIMD, wallet attestation, etc.), so the flag must reflect - // that in addition to unauthenticated DCR. - public_client_supported: - opts.allowUnauthenticatedClientRegistration || - toClientDiscoveryArray(opts.clientDiscovery).length > 0, - grant_types_supported: opts.grantTypes, - jwt_disabled: opts.disableJwtPlugin, - }); + const { jwtPluginOptions, clientDiscoveries, authMetadata } = + buildAuthServerMetadata(ctx, opts); const metadata: Omit< OIDCMetadata, "id_token_signing_alg_values_supported" @@ -145,10 +189,15 @@ export function oidcServerMetadata( "none", ], }; - return { - ...metadata, - ...mergeDiscoveryMetadata(opts.clientDiscovery), - }; + return applyOAuthProviderMetadataExtensions( + ctx, + opts, + "openid-configuration", + { + ...mergeDiscoveryMetadata(clientDiscoveries), + ...metadata, + }, + ); } // Cache for 15s with a short stale window; metadata rarely changes. diff --git a/packages/oauth-provider/src/oauth-endpoint.integration.test.ts b/packages/oauth-provider/src/oauth-endpoint.integration.test.ts index 0f6d52052f..7564416987 100644 --- a/packages/oauth-provider/src/oauth-endpoint.integration.test.ts +++ b/packages/oauth-provider/src/oauth-endpoint.integration.test.ts @@ -152,19 +152,19 @@ describe("RFC envelope compliance across OAuth endpoints", async () => { }); describe("registerOAuthClient (JSON delivery)", () => { - it("missing redirect_uris → invalid_redirect_uri", async () => { + it("disabled registration rejects before metadata validation", async () => { const { status, body } = await postJson("/oauth2/register", {}); - expect(status).toBe(400); - expect(body?.error).toBe("invalid_redirect_uri"); + expect(status).toBe(403); + expect(body?.error).toBe("access_denied"); }); - it("unsupported token_endpoint_auth_method → invalid_client_metadata default", async () => { + it("disabled registration does not leak supported auth methods", async () => { const { status, body } = await postJson("/oauth2/register", { redirect_uris: [redirectUri], token_endpoint_auth_method: "not_a_real_method", }); - expect(status).toBe(400); - expect(body?.error).toBe("invalid_client_metadata"); + expect(status).toBe(403); + expect(body?.error).toBe("access_denied"); }); }); diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index b2308b4a88..e32a6376a5 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -20,6 +20,7 @@ import type { AuthorizeEndpointSettings } from "./authorize"; import { authorizeEndpoint, authorizeRedirectOnError } from "./authorize"; import { consentEndpoint } from "./consent"; import { continueEndpoint } from "./continue"; +import { validateOAuthProviderExtensions } from "./extensions"; import { introspectEndpoint } from "./introspect"; import { deliverBackchannelLogoutTokens, @@ -27,8 +28,8 @@ import { rpInitiatedLogoutEndpoint, } from "./logout"; import { - authServerMetadata, metadataResponse, + oauthAuthorizationServerMetadata, oidcServerMetadata, } from "./metadata"; import { createOAuthEndpoint } from "./oauth-endpoint"; @@ -55,13 +56,11 @@ import { getJwtPlugin, getSignedQueryIssuedAt, isSessionFreshForSignedQuery, - mergeDiscoveryMetadata, postLoginClearedParam, removeMaxAgeFromQuery, removePromptFromQuery, searchParamsToQuery, signedQueryIssuedAtParam, - toClientDiscoveryArray, verifyOAuthQueryParams, } from "./utils"; import { PACKAGE_VERSION } from "./version"; @@ -179,6 +178,7 @@ export const oauthProvider = >(options: O) => { claims: Array.from(claims), clientRegistrationAllowedScopes, }; + validateOAuthProviderExtensions(opts.extensions); // Validate pairwiseSecret minimum length if (opts.pairwiseSecret && opts.pairwiseSecret.length < 32) { @@ -281,25 +281,10 @@ export const oauthProvider = >(options: O) => { ), }; } - const jwtPluginOptions = opts.disableJwtPlugin - ? undefined - : getJwtPlugin(ctx)?.options; - const authMetadata = authServerMetadata(endpointCtx, jwtPluginOptions, { - scopes_supported: - opts.advertisedMetadata?.scopes_supported ?? opts.scopes, - dynamic_client_registration_supported: - opts.allowDynamicClientRegistration, - public_client_supported: - opts.allowUnauthenticatedClientRegistration || - toClientDiscoveryArray(opts.clientDiscovery).length > 0, - grant_types_supported: opts.grantTypes, - jwt_disabled: opts.disableJwtPlugin, - }); return { - response: createMetadataResponse({ - ...authMetadata, - ...mergeDiscoveryMetadata(opts.clientDiscovery), - }), + response: createMetadataResponse( + oauthAuthorizationServerMetadata(endpointCtx, opts), + ), }; } @@ -708,24 +693,7 @@ export const oauthProvider = >(options: O) => { const metadata = oidcServerMetadata(ctx, opts); return metadata; } else { - const jwtPluginOptions = opts.disableJwtPlugin - ? undefined - : getJwtPlugin(ctx.context)?.options; - const authMetadata = authServerMetadata(ctx, jwtPluginOptions, { - scopes_supported: - opts.advertisedMetadata?.scopes_supported ?? opts.scopes, - dynamic_client_registration_supported: - opts.allowDynamicClientRegistration, - public_client_supported: - opts.allowUnauthenticatedClientRegistration || - toClientDiscoveryArray(opts.clientDiscovery).length > 0, - grant_types_supported: opts.grantTypes, - jwt_disabled: opts.disableJwtPlugin, - }); - return { - ...authMetadata, - ...mergeDiscoveryMetadata(opts.clientDiscovery), - }; + return oauthAuthorizationServerMetadata(ctx, opts); } }, ), @@ -864,29 +832,23 @@ export const oauthProvider = >(options: O) => { // second time, which only works when better-call clones // the request before its own parse. cloneRequest: true, - body: z.object({ - grant_type: z - .string() - .pipe( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ), - client_id: z.string().optional(), - client_secret: z.string().optional(), - client_assertion: z.string().optional(), - client_assertion_type: z.string().optional(), - code: z.string().optional(), - code_verifier: z.string().optional(), - redirect_uri: SafeUrlSchema.optional(), - refresh_token: z.string().optional(), - resource: z - .union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]) - .optional(), - scope: z.string().optional(), - }), + body: z + .object({ + grant_type: z.string().trim().min(1), + client_id: z.string().optional(), + client_secret: z.string().optional(), + client_assertion: z.string().optional(), + client_assertion_type: z.string().optional(), + code: z.string().optional(), + code_verifier: z.string().optional(), + redirect_uri: SafeUrlSchema.optional(), + refresh_token: z.string().optional(), + resource: z + .union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)]) + .optional(), + scope: z.string().optional(), + }) + .passthrough(), errorCodesByField: { grant_type: { missing: "invalid_request", @@ -907,11 +869,6 @@ export const oauthProvider = >(options: O) => { properties: { grant_type: { type: "string", - enum: [ - "authorization_code", - "client_credentials", - "refresh_token", - ], description: "OAuth2 grant type", }, client_id: { @@ -1439,7 +1396,7 @@ export const oauthProvider = >(options: O) => { { method: "POST", body: z.object({ - redirect_uris: z.array(SafeUrlSchema).min(1).min(1), + redirect_uris: z.array(SafeUrlSchema).min(1).optional(), scope: z.string().optional(), client_name: z.string().optional(), client_uri: z.string().optional(), @@ -1453,15 +1410,7 @@ export const oauthProvider = >(options: O) => { post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(), backchannel_logout_uri: SafeUrlSchema.optional(), backchannel_logout_session_required: z.boolean().optional(), - token_endpoint_auth_method: z - .enum([ - "none", - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ]) - .default("client_secret_basic") - .optional(), + token_endpoint_auth_method: z.string().trim().min(1).optional(), jwks: z .union([ z.array(z.record(z.string(), z.unknown())).min(1), @@ -1471,20 +1420,8 @@ export const oauthProvider = >(options: O) => { ]) .optional(), jwks_uri: z.string().optional(), - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .default(["authorization_code"]) - .optional(), - response_types: z - .array(z.enum(["code"])) - .default(["code"]) - .optional(), + grant_types: z.array(z.string().trim().min(1)).min(1).optional(), + response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), subject_type: z.enum(["public", "pairwise"]).optional(), // RFC 7591 §2 extension: declare the resources this client @@ -1617,25 +1554,14 @@ export const oauthProvider = >(options: O) => { type: "string", description: "Requested authentication method for the token endpoint", - enum: [ - "none", - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ], }, grant_types: { type: "array", items: { type: "string", - enum: [ - "authorization_code", - "client_credentials", - "refresh_token", - ], }, description: - "Requested authentication method for the token endpoint", + "Grant types the client may use at the token endpoint", }, response_types: { type: "array", @@ -1644,7 +1570,7 @@ export const oauthProvider = >(options: O) => { enum: ["code"], }, description: - "Requested authentication method for the token endpoint", + "Response types the client may use at the authorization endpoint", }, public: { type: "boolean", diff --git a/packages/oauth-provider/src/oauthClient/index.ts b/packages/oauth-provider/src/oauthClient/index.ts index 07e3cf0860..91558c6ec1 100644 --- a/packages/oauth-provider/src/oauthClient/index.ts +++ b/packages/oauth-provider/src/oauthClient/index.ts @@ -13,13 +13,16 @@ import { updateClientEndpoint, } from "./endpoints"; +const tokenEndpointAuthMethodSchema = z.string().trim().min(1); +const grantTypesSchema = z.array(z.string().trim().min(1)).min(1); + export const adminCreateOAuthClient = (opts: OAuthOptions) => createAuthEndpoint( "/admin/oauth2/create-client", { method: "POST", body: z.object({ - redirect_uris: z.array(SafeUrlSchema).min(1), + redirect_uris: z.array(SafeUrlSchema).min(1).optional(), scope: z.string().optional(), client_name: z.string().optional(), client_uri: z.string().optional(), @@ -33,15 +36,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(), backchannel_logout_uri: SafeUrlSchema.optional(), backchannel_logout_session_required: z.boolean().optional(), - token_endpoint_auth_method: z - .enum([ - "none", - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ]) - .default("client_secret_basic") - .optional(), + token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(), jwks: z .union([ z.array(z.record(z.string(), z.unknown())).min(1), @@ -51,20 +46,8 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => ]) .optional(), jwks_uri: z.string().optional(), - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .default(["authorization_code"]) - .optional(), - response_types: z - .array(z.enum(["code"])) - .default(["code"]) - .optional(), + grant_types: grantTypesSchema.optional(), + response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), // SERVER_ONLY applicable fields client_secret_expires_at: z @@ -171,24 +154,14 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => type: "string", description: "Requested authentication method for the token endpoint", - enum: [ - "none", - "client_secret_basic", - "client_secret_post", - ], }, grant_types: { type: "array", items: { type: "string", - enum: [ - "authorization_code", - "client_credentials", - "refresh_token", - ], }, description: - "Requested authentication method for the token endpoint", + "Grant types the client may use at the token endpoint", }, response_types: { type: "array", @@ -197,7 +170,7 @@ export const adminCreateOAuthClient = (opts: OAuthOptions) => enum: ["code"], }, description: - "Requested authentication method for the token endpoint", + "Response types the client may use at the authorization endpoint", }, public: { type: "boolean", @@ -248,7 +221,7 @@ export const createOAuthClient = (opts: OAuthOptions) => method: "POST", use: [sessionMiddleware], body: z.object({ - redirect_uris: z.array(SafeUrlSchema).min(1), + redirect_uris: z.array(SafeUrlSchema).min(1).optional(), scope: z.string().optional(), client_name: z.string().optional(), client_uri: z.string().optional(), @@ -262,15 +235,7 @@ export const createOAuthClient = (opts: OAuthOptions) => post_logout_redirect_uris: z.array(SafeUrlSchema).min(1).optional(), backchannel_logout_uri: SafeUrlSchema.optional(), backchannel_logout_session_required: z.boolean().optional(), - token_endpoint_auth_method: z - .enum([ - "none", - "client_secret_basic", - "client_secret_post", - "private_key_jwt", - ]) - .default("client_secret_basic") - .optional(), + token_endpoint_auth_method: tokenEndpointAuthMethodSchema.optional(), jwks: z .union([ z.array(z.record(z.string(), z.unknown())).min(1), @@ -280,20 +245,8 @@ export const createOAuthClient = (opts: OAuthOptions) => ]) .optional(), jwks_uri: z.string().optional(), - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .default(["authorization_code"]) - .optional(), - response_types: z - .array(z.enum(["code"])) - .default(["code"]) - .optional(), + grant_types: grantTypesSchema.optional(), + response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), }), metadata: { @@ -387,25 +340,16 @@ export const createOAuthClient = (opts: OAuthOptions) => }, token_endpoint_auth_method: { type: "string", - description: "Response types the client may use", - enum: [ - "none", - "client_secret_basic", - "client_secret_post", - ], + description: + "Requested authentication method for the token endpoint", }, grant_types: { type: "array", items: { type: "string", - enum: [ - "authorization_code", - "client_credentials", - "refresh_token", - ], }, description: - "Requested authentication method for the token endpoint", + "Grant types the client may use at the token endpoint", }, response_types: { type: "array", @@ -414,7 +358,7 @@ export const createOAuthClient = (opts: OAuthOptions) => enum: ["code"], }, description: - "Requested authentication method for the token endpoint", + "Response types the client may use at the authorization endpoint", }, public: { type: "boolean", @@ -557,15 +501,7 @@ export const adminUpdateOAuthClient = (opts: OAuthOptions) => backchannel_logout_uri: SafeUrlSchema.optional(), backchannel_logout_session_required: z.boolean().optional(), // NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .optional(), + grant_types: grantTypesSchema.optional(), response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), // SERVER_ONLY applicable fields @@ -613,15 +549,7 @@ export const updateOAuthClient = (opts: OAuthOptions) => backchannel_logout_uri: SafeUrlSchema.optional(), backchannel_logout_session_required: z.boolean().optional(), // NOTE: token_endpoint_auth_method is currently immutable since it changes isPublic definition - grant_types: z - .array( - z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - ) - .optional(), + grant_types: grantTypesSchema.optional(), response_types: z.array(z.enum(["code"])).optional(), type: z.enum(["web", "native", "user-agent-based"]).optional(), }), diff --git a/packages/oauth-provider/src/register.test.ts b/packages/oauth-provider/src/register.test.ts index 4e09f66ae4..3ba111894d 100644 --- a/packages/oauth-provider/src/register.test.ts +++ b/packages/oauth-provider/src/register.test.ts @@ -74,6 +74,21 @@ describe("oauth register", async () => { expect(response.error?.status).toBe(401); }); + it("should reject unauthenticated registration before metadata validation", async () => { + const unauthenticatedClient = createAuthClient({ + plugins: [oauthProviderClient()], + baseURL: baseUrl, + fetchOptions: { + customFetchImpl, + }, + }); + const response = await unauthenticatedClient.oauth2.register({ + redirect_uris: [redirectUri], + token_endpoint_auth_method: "not_a_real_method", + }); + expect(response.error?.status).toBe(401); + }); + it("should register private client with minimum requirements", async () => { const response = await serverClient.oauth2.register({ redirect_uris: [redirectUri], diff --git a/packages/oauth-provider/src/register.ts b/packages/oauth-provider/src/register.ts index 0f7e6f16b8..a6905b0019 100644 --- a/packages/oauth-provider/src/register.ts +++ b/packages/oauth-provider/src/register.ts @@ -4,10 +4,19 @@ import { isLoopbackHost } from "@better-auth/core/utils/host"; import { APIError, getSessionFromCtx } from "better-auth/api"; import { generateRandomString } from "better-auth/crypto"; import { toExpJWT } from "better-auth/plugins"; +import { + getSupportedAuthMethods, + getSupportedGrantTypes, + isExtensionTokenEndpointAuthMethod, +} from "./extensions"; import { assertClientPrivileges } from "./oauthClient/privileges"; import { buildClientResourceLinkId, getResource } from "./resources"; import type { OAuthOptions, SchemaClient, Scope } from "./types"; -import type { OAuthClient, TokenEndpointAuthMethod } from "./types/oauth"; +import type { + GrantType, + OAuthClient, + TokenEndpointAuthMethod, +} from "./types/oauth"; import { parseClientMetadata, storeClientSecret } from "./utils"; import { isPrivateHostname } from "./utils/client-assertion"; @@ -32,10 +41,48 @@ function resolveUnauthenticatedAuth(body: OAuthClient): { }; } +const DEFAULT_REGISTRATION_GRANT_TYPES = [ + "authorization_code", +] as const satisfies GrantType[]; + +function resolveRegistrationGrantTypes(client: OAuthClient): GrantType[] { + const grantTypes = client.grant_types ?? [ + ...DEFAULT_REGISTRATION_GRANT_TYPES, + ]; + if (grantTypes.length > 0) return grantTypes; + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: "grant_types must contain at least one grant type", + }); +} + +function resolveRegistrationResponseTypes( + client: OAuthClient, + grantTypes: GrantType[], +): OAuthClient["response_types"] { + if (client.response_types) return client.response_types; + return grantTypes.includes("authorization_code") ? ["code"] : undefined; +} + +function applyOAuthClientRegistrationDefaults( + client: OAuthClient, +): OAuthClient { + const grantTypes = resolveRegistrationGrantTypes(client); + return { + ...client, + token_endpoint_auth_method: + client.token_endpoint_auth_method ?? "client_secret_basic", + grant_types: grantTypes, + response_types: resolveRegistrationResponseTypes(client, grantTypes), + }; +} + export async function registerEndpoint( ctx: GenericEndpointContext, opts: OAuthOptions, ) { + const body = ctx.body as OAuthClient & { resources?: string[] }; + if (!opts.allowDynamicClientRegistration) { throw new APIError("FORBIDDEN", { error: "access_denied", @@ -43,7 +90,6 @@ export async function registerEndpoint( }); } - const body = ctx.body as OAuthClient & { resources?: string[] }; const session = await getSessionFromCtx(ctx); if (!(session || opts.allowUnauthenticatedClientRegistration)) { @@ -120,21 +166,36 @@ export async function checkOAuthClient( ctx?: GenericEndpointContext; }, ) { + const clientWithDefaults = applyOAuthClientRegistrationDefaults(client); // Determine whether registration request for public client // https://datatracker.ietf.org/doc/html/rfc7591#section-2 - const isPublic = client.token_endpoint_auth_method === "none"; + const isPublic = clientWithDefaults.token_endpoint_auth_method === "none"; + const tokenEndpointAuthMethod = + clientWithDefaults.token_endpoint_auth_method ?? "client_secret_basic"; + const supportedTokenEndpointAuthMethods = new Set( + getSupportedAuthMethods(opts, { includeNone: true }), + ); + if (!supportedTokenEndpointAuthMethods.has(tokenEndpointAuthMethod)) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `unsupported token_endpoint_auth_method ${tokenEndpointAuthMethod}`, + }); + } // Check value of type, if sent, matches isPublic - if (client.type) { + if (clientWithDefaults.type) { if ( isPublic && - !(client.type === "native" || client.type === "user-agent-based") + !( + clientWithDefaults.type === "native" || + clientWithDefaults.type === "user-agent-based" + ) ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: `Type must be 'native' or 'user-agent-based' for public applications`, }); - } else if (!isPublic && !(client.type === "web")) { + } else if (!isPublic && !(clientWithDefaults.type === "web")) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: `Type must be 'web' for confidential applications`, @@ -142,11 +203,14 @@ export async function checkOAuthClient( } } + const grantTypes = clientWithDefaults.grant_types ?? []; + const responseTypes = clientWithDefaults.response_types; + // Validate redirect URIs for redirect-based flows if ( - (!client.grant_types || - client.grant_types.includes("authorization_code")) && - (!client.redirect_uris || client.redirect_uris.length === 0) + grantTypes.includes("authorization_code") && + (!clientWithDefaults.redirect_uris || + clientWithDefaults.redirect_uris.length === 0) ) { throw new APIError("BAD_REQUEST", { error: "invalid_redirect_uri", @@ -156,11 +220,18 @@ export async function checkOAuthClient( } // Validate correlation between grant_types and response_types - const grantTypes = client.grant_types ?? ["authorization_code"]; - const responseTypes = client.response_types ?? ["code"]; + const supportedGrantTypes = new Set(getSupportedGrantTypes(opts)); + for (const grantType of grantTypes) { + if (!supportedGrantTypes.has(grantType)) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: `unsupported grant_type ${grantType}`, + }); + } + } if ( grantTypes.includes("authorization_code") && - !responseTypes.includes("code") + !responseTypes?.includes("code") ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", @@ -168,19 +239,32 @@ export async function checkOAuthClient( "When 'authorization_code' grant type is used, 'code' response type must be included", }); } + if ( + !grantTypes.includes("authorization_code") && + responseTypes?.includes("code") + ) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "When 'code' response type is used, 'authorization_code' grant type must be included", + }); + } // Validate subject_type - if (client.subject_type !== undefined) { + if (clientWithDefaults.subject_type !== undefined) { if ( - client.subject_type !== "public" && - client.subject_type !== "pairwise" + clientWithDefaults.subject_type !== "public" && + clientWithDefaults.subject_type !== "pairwise" ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: `subject_type must be "public" or "pairwise"`, }); } - if (client.subject_type === "pairwise" && !opts.pairwiseSecret) { + if ( + clientWithDefaults.subject_type === "pairwise" && + !opts.pairwiseSecret + ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: @@ -191,12 +275,14 @@ export async function checkOAuthClient( // a sector_identifier_uri is required (not yet supported). Reject registration // until sector_identifier_uri support is added. if ( - client.subject_type === "pairwise" && - client.redirect_uris && - client.redirect_uris.length > 1 + clientWithDefaults.subject_type === "pairwise" && + clientWithDefaults.redirect_uris && + clientWithDefaults.redirect_uris.length > 1 ) { const hosts = new Set( - client.redirect_uris.map((uri: string) => new URL(uri).host), + clientWithDefaults.redirect_uris.map( + (uri: string) => new URL(uri).host, + ), ); if (hosts.size > 1) { throw new APIError("BAD_REQUEST", { @@ -209,7 +295,7 @@ export async function checkOAuthClient( } // Check requested application scopes - const requestedScopes = (client?.scope as string | undefined) + const requestedScopes = (clientWithDefaults?.scope as string | undefined) ?.split(" ") .filter((v) => v.length); const allowedScopes = settings?.isRegister @@ -227,30 +313,39 @@ export async function checkOAuthClient( } } - if (settings?.isRegister && client.require_pkce === false) { + if (settings?.isRegister && clientWithDefaults.require_pkce === false) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: `pkce is required for registered clients.`, }); } - // Validate private_key_jwt requirements - if (client.token_endpoint_auth_method === "private_key_jwt") { - if (client.jwks && client.jwks_uri) { + // Validate client key material (jwks / jwks_uri). These belong to + // assertion-based authentication: private_key_jwt and any extension method + // that consumes them. The validation (mutual exclusion, jwks_uri origin and + // SSRF guards, structure) is the same for all of them, so an extension cannot + // register an unvalidated jwks_uri or both jwks and jwks_uri. + const usesAssertionKeyMaterial = + tokenEndpointAuthMethod === "private_key_jwt" || + isExtensionTokenEndpointAuthMethod(opts, tokenEndpointAuthMethod); + if (clientWithDefaults.jwks || clientWithDefaults.jwks_uri) { + if (!usesAssertionKeyMaterial) { + throw new APIError("BAD_REQUEST", { + error: "invalid_client_metadata", + error_description: + "jwks and jwks_uri are only allowed with private_key_jwt or an assertion-based authentication method", + }); + } + // OIDC Registration: jwks and jwks_uri must not both be present. + if (clientWithDefaults.jwks && clientWithDefaults.jwks_uri) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: "jwks and jwks_uri are mutually exclusive", }); } - if (!client.jwks && !client.jwks_uri) { - throw new APIError("BAD_REQUEST", { - error: "invalid_client_metadata", - error_description: "private_key_jwt requires either jwks or jwks_uri", - }); - } - if (client.jwks_uri) { + if (clientWithDefaults.jwks_uri) { try { - const uri = new URL(client.jwks_uri); + const uri = new URL(clientWithDefaults.jwks_uri); if (uri.protocol !== "https:") { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", @@ -278,11 +373,11 @@ export async function checkOAuthClient( }); } } - if (client.jwks) { + if (clientWithDefaults.jwks) { // Accept both RFC 7517 JWKS object {"keys":[...]} and bare key array - const keys = Array.isArray(client.jwks) - ? client.jwks - : (client.jwks as { keys?: unknown[] }).keys; + const keys = Array.isArray(clientWithDefaults.jwks) + ? clientWithDefaults.jwks + : (clientWithDefaults.jwks as { keys?: unknown[] }).keys; if (!Array.isArray(keys) || keys.length === 0) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", @@ -291,15 +386,20 @@ export async function checkOAuthClient( }); } } - } else if (client.jwks || client.jwks_uri) { + } + // private_key_jwt requires key material; extension methods may carry their own. + if ( + tokenEndpointAuthMethod === "private_key_jwt" && + !clientWithDefaults.jwks && + !clientWithDefaults.jwks_uri + ) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", - error_description: - "jwks and jwks_uri are only allowed with private_key_jwt authentication", + error_description: "private_key_jwt requires either jwks or jwks_uri", }); } - if (client.backchannel_logout_uri !== undefined) { + if (clientWithDefaults.backchannel_logout_uri !== undefined) { if (opts.disableJwtPlugin) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", @@ -309,7 +409,7 @@ export async function checkOAuthClient( } let url: URL; try { - url = new URL(client.backchannel_logout_uri); + url = new URL(clientWithDefaults.backchannel_logout_uri); } catch { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", @@ -328,7 +428,7 @@ export async function checkOAuthClient( // 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 (client.backchannel_logout_uri.includes("#")) { + if (clientWithDefaults.backchannel_logout_uri.includes("#")) { throw new APIError("BAD_REQUEST", { error: "invalid_client_metadata", error_description: @@ -375,7 +475,7 @@ export async function createOAuthClientEndpoint( resources?: string[] | undefined; }, ) { - const body = ctx.body as OAuthClient; + const body = applyOAuthClientRegistrationDefaults(ctx.body as OAuthClient); const session = await getSessionFromCtx(ctx); // Single authorization chokepoint for OAuth client creation. Every creation @@ -397,9 +497,13 @@ export async function createOAuthClientEndpoint( // https://datatracker.ietf.org/doc/html/rfc7591#section-2 const isPublic = body.token_endpoint_auth_method === "none"; const isPrivateKeyJwt = body.token_endpoint_auth_method === "private_key_jwt"; + const isExtensionAuthMethod = isExtensionTokenEndpointAuthMethod( + opts, + body.token_endpoint_auth_method, + ); // Check if client parameters are valid combination - await checkOAuthClient(ctx.body, opts, { + await checkOAuthClient(body, opts, { ...settings, ctx, }); @@ -408,7 +512,7 @@ export async function createOAuthClientEndpoint( const clientId = opts.generateClientId?.() || generateRandomString(32, "a-z", "A-Z"); const clientSecret = - isPublic || isPrivateKeyJwt + isPublic || isPrivateKeyJwt || isExtensionAuthMethod ? undefined : opts.generateClientSecret?.() || generateRandomString(32, "a-z", "A-Z"); const storedClientSecret = clientSecret @@ -424,7 +528,8 @@ export async function createOAuthClientEndpoint( }) : undefined; const schema = oauthToSchema({ - ...((body ?? {}) as OAuthClient), + ...body, + redirect_uris: body.redirect_uris ?? [], // Dynamic registration should not have disabled defined disabled: undefined, // Required if client secret is issued diff --git a/packages/oauth-provider/src/revoke.ts b/packages/oauth-provider/src/revoke.ts index b3e9d24fa2..90c6da86d6 100644 --- a/packages/oauth-provider/src/revoke.ts +++ b/packages/oauth-provider/src/revoke.ts @@ -312,6 +312,7 @@ export async function revokeEndpoint( clientId: client_id, clientSecret: client_secret, preVerifiedClient, + authMethod, } = destructureCredentials(credentials); if (!client_id) { @@ -340,6 +341,8 @@ export async function revokeEndpoint( client_secret, undefined, preVerifiedClient, + undefined, + authMethod, ); try { diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index 35f00fcb5c..c4293226b0 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -7,23 +7,37 @@ import type { Session, User } from "better-auth/types"; import type { JWTPayload } from "jose"; import { base64url, decodeProtectedHeader, SignJWT } from "jose"; import { resolveAccessTokenClaims } from "./claims"; +import { + collectExtensionIdTokenClaims, + getExtensionGrantHandler, + getSupportedGrantTypes, +} from "./extensions"; import type { ResolvedResourcePolicy } from "./resources"; import { resolveResourcePolicy } from "./resources"; import type { + Confirmation, + OAuthAuthenticatedClient, + OAuthClientAuthenticationRequest, OAuthOptions, + OAuthProviderApi, OAuthRefreshToken, + OAuthTokenIssueParams, + OAuthTokenResponse, SchemaClient, Scope, + StoreTokenType, + TokenType, VerificationValue, } from "./types"; import type { GrantType } from "./types/oauth"; import { verificationValueSchema } from "./types/zod"; -import { userNormalClaims } from "./userinfo"; +import { pickClaims, userNormalClaims } from "./userinfo"; import { clientAllowsGrant, decryptStoredClientSecret, destructureCredentials, extractClientCredentials, + getClient, getJwtPlugin, getStoredToken, isPKCERequired, @@ -37,6 +51,15 @@ import { validateClientCredentials, } from "./utils"; +/** + * Token presentation scheme implied by a confirmation: a DPoP key thumbprint + * (`jkt`) yields `"DPoP"`; any other confirmation (including mTLS `x5t#S256`) + * keeps `"Bearer"`, since that constraint lives at the TLS layer. + */ +function confirmationTokenType(confirmation?: Confirmation): TokenType { + return confirmation && "jkt" in confirmation ? "DPoP" : "Bearer"; +} + const JWT_ACCESS_TOKEN_TYPE = "at+jwt"; /** @@ -49,7 +72,7 @@ export async function tokenEndpoint( ) { const grantType: GrantType = ctx.body.grant_type; - if (opts.grantTypes && !opts.grantTypes.includes(grantType)) { + if (!getSupportedGrantTypes(opts).includes(grantType)) { throw new APIError("BAD_REQUEST", { error_description: `unsupported grant_type ${grantType}`, error: "unsupported_grant_type", @@ -63,9 +86,111 @@ export async function tokenEndpoint( return handleClientCredentialsGrant(ctx, opts); case "refresh_token": return handleRefreshTokenGrant(ctx, opts); + default: { + const handler = getExtensionGrantHandler(opts, grantType); + if (handler) { + return handler({ + ctx, + opts, + grantType, + provider: getOAuthProviderApi(ctx, opts, grantType), + }); + } + throw new APIError("BAD_REQUEST", { + error_description: `unsupported grant_type ${grantType}`, + error: "unsupported_grant_type", + }); + } } } +/** + * Returns the OAuth Provider's server-side capability surface bound to `ctx`. + * The token endpoint passes one (pre-bound to the dispatched grant) to each + * extension grant handler; a companion plugin's own endpoint calls this directly + * with its grant type. `grantType` is bound here, not per issuance, so a handler + * cannot mislabel the grant; omit it for capabilities that do not issue tokens + * (`getClient`, `validateAccessToken`), and `issueTokens` then throws. + */ +export function getOAuthProviderApi( + ctx: GenericEndpointContext, + opts: OAuthOptions, + grantType?: GrantType, +): OAuthProviderApi { + return { + getClient: (clientId: string) => getClient(ctx, opts, clientId), + authenticateClient: async ( + request?: OAuthClientAuthenticationRequest, + ): Promise => { + const credentials = await extractClientCredentials( + ctx, + opts, + // Bind the RFC 7523 assertion audience to the endpoint actually + // serving this request: the token endpoint for an in-grant handler, + // or a plugin's own endpoint out-of-grant. A fixed token-endpoint + // audience would let an assertion be replayed across endpoints, or + // reject a valid assertion minted for the real endpoint. + `${ctx.context.baseURL}${ctx.path ?? "/oauth2/token"}`, + ); + const { + clientId, + clientSecret, + preVerifiedClient, + authMethod, + confirmation, + } = destructureCredentials(credentials); + if (!clientId) { + throw new APIError("BAD_REQUEST", { + error_description: "Missing required client_id", + error: "invalid_grant", + }); + } + if ( + request?.requireCredentials !== false && + !clientSecret && + !preVerifiedClient + ) { + throw new APIError("BAD_REQUEST", { + error_description: "Missing required client credentials", + error: "invalid_grant", + }); + } + const client = await validateClientCredentials( + ctx, + opts, + clientId, + clientSecret, + request?.scopes, + preVerifiedClient, + request?.grantType ?? grantType, + authMethod, + ); + return { + clientId, + client, + method: authMethod, + confirmation, + }; + }, + issueTokens: (params: OAuthTokenIssueParams) => { + if (!grantType) { + throw new APIError("INTERNAL_SERVER_ERROR", { + error_description: + "issueTokens requires a grant type; pass it to getOAuthProviderApi(ctx, opts, grantType).", + error: "server_error", + }); + } + return createUserTokens(ctx, opts, { ...params, grantType }); + }, + hashToken: (token: string, type: StoreTokenType) => + storeToken(opts.storeTokens, token, type), + validateAccessToken: async (token: string, clientId?: string) => { + const { validateAccessToken } = await import("./introspect"); + return validateAccessToken(ctx, opts, token, clientId); + }, + }; +} + // User Jwt SHALL follow oAuth 2 // NOTE: Requires jwt plugin (assert !opts.disableJwtPlugin) async function createJwtAccessToken( @@ -87,10 +212,16 @@ async function createJwtAccessToken( signingKeyId?: ResolvedResourcePolicy["signingKeyId"]; /** * Enriched access-token claims from {@link resolveAccessTokenClaims} - * (reserved RFC 9068 names already stripped). The AS-owned claims below + * (reserved AS-owned names already stripped). The AS-owned claims below * are stamped after and always win. */ accessTokenClaims?: Record; + /** + * Sender-constraint to stamp as the RFC 7800 `cnf` claim. AS-owned: it is + * stamped after the enriched claims (which have `cnf` stripped), so a + * contributor cannot forge it. + */ + confirmation?: Confirmation; }, ) { const iat = overrides?.iat ?? Math.floor(Date.now() / 1000); @@ -129,6 +260,8 @@ async function createJwtAccessToken( // so audit trails and (future) revocation lookups can reference // individual tokens. jti: generateRandomString(32), + // RFC 7800 sender-constraint, stamped last so the AS owns it. + ...(overrides?.confirmation ? { cnf: overrides.confirmation } : {}), }, }); } @@ -172,6 +305,7 @@ async function createIdToken( sessionId?: string, authTime?: Date, accessToken?: string, + extraClaims?: Record, ) { const iat = Math.floor(Date.now() / 1000); const exp = iat + (opts.idTokenExpiresIn ?? 36000); @@ -227,6 +361,11 @@ async function createIdToken( exp, sid: emitSid ? sessionId : undefined, }; + // Extension and grant claims are additive: they only fill keys the provider + // does not already own, so a contributor cannot replace an identity claim + // (email/name/...), the authentication context, or an AS-owned claim. + // First-party `customIdTokenClaims` already overrode auth_time/acr above. + Object.assign(payload, pickClaims(extraClaims, payload)); // Public clients without a client secret cannot receive an idToken as it can't be verified // Confidential clients would still receive an idToken signed by the clientSecret @@ -492,22 +631,9 @@ async function createRefreshToken( }; } -interface CreateUserTokensParams { - client: SchemaClient; - scopes: string[]; +type CreateUserTokensParams = OAuthTokenIssueParams & { grantType: GrantType; - user?: User; - referenceId?: string; - sessionId?: string; - nonce?: string; - refreshToken?: OAuthRefreshToken & { id: string }; - authTime?: Date; - verificationValue?: VerificationValue; - resources?: string[]; - /** Full original authorized resources for the grant, used to seed the refresh token - * when the token request narrows the resource (RFC 8707 §2.2). */ - originalResources?: string[]; -} +}; interface ResourceGrantIssuance { audienceClaim: ResolvedResourcePolicy["audienceClaim"]; @@ -570,7 +696,7 @@ async function createUserTokens( ctx: GenericEndpointContext, opts: OAuthOptions, params: CreateUserTokensParams, -) { +): Promise { const { client, scopes, @@ -623,7 +749,25 @@ async function createUserTokens( (existingRefreshToken?.scopes?.includes("offline_access") || scopes.includes("offline_access")); const isJwtAccessToken = audienceClaim && !opts.disableJwtPlugin; - const isIdToken = user && scopes.includes("openid"); + const isIdToken = user && effectiveScopes.includes("openid"); + const metadata = parseClientMetadata(client.metadata); + const additionalIdTokenClaims = + isIdToken && user + ? { + ...(await collectExtensionIdTokenClaims(opts, { + ctx, + opts, + user, + client, + scopes: effectiveScopes, + grantType, + referenceId, + resources: params.resources, + metadata, + })), + ...(params.idTokenClaims ?? {}), + } + : undefined; // Resolve custom fields before any token side effects (refresh rotation, DB writes) const customFields = opts.customTokenResponseFields @@ -631,7 +775,7 @@ async function createUserTokens( grantType, user, scopes: effectiveScopes, - metadata: parseClientMetadata(client.metadata), + metadata, verificationValue, }) : undefined; @@ -664,16 +808,33 @@ async function createUserTokens( // introspection through this same resolver, so the formats cannot drift. const accessTokenClaims = isJwtAccessToken ? await resolveAccessTokenClaims({ + ctx, opts, user, + client, scopes: effectiveScopes, + grantType, resources: params.resources, referenceId, - metadata: parseClientMetadata(client.metadata), + metadata, + perRequestClaims: params.accessTokenClaims, resourcePolicyClaims: grantIssuance.resourceCustomClaims, }) : undefined; + // A sender-constraint is stamped onto JWT access tokens. Opaque-token binding + // persistence (a stored confirmation column) ships with the binding + // mechanism, so fail closed rather than silently mint an unbound opaque token + // a caller believes is sender-constrained. + if (params.confirmation && !isJwtAccessToken) { + throw new APIError("INTERNAL_SERVER_ERROR", { + error_description: + "Cannot sender-constrain an opaque access token; confirmation is supported only for JWT access tokens.", + error: "server_error", + }); + } + const confirmation = params.confirmation; + // Create access token and refresh token in parallel const [accessToken, refreshToken] = await Promise.all([ isJwtAccessToken @@ -691,6 +852,7 @@ async function createUserTokens( signingAlgorithm: grantIssuance.signingAlgorithm, signingKeyId: grantIssuance.signingKeyId, accessTokenClaims, + confirmation, }, ) : createOpaqueAccessToken( @@ -737,21 +899,23 @@ async function createUserTokens( opts, user, client, - scopes, + effectiveScopes, nonce, sessionId, authTime, accessToken, + additionalIdTokenClaims, ) : undefined; return ctx.json( { ...customFields, + ...(params.tokenResponse ?? {}), access_token: accessToken, expires_in: exp - iat, expires_at: exp, - token_type: "Bearer" as const, + token_type: confirmationTokenType(confirmation), refresh_token: refreshToken?.token, scope: effectiveScopes.join(" "), id_token: idToken, @@ -865,6 +1029,7 @@ async function handleAuthorizationCodeGrant( clientId: client_id, clientSecret: client_secret, preVerifiedClient, + authMethod, } = destructureCredentials(credentials); const { @@ -936,6 +1101,7 @@ async function handleAuthorizationCodeGrant( scopes, preVerifiedClient, "authorization_code", + authMethod, ); // Parse scopes from the authorization request @@ -1077,6 +1243,7 @@ async function handleClientCredentialsGrant( clientId: client_id, clientSecret: client_secret, preVerifiedClient, + authMethod, } = destructureCredentials(credentials); const { scope, resource }: { scope?: string; resource?: string | string[] } = ctx.body; @@ -1104,6 +1271,7 @@ async function handleClientCredentialsGrant( undefined, preVerifiedClient, "client_credentials", + authMethod, ); // OIDC scopes should not be requestable (code authorization grant should be used) @@ -1162,6 +1330,7 @@ async function handleRefreshTokenGrant( clientId: client_id, clientSecret: client_secret, preVerifiedClient, + authMethod, } = destructureCredentials(credentials); const { @@ -1270,6 +1439,7 @@ async function handleRefreshTokenGrant( requestedScopes ?? scopes, preVerifiedClient, "refresh_token", + authMethod, ); const user = await ctx.context.internalAdapter.findUserById( diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 1a6c46a316..86db589431 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -4,23 +4,33 @@ import type { InferOptionSchema, Session, User } from "better-auth/types"; import type { JWTPayload } from "jose"; import type { schema } from "../schema"; import type { Awaitable } from "./helpers"; -import type { GrantType } from "./oauth"; +import type { + AuthServerMetadata, + Confirmation, + GrantType, + OIDCMetadata, + TokenEndpointAuthMethod, + TokenType, +} from "./oauth"; export type { AuthMethod, AuthServerMetadata, BearerMethodsSupported, + Confirmation, GrantType, OAuthClient, OIDCMetadata, ResourceServerMetadata, TokenEndpointAuthMethod, + TokenType, } from "./oauth"; export type StoreTokenType = | "access_token" | "refresh_token" - | "authorization_code"; + | "authorization_code" + | (string & {}); type InternallySupportedScopes = | "openid" @@ -39,13 +49,11 @@ export type AuthorizePrompt = * metadata document, a federated registry, an attestation header, etc.) and * what fields that source contributes to discovery metadata. * - * Plugins install one of these onto {@link OAuthOptions.clientDiscovery}. - * The host walks the configured entries in order and returns the first - * non-null `resolve()` result. + * Plugins contribute one of these through + * {@link OAuthProviderExtension.clientDiscovery}. The host walks every + * configured entry in order and returns the first non-null `resolve()` result. */ -export interface ClientDiscovery< - Scopes extends readonly Scope[] = InternallySupportedScopes[], -> { +export interface ClientDiscovery { /** * Stable identifier used in error messages and diagnostics. Convention * is to match the plugin id (for example `"cimd"`). @@ -70,8 +78,8 @@ export interface ClientDiscovery< resolve: ( ctx: GenericEndpointContext, clientId: string, - existing: SchemaClient | null, - ) => Awaitable | null>; + existing: SchemaClient | null, + ) => Awaitable | null>; /** * Fields merged into `/.well-known/oauth-authorization-server` and * `/.well-known/openid-configuration` responses. Useful for advertising @@ -81,6 +89,306 @@ export interface ClientDiscovery< discoveryMetadata?: Record; } +export interface OAuthAuthenticatedClient { + clientId: string; + client: SchemaClient; + method?: TokenEndpointAuthMethod; + /** + * A sender-constraint the authentication step already proved (for example a + * wallet-instance key thumbprint). Pass it to `issueTokens` as + * {@link OAuthTokenIssueParams.confirmation} to bind the issued token to it. + * The authorization server writes this as token material and does not verify + * it again, so a strategy must set it only after proving possession. + */ + confirmation?: Confirmation; +} + +export interface OAuthClientAuthenticationRequest { + /** + * Scopes to validate against the registered client. + */ + scopes?: string[]; + /** + * Grant type to enforce for the registered client. + */ + grantType?: GrantType; + /** + * Set to `false` for public extension grants that only require client_id. + * + * @default true + */ + requireCredentials?: boolean; +} + +export interface OAuthTokenIssueParams { + client: SchemaClient; + scopes: string[]; + user?: User; + referenceId?: string; + sessionId?: string; + nonce?: string; + refreshToken?: OAuthRefreshToken & { id: string }; + authTime?: Date; + verificationValue?: VerificationValue; + resources?: string[]; + /** Full original authorized resources for the grant, used to seed refresh tokens. */ + originalResources?: string[]; + /** + * Additional JWT access-token claims for this single issuance. + * + * JWT-only: these are baked into the signed token at mint. Opaque access + * tokens persist no per-issuance claims, so they do NOT reappear at + * introspection. A claim that must be visible at opaque-token introspection + * belongs in a grant-type-stable `claims.accessToken` contributor instead, + * which the introspection path re-derives. Reserved RFC 9068 claim names stay + * owned by the authorization server. + */ + accessTokenClaims?: Record; + /** + * Additional ID-token claims for this single issuance. Additive: they cannot + * replace identity, authentication-context, or AS-owned claims. + */ + idTokenClaims?: Record; + /** + * Additional fields for the token response envelope. Standard OAuth token + * response fields stay owned by the authorization server. + */ + tokenResponse?: Record; + /** + * Sender-constraint to bind this issuance to (RFC 7800 `cnf`). When set, the + * issuer stamps it as the access token's `cnf` and derives `token_type` from + * it, instead of leaving the token a bearer token. Use it to carry a + * confirmation a client-auth strategy or an out-of-band flow already proved + * (see {@link OAuthAuthenticatedClient.confirmation}). `cnf` is AS-owned: it + * is stamped after, and cannot be overridden by, contributed claims. + */ + confirmation?: Confirmation; +} + +export interface OAuthTokenResponse { + access_token: string; + expires_in: number; + expires_at: number; + token_type: TokenType; + refresh_token: string | undefined; + scope: string; + id_token: string | undefined; + [key: string]: unknown; +} + +/** + * The OAuth Provider's server-side capability surface, bound to a request `ctx`. + * A grant handler receives one as `provider`; a companion plugin's own endpoint + * obtains one with `getOAuthProviderApi(ctx, opts, grantType?)`. The same object + * serves both, so issuance, client resolution, and token verification behave the + * same inside and outside a grant. + */ +export interface OAuthProviderApi { + /** + * Resolves a registered client by id, consulting extension client-discovery + * sources. Returns `null` when no client matches. + */ + getClient: (clientId: string) => Awaitable | null>; + /** + * Authenticates the calling client from the request (client secret, assertion, + * or none). For assertion-based methods, the RFC 7523 audience is bound to the + * endpoint serving the request, so an assertion cannot be replayed across + * endpoints. Returns the authenticated client, plus any `confirmation` an + * assertion strategy proved. + */ + authenticateClient: ( + request?: OAuthClientAuthenticationRequest, + ) => Awaitable; + /** + * Issues the token set for this grant (access token, optional refresh token, + * optional ID token, resource policy, response envelope, and any + * sender-constraint). The grant type is fixed by the `getOAuthProviderApi` + * binding (the dispatcher's grant for an in-grant handler, the caller's grant + * for an out-of-grant endpoint), so it cannot be mislabeled per issuance. + * + * Authorization is the caller's responsibility. The authorization server does + * NOT re-check that `params.scopes`, `params.user`, or `params.resources` are + * a subset of what `authenticateClient` validated: this is a raw minting + * primitive, and the built-in grants each validate scopes themselves before + * calling it. + */ + issueTokens: (params: OAuthTokenIssueParams) => Awaitable; + /** + * Computes the stored lookup key for a token value (the same hash the + * provider persists), so a caller can find or revoke a previously issued + * opaque token by its value. + */ + hashToken: (token: string, type: StoreTokenType) => Awaitable; + validateAccessToken: ( + token: string, + clientId?: string, + ) => Awaitable; +} + +export interface OAuthExtensionGrantHandlerInput { + ctx: GenericEndpointContext; + opts: OAuthOptions; + grantType: GrantType; + /** The provider capability surface, pre-bound to this grant's `grantType`. */ + provider: OAuthProviderApi; +} + +export type OAuthExtensionGrantHandler = ( + input: OAuthExtensionGrantHandlerInput, +) => Awaitable; + +export interface OAuthClientAuthenticationInput { + ctx: GenericEndpointContext; + opts: OAuthOptions; + assertion: string; + assertionType: string; + clientId?: string; + /** + * The endpoint URL the assertion was presented to. A strategy MUST bind the + * assertion to this audience (see {@link OAuthClientAuthenticationStrategy.authenticate}). + */ + expectedAudience?: string; +} + +export interface OAuthClientAuthenticationStrategy { + /** + * Assertion type URIs this strategy consumes from `client_assertion_type`. + * Values must be absolute URIs per RFC 7521. When omitted, the strategy key + * in `OAuthProviderExtension.clientAuthentication` is used and must also be + * an absolute URI. + */ + assertionTypes?: string[]; + /** + * Verifies the presented assertion and returns the authenticated client. + * + * The strategy owns the full RFC 7521/7523 verification. After verifying the + * signature against its own key source, it MUST enforce the assertion-hygiene + * checks the built-in `private_key_jwt` path enforces, or the provider will + * accept a forged or replayed assertion: + * - bind the assertion to `input.expectedAudience` (RFC 7523 §3 rule 3), + * - require a bounded `exp` (RFC 7523 §3 rule 4), + * - reject replays via a single-use `jti`. + * + * The exported `consumeClientAssertion` helper performs the audience, + * lifetime, and `jti` single-use checks for a decoded payload; call it after + * signature verification so an extension method inherits the same guarantees + * as `private_key_jwt`. + */ + authenticate: ( + input: OAuthClientAuthenticationInput, + ) => Awaitable; +} + +export interface OAuthMetadataExtensionInput { + ctx: GenericEndpointContext; + opts: OAuthOptions; + type: "oauth-authorization-server" | "openid-configuration"; + /** + * The discovery document the provider assembled (core authorization-server + * fields plus any client-discovery metadata). Contributions from other + * extensions are merged afterwards and are not reflected here, so a + * contributor decides what to add from provider state alone, independent of + * extension registration order. The contributor returns the fields to add. + */ + document: AuthServerMetadata | OIDCMetadata; +} + +export interface OAuthClaimExtensionInput { + ctx: GenericEndpointContext; + opts: OAuthOptions; + user?: (User & Record) | null; + client: SchemaClient; + scopes: string[]; + grantType?: GrantType; + referenceId?: string; + resources?: string[]; + /** Parsed client metadata, as returned by `parseClientMetadata`. */ + metadata?: Record; +} + +export interface OAuthUserInfoExtensionInput { + ctx: GenericEndpointContext; + opts: OAuthOptions; + user: User & Record; + scopes: string[]; + jwt: JWTPayload; + client?: SchemaClient; +} + +/** + * What a companion plugin contributes to the OAuth Provider, registered through + * `extendOAuthProvider(ctx, extension)` (or the `oauthProvider({ extensions })` + * option). + * + * All five kinds live on one object so a single protocol plugin (for example + * RFC 8693 token exchange, which adds a grant, advertises metadata, and emits + * claims) registers atomically and the host validates the combined surface in + * one place. Every field is independently optional, so a single-concern plugin + * (such as `@better-auth/cimd`, which contributes only `clientDiscovery`) sets + * just the one it needs. This is the shape every future OAuth RFC plugin copies. + * + * Two contribution disciplines: + * - Dispatched kinds (`grants`, `clientAuthentication`) must be disjoint across + * extensions: registering a grant type, auth method, or assertion type that + * another extension already registered is rejected at setup, since the second + * would otherwise be silently unreachable. + * - Additive kinds (`metadata`, `claims`) never override authorization-server + * core; a key already owned by the provider is kept, and a key two extensions + * both contribute resolves to the first-registered extension. + */ +export interface OAuthProviderExtension { + /** + * Token grants keyed by absolute-URI `grant_type`. The token endpoint + * dispatches a matching `grant_type` to the handler, which authenticates the + * client and issues tokens through the shared `provider`. + */ + grants?: Record; + /** + * Assertion-based client authentication, keyed by the advertised + * `token_endpoint_auth_method`. Consumes the matching `client_assertion_type` + * at the token, introspection, and revocation endpoints. Built-in method + * names (`client_secret_basic`/`_post`, `private_key_jwt`, `none`) are + * reserved. + */ + clientAuthentication?: Record; + /** + * Additional discovery metadata fields. Core fields (`issuer`, + * `token_endpoint`, advertised grants and auth methods, ...) stay owned by + * the provider; only absent keys are added. To advertise the claim names a + * claims contributor emits, set `advertisedMetadata.claims_supported`: the + * provider owns `claims_supported` and does not infer it from contributors. + */ + metadata?: (input: OAuthMetadataExtensionInput) => Record; + /** + * Additional claims for access tokens, ID tokens, and the UserInfo response. + * Strictly additive: a contributor can add new claims but never replace an + * identity, authentication-context, reserved RFC 9068, or other AS-owned + * claim. Access-token claims are re-derived at opaque-token introspection, so + * they must be grant-type-stable (a contributor receives `grantType: + * undefined` there). See the claim-authority overview in the docs for the + * full per-token precedence ladder. + */ + claims?: { + accessToken?: ( + input: OAuthClaimExtensionInput, + ) => Awaitable>; + idToken?: ( + input: OAuthClaimExtensionInput, + ) => Awaitable>; + userInfo?: ( + input: OAuthUserInfoExtensionInput, + ) => Awaitable>; + }; + /** + * Client-id resolution sources consulted by `getClient()`, plus the + * discovery-metadata fields they advertise. Entries across all extensions + * run in order; the first to return a client wins. A plugin that resolves + * clients from an external source (a metadata-document URL, a federated + * registry, an attestation header) contributes it here. + */ + clientDiscovery?: ClientDiscovery | ClientDiscovery[]; +} + export interface OAuthOptions< Scopes extends readonly Scope[] = InternallySupportedScopes[], > { @@ -281,20 +589,15 @@ export interface OAuthOptions< */ allowDynamicClientRegistration?: boolean; /** - * Discovery implementations consulted by `getClient()` when resolving - * a `client_id`. Each entry decides whether it handles the `client_id` - * via {@link ClientDiscovery.matches}, then creates, refreshes, or - * passes on a client record. Entries run in order; the first one to - * return a client wins. + * OAuth/OIDC extension points used by companion plugins to add protocol + * grants, client authentication methods, metadata, claims, and client-id + * discovery without modifying oauth-provider core for each RFC. * - * Each entry also contributes {@link ClientDiscovery.discoveryMetadata} - * into the `/.well-known/oauth-authorization-server` and - * `/.well-known/openid-configuration` responses. - * - * Plugins such as `@better-auth/cimd` install an entry here at init - * time; users can also pass discovery implementations directly. + * Extension plugins should prefer `extendOAuthProvider(ctx, extension)` in + * their `init()` hook so users can compose plugins declaratively. Plugins + * such as `@better-auth/cimd` contribute their client discovery this way. */ - clientDiscovery?: ClientDiscovery | ClientDiscovery[]; + extensions?: OAuthProviderExtension[]; /** * List of scopes for newly registered clients * if not requested. @@ -1187,11 +1490,7 @@ export interface SchemaClient< * @default false */ backchannelLogoutSessionRequired?: boolean; - tokenEndpointAuthMethod?: - | "none" - | "client_secret_basic" - | "client_secret_post" - | "private_key_jwt"; + tokenEndpointAuthMethod?: TokenEndpointAuthMethod; grantTypes?: GrantType[]; responseTypes?: "code"[]; /** Client's JSON Web Key Set for `private_key_jwt` authentication. Mutually exclusive with `jwksUri`. */ diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 70d40698c6..ed052f597b 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -5,7 +5,7 @@ import type { Prompt } from "."; /** * Supported grant types of the token endpoint */ -export type GrantType = +export type BuiltInGrantType = | "authorization_code" // | "implicit" // NEVER SUPPORT - deprecated in oAuth2.1 // | "password" // NEVER SUPPORT - deprecated in oAuth2.1 @@ -15,14 +15,33 @@ export type GrantType = // | "urn:ietf:params:oauth:grant-type:jwt-bearer" // unspecified in oAuth2.1 // | "urn:ietf:params:oauth:grant-type:saml2-bearer" // unspecified in oAuth2.1 -export type AuthMethod = +export type GrantType = BuiltInGrantType | (string & {}); + +export type BuiltInAuthMethod = | "client_secret_basic" // Basic header | "client_secret_post" // POST | "private_key_jwt"; // JWT signed with client's private key (RFC 7523) // | "client_secret_jwt" // must also add alg_values_supported for that endpoint +export type AuthMethod = BuiltInAuthMethod | (string & {}); export type TokenEndpointAuthMethod = AuthMethod | "none"; // Public client support for the token auth endpoint export type BearerMethodsSupported = "header" | "body"; +/** + * RFC 7800 `cnf` (confirmation) members that sender-constrain an access token to + * a key the client must prove possession of. Each binding mechanism populates + * one member of the same object: DPoP sets `jkt` (RFC 9449 §6), mTLS sets + * `x5t#S256` (RFC 8705 §3.1). The authorization server is the sole authority + * over this value; it is token material, never a contributed claim. + */ +export type Confirmation = { jkt: string } | { "x5t#S256": string }; + +/** + * Token presentation scheme returned in `token_type`. A DPoP-bound token uses + * `"DPoP"` (RFC 9449 §5); everything else (including mTLS-bound) uses + * `"Bearer"`, since the mTLS constraint lives at the TLS layer. + */ +export type TokenType = "Bearer" | "DPoP"; + /** * Metadata for authentication servers. * @@ -137,7 +156,7 @@ export interface AuthServerMetadata { * @default * ["client_secret_basic", "client_secret_post"] */ - revocation_endpoint_auth_methods_supported?: AuthMethod[]; + revocation_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; /** * Array containing a list of the JWS signing * algorithms ("alg" values) supported by the revocation endpoint for @@ -158,7 +177,7 @@ export interface AuthServerMetadata { * @default * ["client_secret_basic", "client_secret_post"] */ - introspection_endpoint_auth_methods_supported?: AuthMethod[]; + introspection_endpoint_auth_methods_supported?: TokenEndpointAuthMethod[]; /** * Array containing a list of the JWS signing * algorithms ("alg" values) supported by the introspection endpoint @@ -337,11 +356,7 @@ export interface OAuthClient { * @see https://openid.net/specs/openid-connect-backchannel-1_0.html#RPMetadata */ backchannel_logout_session_required?: boolean; - token_endpoint_auth_method?: - | "none" - | "client_secret_basic" - | "client_secret_post" - | "private_key_jwt"; + token_endpoint_auth_method?: TokenEndpointAuthMethod; grant_types?: GrantType[]; response_types?: "code"[]; // | "token" // NEVER SUPPORT - depreciated in oAuth2.1 diff --git a/packages/oauth-provider/src/userinfo.ts b/packages/oauth-provider/src/userinfo.ts index 155d2ae998..bd89f75097 100644 --- a/packages/oauth-provider/src/userinfo.ts +++ b/packages/oauth-provider/src/userinfo.ts @@ -1,6 +1,10 @@ import type { GenericEndpointContext } from "@better-auth/core"; import { APIError } from "better-auth/api"; import type { User } from "better-auth/types"; +import { + collectExtensionUserInfoClaims, + hasUserInfoClaimExtension, +} from "./extensions"; import { validateAccessToken } from "./introspect"; import type { OAuthOptions, Scope } from "./types"; import { getClient, resolveSubjectIdentifier } from "./utils"; @@ -30,6 +34,34 @@ export function userNormalClaims(user: User, scopes: string[]) { }; } +/** + * Returns the defined-valued entries of `claims`, dropping any key already + * present in `base` when given. + * + * This is the two-tier claim authority shared by the /userinfo response and the + * ID token: + * - Called WITH `base` (the provider's own claims): the additive rule for + * third-party extension claims. A contributor may add new keys but never + * replace a claim the provider already owns. + * - Called WITHOUT `base`: the deliberate first-party override path for the + * operator's own `customUserInfoClaims` / `customIdTokenClaims`, which is + * trusted to override identity claims (for example a formatted `name`). The + * caller re-pins `sub` afterwards, so subject integrity holds either way + * (OIDC Core §5.3.2: UserInfo `sub` MUST match the ID Token `sub`). + */ +export function pickClaims( + claims?: Record, + base?: Record, +) { + const next: Record = {}; + for (const [key, value] of Object.entries(claims ?? {})) { + if (value === undefined) continue; + if (base && key in base) continue; + next[key] = value; + } + return next; +} + /** * Handles the /oauth2/userinfo endpoint */ @@ -84,27 +116,37 @@ export async function userInfoEndpoint( } const baseUserClaims = userNormalClaims(user, scopes ?? []); + const clientId = (jwt.client_id ?? jwt.azp) as string | undefined; + // Load the client only when something needs it: pairwise subject resolution + // or a UserInfo claim extension. The token was already validated against its + // issuing client, so an unconditional lookup here would be redundant. + const client = + clientId && (opts.pairwiseSecret || hasUserInfoClaimExtension(opts)) + ? await getClient(ctx, opts, clientId) + : undefined; // Resolve pairwise sub if server has pairwise enabled and client is configured for it - if (opts.pairwiseSecret) { - const clientId = (jwt.client_id ?? jwt.azp) as string | undefined; - if (clientId) { - const client = await getClient(ctx, opts, clientId); - if (client) { - baseUserClaims.sub = await resolveSubjectIdentifier( - user.id, - client, - opts, - ); - } - } + if (opts.pairwiseSecret && client) { + baseUserClaims.sub = await resolveSubjectIdentifier(user.id, client, opts); } + const extensionUserClaims = scopes?.length + ? await collectExtensionUserInfoClaims(opts, { + ctx, + opts, + user, + scopes, + jwt, + client: client ?? undefined, + }) + : {}; const additionalInfoUserClaims = opts.customUserInfoClaims && scopes?.length ? await opts.customUserInfoClaims({ user, scopes, jwt }) : {}; return { ...baseUserClaims, - ...additionalInfoUserClaims, + ...pickClaims(extensionUserClaims, baseUserClaims), + ...pickClaims(additionalInfoUserClaims), + sub: baseUserClaims.sub, }; } diff --git a/packages/oauth-provider/src/utils/client-assertion.ts b/packages/oauth-provider/src/utils/client-assertion.ts index 6419dbca7b..35b2d1e41a 100644 --- a/packages/oauth-provider/src/utils/client-assertion.ts +++ b/packages/oauth-provider/src/utils/client-assertion.ts @@ -175,6 +175,129 @@ async function refetchClientJwks( } } +/** + * Enforces the assertion-hygiene claims every client-assertion authentication + * method must check, independent of how the signature is verified or where the + * verification keys come from: + * - `aud` MUST include `expectedAudience` (RFC 7523 §3 rule 3), + * - `exp` MUST be present, unexpired, and at most `assertionMaxLifetime` + * seconds away (RFC 7523 §3 rule 4), + * - `iat`, when present, MUST be within `assertionMaxLifetime`, + * - `jti` MUST be present and single-use; this consumes a replay tombstone keyed + * by `` `${namespace}:${jti}` ``, inserted under the adapter's primary key so a + * replay across workers fails atomically. + * + * A custom {@link OAuthClientAuthenticationStrategy} should call this after + * verifying the assertion signature, so an extension method inherits the same + * replay, lifetime, and audience guarantees as the built-in `private_key_jwt` + * path, which calls it too. + * + * @param params.namespace Scopes the replay tombstone to the method and client, + * e.g. `` `${method}:${clientId}` ``, so the same `jti` can recur across + * distinct methods or clients but never within one. + */ +export async function consumeClientAssertion( + ctx: GenericEndpointContext, + opts: OAuthOptions, + params: { + namespace: string; + payload: { aud?: unknown; exp?: unknown; iat?: unknown; jti?: unknown }; + expectedAudience: string; + }, +): Promise { + const { namespace, payload, expectedAudience } = params; + + const audiences = Array.isArray(payload.aud) + ? payload.aud + : payload.aud != null + ? [payload.aud] + : []; + if (!audiences.includes(expectedAudience)) { + throw new APIError("BAD_REQUEST", { + error_description: "client assertion aud does not match the endpoint", + error: "invalid_client", + }); + } + + const maxLifetime = opts.assertionMaxLifetime ?? 300; + const now = Math.floor(Date.now() / 1000); + if (typeof payload.exp !== "number") { + throw new APIError("BAD_REQUEST", { + error_description: "client assertion must include exp claim", + error: "invalid_client", + }); + } + // An extension strategy relies on this; the built-in path has jose reject + // expiry before this runs. + if (payload.exp <= now) { + throw new APIError("BAD_REQUEST", { + error_description: "client assertion has expired", + error: "invalid_client", + }); + } + // Cap the window so exp cannot outlive the jti tombstone. + if (payload.exp - now > maxLifetime) { + throw new APIError("BAD_REQUEST", { + error_description: `client assertion exp is too far in the future (max ${maxLifetime}s)`, + error: "invalid_client", + }); + } + if (typeof payload.iat === "number" && now - payload.iat > maxLifetime) { + throw new APIError("BAD_REQUEST", { + error_description: `client assertion iat is too far in the past (max ${maxLifetime}s)`, + error: "invalid_client", + }); + } + + if (typeof payload.jti !== "string" || payload.jti.length === 0) { + throw new APIError("BAD_REQUEST", { + error_description: "client assertion must include jti claim", + error: "invalid_client", + }); + } + + // Consume the jti with a single insert keyed by a digest of the namespaced + // identifier. The primary key is the atomic gate on every adapter (SQL + // primary key, MongoDB `_id`), so concurrent requests across workers cannot + // both pass. A duplicate-key failure means the jti was already used (replay); + // any other failure is surfaced unchanged. + const jtiDigest = await createHash("SHA-256").digest( + new TextEncoder().encode(`${namespace}:${payload.jti}`), + ); + const jtiId = base64Url.encode(new Uint8Array(jtiDigest).slice(0, 24), { + padding: false, + }); + try { + await ctx.context.adapter.create({ + model: "oauthClientAssertion", + data: { + id: jtiId, + expiresAt: new Date(payload.exp * 1000), + }, + forceAllowId: true, + }); + } catch (createErr) { + let alreadyUsed = false; + try { + alreadyUsed = Boolean( + await ctx.context.adapter.findOne({ + model: "oauthClientAssertion", + where: [{ field: "id", value: jtiId }], + }), + ); + } catch { + // Lookup failed, so a replay cannot be confirmed; surface the insert error. + } + if (alreadyUsed) { + throw new APIError("BAD_REQUEST", { + error_description: "client assertion jti has already been used", + error: "invalid_client", + }); + } + throw createErr; + } +} + /** * Verifies a client assertion JWT for `private_key_jwt` authentication. * @@ -265,7 +388,6 @@ export async function verifyClientAssertion( // Fetch JWKS and verify signature + claims const jwks = await fetchClientJwks(ctx, client); const audience = expectedAudience ?? `${ctx.context.baseURL}/oauth2/token`; - const maxLifetime = opts.assertionMaxLifetime ?? 300; const verifyOpts = { issuer: clientId, subject: clientId, @@ -320,84 +442,16 @@ export async function verifyClientAssertion( } } - // exp is REQUIRED per RFC 7523 Section 3 rule 4. - // jose's jwtVerify only validates exp when present — it does NOT reject - // assertions that omit exp entirely. We must enforce this explicitly. - const now = Math.floor(Date.now() / 1000); - if (typeof payload.exp !== "number") { - throw new APIError("BAD_REQUEST", { - error_description: "client assertion must include exp claim", - error: "invalid_client", - }); - } - // Cap the assertion validity window: exp must not be more than - // assertionMaxLifetime seconds from now (default 5 minutes). - // This prevents long-lived assertions that could outlive JTI tombstones. - if (payload.exp - now > maxLifetime) { - throw new APIError("BAD_REQUEST", { - error_description: `client assertion exp is too far in the future (max ${maxLifetime}s)`, - error: "invalid_client", - }); - } - // Advisory iat check: when present, reject assertions older than maxLifetime. - // iat is optional per RFC 7523 S3, so we only enforce when the client provides it. - if (typeof payload.iat === "number" && now - payload.iat > maxLifetime) { - throw new APIError("BAD_REQUEST", { - error_description: `client assertion iat is too far in the past (max ${maxLifetime}s)`, - error: "invalid_client", - }); - } - - // jti is REQUIRED per OIDC Core Section 9 for private_key_jwt. - // RFC 7523 Section 3 makes jti MAY, but OIDC tightens this to REQUIRED - // with single-use enforcement. - if (!payload.jti) { - throw new APIError("BAD_REQUEST", { - error_description: "client assertion must include jti claim", - error: "invalid_client", - }); - } - - // Consume the jti with a single insert keyed by a digest of the per-client - // assertion identifier. The primary key is the atomic gate on every adapter - // (SQL primary key, MongoDB `_id`), so concurrent token requests across - // workers cannot both pass. A duplicate-key failure means the jti was - // already used (replay); any other failure is surfaced unchanged. - const jtiDigest = await createHash("SHA-256").digest( - new TextEncoder().encode(`private_key_jwt:${clientId}:${payload.jti}`), - ); - const jtiId = base64Url.encode(new Uint8Array(jtiDigest).slice(0, 24), { - padding: false, + // Audience, lifetime, and jti single-use replay protection, shared with + // extension client-auth methods through `consumeClientAssertion` so both + // paths enforce RFC 7523 §3 identically. jose already matched `aud` via + // `verifyOpts.audience`; the helper re-checks it so it is self-sufficient for + // callers that verify the signature without jose. + await consumeClientAssertion(ctx, opts, { + namespace: `private_key_jwt:${clientId}`, + payload, + expectedAudience: audience, }); - try { - await ctx.context.adapter.create({ - model: "oauthClientAssertion", - data: { - id: jtiId, - expiresAt: new Date(payload.exp * 1000), - }, - forceAllowId: true, - }); - } catch (createErr) { - let alreadyUsed = false; - try { - alreadyUsed = Boolean( - await ctx.context.adapter.findOne({ - model: "oauthClientAssertion", - where: [{ field: "id", value: jtiId }], - }), - ); - } catch { - // Lookup failed, so a replay cannot be confirmed; surface the insert error. - } - if (alreadyUsed) { - throw new APIError("BAD_REQUEST", { - error_description: "client assertion jti has already been used", - error: "invalid_client", - }); - } - throw createErr; - } return { clientId, client }; } diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index a26305bb8e..f4372d6713 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -11,16 +11,23 @@ import { } from "better-auth/crypto"; import type { jwt } from "better-auth/plugins"; import { APIError } from "better-call"; +import { + getClientDiscoveries, + getExtensionClientAuthenticationStrategy, + isExtensionTokenEndpointAuthMethod, +} from "../extensions"; import type { oauthProvider } from "../oauth"; import { canonicalizeOAuthQueryParams } from "../signed-query"; import type { ClientDiscovery, + Confirmation, GrantType, OAuthOptions, Prompt, SchemaClient, Scope, StoreTokenType, + TokenEndpointAuthMethod, } from "../types"; export { @@ -207,7 +214,7 @@ export async function getClient( where: [{ field: "clientId", value: clientId }], }); - const discoveries = toClientDiscoveryArray(options.clientDiscovery); + const discoveries = getClientDiscoveries(options); for (const discovery of discoveries) { if (!discovery.matches(clientId)) continue; const resolved = await discovery.resolve(ctx, clientId, dbClient); @@ -225,29 +232,15 @@ export async function getClient( } /** - * Normalize the `clientDiscovery` option into an array. Accepts a single - * {@link ClientDiscovery}, an array of them, or `undefined`. - * - * @internal - */ -export function toClientDiscoveryArray( - discovery: OAuthOptions["clientDiscovery"], -): ClientDiscovery[] { - if (!discovery) return []; - return Array.isArray(discovery) ? discovery : [discovery]; -} - -/** - * Merge `discoveryMetadata` from every configured {@link ClientDiscovery} + * Merge `discoveryMetadata` from every contributed {@link ClientDiscovery} * into a single object. Entries are spread in order; later entries override * earlier ones on key collisions. * * @internal */ export function mergeDiscoveryMetadata( - discovery: OAuthOptions["clientDiscovery"], + discoveries: ClientDiscovery[], ): Record { - const discoveries = toClientDiscoveryArray(discovery); return discoveries.reduce>( (acc, d) => ({ ...acc, ...(d.discoveryMetadata ?? {}) }), {}, @@ -513,6 +506,7 @@ export async function validateClientCredentials( scopes?: string[], // checks requested scopes against allowed scopes preVerifiedClient?: SchemaClient, grantType?: GrantType, // if set, enforces the client is registered for this grant type + authMethod?: TokenEndpointAuthMethod, ) { const client = preVerifiedClient ?? (await getClient(ctx, options, clientId)); if (!client) { @@ -528,14 +522,27 @@ export async function validateClientCredentials( }); } - // Enforce registered auth method: private_key_jwt clients must use assertion + // Enforce registered auth method for assertion/pre-verified methods. + if (preVerifiedClient && authMethod) { + const registeredAuthMethod = + client.tokenEndpointAuthMethod ?? "client_secret_basic"; + if (registeredAuthMethod !== authMethod) { + throw new APIError("BAD_REQUEST", { + error_description: `client registered for ${registeredAuthMethod} cannot use ${authMethod}`, + error: "invalid_client", + }); + } + } if ( - client.tokenEndpointAuthMethod === "private_key_jwt" && + (client.tokenEndpointAuthMethod === "private_key_jwt" || + isExtensionTokenEndpointAuthMethod( + options, + client.tokenEndpointAuthMethod, + )) && !preVerifiedClient ) { throw new APIError("BAD_REQUEST", { - error_description: - "client registered for private_key_jwt must use client_assertion", + error_description: `client registered for ${client.tokenEndpointAuthMethod} must use client_assertion`, error: "invalid_client", }); } @@ -608,23 +615,30 @@ export async function validateClientCredentials( */ export function parseClientMetadata( metadata: string | object | undefined, -): object | undefined { +): Record | undefined { if (!metadata) return undefined; - return typeof metadata === "string" ? JSON.parse(metadata) : metadata; + return typeof metadata === "string" + ? JSON.parse(metadata) + : (metadata as Record); } export type ExtractedCredentials = | { + kind: "client_secret"; method: "client_secret_basic" | "client_secret_post"; clientId: string; clientSecret: string; } | { - method: "private_key_jwt"; + kind: "pre_verified"; + method: TokenEndpointAuthMethod; clientId: string; client: SchemaClient; + /** Sender-constraint the auth strategy proved, forwarded to issuance. */ + confirmation?: Confirmation; } | { + kind: "public"; method: "none"; clientId: string; }; @@ -636,13 +650,15 @@ export function destructureCredentials( return { clientId: credentials?.clientId, clientSecret: - credentials?.method === "client_secret_basic" || - credentials?.method === "client_secret_post" + credentials?.kind === "client_secret" ? credentials.clientSecret : undefined, preVerifiedClient: - credentials?.method === "private_key_jwt" - ? credentials.client + credentials?.kind === "pre_verified" ? credentials.client : undefined, + authMethod: credentials?.method, + confirmation: + credentials?.kind === "pre_verified" + ? credentials.confirmation : undefined, }; } @@ -659,7 +675,7 @@ export async function extractClientCredentials( const body = (ctx.body ?? {}) as Record; const authorization = ctx.request?.headers.get("authorization") ?? undefined; - // 1. Check for private_key_jwt assertion + // 1. Check for assertion-based client authentication. if (body.client_assertion_type || body.client_assertion) { if (!body.client_assertion || !body.client_assertion_type) { throw new APIError("BAD_REQUEST", { @@ -668,25 +684,52 @@ export async function extractClientCredentials( error: "invalid_client", }); } - if (body.client_secret || authorization?.startsWith("Basic ")) { + if ( + body.client_secret || + (authorization && BASIC_SCHEME_PREFIX.test(authorization)) + ) { throw new APIError("BAD_REQUEST", { error_description: "client_assertion cannot be combined with client_secret or Basic auth", error: "invalid_client", }); } + const assertion = body.client_assertion as string; + const assertionType = body.client_assertion_type as string; + const extensionStrategy = getExtensionClientAuthenticationStrategy( + opts, + assertionType, + ); + if (extensionStrategy) { + const result = await extensionStrategy.strategy.authenticate({ + ctx, + opts, + assertion, + assertionType, + clientId: body.client_id as string | undefined, + expectedAudience, + }); + return { + kind: "pre_verified", + method: extensionStrategy.method, + clientId: result.clientId, + client: result.client, + confirmation: result.confirmation, + }; + } const { verifyClientAssertion: verify } = await import( "./client-assertion" ); const result = await verify( ctx, opts, - body.client_assertion as string, - body.client_assertion_type as string, + assertion, + assertionType, body.client_id as string | undefined, expectedAudience, ); return { + kind: "pre_verified", method: "private_key_jwt", clientId: result.clientId, client: result.client, @@ -694,10 +737,11 @@ export async function extractClientCredentials( } // 2. Check for Basic auth header - if (authorization?.startsWith("Basic ")) { + if (authorization && BASIC_SCHEME_PREFIX.test(authorization)) { const res = basicToClientCredentials(authorization); if (res) { return { + kind: "client_secret", method: "client_secret_basic", clientId: res.client_id, clientSecret: res.client_secret, @@ -708,6 +752,7 @@ export async function extractClientCredentials( // 3. Check body params if (body.client_id && body.client_secret) { return { + kind: "client_secret", method: "client_secret_post", clientId: body.client_id as string, clientSecret: body.client_secret as string, @@ -716,7 +761,11 @@ export async function extractClientCredentials( // 4. client_id only (public client) if (body.client_id) { - return { method: "none", clientId: body.client_id as string }; + return { + kind: "public", + method: "none", + clientId: body.client_id as string, + }; } return null;