Skip to content

Commit c32a7b9

Browse files
committed
feat(oauth2): support for DPoP combined mode
Signed-off-by: Henrique Dias <mail@hacdias.com>
1 parent ed80301 commit c32a7b9

8 files changed

Lines changed: 390 additions & 38 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@openid4vc/oauth2": patch
3+
---
4+
5+
Add support for the DPoP-bound `attest_jwt_client_auth_dpop` client authentication method (draft 09 §5.2): a new `clientAuthenticationClientAttestationJwtDpop` client-auth callback that emits a single DPoP proof doubling as the Client Attestation PoP (client instance key == DPoP key), plus authorization-server verification of the combined method (attestation JWT + DPoP proof with a mandatory `cnf` JWK to DPoP key match).

packages/oauth2/src/access-token/parse-access-token-request.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,12 @@ export interface ParseAccessTokenRequestResult {
5353
*/
5454
clientAttestation?: {
5555
clientAttestationJwt: string
56-
clientAttestationPopJwt: string
56+
57+
/**
58+
* Absent for the DPoP-bound `attest_jwt_client_auth_dpop` method (draft 09), where the DPoP proof
59+
* serves as the Client Attestation PoP.
60+
*/
61+
clientAttestationPopJwt?: string
5762
}
5863

5964
/**

packages/oauth2/src/access-token/verify-access-token-request.ts

Lines changed: 117 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { type CallbackContext, HashAlgorithm } from '../callbacks'
2-
import { type VerifiedClientAttestationJwt, verifyClientAttestation } from '../client-attestation/client-attestation'
2+
import {
3+
type VerifiedClientAttestationJwt,
4+
verifyClientAttestation,
5+
verifyClientAttestationJwt,
6+
} from '../client-attestation/client-attestation'
37
import type { VerifiedClientAttestationPopJwt } from '../client-attestation/client-attestation-pop'
48
import {
59
oauthClientAttestationHeader,
610
oauthClientAttestationPopHeader,
711
} from '../client-attestation/z-client-attestation'
12+
import { SupportedClientAuthenticationMethod } from '../client-authentication'
813
import { calculateJwkThumbprint } from '../common/jwk/jwk-thumbprint'
914
import type { Jwk } from '../common/jwk/z-jwk'
1015
import type { RequestLike } from '../common/z-common'
@@ -44,6 +49,15 @@ export interface VerifyAccessTokenRequestDpop {
4449
* to handle the alg.
4550
*/
4651
allowedSigningAlgs?: string[]
52+
53+
/**
54+
* Expected nonce in the dpop proof. If not provided the nonce won't be validated.
55+
*
56+
* For the DPoP-bound `attest_jwt_client_auth_dpop` method (draft 09) the server-provided client
57+
* attestation challenge is carried in the dpop `nonce` claim, so this can be set to the issued
58+
* challenge to enforce it.
59+
*/
60+
expectedNonce?: string
4761
}
4862

4963
export interface VerifyAccessTokenRequestClientAttestation {
@@ -91,7 +105,11 @@ export interface VerifyAccessTokenRequestReturn {
91105

92106
clientAttestation?: {
93107
clientAttestation: VerifiedClientAttestationJwt
94-
clientAttestationPop: VerifiedClientAttestationPopJwt
108+
/**
109+
* Absent for the DPoP-bound `attest_jwt_client_auth_dpop` method (draft 09), where the verified
110+
* DPoP proof serves as the Client Attestation PoP.
111+
*/
112+
clientAttestationPop?: VerifiedClientAttestationPopJwt
95113
}
96114
}
97115

@@ -327,8 +345,8 @@ async function verifyAccessTokenRequestClientAttestation(
327345
dpopJwkThumbprint?: string,
328346
now?: Date
329347
) {
330-
if (!options.clientAttestationJwt || !options.clientAttestationPopJwt) {
331-
if (!options.required && !options.clientAttestationJwt && !options.clientAttestationPopJwt) {
348+
if (!options.clientAttestationJwt) {
349+
if (!options.required && !options.clientAttestationPopJwt) {
332350
return undefined
333351
}
334352

@@ -338,6 +356,17 @@ async function verifyAccessTokenRequestClientAttestation(
338356
})
339357
}
340358

359+
// DPoP-bound method (`attest_jwt_client_auth_dpop`, draft 09 §5.2): the client attestation is present
360+
// without a separate PoP header; the DPoP proof serves as the Client Attestation PoP.
361+
if (!options.clientAttestationPopJwt) {
362+
return verifyAccessTokenRequestClientAttestationDpop(
363+
{ ...options, clientAttestationJwt: options.clientAttestationJwt },
364+
callbacks,
365+
dpopJwkThumbprint,
366+
now
367+
)
368+
}
369+
341370
const verifiedClientAttestation = await verifyClientAttestation({
342371
authorizationServer: authorizationServerMetadata.issuer,
343372
callbacks,
@@ -346,44 +375,101 @@ async function verifyAccessTokenRequestClientAttestation(
346375
now,
347376
})
348377

349-
if (
350-
options.expectedClientId &&
351-
options.expectedClientId !== verifiedClientAttestation.clientAttestation.payload.sub
352-
) {
353-
// Ensure the client id matches with the client id from the session
354-
throw new Oauth2ServerErrorResponseError(
355-
{
356-
error: Oauth2ErrorCodes.InvalidClient,
357-
error_description: `The client id '${verifiedClientAttestation.clientAttestation.payload.sub}' in the client attestation does not match the client id for the authorization.`,
358-
},
359-
{
360-
status: 401,
361-
}
378+
// Ensure the client id matches with the client id from the session
379+
assertExpectedClientId(options.expectedClientId, verifiedClientAttestation.clientAttestation.payload.sub)
380+
381+
if (options.ensureConfirmationKeyMatchesDpopKey && dpopJwkThumbprint) {
382+
await assertConfirmationKeyMatchesDpopKey(
383+
verifiedClientAttestation.clientAttestation.payload.cnf.jwk,
384+
dpopJwkThumbprint,
385+
callbacks
362386
)
363387
}
364388

365-
if (options.ensureConfirmationKeyMatchesDpopKey && dpopJwkThumbprint) {
366-
const clientAttestationJkt = await calculateJwkThumbprint({
367-
hashAlgorithm: HashAlgorithm.Sha256,
368-
hashCallback: callbacks.hash,
369-
jwk: verifiedClientAttestation.clientAttestation.payload.cnf.jwk,
389+
return verifiedClientAttestation
390+
}
391+
392+
async function verifyAccessTokenRequestClientAttestationDpop(
393+
options: VerifyAccessTokenRequestClientAttestation & { clientAttestationJwt: string },
394+
callbacks: Pick<CallbackContext, 'verifyJwt' | 'hash'>,
395+
dpopJwkThumbprint?: string,
396+
now?: Date
397+
) {
398+
// The DPoP proof is the Client Attestation PoP in this method, so a valid DPoP proof is required.
399+
if (!dpopJwkThumbprint) {
400+
throw new Oauth2ServerErrorResponseError({
401+
error: Oauth2ErrorCodes.InvalidClient,
402+
error_description: `Client attestation provided without an '${oauthClientAttestationPopHeader}' header, but no valid DPoP proof is present. The '${SupportedClientAuthenticationMethod.ClientAttestationJwtDpop}' method requires a DPoP proof.`,
370403
})
404+
}
371405

372-
if (clientAttestationJkt !== dpopJwkThumbprint) {
406+
let clientAttestation: VerifiedClientAttestationJwt
407+
try {
408+
clientAttestation = await verifyClientAttestationJwt({
409+
callbacks,
410+
clientAttestationJwt: options.clientAttestationJwt,
411+
now,
412+
})
413+
} catch (error) {
414+
if (error instanceof Oauth2Error) {
373415
throw new Oauth2ServerErrorResponseError(
374416
{
375-
error: Oauth2ErrorCodes.InvalidRequest,
376-
error_description:
377-
'Expected the DPoP JWK thumbprint value to match the JWK thumbprint of the client attestation confirmation JWK. Ensure both DPoP and client attestation use the same key.',
417+
error: Oauth2ErrorCodes.InvalidClient,
418+
error_description: `Error verifying client attestation. ${error.message}`,
378419
},
379-
{
380-
status: 401,
381-
}
420+
{ status: 401, cause: error }
382421
)
383422
}
423+
throw error
384424
}
385425

386-
return verifiedClientAttestation
426+
// Ensure the client id matches with the client id from the session
427+
assertExpectedClientId(options.expectedClientId, clientAttestation.payload.sub)
428+
429+
// draft 09 §7.3: the DPoP public key MUST match the `cnf` JWK of the Client Attestation. This is
430+
// mandatory for the DPoP-bound method (not gated on `ensureConfirmationKeyMatchesDpopKey`).
431+
await assertConfirmationKeyMatchesDpopKey(clientAttestation.payload.cnf.jwk, dpopJwkThumbprint, callbacks)
432+
433+
return { clientAttestation }
434+
}
435+
436+
function assertExpectedClientId(expectedClientId: string | undefined, sub: string) {
437+
if (expectedClientId && expectedClientId !== sub) {
438+
throw new Oauth2ServerErrorResponseError(
439+
{
440+
error: Oauth2ErrorCodes.InvalidClient,
441+
error_description: `The client id '${sub}' in the client attestation does not match the client id for the authorization.`,
442+
},
443+
{
444+
status: 401,
445+
}
446+
)
447+
}
448+
}
449+
450+
async function assertConfirmationKeyMatchesDpopKey(
451+
confirmationJwk: Jwk,
452+
dpopJwkThumbprint: string,
453+
callbacks: Pick<CallbackContext, 'hash'>
454+
) {
455+
const clientAttestationJkt = await calculateJwkThumbprint({
456+
hashAlgorithm: HashAlgorithm.Sha256,
457+
hashCallback: callbacks.hash,
458+
jwk: confirmationJwk,
459+
})
460+
461+
if (clientAttestationJkt !== dpopJwkThumbprint) {
462+
throw new Oauth2ServerErrorResponseError(
463+
{
464+
error: Oauth2ErrorCodes.InvalidRequest,
465+
error_description:
466+
'Expected the DPoP JWK thumbprint value to match the JWK thumbprint of the client attestation confirmation JWK. Ensure both DPoP and client attestation use the same key.',
467+
},
468+
{
469+
status: 401,
470+
}
471+
)
472+
}
387473
}
388474

389475
async function verifyAccessTokenRequestDpop(
@@ -406,6 +492,7 @@ async function verifyAccessTokenRequestDpop(
406492
request,
407493
allowedSigningAlgs: options.allowedSigningAlgs,
408494
expectedJwkThumbprint: options.expectedJwkThumbprint,
495+
expectedNonce: options.expectedNonce,
409496
})
410497

411498
return {

packages/oauth2/src/authorization-request/parse-authorization-request.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,12 @@ export interface ParseAuthorizationRequestResult {
3939
*/
4040
clientAttestation?: {
4141
clientAttestationJwt: string
42-
clientAttestationPopJwt: string
42+
43+
/**
44+
* Absent for the DPoP-bound `attest_jwt_client_auth_dpop` method (draft 09), where the DPoP proof
45+
* serves as the Client Attestation PoP.
46+
*/
47+
clientAttestationPopJwt?: string
4348
}
4449
}
4550

0 commit comments

Comments
 (0)