fix(oauth-provider): check DPoP before a polled grant consumes its request (#11826)

This commit is contained in:
Gustavo Valverde
2026-10-15 15:09:47 +01:00
committed by GitHub
parent c7c8331400
commit 296133cc41
8 changed files with 130 additions and 9 deletions
+8
View File
@@ -0,0 +1,8 @@
---
"@better-auth/oauth-provider": minor
"@better-auth/ciba": patch
---
Device authorization and CIBA clients that poll the token endpoint with a missing or invalid DPoP proof now receive `invalid_dpop_proof` and can poll again with a valid proof. Previously the rejected poll used up the approved device code or CIBA request, so the user had to approve again. Clients that send valid proofs, or no proof when none is required, are unaffected.
Extension grants that consume a one-time credential can call `provider.resolveConfirmation()` before the claim and pass its result to `issueTokens` as `confirmation` to get the same behavior.
+3 -1
View File
@@ -1908,12 +1908,14 @@ A [client metadata document](/docs/plugins/cimd) carries declared fields the sam
#### Provider capabilities outside a grant
A grant handler receives a `provider` capability surface (`getClient`, `authenticateClient`, `issueTokens`, `hashToken`, `validateAccessToken`, `requireActiveAccessToken`). 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. Use `validateAccessToken` for introspection-style flows that can handle inactive payloads, and `requireActiveAccessToken` for protected-resource endpoints that should reject inactive or unknown tokens with an OAuth bearer challenge. 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.
A grant handler receives a `provider` capability surface (`getClient`, `authenticateClient`, `resolveConfirmation`, `issueTokens`, `hashToken`, `validateAccessToken`, `requireActiveAccessToken`). 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. Use `validateAccessToken` for introspection-style flows that can handle inactive payloads, and `requireActiveAccessToken` for protected-resource endpoints that should reject inactive or unknown tokens with an OAuth bearer challenge. 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.
When a companion endpoint accepts `application/x-www-form-urlencoded`, set `cloneRequest: true` in its endpoint options before calling `authenticateClient()`. Client authentication re-reads the raw form body to enforce that client credentials are not repeated or combined across authentication methods, so the endpoint must preserve a readable request after parsing the form.
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.
A grant that consumes a one-time credential, such as a device code, should resolve the binding before the claim: `provider.resolveConfirmation({ client, scopes, resources, confirmation })` returns the confirmation the client proved at authentication, or verifies the request's DPoP proof by the same rules `issueTokens` applies. Pass the result to `issueTokens` as `confirmation`. A rejected proof then fails before the credential is consumed, so the client can retry with a valid one.
#### Client authentication obligations
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:
+29
View File
@@ -485,6 +485,35 @@ describe("ciba poll mode", () => {
expect(introspection.cnf).toEqual({ jkt: await deriveDpopJkt(publicJwk) });
});
it("keeps an approved request when the poll's DPoP proof is rejected", async () => {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk: JWK = await exportJWK(publicKey);
const proofFor = (htu: string) =>
new SignJWT({
jti: crypto.randomUUID(),
htm: "POST",
htu,
iat: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk })
.sign(privateKey);
const { authReqId, url } = await start();
await approve(url);
const rejected = await poll(authReqId, pollClient, {
dpop: await proofFor(`${baseURL}/api/auth/oauth2/introspect`),
});
expect(rejected.body.error).toBe("invalid_dpop_proof");
const issued = await poll(authReqId, pollClient, {
dpop: await proofFor(`${baseURL}/api/auth/oauth2/token`),
});
expect(issued.status).toBe(200);
expect(issued.body.token_type).toBe("DPoP");
});
it("binds the token to every repeated resource (RFC 8707)", async () => {
const res = await bcAuthorize({
scope: "openid",
+8 -1
View File
@@ -112,6 +112,13 @@ export const createCibaGrantHandler =
"INTERNAL_SERVER_ERROR",
);
}
// A rejected DPoP proof also refuses here, before the claim.
const binding = await provider.resolveConfirmation({
client,
scopes,
resources,
confirmation,
});
const claimed = await adapter.consumeOne<CibaRequest>({
model: "cibaRequest",
where: [
@@ -145,6 +152,6 @@ export const createCibaGrantHandler =
amr: claimed.amr ?? undefined,
// A DPoP proof on this poll, or a sender-constraint proved at client
// authentication, binds the issued token.
confirmation,
confirmation: binding,
});
};
@@ -5,7 +5,7 @@ import { openAPI } from "better-auth/plugins";
import { deviceAuthorization } from "better-auth/plugins/device-authorization";
import { jwt } from "better-auth/plugins/jwt";
import { getTestInstance } from "better-auth/test";
import { decodeJwt } from "jose";
import { decodeJwt, exportJWK, generateKeyPair, SignJWT } from "jose";
import { describe, expect, expectTypeOf, it, vi } from "vitest";
import { oauthDeviceAuthorizationClient, oauthProviderClient } from "./client";
import {
@@ -380,6 +380,44 @@ describe("oauth-provider device-code grant", async () => {
});
}
it("keeps an approved device code when the poll's DPoP proof is rejected", async () => {
const { privateKey, publicKey } = await generateKeyPair("ES256", {
extractable: true,
});
const publicJwk = await exportJWK(publicKey);
const proofFor = (htu: string) =>
new SignJWT({
jti: crypto.randomUUID(),
htm: "POST",
htu,
iat: Math.floor(Date.now() / 1000),
})
.setProtectedHeader({ typ: "dpop+jwt", alg: "ES256", jwk: publicJwk })
.sign(privateKey);
const clientId = await createDeviceClient();
const deviceCode = await approvedDeviceCode(clientId);
const pollWithProof = async (htu: string) =>
client.$fetch<Record<string, unknown>>("/oauth2/token", {
method: "POST",
body: new URLSearchParams({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: deviceCode,
client_id: clientId,
}),
headers: { ...FORM_HEADERS, dpop: await proofFor(htu) },
});
const rejected = await pollWithProof(
`${baseURL}/api/auth/oauth2/introspect`,
);
expect(rejected.error).toMatchObject({ error: "invalid_dpop_proof" });
await new Promise((resolve) => setTimeout(resolve, 2_100));
const issued = await pollWithProof(`${baseURL}/api/auth/oauth2/token`);
expect(issued.error).toBeNull();
expect(issued.data?.token_type).toBe("DPoP");
});
it("accepts a confidential client authenticated with Basic without body client_id", async () => {
const { headers } = await signInWithTestUser();
const created = await auth.api.adminCreateOAuthClient({
+13 -5
View File
@@ -24,6 +24,7 @@ import type {
OAuthProviderExtension,
OAuthTokenResponse,
} from "./types";
import type { Confirmation } from "./types/oauth";
import { ResourceUriSchema } from "./types/zod";
import {
getClient,
@@ -117,14 +118,14 @@ async function exchangeOAuthDeviceCode(
requireCredentials: false,
});
const {
authorizationContext: { client, confirmation, scopes },
authorizationContext: { client, scopes },
claimedDeviceCode,
redemptionContext: resources,
redemptionContext: { resources, confirmation },
user,
} = await redeemDeviceCode<
OAuthDeviceCodeFields,
ClientAuthorization,
string[] | undefined
{ resources?: string[]; confirmation?: Confirmation }
>({
ctx,
deviceCode,
@@ -175,7 +176,14 @@ async function exchangeOAuthDeviceCode(
clientId: authorization.client.clientId,
requestedScopes: authorization.scopes,
});
return resources;
// Same for a rejected DPoP proof: the client can retry with a valid one.
const confirmation = await provider.resolveConfirmation({
client: authorization.client,
scopes: authorization.scopes,
resources,
confirmation: authorization.confirmation,
});
return { resources, confirmation };
},
});
@@ -190,7 +198,7 @@ async function exchangeOAuthDeviceCode(
amr: claimedDeviceCode.amr ?? undefined,
// Seed refresh tokens with every approved resource, not the narrowed poll.
originalResources: claimedDeviceCode.resources ?? undefined,
// Forward a sender-constraint a confidential client-auth strategy proved.
// Resolved before the claim: client-auth proof or the poll's DPoP proof.
confirmation,
});
}
+15 -1
View File
@@ -207,6 +207,20 @@ export function getOAuthProviderApi(
}
return createUserTokens(ctx, opts, { ...params, grantType });
},
resolveConfirmation: async (params) => {
if (params.confirmation) return params.confirmation;
const policy = await resolveResourcePolicy(ctx, opts, {
resource: params.resources,
clientId: params.client.clientId,
requestedScopes: params.scopes,
});
return resolveDpopTokenBinding(ctx, opts, {
client: params.client,
grantIssuance: {
dpopBoundAccessTokensRequired: policy.dpopBoundAccessTokensRequired,
},
});
},
hashToken: (token: string, type: StoreTokenType) =>
storeToken(opts.storeTokens, token, type),
validateAccessToken: async (token: string, clientId?: string) => {
@@ -780,7 +794,7 @@ async function resolveDpopTokenBinding(
opts: OAuthOptions<Scope[]>,
params: {
client: SchemaClient<Scope[]>;
grantIssuance: ResourceGrantIssuance;
grantIssuance: Pick<ResourceGrantIssuance, "dpopBoundAccessTokensRequired">;
verificationValue?: VerificationValue;
refreshToken?: OAuthRefreshToken<Scope[]> & { id: string };
},
@@ -334,6 +334,21 @@ export interface OAuthProviderApi {
* calling it.
*/
issueTokens: (params: OAuthTokenIssueParams) => Awaitable<OAuthTokenResponse>;
/**
* Resolves the sender-constraint the tokens for this request will carry: the
* `confirmation` the client proved at authentication, or else the DPoP proof
* on this request, verified and required by the same rules `issueTokens`
* applies. A grant that consumes a one-time credential (a device code, a CIBA
* request) calls this before the claim and passes the result to
* `issueTokens` as `confirmation`, so a rejected proof leaves the credential
* for a retry with a valid one.
*/
resolveConfirmation: (params: {
client: SchemaClient<Scope[]>;
scopes: string[];
resources?: string[];
confirmation?: Confirmation;
}) => Promise<Confirmation | undefined>;
/**
* Computes the stored lookup key for a token value (the same hash the
* provider persists), so a caller can find or revoke a previously issued