diff --git a/.cspell/auth-terms.txt b/.cspell/auth-terms.txt index b090e3954f..7f84870e61 100644 --- a/.cspell/auth-terms.txt +++ b/.cspell/auth-terms.txt @@ -32,3 +32,4 @@ Timestampz Vercel rgba idtoken +eidas diff --git a/docs/content/docs/concepts/plugins.mdx b/docs/content/docs/concepts/plugins.mdx index bec6db6265..a639137c55 100644 --- a/docs/content/docs/concepts/plugins.mdx +++ b/docs/content/docs/concepts/plugins.mdx @@ -638,4 +638,4 @@ init(ctx) { } ``` -See the Plugins documentation for more details on creating plugins. +See the Extending Plugins guide for the full reference. diff --git a/docs/content/docs/guides/extending-plugins.mdx b/docs/content/docs/guides/extending-plugins.mdx new file mode 100644 index 0000000000..87c0b90398 --- /dev/null +++ b/docs/content/docs/guides/extending-plugins.mdx @@ -0,0 +1,260 @@ +--- +title: Extending Plugins +description: Create plugins that extend oauth-provider with custom grant types, metadata, and token claims. +--- + +Some plugins act as **platforms**, providing shared infrastructure that other plugins build on. The `oauth-provider` plugin is the primary example: it provides an OAuth 2.1 authorization server that other plugins can extend with custom grant types, discovery metadata, and token claims. + +The **Plugin Extension Protocol** enables this composition through a typed, declarative contract. Your plugin declares what it contributes, and the host plugin collects and integrates those contributions automatically. + +## Quick Start + +A complete plugin that adds a custom OAuth grant type: + +```ts +import type { BetterAuthPlugin } from "@better-auth/core"; +import type { OAuthProviderExtension } from "@better-auth/oauth-provider"; +import { + type GrantTypeHandler, + createUserTokens, + validateClientCredentials, + basicToClientCredentials, +} from "@better-auth/oauth-provider"; + +const GRANT = "urn:ietf:params:oauth:grant-type:token-exchange"; + +const handler: GrantTypeHandler = async (ctx, opts) => { + const authorization = ctx.request?.headers.get("authorization") ?? ""; + const basic = basicToClientCredentials(authorization); + const client = await validateClientCredentials( + ctx, opts, + ctx.body.client_id ?? basic?.client_id ?? "", + ctx.body.client_secret ?? basic?.client_secret, + ); + // Your plugin-specific logic to resolve the subject token + const user = await resolveSubjectToken(ctx, ctx.body.subject_token); + return createUserTokens(ctx, opts, client, ["openid"], user); +}; + +export function tokenExchange(): BetterAuthPlugin { + return { + id: "token-exchange", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + grantTypes: { [GRANT]: handler }, + grantTypeURIs: [GRANT], + } satisfies OAuthProviderExtension, + }, + }; +} +``` + +The user installs it by adding it to their plugins array, with no manual wiring: + +```ts +import { betterAuth } from "better-auth"; +import { oauthProvider } from "@better-auth/oauth-provider"; +import { tokenExchange } from "./my-token-exchange-plugin"; + +const auth = betterAuth({ + plugins: [ + oauthProvider({ loginPage: "/login" }), + tokenExchange(), + ], +}); +``` + +## How It Works + +### Host and Guest + +A **host plugin** (like `oauth-provider`) defines an extension contract, a TypeScript interface describing what contributions it accepts. A **guest plugin** (like your custom grant type) declares contributions via the `extensions` field on `BetterAuthPlugin`. + +At startup, the host calls `ctx.getExtensions("oauth-provider")` to collect all contributions from guest plugins and integrates them into its runtime. + +### The `extensions` Field + +The `extensions` field is a record keyed by host plugin ID. TypeScript provides autocomplete for registered hosts and type-checking for the contract: + +```ts +{ + id: "my-plugin", + extensions: { + "oauth-provider": { + // TypeScript knows the shape of OAuthProviderExtension + grantTypes: { ... }, + metadata: ({ baseURL }) => ({ ... }), + } satisfies OAuthProviderExtension, + }, +} +``` + +### The `dependencies` Field + +Declare which plugins your plugin requires. The framework validates at startup and gives a clear error if any dependency is missing: + +```ts +{ + id: "my-plugin", + dependencies: ["oauth-provider"], +} +``` + +If the user installs your plugin without `oauth-provider`: + +``` +BetterAuthError: Plugin "my-plugin" requires plugin "oauth-provider" +which is not installed. Add it to your plugins array. +``` + +## OAuthProviderExtension Reference + +The `oauth-provider` plugin accepts five extension points: + +### `grantTypes` + +Register custom OAuth grant type handlers on the token endpoint. + +```ts +grantTypes: { + "urn:custom:my-grant": async (ctx, opts) => { + // Validate client, resolve user, issue tokens + return ctx.json({ + access_token: "...", + token_type: "Bearer", + expires_in: 3600, + scope: "openid", + }); + }, +} +``` + +The handler receives the same `(ctx, opts)` as built-in grant handlers. Use the exported utilities (`createUserTokens`, `validateClientCredentials`) to implement standard flows. + +The token body schema uses `.passthrough()`, so your grant type can accept arbitrary body fields beyond the standard ones. + +### `grantTypeURIs` + +Grant type URIs to add to the server's allowlist and advertise in `grant_types_supported` metadata. + +```ts +grantTypeURIs: ["urn:custom:my-grant"], +``` + +If your grant type URI is not in this list, the token endpoint rejects it before reaching your handler. + +### `metadata` + +Contribute fields to OAuth/OIDC discovery metadata (`/.well-known/openid-configuration`). + +```ts +metadata: ({ baseURL }) => ({ + backchannel_authentication_endpoint: `${baseURL}/oauth2/bc-authorize`, + custom_capability: true, +}), +``` + +Use this for non-array fields (endpoint URLs, capability flags). Array fields like `grant_types_supported` are handled via `grantTypeURIs` and `tokenEndpointAuthMethods`, which the host merges additively. + +### `tokenClaims` + +Contribute claims to access tokens and ID tokens at creation time, before signing. + +```ts +tokenClaims: { + access: async (info) => ({ + custom_role: info.user.role, + }), + id: async (info) => ({ + verified_claims: { trust_framework: "eidas" }, + }), +}, +``` + +The `info` parameter provides `{ user, scopes, client, referenceId }`. + +Extension claims are applied **before** the user's `customAccessTokenClaims` / `customIdTokenClaims`. If both set the same key, the user's value wins. + +### `tokenEndpointAuthMethods` + +Additional token endpoint authentication methods to advertise in `token_endpoint_auth_methods_supported`. + +```ts +tokenEndpointAuthMethods: ["private_key_jwt"], +``` + +## Exported Utilities + +The `@better-auth/oauth-provider` package exports these utilities for grant handler authors: + +| Utility | Purpose | +|---------|---------| +| `createUserTokens` | Issue access token, refresh token, and ID token for a user | +| `validateClientCredentials` | Authenticate the OAuth client from the request | +| `basicToClientCredentials` | Decode a `Basic` Authorization header into client_id and client_secret | +| `getClient` | Look up an OAuth client by client_id | +| `checkResource` | Validate the `resource` parameter and resolve the audience | +| `storeToken` | Hash a token for secure database storage | +| `getStoredToken` | Hash an incoming token for database lookup | + +Types: `GrantTypeHandler`, `OAuthProviderExtension`, `TokenClaimInfo`. + +## Creating Your Own Platform Plugin + +Any plugin can become a host for extensions. Define an extension contract, augment the registry, and call `getExtensions` in your `init`: + +```ts +// 1. Define the contract +export interface MyPlatformExtension { + customFeature?: (ctx: { baseURL: string }) => Record; +} + +// 2. Augment the registry +declare module "@better-auth/core" { + interface BetterAuthExtensionRegistry { + "my-platform": MyPlatformExtension; + } +} + +// 3. Collect in init +export function myPlatform() { + return { + id: "my-platform", + init(ctx) { + const extensions = ctx.getExtensions("my-platform"); + const features = extensions + .map((e) => e.customFeature) + .filter(Boolean); + return { context: { myPlatformFeatures: features } }; + }, + } satisfies BetterAuthPlugin; +} +``` + +Guest plugins then contribute via their `extensions` field: + +```ts +{ + id: "my-guest", + dependencies: ["my-platform"], + extensions: { + "my-platform": { + customFeature: ({ baseURL }) => ({ endpoint: `${baseURL}/custom` }), + } satisfies MyPlatformExtension, + }, +} +``` + +## Migrating from Hooks to Extensions + +If you have an existing plugin that uses hooks or init mutations for cross-plugin communication, consider migrating to extensions: + +| Legacy pattern | Preferred approach | +|---------------|-------------------| +| `hooks.after` matching `/.well-known/` paths to add metadata | `extensions["oauth-provider"].metadata` callback | +| `init` returning `{ context: { customKey } }` for injecting handlers | `extensions["oauth-provider"].grantTypes` | +| `getPlugin("oauth-provider").options.grantTypes.push(...)` | `extensions["oauth-provider"].grantTypeURIs` | +| After-hook that decodes and re-signs JWTs to add claims | `extensions["oauth-provider"].tokenClaims` | + +The legacy patterns still work and existing plugins will not break. Extensions are preferred because they are typed, discoverable via IDE autocomplete, and aggregated safely by the host without risk of one plugin clobbering another's contributions. diff --git a/docs/content/docs/plugins/index.mdx b/docs/content/docs/plugins/index.mdx index 0fb932603c..e392d18307 100644 --- a/docs/content/docs/plugins/index.mdx +++ b/docs/content/docs/plugins/index.mdx @@ -78,6 +78,13 @@ Better Auth ships with 50+ plugins that extend the framework with additional aut | --- | --- | | [Dub](/docs/plugins/dub) | Lead tracking using Dub links and OAuth linking | +## Building Plugins + +| Guide | Description | +| --- | --- | +| [Your First Plugin](/docs/guides/your-first-plugin) | Create a basic plugin with custom fields and endpoints | +| [Extending Plugins](/docs/guides/extending-plugins) | Build plugins that extend oauth-provider with custom grant types, metadata, and token claims | + ## Community Plugins Looking for more? Check out [community plugins](/docs/plugins/community-plugins) built by the Better Auth community. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 44cd491d98..920d5527ef 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -2114,3 +2114,22 @@ The MCP endpoints moved from `/mcp` to the `/oauth2` equivalent. - `/mcp/get-session` removed as not OAuth 2 compliant, use `/oauth2/introspect` instead - `/.well-known/oauth-protected-resource` removed, use the helper `mcpHandler` (or manually with the server `api.oAuth2introspectVerify` or the resource client `verifyAccessToken`) - Database changes are equivalent to the [From OIDC Provider Plugin](#from-oidc-provider-plugin) section. + +## Extending OAuth Provider + +The oauth-provider supports a **Plugin Extension Protocol** that enables other plugins to contribute custom grant types, discovery metadata, token claims, and authentication methods — without modifying the oauth-provider source code. + +See the Extending Plugins guide for the full reference, including code examples and the `OAuthProviderExtension` contract. + +### Exported Utilities + +The following functions are exported for use in custom grant type handlers: + +- `createUserTokens` — Issue access token, refresh token, and ID token +- `validateClientCredentials` — Authenticate the OAuth client +- `basicToClientCredentials` — Decode a Basic Authorization header +- `getClient` — Look up an OAuth client by client_id +- `checkResource` — Validate the resource parameter +- `storeToken` / `getStoredToken` — Hash tokens for database storage/lookup + +Types: `GrantTypeHandler`, `OAuthProviderExtension`, `TokenClaimInfo`. diff --git a/packages/oauth-provider/src/index.ts b/packages/oauth-provider/src/index.ts index b38f50400f..3098475561 100644 --- a/packages/oauth-provider/src/index.ts +++ b/packages/oauth-provider/src/index.ts @@ -6,4 +6,17 @@ export { oidcServerMetadata, } from "./metadata"; export { getOAuthProviderState, oauthProvider } from "./oauth"; +export { checkResource, createUserTokens } from "./token"; export type * from "./types"; +export type { + GrantTypeHandler, + OAuthProviderExtension, + TokenClaimInfo, +} from "./types/extension"; +export { + basicToClientCredentials, + getClient, + getStoredToken, + storeToken, + validateClientCredentials, +} from "./utils"; diff --git a/packages/oauth-provider/src/oauth.ts b/packages/oauth-provider/src/oauth.ts index bb38542539..d4be8959e0 100644 --- a/packages/oauth-provider/src/oauth.ts +++ b/packages/oauth-provider/src/oauth.ts @@ -39,6 +39,9 @@ declare module "@better-auth/core" { creator: typeof oauthProvider; }; } + interface BetterAuthExtensionRegistry { + "oauth-provider": import("./types/extension").OAuthProviderExtension; + } } export const oAuthState = defineRequestState<{ query?: string } | null>( @@ -162,6 +165,44 @@ export const oauthProvider = >(options: O) => { ); } + function mergeExtensionMetadata( + ctx: import("@better-auth/core").GenericEndpointContext, + metadata: import("./types/oauth").AuthServerMetadata, + ): Record { + const contributors = + ((ctx.context as Record).oauthMetadataContributors as + | NonNullable< + import("./types/extension").OAuthProviderExtension["metadata"] + >[] + | undefined) ?? []; + const extraAuthMethods = + ((ctx.context as Record).oauthExtraAuthMethods as + | string[] + | undefined) ?? []; + + if (contributors.length === 0 && extraAuthMethods.length === 0) { + return { ...metadata }; + } + + const extensionFields = Object.assign( + {}, + ...contributors.map((fn) => fn({ baseURL: ctx.context.baseURL })), + ); + + const merged = { ...metadata, ...extensionFields }; + + if (extraAuthMethods.length > 0) { + const existing = + (merged.token_endpoint_auth_methods_supported as string[]) ?? []; + merged.token_endpoint_auth_methods_supported = [ + ...existing, + ...extraAuthMethods, + ]; + } + + return merged; + } + return { id: "oauth-provider", options: opts as NoInfer, @@ -201,6 +242,61 @@ export const oauthProvider = >(options: O) => { ); } } + + // Aggregate extension contributions + const extensions = ctx.getExtensions("oauth-provider"); + + const oauthGrantHandlers: Record< + string, + import("./types/extension").GrantTypeHandler + > = {}; + const extensionGrantURIs: string[] = []; + const oauthMetadataContributors: NonNullable< + import("./types/extension").OAuthProviderExtension["metadata"] + >[] = []; + const oauthClaimContributors: NonNullable< + import("./types/extension").OAuthProviderExtension["tokenClaims"] + >[] = []; + const oauthExtraAuthMethods: string[] = []; + + for (const ext of extensions) { + if (ext.grantTypes) { + Object.assign(oauthGrantHandlers, ext.grantTypes); + } + if (ext.grantTypeURIs) { + extensionGrantURIs.push(...ext.grantTypeURIs); + } + if (ext.metadata) { + oauthMetadataContributors.push(ext.metadata); + } + if (ext.tokenClaims) { + oauthClaimContributors.push(ext.tokenClaims); + } + if (ext.tokenEndpointAuthMethods) { + oauthExtraAuthMethods.push(...ext.tokenEndpointAuthMethods); + } + } + + // Merge extension grant URIs into the allowlist + if (extensionGrantURIs.length > 0) { + opts.grantTypes = [ + ...(opts.grantTypes ?? [ + "authorization_code", + "client_credentials", + "refresh_token", + ]), + ...extensionGrantURIs, + ]; + } + + return { + context: { + oauthGrantHandlers, + oauthMetadataContributors, + oauthClaimContributors, + oauthExtraAuthMethods, + }, + }; }, hooks: { before: [ @@ -292,14 +388,14 @@ export const oauthProvider = >(options: O) => { }, }, async (ctx) => { + let metadata: import("./types/oauth").AuthServerMetadata; if (opts.scopes && opts.scopes.includes("openid")) { - const metadata = oidcServerMetadata(ctx, opts); - return metadata; + metadata = oidcServerMetadata(ctx, opts); } else { const jwtPluginOptions = opts.disableJwtPlugin ? undefined : getJwtPlugin(ctx.context)?.options; - const authMetadata = authServerMetadata(ctx, jwtPluginOptions, { + metadata = authServerMetadata(ctx, jwtPluginOptions, { scopes_supported: opts.advertisedMetadata?.scopes_supported ?? opts.scopes, public_client_supported: @@ -307,8 +403,8 @@ export const oauthProvider = >(options: O) => { grant_types_supported: opts.grantTypes, jwt_disabled: opts.disableJwtPlugin, }); - return authMetadata; } + return mergeExtensionMetadata(ctx, metadata); }, ), /** @@ -331,7 +427,7 @@ export const oauthProvider = >(options: O) => { throw new APIError("NOT_FOUND"); } const metadata = oidcServerMetadata(ctx, opts); - return metadata; + return mergeExtensionMetadata(ctx, metadata); }, ), oauth2Authorize: createAuthEndpoint( @@ -566,21 +662,19 @@ export const oauthProvider = >(options: O) => { "/oauth2/token", { method: "POST", - body: z.object({ - grant_type: z.enum([ - "authorization_code", - "client_credentials", - "refresh_token", - ]), - client_id: z.string().optional(), - client_secret: z.string().optional(), - code: z.string().optional(), - code_verifier: z.string().optional(), - redirect_uri: SafeUrlSchema.optional(), - refresh_token: z.string().optional(), - resource: z.string().optional(), - scope: z.string().optional(), - }), + body: z + .object({ + grant_type: z.string().trim().min(1), + client_id: z.string().optional(), + client_secret: z.string().optional(), + code: z.string().optional(), + code_verifier: z.string().optional(), + redirect_uri: SafeUrlSchema.optional(), + refresh_token: z.string().optional(), + resource: z.string().optional(), + scope: z.string().optional(), + }) + .passthrough(), metadata: { allowedMediaTypes: ["application/x-www-form-urlencoded"], openapi: { @@ -594,12 +688,8 @@ export const oauthProvider = >(options: O) => { properties: { grant_type: { type: "string", - enum: [ - "authorization_code", - "client_credentials", - "refresh_token", - ], - description: "OAuth2 grant type", + description: + "OAuth2 grant type (e.g., authorization_code, client_credentials, refresh_token, or a custom URI registered via extensions)", }, client_id: { type: "string", diff --git a/packages/oauth-provider/src/token.test.ts b/packages/oauth-provider/src/token.test.ts index a40eb31318..872b7e2a92 100644 --- a/packages/oauth-provider/src/token.test.ts +++ b/packages/oauth-provider/src/token.test.ts @@ -1807,3 +1807,395 @@ describe("oauth token - client secret validation", async () => { expect(responseStatus).toBe(500); }); }); + +describe("oauth token - custom grant types via extensions", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const CUSTOM_GRANT = "urn:test:custom-grant"; + const CUSTOM_GRANT_B = "urn:test:custom-grant-b"; + + const customGrantPlugin = { + id: "custom-grant-plugin", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + grantTypes: { + [CUSTOM_GRANT]: async (ctx) => { + return ctx.json({ + access_token: "custom-token-value", + token_type: "Bearer", + expires_in: 3600, + scope: "custom", + custom_field: ctx.body?.custom_param ?? "none", + }); + }, + }, + grantTypeURIs: [CUSTOM_GRANT], + }, + }, + } satisfies import("better-auth/types").BetterAuthPlugin; + + const secondGrantPlugin = { + id: "second-grant-plugin", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + grantTypes: { + [CUSTOM_GRANT_B]: async (ctx) => { + return ctx.json({ + access_token: "second-custom-token", + token_type: "Bearer", + expires_in: 1800, + scope: "second", + }); + }, + }, + grantTypeURIs: [CUSTOM_GRANT_B], + }, + }, + } satisfies import("better-auth/types").BetterAuthPlugin; + + const { auth, signInWithTestUser } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ + jwt: { + issuer: authServerBaseUrl, + }, + }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + customGrantPlugin, + secondGrantPlugin, + ], + }); + + const { headers } = await signInWithTestUser(); + let oauthClient: OAuthClient | null; + + beforeAll(async () => { + const response = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: ["http://localhost:5000/callback"], + skip_consent: true, + }, + }); + oauthClient = response; + }); + + it("should dispatch to a custom grant handler", async () => { + const response = await auth.handler( + new Request(`${authServerBaseUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${btoa(`${oauthClient!.client_id}:${oauthClient!.client_secret}`)}`, + }, + body: new URLSearchParams({ + grant_type: CUSTOM_GRANT, + }).toString(), + }), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.access_token).toBe("custom-token-value"); + expect(body.token_type).toBe("Bearer"); + expect(body.scope).toBe("custom"); + }); + + it("should pass through extra body fields", async () => { + const response = await auth.handler( + new Request(`${authServerBaseUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${btoa(`${oauthClient!.client_id}:${oauthClient!.client_secret}`)}`, + }, + body: new URLSearchParams({ + grant_type: CUSTOM_GRANT, + custom_param: "my-custom-value", + }).toString(), + }), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.custom_field).toBe("my-custom-value"); + }); + + it("should reject unknown grant types not in allowlist", async () => { + const response = await auth.handler( + new Request(`${authServerBaseUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${btoa(`${oauthClient!.client_id}:${oauthClient!.client_secret}`)}`, + }, + body: new URLSearchParams({ + grant_type: "urn:unknown:not-registered", + }).toString(), + }), + ); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("unsupported_grant_type"); + }); + + it("should support multiple plugins each registering different grant types", async () => { + const response = await auth.handler( + new Request(`${authServerBaseUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + authorization: `Basic ${btoa(`${oauthClient!.client_id}:${oauthClient!.client_secret}`)}`, + }, + body: new URLSearchParams({ + grant_type: CUSTOM_GRANT_B, + }).toString(), + }), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.access_token).toBe("second-custom-token"); + expect(body.scope).toBe("second"); + }); + + it("should include extension grantTypeURIs in discovery metadata", async () => { + const metadata = (await auth.api.getOAuthServerConfig()) as Record< + string, + unknown + >; + expect(metadata.grant_types_supported as string[]).toContain(CUSTOM_GRANT); + expect(metadata.grant_types_supported as string[]).toContain( + CUSTOM_GRANT_B, + ); + expect(metadata.grant_types_supported as string[]).toContain( + "authorization_code", + ); + }); +}); + +describe("oauth token - extension metadata contributions", async () => { + const authServerBaseUrl = "http://localhost:3000"; + + const metadataPlugin = { + id: "metadata-plugin", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + metadata: ({ baseURL }) => ({ + backchannel_authentication_endpoint: `${baseURL}/oauth2/bc-authorize`, + custom_flag: true, + }), + tokenEndpointAuthMethods: ["private_key_jwt"], + }, + }, + } satisfies import("better-auth/types").BetterAuthPlugin; + + const secondMetadataPlugin = { + id: "second-metadata-plugin", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + metadata: () => ({ + pushed_authorization_request_endpoint: "/par", + }), + }, + }, + } satisfies import("better-auth/types").BetterAuthPlugin; + + const { auth } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + }), + metadataPlugin, + secondMetadataPlugin, + ], + }); + + it("should include extension metadata in discovery response", async () => { + const metadata = (await auth.api.getOAuthServerConfig()) as Record< + string, + unknown + >; + expect(metadata.backchannel_authentication_endpoint).toContain( + "/oauth2/bc-authorize", + ); + expect(metadata.custom_flag).toBe(true); + }); + + it("should merge metadata from multiple plugins", async () => { + const metadata = (await auth.api.getOAuthServerConfig()) as Record< + string, + unknown + >; + expect(metadata.backchannel_authentication_endpoint).toBeDefined(); + expect(metadata.pushed_authorization_request_endpoint).toBe("/par"); + }); + + it("should merge extension tokenEndpointAuthMethods", async () => { + const metadata = (await auth.api.getOAuthServerConfig()) as Record< + string, + unknown + >; + expect(metadata.token_endpoint_auth_methods_supported).toContain( + "private_key_jwt", + ); + expect(metadata.token_endpoint_auth_methods_supported).toContain( + "client_secret_basic", + ); + }); +}); + +describe("oauth token - extension token claims", async () => { + const authServerBaseUrl = "http://localhost:3000"; + const rpBaseUrl = "http://localhost:5000"; + const validAudience = "https://myapi.example.com"; + + const claimsPlugin = { + id: "claims-plugin", + dependencies: ["oauth-provider"], + extensions: { + "oauth-provider": { + tokenClaims: { + access: async () => ({ + custom_access_claim: "from-extension", + overridable_claim: "extension-value", + }), + id: async () => ({ + verified_claims: { trust_framework: "eidas" }, + }), + }, + }, + }, + } satisfies import("better-auth/types").BetterAuthPlugin; + + const { auth, signInWithTestUser, customFetchImpl } = await getTestInstance({ + baseURL: authServerBaseUrl, + plugins: [ + jwt({ jwt: { issuer: authServerBaseUrl } }), + oauthProvider({ + loginPage: "/login", + consentPage: "/consent", + validAudiences: [validAudience], + silenceWarnings: { + oauthAuthServerConfig: true, + openidConfig: true, + }, + customAccessTokenClaims: async () => ({ + overridable_claim: "user-wins", + }), + }), + claimsPlugin, + ], + }); + + const { headers } = await signInWithTestUser(); + const _client = createAuthClient({ + plugins: [oauthProviderClient(), jwtClient()], + baseURL: authServerBaseUrl, + fetchOptions: { customFetchImpl, headers }, + }); + let oauthClient: OAuthClient | null; + + const providerId = "test-claims"; + const redirectUri = `${rpBaseUrl}/api/auth/oauth2/callback/${providerId}`; + + beforeAll(async () => { + oauthClient = await auth.api.adminCreateOAuthClient({ + headers, + body: { + redirect_uris: [redirectUri], + skip_consent: true, + }, + }); + }); + + async function getTokensViaAuthCode(scopes: string[], resource?: string) { + const codeVerifier = generateRandomString(32); + const url = await createAuthorizationURL({ + id: "test-claims", + options: { + clientId: oauthClient!.client_id, + clientSecret: oauthClient!.client_secret!, + redirectURI: redirectUri, + }, + redirectURI: "", + authorizationEndpoint: `${authServerBaseUrl}/api/auth/oauth2/authorize`, + state: "test-state", + scopes, + codeVerifier, + resource, + }); + + const authRes = await auth.handler( + new Request(url.toString(), { headers }), + ); + const location = authRes.headers.get("location")!; + const code = new URL(location).searchParams.get("code")!; + + const { body: tokenBody, headers: tokenHeaders } = + createAuthorizationCodeRequest({ + code, + codeVerifier, + redirectURI: redirectUri, + resource, + options: { + clientId: oauthClient!.client_id, + clientSecret: oauthClient!.client_secret!, + redirectURI: redirectUri, + }, + }); + + const tokenRes = await auth.handler( + new Request(`${authServerBaseUrl}/api/auth/oauth2/token`, { + method: "POST", + headers: tokenHeaders, + body: tokenBody, + }), + ); + expect(tokenRes.status).toBe(200); + return tokenRes.json(); + } + + it("should include extension claims in JWT access tokens", async () => { + const tokenBody = await getTokensViaAuthCode(["openid"], validAudience); + + const decoded = decodeJwt(tokenBody.access_token); + expect(decoded.custom_access_claim).toBe("from-extension"); + }); + + it("should let user customAccessTokenClaims override extension claims", async () => { + const tokenBody = await getTokensViaAuthCode(["openid"], validAudience); + + const decoded = decodeJwt(tokenBody.access_token); + expect(decoded.overridable_claim).toBe("user-wins"); + }); + + it("should include extension claims in ID tokens", async () => { + const tokenBody = await getTokensViaAuthCode(["openid"]); + + expect(tokenBody.id_token).toBeDefined(); + const decoded = decodeJwt(tokenBody.id_token); + expect(decoded.verified_claims).toEqual({ + trust_framework: "eidas", + }); + }); +}); diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index 7d3d493edc..d240f81b8a 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -56,11 +56,24 @@ export async function tokenEndpoint( error_description: "missing required grant_type", error: "unsupported_grant_type", }); - default: + default: { + const handlers = (ctx.context as Record) + .oauthGrantHandlers as + | Record + | undefined; + if ( + handlers != null && + typeof handlers === "object" && + Object.hasOwn(handlers, grantType) && + typeof handlers[grantType] === "function" + ) { + return handlers[grantType](ctx, opts); + } throw new APIError("BAD_REQUEST", { error_description: `unsupported grant_type ${grantType}`, error: "unsupported_grant_type", }); + } } } @@ -79,18 +92,41 @@ async function createJwtAccessToken( exp?: number; sid?: string; }, + extraClaims?: Record, ) { const iat = overrides?.iat ?? Math.floor(Date.now() / 1000); const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600); - const customClaims = opts.customAccessTokenClaims - ? await opts.customAccessTokenClaims({ + // Extension claims first, user claims last (user wins) + const claimContributors = + ((ctx.context as Record).oauthClaimContributors as + | NonNullable< + import("./types/extension").OAuthProviderExtension["tokenClaims"] + >[] + | undefined) ?? []; + const claimInfo: import("./types/extension").TokenClaimInfo = { + user, + scopes, + client, + referenceId, + }; + const customClaims: Record = {}; + for (const contributor of claimContributors) { + if (contributor.access) { + Object.assign(customClaims, await contributor.access(claimInfo)); + } + } + if (opts.customAccessTokenClaims) { + Object.assign( + customClaims, + await opts.customAccessTokenClaims({ user, scopes, resource: ctx.body.resource, referenceId, metadata: parseClientMetadata(client.metadata), - }) - : {}; + }), + ); + } const jwtPluginOptions = getJwtPlugin(ctx.context).options; @@ -99,6 +135,7 @@ async function createJwtAccessToken( options: jwtPluginOptions, payload: { ...customClaims, + ...extraClaims, sub: user.id, aud: typeof audience === "string" @@ -141,13 +178,34 @@ async function createIdToken( // - silver : mfa const acr = "urn:mace:incommon:iap:bronze"; - const customClaims = opts.customIdTokenClaims - ? await opts.customIdTokenClaims({ + // Extension claims first, user claims last (user wins) + const idClaimContributors = + ((ctx.context as Record).oauthClaimContributors as + | NonNullable< + import("./types/extension").OAuthProviderExtension["tokenClaims"] + >[] + | undefined) ?? []; + const idClaimInfo: import("./types/extension").TokenClaimInfo = { + user, + scopes, + client, + }; + const customClaims: Record = {}; + for (const contributor of idClaimContributors) { + if (contributor.id) { + Object.assign(customClaims, await contributor.id(idClaimInfo)); + } + } + if (opts.customIdTokenClaims) { + Object.assign( + customClaims, + await opts.customIdTokenClaims({ user, scopes, metadata: parseClientMetadata(client.metadata), - }) - : {}; + }), + ); + } const jwtPluginOptions = opts.disableJwtPlugin ? undefined @@ -322,7 +380,7 @@ async function createRefreshToken( * Checks the resource parameter, if provided, * and returns a valid audience based on the request */ -async function checkResource( +export async function checkResource( ctx: GenericEndpointContext, opts: OAuthOptions, scopes: string[], @@ -362,7 +420,7 @@ async function checkResource( return audience?.length === 1 ? audience.at(0) : audience; } -async function createUserTokens( +export async function createUserTokens( ctx: GenericEndpointContext, opts: OAuthOptions, client: SchemaClient, @@ -375,6 +433,10 @@ async function createUserTokens( refreshToken?: OAuthRefreshToken & { id: string }; }, authTime?: Date, + extra?: { + accessTokenClaims?: Record; + tokenResponse?: Record; + }, ) { const iat = Math.floor(Date.now() / 1000); const defaultExp = iat + (opts.accessTokenExpiresIn ?? 3600); @@ -434,6 +496,7 @@ async function createUserTokens( exp, sid: sessionId, }, + extra?.accessTokenClaims, ) : createOpaqueAccessToken( ctx, @@ -484,6 +547,7 @@ async function createUserTokens( return ctx.json( { + ...extra?.tokenResponse, access_token: accessToken, expires_in: exp - iat, expires_at: exp, diff --git a/packages/oauth-provider/src/types/extension.ts b/packages/oauth-provider/src/types/extension.ts new file mode 100644 index 0000000000..a6a1ea4c84 --- /dev/null +++ b/packages/oauth-provider/src/types/extension.ts @@ -0,0 +1,77 @@ +import type { Awaitable, GenericEndpointContext } from "@better-auth/core"; +import type { User } from "better-auth/types"; +import type { OAuthOptions, SchemaClient, Scope } from "."; + +/** + * Handler for a custom grant type registered via the extension protocol. + */ +export type GrantTypeHandler = ( + ctx: GenericEndpointContext, + opts: OAuthOptions, +) => Promise; + +/** + * Context provided to token claim contributors. + */ +export type TokenClaimInfo = { + user: User & Record; + scopes: string[]; + client: SchemaClient; + referenceId?: string; +}; + +/** + * Extension contract for plugins that extend oauth-provider. + * + * Plugins contribute to this contract via the `extensions` field: + * ```ts + * { + * id: "my-plugin", + * extensions: { + * "oauth-provider": { + * grantTypes: { "urn:custom:grant": myHandler }, + * grantTypeURIs: ["urn:custom:grant"], + * } satisfies OAuthProviderExtension, + * }, + * } + * ``` + */ +export interface OAuthProviderExtension { + /** + * Custom grant type handlers. + * Key is the full grant_type URI string. + */ + grantTypes?: Record; + + /** + * Grant type URIs to add to the allowlist and advertise + * in grant_types_supported metadata. + */ + grantTypeURIs?: string[]; + + /** + * Contribute fields to OAuth/OIDC discovery metadata. + * Called at request time in the discovery endpoint handlers. + * + * Use this for non-array fields (endpoint URLs, capability flags). + * Array fields like grant_types_supported are handled via + * grantTypeURIs and tokenEndpointAuthMethods. + */ + metadata?: (ctx: { baseURL: string }) => Record; + + /** + * Contribute claims to tokens at creation time. + * Called per-request during token issuance, BEFORE the user's + * customAccessTokenClaims / customIdTokenClaims. + */ + tokenClaims?: { + access?: (info: TokenClaimInfo) => Awaitable>; + id?: (info: TokenClaimInfo) => Awaitable>; + }; + + /** + * Additional token endpoint authentication methods to advertise + * in token_endpoint_auth_methods_supported. + */ + tokenEndpointAuthMethods?: string[]; +} diff --git a/packages/oauth-provider/src/types/oauth.ts b/packages/oauth-provider/src/types/oauth.ts index 7de56189db..ed686e3a65 100644 --- a/packages/oauth-provider/src/types/oauth.ts +++ b/packages/oauth-provider/src/types/oauth.ts @@ -6,13 +6,9 @@ import type { Prompt } from "."; */ export type GrantType = | "authorization_code" - // | "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" + | (string & {}); export type AuthMethod = | "client_secret_basic" // Basic header