feat(oauth-provider): extensible grant types, metadata, and token claims

Open the oauth-provider for plugin-based extension via the Plugin
Extension Protocol.

OAuthProviderExtension contract (5 extension points):
- grantTypes: register custom grant type handlers on the token endpoint
- grantTypeURIs: add to allowlist and grant_types_supported metadata
- metadata: contribute fields to discovery endpoints
- tokenClaims: inject claims into access tokens and ID tokens
- tokenEndpointAuthMethods: advertise additional auth methods

Token endpoint:
- Body schema: z.enum() → z.string().trim().min(1) + .passthrough()
- Switch default: dispatches to extension-contributed grant handlers
- GrantType union widened with (string & {}) for custom URIs

Exported utilities for grant handler authors:
- createUserTokens, checkResource, validateClientCredentials,
  basicToClientCredentials, getClient, storeToken, getStoredToken
- Types: GrantTypeHandler, OAuthProviderExtension, TokenClaimInfo

Docs:
- New guide: extending-plugins.mdx (full authoring reference)
- Updated oauth-provider docs with extension points section
- Updated plugin index with Building Plugins section
This commit is contained in:
Gustavo Valverde
2026-03-20 01:07:06 +00:00
parent 78bb334f0a
commit 9ceb4c0246
11 changed files with 963 additions and 44 deletions
+1
View File
@@ -32,3 +32,4 @@ Timestampz
Vercel
rgba
idtoken
eidas
+1 -1
View File
@@ -638,4 +638,4 @@ init(ctx) {
}
```
See the <Link href="/docs/concepts/plugins">Plugins</Link> documentation for more details on creating plugins.
See the <Link href="/docs/guides/extending-plugins">Extending Plugins</Link> guide for the full reference.
@@ -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<string, unknown>;
}
// 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.
+7
View File
@@ -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.
@@ -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 <Link href="/docs/guides/extending-plugins">Extending Plugins</Link> 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`.
+13
View File
@@ -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";
+116 -26
View File
@@ -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 = <O extends OAuthOptions<Scope[]>>(options: O) => {
);
}
function mergeExtensionMetadata(
ctx: import("@better-auth/core").GenericEndpointContext,
metadata: import("./types/oauth").AuthServerMetadata,
): Record<string, unknown> {
const contributors =
((ctx.context as Record<string, unknown>).oauthMetadataContributors as
| NonNullable<
import("./types/extension").OAuthProviderExtension["metadata"]
>[]
| undefined) ?? [];
const extraAuthMethods =
((ctx.context as Record<string, unknown>).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<O>,
@@ -201,6 +242,61 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(options: O) => {
grant_types_supported: opts.grantTypes,
jwt_disabled: opts.disableJwtPlugin,
});
return authMetadata;
}
return mergeExtensionMetadata(ctx, metadata);
},
),
/**
@@ -331,7 +427,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(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 = <O extends OAuthOptions<Scope[]>>(options: O) => {
properties: {
grant_type: {
type: "string",
enum: [
"authorization_code",
"client_credentials",
"refresh_token",
],
description: "OAuth2 grant type",
description:
"OAuth2 grant type (e.g., authorization_code, client_credentials, refresh_token, or a custom URI registered via extensions)",
},
client_id: {
type: "string",
+392
View File
@@ -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",
});
});
});
+75 -11
View File
@@ -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<string, unknown>)
.oauthGrantHandlers as
| Record<string, import("./types/extension").GrantTypeHandler>
| 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<string, unknown>,
) {
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<string, unknown>).oauthClaimContributors as
| NonNullable<
import("./types/extension").OAuthProviderExtension["tokenClaims"]
>[]
| undefined) ?? [];
const claimInfo: import("./types/extension").TokenClaimInfo = {
user,
scopes,
client,
referenceId,
};
const customClaims: Record<string, unknown> = {};
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<string, unknown>).oauthClaimContributors as
| NonNullable<
import("./types/extension").OAuthProviderExtension["tokenClaims"]
>[]
| undefined) ?? [];
const idClaimInfo: import("./types/extension").TokenClaimInfo = {
user,
scopes,
client,
};
const customClaims: Record<string, unknown> = {};
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<Scope[]>,
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<Scope[]>,
client: SchemaClient<Scope[]>,
@@ -375,6 +433,10 @@ async function createUserTokens(
refreshToken?: OAuthRefreshToken<Scope[]> & { id: string };
},
authTime?: Date,
extra?: {
accessTokenClaims?: Record<string, unknown>;
tokenResponse?: Record<string, unknown>;
},
) {
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,
@@ -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<Scope[]>,
) => Promise<unknown>;
/**
* Context provided to token claim contributors.
*/
export type TokenClaimInfo = {
user: User & Record<string, unknown>;
scopes: string[];
client: SchemaClient<Scope[]>;
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<string, GrantTypeHandler>;
/**
* 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<string, unknown>;
/**
* Contribute claims to tokens at creation time.
* Called per-request during token issuance, BEFORE the user's
* customAccessTokenClaims / customIdTokenClaims.
*/
tokenClaims?: {
access?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
id?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
};
/**
* Additional token endpoint authentication methods to advertise
* in token_endpoint_auth_methods_supported.
*/
tokenEndpointAuthMethods?: string[];
}
+2 -6
View File
@@ -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