Skip to content

Commit 33adaf0

Browse files
authored
feat(oauth2): bring wallet attestations to draft 9 (#255)
Signed-off-by: Henrique Dias <mail@hacdias.com>
1 parent 78cdbca commit 33adaf0

9 files changed

Lines changed: 238 additions & 41 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@openid4vc/oauth2": patch
3+
---
4+
5+
Align wallet (client) attestation with draft 09 of OAuth 2.0 Attestation-Based Client Authentication.
6+
7+
- Client Attestation and Client Attestation PoP JWTs no longer emit the `iss` claim (removed in draft 08). Verification still accepts legacy JWTs that include `iss`.
8+
- The Client Attestation PoP JWT uses the `challenge` claim (renamed from `nonce` in draft 06) and no longer includes `exp` (removed in draft 06). Verification accepts either `challenge` or the legacy `nonce`. The `nonce`/`expectedNonce` options are deprecated aliases for `challenge`/`expectedChallenge`.
9+
- Added authorization server metadata parameters `client_attestation_signing_alg_values_supported` and `client_attestation_pop_signing_alg_values_supported` (draft 07), the `challenge_endpoint` parameter, the `attest_jwt_client_auth_dpop` authentication method, and the `OAuth-Client-Attestation-Challenge` header (draft 09).
10+
- `verifyClientAttestationPopJwt` accepts an `expectedAudience` option so a resource server can verify a PoP JWT bound to its own identifier (draft 09).

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ An implementation of the [OAuth 2.0 Authorization Framework](https://datatracker
3737
- [RFC 7662 - OAuth 2.0 Token Introspection](https://datatracker.ietf.org/doc/html/rfc7662)
3838
- [RFC 9068 JSON Web Token (JWT) Profile for OAuth 2.0 Access Tokens](https://datatracker.ietf.org/doc/html/rfc9068)
3939
- [RFC 8707 - Resource Indicators for OAuth 2.0](https://www.rfc-editor.org/rfc/rfc8707.html)
40-
- [OAuth 2.0 Attestation-Based Client Authentication](https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-07.html)
40+
- [OAuth 2.0 Attestation-Based Client Authentication](https://www.ietf.org/archive/id/draft-ietf-oauth-attestation-based-client-auth-09.html)
4141
- [RFC 9207 - OAuth 2.0 Authorization Server Issuer Identification](https://www.rfc-editor.org/rfc/rfc9207.html)
4242

4343
```ts
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import * as jose from 'jose'
2+
import { beforeAll, describe, expect, test } from 'vitest'
3+
import { callbacks, getSignJwtCallback } from '../../../tests/util.mjs'
4+
import type { Jwk } from '../../common/jwk/z-jwk'
5+
import { decodeJwt } from '../../common/jwt/decode-jwt'
6+
import { createClientAttestationJwt } from '../client-attestation'
7+
import { createClientAttestationPopJwt, verifyClientAttestationPopJwt } from '../client-attestation-pop'
8+
import {
9+
zClientAttestationJwtHeader,
10+
zClientAttestationJwtPayload,
11+
zClientAttestationPopJwtHeader,
12+
zClientAttestationPopJwtPayload,
13+
} from '../z-client-attestation'
14+
15+
const authorizationServer = 'https://oauth2-auth-server.com'
16+
17+
async function generateEs256Key() {
18+
const { publicKey, privateKey } = await jose.generateKeyPair('ES256', { extractable: true })
19+
return {
20+
privateJwk: (await jose.exportJWK(privateKey)) as Jwk,
21+
publicJwk: (await jose.exportJWK(publicKey)) as Jwk,
22+
}
23+
}
24+
25+
describe('Client (Wallet) Attestation', () => {
26+
// Attester signs the client attestation; the client instance holds the `cnf` key and signs the PoP.
27+
let attester: Awaited<ReturnType<typeof generateEs256Key>>
28+
let instance: Awaited<ReturnType<typeof generateEs256Key>>
29+
let signJwt: ReturnType<typeof getSignJwtCallback>
30+
let clientAttestationJwt: string
31+
let clientAttestation: ReturnType<
32+
typeof decodeJwt<typeof zClientAttestationJwtHeader, typeof zClientAttestationJwtPayload>
33+
>
34+
35+
beforeAll(async () => {
36+
attester = await generateEs256Key()
37+
instance = await generateEs256Key()
38+
signJwt = getSignJwtCallback([attester.privateJwk, instance.privateJwk])
39+
40+
clientAttestationJwt = await createClientAttestationJwt({
41+
callbacks: { signJwt },
42+
clientId: 'wallet',
43+
confirmation: { jwk: instance.publicJwk },
44+
expiresAt: new Date(Date.now() + 3600 * 1000),
45+
signer: { method: 'jwk', alg: 'ES256', publicJwk: attester.publicJwk },
46+
})
47+
48+
clientAttestation = decodeJwt({
49+
jwt: clientAttestationJwt,
50+
headerSchema: zClientAttestationJwtHeader,
51+
payloadSchema: zClientAttestationJwtPayload,
52+
})
53+
})
54+
55+
test('creates a draft-09 Client Attestation JWT without an `iss` claim', () => {
56+
expect(clientAttestation.payload.iss).toBeUndefined()
57+
expect(clientAttestation.payload.sub).toBe('wallet')
58+
expect(clientAttestation.payload.cnf.jwk).toEqual(instance.publicJwk)
59+
})
60+
61+
test('creates a draft-09 PoP JWT (no `iss`, no `exp`, uses `challenge`) that verifies', async () => {
62+
const clientAttestationPopJwt = await createClientAttestationPopJwt({
63+
callbacks: { signJwt, generateRandom: callbacks.generateRandom },
64+
authorizationServer,
65+
clientAttestation: clientAttestationJwt,
66+
challenge: 'challenge-123',
67+
})
68+
69+
const { payload } = decodeJwt({
70+
jwt: clientAttestationPopJwt,
71+
headerSchema: zClientAttestationPopJwtHeader,
72+
payloadSchema: zClientAttestationPopJwtPayload,
73+
})
74+
expect(payload.iss).toBeUndefined()
75+
expect(payload.exp).toBeUndefined()
76+
expect(payload.nonce).toBeUndefined()
77+
expect(payload.challenge).toBe('challenge-123')
78+
expect(payload.aud).toBe(authorizationServer)
79+
expect(payload.jti).toEqual(expect.any(String))
80+
81+
await expect(
82+
verifyClientAttestationPopJwt({
83+
callbacks: { verifyJwt: callbacks.verifyJwt },
84+
authorizationServer,
85+
clientAttestation,
86+
clientAttestationPopJwt,
87+
expectedChallenge: 'challenge-123',
88+
})
89+
).resolves.toBeDefined()
90+
})
91+
92+
test('verifies a legacy (<= draft 07) PoP JWT carrying `iss` and `nonce`', async () => {
93+
const now = Math.floor(Date.now() / 1000)
94+
const { jwt: legacyPopJwt } = await signJwt(
95+
{ method: 'jwk', alg: 'ES256', publicJwk: instance.publicJwk },
96+
{
97+
header: { typ: 'oauth-client-attestation-pop+jwt', alg: 'ES256' },
98+
payload: {
99+
iss: 'wallet',
100+
aud: authorizationServer,
101+
iat: now,
102+
exp: now + 300,
103+
jti: 'legacy-jti',
104+
nonce: 'legacy-nonce',
105+
},
106+
}
107+
)
108+
109+
await expect(
110+
verifyClientAttestationPopJwt({
111+
callbacks: { verifyJwt: callbacks.verifyJwt },
112+
authorizationServer,
113+
clientAttestation,
114+
clientAttestationPopJwt: legacyPopJwt,
115+
// `expectedNonce` is the deprecated alias for `expectedChallenge`
116+
expectedNonce: 'legacy-nonce',
117+
})
118+
).resolves.toBeDefined()
119+
})
120+
121+
test('rejects a legacy PoP JWT whose `iss` does not match the attestation `sub`', async () => {
122+
const now = Math.floor(Date.now() / 1000)
123+
const { jwt: legacyPopJwt } = await signJwt(
124+
{ method: 'jwk', alg: 'ES256', publicJwk: instance.publicJwk },
125+
{
126+
header: { typ: 'oauth-client-attestation-pop+jwt', alg: 'ES256' },
127+
payload: {
128+
iss: 'not-wallet',
129+
aud: authorizationServer,
130+
iat: now,
131+
jti: 'legacy-jti',
132+
},
133+
}
134+
)
135+
136+
await expect(
137+
verifyClientAttestationPopJwt({
138+
callbacks: { verifyJwt: callbacks.verifyJwt },
139+
authorizationServer,
140+
clientAttestation,
141+
clientAttestationPopJwt: legacyPopJwt,
142+
})
143+
).rejects.toThrow("'iss'")
144+
})
145+
})

packages/oauth2/src/client-attestation/client-attestation-pop.ts

Lines changed: 46 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addSecondsToDate, dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@openid4vc/utils'
1+
import { dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@openid4vc/utils'
22
import type { CallbackContext } from '../callbacks'
33
import { decodeJwt } from '../common/jwt/decode-jwt'
44
import { verifyJwt } from '../common/jwt/verify-jwt'
@@ -19,16 +19,14 @@ import {
1919

2020
export interface RequestClientAttestationOptions {
2121
/**
22-
* Dpop nonce to use for constructing the client attestation pop jwt
22+
* The challenge provided by the authorization server to include in the client attestation pop jwt.
2323
*/
24-
nonce?: string
24+
challenge?: string
2525

2626
/**
27-
* Expiration time of the client attestation pop jwt.
28-
*
29-
* @default 5 minutes after issuance date
27+
* @deprecated Renamed to `challenge` in draft 06. If `challenge` is not set, this value is used.
3028
*/
31-
expiresAt?: Date
29+
nonce?: string
3230

3331
/**
3432
* The client attestation jwt to create the pop for.
@@ -53,10 +51,9 @@ export async function createClientAttestationForRequest(
5351
authorizationServer: options.authorizationServer,
5452
clientAttestation: options.clientAttestation.jwt,
5553
callbacks: options.callbacks,
56-
expiresAt: options.clientAttestation.expiresAt,
5754
signer: options.clientAttestation.signer,
58-
// TODO: support dynamic fetching of the nonce
59-
nonce: options.clientAttestation.nonce,
55+
// TODO: support dynamic fetching of the challenge from the `challenge_endpoint`
56+
challenge: options.clientAttestation.challenge ?? options.clientAttestation.nonce,
6057
})
6158

6259
return {
@@ -74,12 +71,29 @@ export interface VerifyClientAttestationPopJwtOptions {
7471
clientAttestationPopJwt: string
7572

7673
/**
77-
* The issuer identifier of the authorization server handling the client attestation
74+
* The issuer identifier of the authorization server handling the client attestation.
7875
*/
7976
authorizationServer: string
8077

8178
/**
82-
* Expected nonce in the payload. If not provided the nonce won't be validated.
79+
* The expected value of the `aud` claim. Defaults to `authorizationServer`.
80+
*
81+
* draft 09 allows the audience to be a Resource Server identifier URL in addition to the
82+
* authorization server issuer URL; set this when verifying a PoP JWT at a resource server.
83+
*/
84+
expectedAudience?: string
85+
86+
/**
87+
* Expected challenge in the payload. If not provided the challenge won't be validated.
88+
*
89+
* Matched against the `challenge` claim (draft 06+) and, for backwards compatibility,
90+
* the legacy `nonce` claim.
91+
*/
92+
expectedChallenge?: string
93+
94+
/**
95+
* @deprecated Renamed to `expectedChallenge` in draft 06. If `expectedChallenge` is not set,
96+
* this value is used.
8397
*/
8498
expectedNonce?: string
8599

@@ -118,12 +132,20 @@ export async function verifyClientAttestationPopJwt(options: VerifyClientAttesta
118132
payloadSchema: zClientAttestationPopJwtPayload,
119133
})
120134

121-
if (payload.iss !== options.clientAttestation.payload.sub) {
135+
// `iss` was removed from the Client Attestation PoP JWT in draft 08. Only validate it against
136+
// the client attestation `sub` when a legacy (<= draft 07) PoP JWT still includes it.
137+
if (payload.iss !== undefined && payload.iss !== options.clientAttestation.payload.sub) {
122138
throw new Oauth2Error(
123139
`Client Attestation Pop jwt contains 'iss' (client_id) value '${payload.iss}', but expected 'sub' value from client attestation '${options.clientAttestation.payload.sub}'`
124140
)
125141
}
126142

143+
// `challenge` (draft 06+) replaced `nonce`. Accept either claim for backwards compatibility.
144+
const expectedChallenge = options.expectedChallenge ?? options.expectedNonce
145+
if (expectedChallenge !== undefined && expectedChallenge !== (payload.challenge ?? payload.nonce)) {
146+
throw new Oauth2Error("Client Attestation Pop jwt 'challenge' does not match expected value.")
147+
}
148+
127149
const { signer } = await verifyJwt({
128150
signer: {
129151
alg: header.alg,
@@ -132,9 +154,8 @@ export async function verifyClientAttestationPopJwt(options: VerifyClientAttesta
132154
},
133155
now: options.now,
134156
header,
135-
expectedNonce: options.expectedNonce,
136157
payload,
137-
expectedAudience: options.authorizationServer,
158+
expectedAudience: options.expectedAudience ?? options.authorizationServer,
138159
compact: options.clientAttestationPopJwt,
139160
verifyJwtCallback: options.callbacks.verifyJwt,
140161
errorMessage: 'client attestation pop jwt verification failed',
@@ -150,7 +171,12 @@ export async function verifyClientAttestationPopJwt(options: VerifyClientAttesta
150171

151172
export interface CreateClientAttestationPopJwtOptions {
152173
/**
153-
* Client attestation Pop nonce value
174+
* The challenge provided by the authorization server to include in the client attestation pop jwt.
175+
*/
176+
challenge?: string
177+
178+
/**
179+
* @deprecated Renamed to `challenge` in draft 06. If `challenge` is not set, this value is used.
154180
*/
155181
nonce?: string
156182

@@ -164,11 +190,6 @@ export interface CreateClientAttestationPopJwtOptions {
164190
*/
165191
issuedAt?: Date
166192

167-
/**
168-
* Expiration time of the JWT. If not proided 1 minute will be added to the `issuedAt`
169-
*/
170-
expiresAt?: Date
171-
172193
/**
173194
* The client attestation to create the Pop for
174195
*/
@@ -212,15 +233,13 @@ export async function createClientAttestationPopJwt(options: CreateClientAttesta
212233
alg: signer.alg,
213234
} satisfies ClientAttestationPopJwtHeader)
214235

215-
const expiresAt = options.expiresAt ?? addSecondsToDate(options.issuedAt ?? new Date(), 1 * 60)
216-
236+
// `iss` (removed in draft 08) and `exp` (removed in draft 06) are no longer part of the
237+
// Client Attestation PoP JWT. `challenge` (draft 06+) replaces the legacy `nonce`.
217238
const payload = parseWithErrorHandling(zClientAttestationPopJwtPayload, {
218239
aud: options.authorizationServer,
219-
iss: clientAttestation.payload.sub,
220-
iat: dateToSeconds(options.issuedAt),
221-
exp: dateToSeconds(expiresAt),
240+
iat: dateToSeconds(options.issuedAt ?? new Date()),
222241
jti: encodeToBase64Url(await options.callbacks.generateRandom(32)),
223-
nonce: options.nonce,
242+
challenge: options.challenge ?? options.nonce,
224243
...options.additionalPayload,
225244
} satisfies ClientAttestationPopJwtPayload)
226245

packages/oauth2/src/client-attestation/client-attestation.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,13 @@ export interface CreateClientAttestationJwtOptions {
8080
expiresAt: Date
8181

8282
/**
83-
* Issuer of the client attestation, usually identifier of the client backend
83+
* Issuer of the client attestation, usually the identifier of the client backend (attester).
84+
*
85+
* The `iss` claim was removed from the Client Attestation JWT in draft 08, so it is only
86+
* included in the payload when this option is provided. It is also useful for interoperability
87+
* with <= draft 07 verifiers and when the KID is a relative DID URL to the issuer.
8488
*/
85-
issuer: string
89+
issuer?: string
8690

8791
/**
8892
* The client id of the client instance.
@@ -118,7 +122,9 @@ export async function createClientAttestationJwt(options: CreateClientAttestatio
118122
} satisfies ClientAttestationJwtHeader)
119123

120124
const payload = parseWithErrorHandling(zClientAttestationJwtPayload, {
121-
iss: options.issuer,
125+
// `iss` was removed from the Client Attestation JWT in draft 08. Only include it when a
126+
// legacy `issuer` is explicitly provided.
127+
...(options.issuer ? { iss: options.issuer } : {}),
122128
iat: dateToSeconds(options.issuedAt),
123129
exp: dateToSeconds(options.expiresAt),
124130
sub: options.clientId,

packages/oauth2/src/client-attestation/z-client-attestation.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ export const oauthClientAttestationHeader = zOauthClientAttestationHeader.value
99
export const zClientAttestationJwtPayload = z
1010
.object({
1111
...zJwtPayload.shape,
12-
iss: z.string(),
1312
sub: z.string(),
1413
exp: zNumericDate,
1514
cnf: z
@@ -37,13 +36,20 @@ export type ClientAttestationJwtHeader = z.infer<typeof zClientAttestationJwtHea
3736
export const zOauthClientAttestationPopHeader = z.literal('OAuth-Client-Attestation-PoP')
3837
export const oauthClientAttestationPopHeader = zOauthClientAttestationPopHeader.value
3938

39+
// draft 09: header used by the authorization/resource server to provide a fresh challenge.
40+
export const zOauthClientAttestationChallengeHeader = z.literal('OAuth-Client-Attestation-Challenge')
41+
export const oauthClientAttestationChallengeHeader = zOauthClientAttestationChallengeHeader.value
42+
4043
export const zClientAttestationPopJwtPayload = z
4144
.object({
4245
...zJwtPayload.shape,
43-
iss: z.string(),
4446
aud: z.union([zHttpsUrl, z.array(zHttpsUrl)]),
4547

4648
jti: z.string(),
49+
50+
// `challenge` (draft 06+) replaced `nonce`. Both are accepted on verification; `nonce`
51+
// is retained only for backwards compatibility with <= draft 05 PoP JWTs.
52+
challenge: z.optional(z.string()),
4753
nonce: z.optional(z.string()),
4854
})
4955
.loose()

0 commit comments

Comments
 (0)