feat(oauth-provider): dispatch contributed grant types, claims, and discovery

The oauth-provider consumes plugin contributions to its host. 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.

The `grant_type` body parameter accepts any registered grant URI instead of a
fixed enum. An unknown grant type returns an `unsupported_grant_type` error
response rather than a schema-validation error; the built-in grants are
unaffected.
This commit is contained in:
Gustavo Valverde
2026-05-31 17:18:11 +01:00
parent dd9769907c
commit 9c40ceeefd
10 changed files with 461 additions and 36 deletions
@@ -0,0 +1,11 @@
---
"@better-auth/core": minor
"better-auth": minor
"@better-auth/oauth-provider": minor
---
Add a plugin contribution protocol so plugins can extend the oauth-provider without changing better-auth. A plugin declares `contributes: { "oauth-provider": { ... } }` (typed via the host-augmented `ContributionContracts` interface) plus an optional `requires` list validated at startup, and the host collects them with `ctx.getContributions(...)` in its init.
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.
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.
@@ -0,0 +1,150 @@
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import type { BetterAuthPlugin } from "better-auth/types";
import { decodeJwt } from "jose";
import { describe, expect, it } from "vitest";
import { createUserTokens } from "./index";
import { oauthProvider } from "./oauth";
import type { OAuthContributions } from "./types/contributions";
const BASE_URL = "http://localhost:3000";
const TOKEN_URL = `${BASE_URL}/api/auth/oauth2/token`;
const METADATA_URL = `${BASE_URL}/api/auth/.well-known/oauth-authorization-server`;
const CUSTOM_GRANT = "urn:better-auth:test:custom-grant";
const RESOURCE = "https://api.test";
/**
* A guest plugin that exercises every OAuthContributions slot: a custom grant
* handler, the grant URI, discovery metadata, an advertised auth method, and an
* access-token claim contributor.
*/
function contributionProbe() {
return {
id: "contribution-probe",
requires: ["oauth-provider"],
contributes: {
"oauth-provider": {
grantTypes: {
// The handler echoes a passthrough body param to prove the open
// schema preserves extension params, then returns a plain response.
[CUSTOM_GRANT]: (ctx) =>
ctx.json({
contributed_grant: true,
echoed: ctx.body.probe,
}),
},
grantTypeURIs: [CUSTOM_GRANT],
metadata: ({ baseURL }) => ({
test_probe_endpoint: `${baseURL}/probe`,
}),
tokenEndpointAuthMethods: ["urn:better-auth:test:auth"],
tokenClaims: {
access: () => ({ probe_claim: "present" }),
},
} satisfies OAuthContributions,
},
} satisfies BetterAuthPlugin;
}
async function setup() {
return getTestInstance({
baseURL: BASE_URL,
plugins: [
jwt(),
oauthProvider({
loginPage: "/login",
consentPage: "/consent",
// A valid audience lets client_credentials mint a JWT access token,
// which is where contributed access-token claims are applied.
validAudiences: [RESOURCE],
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
}),
contributionProbe(),
],
});
}
describe("oauth-provider contribution surface", () => {
it("dispatches a contributed grant type and preserves passthrough body params", async () => {
const { auth } = await setup();
const response = await auth.handler(
new Request(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: CUSTOM_GRANT,
probe: "hello",
}).toString(),
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as {
contributed_grant?: boolean;
echoed?: string;
};
expect(body.contributed_grant).toBe(true);
expect(body.echoed).toBe("hello");
});
it("rejects an unregistered grant type with unsupported_grant_type", async () => {
const { auth } = await setup();
const response = await auth.handler(
new Request(TOKEN_URL, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "urn:better-auth:test:unregistered",
}).toString(),
}),
);
expect(response.status).toBe(400);
const body = (await response.json()) as { error?: string };
expect(body.error).toBe("unsupported_grant_type");
});
it("advertises the contributed grant URI, metadata, and auth method in discovery", async () => {
const { auth } = await setup();
const response = await auth.handler(new Request(METADATA_URL));
expect(response.status).toBe(200);
const metadata = (await response.json()) as {
grant_types_supported?: string[];
token_endpoint_auth_methods_supported?: string[];
test_probe_endpoint?: string;
};
expect(metadata.grant_types_supported).toContain(CUSTOM_GRANT);
expect(metadata.token_endpoint_auth_methods_supported).toContain(
"urn:better-auth:test:auth",
);
expect(metadata.test_probe_endpoint).toBe(`${BASE_URL}/api/auth/probe`);
});
it("merges contributed token claims into a minted access token", 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_secret: client!.client_secret!,
resource: RESOURCE,
}).toString(),
}),
);
expect(response.status).toBe(200);
const body = (await response.json()) as { access_token?: string };
expect(body.access_token).toBeDefined();
const claims = decodeJwt(body.access_token!);
expect(claims.probe_claim).toBe("present");
});
it("exports the grant-author token-minting helper", () => {
expect(typeof createUserTokens).toBe("function");
});
});
+9
View File
@@ -15,4 +15,13 @@ export type {
OAuthRedirectOnError,
} from "./oauth-endpoint";
export { checkOAuthClient, oauthToSchema } from "./register";
// Grant-author toolkit: helpers a contributed grant handler reuses to mint
// tokens and authenticate the client instead of reimplementing them.
export { createUserTokens } from "./token";
export type * from "./types";
export {
basicToClientCredentials,
getClient,
storeToken,
validateClientCredentials,
} from "./utils";
+39
View File
@@ -15,6 +15,44 @@ import {
toClientDiscoveryArray,
} from "./utils";
/**
* Folds plugin-contributed discovery into the metadata document: appends
* contributed token-endpoint auth methods and adds contributed fields. Core
* fields are never overridden — a contributor can only add new keys.
*/
function applyMetadataContributions(
ctx: GenericEndpointContext,
metadata: AuthServerMetadata,
): void {
const extraAuthMethods = (ctx.context as Record<string, unknown>)
.oauthExtraAuthMethods as string[] | undefined;
if (extraAuthMethods?.length) {
const methods = metadata.token_endpoint_auth_methods_supported ?? [];
for (const method of extraAuthMethods) {
if (!methods.includes(method as TokenEndpointAuthMethod)) {
methods.push(method as TokenEndpointAuthMethod);
}
}
metadata.token_endpoint_auth_methods_supported = methods;
}
const contributors = (ctx.context as Record<string, unknown>)
.oauthMetadataContributors as
| ((c: { baseURL: string }) => Record<string, unknown>)[]
| undefined;
if (contributors?.length) {
const target = metadata as Record<string, unknown>;
for (const contribute of contributors) {
for (const [key, value] of Object.entries(
contribute({ baseURL: ctx.context.baseURL }),
)) {
if (target[key] == null) {
target[key] = value;
}
}
}
}
}
export function authServerMetadata(
ctx: GenericEndpointContext,
opts?: JwtOptions,
@@ -82,6 +120,7 @@ export function authServerMetadata(
code_challenge_methods_supported: ["S256"],
authorization_response_iss_parameter_supported: true,
};
applyMetadataContributions(ctx, metadata);
return metadata;
}
+75 -30
View File
@@ -31,6 +31,7 @@ import { revokeEndpoint } from "./revoke";
import { schema } from "./schema";
import { tokenEndpoint } from "./token";
import type { OAuthOptions, Scope } from "./types";
import type { GrantHandler, OAuthContributions } from "./types/contributions";
import { ResourceUriSchema, SafeUrlSchema } from "./types/zod";
import { userInfoEndpoint } from "./userinfo";
import {
@@ -271,6 +272,54 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
options: opts as NoInfer<O>,
onRequest: handleIssuerMetadataRequest,
init: (ctx) => {
// Aggregate contributions from plugins that target this host. Read once
// at init (static, order-independent) and expose on the context so the
// token endpoint, discovery, and minting can consume them at request time.
const contributions = ctx.getContributions("oauth-provider");
const oauthGrantHandlers: Record<string, GrantHandler> = {};
const extensionGrantURIs: string[] = [];
const oauthMetadataContributors: NonNullable<
OAuthContributions["metadata"]
>[] = [];
const oauthExtraAuthMethods: string[] = [];
const oauthClaimContributors: NonNullable<
OAuthContributions["tokenClaims"]
>[] = [];
for (const contribution of contributions) {
if (contribution.grantTypes) {
Object.assign(oauthGrantHandlers, contribution.grantTypes);
}
if (contribution.grantTypeURIs) {
extensionGrantURIs.push(...contribution.grantTypeURIs);
}
if (contribution.metadata) {
oauthMetadataContributors.push(contribution.metadata);
}
if (contribution.tokenEndpointAuthMethods) {
oauthExtraAuthMethods.push(...contribution.tokenEndpointAuthMethods);
}
if (contribution.tokenClaims) {
oauthClaimContributors.push(contribution.tokenClaims);
}
}
// Allow contributed grant URIs through the token-endpoint allowlist.
if (extensionGrantURIs.length > 0) {
opts.grantTypes = [
...(opts.grantTypes ?? [
"authorization_code",
"client_credentials",
"refresh_token",
]),
...extensionGrantURIs,
];
}
const pluginContext = {
oauthGrantHandlers,
oauthMetadataContributors,
oauthExtraAuthMethods,
oauthClaimContributors,
};
// OAuth provider performs adapter-level session lookups by id, so it
// currently requires DB-backed sessions whenever secondary storage is enabled.
if (
@@ -300,7 +349,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
} catch (error) {
// baseURL may not be available during init when using dynamic baseURL config
if (isDynamicBaseURLInit && issuer === "") {
return;
return { context: pluginContext };
}
throw error;
}
@@ -324,6 +373,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
);
}
}
return { context: pluginContext };
},
hooks: {
before: [
@@ -748,29 +798,28 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
"/oauth2/token",
{
method: "POST",
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(),
}),
// Open `grant_type` to any non-empty string so plugin-contributed
// extension grants reach the dispatcher; the allowlist + the token
// endpoint's `default` case still reject unregistered URIs with
// `unsupported_grant_type`. `.passthrough()` keeps extension body
// params (e.g. `subject_token`, `auth_req_id`) for the grant handler.
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",
@@ -791,12 +840,8 @@ 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",
description:
"OAuth2 grant type. Built-ins: authorization_code, client_credentials, refresh_token. Plugins may register extension grant URIs (e.g. CIBA, token-exchange).",
},
client_id: {
type: "string",
+64 -1
View File
@@ -13,6 +13,11 @@ import type {
Scope,
VerificationValue,
} from "./types";
import type {
GrantHandler,
OAuthContributions,
TokenClaimInfo,
} from "./types/contributions";
import type { GrantType } from "./types/oauth";
import { verificationValueSchema } from "./types/zod";
import { userNormalClaims } from "./userinfo";
@@ -58,11 +63,50 @@ export async function tokenEndpoint(
return handleClientCredentialsGrant(ctx, opts);
case "refresh_token":
return handleRefreshTokenGrant(ctx, opts);
default: {
// Extension grants registered by plugins via OAuthContributions.grantTypes.
// The allowlist check above already rejected unknown grant_type values, so
// reaching here means the URI was allowed; dispatch to its handler.
const handlers = (ctx.context as Record<string, unknown>)
.oauthGrantHandlers as Record<string, GrantHandler> | undefined;
const handler = handlers?.[grantType];
if (handler) {
return handler(ctx, opts);
}
throw new APIError("BAD_REQUEST", {
error_description: `unsupported grant_type ${grantType}`,
error: "unsupported_grant_type",
});
}
}
}
// User Jwt SHALL follow oAuth 2
// NOTE: Requires jwt plugin (assert !opts.disableJwtPlugin)
/**
* Collects extra claims from plugin-contributed `tokenClaims` contributors for
* the given token kind. Returns the merged claims, applied before the
* consumer's `customAccessTokenClaims`/`customIdTokenClaims` and never over the
* pinned security claims set by the caller.
*/
async function collectExtensionClaims(
ctx: GenericEndpointContext,
kind: "access" | "id",
info: TokenClaimInfo,
): Promise<Record<string, unknown>> {
const contributors = (ctx.context as Record<string, unknown>)
.oauthClaimContributors as
| NonNullable<OAuthContributions["tokenClaims"]>[]
| undefined;
if (!contributors?.length) return {};
const claims: Record<string, unknown> = {};
for (const contributor of contributors) {
const fn = contributor[kind];
if (fn) Object.assign(claims, await fn(info));
}
return claims;
}
async function createJwtAccessToken(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
@@ -80,6 +124,14 @@ async function createJwtAccessToken(
) {
const iat = overrides?.iat ?? Math.floor(Date.now() / 1000);
const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
const extensionClaims = await collectExtensionClaims(ctx, "access", {
user,
scopes,
client,
referenceId,
sessionId: overrides?.sid,
resources,
});
const customClaims = opts.customAccessTokenClaims
? await opts.customAccessTokenClaims({
user,
@@ -96,6 +148,7 @@ async function createJwtAccessToken(
return signJWT(ctx, {
options: jwtPluginOptions,
payload: {
...extensionClaims,
...customClaims,
sub: user?.id,
aud: toAudienceClaim(audience),
@@ -148,6 +201,7 @@ async function createIdToken(
sessionId?: string,
authTime?: Date,
accessToken?: string,
referenceId?: string,
) {
const iat = Math.floor(Date.now() / 1000);
const exp = iat + (opts.idTokenExpiresIn ?? 36000);
@@ -160,6 +214,13 @@ async function createIdToken(
// - silver : mfa
const acr = "urn:mace:incommon:iap:bronze";
const extensionClaims = await collectExtensionClaims(ctx, "id", {
user,
scopes,
client,
sessionId,
referenceId,
});
const customClaims = opts.customIdTokenClaims
? await opts.customIdTokenClaims({
user,
@@ -190,6 +251,7 @@ async function createIdToken(
...userClaims,
auth_time: authTimeSec,
acr,
...extensionClaims,
...customClaims,
at_hash: atHash,
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
@@ -482,7 +544,7 @@ interface CreateUserTokensParams {
originalResources?: string[];
}
async function createUserTokens(
export async function createUserTokens(
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
params: CreateUserTokensParams,
@@ -644,6 +706,7 @@ async function createUserTokens(
sessionId,
authTime,
accessToken,
referenceId,
)
: undefined;
@@ -0,0 +1,101 @@
import type { Awaitable, GenericEndpointContext } from "@better-auth/core";
import type { User } from "better-auth/types";
import type { OAuthOptions, SchemaClient, Scope } from "./index";
/**
* A custom grant-type handler dispatched by the token endpoint when an incoming
* `grant_type` matches a key a plugin registered via `OAuthContributions.grantTypes`.
*
* It receives the endpoint context and the resolved oauth-provider options and
* returns the full token-endpoint response (the same shape the built-in grant
* handlers produce). Use the exported `createUserTokens` helper to mint tokens.
*/
export type GrantHandler = (
ctx: GenericEndpointContext,
opts: OAuthOptions<Scope[]>,
) => Awaitable<Response>;
/**
* Context passed to a token-claim contributor at minting time. A contributor
* returns extra claims to merge into the access token or ID token.
*/
export interface TokenClaimInfo {
/** The authenticated user, or `undefined` for client_credentials access tokens. */
user: User | undefined;
/** Scopes granted to the token. */
scopes: string[];
/** The OAuth client the token is issued to. */
client: SchemaClient<Scope[]>;
/** Grant reference id (e.g. a CIBA `auth_req_id`), when the grant supplies one. */
referenceId?: string;
/** Session id bound to the token, when available. */
sessionId?: string;
/** Requested resource indicators (RFC 8707), when present. */
resources?: string[];
}
/**
* What a plugin can contribute to the `oauth-provider` host through its
* `contributes` field. The host collects these at init via
* `ctx.getContributions("oauth-provider")` and wires them into the token
* endpoint, discovery metadata, and token minting — so a plugin can add custom
* grant types, advertise capabilities, and inject claims without the
* oauth-provider knowing about it.
*
* @example
* ```ts
* import type { OAuthContributions } from "@better-auth/oauth-provider";
*
* export const ciba = () => ({
* id: "ciba",
* requires: ["oauth-provider"],
* contributes: {
* "oauth-provider": {
* grantTypes: { "urn:openid:params:grant-type:ciba": cibaGrantHandler },
* grantTypeURIs: ["urn:openid:params:grant-type:ciba"],
* metadata: ({ baseURL }) => ({
* backchannel_authentication_endpoint: `${baseURL}/oauth2/bc-authorize`,
* }),
* } satisfies OAuthContributions,
* },
* });
* ```
*/
export interface OAuthContributions {
/**
* Custom grant-type handlers keyed by `grant_type` URI. The token endpoint
* dispatches to the handler when the request's grant_type matches and the URI
* is allowed. Pair every key with a `grantTypeURIs` entry.
*/
grantTypes?: Record<string, GrantHandler>;
/**
* Grant-type URIs to add to the token-endpoint allowlist and to
* `grant_types_supported` in discovery.
*/
grantTypeURIs?: string[];
/**
* Fields merged into the `.well-known` discovery documents (e.g. an endpoint
* or capability a profile adds).
*/
metadata?: (ctx: { baseURL: string }) => Record<string, unknown>;
/**
* Token-endpoint client authentication methods to advertise in
* `token_endpoint_auth_methods_supported`.
*/
tokenEndpointAuthMethods?: string[];
/**
* Claim contributors merged into issued tokens. Extension claims are applied
* before the consumer's `customAccessTokenClaims`/`customIdTokenClaims` and
* never override pinned security claims (`sub`, `iss`, `aud`, `iat`, `exp`).
*/
tokenClaims?: {
access?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
id?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
};
}
declare module "@better-auth/core" {
interface ContributionContracts {
"oauth-provider": OAuthContributions;
}
}
@@ -5,6 +5,11 @@ import type { schema } from "../schema";
import type { Awaitable } from "./helpers";
import type { GrantType } from "./oauth";
export type {
GrantHandler,
OAuthContributions,
TokenClaimInfo,
} from "./contributions";
export type {
AuthMethod,
AuthServerMetadata,
+6 -4
View File
@@ -10,10 +10,12 @@ export type GrantType =
// | "implicit" // NEVER SUPPORT - deprecated in oAuth2.1
// | "password" // NEVER SUPPORT - deprecated in oAuth2.1
| "client_credentials"
| "refresh_token";
// | "urn:ietf:params:oauth:grant-type:device_code" // specified in oAuth2.1 but not yet implemented
// | "urn:ietf:params:oauth:grant-type:jwt-bearer" // unspecified in oAuth2.1
// | "urn:ietf:params:oauth:grant-type:saml2-bearer" // unspecified in oAuth2.1
| "refresh_token"
// Extension grant URIs (CIBA, pre-authorized_code, token-exchange, device_code,
// ...) are registered by plugins via `OAuthContributions.grantTypes`. The
// `(string & {})` member accepts those custom URIs while preserving literal
// autocomplete for the built-ins above.
| (string & {});
export type AuthMethod =
| "client_secret_basic" // Basic header
+1 -1
View File
@@ -487,7 +487,7 @@ export async function getStoredToken(
// spaces aren't rejected before reaching the spec-correct decoder.
const BASIC_SCHEME_PREFIX = /^Basic +/i;
function basicToClientCredentials(authorization: string) {
export function basicToClientCredentials(authorization: string) {
if (!BASIC_SCHEME_PREFIX.test(authorization)) {
return undefined;
}