diff --git a/.changeset/plugin-contribution-protocol.md b/.changeset/plugin-contribution-protocol.md index 4036d4f0f0..2f00ce29e1 100644 --- a/.changeset/plugin-contribution-protocol.md +++ b/.changeset/plugin-contribution-protocol.md @@ -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. diff --git a/packages/oauth-provider/src/contributions.test.ts b/packages/oauth-provider/src/contributions.test.ts index 040f286bf3..ed4207bf01 100644 --- a/packages/oauth-provider/src/contributions.test.ts +++ b/packages/oauth-provider/src/contributions.test.ts @@ -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", () => { diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index 36b58e3328..3a056e0ad9 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -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"; diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index 3953288a10..323812635b 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -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 = >(options: O) => { const claimContributors: NonNullable< OAuthContributions["tokenClaims"] >[] = []; + const clientAuthStrategies: Record = {}; for (const contribution of contributions) { if (contribution.grantTypes) { for (const [uri, handler] of Object.entries( @@ -327,6 +332,18 @@ export const oauthProvider = >(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 = >(options: O) => { metadataContributors, extraAuthMethods: [...extraAuthMethods], claimContributors, + clientAuthStrategies, }; // OAuth provider performs adapter-level session lookups by id, so it diff --git a/packages/oauth-provider/src/runtime.ts b/packages/oauth-provider/src/runtime.ts index f321917d95..c87fc6cf5a 100644 --- a/packages/oauth-provider/src/runtime.ts +++ b/packages/oauth-provider/src/runtime.ts @@ -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[]; + /** Client-authentication strategies keyed by `client_assertion_type`. */ + clientAuthStrategies: Record; } const RUNTIME_KEY = "oauthProviderRuntime"; diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index 471d48851f..a1a66d819f 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -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; }, ) { 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; 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; + tokenResponse?: Record; + }; } 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, diff --git a/packages/oauth-provider/src/types/contributions.ts b/packages/oauth-provider/src/types/contributions.ts index e21bed338e..e4836df411 100644 --- a/packages/oauth-provider/src/types/contributions.ts +++ b/packages/oauth-provider/src/types/contributions.ts @@ -39,6 +39,25 @@ export interface TokenClaimInfo { resources?: string[]; } +/** A client authenticated by a {@link ClientAuthStrategy}. */ +export interface ClientAuthResult { + clientId: string; + client: SchemaClient; +} + +/** + * 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, +) => Awaitable; + /** * 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>; id?: (info: TokenClaimInfo) => Awaitable>; }; + /** + * 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; } declare module "@better-auth/core" { diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index 14ee291657..e637666692 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -6,6 +6,8 @@ import type { Awaitable } from "./helpers"; import type { GrantType } from "./oauth"; export type { + ClientAuthResult, + ClientAuthStrategy, GrantHandler, OAuthContributions, TokenClaimInfo, diff --git a/packages/oauth-provider/src/utils/index.ts b/packages/oauth-provider/src/utils/index.ts index 288536b5e3..39108bb167 100644 --- a/packages/oauth-provider/src/utils/index.ts +++ b/packages/oauth-provider/src/utils/index.ts @@ -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; } @@ -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" );