fix(oauth-provider)!: bind client authentication to the issuing grant (#10063)

This commit is contained in:
Gustavo Valverde
2026-06-14 20:46:40 -07:00
committed by GitHub
parent 2196ea65e7
commit 3ef3cfecfa
9 changed files with 164 additions and 94 deletions
@@ -4,7 +4,7 @@
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.
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. A client authentication strategy proves only which client id the caller controls. The authorization server resolves and authorizes the client record itself, bound to the grant being issued, so a strategy cannot influence a client's grants, scopes, or enabled state, and a handler cannot authorize one grant while minting another.
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.
+6 -8
View File
@@ -1417,10 +1417,9 @@ export const customOAuthExtension = () =>
extendOAuthProvider(ctx, {
grants: {
"urn:example:params:oauth:grant-type:custom": async ({
grantType,
provider,
}) => {
const { client } = await provider.authenticateClient({ grantType });
const { client } = await provider.authenticateClient();
return provider.issueTokens({
client,
scopes: ["openid"],
@@ -1450,11 +1449,11 @@ A claims contributor can add new claim names but never replaces an identity, aut
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.
To sender-constrain an issued token (RFC 7800 `cnf`), pass `confirmation` to `issueTokens`, or return it from a `clientAuthentication` strategy. 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:
A `clientAuthentication` strategy verifies the assertion against its own key source and returns the client id it proved; the provider resolves and authorizes the client record itself, so a strategy proves identity but never supplies the record. 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";
@@ -1468,10 +1467,9 @@ authenticate: async ({ ctx, opts, assertion, expectedAudience }) => {
payload,
expectedAudience: expectedAudience!,
});
return {
clientId: payload.sub as string,
client: await loadRegisteredClient(ctx, opts, payload.sub),
};
// Return only the proven client id (and an optional `confirmation`). The
// provider resolves and authorizes the client record itself.
return { clientId: payload.sub as string };
};
```
+107 -41
View File
@@ -16,7 +16,6 @@ import type {
SchemaClient,
Scope,
} from "./types";
import { validateClientCredentials } from "./utils";
import { consumeClientAssertion } from "./utils/client-assertion";
describe("oauth-provider extensions", async () => {
@@ -26,6 +25,7 @@ describe("oauth-provider extensions", async () => {
const extensionGrant = "urn:better-auth:test:grant";
const extensionOpaqueGrant = "urn:better-auth:test:opaque-grant";
const extensionOpaqueBoundGrant = "urn:better-auth:test:opaque-bound-grant";
const extensionDriftGrant = "urn:better-auth:test:drift-grant";
const extensionAuthMethod = "test_attestation_jwt";
const extensionAssertionType =
"urn:better-auth:test:client-assertion-type:test-attestation";
@@ -47,7 +47,7 @@ describe("oauth-provider extensions", async () => {
init(ctx) {
extendOAuthProvider(ctx, {
grants: {
[extensionGrant]: async ({ ctx, grantType, provider }) => {
[extensionGrant]: async ({ ctx, provider }) => {
observedCustomParam = (ctx.body as { custom_param?: string })
.custom_param;
if (!grantUser) {
@@ -58,7 +58,6 @@ describe("oauth-provider extensions", async () => {
}
const { client, confirmation } = await provider.authenticateClient({
scopes: ["openid", "email", "vc"],
grantType,
});
return provider.issueTokens({
client,
@@ -86,7 +85,7 @@ describe("oauth-provider extensions", async () => {
// Issues an opaque access token (no resource -> no audience), so
// introspection re-derives extension claims through the resolver
// instead of returning a signed JWT payload verbatim.
[extensionOpaqueGrant]: async ({ grantType, provider }) => {
[extensionOpaqueGrant]: async ({ provider }) => {
if (!grantUser) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
@@ -95,7 +94,6 @@ describe("oauth-provider extensions", async () => {
}
const { client } = await provider.authenticateClient({
scopes: ["openid", "email", "vc"],
grantType,
});
return provider.issueTokens({
client,
@@ -109,7 +107,7 @@ describe("oauth-provider extensions", async () => {
// Sender-constrains an opaque access token (no resource). The
// confirmation is persisted on the opaque-token row (RFC 7800 `cnf`)
// and surfaced at introspection, not silently dropped.
[extensionOpaqueBoundGrant]: async ({ grantType, provider }) => {
[extensionOpaqueBoundGrant]: async ({ provider }) => {
if (!grantUser) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
@@ -118,7 +116,6 @@ describe("oauth-provider extensions", async () => {
}
const { client } = await provider.authenticateClient({
scopes: ["openid", "email", "vc"],
grantType,
});
return provider.issueTokens({
client,
@@ -127,32 +124,42 @@ describe("oauth-provider extensions", async () => {
confirmation: { jkt: "opaque-bound-jkt" },
});
},
// A stale/buggy handler that still passes a permissive grantType the
// type no longer allows. The provider must ignore it and authorize
// against the dispatched grant.
[extensionDriftGrant]: async ({ provider }) => {
if (!grantUser) {
throw new APIError("BAD_REQUEST", {
error: "invalid_request",
error_description: "test user is not ready",
});
}
const staleRequest = {
scopes: ["openid", "email", "vc"],
grantType: extensionOpaqueGrant,
} as Parameters<typeof provider.authenticateClient>[0];
const { client } = await provider.authenticateClient(staleRequest);
return provider.issueTokens({
client,
scopes: ["openid", "email", "vc"],
user: grantUser,
});
},
},
clientAuthentication: {
[extensionAuthMethod]: {
assertionTypes: [extensionAssertionType],
authenticate: async ({ ctx, assertion, clientId }) => {
authenticate: async ({ assertion, clientId }) => {
if (!clientId || assertion !== `assertion:${clientId}`) {
throw new APIError("UNAUTHORIZED", {
error: "invalid_client",
error_description: "invalid test assertion",
});
}
const client = await ctx.context.adapter.findOne<
SchemaClient<Scope[]>
>({
model: "oauthClient",
where: [{ field: "clientId", value: clientId }],
});
if (!client) {
throw new APIError("BAD_REQUEST", {
error: "invalid_client",
error_description: "missing client",
});
}
// Prove the caller controls clientId; the provider resolves and
// authorizes the client record itself.
return {
clientId,
client,
// A sender-constraint the strategy proved (wallet
// attestation); the grant forwards it to issueTokens. An
// mTLS-style `x5t#S256` keeps the token bearer-presentable,
@@ -373,29 +380,56 @@ describe("oauth-provider extensions", async () => {
expect(response.error?.status).toBe(400);
});
it("rejects assertion auth for legacy clients with omitted auth method", async () => {
const legacyClient = {
clientId: "legacy-default-client",
public: false,
} as SchemaClient<Scope[]>;
await expect(
validateClientCredentials(
{} as Parameters<typeof validateClientCredentials>[0],
{} as Parameters<typeof validateClientCredentials>[1],
legacyClient.clientId,
undefined,
undefined,
legacyClient,
undefined,
extensionAuthMethod,
),
).rejects.toMatchObject({
statusCode: 400,
it("rejects assertion auth for clients with omitted (default) auth method", async () => {
// A client with no explicit auth method defaults to client_secret_basic, so
// it cannot authenticate through an extension assertion method.
const legacyClient = await auth.api.adminCreateOAuthClient({
headers,
body: {
error: "invalid_client",
error_description: `client registered for client_secret_basic cannot use ${extensionAuthMethod}`,
grant_types: [extensionGrant],
scope: "openid email vc",
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: extensionGrant,
client_id: legacyClient!.client_id,
client_assertion_type: extensionAssertionType,
client_assertion: `assertion:${legacyClient!.client_id}`,
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
},
});
expect(response.error?.status).toBe(400);
expect(
(response.error as { error_description?: string } | undefined)
?.error_description,
).toContain("client registered for client_secret_basic cannot use");
});
it("authorizes against the resolved record, not the strategy", async () => {
// The strategy proves a client id; the provider resolves the record. A proven
// id with no registered client is rejected, since a strategy cannot conjure a
// client the authorization server never registered.
const response = await client.$fetch("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: extensionGrant,
client_id: "unregistered-but-proven",
client_assertion_type: extensionAssertionType,
client_assertion: "assertion:unregistered-but-proven",
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
},
});
expect(response.error?.status).toBe(400);
expect((response.error as { error?: string } | undefined)?.error).toBe(
"invalid_client",
);
});
it("rejects invalid direct extension options during provider setup", () => {
@@ -548,6 +582,38 @@ describe("oauth-provider extensions", async () => {
expect(introspection.data?.extension_access_claim).toBe("extension-access");
});
it("ignores a runtime grantType argument and gates on the dispatched grant", async () => {
// The drift handler smuggles grantType: extensionOpaqueGrant into
// authenticateClient. This client is registered for the opaque grant but not
// the dispatched drift grant. Honoring the smuggled grant would authorize it;
// the bound grant must gate instead.
const oauthClient = await auth.api.adminCreateOAuthClient({
headers,
body: {
token_endpoint_auth_method: extensionAuthMethod,
grant_types: [extensionOpaqueGrant],
scope: "openid email vc",
type: "web",
},
});
const response = await client.$fetch("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: extensionDriftGrant,
client_id: oauthClient!.client_id,
client_assertion_type: extensionAssertionType,
client_assertion: `assertion:${oauthClient!.client_id}`,
}),
headers: {
"content-type": "application/x-www-form-urlencoded",
},
});
expect(response.error?.status).toBe(400);
expect((response.error as { error?: string } | undefined)?.error).toBe(
"unauthorized_client",
);
});
it("rejects unsupported extension grant types before credential handling", async () => {
const response = await client.$fetch("/oauth2/token", {
method: "POST",
+3 -3
View File
@@ -595,11 +595,11 @@ export async function introspectEndpoint(
const {
clientId: client_id,
clientSecret: client_secret,
preVerifiedClient,
preVerified,
authMethod,
} = destructureCredentials(credentials);
if (!client_id || (!client_secret && !preVerifiedClient)) {
if (!client_id || (!client_secret && !preVerified)) {
throw new APIError("UNAUTHORIZED", {
error_description: "missing required credentials",
error: "invalid_client",
@@ -624,7 +624,7 @@ export async function introspectEndpoint(
client_id,
client_secret,
undefined,
preVerifiedClient,
preVerified,
undefined,
authMethod,
);
+2 -2
View File
@@ -314,7 +314,7 @@ export async function revokeEndpoint(
const {
clientId: client_id,
clientSecret: client_secret,
preVerifiedClient,
preVerified,
authMethod,
} = destructureCredentials(credentials);
@@ -343,7 +343,7 @@ export async function revokeEndpoint(
client_id,
client_secret,
undefined,
preVerifiedClient,
preVerified,
undefined,
authMethod,
);
+14 -19
View File
@@ -139,13 +139,8 @@ export function getOAuthProviderApi(
// reject a valid assertion minted for the real endpoint.
`${ctx.context.baseURL}${ctx.path ?? "/oauth2/token"}`,
);
const {
clientId,
clientSecret,
preVerifiedClient,
authMethod,
confirmation,
} = destructureCredentials(credentials);
const { clientId, clientSecret, preVerified, authMethod, confirmation } =
destructureCredentials(credentials);
if (!clientId) {
throw new APIError("BAD_REQUEST", {
error_description: "Missing required client_id",
@@ -155,7 +150,7 @@ export function getOAuthProviderApi(
if (
request?.requireCredentials !== false &&
!clientSecret &&
!preVerifiedClient
!preVerified
) {
throw new APIError("BAD_REQUEST", {
error_description: "Missing required client credentials",
@@ -168,8 +163,8 @@ export function getOAuthProviderApi(
clientId,
clientSecret,
request?.scopes,
preVerifiedClient,
request?.grantType ?? grantType,
preVerified,
grantType,
authMethod,
);
return {
@@ -1101,7 +1096,7 @@ async function handleAuthorizationCodeGrant(
const {
clientId: client_id,
clientSecret: client_secret,
preVerifiedClient,
preVerified,
authMethod,
} = destructureCredentials(credentials);
@@ -1140,7 +1135,7 @@ async function handleAuthorizationCodeGrant(
const isAuthCodeWithSecret = client_id && client_secret;
const isAuthCodeWithPkce = client_id && code && code_verifier;
if (!isAuthCodeWithSecret && !isAuthCodeWithPkce && !preVerifiedClient) {
if (!isAuthCodeWithSecret && !isAuthCodeWithPkce && !preVerified) {
throw new APIError("BAD_REQUEST", {
error_description: "Either code_verifier or client_secret is required",
error: "invalid_request",
@@ -1172,7 +1167,7 @@ async function handleAuthorizationCodeGrant(
client_id,
client_secret,
scopes,
preVerifiedClient,
preVerified,
"authorization_code",
authMethod,
);
@@ -1195,7 +1190,7 @@ async function handleAuthorizationCodeGrant(
}
} else {
// PKCE is optional - must have either PKCE, client_secret, or client_assertion
if (!(isAuthCodeWithPkce || isAuthCodeWithSecret || preVerifiedClient)) {
if (!(isAuthCodeWithPkce || isAuthCodeWithSecret || preVerified)) {
throw new APIError("BAD_REQUEST", {
error_description:
"Either PKCE (code_verifier) or client authentication (client_secret or client_assertion) is required",
@@ -1315,7 +1310,7 @@ async function handleClientCredentialsGrant(
const {
clientId: client_id,
clientSecret: client_secret,
preVerifiedClient,
preVerified,
authMethod,
} = destructureCredentials(credentials);
const { scope, resource }: { scope?: string; resource?: string | string[] } =
@@ -1328,7 +1323,7 @@ async function handleClientCredentialsGrant(
error: "invalid_grant",
});
}
if (!client_secret && !preVerifiedClient) {
if (!client_secret && !preVerified) {
throw new APIError("BAD_REQUEST", {
error_description: "Missing a required client_secret",
error: "invalid_grant",
@@ -1342,7 +1337,7 @@ async function handleClientCredentialsGrant(
client_id,
client_secret,
undefined,
preVerifiedClient,
preVerified,
"client_credentials",
authMethod,
);
@@ -1402,7 +1397,7 @@ async function handleRefreshTokenGrant(
const {
clientId: client_id,
clientSecret: client_secret,
preVerifiedClient,
preVerified,
authMethod,
} = destructureCredentials(credentials);
@@ -1510,7 +1505,7 @@ async function handleRefreshTokenGrant(
client_id,
client_secret, // Optional for refresh_grant but required on confidential clients
requestedScopes ?? scopes,
preVerifiedClient,
preVerified,
"refresh_token",
authMethod,
);
+18 -6
View File
@@ -110,10 +110,6 @@ 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.
*
@@ -252,6 +248,18 @@ export interface OAuthClientAuthenticationInput {
expectedAudience?: string;
}
export interface OAuthClientAuthenticationResult {
/** The client id the assertion proved the caller controls. */
clientId: string;
/**
* A sender-constraint the strategy proved (for example a wallet-instance key
* thumbprint). The provider stamps it as the issued token's RFC 7800 `cnf`.
* Set it only after proving possession; the authorization server writes it as
* token material and does not verify it again.
*/
confirmation?: Confirmation;
}
export interface OAuthClientAuthenticationStrategy {
/**
* Assertion type URIs this strategy consumes from `client_assertion_type`.
@@ -261,7 +269,11 @@ export interface OAuthClientAuthenticationStrategy {
*/
assertionTypes?: string[];
/**
* Verifies the presented assertion and returns the authenticated client.
* Verifies the presented assertion and returns the proven client id (plus any
* sender-constraint it established). The strategy proves the caller controls
* `clientId`; it does not supply the authorization record. The provider
* resolves and authorizes the client itself, so a strategy cannot influence
* the client's grants, scopes, or enabled state.
*
* The strategy owns the full RFC 7521/7523 verification. After verifying the
* signature against its own key source, it MUST enforce the assertion-hygiene
@@ -278,7 +290,7 @@ export interface OAuthClientAuthenticationStrategy {
*/
authenticate: (
input: OAuthClientAuthenticationInput,
) => Awaitable<OAuthAuthenticatedClient>;
) => Awaitable<OAuthClientAuthenticationResult>;
}
export interface OAuthMetadataExtensionInput {
@@ -311,7 +311,7 @@ export async function verifyClientAssertion(
clientAssertionType: string,
clientIdHint?: string,
expectedAudience?: string,
): Promise<{ clientId: string; client: SchemaClient<Scope[]> }> {
): Promise<{ clientId: string }> {
if (clientAssertionType !== CLIENT_ASSERTION_TYPE) {
throw new APIError("BAD_REQUEST", {
error_description: "unsupported client_assertion_type",
@@ -453,5 +453,5 @@ export async function verifyClientAssertion(
expectedAudience: audience,
});
return { clientId, client };
return { clientId };
}
+11 -12
View File
@@ -522,8 +522,11 @@ export function clientAllowsGrant(
}
/**
* Validates client credentials failing on mismatches
* and incorrectly provided information
* Resolves the registered client by id and authorizes it: existence, disabled
* state, registered auth method, requested scopes, and grant type. The record is
* always resolved here via `getClient`, so a client-auth strategy proves the
* caller controls `clientId` but never supplies the record. `preVerified` marks
* that an assertion already proved control, so the client-secret check is skipped.
*
* @internal
*/
@@ -533,11 +536,11 @@ export async function validateClientCredentials(
clientId: string,
clientSecret?: string, // optional because required if client is confidential or this value is defined
scopes?: string[], // checks requested scopes against allowed scopes
preVerifiedClient?: SchemaClient<Scope[]>,
preVerified?: boolean, // an assertion already proved control of clientId; skip the secret check
grantType?: GrantType, // if set, enforces the client is registered for this grant type
authMethod?: TokenEndpointAuthMethod,
) {
const client = preVerifiedClient ?? (await getClient(ctx, options, clientId));
const client = await getClient(ctx, options, clientId);
if (!client) {
throw new APIError("BAD_REQUEST", {
error_description: "missing client",
@@ -552,7 +555,7 @@ export async function validateClientCredentials(
}
// Enforce registered auth method for assertion/pre-verified methods.
if (preVerifiedClient && authMethod) {
if (preVerified && authMethod) {
const registeredAuthMethod =
client.tokenEndpointAuthMethod ?? "client_secret_basic";
if (registeredAuthMethod !== authMethod) {
@@ -568,7 +571,7 @@ export async function validateClientCredentials(
options,
client.tokenEndpointAuthMethod,
)) &&
!preVerifiedClient
!preVerified
) {
throw new APIError("BAD_REQUEST", {
error_description: `client registered for ${client.tokenEndpointAuthMethod} must use client_assertion`,
@@ -577,7 +580,7 @@ export async function validateClientCredentials(
}
// Skip secret checks for pre-verified clients (already authenticated via assertion)
if (!preVerifiedClient) {
if (!preVerified) {
// Require secret for confidential clients
if (!client.public && !clientSecret) {
throw new APIError("BAD_REQUEST", {
@@ -662,7 +665,6 @@ export type ExtractedCredentials =
kind: "pre_verified";
method: TokenEndpointAuthMethod;
clientId: string;
client: SchemaClient<Scope[]>;
/** Sender-constraint the auth strategy proved, forwarded to issuance. */
confirmation?: Confirmation;
}
@@ -682,8 +684,7 @@ export function destructureCredentials(
credentials?.kind === "client_secret"
? credentials.clientSecret
: undefined,
preVerifiedClient:
credentials?.kind === "pre_verified" ? credentials.client : undefined,
preVerified: credentials?.kind === "pre_verified",
authMethod: credentials?.method,
confirmation:
credentials?.kind === "pre_verified"
@@ -742,7 +743,6 @@ export async function extractClientCredentials(
kind: "pre_verified",
method: extensionStrategy.method,
clientId: result.clientId,
client: result.client,
confirmation: result.confirmation,
};
}
@@ -761,7 +761,6 @@ export async function extractClientCredentials(
kind: "pre_verified",
method: "private_key_jwt",
clientId: result.clientId,
client: result.client,
};
}