feat(oauth-provider): add extension surface (#10030)

This commit is contained in:
Gustavo Valverde
2026-06-14 11:37:28 -07:00
committed by GitHub
parent 2fd3d58500
commit 050ef2dfcf
26 changed files with 2903 additions and 551 deletions
+10 -8
View File
@@ -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.
@@ -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 }] })`.
+10 -6
View File
@@ -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
@@ -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.
+1 -1
View File
@@ -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
>;
+8 -19
View File
@@ -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<Scope[]> {
): 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;
};
+3 -2
View File
@@ -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 = {},
+11 -3
View File
@@ -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<Scope[]>["customAccessTokenClaims"],
@@ -9,12 +10,19 @@ function optsWith(
return { customAccessTokenClaims } as unknown as OAuthOptions<Scope[]>;
}
// 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<Scope[]>,
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<string, unknown> | undefined;
await resolveAccessTokenClaims({
...baseInput,
opts: optsWith((info) => {
received = info as Record<string, unknown>;
return {};
}),
user: undefined,
scopes: ["openid", "email"],
resources: ["https://api.example.com"],
referenceId: "ref-1",
+67 -21
View File
@@ -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<string, unknown> = {};
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<Scope[]>;
/** Token subject; `null`/`undefined` for `client_credentials`. */
user: User | null | undefined;
client: SchemaClient<Scope[]>;
/** 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<string, unknown> | 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<string, unknown> | undefined;
/** Per-resource `customClaims` from `resolveResourcePolicy` (raw, not yet stripped). */
resourcePolicyClaims: Record<string, unknown>;
}
@@ -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<Record<string, unknown>> {
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,
});
}
File diff suppressed because it is too large Load Diff
+398
View File
@@ -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<string>(
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<string>();
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<Scope[]>,
): 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<Scope[]>,
): 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<Scope[]>): GrantType[] {
return getOAuthProviderExtensions(opts).flatMap((extension) =>
Object.keys(extension.grants ?? {}),
);
}
export function getSupportedGrantTypes(
opts: OAuthOptions<Scope[]>,
): GrantType[] {
return Array.from(
new Set<GrantType>([
...(opts.grantTypes ?? DEFAULT_GRANT_TYPES),
...getExtensionGrantTypes(opts),
]),
);
}
export function getExtensionGrantHandler(
opts: OAuthOptions<Scope[]>,
grantType: GrantType,
) {
for (const extension of getOAuthProviderExtensions(opts)) {
const handler = extension.grants?.[grantType];
if (handler) return handler;
}
return undefined;
}
function getExtensionTokenEndpointAuthMethods(
opts: OAuthOptions<Scope[]>,
): 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<Scope[]>,
settings?: { includeNone?: boolean },
): TokenEndpointAuthMethod[] {
return Array.from(
new Set<TokenEndpointAuthMethod>([
...(settings?.includeNone ? (["none"] as TokenEndpointAuthMethod[]) : []),
...BUILT_IN_CONFIDENTIAL_AUTH_METHODS,
...getExtensionTokenEndpointAuthMethods(opts),
]),
);
}
export function isExtensionTokenEndpointAuthMethod(
opts: OAuthOptions<Scope[]>,
method: TokenEndpointAuthMethod | undefined,
) {
return method
? getExtensionTokenEndpointAuthMethods(opts).includes(method)
: false;
}
export function getExtensionClientAuthenticationStrategy(
opts: OAuthOptions<Scope[]>,
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<Scope[]>,
type: OAuthMetadataExtensionInput["type"],
document: T,
): T {
const next: Record<string, unknown> = { ...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<Scope[]>,
run: (
extension: OAuthProviderExtension,
) => Awaitable<Record<string, unknown> | undefined>,
) {
const claims: Record<string, unknown> = {};
// 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<Scope[]>,
input: OAuthClaimExtensionInput,
) {
return collectClaims(opts, (extension) =>
extension.claims?.accessToken?.(input),
);
}
export function collectExtensionIdTokenClaims(
opts: OAuthOptions<Scope[]>,
input: OAuthClaimExtensionInput,
) {
return collectClaims(opts, (extension) => extension.claims?.idToken?.(input));
}
export function collectExtensionUserInfoClaims(
opts: OAuthOptions<Scope[]>,
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<Scope[]>,
): boolean {
return getOAuthProviderExtensions(opts).some(
(extension) => extension.claims?.userInfo,
);
}
+4
View File
@@ -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";
+26 -15
View File
@@ -275,6 +275,10 @@ async function validateOpaqueAccessToken(
};
}
const resources = Array.isArray(accessToken.resources)
? accessToken.resources
: undefined;
let client: SchemaClient<Scope[]> | 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 {
+92 -43
View File
@@ -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<Scope[]>,
) {
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<Scope[]>,
): AuthServerMetadata {
const { clientDiscoveries, authMetadata } = buildAuthServerMetadata(
ctx,
opts,
);
return applyOAuthProviderMetadataExtensions(
ctx,
opts,
"oauth-authorization-server",
{
...mergeDiscoveryMetadata(clientDiscoveries),
...authMetadata,
},
);
}
export function oidcServerMetadata(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]> & { 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.
@@ -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");
});
});
+30 -104
View File
@@ -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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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",
@@ -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<Scope[]>) =>
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<Scope[]>) =>
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<Scope[]>) =>
])
.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<Scope[]>) =>
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<Scope[]>) =>
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<Scope[]>) =>
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<Scope[]>) =>
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<Scope[]>) =>
])
.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<Scope[]>) =>
},
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<Scope[]>) =>
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<Scope[]>) =>
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<Scope[]>) =>
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(),
}),
@@ -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],
+152 -47
View File
@@ -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<Scope[]>,
) {
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
+3
View File
@@ -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 {
+194 -24
View File
@@ -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<Scope[]>,
grantType?: GrantType,
): OAuthProviderApi {
return {
getClient: (clientId: string) => getClient(ctx, opts, clientId),
authenticateClient: async (
request?: OAuthClientAuthenticationRequest,
): Promise<OAuthAuthenticatedClient> => {
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<string, unknown>;
/**
* 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<string, unknown>,
) {
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<Scope[]>;
scopes: string[];
type CreateUserTokensParams = OAuthTokenIssueParams & {
grantType: GrantType;
user?: User;
referenceId?: string;
sessionId?: string;
nonce?: string;
refreshToken?: OAuthRefreshToken<Scope[]> & { 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<Scope[]>,
params: CreateUserTokensParams,
) {
): Promise<OAuthTokenResponse> {
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(
+326 -27
View File
@@ -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<Scopes> | null,
) => Awaitable<SchemaClient<Scopes> | null>;
existing: SchemaClient<Scope[]> | null,
) => Awaitable<SchemaClient<Scope[]> | 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<string, unknown>;
}
export interface OAuthAuthenticatedClient {
clientId: string;
client: SchemaClient<Scope[]>;
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<Scope[]>;
scopes: string[];
user?: User;
referenceId?: string;
sessionId?: string;
nonce?: string;
refreshToken?: OAuthRefreshToken<Scope[]> & { 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<string, unknown>;
/**
* Additional ID-token claims for this single issuance. Additive: they cannot
* replace identity, authentication-context, or AS-owned claims.
*/
idTokenClaims?: Record<string, unknown>;
/**
* Additional fields for the token response envelope. Standard OAuth token
* response fields stay owned by the authorization server.
*/
tokenResponse?: Record<string, unknown>;
/**
* 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<SchemaClient<Scope[]> | 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<OAuthAuthenticatedClient>;
/**
* 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<OAuthTokenResponse>;
/**
* 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<string>;
validateAccessToken: (
token: string,
clientId?: string,
) => Awaitable<JWTPayload>;
}
export interface OAuthExtensionGrantHandlerInput {
ctx: GenericEndpointContext;
opts: OAuthOptions<Scope[]>;
grantType: GrantType;
/** The provider capability surface, pre-bound to this grant's `grantType`. */
provider: OAuthProviderApi;
}
export type OAuthExtensionGrantHandler = (
input: OAuthExtensionGrantHandlerInput,
) => Awaitable<OAuthTokenResponse>;
export interface OAuthClientAuthenticationInput {
ctx: GenericEndpointContext;
opts: OAuthOptions<Scope[]>;
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<OAuthAuthenticatedClient>;
}
export interface OAuthMetadataExtensionInput {
ctx: GenericEndpointContext;
opts: OAuthOptions<Scope[]>;
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<Scope[]>;
user?: (User & Record<string, unknown>) | null;
client: SchemaClient<Scope[]>;
scopes: string[];
grantType?: GrantType;
referenceId?: string;
resources?: string[];
/** Parsed client metadata, as returned by `parseClientMetadata`. */
metadata?: Record<string, unknown>;
}
export interface OAuthUserInfoExtensionInput {
ctx: GenericEndpointContext;
opts: OAuthOptions<Scope[]>;
user: User & Record<string, unknown>;
scopes: string[];
jwt: JWTPayload;
client?: SchemaClient<Scope[]>;
}
/**
* 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<string, OAuthExtensionGrantHandler>;
/**
* 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<string, OAuthClientAuthenticationStrategy>;
/**
* 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<string, unknown>;
/**
* 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<Record<string, unknown>>;
idToken?: (
input: OAuthClaimExtensionInput,
) => Awaitable<Record<string, unknown>>;
userInfo?: (
input: OAuthUserInfoExtensionInput,
) => Awaitable<Record<string, unknown>>;
};
/**
* 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<Scopes> | ClientDiscovery<Scopes>[];
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`. */
+24 -9
View File
@@ -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
+55 -13
View File
@@ -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<string, unknown>,
base?: Record<string, unknown>,
) {
const next: Record<string, unknown> = {};
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,
};
}
@@ -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<Scope[]>,
params: {
namespace: string;
payload: { aud?: unknown; exp?: unknown; iat?: unknown; jti?: unknown };
expectedAudience: string;
},
): Promise<void> {
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 };
}
+83 -34
View File
@@ -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<Scope[]>["clientDiscovery"],
): ClientDiscovery<Scope[]>[] {
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<Scope[]>["clientDiscovery"],
discoveries: ClientDiscovery[],
): Record<string, unknown> {
const discoveries = toClientDiscoveryArray(discovery);
return discoveries.reduce<Record<string, unknown>>(
(acc, d) => ({ ...acc, ...(d.discoveryMetadata ?? {}) }),
{},
@@ -513,6 +506,7 @@ export async function validateClientCredentials(
scopes?: string[], // checks requested scopes against allowed scopes
preVerifiedClient?: SchemaClient<Scope[]>,
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<string, unknown> | undefined {
if (!metadata) return undefined;
return typeof metadata === "string" ? JSON.parse(metadata) : metadata;
return typeof metadata === "string"
? JSON.parse(metadata)
: (metadata as Record<string, unknown>);
}
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<Scope[]>;
/** 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<string, unknown>;
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;