diff --git a/.changeset/brave-apricots-provide.md b/.changeset/brave-apricots-provide.md new file mode 100644 index 0000000..3f52dfb --- /dev/null +++ b/.changeset/brave-apricots-provide.md @@ -0,0 +1,5 @@ +--- +"@animo-id/oauth2": minor +--- + +feat: add client attestations diff --git a/.changeset/old-feet-begin.md b/.changeset/old-feet-begin.md new file mode 100644 index 0000000..797e5de --- /dev/null +++ b/.changeset/old-feet-begin.md @@ -0,0 +1,5 @@ +--- +"@animo-id/oid4vci": minor +--- + +feat: add key attestations diff --git a/packages/oauth2/src/Oauth2AuthorizationServer.ts b/packages/oauth2/src/Oauth2AuthorizationServer.ts index fc90f44..4274961 100644 --- a/packages/oauth2/src/Oauth2AuthorizationServer.ts +++ b/packages/oauth2/src/Oauth2AuthorizationServer.ts @@ -1,4 +1,4 @@ -import { parseWithErrorHandling } from '@animo-id/oauth2-utils' +import { type FetchHeaders, parseWithErrorHandling } from '@animo-id/oauth2-utils' import { type CreateAccessTokenOptions, createAccessTokenJwt } from './access-token/create-access-token' import { type CreateAccessTokenResponseOptions, @@ -22,6 +22,11 @@ import { parseAuthorizationChallengeRequest, } from './authorization-challenge/parse-authorization-challenge-request' import type { CallbackContext } from './callbacks' +import { + extractClientAttestationJwtsFromHeaders, + verifyClientAttestationJwt, +} from './client-attestation/clent-attestation' +import { verifyClientAttestationPopJwt } from './client-attestation/client-attestation-pop' import { Oauth2ErrorCodes } from './common/v-oauth2-error' import { type AuthorizationServerMetadata, @@ -159,4 +164,28 @@ export class Oauth2AuthorizationServer { public createAuthorizationChallengeErrorResponse(options: CreateAuthorizationChallengeErrorResponseOptions) { return createAuthorizationChallengeErrorResponse(options) } + + public async verifyClientAttestation({ + authorizationServer, + headers, + }: { authorizationServer: string; headers: FetchHeaders }) { + const { clientAttestationHeader, clientAttestationPopHeader } = extractClientAttestationJwtsFromHeaders(headers) + + const clientAttestation = await verifyClientAttestationJwt({ + callbacks: this.options.callbacks, + clientAttestationJwt: clientAttestationHeader, + }) + + const clientAttestationPop = await verifyClientAttestationPopJwt({ + callbacks: this.options.callbacks, + authorizationServer, + clientAttestation, + clientAttestationPopJwt: clientAttestationPopHeader, + }) + + return { + clientAttestation, + clientAttestationPop, + } + } } diff --git a/packages/oauth2/src/Oauth2Client.ts b/packages/oauth2/src/Oauth2Client.ts index 614aeba..a4ed38e 100644 --- a/packages/oauth2/src/Oauth2Client.ts +++ b/packages/oauth2/src/Oauth2Client.ts @@ -16,7 +16,12 @@ import { createAuthorizationRequestUrl, } from './authorization-request/create-authorization-request' import type { CallbackContext } from './callbacks' +import { + type CreateClientAttestationJwtOptions, + createClientAttestationJwt, +} from './client-attestation/clent-attestation' import { Oauth2ErrorCodes } from './common/v-oauth2-error' +import { extractDpopNonceFromHeaders } from './dpop/dpop' import { Oauth2ClientAuthorizationChallengeError } from './error/Oauth2ClientAuthorizationChallengeError' import { fetchAuthorizationServerMetadata } from './metadata/authorization-server/authorization-server-metadata' import type { AuthorizationServerMetadata } from './metadata/authorization-server/v-authorization-server-metadata' @@ -84,6 +89,8 @@ export class Oauth2Client { pkceCodeVerifier: pkce?.codeVerifier, scope: options.scope, resource: options.resource, + clientAttestation: options.clientAttestation, + dpop: options.dpop, }) } catch (error) { // In this case we resume with the normal auth flow @@ -102,7 +109,14 @@ export class Oauth2Client { } ).toString()}` + const dpopNonce = extractDpopNonceFromHeaders(error.response.headers) return { + dpop: options.dpop + ? { + ...options.dpop, + nonce: dpopNonce, + } + : undefined, authorizationRequestUrl, pkce, } @@ -118,6 +132,8 @@ export class Oauth2Client { scope: options.scope, pkceCodeVerifier: pkce?.codeVerifier, resource: options.resource, + clientAttestation: options.clientAttestation, + dpop: options.dpop, }) } @@ -138,6 +154,8 @@ export class Oauth2Client { scope: options.scope, callbacks: this.options.callbacks, pkceCodeVerifier: options.pkceCodeVerifier, + clientAttestation: options.clientAttestation, + dpop: options.dpop, }) } @@ -148,6 +166,7 @@ export class Oauth2Client { txCode, dpop, resource, + clientAttestation, }: Omit) { const result = await retrievePreAuthorizedCodeAccessToken({ authorizationServerMetadata, @@ -160,6 +179,7 @@ export class Oauth2Client { }, callbacks: this.options.callbacks, dpop, + clientAttestation, }) return result @@ -173,6 +193,7 @@ export class Oauth2Client { redirectUri, resource, dpop, + clientAttestation, }: Omit) { const result = await retrieveAuthorizationCodeAccessToken({ authorizationServerMetadata, @@ -183,6 +204,7 @@ export class Oauth2Client { callbacks: this.options.callbacks, dpop, redirectUri, + clientAttestation, }) return result @@ -194,6 +216,7 @@ export class Oauth2Client { refreshToken, resource, dpop, + clientAttestation, }: Omit) { const result = await retrieveRefreshTokenAccessToken({ authorizationServerMetadata, @@ -202,6 +225,7 @@ export class Oauth2Client { resource, callbacks: this.options.callbacks, dpop, + clientAttestation, }) return result @@ -210,4 +234,14 @@ export class Oauth2Client { public async resourceRequest(options: ResourceRequestOptions) { return resourceRequest(options) } + + /** + * @todo move this to another class? + */ + public async createClientAttestationJwt(options: Omit) { + return await createClientAttestationJwt({ + callbacks: this.options.callbacks, + ...options, + }) + } } diff --git a/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts b/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts index 72022ed..4bb409b 100644 --- a/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts +++ b/packages/oauth2/src/access-token/__tests__/parse-access-token-request.test.ts @@ -85,7 +85,7 @@ describe('Parse Access Token Request', () => { }, request: { headers: new Headers({ - DPoP: ['hello', 'two'], + DPoP: ['ey.ey.S', 'ey.ey.S'], }), method: 'POST', url: 'https://request.com/token', diff --git a/packages/oauth2/src/access-token/create-access-token.ts b/packages/oauth2/src/access-token/create-access-token.ts index 5a28260..9581738 100644 --- a/packages/oauth2/src/access-token/create-access-token.ts +++ b/packages/oauth2/src/access-token/create-access-token.ts @@ -1,11 +1,16 @@ -import { addSecondsToDate, dateToSeconds, encodeToBase64Url } from '@animo-id/oauth2-utils' +import { addSecondsToDate, dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@animo-id/oauth2-utils' import type { CallbackContext } from '../callbacks' import { HashAlgorithm } from '../callbacks' import { calculateJwkThumbprint } from '../common/jwk/jwk-thumbprint' import type { Jwk } from '../common/jwk/v-jwk' import { jwtHeaderFromJwtSigner } from '../common/jwt/decode-jwt' import type { JwtSigner } from '../common/jwt/v-jwt' -import type { AccessTokenProfileJwtHeader, AccessTokenProfileJwtPayload } from './v-access-token-jwt' +import { + type AccessTokenProfileJwtHeader, + type AccessTokenProfileJwtPayload, + vAccessTokenProfileJwtHeader, + vAccessTokenProfileJwtPayload, +} from './v-access-token-jwt' export interface CreateAccessTokenOptions { callbacks: Pick @@ -70,14 +75,14 @@ export interface CreateAccessTokenOptions { * @see https://datatracker.ietf.org/doc/html/rfc9068 */ export async function createAccessTokenJwt(options: CreateAccessTokenOptions) { - const header = { + const header = parseWithErrorHandling(vAccessTokenProfileJwtHeader, { ...jwtHeaderFromJwtSigner(options.signer), typ: 'at+jwt', - } satisfies AccessTokenProfileJwtHeader + } satisfies AccessTokenProfileJwtHeader) const now = options.now ?? new Date() - const payload: AccessTokenProfileJwtPayload = { + const payload = parseWithErrorHandling(vAccessTokenProfileJwtPayload, { iat: dateToSeconds(now), exp: dateToSeconds(addSecondsToDate(now, options.expiresInSeconds)), aud: options.audience, @@ -86,23 +91,23 @@ export async function createAccessTokenJwt(options: CreateAccessTokenOptions) { client_id: options.clientId, sub: options.subject, scope: options.scope, + cnf: options.dpopJwk + ? { + jkt: await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: options.callbacks.hash, + jwk: options.dpopJwk, + }), + } + : undefined, ...options.additionalPayload, - } - - if (options.dpopJwk) { - payload.cnf = { - jkt: await calculateJwkThumbprint({ - hashAlgorithm: HashAlgorithm.Sha256, - hashCallback: options.callbacks.hash, - jwk: options.dpopJwk, - }), - } - } + } satisfies AccessTokenProfileJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) + return { jwt, } diff --git a/packages/oauth2/src/access-token/retrieve-access-token.ts b/packages/oauth2/src/access-token/retrieve-access-token.ts index 59cc32f..1afdec2 100644 --- a/packages/oauth2/src/access-token/retrieve-access-token.ts +++ b/packages/oauth2/src/access-token/retrieve-access-token.ts @@ -3,8 +3,12 @@ import { InvalidFetchResponseError } from '@animo-id/oauth2-utils' import * as v from 'valibot' import { ValidationError } from '../../../utils/src/error/ValidationError' import type { CallbackContext } from '../callbacks' -import { type RequestDpopOptions, createDpopJwt, extractDpopNonceFromHeaders } from '../dpop/dpop' -import { shouldRetryTokenRequestWithDPoPNonce } from '../dpop/dpop-retry' +import { + type RequestClientAttestationOptions, + createClientAttestationForRequest, +} from '../client-attestation/client-attestation-pop' +import { type RequestDpopOptions, createDpopHeadersForRequest, extractDpopNonceFromHeaders } from '../dpop/dpop' +import { authorizationServerRequestWithDpopRetry } from '../dpop/dpop-retry' import { Oauth2ClientErrorResponseError } from '../error/Oauth2ClientErrorResponseError' import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata' import { @@ -50,6 +54,11 @@ interface RetrieveAccessTokenBaseOptions { * metadata, or the 'alg' value does not match an error will be thrown. */ dpop?: RequestDpopOptions + + /** + * If client attestation needs to be included in the request. + */ + clientAttestation?: RequestClientAttestationOptions } export interface RetrievePreAuthorizedCodeAccessTokenOptions extends RetrieveAccessTokenBaseOptions { @@ -74,14 +83,14 @@ export async function retrievePreAuthorizedCodeAccessToken( ...options.additionalRequestPayload, } satisfies AccessTokenRequest - const accessTokenResponse = await retrieveAccessTokenWithDpopRetry({ + return retrieveAccessToken({ authorizationServerMetadata: options.authorizationServerMetadata, request, dpop: options.dpop, callbacks: options.callbacks, + resource: options.resource, + clientAttestation: options.clientAttestation, }) - - return accessTokenResponse } export interface RetrieveAuthorizationCodeAccessTokenOptions extends RetrieveAccessTokenBaseOptions { @@ -120,14 +129,14 @@ export async function retrieveAuthorizationCodeAccessToken( ...options.additionalRequestPayload, } satisfies AccessTokenRequest - const accessTokenResponse = await retrieveAccessTokenWithDpopRetry({ + return retrieveAccessToken({ authorizationServerMetadata: options.authorizationServerMetadata, request, dpop: options.dpop, + resource: options.resource, callbacks: options.callbacks, + clientAttestation: options.clientAttestation, }) - - return accessTokenResponse } export interface RetrieveRefreshTokenAccessTokenOptions extends RetrieveAccessTokenBaseOptions { @@ -153,14 +162,14 @@ export async function retrieveRefreshTokenAccessToken( ...options.additionalRequestPayload, } satisfies AccessTokenRequest - const accessTokenResponse = await retrieveAccessTokenWithDpopRetry({ + return retrieveAccessToken({ authorizationServerMetadata: options.authorizationServerMetadata, request, dpop: options.dpop, callbacks: options.callbacks, + resource: options.resource, + clientAttestation: options.clientAttestation, }) - - return accessTokenResponse } interface RetrieveAccessTokenOptions extends RetrieveAccessTokenBaseOptions { @@ -176,18 +185,6 @@ interface RetrieveAccessTokenOptions extends RetrieveAccessTokenBaseOptions { async function retrieveAccessToken(options: RetrieveAccessTokenOptions): Promise { const fetchWithValibot = createValibotFetcher(options.callbacks.fetch) - const dpopJwt = options.dpop - ? await createDpopJwt({ - request: { - method: 'POST', - url: options.authorizationServerMetadata.token_endpoint, - }, - signer: options.dpop.signer, - callbacks: options.callbacks, - nonce: options.dpop.nonce, - }) - : undefined - const accessTokenRequest = parseWithErrorHandling( vAccessTokenRequest, options.request, @@ -199,82 +196,85 @@ async function retrieveAccessToken(options: RetrieveAccessTokenOptions): Promise accessTokenRequest.user_pin = accessTokenRequest.tx_code } - const requestQueryParams = objectToQueryParams(accessTokenRequest) - const { response, result } = await fetchWithValibot( - vAccessTokenResponse, - ContentType.Json, - options.authorizationServerMetadata.token_endpoint, - { - body: requestQueryParams.toString(), - method: 'POST', - headers: { - 'Content-Type': ContentType.XWwwFormUrlencoded, - ...(dpopJwt ? { DPoP: dpopJwt } : {}), - }, - } - ) + const clientAttestation = options.clientAttestation + ? await createClientAttestationForRequest({ + authorizationServer: options.authorizationServerMetadata.issuer, + clientAttestation: options.clientAttestation, + callbacks: options.callbacks, + }) + : undefined - if (!response.ok || !result) { - const tokenErrorResponse = v.safeParse( - vAccessTokenErrorResponse, - await response - .clone() - .json() - .catch(() => null) - ) - if (tokenErrorResponse.success) { - throw new Oauth2ClientErrorResponseError( - `Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received token error response with status ${response.status}`, - tokenErrorResponse.output, - response + return await authorizationServerRequestWithDpopRetry({ + dpop: options.dpop, + request: async (dpop) => { + const dpopHeaders = dpop + ? await createDpopHeadersForRequest({ + request: { + method: 'POST', + url: options.authorizationServerMetadata.token_endpoint, + }, + signer: dpop.signer, + callbacks: options.callbacks, + nonce: dpop.nonce, + }) + : undefined + + const requestQueryParams = objectToQueryParams({ + ...accessTokenRequest, + ...clientAttestation?.body, + }) + const { response, result } = await fetchWithValibot( + vAccessTokenResponse, + ContentType.Json, + options.authorizationServerMetadata.token_endpoint, + { + body: requestQueryParams.toString(), + method: 'POST', + headers: { + 'Content-Type': ContentType.XWwwFormUrlencoded, + ...clientAttestation?.headers, + ...dpopHeaders, + }, + } ) - } - - throw new InvalidFetchResponseError( - `Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received response with status ${response.status}`, - await response.clone().text(), - response - ) - } - - if (!result.success) { - throw new ValidationError('Error validating access token response', result.issues) - } - const dpopNonce = extractDpopNonceFromHeaders(response.headers) ?? undefined - return { - dpop: options.dpop - ? { - nonce: dpopNonce, - signer: options.dpop.signer, + if (!response.ok || !result) { + const tokenErrorResponse = v.safeParse( + vAccessTokenErrorResponse, + await response + .clone() + .json() + .catch(() => null) + ) + if (tokenErrorResponse.success) { + throw new Oauth2ClientErrorResponseError( + `Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received token error response with status ${response.status}`, + tokenErrorResponse.output, + response + ) } - : undefined, - accessTokenResponse: result.output, - } -} -async function retrieveAccessTokenWithDpopRetry(options: RetrieveAccessTokenOptions) { - try { - return await retrieveAccessToken(options) - } catch (error) { - if (options.dpop && error instanceof Oauth2ClientErrorResponseError) { - const dpopRetry = shouldRetryTokenRequestWithDPoPNonce({ - responseHeaders: error.response.headers, - tokenErrorResponse: error.errorResponse, - }) + throw new InvalidFetchResponseError( + `Unable to retrieve access token from '${options.authorizationServerMetadata.token_endpoint}'. Received response with status ${response.status}`, + await response.clone().text(), + response + ) + } - // Retry with the dpop nonce - if (dpopRetry.retry) { - return retrieveAccessToken({ - ...options, - dpop: { - ...options.dpop, - nonce: dpopRetry.dpopNonce, - }, - }) + if (!result.success) { + throw new ValidationError('Error validating access token response', result.issues) } - } - throw error - } + const dpopNonce = extractDpopNonceFromHeaders(response.headers) ?? undefined + return { + dpop: dpop + ? { + ...dpop, + nonce: dpopNonce, + } + : undefined, + accessTokenResponse: result.output, + } + }, + }) } diff --git a/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts b/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts index 614b605..ff525db 100644 --- a/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts +++ b/packages/oauth2/src/authorization-challenge/create-authorization-challenge-response.ts @@ -50,7 +50,7 @@ export interface CreateAuthorizationChallengeErrorResponseOptions { * * If you want to require presentation of a */ - error: Oauth2ErrorCodes | StringWithAutoCompletion + error: StringWithAutoCompletion /** * Optional error description diff --git a/packages/oauth2/src/authorization-challenge/send-authorization-challenge.ts b/packages/oauth2/src/authorization-challenge/send-authorization-challenge.ts index 162b61f..a96086d 100644 --- a/packages/oauth2/src/authorization-challenge/send-authorization-challenge.ts +++ b/packages/oauth2/src/authorization-challenge/send-authorization-challenge.ts @@ -8,6 +8,12 @@ import { import { InvalidFetchResponseError } from '@animo-id/oauth2-utils' import * as v from 'valibot' import type { CallbackContext } from '../callbacks' +import { + type RequestClientAttestationOptions, + createClientAttestationForRequest, +} from '../client-attestation/client-attestation-pop' +import { type RequestDpopOptions, createDpopHeadersForRequest, extractDpopNonceFromHeaders } from '../dpop/dpop' +import { authorizationServerRequestWithDpopRetry } from '../dpop/dpop-retry' import { Oauth2ClientAuthorizationChallengeError } from '../error/Oauth2ClientAuthorizationChallengeError' import { Oauth2Error } from '../error/Oauth2Error' import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata' @@ -23,7 +29,7 @@ export interface SendAuthorizationChallengeRequestOptions { /** * Callback context */ - callbacks: Pick + callbacks: Pick /** * Metadata of the authorization server where to perform the authorization challenge @@ -67,6 +73,16 @@ export interface SendAuthorizationChallengeRequestOptions { * Code verifier to use for pkce. If not provided a value will generated when pkce is supported */ pkceCodeVerifier?: string + + /** + * If client attestation needs to be included in the request. + */ + clientAttestation?: RequestClientAttestationOptions + + /** + * DPoP options + */ + dpop?: RequestDpopOptions } /** @@ -80,7 +96,8 @@ export async function sendAuthorizationChallengeRequest(options: SendAuthorizati const fetchWithValibot = createValibotFetcher(options.callbacks.fetch) const authorizationServerMetadata = options.authorizationServerMetadata - if (!authorizationServerMetadata.authorization_challenge_endpoint) { + const authorizationChallengeEndpoint = authorizationServerMetadata.authorization_challenge_endpoint + if (!authorizationChallengeEndpoint) { throw new Oauth2Error( `Unable to send authorization challange. Authorization server '${authorizationServerMetadata.issuer}' has no 'authorization_challenge_endpoint'` ) @@ -97,6 +114,14 @@ export async function sendAuthorizationChallengeRequest(options: SendAuthorizati }) : undefined + const clientAttestation = options.clientAttestation + ? await createClientAttestationForRequest({ + authorizationServer: options.authorizationServerMetadata.issuer, + clientAttestation: options.clientAttestation, + callbacks: options.callbacks, + }) + : undefined + const authorizationChallengeRequest = parseWithErrorHandling(vAuthorizationChallengeRequest, { ...options.additionalRequestPayload, auth_session: options.authSession, @@ -106,47 +131,77 @@ export async function sendAuthorizationChallengeRequest(options: SendAuthorizati code_challenge: pkce?.codeChallenge, code_challenge_method: pkce?.codeChallengeMethod, presentation_during_issuance_session: options.presentationDuringIssuanceSession, + ...clientAttestation?.body, } satisfies AuthorizationChallengeRequest) - const { response, result } = await fetchWithValibot( - vAuthorizationChallengeResponse, - ContentType.Json, - authorizationServerMetadata.authorization_challenge_endpoint, - { - method: 'POST', - body: objectToQueryParams(authorizationChallengeRequest).toString(), - headers: { - 'Content-Type': ContentType.XWwwFormUrlencoded, - }, - } - ) - - if (!response.ok || !result) { - const authorizationChallengeErrorResponse = v.safeParse( - vAuthorizationChallengeErrorResponse, - await response - .clone() - .json() - .catch(() => null) - ) - if (authorizationChallengeErrorResponse.success) { - throw new Oauth2ClientAuthorizationChallengeError( - `Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`, - authorizationChallengeErrorResponse.output, - response + return authorizationServerRequestWithDpopRetry({ + dpop: options.dpop, + request: async (dpop) => { + const dpopHeaders = dpop + ? await createDpopHeadersForRequest({ + request: { + method: 'POST', + url: authorizationChallengeEndpoint, + }, + signer: dpop.signer, + callbacks: options.callbacks, + nonce: dpop.nonce, + }) + : undefined + + const { response, result } = await fetchWithValibot( + vAuthorizationChallengeResponse, + ContentType.Json, + authorizationChallengeEndpoint, + { + method: 'POST', + body: objectToQueryParams(authorizationChallengeRequest).toString(), + headers: { + ...clientAttestation?.headers, + ...dpopHeaders, + 'Content-Type': ContentType.XWwwFormUrlencoded, + }, + } ) - } - - throw new InvalidFetchResponseError( - `Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`, - await response.clone().text(), - response - ) - } - - if (!result.success) { - throw new ValidationError('Error validating authorization challenge response', result.issues) - } - return { pkce, authorizationChallengeResponse: result.output } + if (!response.ok || !result) { + const authorizationChallengeErrorResponse = v.safeParse( + vAuthorizationChallengeErrorResponse, + await response + .clone() + .json() + .catch(() => null) + ) + if (authorizationChallengeErrorResponse.success) { + throw new Oauth2ClientAuthorizationChallengeError( + `Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`, + authorizationChallengeErrorResponse.output, + response + ) + } + + throw new InvalidFetchResponseError( + `Error requesting authorization code from authorization challenge endpoint '${authorizationServerMetadata.authorization_challenge_endpoint}'. Received response with status ${response.status}`, + await response.clone().text(), + response + ) + } + + if (!result.success) { + throw new ValidationError('Error validating authorization challenge response', result.issues) + } + + const dpopNonce = extractDpopNonceFromHeaders(response.headers) ?? undefined + return { + pkce, + dpop: dpop + ? { + ...dpop, + nonce: dpopNonce, + } + : undefined, + authorizationChallengeResponse: result.output, + } + }, + }) } diff --git a/packages/oauth2/src/authorization-request/create-authorization-request.ts b/packages/oauth2/src/authorization-request/create-authorization-request.ts index b8d7e05..a1325f4 100644 --- a/packages/oauth2/src/authorization-request/create-authorization-request.ts +++ b/packages/oauth2/src/authorization-request/create-authorization-request.ts @@ -2,8 +2,15 @@ import { ContentType, type Fetch, createValibotFetcher, objectToQueryParams } fr import { InvalidFetchResponseError } from '@animo-id/oauth2-utils' import * as v from 'valibot' import { ValidationError } from '../../../utils/src/error/ValidationError' -import { vAccessTokenErrorResponse } from '../access-token/v-access-token' -import type { CallbackContext } from '../callbacks' +import { type CallbackContext, HashAlgorithm } from '../callbacks' +import { + type RequestClientAttestationOptions, + createClientAttestationForRequest, +} from '../client-attestation/client-attestation-pop' +import { calculateJwkThumbprint } from '../common/jwk/jwk-thumbprint' +import { vOauth2ErrorResponse } from '../common/v-oauth2-error' +import { type RequestDpopOptions, createDpopHeadersForRequest, extractDpopNonceFromHeaders } from '../dpop/dpop' +import { authorizationServerRequestWithDpopRetry } from '../dpop/dpop-retry' import { Oauth2ClientErrorResponseError } from '../error/Oauth2ClientErrorResponseError' import { Oauth2Error } from '../error/Oauth2Error' import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata' @@ -18,7 +25,7 @@ export interface CreateAuthorizationRequestUrlOptions { /** * Callback context mostly for crypto related functionality */ - callbacks: Pick + callbacks: Pick /** * Metadata of the authorization server for which to create the authorization request url @@ -56,6 +63,20 @@ export interface CreateAuthorizationRequestUrlOptions { * Code verifier to use for pkce. If not provided a value will generated when pkce is supported */ pkceCodeVerifier?: string + + /** + * If client attestation needs to be included in the request. + * + * Will ONLY be used if PAR is used. + */ + clientAttestation?: RequestClientAttestationOptions + + /** + * DPoP options + * + * If PAR is not used only the `dpop_jkt` property will be included in the request + */ + dpop?: RequestDpopOptions } /** @@ -68,6 +89,7 @@ export interface CreateAuthorizationRequestUrlOptions { export async function createAuthorizationRequestUrl(options: CreateAuthorizationRequestUrlOptions) { const authorizationServerMetadata = options.authorizationServerMetadata + const pushedAuthorizationRequestEndpoint = authorizationServerMetadata.pushed_authorization_request_endpoint if (!authorizationServerMetadata.authorization_endpoint) { throw new Oauth2Error( `Unable to create authorization request url. Authorization server '${authorizationServerMetadata.issuer}' has no 'authorization_endpoint'` @@ -94,34 +116,81 @@ export async function createAuthorizationRequestUrl(options: CreateAuthorization code_challenge_method: pkce?.codeChallengeMethod, } let pushedAuthorizationRequest: PushedAuthorizationRequest | undefined = undefined + let dpop: RequestDpopOptions | undefined = options.dpop - if ( - authorizationServerMetadata.require_pushed_authorization_requests || - authorizationServerMetadata.pushed_authorization_request_endpoint - ) { + if (authorizationServerMetadata.require_pushed_authorization_requests || pushedAuthorizationRequestEndpoint) { // Use PAR if supported or required - if (!authorizationServerMetadata.pushed_authorization_request_endpoint) { + if (!pushedAuthorizationRequestEndpoint) { throw new Oauth2Error( `Authorization server '${authorizationServerMetadata.issuer}' indicated that pushed authorization requests are required, but the 'pushed_authorization_request_endpoint' is missing in the authorization server metadata.` ) } - const { request_uri } = await pushAuthorizationRequest({ - authorizationRequest, - pushedAuthorizationRequestEndpoint: authorizationServerMetadata.pushed_authorization_request_endpoint, - fetch: options.callbacks.fetch, + const clientAttestation = options.clientAttestation + ? await createClientAttestationForRequest({ + authorizationServer: options.authorizationServerMetadata.issuer, + clientAttestation: options.clientAttestation, + callbacks: options.callbacks, + }) + : undefined + + const { pushedAuthorizationResponse, dpopNonce } = await authorizationServerRequestWithDpopRetry({ + dpop: options.dpop, + request: async (dpop) => { + const dpopHeaders = dpop + ? await createDpopHeadersForRequest({ + request: { + method: 'POST', + url: pushedAuthorizationRequestEndpoint, + }, + signer: dpop.signer, + callbacks: options.callbacks, + nonce: dpop.nonce, + }) + : undefined + + return await pushAuthorizationRequest({ + authorizationRequest: { + ...authorizationRequest, + ...clientAttestation?.headers, + }, + pushedAuthorizationRequestEndpoint, + fetch: options.callbacks.fetch, + headers: { + ...clientAttestation?.headers, + ...dpopHeaders, + }, + }) + }, }) pushedAuthorizationRequest = { - request_uri, + request_uri: pushedAuthorizationResponse.request_uri, client_id: authorizationRequest.client_id, } + + if (options.dpop && dpopNonce) { + dpop = { + ...options.dpop, + nonce: dpopNonce, + } + } + } else { + // If not using PAR but dpop we include the `dpop_jkt` option + if (options.dpop) { + authorizationRequest.dpop_jkt = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: options.callbacks.hash, + jwk: options.dpop.signer.publicJwk, + }) + } } const authorizationRequestUrl = `${authorizationServerMetadata.authorization_endpoint}?${objectToQueryParams(pushedAuthorizationRequest ?? authorizationRequest).toString()}` return { authorizationRequestUrl, pkce, + dpop, } } @@ -129,6 +198,11 @@ interface PushAuthorizationRequestOptions { pushedAuthorizationRequestEndpoint: string authorizationRequest: AuthorizationRequest + /** + * Headers to include in the PAR request + */ + headers?: Record + /** * Custom fetch implementation to use */ @@ -152,6 +226,7 @@ async function pushAuthorizationRequest(options: PushAuthorizationRequestOptions method: 'POST', body: objectToQueryParams(options.authorizationRequest).toString(), headers: { + ...options.headers, 'Content-Type': ContentType.XWwwFormUrlencoded, }, } @@ -159,7 +234,7 @@ async function pushAuthorizationRequest(options: PushAuthorizationRequestOptions if (!response.ok || !result) { const parErrorResponse = v.safeParse( - vAccessTokenErrorResponse, + vOauth2ErrorResponse, await response .clone() .json() @@ -184,5 +259,9 @@ async function pushAuthorizationRequest(options: PushAuthorizationRequestOptions throw new ValidationError('Error validating pushed authorization response', result.issues) } - return result.output + const dpopNonce = extractDpopNonceFromHeaders(response.headers) + return { + dpopNonce, + pushedAuthorizationResponse: result.output, + } } diff --git a/packages/oauth2/src/authorization-request/v-authorization-request.ts b/packages/oauth2/src/authorization-request/v-authorization-request.ts index db2b499..0109c51 100644 --- a/packages/oauth2/src/authorization-request/v-authorization-request.ts +++ b/packages/oauth2/src/authorization-request/v-authorization-request.ts @@ -12,6 +12,9 @@ export const vAuthorizationRequest = v.looseObject({ resource: v.optional(vHttpsUrl), scope: v.optional(v.string()), + // DPoP jwk thumbprint + dpop_jkt: v.optional(v.string()), + code_challenge: v.optional(v.string()), code_challenge_method: v.optional(v.string()), }) diff --git a/packages/oauth2/src/callbacks.ts b/packages/oauth2/src/callbacks.ts index 93d03ab..4c0c19d 100644 --- a/packages/oauth2/src/callbacks.ts +++ b/packages/oauth2/src/callbacks.ts @@ -1,5 +1,6 @@ -import type { Fetch } from '@animo-id/oauth2-utils' +import type { Fetch, OrPromise } from '@animo-id/oauth2-utils' import type { ClientAuthenticationCallback } from './client-authentication' +import type { Jwk } from './common/jwk/v-jwk' import type { JwtHeader, JwtPayload, JwtSigner } from './common/jwt/v-jwt' /** @@ -12,19 +13,31 @@ export enum HashAlgorithm { /** * Callback used for operations that require hashing */ -export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => Promise | Uint8Array +export type HashCallback = (data: Uint8Array, alg: HashAlgorithm) => OrPromise -export type GenerateRandomCallback = (byteLength: number) => Promise | Uint8Array +export type GenerateRandomCallback = (byteLength: number) => OrPromise export type SignJwtCallback = ( jwtSigner: JwtSigner, jwt: { header: JwtHeader; payload: JwtPayload } -) => Promise | string +) => OrPromise<{ + jwt: string + signerJwk: Jwk +}> export type VerifyJwtCallback = ( jwtSigner: JwtSigner, jwt: { header: JwtHeader; payload: JwtPayload; compact: string } -) => Promise | boolean +) => OrPromise< + | { + verified: true + signerJwk: Jwk + } + | { + verified: false + signerJwk?: Jwk + } +> /** * Callback context provides the callbacks that are required for the oid4vc library diff --git a/packages/oauth2/src/client-attestation/clent-attestation.ts b/packages/oauth2/src/client-attestation/clent-attestation.ts new file mode 100644 index 0000000..02fa558 --- /dev/null +++ b/packages/oauth2/src/client-attestation/clent-attestation.ts @@ -0,0 +1,143 @@ +import { type FetchHeaders, dateToSeconds, parseWithErrorHandling } from '@animo-id/oauth2-utils' +import type { CallbackContext } from '../callbacks' +import { decodeJwt, jwtHeaderFromJwtSigner, jwtSignerFromJwt } from '../common/jwt/decode-jwt' +import type { JwtSigner } from '../common/jwt/v-jwt' +import { verifyJwt } from '../common/jwt/verify-jwt' +import { Oauth2Error } from '../error/Oauth2Error' +import { + type ClientAttestationJwtHeader, + type ClientAttestationJwtPayload, + oauthClientAttestationHeader, + oauthClientAttestationPopHeader, + vClientAttestationJwtHeader, + vClientAttestationJwtPayload, + vClientAttestationPopJwtHeader, +} from './v-client-attestation' + +export interface VerifyClientAttestationJwtOptions { + /** + * The compact client attestation jwt. + */ + clientAttestationJwt: string + + /** + * Date to use for expiration. If not provided current date will be used. + */ + now?: Date + + /** + * Callbacks used for verifying client attestation pop jwt. + */ + callbacks: Pick + + // TODO: expectedClientId? expectedIssuer? +} + +export async function verifyClientAttestationJwt(options: VerifyClientAttestationJwtOptions) { + const { header, payload } = decodeJwt({ + jwt: options.clientAttestationJwt, + headerSchema: vClientAttestationJwtHeader, + payloadSchema: vClientAttestationJwtPayload, + }) + + const signer = jwtSignerFromJwt({ header, payload }) + + await verifyJwt({ + signer, + now: options.now, + header, + payload, + compact: options.clientAttestationJwt, + verifyJwtCallback: options.callbacks.verifyJwt, + errorMessage: 'client attestation jwt verification failed', + }) + + return { + header, + payload, + signer, + } +} + +export interface CreateClientAttestationJwtOptions { + /** + * Creation time of the JWT. If not provided the current date will be used + */ + issuedAt?: Date + + /** + * Expiration time of the JWT. + */ + expiresAt: Date + + /** + * Issuer of the client attestation, usually identifier of the client backend + */ + issuer: string + + /** + * The client id of the client instance. + */ + clientId: string + + /** + * The confirmation payload for the client, attesting the `jwk`, `key_type` and `user_authentication` + */ + confirmation: ClientAttestationJwtPayload['cnf'] + + /** + * Additional payload to include in the client attestation jwt payload. Will be applied after + * any default claims that are included, so add claims with caution. + */ + additionalPayload?: Record + + /** + * Callback used for client attestation + */ + callbacks: Pick + + /** + * The signer of the client attestation jwt. + */ + signer: JwtSigner +} + +export async function createClientAttestationJwt(options: CreateClientAttestationJwtOptions) { + const header = parseWithErrorHandling(vClientAttestationPopJwtHeader, { + typ: 'oauth-client-attestation+jwt', + ...jwtHeaderFromJwtSigner(options.signer), + } satisfies ClientAttestationJwtHeader) + + const payload = parseWithErrorHandling(vClientAttestationJwtPayload, { + iss: options.issuer, + iat: dateToSeconds(options.issuedAt), + exp: dateToSeconds(options.expiresAt), + sub: options.clientId, + cnf: options.confirmation, + ...options.additionalPayload, + } satisfies ClientAttestationJwtPayload) + + const { jwt } = await options.callbacks.signJwt(options.signer, { + header, + payload, + }) + + return jwt +} + +export function extractClientAttestationJwtsFromHeaders(headers: FetchHeaders) { + const clientAttestationHeader = headers.get(oauthClientAttestationHeader) + const clientAttestationPopHeader = headers.get(oauthClientAttestationPopHeader) + + if (!clientAttestationHeader || clientAttestationHeader.includes(',')) { + throw new Oauth2Error(`Missing or invalid '${oauthClientAttestationHeader}' header.`) + } + if (!clientAttestationPopHeader || clientAttestationPopHeader.includes(',')) { + throw new Oauth2Error(`Missing or invalid '${oauthClientAttestationPopHeader}' header.`) + } + + return { + clientAttestationPopHeader, + clientAttestationHeader, + } +} diff --git a/packages/oauth2/src/client-attestation/client-attestation-pop.ts b/packages/oauth2/src/client-attestation/client-attestation-pop.ts new file mode 100644 index 0000000..942ce95 --- /dev/null +++ b/packages/oauth2/src/client-attestation/client-attestation-pop.ts @@ -0,0 +1,229 @@ +import { addSecondsToDate, dateToSeconds, encodeToBase64Url, parseWithErrorHandling } from '@animo-id/oauth2-utils' +import type { CallbackContext } from '../callbacks' +import { decodeJwt } from '../common/jwt/decode-jwt' +import type { JwtSignerJwk } from '../common/jwt/v-jwt' +import { verifyJwt } from '../common/jwt/verify-jwt' +import { Oauth2Error } from '../error/Oauth2Error' +import { + type ClientAttestationJwtHeader, + type ClientAttestationJwtPayload, + type ClientAttestationPopJwtHeader, + type ClientAttestationPopJwtPayload, + oauthClientAttestationHeader, + oauthClientAttestationPopHeader, + vClientAttestationJwtHeader, + vClientAttestationJwtPayload, + vClientAttestationPopJwtHeader, + vClientAttestationPopJwtPayload, +} from './v-client-attestation' + +export interface RequestClientAttestationOptions { + /** + * Dpop nonce to use for constructing the client attestation pop jwt + */ + nonce?: string + + /** + * Expiration time of the client attestation pop jwt. + * + * @default 5 minutes after issuance date + */ + expiresAt?: Date + + /** + * The client attestation jwt to create the pop for. + */ + jwt: string + + /** + * The signer of the client attestation pop jwt + */ + signer: JwtSignerJwk + + /** + * Whether to include the legacy draft 2 `client_assertion` and `client_assertion_type` properties + * IN ADDITION to the new header syntax + * + * @default false + */ + includeLegacyDraft2ClientAssertion?: boolean +} + +export async function createClientAttestationForRequest( + options: { clientAttestation: RequestClientAttestationOptions } & Pick< + CreateClientAttestationPopJwtOptions, + 'callbacks' | 'authorizationServer' + > +) { + const clientAttestationPopJwt = await createClientAttestationPopJwt({ + authorizationServer: options.authorizationServer, + clientAttestation: options.clientAttestation.jwt, + callbacks: options.callbacks, + expiresAt: options.clientAttestation.expiresAt, + signer: options.clientAttestation.signer, + nonce: options.clientAttestation.nonce, + }) + + return { + headers: { + [oauthClientAttestationHeader]: options.clientAttestation.jwt, + [oauthClientAttestationPopHeader]: clientAttestationPopJwt, + }, + body: options.clientAttestation.includeLegacyDraft2ClientAssertion + ? { + client_assertion: `${options.clientAttestation.jwt}~${clientAttestationPopJwt}`, + client_assertion_type: 'urn:ietf:params:oauth:client-assertion-type:jwt-client-attestation', + } + : undefined, + } +} + +export interface VerifyClientAttestationPopJwtOptions { + /** + * The compact client attestation pop jwt. + */ + clientAttestationPopJwt: string + + /** + * The issuer identifier of the authorization server handling the client attestation + */ + authorizationServer: string + + /** + * Expected nonce in the payload. If not provided the nonce won't be validated. + */ + expectedNonce?: string + + /** + * Date to use for expiration. If not provided current date will be used. + */ + now?: Date + + /** + * Callbacks used for verifying client attestation pop jwt. + */ + callbacks: Pick + + /** + * The parsed and verified client attestation jwt + */ + clientAttestation: { + header: ClientAttestationJwtHeader + payload: ClientAttestationJwtPayload + } +} + +export async function verifyClientAttestationPopJwt(options: VerifyClientAttestationPopJwtOptions) { + const { header, payload } = decodeJwt({ + jwt: options.clientAttestationPopJwt, + headerSchema: vClientAttestationPopJwtHeader, + payloadSchema: vClientAttestationPopJwtPayload, + }) + + if (payload.iss !== options.clientAttestation.payload.sub) { + throw new Oauth2Error( + `Client Attestation Pop jwt contains 'iss' (client_id) value '${payload.iss}', but expected 'sub' value from client attestation '${options.clientAttestation.payload.sub}'` + ) + } + + if (payload.aud !== options.authorizationServer) { + throw new Oauth2Error( + `Client Attestation Pop jwt contains 'aud' value '${payload.aud}', but expected authorization server identifier '${options.authorizationServer}'` + ) + } + + await verifyJwt({ + signer: { + alg: header.alg, + method: 'jwk', + publicJwk: options.clientAttestation.payload.cnf.jwk, + }, + now: options.now, + header, + expectedNonce: options.expectedNonce, + payload, + compact: options.clientAttestationPopJwt, + verifyJwtCallback: options.callbacks.verifyJwt, + errorMessage: 'client attestation pop jwt verification failed', + }) + + return { + header, + payload, + } +} + +export interface CreateClientAttestationPopJwtOptions { + /** + * Client attestation Pop nonce value + */ + nonce?: string + + /** + * The audience authorization server identifier + */ + authorizationServer: string + + /** + * Creation time of the JWT. If not provided the current date will be used + */ + issuedAt?: Date + + /** + * Expiration time of the JWT. If not proided 1 minute will be added to the `issuedAt` + */ + expiresAt?: Date + + /** + * The client attestation to create the Pop for + */ + clientAttestation: string + + /** + * Additional payload to include in the client attestation pop jwt payload. Will be applied after + * any default claims that are included, so add claims with caution. + */ + additionalPayload?: Record + + /** + * Callback used for dpop + */ + callbacks: Pick + + /** + * The signer of jwt. Only jwk signer allowed. + */ + signer: JwtSignerJwk +} + +export async function createClientAttestationPopJwt(options: CreateClientAttestationPopJwtOptions) { + const header = parseWithErrorHandling(vClientAttestationPopJwtHeader, { + typ: 'oauth-client-attestation-pop+jwt', + alg: options.signer.alg, + } satisfies ClientAttestationPopJwtHeader) + + const clientAttestation = decodeJwt({ + jwt: options.clientAttestation, + headerSchema: vClientAttestationJwtHeader, + payloadSchema: vClientAttestationJwtPayload, + }) + + const expiresAt = options.expiresAt ?? addSecondsToDate(options.issuedAt ?? new Date(), 1 * 60) + + const payload = parseWithErrorHandling(vClientAttestationPopJwtPayload, { + aud: options.authorizationServer, + iss: clientAttestation.payload.sub, + iat: dateToSeconds(options.issuedAt), + exp: dateToSeconds(expiresAt), + jti: encodeToBase64Url(await options.callbacks.generateRandom(32)), + nonce: options.nonce, + ...options.additionalPayload, + } satisfies ClientAttestationPopJwtPayload) + + const { jwt } = await options.callbacks.signJwt(options.signer, { + header, + payload, + }) + + return jwt +} diff --git a/packages/oauth2/src/client-attestation/v-client-attestation.ts b/packages/oauth2/src/client-attestation/v-client-attestation.ts new file mode 100644 index 0000000..b6a6bbc --- /dev/null +++ b/packages/oauth2/src/client-attestation/v-client-attestation.ts @@ -0,0 +1,55 @@ +import * as v from 'valibot' +import { vJwtHeader, vJwtPayload } from '../common/jwt/v-jwt' + +import { vHttpsUrl, vInteger } from '@animo-id/oauth2-utils' +import { vJwk } from '../common/jwk/v-jwk' + +export const vOauthClientAttestationHeader = v.literal('OAuth-Client-Attestation') +export const oauthClientAttestationHeader = vOauthClientAttestationHeader.literal + +export const vClientAttestationJwtPayload = v.looseObject({ + ...vJwtPayload.entries, + iss: v.string(), + sub: v.string(), + exp: vInteger, + cnf: v.looseObject({ + jwk: vJwk, + key_type: v.union([ + v.picklist(['software', 'hardware', 'tee', 'secure_enclave', 'strong_box', 'secure_element', 'hsm']), + v.string(), + ]), + user_authentication: v.union([ + v.picklist(['system_biometry', 'system_pin', 'internal_biometry', 'internal_pin', 'secure_element_pin']), + v.string(), + ]), + }), + + aal: v.optional(v.string()), +}) +export type ClientAttestationJwtPayload = v.InferOutput + +export const vClientAttestationJwtHeader = v.looseObject({ + ...vJwtHeader.entries, + typ: v.literal('oauth-client-attestation+jwt'), +}) +export type ClientAttestationJwtHeader = v.InferOutput + +export const vOauthClientAttestationPopHeader = v.literal('OAuth-Client-Attestation-PoP') +export const oauthClientAttestationPopHeader = vOauthClientAttestationPopHeader.literal + +export const vClientAttestationPopJwtPayload = v.looseObject({ + ...vJwtPayload.entries, + iss: v.string(), + exp: vInteger, + aud: vHttpsUrl, + + jti: v.string(), + nonce: v.optional(v.string()), +}) +export type ClientAttestationPopJwtPayload = v.InferOutput + +export const vClientAttestationPopJwtHeader = v.looseObject({ + ...vJwtHeader.entries, + typ: v.literal('oauth-client-attestation-pop+jwt'), +}) +export type ClientAttestationPopJwtHeader = v.InferOutput diff --git a/packages/oauth2/src/common/jwk/jwks.ts b/packages/oauth2/src/common/jwk/jwks.ts index 93acc8f..0a4aa3d 100644 --- a/packages/oauth2/src/common/jwk/jwks.ts +++ b/packages/oauth2/src/common/jwk/jwks.ts @@ -1,5 +1,7 @@ +import { type CallbackContext, HashAlgorithm } from '../../callbacks' import { Oauth2Error } from '../../error/Oauth2Error' -import type { JwkSet } from './v-jwk' +import { calculateJwkThumbprint } from './jwk-thumbprint' +import type { Jwk, JwkSet } from './v-jwk' interface ExtractJwkFromJwksForJwtOptions { kid?: string @@ -32,3 +34,31 @@ export function extractJwkFromJwksForJwt(options: ExtractJwkFromJwksForJwtOption `Unable to extract jwk from jwks for use '${options.use}'${options.kid ? `with kid '${options.kid}'.` : '. No kid provided and more than jwk.'}` ) } + +export async function isJwkInSet({ + jwk, + jwks, + callbacks, +}: { + jwk: Jwk + jwks: Jwk[] + callbacks: Pick +}) { + const jwkThumbprint = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk, + }) + + for (const jwkFromSet of jwks) { + const jwkFromSetThumbprint = await calculateJwkThumbprint({ + hashAlgorithm: HashAlgorithm.Sha256, + hashCallback: callbacks.hash, + jwk: jwkFromSet, + }) + + if (jwkFromSetThumbprint === jwkThumbprint) return true + } + + return false +} diff --git a/packages/oauth2/src/common/jwt/decode-jwt.ts b/packages/oauth2/src/common/jwt/decode-jwt.ts index c09c5c4..74046a8 100644 --- a/packages/oauth2/src/common/jwt/decode-jwt.ts +++ b/packages/oauth2/src/common/jwt/decode-jwt.ts @@ -83,6 +83,14 @@ export function jwtHeaderFromJwtSigner(signer: JwtSigner) { } as const } + if (signer.method === 'trustChain') { + return { + alg: signer.alg, + kid: signer.kid, + trust_chain: signer.trustChain, + } as const + } + if (signer.method === 'jwk') { return { alg: signer.alg, @@ -111,6 +119,20 @@ export function jwtSignerFromJwt({ header, payload }: Pick diff --git a/packages/oauth2/src/common/jwt/verify-jwt.ts b/packages/oauth2/src/common/jwt/verify-jwt.ts index df14cbc..e358537 100644 --- a/packages/oauth2/src/common/jwt/verify-jwt.ts +++ b/packages/oauth2/src/common/jwt/verify-jwt.ts @@ -1,7 +1,8 @@ import { dateToSeconds } from '@animo-id/oauth2-utils' import type { VerifyJwtCallback } from '../../callbacks' import { Oauth2JwtVerificationError } from '../../error/Oauth2JwtVerificationError' -import type { JwtHeader, JwtPayload, JwtSigner } from './v-jwt' +import type { Jwk } from '../jwk/v-jwk' +import type { JwtHeader, JwtPayload, JwtSigner, JwtSignerWithJwk } from './v-jwt' export interface VerifyJwtOptions { /** @@ -76,16 +77,23 @@ export interface VerifyJwtOptions { expectedSubject?: string } -export async function verifyJwt(options: VerifyJwtOptions) { +export interface VerifyJwtReturn { + signer: JwtSignerWithJwk +} + +export async function verifyJwt(options: VerifyJwtOptions): Promise { const errorMessage = options.errorMessage ?? 'Error during verification of jwt.' + + let signerJwk: Jwk try { - const isValid = await options.verifyJwtCallback(options.signer, { + const result = await options.verifyJwtCallback(options.signer, { header: options.header, payload: options.payload, compact: options.compact, }) - if (!isValid) throw new Oauth2JwtVerificationError(errorMessage) + if (!result.verified) throw new Oauth2JwtVerificationError(errorMessage) + signerJwk = result.signerJwk } catch (error) { if (error instanceof Oauth2JwtVerificationError) throw error throw new Oauth2JwtVerificationError(errorMessage, { cause: error }) @@ -118,4 +126,11 @@ export async function verifyJwt(options: VerifyJwtOptions) { if (options.expectedSubject && options.expectedSubject !== options.payload.sub) { throw new Oauth2JwtVerificationError(`${errorMessage} jwt 'sub' does not match expected value.`) } + + return { + signer: { + ...options.signer, + publicJwk: signerJwk, + }, + } } diff --git a/packages/oauth2/src/dpop/dpop-retry.ts b/packages/oauth2/src/dpop/dpop-retry.ts index e8e1633..247deb9 100644 --- a/packages/oauth2/src/dpop/dpop-retry.ts +++ b/packages/oauth2/src/dpop/dpop-retry.ts @@ -1,21 +1,47 @@ import type { FetchHeaders } from '@animo-id/oauth2-utils' -import type { AccessTokenErrorResponse } from '../access-token/v-access-token' import { SupportedAuthenticationScheme } from '../access-token/verify-access-token' -import { Oauth2ErrorCodes } from '../common/v-oauth2-error' +import { Oauth2ErrorCodes, type Oauth2ErrorResponse } from '../common/v-oauth2-error' +import { Oauth2ClientErrorResponseError } from '../error/Oauth2ClientErrorResponseError' import { Oauth2Error } from '../error/Oauth2Error' import type { Oauth2ResourceUnauthorizedError } from '../error/Oauth2ResourceUnauthorizedError' -import { extractDpopNonceFromHeaders } from './dpop' +import { type RequestDpopOptions, extractDpopNonceFromHeaders } from './dpop' -export interface ShouldRetryTokenRequestWithDpopNonceOptions { +export async function authorizationServerRequestWithDpopRetry(options: { + dpop?: RequestDpopOptions + request: (dpop?: RequestDpopOptions) => Promise +}): Promise { + try { + return await options.request(options.dpop) + } catch (error) { + if (options.dpop && error instanceof Oauth2ClientErrorResponseError) { + const dpopRetry = shouldRetryAuthorizationServerRequestWithDPoPNonce({ + responseHeaders: error.response.headers, + errorResponse: error.errorResponse, + }) + + // Retry with the dpop nonce + if (dpopRetry.retry) { + return options.request({ + ...options.dpop, + nonce: dpopRetry.dpopNonce, + }) + } + } + + throw error + } +} + +export interface ShouldRetryAuthorizationServerRequestWithDpopNonceOptions { /** - * The token error response that will be evaluated for the + * The error response that will be evaluated for the * 'use_dpop_nonce' error to determine whether the request * should be retried using a dpop nonce. */ - tokenErrorResponse: AccessTokenErrorResponse + errorResponse: Oauth2ErrorResponse /** - * The headers returned in the access token response. The 'DPoP-Nonce' + * The headers returned in the response. The 'DPoP-Nonce' * header will be extracted if the access token error response indicates so. * Will throw an error if the 'error' in the response is 'use_dpop_nonce' but the * headers does not contain the 'DPoP-Nonce' header value. @@ -23,8 +49,10 @@ export interface ShouldRetryTokenRequestWithDpopNonceOptions { responseHeaders: FetchHeaders } -export function shouldRetryTokenRequestWithDPoPNonce(options: ShouldRetryTokenRequestWithDpopNonceOptions) { - if (options.tokenErrorResponse.error !== 'use_dpop_nonce') { +export function shouldRetryAuthorizationServerRequestWithDPoPNonce( + options: ShouldRetryAuthorizationServerRequestWithDpopNonceOptions +) { + if (options.errorResponse.error !== 'use_dpop_nonce') { return { retry: false, } as const @@ -33,7 +61,7 @@ export function shouldRetryTokenRequestWithDPoPNonce(options: ShouldRetryTokenRe const dpopNonce = extractDpopNonceFromHeaders(options.responseHeaders) if (!dpopNonce) { throw new Oauth2Error( - `Access token response error contains error 'use_dpop_nonce' but the access token response headers do not include a valid 'DPoP-Nonce' header value.` + `Error response error contains error 'use_dpop_nonce' but the response headers do not include a valid 'DPoP-Nonce' header value.` ) } diff --git a/packages/oauth2/src/dpop/dpop.ts b/packages/oauth2/src/dpop/dpop.ts index c2fca5a..225aa5b 100644 --- a/packages/oauth2/src/dpop/dpop.ts +++ b/packages/oauth2/src/dpop/dpop.ts @@ -28,11 +28,12 @@ export interface RequestDpopOptions { signer: JwtSignerJwk } -export interface ResponseDpopReturn { - /** - * Dpop nonce returned that needs to be used in the subsequent request. - */ - nonce: string +export async function createDpopHeadersForRequest(options: CreateDpopJwtOptions) { + const dpopJwt = await createDpopJwt(options) + + return { + DPoP: dpopJwt, + } } export interface CreateDpopJwtOptions { @@ -96,7 +97,7 @@ export async function createDpopJwt(options: CreateDpopJwtOptions) { ...options.additionalPayload, } satisfies DpopJwtPayload) - const jwt = await options.callbacks.signJwt(options.signer, { + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload, }) @@ -104,7 +105,7 @@ export async function createDpopJwt(options: CreateDpopJwtOptions) { return jwt } -export type VerifyDpopJwtOptions = { +export interface VerifyDpopJwtOptions { /** * The compact dpop jwt. */ diff --git a/packages/oauth2/src/index.ts b/packages/oauth2/src/index.ts index 9eb454e..1087712 100644 --- a/packages/oauth2/src/index.ts +++ b/packages/oauth2/src/index.ts @@ -8,6 +8,10 @@ export { type HttpMethod, getGlobalConfig, setGlobalConfig, type Oid4vcTsConfig export { Oauth2ErrorCodes, type Oauth2ErrorResponse, vOauth2ErrorResponse } from './common/v-oauth2-error' export { calculateJwkThumbprint, type CalculateJwkThumbprintOptions } from './common/jwk/jwk-thumbprint' + +// TODO: should we move this to oauth2-utils? +export { isJwkInSet } from './common/jwk/jwks' + export { type Jwk, type JwkSet, vJwk } from './common/jwk/v-jwk' export type { AccessTokenProfileJwtPayload } from './access-token/v-access-token-jwt' @@ -28,15 +32,20 @@ export { JwtSignerDid, JwtSignerJwk, JwtSignerX5c, + JwtSignerWithJwk, vJwtHeader, vJwtPayload, vCompactJwt, } from './common/jwt/v-jwt' +export type { RequestDpopOptions } from './dpop/dpop' +export type { RequestClientAttestationOptions } from './client-attestation/client-attestation-pop' export type { - RequestDpopOptions, - ResponseDpopReturn, -} from './dpop/dpop' + ClientAttestationJwtHeader, + ClientAttestationJwtPayload, + ClientAttestationPopJwtHeader, + ClientAttestationPopJwtPayload, +} from './client-attestation/v-client-attestation' export { Oauth2Error, Oauth2ErrorOptions } from './error/Oauth2Error' export { Oauth2JwtVerificationError } from './error/Oauth2JwtVerificationError' diff --git a/packages/oauth2/src/resource-request/make-resource-request.ts b/packages/oauth2/src/resource-request/make-resource-request.ts index ecbb1f6..c56be20 100644 --- a/packages/oauth2/src/resource-request/make-resource-request.ts +++ b/packages/oauth2/src/resource-request/make-resource-request.ts @@ -1,6 +1,6 @@ import { type FetchRequestInit, type FetchResponse, type HttpMethod, defaultFetcher } from '@animo-id/oauth2-utils' import type { CallbackContext } from '../callbacks' -import { type RequestDpopOptions, createDpopJwt, extractDpopNonceFromHeaders } from '../dpop/dpop' +import { type RequestDpopOptions, createDpopHeadersForRequest, extractDpopNonceFromHeaders } from '../dpop/dpop' import { shouldRetryResourceRequestWithDPoPNonce } from '../dpop/dpop-retry' import { Oauth2ResourceUnauthorizedError, @@ -64,8 +64,8 @@ export interface ResourceRequestResponseNotOk extends ResourceRequestResponseBas export async function resourceRequest( options: ResourceRequestOptions ): Promise { - const dpopJwt = options.dpop - ? await createDpopJwt({ + const dpopHeaders = options.dpop + ? await createDpopHeadersForRequest({ request: { url: options.url, // in fetch the default is GET if not provided @@ -83,8 +83,8 @@ export async function resourceRequest( ...options.requestOptions, headers: { ...options.requestOptions.headers, - Authorization: `${dpopJwt ? 'DPoP' : 'Bearer'} ${options.accessToken}`, - ...(dpopJwt ? { DPoP: dpopJwt } : {}), + Authorization: `${dpopHeaders ? 'DPoP' : 'Bearer'} ${options.accessToken}`, + ...dpopHeaders, }, }) diff --git a/packages/oauth2/tests/util.ts b/packages/oauth2/tests/util.ts index 0b7c821..7c98408 100644 --- a/packages/oauth2/tests/util.ts +++ b/packages/oauth2/tests/util.ts @@ -29,9 +29,14 @@ export const callbacks = { await jose.jwtVerify(compact, josePublicKey, { currentDate: payload.exp ? new Date((payload.exp - 300) * 1000) : undefined, }) - return true + return { + verified: true, + signerJwk: jwk, + } } catch (error) { - return false + return { + verified: false, + } } }, } as const satisfies Partial @@ -71,6 +76,9 @@ export const getSignJwtCallback = (privateJwks: Jwk[]): SignJwtCallback => { const josePrivateKey = await jose.importJWK(privateJwk as jose.JWK, signer.alg) const jwt = await new jose.SignJWT(payload).setProtectedHeader(header).sign(josePrivateKey) - return jwt + return { + jwt: jwt, + signerJwk: jwk, + } } } diff --git a/packages/oid4vci/src/Oid4vciClient.ts b/packages/oid4vci/src/Oid4vciClient.ts index 8123b2a..c80cb79 100644 --- a/packages/oid4vci/src/Oid4vciClient.ts +++ b/packages/oid4vci/src/Oid4vciClient.ts @@ -1,10 +1,12 @@ import { type CallbackContext, type CreateAuthorizationRequestUrlOptions, + type CreatePkceReturn, Oauth2Client, Oauth2ClientAuthorizationChallengeError, Oauth2Error, Oauth2ErrorCodes, + type RequestDpopOptions, type RetrieveAuthorizationCodeAccessTokenOptions, type RetrievePreAuthorizedCodeAccessTokenOptions, authorizationCodeGrantIdentifier, @@ -12,7 +14,6 @@ import { preAuthorizedCodeGrantIdentifier, } from '@animo-id/oauth2' -import type { CreatePkceReturn } from '../../oauth2/src/pkce' import { determineAuthorizationServerForCredentialOffer, resolveCredentialOffer, @@ -100,6 +101,8 @@ export class Oid4vciClient { credentialOffer: CredentialOfferObject issuerMetadata: IssuerMetadataResult + + dpop?: RequestDpopOptions }) { if (!options.credentialOffer.grants?.[authorizationCodeGrantIdentifier]) { throw new Oauth2Error(`Provided credential offer does not include the 'authorization_code' grant.`) @@ -117,13 +120,14 @@ export class Oid4vciClient { ) const oauth2Client = new Oauth2Client({ callbacks: this.options.callbacks }) - const { authorizationChallengeResponse } = await oauth2Client.sendAuthorizationChallengeRequest({ + const { authorizationChallengeResponse, dpop } = await oauth2Client.sendAuthorizationChallengeRequest({ authorizationServerMetadata, authSession: options.authSession, presentationDuringIssuanceSession: options.presentationDuringIssuanceSession, + dpop: options.dpop, }) - return { authorizationChallengeResponse } + return { authorizationChallengeResponse, dpop } } /** @@ -187,6 +191,8 @@ export class Oid4vciClient { ...options.additionalRequestPayload, issuer_state: options.credentialOffer?.grants?.authorization_code?.issuer_state, }, + dpop: options.dpop, + clientAttestation: options.clientAttestation, resource: options.issuerMetadata.credentialIssuer.credential_issuer, authorizationServerMetadata, }) @@ -245,7 +251,7 @@ export class Oid4vciClient { authorizationServer ) - const { authorizationRequestUrl, pkce } = await this.oauth2Client.createAuthorizationRequestUrl({ + const { authorizationRequestUrl, pkce, dpop } = await this.oauth2Client.createAuthorizationRequestUrl({ authorizationServerMetadata, clientId: options.clientId, additionalRequestPayload: { @@ -256,11 +262,14 @@ export class Oid4vciClient { redirectUri: options.redirectUri, scope: options.scope, pkceCodeVerifier: options.pkceCodeVerifier, + clientAttestation: options.clientAttestation, + dpop: options.dpop, }) return { authorizationRequestUrl, pkce, + dpop, authorizationServer: authorizationServerMetadata.issuer, } } @@ -275,6 +284,7 @@ export class Oid4vciClient { additionalRequestPayload, txCode, dpop, + clientAttestation, }: Omit< RetrievePreAuthorizedCodeAccessTokenOptions, 'callbacks' | 'authorizationServerMetadata' | 'preAuthorizedCode' | 'resource' @@ -312,6 +322,7 @@ export class Oid4vciClient { resource: issuerMetadata.credentialIssuer.credential_issuer, additionalRequestPayload, dpop, + clientAttestation, }) return { @@ -332,6 +343,7 @@ export class Oid4vciClient { pkceCodeVerifier, redirectUri, dpop, + clientAttestation, }: Omit & { credentialOffer: CredentialOfferObject issuerMetadata: IssuerMetadataResult @@ -356,6 +368,7 @@ export class Oid4vciClient { pkceCodeVerifier, additionalRequestPayload, dpop, + clientAttestation, redirectUri, resource: issuerMetadata.credentialIssuer.credential_issuer, }) @@ -381,7 +394,10 @@ export class Oid4vciClient { * Creates the jwt proof payload and header to be included in a credential request. */ public async createCredentialRequestJwtProof( - options: Pick & { + options: Pick< + CreateCredentialRequestJwtProofOptions, + 'signer' | 'nonce' | 'issuedAt' | 'clientId' | 'keyAttestationJwt' + > & { issuerMetadata: IssuerMetadataResult credentialConfigurationId: string } @@ -410,6 +426,13 @@ export class Oid4vciClient { `Credential configuration with id '${options.credentialConfigurationId}' does not support the '${options.signer.alg}' alg for 'jwt' proof type.` ) } + + // TODO: might be beneficial to also decode the key attestation and see if the required level is reached + if (credentialConfiguration.proof_types_supported.jwt.key_attestations_required && !options.keyAttestationJwt) { + throw new Oid4vciError( + `Credential configuration with id '${options.credentialConfigurationId}' requires key attestations for 'jwt' proof type but no 'keyAttestationJwt' was provided` + ) + } } const jwt = await createCredentialRequestJwtProof({ @@ -418,6 +441,7 @@ export class Oid4vciClient { clientId: options.clientId, issuedAt: options.issuedAt, nonce: options.nonce, + keyAttestationJwt: options.keyAttestationJwt, callbacks: this.options.callbacks, }) diff --git a/packages/oid4vci/src/Oid4vciIssuer.ts b/packages/oid4vci/src/Oid4vciIssuer.ts index 60ba4af..e099093 100644 --- a/packages/oid4vci/src/Oid4vciIssuer.ts +++ b/packages/oid4vci/src/Oid4vciIssuer.ts @@ -16,6 +16,10 @@ import { parseCredentialRequest, } from './credential-request/parse-credential-request' import { Oid4vciError } from './error/Oid4vciError' +import { + type VerifyCredentialRequestAttestationProofOptions, + verifyCredentialRequestAttestationProof, +} from './formats/proof-type/attestation/attestation-proof-type' import { type VerifyCredentialRequestJwtProofOptions, verifyCredentialRequestJwtProof, @@ -99,6 +103,7 @@ export class Oid4vciIssuer { callbacks: this.options.callbacks, credentialIssuer: options.issuerMetadata.credentialIssuer.credential_issuer, expectedNonce: options.expectedNonce, + nonceExpiresAt: options.nonceExpiresAt, jwt: options.jwt, clientId: options.clientId, now: options.now, @@ -122,6 +127,45 @@ export class Oid4vciIssuer { } } + /** + * @throws Oauth2ServerErrorResponseError - if verification of the key attestation failed. You can extract + * the credential error response from this. + */ + public async verifyCredentialRequestAttestationProof( + options: Pick< + VerifyCredentialRequestAttestationProofOptions, + 'keyAttestationJwt' | 'expectedNonce' | 'nonceExpiresAt' | 'now' + > & { + issuerMetadata: IssuerMetadataResult + } + ) { + try { + return await verifyCredentialRequestAttestationProof({ + callbacks: this.options.callbacks, + expectedNonce: options.expectedNonce, + keyAttestationJwt: options.keyAttestationJwt, + nonceExpiresAt: options.nonceExpiresAt, + now: options.now, + }) + } catch (error) { + throw new Oauth2ServerErrorResponseError( + { + error: Oauth2ErrorCodes.InvalidProof, + error_description: + // TOOD: error should have a internalErrorMessage and a publicErrorMessage + error instanceof Oauth2JwtVerificationError || error instanceof Oid4vciError + ? error.message + : 'Invalid proof', + }, + + { + internalMessage: 'Error verifying credential request proof attestation', + cause: error, + } + ) + } + } + /** * @throws Oauth2ServerErrorResponseError - when validation of the credential request fails * You can extract the credential error response from this. @@ -144,6 +188,11 @@ export class Oid4vciIssuer { } ) } + + // TOOD: might be nice to add some extra validation params here so it's + // easy for an issuer to verify whether the request matches with the configuration + // e.g. alg of holder binding, key_attestations_required, proof_types_supported, + // request matches offer, etc.. } /** diff --git a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts index 6ce49dd..a823dca 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts @@ -99,7 +99,7 @@ describe('Oid4vciClient', () => { nonce: accessTokenResponse.c_nonce, }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpGUXlJc0luZ2lPaUpCUlZod1NIa3hNRWRvZEZkb2JGWlFUbTF5Um5OaWVYUmZkMFJ6VVY4M2NUTmthazV1Y21oNmFsODBJaXdpZVNJNklrUkhWRUZEVDBGQmJsRlVaWEJoUkRRd1ozbEhPVnBzTFc5RWFFOXNkak5WUW14VWRIaEpaWEkxWlc4aUxDSmpjbllpT2lKUUxUSTFOaUo5IzAiLCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6IjQ2MzI1MzkxNzA5NDg2OTE3MjA3ODMxMCIsImF1ZCI6Imh0dHBzOi8vYWdlbnQucGFyYWR5bS5pZC9vaWQ0dmNpL2RyYWZ0LTEzLWlzc3VlciIsImlhdCI6MTcyODUxODQwMH0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0Iiwia2lkIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5naU9pSkJSVmh3U0hreE1FZG9kRmRvYkZaUVRtMXlSbk5pZVhSZmQwUnpVVjgzY1ROa2FrNXVjbWg2YWw4MElpd2llU0k2SWtSSFZFRkRUMEZCYmxGVVpYQmhSRFF3WjNsSE9WcHNMVzlFYUU5c2RqTlZRbXhVZEhoSlpYSTFaVzhpTENKamNuWWlPaUpRTFRJMU5pSjkjMCJ9.eyJhdWQiOiJodHRwczovL2FnZW50LnBhcmFkeW0uaWQvb2lkNHZjaS9kcmFmdC0xMy1pc3N1ZXIiLCJpYXQiOjE3Mjg1MTg0MDAsIm5vbmNlIjoiNDYzMjUzOTE3MDk0ODY5MTcyMDc4MzEwIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { @@ -258,7 +258,7 @@ describe('Oid4vciClient', () => { nonce: accessTokenResponse.c_nonce, }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpqd2s6ZXlKcmRIa2lPaUpGUXlJc0luZ2lPaUpCUlZod1NIa3hNRWRvZEZkb2JGWlFUbTF5Um5OaWVYUmZkMFJ6VVY4M2NUTmthazV1Y21oNmFsODBJaXdpZVNJNklrUkhWRUZEVDBGQmJsRlVaWEJoUkRRd1ozbEhPVnBzTFc5RWFFOXNkak5WUW14VWRIaEpaWEkxWlc4aUxDSmpjbllpT2lKUUxUSTFOaUo5IzAiLCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6IjQ2MzI1MzkxNzA5NDg2OTE3MjA3ODMxMCIsImF1ZCI6Imh0dHBzOi8vYWdlbnQucGFyYWR5bS5pZC9vaWQ0dmNpL2RyYWZ0LTExLWlzc3VlciIsImlhdCI6MTcyODUxODQwMH0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0Iiwia2lkIjoiZGlkOmp3azpleUpyZEhraU9pSkZReUlzSW5naU9pSkJSVmh3U0hreE1FZG9kRmRvYkZaUVRtMXlSbk5pZVhSZmQwUnpVVjgzY1ROa2FrNXVjbWg2YWw4MElpd2llU0k2SWtSSFZFRkRUMEZCYmxGVVpYQmhSRFF3WjNsSE9WcHNMVzlFYUU5c2RqTlZRbXhVZEhoSlpYSTFaVzhpTENKamNuWWlPaUpRTFRJMU5pSjkjMCJ9.eyJhdWQiOiJodHRwczovL2FnZW50LnBhcmFkeW0uaWQvb2lkNHZjaS9kcmFmdC0xMS1pc3N1ZXIiLCJpYXQiOjE3Mjg1MTg0MDAsIm5vbmNlIjoiNDYzMjUzOTE3MDk0ODY5MTcyMDc4MzEwIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { @@ -462,7 +462,7 @@ describe('Oid4vciClient', () => { }) expect(proofJwt).toMatch( - 'eyJhbGciOiJFUzI1NiIsImp3ayI6eyJrdHkiOiJFQyIsIngiOiJBRVhwSHkxMEdodFdobFZQTm1yRnNieXRfd0RzUV83cTNkak5ucmh6al80IiwieSI6IkRHVEFDT0FBblFUZXBhRDQwZ3lHOVpsLW9EaE9sdjNVQmxUdHhJZXI1ZW8iLCJjcnYiOiJQLTI1NiJ9LCJ0eXAiOiJvcGVuaWQ0dmNpLXByb29mK2p3dCJ9.eyJub25jZSI6InNqTk1pcXlmbUJlRDFxaW9DVnlxdlMiLCJhdWQiOiJodHRwczovL2RlbW8ucGlkLWlzc3Vlci5idW5kZXNkcnVja2VyZWkuZGUvYyIsImlhdCI6MTcyODUxODQwMCwiaXNzIjoiNzZjN2M4OWItODc5OS00YmQxLWE2OTMtZDQ5OTQ4YTkxYjAwIn0.' + 'eyJhbGciOiJFUzI1NiIsInR5cCI6Im9wZW5pZDR2Y2ktcHJvb2Yrand0IiwiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiQUVYcEh5MTBHaHRXaGxWUE5tckZzYnl0X3dEc1FfN3EzZGpObnJoempfNCIsInkiOiJER1RBQ09BQW5RVGVwYUQ0MGd5RzlabC1vRGhPbHYzVUJsVHR4SWVyNWVvIn19.eyJpc3MiOiI3NmM3Yzg5Yi04Nzk5LTRiZDEtYTY5My1kNDk5NDhhOTFiMDAiLCJhdWQiOiJodHRwczovL2RlbW8ucGlkLWlzc3Vlci5idW5kZXNkcnVja2VyZWkuZGUvYyIsImlhdCI6MTcyODUxODQwMCwibm9uY2UiOiJzak5NaXF5Zm1CZUQxcWlvQ1Z5cXZTIn0.' ) expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ header: { diff --git a/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts b/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts index cd364fc..41fe210 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciIssuer.test.ts @@ -1,9 +1,11 @@ import { preAuthorizedCodeGrantIdentifier } from '@animo-id/oauth2' +import { addSecondsToDate } from '@animo-id/oauth2-utils' import { describe, expect, test } from 'vitest' import { callbacks, getSignJwtCallback } from '../../../oauth2/tests/util' import { Oid4vciIssuer } from '../Oid4vciIssuer' import { parseCredentialRequest } from '../credential-request/parse-credential-request' import { createCredentialRequestJwtProof } from '../formats/proof-type/jwt/jwt-proof-type' +import { createKeyAttestationJwt } from '../key-attestation/key-attestation' import type { IssuerMetadataResult } from '../metadata/fetch-issuer-metadata' import { Oid4vciDraftVersion } from '../version' @@ -16,75 +18,84 @@ const credentialRequestProofJwk = { } const { d, ...credentialRequestProofJwkPublic } = credentialRequestProofJwk -describe('Oid4vciIssuer', () => { - test('create issuer metadata, create a credential offer, parse a credential request, create a credential response', async () => { - const issuer = new Oid4vciIssuer({ - callbacks: { - ...callbacks, - signJwt: getSignJwtCallback([]), - }, - }) +const keyAttestationJwk = { + kty: 'EC', + d: 'e3q7tfa5NRbTUAslncGRUEXe2uf-xKlBsRvLVBHwY0U', + crv: 'P-256', + x: 'fcwkU-4CswPMud4mTz0fZP9a-cfE00OG7dEkQlfPuk0', + y: 'Cs2h5PnM16hWXObTZGg8BQLbprCIQDebP1GH9oAvAq0', +} +const { d: _, ...keyAttestationJwkPublic } = keyAttestationJwk - const credentialIssuerMetadata = issuer.createCredentialIssuerMetadata({ - credential_issuer: 'https://credential-issuer.com', - credential_configurations_supported: { - pidSdJwt: { - format: 'vc+sd-jwt', - vct: 'https://sd-jwt.com', - proof_types_supported: { - jwt: { - proof_signing_alg_values_supported: ['ES256'], - }, - }, - credential_signing_alg_values_supported: ['ES256'], - cryptographic_binding_methods_supported: ['jwk'], - scope: 'PidSdJwt', - display: [ - { - name: 'PID SD JWT', - background_color: '#FFFFFF', - background_image: { - uri: 'https://background-image.com', - }, - description: 'PID SD JWT Credential', - locale: 'en-US', - logo: { - uri: 'https://logo.com/logo.png', - alt_text: 'logo of logo', - }, - text_color: '#GGGGGG', - }, - ], +const issuer = new Oid4vciIssuer({ + callbacks: { + ...callbacks, + signJwt: getSignJwtCallback([]), + }, +}) + +const credentialIssuerMetadata = issuer.createCredentialIssuerMetadata({ + credential_issuer: 'https://credential-issuer.com', + credential_configurations_supported: { + pidSdJwt: { + format: 'vc+sd-jwt', + vct: 'https://sd-jwt.com', + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['ES256'], }, }, - credential_endpoint: 'https://credential-issuer.com/credential', - authorization_servers: ['https://one.com'], - batch_credential_issuance: { - batch_size: 10, - }, + credential_signing_alg_values_supported: ['ES256'], + cryptographic_binding_methods_supported: ['jwk'], + scope: 'PidSdJwt', display: [ { - name: 'Oid4vciIssuer', - locale: 'nl-NL', + name: 'PID SD JWT', + background_color: '#FFFFFF', + background_image: { + uri: 'https://background-image.com', + }, + description: 'PID SD JWT Credential', + locale: 'en-US', logo: { - alt_text: 'some-log', - uri: 'https://some-logo.com', + uri: 'https://logo.com/logo.png', + alt_text: 'logo of logo', }, + text_color: '#GGGGGG', }, ], - }) + }, + }, + credential_endpoint: 'https://credential-issuer.com/credential', + authorization_servers: ['https://one.com'], + batch_credential_issuance: { + batch_size: 10, + }, + display: [ + { + name: 'Oid4vciIssuer', + locale: 'nl-NL', + logo: { + alt_text: 'some-log', + uri: 'https://some-logo.com', + }, + }, + ], +}) - const issuerMetadata = { - credentialIssuer: credentialIssuerMetadata, - authorizationServers: [ - { - issuer: 'https://one.com', - token_endpoint: 'https://one.com/token', - }, - ], - originalDraftVersion: Oid4vciDraftVersion.Draft11, - } as const satisfies IssuerMetadataResult +const issuerMetadata = { + credentialIssuer: credentialIssuerMetadata, + authorizationServers: [ + { + issuer: 'https://one.com', + token_endpoint: 'https://one.com/token', + }, + ], + originalDraftVersion: Oid4vciDraftVersion.Draft11, +} as const satisfies IssuerMetadataResult +describe('Oid4vciIssuer', () => { + test('create issuer metadata, create a credential offer, parse a credential request with jwt proof, create a credential response', async () => { const credentialOffer = await issuer.createCredentialOffer({ credentialConfigurationIds: ['pidSdJwt'], grants: { @@ -115,11 +126,30 @@ describe('Oid4vciIssuer', () => { }, }) + const keyAttestationJwt = await createKeyAttestationJwt({ + callbacks: { + ...callbacks, + signJwt: getSignJwtCallback([keyAttestationJwk]), + }, + attestedKeys: [credentialRequestProofJwkPublic], + expiresAt: addSecondsToDate(new Date(), 500), + use: 'proof_type.jwt', + keyStorage: ['iso_18045_high'], + userAuthentication: ['iso_18045_high'], + signer: { + method: 'jwk', + alg: 'ES256', + publicJwk: keyAttestationJwkPublic, + }, + nonce: 'some-nonce', + }) + const credentialRequestJwt = await createCredentialRequestJwtProof({ callbacks: { ...callbacks, signJwt: getSignJwtCallback([credentialRequestProofJwk]), }, + keyAttestationJwt, credentialIssuer: credentialIssuerMetadata.credential_issuer, signer: { method: 'jwk', @@ -173,12 +203,33 @@ describe('Oid4vciIssuer', () => { alg: 'ES256', jwk: credentialRequestProofJwkPublic, typ: 'openid4vci-proof+jwt', + key_attestation: keyAttestationJwt, }, payload: { aud: 'https://credential-issuer.com', iat: expect.any(Number), nonce: 'some-nonce', }, + keyAttestation: { + header: { + alg: 'ES256', + jwk: keyAttestationJwkPublic, + typ: 'keyattestation+jwt', + }, + payload: { + attested_keys: [credentialRequestProofJwkPublic], + exp: expect.any(Number), + iat: expect.any(Number), + key_storage: ['iso_18045_high'], + nonce: 'some-nonce', + user_authentication: ['iso_18045_high'], + }, + signer: { + alg: 'ES256', + method: 'jwk', + publicJwk: keyAttestationJwkPublic, + }, + }, }) const credentialResponse = issuer.createCredentialResponse({ diff --git a/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts b/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts index 5ad1995..0566fe6 100644 --- a/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts +++ b/packages/oid4vci/src/credential-request/__tests__/parse-credential-request.test.ts @@ -11,13 +11,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'vc+sd-jwt', @@ -29,7 +29,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -44,13 +44,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'mso_mdoc', @@ -62,7 +62,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -85,7 +85,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -103,7 +103,7 @@ describe('Parse Credential Request', () => { }, }, proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'ldp_vc', @@ -119,7 +119,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -142,13 +142,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json-ld', @@ -176,7 +176,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -198,13 +198,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json', @@ -230,7 +230,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -244,13 +244,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { @@ -258,7 +258,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -297,20 +297,20 @@ describe('Parse Credential Request', () => { credential_identifier: 'some-identifier', extra_prop: 'should-stay', proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, }, }) ).toStrictEqual({ proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { credential_identifier: 'some-identifier', extra_prop: 'should-stay', proofs: { - jwt: ['one', 'two'], + jwt: ['ey.ey.S', 'ey.ey.S'], }, }, }) @@ -348,13 +348,13 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialIdentifier: 'some-identifier', credentialRequest: { @@ -362,7 +362,7 @@ describe('Parse Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -375,19 +375,19 @@ describe('Parse Credential Request', () => { format: 'a-new-format', some_random_prop: 'should-be-allowed', proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'a-new-format', some_random_prop: 'should-be-allowed', proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, }, }) @@ -407,13 +407,13 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'jwt_vc_json', @@ -439,7 +439,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -462,7 +462,7 @@ describe('Parse Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -480,7 +480,7 @@ describe('Parse Credential Request', () => { }, }, proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, credentialRequest: { format: 'jwt_vc_json-ld', @@ -496,7 +496,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) @@ -519,13 +519,13 @@ describe('Parse Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) ).toStrictEqual({ proofs: { - jwt: ['hello'], + jwt: ['ey.ey.S'], }, format: { format: 'ldp_vc', @@ -553,7 +553,7 @@ describe('Parse Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, }) diff --git a/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts b/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts index 4e976c7..d4ca407 100644 --- a/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts +++ b/packages/oid4vci/src/credential-request/__tests__/v-credential-request.test.ts @@ -11,10 +11,10 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, proofs: { - jwt: ['one'], + jwt: ['ey.ey.S'], }, }) @@ -38,7 +38,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -63,7 +63,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -75,7 +75,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -90,7 +90,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -102,7 +102,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -125,7 +125,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -145,7 +145,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -168,7 +168,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -188,7 +188,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -210,7 +210,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -229,7 +229,7 @@ describe('Credential Request', () => { extra_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -242,7 +242,7 @@ describe('Credential Request', () => { credential_identifier: 'some-identifier', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -252,7 +252,7 @@ describe('Credential Request', () => { credential_identifier: 'some-identifier', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -266,7 +266,7 @@ describe('Credential Request', () => { some_random_prop: 'should-be-allowed', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -277,7 +277,7 @@ describe('Credential Request', () => { some_random_prop: 'should-be-allowed', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -297,7 +297,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -316,7 +316,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -339,7 +339,7 @@ describe('Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -359,7 +359,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, @@ -382,7 +382,7 @@ describe('Credential Request', () => { some_other_prop: 'should-stay', proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }) @@ -402,7 +402,7 @@ describe('Credential Request', () => { }, proof: { proof_type: 'jwt', - jwt: 'hello', + jwt: 'ey.ey.S', }, }, success: true, diff --git a/packages/oid4vci/src/credential-request/parse-credential-request.ts b/packages/oid4vci/src/credential-request/parse-credential-request.ts index 8074925..24d72e0 100644 --- a/packages/oid4vci/src/credential-request/parse-credential-request.ts +++ b/packages/oid4vci/src/credential-request/parse-credential-request.ts @@ -1,6 +1,7 @@ import { parseWithErrorHandling } from '@animo-id/oauth2-utils' import * as v from 'valibot' import type { CredentialFormatIdentifier } from '../formats/credential' +import { attestationProofTypeIdentifier } from '../formats/proof-type/attestation/v-attestation-proof-type' import { jwtProofTypeIdentifier } from '../formats/proof-type/jwt/v-jwt-proof-type' import { type CredentialRequest, @@ -28,7 +29,7 @@ export interface ParseCredentialRequestReturn { format?: CredentialRequestFormatSpecific /** - * If the reuest contains `proof` or `proofs` with a `proof_type` that is known to this + * If the request contains `proof` or `proofs` with a `proof_type` that is known to this * library it will have the proof type specific data defined here. Will not be defined * if the `proof_type` is not known or no `proof` or `proofs` were included. * @@ -72,6 +73,8 @@ export function parseCredentialRequest(options: ParseCredentialRequestOptions): const knownProof = v.safeParse(v.union(allCredentialRequestProofs), credentialRequest.proof) if (knownProof.success && knownProof.output.proof_type === jwtProofTypeIdentifier) { proofs = { [jwtProofTypeIdentifier]: [knownProof.output.jwt] } + } else if (knownProof.success && knownProof.output.proof_type === attestationProofTypeIdentifier) { + proofs = { [attestationProofTypeIdentifier]: [knownProof.output.attestation] } } if (credentialRequest.credential_identifier) { diff --git a/packages/oid4vci/src/credential-request/v-credential-request-common.ts b/packages/oid4vci/src/credential-request/v-credential-request-common.ts index c5fd97e..a7f40a3 100644 --- a/packages/oid4vci/src/credential-request/v-credential-request-common.ts +++ b/packages/oid4vci/src/credential-request/v-credential-request-common.ts @@ -1,14 +1,19 @@ import { vJwk } from '@animo-id/oauth2' import type { InferOutputUnion, Simplify } from '@animo-id/oauth2-utils' import * as v from 'valibot' -import type { ProofTypeIdentifier } from '../formats/proof-type' -import { vCredentialRequestProofJwt, vJwtProofTypeIdentifier } from '../formats/proof-type/jwt/v-jwt-proof-type' +import { + type ProofTypeIdentifier, + vAttestationProofTypeIdentifier, + vCredentialRequestProofAttestation, + vCredentialRequestProofJwt, + vJwtProofTypeIdentifier, +} from '../formats/proof-type' const vCredentialRequestProofCommon = v.looseObject({ proof_type: v.string(), }) -export const allCredentialRequestProofs = [vCredentialRequestProofJwt] as const +export const allCredentialRequestProofs = [vCredentialRequestProofJwt, vCredentialRequestProofAttestation] as const const allCredentialRequestProofsTypes = allCredentialRequestProofs.map((format) => format.entries.proof_type.literal) export const vCredentialRequestProof = v.intersect([ @@ -31,6 +36,9 @@ export const vCredentialRequestProof = v.intersect([ const vCredentialRequestProofsCommon = v.record(v.string(), v.array(v.unknown())) export const vCredentialRequestProofs = v.object({ [vJwtProofTypeIdentifier.literal]: v.optional(v.array(vCredentialRequestProofJwt.entries.jwt)), + [vAttestationProofTypeIdentifier.literal]: v.optional( + v.array(vCredentialRequestProofAttestation.entries.attestation) + ), }) type CredentialRequestProofCommon = v.InferOutput @@ -48,7 +56,15 @@ export type CredentialRequestProofs = v.InferOutput Object.values(proofs).length === 1, + `The 'proofs' object in a credential request should contain exactly one attribute` + ) + ) + ), credential_response_encryption: v.optional( v.looseObject({ @@ -62,10 +78,5 @@ export const vCredentialRequestCommon = v.pipe( v.check( ({ proof, proofs }) => !(proof !== undefined && proofs !== undefined), `Both 'proof' and 'proofs' are defined. Only one is allowed` - ), - // Only one proof type allowed per requet - v.check( - ({ proofs }) => (proofs === undefined ? true : Object.values(proofs).length === 1), - `The 'proofs' object in a credential request should contain exactly one attribute` ) ) diff --git a/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts b/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts new file mode 100644 index 0000000..c79b73f --- /dev/null +++ b/packages/oid4vci/src/formats/proof-type/attestation/attestation-proof-type.ts @@ -0,0 +1,39 @@ +import { + type CreateKeyAttestationJwtOptions, + type VerifyKeyAttestationJwtOptions, + createKeyAttestationJwt, + verifyKeyAttestationJwt, +} from '../../../key-attestation/key-attestation' + +export interface CreateCredentialRequestAttestationProofOptions extends Omit { + /** + * Nonce to use in the attestation. Should be derived from the c_nonce + * + * Required because the attestation is created for 'attestation' proof types + */ + nonce: string + + /** + * The date when the key attestation will expire. + */ + expiresAt: Date +} + +export async function createCredentialRequestAttestationProof( + options: CreateCredentialRequestAttestationProofOptions +): Promise { + return createKeyAttestationJwt({ + ...options, + use: 'proof_type.attestation', + }) +} + +export interface VerifyCredentialRequestAttestationProofOptions extends Omit {} +export async function verifyCredentialRequestAttestationProof(options: VerifyCredentialRequestAttestationProofOptions) { + const verificationResult = await verifyKeyAttestationJwt({ + ...options, + use: 'proof_type.attestation', + }) + + return verificationResult +} diff --git a/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts b/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts new file mode 100644 index 0000000..0827bf8 --- /dev/null +++ b/packages/oid4vci/src/formats/proof-type/attestation/v-attestation-proof-type.ts @@ -0,0 +1,25 @@ +import * as v from 'valibot' + +import { vCompactJwt } from '@animo-id/oauth2' +import { + type KeyAttestationJwtHeader, + vKeyAttestationJwtHeader, + vKeyAttestationJwtPayloadForUse, +} from '../../../key-attestation/v-key-attestation' + +export const vAttestationProofTypeIdentifier = v.literal('attestation') +export const attestationProofTypeIdentifier = vAttestationProofTypeIdentifier.literal +export type AttestationProofTypeIdentifier = v.InferOutput + +export const vCredentialRequestProofAttestation = v.object({ + proof_type: vAttestationProofTypeIdentifier, + attestation: vCompactJwt, +}) + +export const vCredentialRequestAttestationProofTypeHeader = vKeyAttestationJwtHeader +export type CredentialRequestAttestationProofTypeHeader = KeyAttestationJwtHeader + +export const vCredentialRequestAttestationProofTypePayload = vKeyAttestationJwtPayloadForUse('proof_type.attestation') +export type CredentialRequestAttestationProofTypePayload = v.InferOutput< + typeof vCredentialRequestAttestationProofTypePayload +> diff --git a/packages/oid4vci/src/formats/proof-type/index.ts b/packages/oid4vci/src/formats/proof-type/index.ts index 5415a82..4cbb189 100644 --- a/packages/oid4vci/src/formats/proof-type/index.ts +++ b/packages/oid4vci/src/formats/proof-type/index.ts @@ -1,5 +1,18 @@ +import type { AttestationProofTypeIdentifier } from './attestation/v-attestation-proof-type' import type { JwtProofTypeIdentifier } from './jwt/v-jwt-proof-type' -export { JwtProofTypeIdentifier } from './jwt/v-jwt-proof-type' +// jwt +export { + type JwtProofTypeIdentifier, + vCredentialRequestProofJwt, + vJwtProofTypeIdentifier, +} from './jwt/v-jwt-proof-type' -export type ProofTypeIdentifier = JwtProofTypeIdentifier +// attestation +export { + type AttestationProofTypeIdentifier, + vCredentialRequestProofAttestation, + vAttestationProofTypeIdentifier, +} from './attestation/v-attestation-proof-type' + +export type ProofTypeIdentifier = JwtProofTypeIdentifier | AttestationProofTypeIdentifier diff --git a/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts b/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts index 9412777..b6c71f0 100644 --- a/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts +++ b/packages/oid4vci/src/formats/proof-type/jwt/jwt-proof-type.ts @@ -1,4 +1,4 @@ -import { type JwtSigner, decodeJwt, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' +import { type JwtSigner, decodeJwt, isJwkInSet, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' import { type CredentialRequestJwtProofTypeHeader, type CredentialRequestJwtProofTypePayload, @@ -7,7 +7,10 @@ import { } from './v-jwt-proof-type' import { type CallbackContext, jwtSignerFromJwt, verifyJwt } from '@animo-id/oauth2' +import { dateToSeconds, parseWithErrorHandling } from '@animo-id/oauth2-utils' import { Oid4vciError } from '../../../error/Oid4vciError' +import { type VerifyKeyAttestationJwtReturn, verifyKeyAttestationJwt } from '../../../key-attestation/key-attestation' +import { vKeyAttestationJwtHeader, vKeyAttestationJwtPayload } from '../../../key-attestation/v-key-attestation' export interface CreateCredentialRequestJwtProofOptions { /** @@ -31,27 +34,56 @@ export interface CreateCredentialRequestJwtProofOptions { */ clientId?: string - signer: JwtSigner + /** + * Key attestation jwt that the proof should based on. In this case it is required that the `signer` uses + * a key from the `attested_keys` in the key attestation jwt payload. + */ + keyAttestationJwt?: string - callbacks: Pick + signer: JwtSigner + callbacks: Pick } export async function createCredentialRequestJwtProof( options: CreateCredentialRequestJwtProofOptions ): Promise { - const header: CredentialRequestJwtProofTypeHeader = { + const header = parseWithErrorHandling(vCredentialRequestJwtProofTypeHeader, { ...jwtHeaderFromJwtSigner(options.signer), + key_attestation: options.keyAttestationJwt, typ: 'openid4vci-proof+jwt', - } + } satisfies CredentialRequestJwtProofTypeHeader) - const payload = { + const payload = parseWithErrorHandling(vCredentialRequestJwtProofTypePayload, { nonce: options.nonce, aud: options.credentialIssuer, - iat: Math.floor((options.issuedAt ?? new Date()).getTime() / 1000), + iat: dateToSeconds(options.issuedAt), iss: options.clientId, - } satisfies CredentialRequestJwtProofTypePayload + } satisfies CredentialRequestJwtProofTypePayload) + + const { jwt, signerJwk } = await options.callbacks.signJwt(options.signer, { header, payload }) + + // Check the jwt is signed with an key from attested_keys in the key_attestation jwt + if (options.keyAttestationJwt) { + const decodedKeyAttestation = decodeJwt({ + jwt: options.keyAttestationJwt, + headerSchema: vKeyAttestationJwtHeader, + payloadSchema: vKeyAttestationJwtPayload, + }) + + const isSigedWithAttestedKey = await isJwkInSet({ + jwk: signerJwk, + jwks: decodedKeyAttestation.payload.attested_keys, + callbacks: options.callbacks, + }) + + if (!isSigedWithAttestedKey) { + throw new Oid4vciError( + `Credential request jwt proof is not signed with a key in the 'key_attestation' jwt payload 'attested_keys'` + ) + } + } - return await options.callbacks.signJwt(options.signer, { header, payload }) + return jwt } export interface VerifyCredentialRequestJwtProofOptions { @@ -86,9 +118,11 @@ export interface VerifyCredentialRequestJwtProofOptions { now?: Date /** - * Callbacks required for the jwt verification + * Callbacks required for the jwt verification. + * + * Will be used for the jwt proof, and optionally a `key_attestation` in the jwt proof header. */ - callbacks: Pick + callbacks: Pick } export async function verifyCredentialRequestJwtProof(options: VerifyCredentialRequestJwtProofOptions) { @@ -103,12 +137,11 @@ export async function verifyCredentialRequestJwtProof(options: VerifyCredentialR throw new Oid4vciError('Nonce used for credential request proof expired') } - const signer = jwtSignerFromJwt({ header, payload }) - await verifyJwt({ + const { signer } = await verifyJwt({ compact: options.jwt, header, payload, - signer, + signer: jwtSignerFromJwt({ header, payload }), verifyJwtCallback: options.callbacks.verifyJwt, errorMessage: 'Error verifiying credential request proof jwt', expectedNonce: options.expectedNonce, @@ -117,9 +150,32 @@ export async function verifyCredentialRequestJwtProof(options: VerifyCredentialR now: options.now, }) + let keyAttestationResult: VerifyKeyAttestationJwtReturn | undefined = undefined + // Check the jwt is signed with an key from attested_keys in the key_attestation jwt + if (header.key_attestation) { + keyAttestationResult = await verifyKeyAttestationJwt({ + callbacks: options.callbacks, + keyAttestationJwt: header.key_attestation, + use: 'proof_type.jwt', + }) + + const isSigedWithAttestedKey = await isJwkInSet({ + jwk: signer.publicJwk, + jwks: keyAttestationResult.payload.attested_keys, + callbacks: options.callbacks, + }) + + if (!isSigedWithAttestedKey) { + throw new Oid4vciError( + `Credential request jwt proof is not signed with a key in the 'key_attestation' jwt payload 'attested_keys'` + ) + } + } + return { header, payload, signer, + keyAttestation: keyAttestationResult, } } diff --git a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts index 723fa00..82d931e 100644 --- a/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts +++ b/packages/oid4vci/src/formats/proof-type/jwt/v-jwt-proof-type.ts @@ -15,6 +15,7 @@ export const vCredentialRequestProofJwt = v.object({ export const vCredentialRequestJwtProofTypeHeader = v.pipe( v.looseObject({ ...vJwtHeader.entries, + key_attestation: v.optional(vCompactJwt), typ: v.literal('openid4vci-proof+jwt'), }), v.check( diff --git a/packages/oid4vci/src/key-attestation/key-attestation.ts b/packages/oid4vci/src/key-attestation/key-attestation.ts new file mode 100644 index 0000000..f2ce851 --- /dev/null +++ b/packages/oid4vci/src/key-attestation/key-attestation.ts @@ -0,0 +1,169 @@ +import { type Jwk, type JwtSigner, decodeJwt, jwtHeaderFromJwtSigner } from '@animo-id/oauth2' + +import { type CallbackContext, jwtSignerFromJwt, verifyJwt } from '@animo-id/oauth2' +import { type StringWithAutoCompletion, dateToSeconds, parseWithErrorHandling } from '@animo-id/oauth2-utils' +import { Oid4vciError } from '../error/Oid4vciError' +import { + type Iso18045, + type KeyAttestationJwtHeader, + type KeyAttestationJwtPayload, + type KeyAttestationJwtUse, + vKeyAttestationJwtHeader, + vKeyAttestationJwtPayloadForUse, +} from './v-key-attestation' + +export interface CreateKeyAttestationJwtOptions { + /** + * Nonce to use in the key attestation. + * + * MUST be present if the attestation is used with the attestation proof + */ + nonce?: string + + /** + * The date when the key attestation was issued. If not provided the current time will be used. + */ + issuedAt?: Date + + /** + * The date when the key attestation will expire. + * + * MUST be present if the attestation is used with the JWT proof + */ + expiresAt?: Date + + /** + * The keys that the attestation jwt attests. + */ + attestedKeys: Jwk[] + + /** + * Optional attack potential resistance of attested keys and key storage + */ + keyStorage?: StringWithAutoCompletion[] + + /** + * Optional attack potential resistance of user authentication methods + */ + userAuthentication?: StringWithAutoCompletion[] + + /** + * Optional url linking to the certification of the key storage component. + */ + certification?: string + + /** + * The intended use of the key attestation. Based on this additional validation + * is performed. + * + * - `proof_type.jwt` -> `exp` MUST be set + * - `proof_type.attestation` -> `nonce` MUST be set + */ + use?: KeyAttestationJwtUse + + /** + * Signer of the key attestation jwt + */ + signer: JwtSigner + + /** + * Callbacks used for creating the key attestation jwt + */ + callbacks: Pick + + /** + * Additional payload to include in the key attestation jwt payload. Will be applied after + * any default claims that are included, so add claims with caution. + */ + additionalPayload?: Record +} + +export async function createKeyAttestationJwt(options: CreateKeyAttestationJwtOptions): Promise { + const header = parseWithErrorHandling(vKeyAttestationJwtHeader, { + ...jwtHeaderFromJwtSigner(options.signer), + typ: 'keyattestation+jwt', + } satisfies KeyAttestationJwtHeader) + + const payload = parseWithErrorHandling(vKeyAttestationJwtPayloadForUse(options.use), { + iat: dateToSeconds(options.issuedAt), + exp: options.expiresAt ? dateToSeconds(options.expiresAt) : undefined, + nonce: options.nonce, + attested_keys: options.attestedKeys, + user_authentication: options.userAuthentication, + key_storage: options.keyStorage, + certification: options.certification, + ...options.additionalPayload, + } satisfies KeyAttestationJwtPayload) + + const { jwt } = await options.callbacks.signJwt(options.signer, { header, payload }) + return jwt +} + +export interface VerifyKeyAttestationJwtOptions { + /** + * The compact key attestation jwt + */ + keyAttestationJwt: string + + /** + * Expected nonce. If the key attestation is used directly as proof this should be provided. + */ + expectedNonce?: string + + /** + * Date at which the nonce will expire + */ + nonceExpiresAt?: Date + + /** + * The intended use of the key attestation. Based on this additional validation + * is performed. + * + * - `proof_type.jwt` -> `exp` MUST be set + * - `proof_type.attestation` -> `nonce` MUST be set + */ + use?: KeyAttestationJwtUse + + /** + * Current time, if not provided a new date instance will be created + */ + now?: Date + + /** + * Callbacks required for the key attestation jwt verification + */ + callbacks: Pick +} + +export type VerifyKeyAttestationJwtReturn = Awaited> +export async function verifyKeyAttestationJwt(options: VerifyKeyAttestationJwtOptions) { + const { header, payload } = decodeJwt({ + jwt: options.keyAttestationJwt, + headerSchema: vKeyAttestationJwtHeader, + payloadSchema: vKeyAttestationJwtPayloadForUse(options.use), + }) + + // TODO: if you use stateless nonce, it doesn't make sense to verify the nonce here + // We should just return the nonce after verification so it can be checked (or actually, it should be checked upfront) + const now = options.now?.getTime() ?? Date.now() + if (options.nonceExpiresAt && now > options.nonceExpiresAt.getTime()) { + throw new Oid4vciError('Nonce used for key attestation jwt expired') + } + + const { signer } = await verifyJwt({ + compact: options.keyAttestationJwt, + header, + payload, + signer: jwtSignerFromJwt({ header, payload }), + verifyJwtCallback: options.callbacks.verifyJwt, + errorMessage: 'Error verifiying key attestation jwt', + expectedNonce: options.expectedNonce, + now: options.now, + }) + + return { + header, + payload, + signer, + } +} diff --git a/packages/oid4vci/src/key-attestation/v-key-attestation.ts b/packages/oid4vci/src/key-attestation/v-key-attestation.ts new file mode 100644 index 0000000..13f8d2e --- /dev/null +++ b/packages/oid4vci/src/key-attestation/v-key-attestation.ts @@ -0,0 +1,55 @@ +import * as v from 'valibot' + +import { vJwk, vJwtHeader, vJwtPayload } from '@animo-id/oauth2' +import { vInteger } from '@animo-id/oauth2-utils' + +export type KeyAttestationJwtUse = 'proof_type.jwt' | 'proof_type.attestation' + +export const vKeyAttestationJwtHeader = v.pipe( + v.looseObject({ + ...vJwtHeader.entries, + typ: v.literal('keyattestation+jwt'), + }), + v.check( + ({ kid, jwk }) => jwk === undefined || kid === undefined, + `Both 'jwk' and 'kid' are defined. Only one is allowed` + ), + v.check(({ trust_chain, kid }) => !trust_chain || !kid, `When 'trust_chain' is provided, 'kid' is required`) +) +export type KeyAttestationJwtHeader = v.InferOutput + +export const vIso18045 = v.picklist([ + 'iso_18045_high', + 'iso_18045_moderate', + 'iso_18045_enhanced-basic', + 'iso_18045_basic', +]) + +export type Iso18045 = v.InferOutput +export const vIso18045OrStringArray = v.array(v.union([vIso18045, v.string()])) + +export const vKeyAttestationJwtPayload = v.looseObject({ + ...vJwtPayload.entries, + iat: vInteger, + + attested_keys: v.array(vJwk), + key_storage: v.optional(vIso18045OrStringArray), + user_authentication: v.optional(vIso18045OrStringArray), + certification: v.optional(v.string()), +}) + +export const vKeyAttestationJwtPayloadForUse = (use?: Use) => + v.looseObject({ + ...vKeyAttestationJwtPayload.entries, + + // REQUIRED when used as proof_type.attesation directly + nonce: + use === 'proof_type.attestation' + ? v.string(`Nonce must be defined when key attestation is used as 'proof_type.attestation' directly`) + : v.optional(v.string()), + + // REQUIRED when used within header of proof_type.jwt + exp: use === 'proof_type.jwt' ? vInteger : v.optional(vInteger), + }) + +export type KeyAttestationJwtPayload = v.InferOutput diff --git a/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts b/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts index 4ff9525..83bcac6 100644 --- a/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts +++ b/packages/oid4vci/src/metadata/credential-issuer/v-credential-configuration-supported-common.ts @@ -1,4 +1,5 @@ import * as v from 'valibot' +import { vIso18045OrStringArray } from '../../key-attestation/v-key-attestation' export const vCredentialConfigurationSupportedClaims = v.looseObject({ mandatory: v.optional(v.boolean()), @@ -18,9 +19,15 @@ export const vCredentialConfigurationSupportedCommon = v.looseObject({ credential_signing_alg_values_supported: v.optional(v.array(v.string())), proof_types_supported: v.optional( v.record( - v.union([v.literal('jwt'), v.string()]), + v.union([v.literal('jwt'), v.literal('attestation'), v.string()]), v.object({ proof_signing_alg_values_supported: v.array(v.string()), + key_attestations_required: v.optional( + v.looseObject({ + key_storage: v.optional(vIso18045OrStringArray), + user_authentication: v.optional(vIso18045OrStringArray), + }) + ), }) ) ), diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 8e006f8..f808284 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -22,7 +22,7 @@ export { valibotRecursiveFlattenIssues, } from './parse' export { joinUriParts } from './path' -export type { Optional, Simplify, StringWithAutoCompletion } from './type' +export type { Optional, Simplify, StringWithAutoCompletion, OrPromise } from './type' export { getQueryParams, objectToQueryParams } from './url' export { type ValibotFetcher, createValibotFetcher, defaultFetcher } from './valibot-fetcher' export { type HttpMethod, vHttpMethod, vHttpsUrl, vInteger } from './validation' diff --git a/packages/utils/src/type.ts b/packages/utils/src/type.ts index 064ee08..042a487 100644 --- a/packages/utils/src/type.ts +++ b/packages/utils/src/type.ts @@ -1,3 +1,4 @@ export type Simplify = { [KeyType in keyof T]: T[KeyType] } & {} export type Optional = Omit & Partial> -export type StringWithAutoCompletion = string & {} +export type StringWithAutoCompletion = T | (string & {}) +export type OrPromise = T | Promise