mirror of
https://github.com/better-auth/better-auth.git
synced 2026-10-19 23:53:17 -05:00
feat(oauth-provider): dispatch contributed grant types, claims, and discovery
The oauth-provider consumes plugin contributions to its host. 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. The `grant_type` body parameter accepts any registered grant URI instead of a fixed enum. An unknown grant type returns an `unsupported_grant_type` error response rather than a schema-validation error; the built-in grants are unaffected.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
---
|
||||
"@better-auth/core": minor
|
||||
"better-auth": minor
|
||||
"@better-auth/oauth-provider": minor
|
||||
---
|
||||
|
||||
Add a plugin contribution protocol so plugins can extend the oauth-provider without changing better-auth. A plugin declares `contributes: { "oauth-provider": { ... } }` (typed via the host-augmented `ContributionContracts` interface) plus an optional `requires` list validated at startup, and the host collects them with `ctx.getContributions(...)` in its init.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,150 @@
|
||||
import { jwt } from "better-auth/plugins/jwt";
|
||||
import { getTestInstance } from "better-auth/test";
|
||||
import type { BetterAuthPlugin } from "better-auth/types";
|
||||
import { decodeJwt } from "jose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createUserTokens } from "./index";
|
||||
import { oauthProvider } from "./oauth";
|
||||
import type { OAuthContributions } from "./types/contributions";
|
||||
|
||||
const BASE_URL = "http://localhost:3000";
|
||||
const TOKEN_URL = `${BASE_URL}/api/auth/oauth2/token`;
|
||||
const METADATA_URL = `${BASE_URL}/api/auth/.well-known/oauth-authorization-server`;
|
||||
const CUSTOM_GRANT = "urn:better-auth:test:custom-grant";
|
||||
const RESOURCE = "https://api.test";
|
||||
|
||||
/**
|
||||
* A guest plugin that exercises every OAuthContributions slot: a custom grant
|
||||
* handler, the grant URI, discovery metadata, an advertised auth method, and an
|
||||
* access-token claim contributor.
|
||||
*/
|
||||
function contributionProbe() {
|
||||
return {
|
||||
id: "contribution-probe",
|
||||
requires: ["oauth-provider"],
|
||||
contributes: {
|
||||
"oauth-provider": {
|
||||
grantTypes: {
|
||||
// The handler echoes a passthrough body param to prove the open
|
||||
// schema preserves extension params, then returns a plain response.
|
||||
[CUSTOM_GRANT]: (ctx) =>
|
||||
ctx.json({
|
||||
contributed_grant: true,
|
||||
echoed: ctx.body.probe,
|
||||
}),
|
||||
},
|
||||
grantTypeURIs: [CUSTOM_GRANT],
|
||||
metadata: ({ baseURL }) => ({
|
||||
test_probe_endpoint: `${baseURL}/probe`,
|
||||
}),
|
||||
tokenEndpointAuthMethods: ["urn:better-auth:test:auth"],
|
||||
tokenClaims: {
|
||||
access: () => ({ probe_claim: "present" }),
|
||||
},
|
||||
} satisfies OAuthContributions,
|
||||
},
|
||||
} satisfies BetterAuthPlugin;
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
return getTestInstance({
|
||||
baseURL: BASE_URL,
|
||||
plugins: [
|
||||
jwt(),
|
||||
oauthProvider({
|
||||
loginPage: "/login",
|
||||
consentPage: "/consent",
|
||||
// A valid audience lets client_credentials mint a JWT access token,
|
||||
// which is where contributed access-token claims are applied.
|
||||
validAudiences: [RESOURCE],
|
||||
silenceWarnings: { oauthAuthServerConfig: true, openidConfig: true },
|
||||
}),
|
||||
contributionProbe(),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("oauth-provider contribution surface", () => {
|
||||
it("dispatches a contributed grant type and preserves passthrough body params", async () => {
|
||||
const { auth } = await setup();
|
||||
const response = await auth.handler(
|
||||
new Request(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: CUSTOM_GRANT,
|
||||
probe: "hello",
|
||||
}).toString(),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as {
|
||||
contributed_grant?: boolean;
|
||||
echoed?: string;
|
||||
};
|
||||
expect(body.contributed_grant).toBe(true);
|
||||
expect(body.echoed).toBe("hello");
|
||||
});
|
||||
|
||||
it("rejects an unregistered grant type with unsupported_grant_type", async () => {
|
||||
const { auth } = await setup();
|
||||
const response = await auth.handler(
|
||||
new Request(TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "urn:better-auth:test:unregistered",
|
||||
}).toString(),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
const body = (await response.json()) as { error?: string };
|
||||
expect(body.error).toBe("unsupported_grant_type");
|
||||
});
|
||||
|
||||
it("advertises the contributed grant URI, metadata, and auth method in discovery", async () => {
|
||||
const { auth } = await setup();
|
||||
const response = await auth.handler(new Request(METADATA_URL));
|
||||
expect(response.status).toBe(200);
|
||||
const metadata = (await response.json()) as {
|
||||
grant_types_supported?: string[];
|
||||
token_endpoint_auth_methods_supported?: string[];
|
||||
test_probe_endpoint?: string;
|
||||
};
|
||||
expect(metadata.grant_types_supported).toContain(CUSTOM_GRANT);
|
||||
expect(metadata.token_endpoint_auth_methods_supported).toContain(
|
||||
"urn:better-auth:test:auth",
|
||||
);
|
||||
expect(metadata.test_probe_endpoint).toBe(`${BASE_URL}/api/auth/probe`);
|
||||
});
|
||||
|
||||
it("merges contributed token claims into a minted access token", 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_secret: client!.client_secret!,
|
||||
resource: RESOURCE,
|
||||
}).toString(),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
const body = (await response.json()) as { access_token?: string };
|
||||
expect(body.access_token).toBeDefined();
|
||||
const claims = decodeJwt(body.access_token!);
|
||||
expect(claims.probe_claim).toBe("present");
|
||||
});
|
||||
|
||||
it("exports the grant-author token-minting helper", () => {
|
||||
expect(typeof createUserTokens).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -15,4 +15,13 @@ export type {
|
||||
OAuthRedirectOnError,
|
||||
} from "./oauth-endpoint";
|
||||
export { checkOAuthClient, oauthToSchema } from "./register";
|
||||
// Grant-author toolkit: helpers a contributed grant handler reuses to mint
|
||||
// tokens and authenticate the client instead of reimplementing them.
|
||||
export { createUserTokens } from "./token";
|
||||
export type * from "./types";
|
||||
export {
|
||||
basicToClientCredentials,
|
||||
getClient,
|
||||
storeToken,
|
||||
validateClientCredentials,
|
||||
} from "./utils";
|
||||
|
||||
@@ -15,6 +15,44 @@ import {
|
||||
toClientDiscoveryArray,
|
||||
} from "./utils";
|
||||
|
||||
/**
|
||||
* Folds plugin-contributed discovery into the metadata document: appends
|
||||
* contributed token-endpoint auth methods and adds contributed fields. Core
|
||||
* fields are never overridden — a contributor can only add new keys.
|
||||
*/
|
||||
function applyMetadataContributions(
|
||||
ctx: GenericEndpointContext,
|
||||
metadata: AuthServerMetadata,
|
||||
): void {
|
||||
const extraAuthMethods = (ctx.context as Record<string, unknown>)
|
||||
.oauthExtraAuthMethods as string[] | undefined;
|
||||
if (extraAuthMethods?.length) {
|
||||
const methods = metadata.token_endpoint_auth_methods_supported ?? [];
|
||||
for (const method of extraAuthMethods) {
|
||||
if (!methods.includes(method as TokenEndpointAuthMethod)) {
|
||||
methods.push(method as TokenEndpointAuthMethod);
|
||||
}
|
||||
}
|
||||
metadata.token_endpoint_auth_methods_supported = methods;
|
||||
}
|
||||
const contributors = (ctx.context as Record<string, unknown>)
|
||||
.oauthMetadataContributors as
|
||||
| ((c: { baseURL: string }) => Record<string, unknown>)[]
|
||||
| undefined;
|
||||
if (contributors?.length) {
|
||||
const target = metadata as Record<string, unknown>;
|
||||
for (const contribute of contributors) {
|
||||
for (const [key, value] of Object.entries(
|
||||
contribute({ baseURL: ctx.context.baseURL }),
|
||||
)) {
|
||||
if (target[key] == null) {
|
||||
target[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function authServerMetadata(
|
||||
ctx: GenericEndpointContext,
|
||||
opts?: JwtOptions,
|
||||
@@ -82,6 +120,7 @@ export function authServerMetadata(
|
||||
code_challenge_methods_supported: ["S256"],
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
};
|
||||
applyMetadataContributions(ctx, metadata);
|
||||
return metadata;
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import { revokeEndpoint } from "./revoke";
|
||||
import { schema } from "./schema";
|
||||
import { tokenEndpoint } from "./token";
|
||||
import type { OAuthOptions, Scope } from "./types";
|
||||
import type { GrantHandler, OAuthContributions } from "./types/contributions";
|
||||
import { ResourceUriSchema, SafeUrlSchema } from "./types/zod";
|
||||
import { userInfoEndpoint } from "./userinfo";
|
||||
import {
|
||||
@@ -271,6 +272,54 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
options: opts as NoInfer<O>,
|
||||
onRequest: handleIssuerMetadataRequest,
|
||||
init: (ctx) => {
|
||||
// Aggregate contributions from plugins that target this host. Read once
|
||||
// at init (static, order-independent) and expose on the context so the
|
||||
// token endpoint, discovery, and minting can consume them at request time.
|
||||
const contributions = ctx.getContributions("oauth-provider");
|
||||
const oauthGrantHandlers: Record<string, GrantHandler> = {};
|
||||
const extensionGrantURIs: string[] = [];
|
||||
const oauthMetadataContributors: NonNullable<
|
||||
OAuthContributions["metadata"]
|
||||
>[] = [];
|
||||
const oauthExtraAuthMethods: string[] = [];
|
||||
const oauthClaimContributors: NonNullable<
|
||||
OAuthContributions["tokenClaims"]
|
||||
>[] = [];
|
||||
for (const contribution of contributions) {
|
||||
if (contribution.grantTypes) {
|
||||
Object.assign(oauthGrantHandlers, contribution.grantTypes);
|
||||
}
|
||||
if (contribution.grantTypeURIs) {
|
||||
extensionGrantURIs.push(...contribution.grantTypeURIs);
|
||||
}
|
||||
if (contribution.metadata) {
|
||||
oauthMetadataContributors.push(contribution.metadata);
|
||||
}
|
||||
if (contribution.tokenEndpointAuthMethods) {
|
||||
oauthExtraAuthMethods.push(...contribution.tokenEndpointAuthMethods);
|
||||
}
|
||||
if (contribution.tokenClaims) {
|
||||
oauthClaimContributors.push(contribution.tokenClaims);
|
||||
}
|
||||
}
|
||||
// Allow contributed grant URIs through the token-endpoint allowlist.
|
||||
if (extensionGrantURIs.length > 0) {
|
||||
opts.grantTypes = [
|
||||
...(opts.grantTypes ?? [
|
||||
"authorization_code",
|
||||
"client_credentials",
|
||||
"refresh_token",
|
||||
]),
|
||||
...extensionGrantURIs,
|
||||
];
|
||||
}
|
||||
const pluginContext = {
|
||||
oauthGrantHandlers,
|
||||
oauthMetadataContributors,
|
||||
oauthExtraAuthMethods,
|
||||
oauthClaimContributors,
|
||||
};
|
||||
|
||||
// OAuth provider performs adapter-level session lookups by id, so it
|
||||
// currently requires DB-backed sessions whenever secondary storage is enabled.
|
||||
if (
|
||||
@@ -300,7 +349,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
} catch (error) {
|
||||
// baseURL may not be available during init when using dynamic baseURL config
|
||||
if (isDynamicBaseURLInit && issuer === "") {
|
||||
return;
|
||||
return { context: pluginContext };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -324,6 +373,7 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
);
|
||||
}
|
||||
}
|
||||
return { context: pluginContext };
|
||||
},
|
||||
hooks: {
|
||||
before: [
|
||||
@@ -748,29 +798,28 @@ export const oauthProvider = <O extends OAuthOptions<Scope[]>>(options: O) => {
|
||||
"/oauth2/token",
|
||||
{
|
||||
method: "POST",
|
||||
body: z.object({
|
||||
grant_type: z
|
||||
.string()
|
||||
.pipe(
|
||||
z.enum([
|
||||
"authorization_code",
|
||||
"client_credentials",
|
||||
"refresh_token",
|
||||
]),
|
||||
),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
client_assertion: z.string().optional(),
|
||||
client_assertion_type: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
redirect_uri: SafeUrlSchema.optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
resource: z
|
||||
.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)])
|
||||
.optional(),
|
||||
scope: z.string().optional(),
|
||||
}),
|
||||
// Open `grant_type` to any non-empty string so plugin-contributed
|
||||
// extension grants reach the dispatcher; the allowlist + the token
|
||||
// endpoint's `default` case still reject unregistered URIs with
|
||||
// `unsupported_grant_type`. `.passthrough()` keeps extension body
|
||||
// params (e.g. `subject_token`, `auth_req_id`) for the grant handler.
|
||||
body: z
|
||||
.object({
|
||||
grant_type: z.string().trim().min(1),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
client_assertion: z.string().optional(),
|
||||
client_assertion_type: z.string().optional(),
|
||||
code: z.string().optional(),
|
||||
code_verifier: z.string().optional(),
|
||||
redirect_uri: SafeUrlSchema.optional(),
|
||||
refresh_token: z.string().optional(),
|
||||
resource: z
|
||||
.union([ResourceUriSchema, z.array(ResourceUriSchema).min(1)])
|
||||
.optional(),
|
||||
scope: z.string().optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
errorCodesByField: {
|
||||
grant_type: {
|
||||
missing: "invalid_request",
|
||||
@@ -791,12 +840,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. Built-ins: authorization_code, client_credentials, refresh_token. Plugins may register extension grant URIs (e.g. CIBA, token-exchange).",
|
||||
},
|
||||
client_id: {
|
||||
type: "string",
|
||||
|
||||
@@ -13,6 +13,11 @@ import type {
|
||||
Scope,
|
||||
VerificationValue,
|
||||
} from "./types";
|
||||
import type {
|
||||
GrantHandler,
|
||||
OAuthContributions,
|
||||
TokenClaimInfo,
|
||||
} from "./types/contributions";
|
||||
import type { GrantType } from "./types/oauth";
|
||||
import { verificationValueSchema } from "./types/zod";
|
||||
import { userNormalClaims } from "./userinfo";
|
||||
@@ -58,11 +63,50 @@ export async function tokenEndpoint(
|
||||
return handleClientCredentialsGrant(ctx, opts);
|
||||
case "refresh_token":
|
||||
return handleRefreshTokenGrant(ctx, opts);
|
||||
default: {
|
||||
// Extension grants registered by plugins via OAuthContributions.grantTypes.
|
||||
// The allowlist check above already rejected unknown grant_type values, so
|
||||
// reaching here means the URI was allowed; dispatch to its handler.
|
||||
const handlers = (ctx.context as Record<string, unknown>)
|
||||
.oauthGrantHandlers as Record<string, GrantHandler> | undefined;
|
||||
const handler = handlers?.[grantType];
|
||||
if (handler) {
|
||||
return handler(ctx, opts);
|
||||
}
|
||||
throw new APIError("BAD_REQUEST", {
|
||||
error_description: `unsupported grant_type ${grantType}`,
|
||||
error: "unsupported_grant_type",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User Jwt SHALL follow oAuth 2
|
||||
// NOTE: Requires jwt plugin (assert !opts.disableJwtPlugin)
|
||||
/**
|
||||
* Collects extra claims from plugin-contributed `tokenClaims` contributors for
|
||||
* the given token kind. Returns the merged claims, applied before the
|
||||
* consumer's `customAccessTokenClaims`/`customIdTokenClaims` and never over the
|
||||
* pinned security claims set by the caller.
|
||||
*/
|
||||
async function collectExtensionClaims(
|
||||
ctx: GenericEndpointContext,
|
||||
kind: "access" | "id",
|
||||
info: TokenClaimInfo,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const contributors = (ctx.context as Record<string, unknown>)
|
||||
.oauthClaimContributors as
|
||||
| NonNullable<OAuthContributions["tokenClaims"]>[]
|
||||
| undefined;
|
||||
if (!contributors?.length) return {};
|
||||
const claims: Record<string, unknown> = {};
|
||||
for (const contributor of contributors) {
|
||||
const fn = contributor[kind];
|
||||
if (fn) Object.assign(claims, await fn(info));
|
||||
}
|
||||
return claims;
|
||||
}
|
||||
|
||||
async function createJwtAccessToken(
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
@@ -80,6 +124,14 @@ async function createJwtAccessToken(
|
||||
) {
|
||||
const iat = overrides?.iat ?? Math.floor(Date.now() / 1000);
|
||||
const exp = overrides?.exp ?? iat + (opts.accessTokenExpiresIn ?? 3600);
|
||||
const extensionClaims = await collectExtensionClaims(ctx, "access", {
|
||||
user,
|
||||
scopes,
|
||||
client,
|
||||
referenceId,
|
||||
sessionId: overrides?.sid,
|
||||
resources,
|
||||
});
|
||||
const customClaims = opts.customAccessTokenClaims
|
||||
? await opts.customAccessTokenClaims({
|
||||
user,
|
||||
@@ -96,6 +148,7 @@ async function createJwtAccessToken(
|
||||
return signJWT(ctx, {
|
||||
options: jwtPluginOptions,
|
||||
payload: {
|
||||
...extensionClaims,
|
||||
...customClaims,
|
||||
sub: user?.id,
|
||||
aud: toAudienceClaim(audience),
|
||||
@@ -148,6 +201,7 @@ async function createIdToken(
|
||||
sessionId?: string,
|
||||
authTime?: Date,
|
||||
accessToken?: string,
|
||||
referenceId?: string,
|
||||
) {
|
||||
const iat = Math.floor(Date.now() / 1000);
|
||||
const exp = iat + (opts.idTokenExpiresIn ?? 36000);
|
||||
@@ -160,6 +214,13 @@ async function createIdToken(
|
||||
// - silver : mfa
|
||||
const acr = "urn:mace:incommon:iap:bronze";
|
||||
|
||||
const extensionClaims = await collectExtensionClaims(ctx, "id", {
|
||||
user,
|
||||
scopes,
|
||||
client,
|
||||
sessionId,
|
||||
referenceId,
|
||||
});
|
||||
const customClaims = opts.customIdTokenClaims
|
||||
? await opts.customIdTokenClaims({
|
||||
user,
|
||||
@@ -190,6 +251,7 @@ async function createIdToken(
|
||||
...userClaims,
|
||||
auth_time: authTimeSec,
|
||||
acr,
|
||||
...extensionClaims,
|
||||
...customClaims,
|
||||
at_hash: atHash,
|
||||
iss: jwtPluginOptions?.jwt?.issuer ?? ctx.context.baseURL,
|
||||
@@ -482,7 +544,7 @@ interface CreateUserTokensParams {
|
||||
originalResources?: string[];
|
||||
}
|
||||
|
||||
async function createUserTokens(
|
||||
export async function createUserTokens(
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
params: CreateUserTokensParams,
|
||||
@@ -644,6 +706,7 @@ async function createUserTokens(
|
||||
sessionId,
|
||||
authTime,
|
||||
accessToken,
|
||||
referenceId,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { Awaitable, GenericEndpointContext } from "@better-auth/core";
|
||||
import type { User } from "better-auth/types";
|
||||
import type { OAuthOptions, SchemaClient, Scope } from "./index";
|
||||
|
||||
/**
|
||||
* A custom grant-type handler dispatched by the token endpoint when an incoming
|
||||
* `grant_type` matches a key a plugin registered via `OAuthContributions.grantTypes`.
|
||||
*
|
||||
* It receives the endpoint context and the resolved oauth-provider options and
|
||||
* returns the full token-endpoint response (the same shape the built-in grant
|
||||
* handlers produce). Use the exported `createUserTokens` helper to mint tokens.
|
||||
*/
|
||||
export type GrantHandler = (
|
||||
ctx: GenericEndpointContext,
|
||||
opts: OAuthOptions<Scope[]>,
|
||||
) => Awaitable<Response>;
|
||||
|
||||
/**
|
||||
* Context passed to a token-claim contributor at minting time. A contributor
|
||||
* returns extra claims to merge into the access token or ID token.
|
||||
*/
|
||||
export interface TokenClaimInfo {
|
||||
/** The authenticated user, or `undefined` for client_credentials access tokens. */
|
||||
user: User | undefined;
|
||||
/** Scopes granted to the token. */
|
||||
scopes: string[];
|
||||
/** The OAuth client the token is issued to. */
|
||||
client: SchemaClient<Scope[]>;
|
||||
/** Grant reference id (e.g. a CIBA `auth_req_id`), when the grant supplies one. */
|
||||
referenceId?: string;
|
||||
/** Session id bound to the token, when available. */
|
||||
sessionId?: string;
|
||||
/** Requested resource indicators (RFC 8707), when present. */
|
||||
resources?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* What a plugin can contribute to the `oauth-provider` host through its
|
||||
* `contributes` field. The host collects these at init via
|
||||
* `ctx.getContributions("oauth-provider")` and wires them into the token
|
||||
* endpoint, discovery metadata, and token minting — so a plugin can add custom
|
||||
* grant types, advertise capabilities, and inject claims without the
|
||||
* oauth-provider knowing about it.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import type { OAuthContributions } from "@better-auth/oauth-provider";
|
||||
*
|
||||
* export const ciba = () => ({
|
||||
* id: "ciba",
|
||||
* requires: ["oauth-provider"],
|
||||
* contributes: {
|
||||
* "oauth-provider": {
|
||||
* grantTypes: { "urn:openid:params:grant-type:ciba": cibaGrantHandler },
|
||||
* grantTypeURIs: ["urn:openid:params:grant-type:ciba"],
|
||||
* metadata: ({ baseURL }) => ({
|
||||
* backchannel_authentication_endpoint: `${baseURL}/oauth2/bc-authorize`,
|
||||
* }),
|
||||
* } satisfies OAuthContributions,
|
||||
* },
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export interface OAuthContributions {
|
||||
/**
|
||||
* Custom grant-type handlers keyed by `grant_type` URI. The token endpoint
|
||||
* dispatches to the handler when the request's grant_type matches and the URI
|
||||
* is allowed. Pair every key with a `grantTypeURIs` entry.
|
||||
*/
|
||||
grantTypes?: Record<string, GrantHandler>;
|
||||
/**
|
||||
* Grant-type URIs to add to the token-endpoint allowlist and to
|
||||
* `grant_types_supported` in discovery.
|
||||
*/
|
||||
grantTypeURIs?: string[];
|
||||
/**
|
||||
* Fields merged into the `.well-known` discovery documents (e.g. an endpoint
|
||||
* or capability a profile adds).
|
||||
*/
|
||||
metadata?: (ctx: { baseURL: string }) => Record<string, unknown>;
|
||||
/**
|
||||
* Token-endpoint client authentication methods to advertise in
|
||||
* `token_endpoint_auth_methods_supported`.
|
||||
*/
|
||||
tokenEndpointAuthMethods?: string[];
|
||||
/**
|
||||
* Claim contributors merged into issued tokens. Extension claims are applied
|
||||
* before the consumer's `customAccessTokenClaims`/`customIdTokenClaims` and
|
||||
* never override pinned security claims (`sub`, `iss`, `aud`, `iat`, `exp`).
|
||||
*/
|
||||
tokenClaims?: {
|
||||
access?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
|
||||
id?: (info: TokenClaimInfo) => Awaitable<Record<string, unknown>>;
|
||||
};
|
||||
}
|
||||
|
||||
declare module "@better-auth/core" {
|
||||
interface ContributionContracts {
|
||||
"oauth-provider": OAuthContributions;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ import type { schema } from "../schema";
|
||||
import type { Awaitable } from "./helpers";
|
||||
import type { GrantType } from "./oauth";
|
||||
|
||||
export type {
|
||||
GrantHandler,
|
||||
OAuthContributions,
|
||||
TokenClaimInfo,
|
||||
} from "./contributions";
|
||||
export type {
|
||||
AuthMethod,
|
||||
AuthServerMetadata,
|
||||
|
||||
@@ -10,10 +10,12 @@ export type GrantType =
|
||||
// | "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"
|
||||
// Extension grant URIs (CIBA, pre-authorized_code, token-exchange, device_code,
|
||||
// ...) are registered by plugins via `OAuthContributions.grantTypes`. The
|
||||
// `(string & {})` member accepts those custom URIs while preserving literal
|
||||
// autocomplete for the built-ins above.
|
||||
| (string & {});
|
||||
|
||||
export type AuthMethod =
|
||||
| "client_secret_basic" // Basic header
|
||||
|
||||
@@ -487,7 +487,7 @@ export async function getStoredToken(
|
||||
// spaces aren't rejected before reaching the spec-correct decoder.
|
||||
const BASIC_SCHEME_PREFIX = /^Basic +/i;
|
||||
|
||||
function basicToClientCredentials(authorization: string) {
|
||||
export function basicToClientCredentials(authorization: string) {
|
||||
if (!BASIC_SCHEME_PREFIX.test(authorization)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user