mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
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:
@@ -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", () => {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user