feat(oauth-provider): per-request token claims and contributed client-auth strategies

Two generic extension points complete the contribution surface.

A custom grant handler injects per-request claims and response fields through the
new `extra` option on the exported `createUserTokens`: `extra.accessTokenClaims`
merge into the JWT access token beneath the host-pinned claims, and
`extra.tokenResponse` into the response body beneath the standard OAuth fields.
`CreateUserTokensParams` is now exported so external grant handlers can type it.

Plugins register token-endpoint client-authentication methods through
`OAuthContributions.clientAuthStrategies`, keyed by `client_assertion_type` and
dispatched at the shared `extractClientCredentials` chokepoint (so the token,
introspection, and revocation endpoints all honor them). A method such as wallet
attestation becomes executable, not merely advertised.
This commit is contained in:
Gustavo Valverde
2026-05-31 22:10:14 +01:00
parent 94fd92491b
commit d5e7128bf1
9 changed files with 227 additions and 11 deletions
@@ -8,6 +8,8 @@ Add a plugin contribution protocol so plugins can extend the oauth-provider with
The oauth-provider now consumes those contributions. The token endpoint dispatches plugin-registered custom grant types, the discovery documents advertise contributed grant URIs, metadata, and token-endpoint auth methods, and minted access and ID tokens include plugin-contributed claims. The grant-author helpers (`createUserTokens`, `validateClientCredentials`, `basicToClientCredentials`, `getClient`, `storeToken`) and the `OAuthContributions` contract type are now exported so grant types like CIBA, OIDC4VCI, and token exchange can ship as external packages.
A custom grant handler can inject per-request claims and response fields through the `extra` option on `createUserTokens` (its `CreateUserTokensParams` type is now exported): `extra.accessTokenClaims` merge into the JWT access token and `extra.tokenResponse` into the response body, both beneath the host's pinned fields. Plugins can also register token-endpoint client-authentication methods through `OAuthContributions.clientAuthStrategies`, keyed by `client_assertion_type`, so a method such as wallet attestation becomes executable rather than only advertised.
The token endpoint's `grant_type` parameter now accepts any registered grant URI instead of a fixed enum. An unknown grant type returns an OAuth `unsupported_grant_type` error response rather than a schema-validation error; valid requests for the built-in grants are unaffected.
Plugin-contributed claims can never override the host's registered or authentication-context claims; on the ID token this includes `acr` and `auth_time`. Contributed access-token claims are also surfaced when an opaque token is read through introspection, matching the JWT path. Conflicting contributions fail at startup: two plugins registering the same grant URI, a grant URI advertised without a handler (or a handler not advertised), and a grant type that is not an absolute URI all throw.
@@ -8,10 +8,15 @@ import {
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import type { BetterAuthPlugin } from "better-auth/types";
import { APIError } from "better-call";
import { decodeJwt } from "jose";
import { describe, expect, it } from "vitest";
import { oauthProviderClient } from "./client";
import { createUserTokens } from "./index";
import {
createUserTokens,
getClient,
validateClientCredentials,
} from "./index";
import { oauthProvider } from "./oauth";
import type { OAuthContributions } from "./types/contributions";
@@ -20,6 +25,11 @@ const TOKEN_URL = `${BASE_URL}/api/auth/oauth2/token`;
const INTROSPECT_URL = `${BASE_URL}/api/auth/oauth2/introspect`;
const METADATA_URL = `${BASE_URL}/api/auth/.well-known/oauth-authorization-server`;
const CUSTOM_GRANT = "urn:better-auth:test:custom-grant";
// A custom grant whose handler reuses the exported createUserTokens to mint, and
// injects per-request claims/response fields via CreateUserTokensParams.extra.
const CLAIMS_GRANT = "urn:better-auth:test:claims-grant";
// A custom token-endpoint client-auth method backed by a contributed verifier.
const ASSERTION_TYPE = "urn:better-auth:test:assertion";
const RESOURCE = "https://api.test";
/**
@@ -51,12 +61,51 @@ function contributionProbe() {
},
},
),
// Reuses the exported createUserTokens to mint, injecting per-request
// claims and response fields through CreateUserTokensParams.extra.
[CLAIMS_GRANT]: async (ctx, opts) => {
const client = await validateClientCredentials(
ctx,
opts,
ctx.body.client_id as string,
ctx.body.client_secret as string | undefined,
["openid"],
);
return createUserTokens(ctx, opts, {
client,
scopes: ["openid"],
grantType: CLAIMS_GRANT,
resources: [RESOURCE],
extra: {
accessTokenClaims: { probe_extra: "yes" },
tokenResponse: { probe_response: "ok" },
},
});
},
},
grantTypeURIs: [CUSTOM_GRANT],
grantTypeURIs: [CUSTOM_GRANT, CLAIMS_GRANT],
metadata: ({ baseURL }) => ({
test_probe_endpoint: `${baseURL}/probe`,
}),
tokenEndpointAuthMethods: ["urn:better-auth:test:auth"],
tokenEndpointAuthMethods: ["urn:better-auth:test:auth", ASSERTION_TYPE],
// A contributed verifier for ASSERTION_TYPE: it authenticates the client
// by a fixed assertion value and returns the registered client record.
clientAuthStrategies: {
[ASSERTION_TYPE]: async (ctx, opts) => {
if (ctx.body.client_assertion !== "valid-attestation") {
throw new APIError("UNAUTHORIZED", { error: "invalid_client" });
}
const client = await getClient(
ctx,
opts,
ctx.body.client_id as string,
);
if (!client) {
throw new APIError("BAD_REQUEST", { error: "invalid_client" });
}
return { clientId: client.clientId, client };
},
},
tokenClaims: {
access: () => ({ probe_claim: "present" }),
// Returns a namespaced claim plus an attempt to override the
@@ -290,6 +339,85 @@ describe("oauth-provider contribution surface", () => {
it("exports the grant-author token-minting helper", () => {
expect(typeof createUserTokens).toBe("function");
});
it("lets a custom grant inject per-request claims and response fields via createUserTokens", async () => {
const { auth, signInWithTestUser } = await setup();
const { headers } = await signInWithTestUser();
const client = await auth.api.adminCreateOAuthClient({
headers,
body: { redirect_uris: ["http://localhost:3000/cb"], skip_consent: true },
});
const response = await auth.handler(
new Request(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: CLAIMS_GRANT,
client_id: client!.client_id,
client_secret: client!.client_secret!,
resource: RESOURCE,
}).toString(),
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as {
access_token?: string;
probe_response?: string;
};
expect(body.probe_response).toBe("ok");
expect(decodeJwt(body.access_token!).probe_extra).toBe("yes");
});
it("authenticates the client through a contributed client-auth strategy", async () => {
const { auth, signInWithTestUser } = await setup();
const { headers } = await signInWithTestUser();
const client = await auth.api.adminCreateOAuthClient({
headers,
body: { redirect_uris: ["http://localhost:3000/cb"], skip_consent: true },
});
const response = await auth.handler(
new Request(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: client!.client_id,
client_assertion_type: ASSERTION_TYPE,
client_assertion: "valid-attestation",
resource: RESOURCE,
}).toString(),
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as { access_token?: string };
expect(body.access_token).toBeDefined();
});
it("rejects a contributed client-auth strategy when the assertion is invalid", async () => {
const { auth, signInWithTestUser } = await setup();
const { headers } = await signInWithTestUser();
const client = await auth.api.adminCreateOAuthClient({
headers,
body: { redirect_uris: ["http://localhost:3000/cb"], skip_consent: true },
});
const response = await auth.handler(
new Request(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: client!.client_id,
client_assertion_type: ASSERTION_TYPE,
client_assertion: "forged",
resource: RESOURCE,
}).toString(),
}),
);
expect(response.status).toBe(401);
expect(((await response.json()) as { error?: string }).error).toBe(
"invalid_client",
);
});
});
describe("oauth-provider contribution init validation", () => {
+1
View File
@@ -15,6 +15,7 @@ export type {
OAuthRedirectOnError,
} from "./oauth-endpoint";
export { checkOAuthClient, oauthToSchema } from "./register";
export type { CreateUserTokensParams } from "./token";
// Grant-author toolkit: helpers a contributed grant handler reuses to mint
// tokens and authenticate the client instead of reimplementing them.
export { createUserTokens } from "./token";
+19 -1
View File
@@ -33,7 +33,11 @@ import { withOAuthRuntime } from "./runtime";
import { schema } from "./schema";
import { tokenEndpoint } from "./token";
import type { OAuthOptions, Scope } from "./types";
import type { GrantHandler, OAuthContributions } from "./types/contributions";
import type {
ClientAuthStrategy,
GrantHandler,
OAuthContributions,
} from "./types/contributions";
import { ResourceUriSchema, SafeUrlSchema } from "./types/zod";
import { userInfoEndpoint } from "./userinfo";
import {
@@ -300,6 +304,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
const claimContributors: NonNullable<
OAuthContributions["tokenClaims"]
>[] = [];
const clientAuthStrategies: Record<string, ClientAuthStrategy> = {};
for (const contribution of contributions) {
if (contribution.grantTypes) {
for (const [uri, handler] of Object.entries(
@@ -327,6 +332,18 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
if (contribution.tokenClaims) {
claimContributors.push(contribution.tokenClaims);
}
if (contribution.clientAuthStrategies) {
for (const [assertionType, strategy] of Object.entries(
contribution.clientAuthStrategies,
)) {
if (clientAuthStrategies[assertionType]) {
throw new BetterAuthError(
`Conflicting client authentication strategy "${assertionType}": two plugins registered a verifier for it.`,
);
}
clientAuthStrategies[assertionType] = strategy;
}
}
}
// Fail fast on a mismatch a request would otherwise hit at runtime: an
@@ -372,6 +389,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
metadataContributors,
extraAuthMethods: [...extraAuthMethods],
claimContributors,
clientAuthStrategies,
};
// OAuth provider performs adapter-level session lookups by id, so it
+3
View File
@@ -1,5 +1,6 @@
import type { GenericEndpointContext } from "@better-auth/core";
import type {
ClientAuthStrategy,
GrantHandler,
OAuthContributions,
TokenClaimInfo,
@@ -22,6 +23,8 @@ export interface OAuthProviderRuntime {
extraAuthMethods: string[];
/** Token-claim contributors merged into minted access and ID tokens. */
claimContributors: NonNullable<OAuthContributions["tokenClaims"]>[];
/** Client-authentication strategies keyed by `client_assertion_type`. */
clientAuthStrategies: Record<string, ClientAuthStrategy>;
}
const RUNTIME_KEY = "oauthProviderRuntime";
+18 -1
View File
@@ -90,6 +90,8 @@ async function createJwtAccessToken(
iat?: number;
exp?: number;
sid?: string;
/** Per-request claims from a custom grant handler (see CreateUserTokensParams.extra). */
extraClaims?: Record<string, unknown>;
},
) {
const iat = overrides?.iat ?? Math.floor(Date.now() / 1000);
@@ -119,6 +121,7 @@ async function createJwtAccessToken(
options: jwtPluginOptions,
payload: {
...extensionClaims,
...overrides?.extraClaims,
...customClaims,
sub: user?.id,
aud: toAudienceClaim(audience),
@@ -506,7 +509,7 @@ async function createRefreshToken(
};
}
interface CreateUserTokensParams {
export interface CreateUserTokensParams {
client: SchemaClient<Scope[]>;
scopes: string[];
grantType: GrantType;
@@ -521,6 +524,18 @@ interface CreateUserTokensParams {
/** 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[];
/**
* Per-request additions a custom grant handler injects for the token it is
* minting. `accessTokenClaims` are merged into the JWT access-token payload
* (applied before the consumer's `customAccessTokenClaims`, never over the
* host-pinned claims); `tokenResponse` fields are merged into the token
* response body under the standard OAuth fields. `accessTokenClaims` reach
* the JWT access token only; opaque access tokens carry no claims.
*/
extra?: {
accessTokenClaims?: Record<string, unknown>;
tokenResponse?: Record<string, unknown>;
};
}
export async function createUserTokens(
@@ -634,6 +649,7 @@ export async function createUserTokens(
iat,
exp,
sid: sessionId,
extraClaims: params.extra?.accessTokenClaims,
},
)
: createOpaqueAccessToken(
@@ -692,6 +708,7 @@ export async function createUserTokens(
return ctx.json(
{
...customFields,
...params.extra?.tokenResponse,
access_token: accessToken,
expires_in: exp - iat,
expires_at: exp,
@@ -39,6 +39,25 @@ export interface TokenClaimInfo {
resources?: string[];
}
/** A client authenticated by a {@link ClientAuthStrategy}. */
export interface ClientAuthResult {
clientId: string;
client: SchemaClient<Scope[]>;
}
/**
* A token-endpoint client-authentication strategy, registered under the
* `client_assertion_type` it verifies. The token endpoint dispatches to it when
* a request presents that assertion type; the strategy reads the credential from
* `ctx.body` / `ctx.request`, verifies it, and returns the authenticated client
* (look it up with the exported `getClient(ctx, opts, clientId)`). Advertise the
* method via `tokenEndpointAuthMethods`. Mirrors `GrantHandler`'s `(ctx, opts)` shape.
*/
export type ClientAuthStrategy = (
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
) => Awaitable<ClientAuthResult>;
/**
* What a plugin can contribute to the `oauth-provider` host through its
* `contributes` field. The host collects these at init via
@@ -103,6 +122,15 @@ export interface OAuthContributions {
access?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
id?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
};
/**
* Token-endpoint client-authentication strategies keyed by the
* `client_assertion_type` they verify (e.g. a wallet-attestation URN). When a
* token request presents that assertion type, the endpoint dispatches to the
* strategy, which verifies the credential and returns the authenticated
* client. Pair with `tokenEndpointAuthMethods` to advertise the method in
* discovery.
*/
clientAuthStrategies?: Record<string, ClientAuthStrategy>;
}
declare module "@better-auth/core" {
@@ -6,6 +6,8 @@ import type { Awaitable } from "./helpers";
import type { GrantType } from "./oauth";
export type {
ClientAuthResult,
ClientAuthStrategy,
GrantHandler,
OAuthContributions,
TokenClaimInfo,
+23 -6
View File
@@ -12,6 +12,7 @@ import {
import type { jwt } from "better-auth/plugins";
import { APIError } from "better-call";
import type { oauthProvider } from "../oauth";
import { getOAuthRuntime } from "../runtime";
import type {
ClientDiscovery,
OAuthOptions,
@@ -614,7 +615,11 @@ export type ExtractedCredentials =
clientSecret: string;
}
| {
method: "private_key_jwt";
// Pre-verified, assertion-based methods: the built-in private_key_jwt plus
// any plugin-contributed clientAuthStrategies, keyed by client_assertion_type.
// The string member carries the contributed method id (= the advertised
// token_endpoint_auth_method).
method: "private_key_jwt" | (string & {});
clientId: string;
client: SchemaClient<Scope[]>;
}
@@ -630,14 +635,11 @@ export function destructureCredentials(
return {
clientId: credentials?.clientId,
clientSecret:
credentials?.method === "client_secret_basic" ||
credentials?.method === "client_secret_post"
credentials && "clientSecret" in credentials
? credentials.clientSecret
: undefined,
preVerifiedClient:
credentials?.method === "private_key_jwt"
? credentials.client
: undefined,
credentials && "client" in credentials ? credentials.client : undefined,
};
}
@@ -669,6 +671,21 @@ export async function extractClientCredentials(
error: "invalid_client",
});
}
// A plugin-contributed strategy for this assertion type takes precedence
// over the built-in private_key_jwt (jwt-bearer) verifier. It verifies the
// credential and returns the authenticated client.
const strategy =
getOAuthRuntime(ctx)?.clientAuthStrategies?.[
body.client_assertion_type as string
];
if (strategy) {
const result = await strategy(ctx, opts);
return {
method: body.client_assertion_type as string,
clientId: result.clientId,
client: result.client,
};
}
const { verifyClientAssertion: verify } = await import(
"./client-assertion"
);