Skip to content

Commit b45629f

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

9 files changed

Lines changed: 525 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: 80 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
} from '@openid4vc/utils'
99
import { ValidationError } from '../../../utils/src/error/ValidationError'
1010
import type { CallbackContext } from '../callbacks'
11+
import { authorizationServerRequestWithClientAttestationChallengeRetry } from '../client-attestation/client-attestation-challenge'
1112
import { createDpopHeadersForRequest, extractDpopNonceFromHeaders, type RequestDpopOptions } from '../dpop/dpop'
1213
import { authorizationServerRequestWithDpopRetry } from '../dpop/dpop-retry'
1314
import { Oauth2ClientErrorResponseError } from '../error/Oauth2ClientErrorResponseError'
@@ -231,83 +232,87 @@ async function retrieveAccessToken(options: RetrieveAccessTokenOptions): Promise
231232
accessTokenRequest.user_pin = accessTokenRequest.tx_code
232233
}
233234

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

0 commit comments

Comments
 (0)