Skip to content

Commit 1462464

Browse files
committed
feat(oauth2): add client attestation challenge utilities
Signed-off-by: Henrique Dias <mail@hacdias.com>
1 parent 9c4c66c commit 1462464

9 files changed

Lines changed: 572 additions & 79 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 `Oauth2Client.requestClientAttestationChallenge` to fetch a Client Attestation challenge from the authorization server's `challenge_endpoint`, and support the `use_attestation_challenge` reactive challenge retry (draft 09) so the challenge is automatically included in the Client Attestation PoP when the server requests one.

packages/oauth2/src/Oauth2Client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,10 @@ import {
2323
verifyAuthorizationResponse,
2424
} from './authorization-response/verify-authorization-response'
2525
import type { CallbackContext } from './callbacks'
26+
import {
27+
type RequestClientAttestationChallengeOptions,
28+
requestClientAttestationChallenge,
29+
} from './client-attestation/client-attestation-challenge'
2630
import { SupportedClientAuthenticationMethod } from './client-authentication'
2731
import { Oauth2ErrorCodes } from './common/z-oauth2-error'
2832
import { extractDpopNonceFromHeaders } from './dpop/dpop'
@@ -166,6 +170,18 @@ export class Oauth2Client {
166170
})
167171
}
168172

173+
/**
174+
* Request a fresh Client Attestation challenge from the authorization server's `challenge_endpoint`
175+
* (draft 09). The returned challenge can be passed to `clientAuthenticationClientAttestationJwt` so
176+
* it is included in the Client Attestation PoP JWT.
177+
*/
178+
public requestClientAttestationChallenge(options: Omit<RequestClientAttestationChallengeOptions, 'callbacks'>) {
179+
return requestClientAttestationChallenge({
180+
...options,
181+
callbacks: this.options.callbacks,
182+
})
183+
}
184+
169185
public async createAuthorizationRequestUrl(options: Omit<CreateAuthorizationRequestUrlOptions, 'callbacks'>) {
170186
return createAuthorizationRequestUrl({
171187
authorizationServerMetadata: options.authorizationServerMetadata,

packages/oauth2/src/access-token/retrieve-access-token.ts

Lines changed: 91 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ import {
88
} from '@openid4vc/utils'
99
import { ValidationError } from '../../../utils/src/error/ValidationError'
1010
import type { CallbackContext } from '../callbacks'
11+
import {
12+
authorizationServerRequestWithClientAttestationChallengeRetry,
13+
extractClientAttestationChallengeFromHeaders,
14+
} from '../client-attestation/client-attestation-challenge'
1115
import { createDpopHeadersForRequest, extractDpopNonceFromHeaders, type RequestDpopOptions } from '../dpop/dpop'
1216
import { authorizationServerRequestWithDpopRetry } from '../dpop/dpop-retry'
1317
import { Oauth2ClientErrorResponseError } from '../error/Oauth2ClientErrorResponseError'
@@ -31,6 +35,13 @@ import {
3135
export interface RetrieveAccessTokenReturn {
3236
accessTokenResponse: AccessTokenResponse
3337
dpop?: RequestDpopOptions
38+
39+
/**
40+
* A fresh Client Attestation challenge provided by the authorization server in the
41+
* `OAuth-Client-Attestation-Challenge` response header (draft 09 §6.2). If present, the client
42+
* should use this challenge for the next Client Attestation PoP JWT.
43+
*/
44+
attestationChallenge?: string
3445
}
3546

3647
interface RetrieveAccessTokenBaseOptions {
@@ -231,83 +242,88 @@ async function retrieveAccessToken(options: RetrieveAccessTokenOptions): Promise
231242
accessTokenRequest.user_pin = accessTokenRequest.tx_code
232243
}
233244

234-
return await authorizationServerRequestWithDpopRetry({
235-
dpop: options.dpop,
236-
request: async (dpop) => {
237-
const dpopHeaders = dpop
238-
? await createDpopHeadersForRequest({
239-
request: {
240-
method: 'POST',
241-
url: options.authorizationServerMetadata.token_endpoint,
242-
},
243-
signer: dpop.signer,
244-
callbacks: options.callbacks,
245-
nonce: dpop.nonce,
245+
return await authorizationServerRequestWithClientAttestationChallengeRetry({
246+
request: (attestationChallenge) =>
247+
authorizationServerRequestWithDpopRetry({
248+
dpop: options.dpop,
249+
request: async (dpop) => {
250+
const dpopHeaders = dpop
251+
? await createDpopHeadersForRequest({
252+
request: {
253+
method: 'POST',
254+
url: options.authorizationServerMetadata.token_endpoint,
255+
},
256+
signer: dpop.signer,
257+
callbacks: options.callbacks,
258+
nonce: dpop.nonce,
259+
})
260+
: undefined
261+
262+
const headers = new Headers({
263+
'Content-Type': ContentType.XWwwFormUrlencoded,
264+
...dpopHeaders,
246265
})
247-
: undefined
248-
249-
const headers = new Headers({
250-
'Content-Type': ContentType.XWwwFormUrlencoded,
251-
...dpopHeaders,
252-
})
253-
254-
// Apply client authentication
255-
await options.callbacks.clientAuthentication({
256-
url: options.authorizationServerMetadata.token_endpoint,
257-
method: 'POST',
258-
authorizationServerMetadata: options.authorizationServerMetadata,
259-
body: accessTokenRequest,
260-
contentType: ContentType.XWwwFormUrlencoded,
261-
headers,
262-
})
263-
264-
const { response, result } = await fetchWithZod(
265-
zAccessTokenResponse,
266-
ContentType.Json,
267-
options.authorizationServerMetadata.token_endpoint,
268-
{
269-
body: objectToQueryParams(accessTokenRequest).toString(),
270-
method: 'POST',
271-
headers,
272-
}
273-
)
274-
275-
if (!response.ok || !result) {
276-
const tokenErrorResponse = zAccessTokenErrorResponse.safeParse(
277-
await response
278-
.clone()
279-
.json()
280-
.catch(() => null)
281-
)
282-
if (tokenErrorResponse.success) {
283-
throw new Oauth2ClientErrorResponseError(
284-
`Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received token error response with status ${response.status}`,
285-
tokenErrorResponse.data,
286-
response
266+
267+
// Apply client authentication
268+
await options.callbacks.clientAuthentication({
269+
url: options.authorizationServerMetadata.token_endpoint,
270+
method: 'POST',
271+
authorizationServerMetadata: options.authorizationServerMetadata,
272+
body: accessTokenRequest,
273+
contentType: ContentType.XWwwFormUrlencoded,
274+
headers,
275+
attestationChallenge,
276+
})
277+
278+
const { response, result } = await fetchWithZod(
279+
zAccessTokenResponse,
280+
ContentType.Json,
281+
options.authorizationServerMetadata.token_endpoint,
282+
{
283+
body: objectToQueryParams(accessTokenRequest).toString(),
284+
method: 'POST',
285+
headers,
286+
}
287287
)
288-
}
289-
290-
throw new InvalidFetchResponseError(
291-
`Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received response with status ${response.status}`,
292-
await response.clone().text(),
293-
response
294-
)
295-
}
296-
297-
if (!result.success) {
298-
throw new ValidationError('Error validating access token response', result.error)
299-
}
300-
301-
const dpopNonce = extractDpopNonceFromHeaders(response.headers) ?? undefined
302-
return {
303-
dpop: dpop
304-
? {
305-
...dpop,
306-
nonce: dpopNonce,
288+
289+
if (!response.ok || !result) {
290+
const tokenErrorResponse = zAccessTokenErrorResponse.safeParse(
291+
await response
292+
.clone()
293+
.json()
294+
.catch(() => null)
295+
)
296+
if (tokenErrorResponse.success) {
297+
throw new Oauth2ClientErrorResponseError(
298+
`Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received token error response with status ${response.status}`,
299+
tokenErrorResponse.data,
300+
response
301+
)
307302
}
308-
: undefined,
309-
accessTokenResponse: result.data,
310-
}
311-
},
303+
304+
throw new InvalidFetchResponseError(
305+
`Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received response with status ${response.status}`,
306+
await response.clone().text(),
307+
response
308+
)
309+
}
310+
311+
if (!result.success) {
312+
throw new ValidationError('Error validating access token response', result.error)
313+
}
314+
315+
const dpopNonce = extractDpopNonceFromHeaders(response.headers) ?? undefined
316+
return {
317+
dpop: dpop
318+
? {
319+
...dpop,
320+
nonce: dpopNonce,
321+
}
322+
: undefined,
323+
attestationChallenge: extractClientAttestationChallengeFromHeaders(response.headers) ?? undefined,
324+
accessTokenResponse: result.data,
325+
}
326+
},
327+
}),
312328
})
313329
}

0 commit comments

Comments
 (0)