mirror of
https://github.com/better-auth/better-auth.git
synced 2026-07-26 22:42:35 -05:00
feat(oauth-provider): add extension surface (#10030)
This commit is contained in:
@@ -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 }] })`.
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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`. */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user