From 296133cc414ac24283ed8e33501e2e4e501ab5e0 Mon Sep 17 00:00:00 2001 From: Gustavo Valverde Date: Thu, 15 Oct 2026 15:09:47 +0100 Subject: [PATCH] fix(oauth-provider): check DPoP before a polled grant consumes its request (#11826) --- .changeset/dpop-before-polled-claim.md | 8 ++++ docs/content/docs/plugins/oauth-provider.mdx | 4 +- packages/ciba/src/ciba.test.ts | 29 ++++++++++++++ packages/ciba/src/grant-handler.ts | 9 ++++- .../oauth-provider/src/device-code.test.ts | 40 ++++++++++++++++++- packages/oauth-provider/src/device-code.ts | 18 ++++++--- packages/oauth-provider/src/token.ts | 16 +++++++- packages/oauth-provider/src/types/index.ts | 15 +++++++ 8 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 .changeset/dpop-before-polled-claim.md diff --git a/.changeset/dpop-before-polled-claim.md b/.changeset/dpop-before-polled-claim.md new file mode 100644 index 0000000000..97d7569940 --- /dev/null +++ b/.changeset/dpop-before-polled-claim.md @@ -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. diff --git a/docs/content/docs/plugins/oauth-provider.mdx b/docs/content/docs/plugins/oauth-provider.mdx index 6ff790fc92..4437399d96 100644 --- a/docs/content/docs/plugins/oauth-provider.mdx +++ b/docs/content/docs/plugins/oauth-provider.mdx @@ -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: diff --git a/packages/ciba/src/ciba.test.ts b/packages/ciba/src/ciba.test.ts index fc75da2851..e90fd5fa10 100644 --- a/packages/ciba/src/ciba.test.ts +++ b/packages/ciba/src/ciba.test.ts @@ -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", diff --git a/packages/ciba/src/grant-handler.ts b/packages/ciba/src/grant-handler.ts index 2c259fc2a0..e7101dbf83 100644 --- a/packages/ciba/src/grant-handler.ts +++ b/packages/ciba/src/grant-handler.ts @@ -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({ 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, }); }; diff --git a/packages/oauth-provider/src/device-code.test.ts b/packages/oauth-provider/src/device-code.test.ts index 179b95875c..0efa3111f5 100644 --- a/packages/oauth-provider/src/device-code.test.ts +++ b/packages/oauth-provider/src/device-code.test.ts @@ -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>("/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({ diff --git a/packages/oauth-provider/src/device-code.ts b/packages/oauth-provider/src/device-code.ts index bcd0046b27..e1aa0f5af1 100644 --- a/packages/oauth-provider/src/device-code.ts +++ b/packages/oauth-provider/src/device-code.ts @@ -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, }); } diff --git a/packages/oauth-provider/src/token.ts b/packages/oauth-provider/src/token.ts index d471fc458f..45860be818 100644 --- a/packages/oauth-provider/src/token.ts +++ b/packages/oauth-provider/src/token.ts @@ -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, params: { client: SchemaClient; - grantIssuance: ResourceGrantIssuance; + grantIssuance: Pick; verificationValue?: VerificationValue; refreshToken?: OAuthRefreshToken & { id: string }; }, diff --git a/packages/oauth-provider/src/types/index.ts b/packages/oauth-provider/src/types/index.ts index c111ad1060..a077793bc5 100644 --- a/packages/oauth-provider/src/types/index.ts +++ b/packages/oauth-provider/src/types/index.ts @@ -334,6 +334,21 @@ export interface OAuthProviderApi { * calling it. */ issueTokens: (params: OAuthTokenIssueParams) => Awaitable; + /** + * 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; + scopes: string[]; + resources?: string[]; + confirmation?: Confirmation; + }) => Promise; /** * 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