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