From d1bbf81d22e9e352e3f352b6ea23a2263892a8b7 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Tue, 5 Nov 2024 02:50:18 +0530 Subject: [PATCH] feat: authorization challenge Signed-off-by: Timo Glastra --- biome.json | 2 +- packages/oauth2/src/Oauth2Client.ts | 81 ++++++++ .../src/access-token/introspect-token.ts | 1 + .../parse-access-token-request.ts | 2 +- .../authorization-challenge.ts | 142 ++++++++++++++ .../v-authorization-challenge.ts | 33 ++++ .../create-authorization-request.ts | 3 +- packages/oauth2/src/common/v-oauth2-error.ts | 15 +- ...Oauth2ClientAuthorizationChallengeError.ts | 13 ++ packages/oauth2/src/index.ts | 7 +- .../v-authorization-server-metadata.ts | 3 + .../verify-resource-request.ts | 2 +- packages/oid4vci/src/Oid4vciClient.ts | 150 ++++++++++++++- .../src/__tests__/Oid4vciClient.test.ts | 174 +++++++++++++++++- .../presentationDuringIssuance.ts | 68 +++++++ packages/oid4vci/tests/full-flow.test.ts | 4 +- packages/utils/src/globals.ts | 21 ++- packages/utils/src/index.ts | 2 +- packages/utils/src/url.ts | 4 +- packages/utils/tsconfig.json | 3 +- tsconfig.json | 3 +- 21 files changed, 707 insertions(+), 26 deletions(-) create mode 100644 packages/oauth2/src/authorization-challenge/authorization-challenge.ts create mode 100644 packages/oauth2/src/authorization-challenge/v-authorization-challenge.ts create mode 100644 packages/oauth2/src/error/Oauth2ClientAuthorizationChallengeError.ts create mode 100644 packages/oid4vci/src/__tests__/__fixtures__/presentationDuringIssuance.ts diff --git a/biome.json b/biome.json index 8af431f..405397e 100644 --- a/biome.json +++ b/biome.json @@ -35,7 +35,7 @@ "noRestrictedGlobals": { "level": "error", "options": { - "deniedGlobals": ["URL", "URLSearchParams", "fetch"] + "deniedGlobals": ["URL", "URLSearchParams", "fetch", "Response", "Headers"] } } }, diff --git a/packages/oauth2/src/Oauth2Client.ts b/packages/oauth2/src/Oauth2Client.ts index fe54bb1..273bad2 100644 --- a/packages/oauth2/src/Oauth2Client.ts +++ b/packages/oauth2/src/Oauth2Client.ts @@ -1,16 +1,24 @@ +import { objectToQueryParams } from '@animo-id/oid4vc-utils' import { type RetrieveAuthorizationCodeAccessTokenOptions, type RetrievePreAuthorizedCodeAccessTokenOptions, retrieveAuthorizationCodeAccessToken, retrievePreAuthorizedCodeAccessToken, } from './access-token/retrieve-access-token' +import { + type SendAuthorizationChallengeRequestOptions, + sendAuthorizationChallengeRequest, +} from './authorization-challenge/authorization-challenge' import { type CreateAuthorizationRequestUrlOptions, createAuthorizationRequestUrl, } from './authorization-request/create-authorization-request' import type { CallbackContext } from './callbacks' +import { Oauth2ErrorCodes } from './common/v-oauth2-error' +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' +import { createPkce } from './pkce' export interface Oauth2ClientOptions { /** @@ -42,6 +50,79 @@ export class Oauth2Client { return fetchAuthorizationServerMetadata(issuer, this.options.callbacks.fetch) } + /** + * Initiate authorization. + * + * It will take the followings steps: + * - if `authorization_challenge_endpoint` is defined, send an authorization challenge request + * - if authorization challenge request returns a `redirect_to_web` error code with `request_uri` + * then construct the authorization request url based on the `request_uri` + * - if the `authorization_challenge_endpoint` is not defined, or authorization challenge request reuturns a `redirect_to_web` error code without `request_uri` + * then the authorization request url will be constructed as usual (optionally using PAR). + * + * @throws {Oauth2ClientAuthorizationChallengeError} in case of an error response. If `error` is + * `insufficient_authorization` possible extra steps can be taken. + */ + public async initiateAuthorization(options: Omit) { + const pkce = options.authorizationServerMetadata.code_challenge_methods_supported + ? await createPkce({ + allowedCodeChallengeMethods: options.authorizationServerMetadata.code_challenge_methods_supported, + callbacks: this.options.callbacks, + codeVerifier: options.pkceCodeVerifier, + }) + : undefined + + if (options.authorizationServerMetadata.authorization_challenge_endpoint) { + try { + await this.sendAuthorizationChallengeRequest({ + authorizationServerMetadata: options.authorizationServerMetadata, + additionalRequestPayload: options.additionalRequestPayload, + clientId: options.clientId, + pkceCodeVerifier: pkce?.codeVerifier, + scope: options.scope, + }) + } catch (error) { + // In this case we resume with the normal auth flow + const isRecoverableError = + error instanceof Oauth2ClientAuthorizationChallengeError && + error.errorResponse.error === Oauth2ErrorCodes.RedirectToWeb + + if (!isRecoverableError) throw error + + // If a request_uri was returned we can treat the response as if PAR was used + if (error.errorResponse.request_uri) { + const authorizationRequestUrl = `${options.authorizationServerMetadata.authorization_endpoint}?${objectToQueryParams( + { + request_uri: error.errorResponse.request_uri, + client_id: options.clientId, + } + )}` + + return { + authorizationRequestUrl, + pkce, + } + } + } + } + + return this.createAuthorizationRequestUrl({ + authorizationServerMetadata: options.authorizationServerMetadata, + clientId: options.clientId, + additionalRequestPayload: options.additionalRequestPayload, + redirectUri: options.redirectUri, + scope: options.scope, + pkceCodeVerifier: pkce?.codeVerifier, + }) + } + + public sendAuthorizationChallengeRequest(options: Omit) { + return sendAuthorizationChallengeRequest({ + ...options, + callbacks: this.options.callbacks, + }) + } + public async createAuthorizationRequestUrl(options: Omit) { return createAuthorizationRequestUrl({ authorizationServerMetadata: options.authorizationServerMetadata, diff --git a/packages/oauth2/src/access-token/introspect-token.ts b/packages/oauth2/src/access-token/introspect-token.ts index cfbdd6b..9299182 100644 --- a/packages/oauth2/src/access-token/introspect-token.ts +++ b/packages/oauth2/src/access-token/introspect-token.ts @@ -4,6 +4,7 @@ import { Oauth2Error } from '../error/Oauth2Error' import { Oauth2InvalidFetchResponseError } from '../error/Oauth2InvalidFetchResponseError' import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata' +import { Headers } from '@animo-id/oid4vc-utils' import type { CallbackContext } from '../callbacks' import { type TokenIntrospectionRequest, diff --git a/packages/oauth2/src/access-token/parse-access-token-request.ts b/packages/oauth2/src/access-token/parse-access-token-request.ts index 4eed5b5..4191b05 100644 --- a/packages/oauth2/src/access-token/parse-access-token-request.ts +++ b/packages/oauth2/src/access-token/parse-access-token-request.ts @@ -99,7 +99,7 @@ export function parseAccessTokenRequest(options: ParseAccessTokenRequestOptions) } else { // Unsupported grant type throw new Oauth2ServerErrorResponseError({ - error: Oauth2ErrorCodes.InvalidGrant, + error: Oauth2ErrorCodes.UnsupportedGrantType, error_description: `The grant type '${accessTokenRequest.grant_type}' is not supported`, }) } diff --git a/packages/oauth2/src/authorization-challenge/authorization-challenge.ts b/packages/oauth2/src/authorization-challenge/authorization-challenge.ts new file mode 100644 index 0000000..6687a44 --- /dev/null +++ b/packages/oauth2/src/authorization-challenge/authorization-challenge.ts @@ -0,0 +1,142 @@ +import { + ValidationError, + createValibotFetcher, + objectToQueryParams, + parseWithErrorHandling, +} from '@animo-id/oid4vc-utils' +import * as v from 'valibot' +import type { CallbackContext } from '../callbacks' +import { ContentType } from '../common/content-type' +import { Oauth2ClientAuthorizationChallengeError } from '../error/Oauth2ClientAuthorizationChallengeError' +import { Oauth2Error } from '../error/Oauth2Error' +import { Oauth2InvalidFetchResponseError } from '../error/Oauth2InvalidFetchResponseError' +import type { AuthorizationServerMetadata } from '../metadata/authorization-server/v-authorization-server-metadata' +import { createPkce } from '../pkce' +import { + type AuthorizationChallengeRequest, + vAuthorizationChallengeErrorResponse, + vAuthorizationChallengeRequest, + vAuthorizationChallengeResponse, +} from './v-authorization-challenge' + +export interface SendAuthorizationChallengeRequestOptions { + /** + * Callback context + */ + callbacks: Pick + + /** + * Metadata of the authorization server where to perform the authorization challenge + */ + authorizationServerMetadata: AuthorizationServerMetadata + + /** + * Previously established auth session + */ + authSession?: string + + /** + * The client id to use for the authorization challenge request + */ + clientId?: string + + /** + * Scope to request for the authorization challenge request + */ + scope?: string + + /** + * Presentation during issuance sessios if credentials were presented + * as part of an issuance session + */ + presentationDuringIssuanceSession?: string + + /** + * Additional payload to include in the authorization challenge request. Items will be encoded and sent + * using x-www-form-urlencoded format. Nested items (JSON) will be stringified and url encoded. + */ + additionalRequestPayload?: Record + + /** + * Code verifier to use for pkce. If not provided a value will generated when pkce is supported + */ + pkceCodeVerifier?: string +} + +/** + * Send an authorization challenge request. + * + * @throws {Oauth2ClientAuthorizationChallengeError} if the request failed and a {@link AuthorizationChallengeErrorResponse} is returned + * @throws {Oauth2InvalidFetchResponseError} if the request failed but no error response could be parsed + * @throws {ValidationError} if a successful response was received but an error occured during verification of the {@link AuthorizationChallengeResponse} + */ +export async function sendAuthorizationChallengeRequest(options: SendAuthorizationChallengeRequestOptions) { + const fetchWithValibot = createValibotFetcher(options.callbacks.fetch) + + const authorizationServerMetadata = options.authorizationServerMetadata + if (!authorizationServerMetadata.authorization_challenge_endpoint) { + throw new Oauth2Error( + `Unable to send authorization challange. Authorization server '${authorizationServerMetadata.issuer}' has no 'authorization_challenge_endpoint'` + ) + } + + // PKCE + const pkce = authorizationServerMetadata.code_challenge_methods_supported + ? await createPkce({ + allowedCodeChallengeMethods: authorizationServerMetadata.code_challenge_methods_supported, + callbacks: options.callbacks, + codeVerifier: options.pkceCodeVerifier, + }) + : undefined + + const authorizationChallengeRequest = parseWithErrorHandling(vAuthorizationChallengeRequest, { + ...options.additionalRequestPayload, + auth_session: options.authSession, + client_id: options.clientId, + scope: options.scope, + code_challenge: pkce?.codeChallenge, + code_challenge_method: pkce?.codeChallengeMethod, + presentation_during_issuance_session: options.presentationDuringIssuanceSession, + } satisfies AuthorizationChallengeRequest) + + const { response, result } = await fetchWithValibot( + vAuthorizationChallengeResponse, + authorizationServerMetadata.authorization_challenge_endpoint, + { + method: 'POST', + body: objectToQueryParams(authorizationChallengeRequest), + 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 + ) + } + + throw new Oauth2InvalidFetchResponseError( + `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 } +} diff --git a/packages/oauth2/src/authorization-challenge/v-authorization-challenge.ts b/packages/oauth2/src/authorization-challenge/v-authorization-challenge.ts new file mode 100644 index 0000000..6fc17e2 --- /dev/null +++ b/packages/oauth2/src/authorization-challenge/v-authorization-challenge.ts @@ -0,0 +1,33 @@ +import { vInteger } from '@animo-id/oid4vc-utils' +import * as v from 'valibot' +import { vOauth2ErrorResponse } from '../common/v-oauth2-error' + +export const vAuthorizationChallengeRequest = v.looseObject({ + client_id: v.optional(v.string()), + scope: v.optional(v.string()), + auth_session: v.optional(v.string()), + + // PKCE + code_challenge: v.optional(v.string()), + code_challenge_method: v.optional(v.string()), + + // DRAFT presentation during issuance + presentation_during_issuance_session: v.optional(v.string()), +}) +export type AuthorizationChallengeRequest = v.InferOutput + +export const vAuthorizationChallengeResponse = v.looseObject({ + authorization_code: v.string(), +}) +export type AuthorizationChallengeResponse = v.InferOutput + +export const vAuthorizationChallengeErrorResponse = v.looseObject({ + ...vOauth2ErrorResponse.entries, + auth_session: v.optional(v.string()), + request_uri: v.optional(v.string()), + expires_in: v.optional(vInteger), + + // DRAFT: presentation during issuance + presentation: v.optional(v.string()), +}) +export type AuthorizationChallengeErrorResponse = v.InferOutput diff --git a/packages/oauth2/src/authorization-request/create-authorization-request.ts b/packages/oauth2/src/authorization-request/create-authorization-request.ts index 645d110..298f5eb 100644 --- a/packages/oauth2/src/authorization-request/create-authorization-request.ts +++ b/packages/oauth2/src/authorization-request/create-authorization-request.ts @@ -43,7 +43,7 @@ export interface CreateAuthorizationRequestUrlOptions { redirectUri?: string /** - * Additional payload to include in the authorizatino request. Items will be encoded and sent + * Additional payload to include in the authorization request. Items will be encoded and sent * using x-www-form-urlencoded format. Nested items (JSON) will be stringified and url encoded. */ additionalRequestPayload?: Record @@ -117,7 +117,6 @@ export async function createAuthorizationRequestUrl(options: CreateAuthorization return { authorizationRequestUrl, pkce, - authorizationServer: authorizationServerMetadata.issuer, } } diff --git a/packages/oauth2/src/common/v-oauth2-error.ts b/packages/oauth2/src/common/v-oauth2-error.ts index 86fffd6..6df13f8 100644 --- a/packages/oauth2/src/common/v-oauth2-error.ts +++ b/packages/oauth2/src/common/v-oauth2-error.ts @@ -1,17 +1,30 @@ import * as v from 'valibot' export enum Oauth2ErrorCodes { + // Oauth2 InvalidRequest = 'invalid_request', InvalidToken = 'invalid_token', InsufficientScope = 'insufficient_scope', + InvalidGrant = 'invalid_grant', + InvalidClient = 'invalid_client', + UnauthorizedClient = 'unauthorized_client', + UnsupportedGrantType = 'unsupported_grant_type', + InvalidScope = 'invalid_scope', + + // DPoP InvalidDpopProof = 'invalid_dpop_proof', UseDpopNonce = 'use_dpop_nonce', - InvalidGrant = 'invalid_grant', + + // FaPI + RedirectToWeb = 'redirect_to_web', + InvalidSession = 'invalid_session', + InsufficientAuthorization = 'insufficient_authorization', } export const vOauth2ErrorResponse = v.looseObject({ error: v.union([v.enum(Oauth2ErrorCodes), v.string()]), error_description: v.optional(v.string()), + error_uri: v.optional(v.string()), }) export type Oauth2ErrorResponse = v.InferOutput diff --git a/packages/oauth2/src/error/Oauth2ClientAuthorizationChallengeError.ts b/packages/oauth2/src/error/Oauth2ClientAuthorizationChallengeError.ts new file mode 100644 index 0000000..a9dea5d --- /dev/null +++ b/packages/oauth2/src/error/Oauth2ClientAuthorizationChallengeError.ts @@ -0,0 +1,13 @@ +import type { FetchResponse } from '@animo-id/oid4vc-utils' +import type { AuthorizationChallengeErrorResponse } from '../authorization-challenge/v-authorization-challenge' +import { Oauth2ClientErrorResponseError } from './Oauth2ClientErrorResponseError' + +export class Oauth2ClientAuthorizationChallengeError extends Oauth2ClientErrorResponseError { + public constructor( + message: string, + public readonly errorResponse: AuthorizationChallengeErrorResponse, + response: FetchResponse + ) { + super(message, errorResponse, response) + } +} diff --git a/packages/oauth2/src/index.ts b/packages/oauth2/src/index.ts index b50645e..66651a4 100644 --- a/packages/oauth2/src/index.ts +++ b/packages/oauth2/src/index.ts @@ -43,6 +43,7 @@ export { } from './error/Oauth2ResourceUnauthorizedError' export { Oauth2InvalidFetchResponseError } from './error/Oauth2InvalidFetchResponseError' export { Oauth2ClientErrorResponseError } from './error/Oauth2ClientErrorResponseError' +export { Oauth2ClientAuthorizationChallengeError } from './error/Oauth2ClientAuthorizationChallengeError' export { Oauth2ServerErrorResponseError } from './error/Oauth2ServerErrorResponseError' export { @@ -57,13 +58,17 @@ export { export { fetchJwks } from './metadata/fetch-jwks-uri' export { fetchWellKnownMetadata } from './metadata/fetch-well-known-metadata' +export { SupportedAuthenticationScheme } from './access-token/verify-access-token' export type { RetrieveAuthorizationCodeAccessTokenOptions, RetrievePreAuthorizedCodeAccessTokenOptions, } from './access-token/retrieve-access-token' export type { CreateAuthorizationRequestUrlOptions } from './authorization-request/create-authorization-request' export { resourceRequestWithDpopRetry, type ResourceRequestOptions } from './resource-request/make-resource-request' -export { type VerifyResourceRequestOptions, verifyResourceRequest } from './resource-request/verify-resource-request' +export { + type VerifyResourceRequestOptions, + verifyResourceRequest, +} from './resource-request/verify-resource-request' export type { CallbackContext, diff --git a/packages/oauth2/src/metadata/authorization-server/v-authorization-server-metadata.ts b/packages/oauth2/src/metadata/authorization-server/v-authorization-server-metadata.ts index ac9b40f..a3d6574 100644 --- a/packages/oauth2/src/metadata/authorization-server/v-authorization-server-metadata.ts +++ b/packages/oauth2/src/metadata/authorization-server/v-authorization-server-metadata.ts @@ -27,6 +27,9 @@ export const vAuthorizationServerMetadata = v.pipe( ), introspection_endpoint_auth_signing_alg_values_supported: v.optional(v.array(vAlgValueNotNone)), + // FiPA (no RFC yet) + authorization_challenge_endpoint: v.optional(vHttpsUrl), + // From OID4VCI specification 'pre-authorized_grant_anonymous_access_supported': v.optional(v.boolean()), }), diff --git a/packages/oauth2/src/resource-request/verify-resource-request.ts b/packages/oauth2/src/resource-request/verify-resource-request.ts index b33559d..e763a31 100644 --- a/packages/oauth2/src/resource-request/verify-resource-request.ts +++ b/packages/oauth2/src/resource-request/verify-resource-request.ts @@ -1,10 +1,10 @@ import { JsonParseError, ValidationError } from '@animo-id/oid4vc-utils' -import type { Jwk } from '../../dist' import { introspectToken } from '../access-token/introspect-token' import type { AccessTokenProfileJwtPayload } from '../access-token/v-access-token-jwt' import type { TokenIntrospectionResponse } from '../access-token/v-token-introspection' import { SupportedAuthenticationScheme, verifyJwtProfileAccessToken } from '../access-token/verify-access-token' import type { CallbackContext } from '../callbacks' +import type { Jwk } from '../common/jwk/v-jwk' import type { RequestLike } from '../common/v-common' import { Oauth2ErrorCodes } from '../common/v-oauth2-error' import { extractDpopJwtFromHeaders, verifyDpopJwt } from '../dpop/dpop' diff --git a/packages/oid4vci/src/Oid4vciClient.ts b/packages/oid4vci/src/Oid4vciClient.ts index 94dd764..0eefccf 100644 --- a/packages/oid4vci/src/Oid4vciClient.ts +++ b/packages/oid4vci/src/Oid4vciClient.ts @@ -2,7 +2,9 @@ import { type CallbackContext, type CreateAuthorizationRequestUrlOptions, Oauth2Client, + Oauth2ClientAuthorizationChallengeError, Oauth2Error, + Oauth2ErrorCodes, type RetrieveAuthorizationCodeAccessTokenOptions, type RetrievePreAuthorizedCodeAccessTokenOptions, authorizationCodeGrantIdentifier, @@ -10,6 +12,7 @@ import { preAuthorizedCodeGrantIdentifier, } from '@animo-id/oauth2' +import type { createPkce } from '../../oauth2/src/pkce' import { determineAuthorizationServerForCredentialOffer, resolveCredentialOffer, @@ -27,6 +30,11 @@ import { import { type IssuerMetadataResult, resolveIssuerMetadata } from './metadata/fetch-issuer-metadata' import { type SendNotifcationOptions, sendNotifcation } from './notification/notification' +export enum AuthorizationFlow { + Oauth2Redirect = 'Oauth2Redirect', + PresentationDuringIssuance = 'PresentationDuringIssuance', +} + export interface Oid4vciClientOptions { /** * Callbacks required for the oid4vc client @@ -59,6 +67,140 @@ export class Oid4vciClient { }) } + /** + * Retrieve an authorization code using an `presentation_during_issuance_session`. + * + * This can only be called if an authorization challenge was performed, and an authorization + * response including presentations was exchanged for a `presentation_during_issuance_session` + */ + public async retrieveAuthorizationCodeUsingPresentation(options: { + /** + * Auth session as returned by `{@link Oid4vciClient.initiateAuthorization} + */ + authSession: string + + /** + * Presentation during issuance session, obtained from the RP after submitting + * openid4vp authorization response + */ + presentationDuringIssuanceSession: string + + credentialOffer: CredentialOfferObject + issuerMetadata: IssuerMetadataResult + }) { + if (!options.credentialOffer.grants?.[authorizationCodeGrantIdentifier]) { + throw new Oauth2Error(`Provided credential offer does not include the 'authorization_code' grant.`) + } + + const authorizationCodeGrant = options.credentialOffer.grants[authorizationCodeGrantIdentifier] + const authorizationServer = determineAuthorizationServerForCredentialOffer({ + issuerMetadata: options.issuerMetadata, + grantAuthorizationServer: authorizationCodeGrant.authorization_server, + }) + + const authorizationServerMetadata = getAuthorizationServerMetadataFromList( + options.issuerMetadata.authorizationServers, + authorizationServer + ) + + const oauth2Client = new Oauth2Client({ callbacks: this.options.callbacks }) + // TODO: think what to do about pkce + const authorizationChallengeResponse = await oauth2Client.sendAuthorizationChallengeRequest({ + authorizationServerMetadata, + authSession: options.authSession, + presentationDuringIssuanceSession: options.presentationDuringIssuanceSession, + }) + + return authorizationChallengeResponse + } + + /** + * Initiates authorization for credential issuance. It handles the following cases: + * - Authorization Challenge + * - Pushed Authorization Request + * - Regular Authorization url + * + * In case the authorization challenge request returns an error with `insufficient_authorization` + * with a `presentation` field it means the authorization server expects presentation of credentials + * before issuance of crednetials. If this is the case, the value in `presentation` should be treated + * as an openid4vp authorization request and submitted to the verifier. Once the presentation response + * has been submitted, the RP will respnosd with a `presentation_during_issuance_session` parameter. + * Together with the `auth_session` parameter returned in this call you can retrieve an `authorization_code` + * using + */ + public async initiateAuthorization( + options: Omit & { + credentialOffer: CredentialOfferObject + issuerMetadata: IssuerMetadataResult + } + ): Promise< + // TODO: cleanup these types + | { + authorizationFlow: AuthorizationFlow.PresentationDuringIssuance + oid4vpRequestUrl: string + authSession: string + authorizationServer: string + } + | { + authorizationFlow: AuthorizationFlow.Oauth2Redirect + authorizationRequestUrl: string + authorizationServer: string + pkce?: Awaited> + } + > { + if (!options.credentialOffer.grants?.[authorizationCodeGrantIdentifier]) { + throw new Oauth2Error(`Provided credential offer does not include the 'authorization_code' grant.`) + } + + const authorizationCodeGrant = options.credentialOffer.grants[authorizationCodeGrantIdentifier] + const authorizationServer = determineAuthorizationServerForCredentialOffer({ + issuerMetadata: options.issuerMetadata, + grantAuthorizationServer: authorizationCodeGrant.authorization_server, + }) + + const authorizationServerMetadata = getAuthorizationServerMetadataFromList( + options.issuerMetadata.authorizationServers, + authorizationServer + ) + + const oauth2Client = new Oauth2Client({ callbacks: this.options.callbacks }) + + try { + const result = await oauth2Client.initiateAuthorization({ + clientId: options.clientId, + pkceCodeVerifier: options.pkceCodeVerifier, + redirectUri: options.redirectUri, + scope: options.scope, + authorizationServerMetadata, + }) + + return { + ...result, + authorizationFlow: AuthorizationFlow.Oauth2Redirect, + authorizationServer: authorizationServerMetadata.issuer, + } + } catch (error) { + // Authorization server asks us to complete oid4vp reqeust before issuance + if ( + error instanceof Oauth2ClientAuthorizationChallengeError && + error.errorResponse.error === Oauth2ErrorCodes.InsufficientAuthorization && + error.errorResponse.presentation && + // TODO: we should probably throw an specifc error if presentation is defined but not auth_session? + error.errorResponse.auth_session + ) { + return { + authorizationFlow: AuthorizationFlow.PresentationDuringIssuance, + // TODO: name? presenationRequestUrl, oid4vpRequestUrl, ?? + oid4vpRequestUrl: error.errorResponse.presentation, + authSession: error.errorResponse.auth_session, + authorizationServer: authorizationServerMetadata.issuer, + } + } + + throw error + } + } + /** * Convenience method around {@link Oauth2Client.createAuthorizationRequestUrl} * but specifically focused on a credential offer @@ -84,7 +226,7 @@ export class Oid4vciClient { authorizationServer ) - return this.oauth2Client.createAuthorizationRequestUrl({ + const { authorizationRequestUrl, pkce } = await this.oauth2Client.createAuthorizationRequestUrl({ authorizationServerMetadata, clientId: options.clientId, additionalRequestPayload: { @@ -95,6 +237,12 @@ export class Oid4vciClient { scope: options.scope, pkceCodeVerifier: options.pkceCodeVerifier, }) + + return { + authorizationRequestUrl, + pkce, + authorizationServer: authorizationServerMetadata.issuer, + } } /** diff --git a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts index 68c7d7e..0541fb8 100644 --- a/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts +++ b/packages/oid4vci/src/__tests__/Oid4vciClient.test.ts @@ -1,12 +1,15 @@ import { decodeJwt, preAuthorizedCodeGrantIdentifier } from '@animo-id/oauth2' +import { parseWithErrorHandling } from '@animo-id/oid4vc-utils' import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' -import { callbacks, getSignJwtCallback } from '../../../oauth2/tests/util' -import { Oid4vciClient } from '../Oid4vciClient' +import { vAuthorizationChallengeRequest } from '../../../oauth2/src/authorization-challenge/v-authorization-challenge' +import { callbacks, getSignJwtCallback, parseXwwwFormUrlEncoded } from '../../../oauth2/tests/util' +import { AuthorizationFlow, Oid4vciClient } from '../Oid4vciClient' import { extractScopesForCredentialConfigurationIds } from '../metadata/credential-issuer/credential-configurations' import { bdrDraft13 } from './__fixtures__/bdr' import { paradymDraft11, paradymDraft13 } from './__fixtures__/paradym' +import { presentationDuringIssuance } from './__fixtures__/presentationDuringIssuance' const server = setupServer() @@ -473,4 +476,171 @@ describe('Oid4vciClient', () => { }) expect(credentialResponse).toStrictEqual(bdrDraft13.credentialResponse) }) + + test('receive a credential using presentation during issuance', async () => { + server.resetHandlers( + http.get( + `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/openid-credential-issuer`, + () => HttpResponse.json(presentationDuringIssuance.credentialIssuerMetadata) + ), + http.get( + `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/openid-configuration`, + () => HttpResponse.text(undefined, { status: 404 }) + ), + http.get( + `${presentationDuringIssuance.credentialOfferObject.credential_issuer}/.well-known/oauth-authorization-server`, + () => HttpResponse.json(presentationDuringIssuance.authorizationServerMetadata) + ), + http.post( + presentationDuringIssuance.authorizationServerMetadata.authorization_challenge_endpoint, + async ({ request }) => { + const authorizationChallengeRequest = parseWithErrorHandling( + vAuthorizationChallengeRequest, + parseXwwwFormUrlEncoded(await request.text()) + ) + + if ( + authorizationChallengeRequest.auth_session && + authorizationChallengeRequest.presentation_during_issuance_session + ) { + expect(authorizationChallengeRequest).toEqual({ + auth_session: 'auth-session-identifier', + code_challenge: expect.any(String), + code_challenge_method: 'S256', + presentation_during_issuance_session: 'some-session', + }) + return HttpResponse.json(presentationDuringIssuance.authorizationChallengeResponse) + } + + expect(authorizationChallengeRequest).toEqual({ + client_id: '76c7c89b-8799-4bd1-a693-d49948a91b00', + scope: 'pid', + code_challenge: expect.any(String), + code_challenge_method: 'S256', + }) + return HttpResponse.json(presentationDuringIssuance.authorizationChallengeErrorResponse, { status: 400 }) + } + ), + http.post(presentationDuringIssuance.authorizationServerMetadata.token_endpoint, async ({ request }) => { + expect(await request.text()).toEqual( + `code=${presentationDuringIssuance.authorizationChallengeResponse.authorization_code}&redirect_uri=https%3A%2F%2Fexample.com%2Fredirect&grant_type=authorization_code` + ) + return HttpResponse.json(presentationDuringIssuance.accessTokenResponse) + }), + http.post(presentationDuringIssuance.credentialIssuerMetadata.credential_endpoint, async ({ request }) => { + expect(request.headers.get('Authorization')).toEqual('Bearer yvFUHf7pZBfgHd6pkI1ktc') + expect(await request.json()).toEqual({ + format: 'vc+sd-jwt', + vct: 'https://example.bmi.bund.de/credential/pid/1.0', + proof: { + proof_type: 'jwt', + jwt: expect.any(String), + }, + }) + return HttpResponse.json(presentationDuringIssuance.credentialResponse) + }) + ) + + const client = new Oid4vciClient({ + callbacks: { + ...callbacks, + fetch, + signJwt: getSignJwtCallback([presentationDuringIssuance.holderPrivateKeyJwk]), + }, + }) + + const credentialOffer = await client.resolveCredentialOffer(presentationDuringIssuance.credentialOffer) + expect(credentialOffer).toStrictEqual(presentationDuringIssuance.credentialOfferObject) + + const issuerMetadata = await client.resolveIssuerMetadata(credentialOffer.credential_issuer) + expect(issuerMetadata.credentialIssuer).toStrictEqual(presentationDuringIssuance.credentialIssuerMetadata) + expect(issuerMetadata.authorizationServers[0]).toStrictEqual(presentationDuringIssuance.authorizationServerMetadata) + + // Use a static value for the tests + const pkceCodeVerifier = 'l-yZMbym56l7IlENP17y-XgKzT6a37ut5n9yXMrh9BpTOt9g77CwCsWheRW0oMA2tL471UZhIr705MdHxRSQvQ' + const clientId = '76c7c89b-8799-4bd1-a693-d49948a91b00' + const redirectUri = 'https://example.com/redirect' + + const authorization = await client.initiateAuthorization({ + clientId, + issuerMetadata, + redirectUri, + credentialOffer, + pkceCodeVerifier, + scope: extractScopesForCredentialConfigurationIds({ + credentialConfigurationIds: credentialOffer.credential_configuration_ids, + issuerMetadata, + })?.join(' '), + }) + + if (authorization.authorizationFlow !== AuthorizationFlow.PresentationDuringIssuance) { + throw new Error('Expected presentation during issuance') + } + expect(authorization.oid4vpRequestUrl).toEqual( + presentationDuringIssuance.authorizationChallengeErrorResponse.presentation + ) + expect(authorization.authSession).toEqual( + presentationDuringIssuance.authorizationChallengeErrorResponse.auth_session + ) + expect(authorization.authorizationServer).toEqual(presentationDuringIssuance.authorizationServerMetadata.issuer) + + const { authorizationChallengeResponse } = await client.retrieveAuthorizationCodeUsingPresentation({ + issuerMetadata, + authSession: authorization.authSession, + credentialOffer, + // out of scope for now, handled by RP + presentationDuringIssuanceSession: 'some-session', + }) + expect(authorizationChallengeResponse).toStrictEqual(presentationDuringIssuance.authorizationChallengeResponse) + + const { accessTokenResponse } = await client.retrieveAuthorizationCodeAccessTokenFromOffer({ + issuerMetadata, + authorizationCode: authorizationChallengeResponse.authorization_code, + credentialOffer, + // TOOD: pkce with presentation_during_issuance? I don't think so + // pkceCodeVerifier: pkce?.codeVerifier, + redirectUri, + }) + expect(accessTokenResponse).toStrictEqual(presentationDuringIssuance.accessTokenResponse) + + const { d, ...publicKeyJwk } = presentationDuringIssuance.holderPrivateKeyJwk + const { jwt: proofJwt } = await client.createCredentialRequestJwtProof({ + issuerMetadata, + signer: { + method: 'jwk', + publicJwk: publicKeyJwk, + alg: 'ES256', + }, + clientId, + issuedAt: new Date('2024-10-10'), + credentialConfigurationId: credentialOffer.credential_configuration_ids[0], + nonce: accessTokenResponse.c_nonce, + }) + + expect(decodeJwt({ jwt: proofJwt })).toStrictEqual({ + header: { + alg: 'ES256', + jwk: publicKeyJwk, + typ: 'openid4vci-proof+jwt', + }, + payload: { + aud: presentationDuringIssuance.authorizationServerMetadata.issuer, + iat: 1728518400, + iss: clientId, + nonce: 'sjNMiqyfmBeD1qioCVyqvS', + }, + signature: expect.any(String), + }) + + const { credentialResponse } = await client.retrieveCredentials({ + accessToken: accessTokenResponse.access_token, + credentialConfigurationId: credentialOffer.credential_configuration_ids[0], + issuerMetadata, + proof: { + proof_type: 'jwt', + jwt: proofJwt, + }, + }) + expect(credentialResponse).toStrictEqual(presentationDuringIssuance.credentialResponse) + }) }) diff --git a/packages/oid4vci/src/__tests__/__fixtures__/presentationDuringIssuance.ts b/packages/oid4vci/src/__tests__/__fixtures__/presentationDuringIssuance.ts new file mode 100644 index 0000000..7c7519d --- /dev/null +++ b/packages/oid4vci/src/__tests__/__fixtures__/presentationDuringIssuance.ts @@ -0,0 +1,68 @@ +export const presentationDuringIssuance = { + credentialOffer: + 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fpresentation-during-issuance.com%22%2C%22credential_configuration_ids%22%3A%5B%22pid-sd-jwt%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%7D%7D%7D', + credentialOfferObject: { + credential_issuer: 'https://presentation-during-issuance.com', + credential_configuration_ids: ['pid-sd-jwt'], + grants: { authorization_code: {} }, + }, + authorizationServerMetadata: { + issuer: 'https://presentation-during-issuance.com', + authorization_endpoint: 'https://presentation-during-issuance.com/authorize', + token_endpoint: 'https://presentation-during-issuance.com/token', + pushed_authorization_request_endpoint: 'https://presentation-during-issuance.com/par', + authorization_challenge_endpoint: 'https://presentation-during-issuance.com/challenge', + require_pushed_authorization_requests: true, + token_endpoint_auth_methods_supported: ['none'], + response_types_supported: ['code'], + code_challenge_methods_supported: ['S256'], + dpop_signing_alg_values_supported: ['ES256'], + }, + credentialIssuerMetadata: { + credential_issuer: 'https://presentation-during-issuance.com', + credential_endpoint: 'https://presentation-during-issuance.com/credential', + credential_configurations_supported: { + 'pid-sd-jwt': { + scope: 'pid', + cryptographic_binding_methods_supported: ['jwk'], + credential_signing_alg_values_supported: ['ES256'], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['ES256'], + }, + }, + vct: 'https://example.bmi.bund.de/credential/pid/1.0', + format: 'vc+sd-jwt', + }, + }, + }, + authorizationChallengeErrorResponse: { + error: 'insufficient_authorization', + presentation: 'openid4vp://?client_id=something&request_uri=something-else', + auth_session: 'auth-session-identifier', + error_description: 'Presentation of credentials required before issuance', + }, + authorizationChallengeResponse: { + authorization_code: 'the-authorization-code', + }, + accessTokenResponse: { + access_token: 'yvFUHf7pZBfgHd6pkI1ktc', + token_type: 'Bearer', + expires_in: 3600, + c_nonce: 'sjNMiqyfmBeD1qioCVyqvS', + c_nonce_expires_in: 3600, + }, + credentialResponse: { + credential: + 'eyJ4NWMiOlsiTUlJQ2REQ0NBaHVnQXdJQkFnSUJBakFLQmdncWhrak9QUVFEQWpDQmlERUxNQWtHQTFVRUJoTUNSRVV4RHpBTkJnTlZCQWNNQmtKbGNteHBiakVkTUJzR0ExVUVDZ3dVUW5WdVpHVnpaSEoxWTJ0bGNtVnBJRWR0WWtneEVUQVBCZ05WQkFzTUNGUWdRMU1nU1VSRk1UWXdOQVlEVlFRRERDMVRVRkpKVGtRZ1JuVnVhMlVnUlZWRVNTQlhZV3hzWlhRZ1VISnZkRzkwZVhCbElFbHpjM1ZwYm1jZ1EwRXdIaGNOTWpRd05UTXhNRGd4TXpFM1doY05NalV3TnpBMU1EZ3hNekUzV2pCc01Rc3dDUVlEVlFRR0V3SkVSVEVkTUJzR0ExVUVDZ3dVUW5WdVpHVnpaSEoxWTJ0bGNtVnBJRWR0WWtneENqQUlCZ05WQkFzTUFVa3hNakF3QmdOVkJBTU1LVk5RVWtsT1JDQkdkVzVyWlNCRlZVUkpJRmRoYkd4bGRDQlFjbTkwYjNSNWNHVWdTWE56ZFdWeU1Ga3dFd1lIS29aSXpqMENBUVlJS29aSXpqMERBUWNEUWdBRU9GQnE0WU1LZzR3NWZUaWZzeXR3QnVKZi83RTdWaFJQWGlObTUyUzNxMUVUSWdCZFh5REsza1Z4R3hnZUhQaXZMUDN1dU12UzZpREVjN3FNeG12ZHVLT0JrRENCalRBZEJnTlZIUTRFRmdRVWlQaENrTEVyRFhQTFcyL0owV1ZlZ2h5dyttSXdEQVlEVlIwVEFRSC9CQUl3QURBT0JnTlZIUThCQWY4RUJBTUNCNEF3TFFZRFZSMFJCQ1l3SklJaVpHVnRieTV3YVdRdGFYTnpkV1Z5TG1KMWJtUmxjMlJ5ZFdOclpYSmxhUzVrWlRBZkJnTlZIU01FR0RBV2dCVFVWaGpBaVRqb0RsaUVHTWwyWXIrcnU4V1F2akFLQmdncWhrak9QUVFEQWdOSEFEQkVBaUFiZjVUemtjUXpoZldvSW95aTFWTjdkOEk5QnNGS20xTVdsdVJwaDJieUdRSWdLWWtkck5mMnhYUGpWU2JqVy9VLzVTNXZBRUM1WHhjT2FudXNPQnJvQmJVPSIsIk1JSUNlVENDQWlDZ0F3SUJBZ0lVQjVFOVFWWnRtVVljRHRDaktCL0gzVlF2NzJnd0NnWUlLb1pJemowRUF3SXdnWWd4Q3pBSkJnTlZCQVlUQWtSRk1ROHdEUVlEVlFRSERBWkNaWEpzYVc0eEhUQWJCZ05WQkFvTUZFSjFibVJsYzJSeWRXTnJaWEpsYVNCSGJXSklNUkV3RHdZRFZRUUxEQWhVSUVOVElFbEVSVEUyTURRR0ExVUVBd3d0VTFCU1NVNUVJRVoxYm10bElFVlZSRWtnVjJGc2JHVjBJRkJ5YjNSdmRIbHdaU0JKYzNOMWFXNW5JRU5CTUI0WERUSTBNRFV6TVRBMk5EZ3dPVm9YRFRNME1EVXlPVEEyTkRnd09Wb3dnWWd4Q3pBSkJnTlZCQVlUQWtSRk1ROHdEUVlEVlFRSERBWkNaWEpzYVc0eEhUQWJCZ05WQkFvTUZFSjFibVJsYzJSeWRXTnJaWEpsYVNCSGJXSklNUkV3RHdZRFZRUUxEQWhVSUVOVElFbEVSVEUyTURRR0ExVUVBd3d0VTFCU1NVNUVJRVoxYm10bElFVlZSRWtnVjJGc2JHVjBJRkJ5YjNSdmRIbHdaU0JKYzNOMWFXNW5JRU5CTUZrd0V3WUhLb1pJemowQ0FRWUlLb1pJemowREFRY0RRZ0FFWUd6ZHdGRG5jNytLbjVpYkF2Q09NOGtlNzdWUXhxZk1jd1pMOElhSUErV0NST2NDZm1ZL2dpSDkycU1ydTVwL2t5T2l2RTBSQy9JYmRNT052RG9VeWFObU1HUXdIUVlEVlIwT0JCWUVGTlJXR01DSk9PZ09XSVFZeVhaaXY2dTd4WkMrTUI4R0ExVWRJd1FZTUJhQUZOUldHTUNKT09nT1dJUVl5WFppdjZ1N3haQytNQklHQTFVZEV3RUIvd1FJTUFZQkFmOENBUUF3RGdZRFZSMFBBUUgvQkFRREFnR0dNQW9HQ0NxR1NNNDlCQU1DQTBjQU1FUUNJR0VtN3drWktIdC9hdGI0TWRGblhXNnlybndNVVQydTEzNmdkdGwxMFk2aEFpQnVURnF2Vll0aDFyYnh6Q1AweFdaSG1RSzlrVnl4bjhHUGZYMjdFSXp6c3c9PSJdLCJraWQiOiJNSUdVTUlHT3BJR0xNSUdJTVFzd0NRWURWUVFHRXdKRVJURVBNQTBHQTFVRUJ3d0dRbVZ5YkdsdU1SMHdHd1lEVlFRS0RCUkNkVzVrWlhOa2NuVmphMlZ5WldrZ1IyMWlTREVSTUE4R0ExVUVDd3dJVkNCRFV5QkpSRVV4TmpBMEJnTlZCQU1NTFZOUVVrbE9SQ0JHZFc1clpTQkZWVVJKSUZkaGJHeGxkQ0JRY205MGIzUjVjR1VnU1hOemRXbHVaeUJEUVFJQkFnPT0iLCJ0eXAiOiJ2YytzZC1qd3QiLCJhbGciOiJFUzI1NiJ9.eyJwbGFjZV9vZl9iaXJ0aCI6eyJfc2QiOlsiOGFRMFpkRHRpVWd5N3FhY1dpcmZWci1sN3NLUXQzckFNNmIwSnFHSjJIUSJdfSwiX3NkIjpbIjVEZFN6SXRLUmhrMmJEUm5yREhGTjE4MmFDeVFkem9YU2xVTXlYelk0M28iLCI2ajdZMmFSYWcwRjZLQzVVNnh6RUp3QklQZTFUOE0tSGVhclJlOFBqSkFjIiwiRlVaSUNaWm9uLThvMGZqVk5QMS1IcmZ2dWhfcjgyQlJ4eVZIZTBoS1BIbyIsIk1KNGZwT1FuLXJ4S242bzBHTi12YUh5VmZIQWhaNTF6WklaZXkybWhVWU0iLCJmMFNJWDJicXZUS3JxMk1rdjJUSE9ReVVQSnhUdkNmNkRESEZ1b1lMTXMwIiwicWU0UlVNUjJfVEtZM0UxUm94TjY2RlhRR1FNTlpKV2tRRnJMOHBJNWJhVSIsInZqSFZ4ellINDBsNjY2SnlEcF80Tkt5NnJIWkVya0wwVEJEbEswVWxQYlkiXSwiYWRkcmVzcyI6eyJfc2QiOlsiMTJpSW1FZDQ3YVdocnVMZ05QZ3QtNnBVTF9sNHFTSmlCTUFfdUl3UWk3NCIsIjFic04zS0dFM2hrOVpVdUpxXzN5cTRIdUhxRi1LcWIyTjllaWR3dFR5d1UiLCJRVEV1cTdfZEdsWDBzNUE0bjRLWVNBUEFQaW5kWTZYZGwxdjJUR3cyUXFVIiwiakNNeThTVWtpTmNqcGZsTGpvRTUzM2VHX3R1NnQ2aUEweGo1RTdPRzNNcyJdfSwiaXNzdWluZ19jb3VudHJ5IjoiREUiLCJ2Y3QiOiJodHRwczovL2V4YW1wbGUuYm1pLmJ1bmQuZGUvY3JlZGVudGlhbC9waWQvMS4wIiwiaXNzdWluZ19hdXRob3JpdHkiOiJERSIsIl9zZF9hbGciOiJzaGEtMjU2IiwiaXNzIjoiaHR0cHM6Ly9kZW1vLnBpZC1pc3N1ZXIuYnVuZGVzZHJ1Y2tlcmVpLmRlL2MiLCJjbmYiOnsiandrIjp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYiLCJ4IjoiQUVYcEh5MTBHaHRXaGxWUE5tckZzYnl0X3dEc1FfN3EzZGpObnJoempfNCIsInkiOiJER1RBQ09BQW5RVGVwYUQ0MGd5RzlabC1vRGhPbHYzVUJsVHR4SWVyNWVvIn19LCJleHAiOjE3MzA5MDc5MjIsImlhdCI6MTcyOTY5ODMyMiwiYWdlX2VxdWFsX29yX292ZXIiOnsiX3NkIjpbIjlDMmk2MVp3bDhWdmE0a09PX3U5UnZ1c0RhYjZXVGlCeUlteEtmd3VROUUiLCJlV1NxQXY5RHY5VzdzNzZwZVFRWWtjV1N1Wlc2M0ZnaDNmZUY2S19oLVE0IiwiZ1lXWmRETlFhTHRrWXlrUDBvclB5SUtIVEl3Z3RYb2dmdWxCY2o4MVFKMCIsInBFQ2dMLTN6QmVHQzZhT20zcXY4ZkZ2R1pZRmZGRERacktRQThicl9fdWsiLCJzSWRxRy14aE96bmRkdW9HVjVRVnhBYU9MT1UxZV8zMjhaV3hoeUdBcUpRIiwieFdkQVRkclZ2R2t1ZE94aWpFby1zT3NlY2JhemUtSHJickZDQk9GNlUtOCJdfX0.PA0jtVtgKqPFr1IDVAxXKL5YSUWqLLoFsJz9cJ9rhr24g44Tu-7ZIAU9Ic9KgUGCHak5RkbL8Y87PeflVQkC1Q~WyJhNFZyamZSVWtKcGE1eThjcVVncTRnIiwiZmFtaWx5X25hbWUiLCJNVVNURVJNQU5OIl0~WyJ2WEhHdUt1VGZXeW9zMEthRzFwQndnIiwiZ2l2ZW5fbmFtZSIsIkVSSUtBIl0~WyIwQXd3d0hGT290TVFIQkJHendycUZnIiwiYmlydGhkYXRlIiwiMTk2NC0wOC0xMiJd~WyJoR1NkcGNBcjI4cnYybDFzSUZKTGhBIiwiYWdlX2JpcnRoX3llYXIiLDE5NjRd~WyI4aEV2Y3lDejkzdDdUcmpLb29Wd2lBIiwiYWdlX2luX3llYXJzIiw2MF0~WyJqMk9XdGNod2hGaW5CMVJleHJNeGlRIiwiYmlydGhfZmFtaWx5X25hbWUiLCJHQUJMRVIiXQ~WyJXZ0ZRS2xpdEszSlNIcWZqdFh3R1RnIiwibmF0aW9uYWxpdGllcyIsWyJERSJdXQ~WyJBYl9ZcVlyVUJ2U1I4M1FXT240SVlBIiwiMTIiLHRydWVd~WyJrQkhXbXJEZFpReEtUcDFMSjRmcGd3IiwiMTQiLHRydWVd~WyJqcVlhd3JTd3BiZEJaTDdzLTNjbXpRIiwiMTYiLHRydWVd~WyJ2ZXZkMUhrRHhmblFURVoxeWJOalN3IiwiMTgiLHRydWVd~WyJ6OHBjU004NnRwWGRZUjFTTGk1TW1nIiwiMjEiLHRydWVd~WyJBTGVzdkNfUXdPazdrRHVfajB0X25RIiwiNjUiLGZhbHNlXQ~WyJkUU9GQ1dmQWdXZUc2dkpyd0ptekt3IiwibG9jYWxpdHkiLCJCRVJMSU4iXQ~WyJSQ05jdlZXUDRGWHIyaktOWWhYV0ZRIiwibG9jYWxpdHkiLCJLw5ZMTiJd~WyIwMEJITldDRG5KZGFUYWFhdjFJNHlRIiwiY291bnRyeSIsIkRFIl0~WyJ5SkhwdktYOU1iNFlmdXBBNHRNN1l3IiwicG9zdGFsX2NvZGUiLCI1MTE0NyJd~WyJXZlAyYndtQTh1VXZCRG4zQTRVcFpRIiwic3RyZWV0X2FkZHJlc3MiLCJIRUlERVNUUkHhup5FIDE3Il0~', + c_nonce: 'K7fOJwQEUYYg3e4f0jbmCg', + c_nonce_expires_in: 3600, + }, + holderPrivateKeyJwk: { + kty: 'EC', + x: 'AEXpHy10GhtWhlVPNmrFsbyt_wDsQ_7q3djNnrhzj_4', + y: 'DGTACOAAnQTepaD40gyG9Zl-oDhOlv3UBlTtxIer5eo', + crv: 'P-256', + d: 'C75pQj72AAl6SCsBW8AKTKxqLGk2Fw7NutIpWZ-xjvE', + }, +} diff --git a/packages/oid4vci/tests/full-flow.test.ts b/packages/oid4vci/tests/full-flow.test.ts index cb544a2..d2de798 100644 --- a/packages/oid4vci/tests/full-flow.test.ts +++ b/packages/oid4vci/tests/full-flow.test.ts @@ -5,6 +5,8 @@ import { Oauth2AuthorizationServer, Oauth2Client, Oauth2ResourceServer, + PkceCodeChallengeMethod, + SupportedAuthenticationScheme, authorizationCodeGrantIdentifier, calculateJwkThumbprint, preAuthorizedCodeGrantIdentifier, @@ -13,8 +15,6 @@ import { type HttpMethod, decodeUtf8String, encodeToBase64Url } from '@animo-id/ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest' -import { PkceCodeChallengeMethod } from '../../oauth2/dist' -import { SupportedAuthenticationScheme } from '../../oauth2/src/access-token/verify-access-token' import { getSignJwtCallback, parseXwwwFormUrlEncoded, callbacks as partialCallbacks } from '../../oauth2/tests/util' import { type CredentialConfigurationSupportedWithFormats, diff --git a/packages/utils/src/globals.ts b/packages/utils/src/globals.ts index 97a47a5..7c0b4c6 100644 --- a/packages/utils/src/globals.ts +++ b/packages/utils/src/globals.ts @@ -1,13 +1,16 @@ // Theses types are provided by the platform (so @types/node, @types/react-native, DOM) -// But therefore we need to add a ts-ignore -// @ts-ignore -export const URL = global.URL -// @ts-ignore -export const URLSearchParams = global.URLSearchParams +// biome-ignore lint/style/noRestrictedGlobals: +const _URL = URL +// biome-ignore lint/style/noRestrictedGlobals: +const _URLSearchParams = URLSearchParams -// @ts-ignore // biome-ignore lint/style/noRestrictedGlobals: -export type Fetch = fetch -export type FetchResponse = Awaited> -export type FetchHeaders = FetchResponse['headers'] +export type Fetch = typeof fetch +// biome-ignore lint/style/noRestrictedGlobals: +export type FetchResponse = Response +// biome-ignore lint/style/noRestrictedGlobals: +const _Headers = Headers +export type FetchHeaders = globalThis.Headers + +export { _URLSearchParams as URLSearchParams, _URL as URL, _Headers as Headers } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 72cfb0b..60ac77b 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,4 +1,4 @@ -export { type Fetch, type FetchHeaders, type FetchResponse, URL, URLSearchParams } from './globals' +export { type Fetch, Headers, type FetchHeaders, type FetchResponse, URL, URLSearchParams } from './globals' export { JsonParseError } from './error/JsonParseError' export { ValidationError } from './error/ValidationError' diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts index 8cf58ad..e8cb17e 100644 --- a/packages/utils/src/url.ts +++ b/packages/utils/src/url.ts @@ -5,9 +5,9 @@ export function getQueryParams(url: string) { const searchParams = new URLSearchParams(parsedUrl.search) const params: Record = {} - for (const [key, value] of searchParams) { + searchParams.forEach((value, key) => { params[key] = value - } + }) return params } diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json index 6026321..8ef75ec 100644 --- a/packages/utils/tsconfig.json +++ b/packages/utils/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../tsconfig.json", "compilerOptions": { - "outDir": "build" + "outDir": "build", + "lib": ["ES2020", "DOM"] } } diff --git a/tsconfig.json b/tsconfig.json index cb0f723..e97c047 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,5 +11,6 @@ "types": [], "esModuleInterop": true, "allowSyntheticDefaultImports": true - } + }, + "exclude": ["dist"] }