diff --git a/packages/client/CHANGELOG.md b/packages/client/CHANGELOG.md index d9110c08..72b077b8 100644 --- a/packages/client/CHANGELOG.md +++ b/packages/client/CHANGELOG.md @@ -166,7 +166,7 @@ Release with support for the pre-authorized code flow only! - Documentation updates/fixes - Fixes: - - The acquireCredential in the OpenID4VCIClient was not using the access token, resulting in auth issues. + - The acquireCredential in the OpenID4VCIClientV1_0_13 was not using the access token, resulting in auth issues. ## v0.3.1 - 2022-11-20 diff --git a/packages/client/README.md b/packages/client/README.md index 79c27184..8cc6b708 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -52,10 +52,10 @@ This initiates the client using a URI obtained from the Issuer using a link (URL already fetching the Server Metadata ```typescript -import { OpenID4VCIClient } from '@sphereon/oid4vci-client'; +import { OpenID4VCIClientV1_0_13 } from '@sphereon/oid4vci-client'; // The client is initiated from a URI. This URI is provided by the Issuer, typically as a URL or QR code. -const client = await OpenID4VCIClient.fromURI({ +const client = await OpenID4VCIClientV1_0_13.fromURI({ uri: 'openid-initiate-issuance://?issuer=https%3A%2F%2Fissuer.research.identiproof.io&credential_type=OpenBadgeCredentialUrl&pre-authorized_code=4jLs9xZHEfqcoow0kHE7d1a8hUk6Sy-5bVSV2MqBUGUgiFFQi-ImL62T-FmLIo8hKA1UdMPH0lM1xAgcFkJfxIw9L-lI3mVs0hRT8YVwsEM1ma6N3wzuCdwtMU4bcwKp&user_pin_required=true', kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21#key-1', // Our DID. You can defer this also to when the acquireCredential method is called alg: Alg.ES256, // The signing Algorithm we will use. You can defer this also to when the acquireCredential method is called @@ -71,10 +71,10 @@ console.log(client.getAccessTokenEndpoint()); // https://auth.research.identipro Using https scheme ```typescript -import { OpenID4VCIClient } from '@sphereon/oid4vci-client'; +import { OpenID4VCIClientV1_0_13 } from '@sphereon/oid4vci-client'; // The client is initiated from a URI. This URI is provided by the Issuer, typically as a URL or QR code. -const client = await OpenID4VCIClient.fromURI({ +const client = await OpenID4VCIClientV1_0_13.fromURI({ uri: 'https://launchpad.vii.electron.mattrlabs.io?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Flaunchpad.vii.electron.mattrlabs.io%22%2C%22credentials%22%3A%5B%7B%22format%22%3A%22ldp_vc%22%2C%22types%22%3A%5B%22OpenBadgeCredential%22%5D%7D%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22UPZohaodPlLBnGsqB02n2tIupCIg8nKRRUEUHWA665X%22%7D%7D%7D', kid: 'did:example:ebfeb1f712ebc6f1c276e12ec21#key-1', // Our DID. You can defer this also to when the acquireCredential method is called alg: Alg.ES256, // The signing Algorithm we will use. You can defer this also to when the acquireCredential method is called @@ -206,15 +206,15 @@ The OpenID4VCI spec defines a server metadata object that contains information a support. Next to this predefined endpoint there are also the well-known locations for OpenID Connect Discovery configuration and Oauth2 Authorization Server configuration. These contain for instance the token endpoints. -The MetadataClient checks the OpenID4VCI well-known location for the medata and existence of a token endpoint. If the +The MetadataClientV1_0_13 checks the OpenID4VCI well-known location for the medata and existence of a token endpoint. If the OpenID4VCI well-known location is not found, the OIDC/OAuth2 well-known locations will be tried: Example: ```typescript -import { MetadataClient } from '@sphereon/oid4vci-client'; +import { MetadataClientV1_0_13 } from '@sphereon/oid4vci-client'; -const metadata = await MetadataClient.retrieveAllMetadataFromCredentialOffer(initiationRequestWithUrl); +const metadata = await MetadataClientV1_0_13.retrieveAllMetadataFromCredentialOffer(initiationRequestWithUrl); console.log(metadata); /** diff --git a/packages/client/lib/AccessTokenClient.ts b/packages/client/lib/AccessTokenClient.ts index a4a781ff..356ff6b7 100644 --- a/packages/client/lib/AccessTokenClient.ts +++ b/packages/client/lib/AccessTokenClient.ts @@ -25,7 +25,7 @@ import { } from '@sphereon/oid4vci-common'; import { ObjectUtils } from '@sphereon/ssi-types'; -import { MetadataClient } from './MetadataClient'; +import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; import { LOG } from './types'; export class AccessTokenClient { @@ -82,7 +82,7 @@ export class AccessTokenClient { metadata: metadata ? metadata : issuerOpts?.fetchMetadata - ? await MetadataClient.retrieveAllMetadata(issuerOpts.issuer, { errorOnNotFound: false }) + ? await MetadataClientV1_0_13.retrieveAllMetadata(issuerOpts.issuer, { errorOnNotFound: false }) : undefined, }); diff --git a/packages/client/lib/AccessTokenClientV1_0_11.ts b/packages/client/lib/AccessTokenClientV1_0_11.ts index fa275ac2..e06630fa 100644 --- a/packages/client/lib/AccessTokenClientV1_0_11.ts +++ b/packages/client/lib/AccessTokenClientV1_0_11.ts @@ -6,28 +6,24 @@ import { AuthorizationServerOpts, AuthzFlowType, convertJsonToURI, - CredentialOfferPayloadV1_0_11, CredentialOfferV1_0_11, CredentialOfferV1_0_13, - determineSpecVersionFromOffer, EndpointMetadata, formPost, getIssuerFromCredentialOfferPayload, GrantTypes, IssuerOpts, JsonURIMode, - OpenId4VCIVersion, OpenIDResponse, PRE_AUTH_CODE_LITERAL, TokenErrorResponse, toUniformCredentialOfferRequest, - toUniformCredentialOfferRequestV1_0_11, UniformCredentialOfferPayload, } from '@sphereon/oid4vci-common'; import { ObjectUtils } from '@sphereon/ssi-types'; import Debug from 'debug'; -import { MetadataClient } from './MetadataClient'; +import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; const debug = Debug('sphereon:oid4vci:token'); @@ -84,7 +80,7 @@ export class AccessTokenClientV1_0_11 { metadata: metadata ? metadata : issuerOpts?.fetchMetadata - ? await MetadataClient.retrieveAllMetadata(issuerOpts.issuer, { errorOnNotFound: false }) + ? await MetadataClientV1_0_13.retrieveAllMetadata(issuerOpts.issuer, { errorOnNotFound: false }) : undefined, }); @@ -94,9 +90,7 @@ export class AccessTokenClientV1_0_11 { public async createAccessTokenRequest(opts: AccessTokenRequestOpts): Promise { const { asOpts, pin, codeVerifier, code, redirectUri } = opts; const credentialOfferRequest = opts.credentialOffer - ? determineSpecVersionFromOffer(opts.credentialOffer as CredentialOfferPayloadV1_0_11).valueOf() <= OpenId4VCIVersion.VER_1_0_11.valueOf() - ? await toUniformCredentialOfferRequestV1_0_11(opts.credentialOffer as CredentialOfferV1_0_11) - : await toUniformCredentialOfferRequest(opts.credentialOffer as CredentialOfferV1_0_13) + ? await toUniformCredentialOfferRequest(opts.credentialOffer as CredentialOfferV1_0_11 | CredentialOfferV1_0_13) : undefined; const request: Partial = {}; diff --git a/packages/client/lib/CredentialOfferClientV1_0_11.ts b/packages/client/lib/CredentialOfferClientV1_0_11.ts index d18d8d18..eeae3635 100644 --- a/packages/client/lib/CredentialOfferClientV1_0_11.ts +++ b/packages/client/lib/CredentialOfferClientV1_0_11.ts @@ -10,7 +10,7 @@ import { determineSpecVersionFromURI, getClientIdFromCredentialOfferPayload, OpenId4VCIVersion, - toUniformCredentialOfferRequestV1_0_11, + toUniformCredentialOfferRequest, } from '@sphereon/oid4vci-common'; import Debug from 'debug'; @@ -31,7 +31,7 @@ export class CredentialOfferClientV1_0_11 { if (version < OpenId4VCIVersion.VER_1_0_11) { credentialOfferPayload = convertURIToJsonObject(uri, { arrayTypeProperties: ['credential_type'], - requiredProperties: uri.includes('credential_offer_uri=') ? ['credential_offer_uri'] : ['issuer', 'credential_type'], + requiredProperties: uri.includes('credential_offer_uri=') ? ['credential_offer_uri='] : ['issuer', 'credential_type='], }) as CredentialOfferPayloadV1_0_09; credentialOffer = { credential_offer: credentialOfferPayload, @@ -39,14 +39,14 @@ export class CredentialOfferClientV1_0_11 { } else { credentialOffer = convertURIToJsonObject(uri, { arrayTypeProperties: ['credentials'], - requiredProperties: uri.includes('credential_offer_uri=') ? ['credential_offer_uri'] : ['credential_offer'], + requiredProperties: uri.includes('credential_offer_uri=') ? ['credential_offer_uri='] : ['credential_offer='], }) as CredentialOfferV1_0_11; if (credentialOffer?.credential_offer_uri === undefined && !credentialOffer?.credential_offer) { throw Error('Either a credential_offer or credential_offer_uri should be present in ' + uri); } } - const request = await toUniformCredentialOfferRequestV1_0_11(credentialOffer, { + const request = await toUniformCredentialOfferRequest(credentialOffer, { ...opts, version, }); diff --git a/packages/client/lib/MetadataClient.ts b/packages/client/lib/MetadataClient.ts index c6ffe152..b9f2f394 100644 --- a/packages/client/lib/MetadataClient.ts +++ b/packages/client/lib/MetadataClient.ts @@ -1,17 +1,24 @@ import { AuthorizationServerMetadata, AuthorizationServerType, + CredentialIssuerMetadataV1_0_11, CredentialIssuerMetadataV1_0_13, + CredentialOfferPayload, CredentialOfferPayloadV1_0_13, CredentialOfferRequestWithBaseUrl, + determineSpecVersionFromOffer, + EndpointMetadataResultV1_0_11, EndpointMetadataResultV1_0_13, getIssuerFromCredentialOfferPayload, - IssuerMetadataV1_0_13, + IssuerMetadataV1_0_08, + OpenId4VCIVersion, OpenIDResponse, WellKnownEndpoints, } from '@sphereon/oid4vci-common'; import Debug from 'debug'; +import { MetadataClientV1_0_11 } from './MetadataClientV1_0_11'; +import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; import { retrieveWellknown } from './functions/OpenIDUtils'; const debug = Debug('sphereon:oid4vci:metadata'); @@ -24,18 +31,28 @@ export class MetadataClient { */ public static async retrieveAllMetadataFromCredentialOffer( credentialOffer: CredentialOfferRequestWithBaseUrl, - ): Promise { - return MetadataClient.retrieveAllMetadataFromCredentialOfferRequest(credentialOffer.credential_offer as CredentialOfferPayloadV1_0_13); + ): Promise { + if (determineSpecVersionFromOffer(credentialOffer.credential_offer) >= OpenId4VCIVersion.VER_1_0_13) { + return await MetadataClientV1_0_13.retrieveAllMetadataFromCredentialOffer(credentialOffer); + } else { + return await MetadataClientV1_0_11.retrieveAllMetadataFromCredentialOffer(credentialOffer); + } } /** * Retrieve the metada using the initiation request obtained from a previous step * @param request */ - public static async retrieveAllMetadataFromCredentialOfferRequest(request: CredentialOfferPayloadV1_0_13): Promise { + public static async retrieveAllMetadataFromCredentialOfferRequest( + request: CredentialOfferPayload, + ): Promise { const issuer = getIssuerFromCredentialOfferPayload(request); if (issuer) { - return MetadataClient.retrieveAllMetadata(issuer); + if (determineSpecVersionFromOffer(request) >= OpenId4VCIVersion.VER_1_0_13) { + return MetadataClientV1_0_13.retrieveAllMetadataFromCredentialOfferRequest(request as CredentialOfferPayloadV1_0_13); + } else { + return MetadataClientV1_0_11.retrieveAllMetadataFromCredentialOfferRequest(request); + } } throw new Error("can't retrieve metadata from CredentialOfferRequest. No issuer field is present"); } @@ -45,24 +62,33 @@ export class MetadataClient { * @param issuer The issuer URL * @param opts */ - public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise { + public static async retrieveAllMetadata( + issuer: string, + opts?: { errorOnNotFound: boolean }, + ): Promise { let token_endpoint: string | undefined; let credential_endpoint: string | undefined; let deferred_credential_endpoint: string | undefined; let authorization_endpoint: string | undefined; let authorizationServerType: AuthorizationServerType = 'OID4VCI'; - let authorization_servers: string[] = [issuer]; + let authorization_servers: string[] | undefined = [issuer]; + let authorization_server: string | undefined = undefined; const oid4vciResponse = await MetadataClient.retrieveOpenID4VCIServerMetadata(issuer, { errorOnNotFound: false }); // We will handle errors later, given we will also try other metadata locations let credentialIssuerMetadata = oid4vciResponse?.successBody; if (credentialIssuerMetadata) { debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${JSON.stringify(credentialIssuerMetadata)}`); credential_endpoint = credentialIssuerMetadata.credential_endpoint; - deferred_credential_endpoint = credentialIssuerMetadata.deferred_credential_endpoint; + deferred_credential_endpoint = credentialIssuerMetadata.deferred_credential_endpoint + ? (credentialIssuerMetadata.deferred_credential_endpoint as string) + : undefined; if (credentialIssuerMetadata.token_endpoint) { token_endpoint = credentialIssuerMetadata.token_endpoint; } if (credentialIssuerMetadata.authorization_servers) { - authorization_servers = credentialIssuerMetadata.authorization_servers; + authorization_servers = credentialIssuerMetadata.authorization_servers as string[]; + } else if (credentialIssuerMetadata.authorization_server) { + authorization_server = credentialIssuerMetadata.authorization_server as string; + authorization_servers = [authorization_server]; } } // No specific OID4VCI endpoint. Either can be an OAuth2 AS or an OIDC IDP. Let's start with OIDC first @@ -154,7 +180,9 @@ export class MetadataClient { if (!credentialIssuerMetadata && authMetadata) { // Apparently everything worked out and the issuer is exposing everything in oAuth2/OIDC well-knowns. Spec is vague about this situation, but we can support it - credentialIssuerMetadata = authMetadata as CredentialIssuerMetadataV1_0_13; + credentialIssuerMetadata = authorization_server + ? (authMetadata as CredentialIssuerMetadataV1_0_11) + : (authMetadata as CredentialIssuerMetadataV1_0_13); } debug(`Issuer ${issuer} token endpoint ${token_endpoint}, credential endpoint ${credential_endpoint}`); return { @@ -162,12 +190,14 @@ export class MetadataClient { token_endpoint, credential_endpoint, deferred_credential_endpoint, - authorization_server: authorization_servers[0], + ...(authorization_server ? { authorization_server } : { authorization_servers: authorization_servers }), authorization_endpoint, authorizationServerType, - credentialIssuerMetadata: credentialIssuerMetadata, + credentialIssuerMetadata: authorization_server + ? (credentialIssuerMetadata as IssuerMetadataV1_0_08 & Partial) + : (credentialIssuerMetadata as CredentialIssuerMetadataV1_0_13), authorizationServerMetadata: authMetadata, - }; + } as EndpointMetadataResultV1_0_13 | EndpointMetadataResultV1_0_11; } /** @@ -180,7 +210,12 @@ export class MetadataClient { opts?: { errorOnNotFound?: boolean; }, - ): Promise | undefined> { + ): Promise< + | OpenIDResponse< + CredentialIssuerMetadataV1_0_11 | CredentialIssuerMetadataV1_0_13 | (IssuerMetadataV1_0_08 & Partial) + > + | undefined + > { return retrieveWellknown(issuerHost, WellKnownEndpoints.OPENID4VCI_ISSUER, { errorOnNotFound: opts?.errorOnNotFound === undefined ? true : opts.errorOnNotFound, }); diff --git a/packages/client/lib/MetadataClientV1_0_13.ts b/packages/client/lib/MetadataClientV1_0_13.ts new file mode 100644 index 00000000..b9076236 --- /dev/null +++ b/packages/client/lib/MetadataClientV1_0_13.ts @@ -0,0 +1,188 @@ +import { + AuthorizationServerMetadata, + AuthorizationServerType, + CredentialIssuerMetadataV1_0_13, + CredentialOfferPayloadV1_0_13, + CredentialOfferRequestWithBaseUrl, + EndpointMetadataResultV1_0_13, + getIssuerFromCredentialOfferPayload, + IssuerMetadataV1_0_13, + OpenIDResponse, + WellKnownEndpoints, +} from '@sphereon/oid4vci-common'; +import Debug from 'debug'; + +import { retrieveWellknown } from './functions/OpenIDUtils'; + +const debug = Debug('sphereon:oid4vci:metadata'); + +export class MetadataClientV1_0_13 { + /** + * Retrieve metadata using the Initiation obtained from a previous step + * + * @param credentialOffer + */ + public static async retrieveAllMetadataFromCredentialOffer( + credentialOffer: CredentialOfferRequestWithBaseUrl, + ): Promise { + return MetadataClientV1_0_13.retrieveAllMetadataFromCredentialOfferRequest(credentialOffer.credential_offer as CredentialOfferPayloadV1_0_13); + } + + /** + * Retrieve the metada using the initiation request obtained from a previous step + * @param request + */ + public static async retrieveAllMetadataFromCredentialOfferRequest(request: CredentialOfferPayloadV1_0_13): Promise { + const issuer = getIssuerFromCredentialOfferPayload(request); + if (issuer) { + return MetadataClientV1_0_13.retrieveAllMetadata(issuer); + } + throw new Error("can't retrieve metadata from CredentialOfferRequest. No issuer field is present"); + } + + /** + * Retrieve all metadata from an issuer + * @param issuer The issuer URL + * @param opts + */ + public static async retrieveAllMetadata(issuer: string, opts?: { errorOnNotFound: boolean }): Promise { + let token_endpoint: string | undefined; + let credential_endpoint: string | undefined; + let deferred_credential_endpoint: string | undefined; + let authorization_endpoint: string | undefined; + let authorizationServerType: AuthorizationServerType = 'OID4VCI'; + let authorization_servers: string[] = [issuer]; + const oid4vciResponse = await MetadataClientV1_0_13.retrieveOpenID4VCIServerMetadata(issuer, { errorOnNotFound: false }); // We will handle errors later, given we will also try other metadata locations + let credentialIssuerMetadata = oid4vciResponse?.successBody; + if (credentialIssuerMetadata) { + debug(`Issuer ${issuer} OID4VCI well-known server metadata\r\n${JSON.stringify(credentialIssuerMetadata)}`); + credential_endpoint = credentialIssuerMetadata.credential_endpoint; + deferred_credential_endpoint = credentialIssuerMetadata.deferred_credential_endpoint; + if (credentialIssuerMetadata.token_endpoint) { + token_endpoint = credentialIssuerMetadata.token_endpoint; + } + if (credentialIssuerMetadata.authorization_servers) { + authorization_servers = credentialIssuerMetadata.authorization_servers; + } + } + // No specific OID4VCI endpoint. Either can be an OAuth2 AS or an OIDC IDP. Let's start with OIDC first + // TODO: for now we're taking just the first one + let response: OpenIDResponse = await retrieveWellknown( + authorization_servers[0], + WellKnownEndpoints.OPENID_CONFIGURATION, + { + errorOnNotFound: false, + }, + ); + let authMetadata = response.successBody; + if (authMetadata) { + debug(`Issuer ${issuer} has OpenID Connect Server metadata in well-known location`); + authorizationServerType = 'OIDC'; + } else { + // Now let's do OAuth2 + // TODO: for now we're taking just the first one + response = await retrieveWellknown(authorization_servers[0], WellKnownEndpoints.OAUTH_AS, { errorOnNotFound: false }); + authMetadata = response.successBody; + } + if (!authMetadata) { + // We will always throw an error, no matter whether the user provided the option not to, because this is bad. + if (!authorization_servers.includes(issuer)) { + throw Error(`Issuer ${issuer} provided a separate authorization server ${authorization_servers}, but that server did not provide metadata`); + } + } else { + if (!authorizationServerType) { + authorizationServerType = 'OAuth 2.0'; + } + debug(`Issuer ${issuer} has ${authorizationServerType} Server metadata in well-known location`); + if (!authMetadata.authorization_endpoint) { + console.warn( + `Issuer ${issuer} of type ${authorizationServerType} has no authorization_endpoint! Will use ${authorization_endpoint}. This only works for pre-authorized flows`, + ); + } else if (authorization_endpoint && authMetadata.authorization_endpoint !== authorization_endpoint) { + throw Error( + `Credential issuer has a different authorization_endpoint (${authorization_endpoint}) from the Authorization Server (${authMetadata.authorization_endpoint})`, + ); + } + authorization_endpoint = authMetadata.authorization_endpoint; + if (!authMetadata.token_endpoint) { + throw Error(`Authorization Sever ${authorization_servers} did not provide a token_endpoint`); + } else if (token_endpoint && authMetadata.token_endpoint !== token_endpoint) { + throw Error( + `Credential issuer has a different token_endpoint (${token_endpoint}) from the Authorization Server (${authMetadata.token_endpoint})`, + ); + } + token_endpoint = authMetadata.token_endpoint; + if (authMetadata.credential_endpoint) { + if (credential_endpoint && authMetadata.credential_endpoint !== credential_endpoint) { + debug( + `Credential issuer has a different credential_endpoint (${credential_endpoint}) from the Authorization Server (${authMetadata.credential_endpoint}). Will use the issuer value`, + ); + } else { + credential_endpoint = authMetadata.credential_endpoint; + } + } + if (authMetadata.deferred_credential_endpoint) { + if (deferred_credential_endpoint && authMetadata.deferred_credential_endpoint !== deferred_credential_endpoint) { + debug( + `Credential issuer has a different deferred_credential_endpoint (${deferred_credential_endpoint}) from the Authorization Server (${authMetadata.deferred_credential_endpoint}). Will use the issuer value`, + ); + } else { + deferred_credential_endpoint = authMetadata.deferred_credential_endpoint; + } + } + } + + if (!authorization_endpoint) { + debug(`Issuer ${issuer} does not expose authorization_endpoint, so only pre-auth will be supported`); + } + if (!token_endpoint) { + debug(`Issuer ${issuer} does not have a token_endpoint listed in well-known locations!`); + if (opts?.errorOnNotFound) { + throw Error(`Could not deduce the token_endpoint for ${issuer}`); + } else { + token_endpoint = `${issuer}${issuer.endsWith('/') ? 'token' : '/token'}`; + } + } + if (!credential_endpoint) { + debug(`Issuer ${issuer} does not have a credential_endpoint listed in well-known locations!`); + if (opts?.errorOnNotFound) { + throw Error(`Could not deduce the credential endpoint for ${issuer}`); + } else { + credential_endpoint = `${issuer}${issuer.endsWith('/') ? 'credential' : '/credential'}`; + } + } + + if (!credentialIssuerMetadata && authMetadata) { + // Apparently everything worked out and the issuer is exposing everything in oAuth2/OIDC well-knowns. Spec is vague about this situation, but we can support it + credentialIssuerMetadata = authMetadata as CredentialIssuerMetadataV1_0_13; + } + debug(`Issuer ${issuer} token endpoint ${token_endpoint}, credential endpoint ${credential_endpoint}`); + return { + issuer, + token_endpoint, + credential_endpoint, + deferred_credential_endpoint, + authorization_server: authorization_servers[0], + authorization_endpoint, + authorizationServerType, + credentialIssuerMetadata: credentialIssuerMetadata, + authorizationServerMetadata: authMetadata, + }; + } + + /** + * Retrieve only the OID4VCI metadata for the issuer. So no OIDC/OAuth2 metadata + * + * @param issuerHost The issuer hostname + */ + public static async retrieveOpenID4VCIServerMetadata( + issuerHost: string, + opts?: { + errorOnNotFound?: boolean; + }, + ): Promise | undefined> { + return retrieveWellknown(issuerHost, WellKnownEndpoints.OPENID4VCI_ISSUER, { + errorOnNotFound: opts?.errorOnNotFound === undefined ? true : opts.errorOnNotFound, + }); + } +} diff --git a/packages/client/lib/OpenID4VCIClient.ts b/packages/client/lib/OpenID4VCIClient.ts index d10a31ae..b0ffe32f 100644 --- a/packages/client/lib/OpenID4VCIClient.ts +++ b/packages/client/lib/OpenID4VCIClient.ts @@ -5,21 +5,22 @@ import { AuthorizationResponse, AuthzFlowType, CodeChallengeMethod, + CredentialConfigurationSupported, CredentialConfigurationSupportedV1_0_13, - CredentialOfferPayloadV1_0_13, + CredentialOfferPayloadV1_0_08, + CredentialOfferPayloadV1_0_11, CredentialOfferRequestWithBaseUrl, CredentialResponse, + CredentialsSupportedLegacy, DefaultURISchemes, + EndpointMetadataResultV1_0_11, EndpointMetadataResultV1_0_13, - ExperimentalSubjectIssuance, getClientIdFromCredentialOfferPayload, getIssuerFromCredentialOfferPayload, getSupportedCredentials, getTypesFromCredentialSupported, JWK, KID_JWK_X5C_ERROR, - NotificationRequest, - NotificationResult, OID4VCICredentialFormat, OpenId4VCIVersion, PKCEOpts, @@ -29,36 +30,21 @@ import { import { CredentialFormat } from '@sphereon/ssi-types'; import Debug from 'debug'; -import { AccessTokenClient } from './AccessTokenClient'; +import { AccessTokenClientV1_0_11 } from './AccessTokenClientV1_0_11'; import { createAuthorizationRequestUrl } from './AuthorizationCodeClient'; -import { CredentialOfferClient } from './CredentialOfferClient'; -import { CredentialRequestOpts } from './CredentialRequestClient'; -import { CredentialRequestClientBuilder } from './CredentialRequestClientBuilder'; +import { createAuthorizationRequestUrlV1_0_11 } from './AuthorizationCodeClientV1_0_11'; +import { CredentialOfferClientV1_0_11 } from './CredentialOfferClientV1_0_11'; +import { CredentialRequestClientBuilderV1_0_11 } from './CredentialRequestClientBuilderV1_0_11'; import { MetadataClient } from './MetadataClient'; +import { OpenID4VCIClientStateV1_0_11 } from './OpenID4VCIClientV1_0_11'; +import { OpenID4VCIClientStateV1_0_13 } from './OpenID4VCIClientV1_0_13'; import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; -import { generateMissingPKCEOpts } from './functions/AuthorizationUtil'; -import { sendNotification } from './functions/notifications'; +import { generateMissingPKCEOpts } from './functions'; const debug = Debug('sphereon:oid4vci'); -export interface OpenID4VCIClientState { - credentialIssuer: string; - credentialOffer?: CredentialOfferRequestWithBaseUrl; - clientId?: string; - kid?: string; - jwk?: JWK; - alg?: Alg | string; - endpointMetadata?: EndpointMetadataResultV1_0_13; - accessTokenResponse?: AccessTokenResponse; - authorizationRequestOpts?: AuthorizationRequestOpts; - authorizationCodeResponse?: AuthorizationResponse; - pkce: PKCEOpts; - accessToken?: string; - authorizationURL?: string; -} - export class OpenID4VCIClient { - private readonly _state: OpenID4VCIClientState; + private readonly _state: OpenID4VCIClientStateV1_0_11 | OpenID4VCIClientStateV1_0_13; private constructor({ credentialOffer, @@ -68,7 +54,6 @@ export class OpenID4VCIClient { credentialIssuer, pkce, authorizationRequest, - accessToken, jwk, endpointMetadata, accessTokenResponse, @@ -84,8 +69,7 @@ export class OpenID4VCIClient { pkce?: PKCEOpts; authorizationRequest?: AuthorizationRequestOpts; // Can be provided here, or when manually calling createAuthorizationUrl jwk?: JWK; - accessToken?: string; - endpointMetadata?: EndpointMetadataResultV1_0_13; + endpointMetadata?: EndpointMetadataResultV1_0_11 | EndpointMetadataResultV1_0_13; accessTokenResponse?: AccessTokenResponse; authorizationRequestOpts?: AuthorizationRequestOpts; authorizationCodeResponse?: AuthorizationResponse; @@ -105,12 +89,13 @@ export class OpenID4VCIClient { pkce: { disabled: false, codeChallengeMethod: CodeChallengeMethod.S256, ...pkce }, authorizationRequestOpts, authorizationCodeResponse, - accessToken, jwk, - endpointMetadata, + endpointMetadata: endpointMetadata?.credentialIssuerMetadata?.authorization_server + ? (endpointMetadata as EndpointMetadataResultV1_0_11) + : (endpointMetadata as EndpointMetadataResultV1_0_13 | undefined), accessTokenResponse, authorizationURL, - }; + } as OpenID4VCIClientStateV1_0_11 | OpenID4VCIClientStateV1_0_13; // Running syncAuthorizationRequestOpts later as it is using the state if (!this._state.authorizationRequestOpts) { this._state.authorizationRequestOpts = this.syncAuthorizationRequestOpts(authorizationRequest); @@ -154,7 +139,7 @@ export class OpenID4VCIClient { return client; } - public static async fromState({ state }: { state: OpenID4VCIClientState | string }): Promise { + public static async fromState({ state }: { state: OpenID4VCIClientStateV1_0_11 | string }): Promise { const clientState = typeof state === 'string' ? JSON.parse(state) : state; return new OpenID4VCIClient(clientState); @@ -181,7 +166,7 @@ export class OpenID4VCIClient { clientId?: string; authorizationRequest?: AuthorizationRequestOpts; // Can be provided here, or when manually calling createAuthorizationUrl }): Promise { - const credentialOfferClient = await CredentialOfferClient.fromURI(uri, { resolve: resolveOfferUri }); + const credentialOfferClient = await CredentialOfferClientV1_0_11.fromURI(uri, { resolve: resolveOfferUri }); const client = new OpenID4VCIClient({ credentialOffer: credentialOfferClient, kid, @@ -219,7 +204,7 @@ export class OpenID4VCIClient { throw Error(`No Authorization Request options present or provided in this call`); } - // todo: Probably can go with current logic in MetadataClient who will always set the authorization_endpoint when found + // todo: Probably can go with current logic in MetadataClientV1_0_13 who will always set the authorization_endpoint when found // handling this because of the support for v1_0-08 if ( this._state.endpointMetadata?.credentialIssuerMetadata && @@ -227,19 +212,28 @@ export class OpenID4VCIClient { ) { this._state.endpointMetadata.authorization_endpoint = this._state.endpointMetadata.credentialIssuerMetadata.authorization_endpoint as string; } - this._state.authorizationURL = await createAuthorizationRequestUrl({ - pkce: this._state.pkce, - endpointMetadata: this.endpointMetadata, - authorizationRequest: this._state.authorizationRequestOpts, - credentialOffer: this.credentialOffer, - credentialConfigurationSupported: this.getCredentialsSupported(), - version: this.version(), - }); + if (this.version() <= OpenId4VCIVersion.VER_1_0_11) { + this._state.authorizationURL = await createAuthorizationRequestUrlV1_0_11({ + pkce: this._state.pkce, + endpointMetadata: this.endpointMetadata as EndpointMetadataResultV1_0_11, + authorizationRequest: this._state.authorizationRequestOpts, + credentialOffer: this.credentialOffer, + credentialsSupported: Object.values(this.getCredentialsSupported()) as CredentialsSupportedLegacy[], + }); + } else { + this._state.authorizationURL = await createAuthorizationRequestUrl({ + pkce: this._state.pkce, + endpointMetadata: this.endpointMetadata as EndpointMetadataResultV1_0_13, + authorizationRequest: this._state.authorizationRequestOpts, + credentialOffer: this.credentialOffer, + credentialConfigurationSupported: this.getCredentialsSupported() as Record, + }); + } } return this._state.authorizationURL; } - public async retrieveServerMetadata(): Promise { + public async retrieveServerMetadata(): Promise { this.assertIssuerData(); if (!this._state.endpointMetadata) { if (this.credentialOffer) { @@ -284,7 +278,7 @@ export class OpenID4VCIClient { this._state.clientId = clientId; } if (!this._state.accessTokenResponse) { - const accessTokenClient = new AccessTokenClient(); + const accessTokenClient = new AccessTokenClientV1_0_11(); if (redirectUri && redirectUri !== this._state.authorizationRequestOpts?.redirectUri) { console.log( @@ -294,6 +288,7 @@ export class OpenID4VCIClient { if (this._state.authorizationRequestOpts?.redirectUri && !redirectUri) { redirectUri = this._state.authorizationRequestOpts.redirectUri; } + const response = await accessTokenClient.acquireAccessToken({ credentialOffer: this.credentialOffer, metadata: this.endpointMetadata, @@ -321,14 +316,12 @@ export class OpenID4VCIClient { ); } this._state.accessTokenResponse = response.successBody; - this._state.accessToken = response.successBody.access_token; } return this.accessTokenResponse; } public async acquireCredentials({ - credentialIdentifier, credentialTypes, context, proofCallbacks, @@ -340,8 +333,7 @@ export class OpenID4VCIClient { deferredCredentialAwait, deferredCredentialIntervalInMS, }: { - credentialIdentifier?: string; - credentialTypes?: string | string[]; + credentialTypes: string | string[]; context?: string[]; proofCallbacks: ProofOfPossessionCallbacks; format?: CredentialFormat | OID4VCICredentialFormat; @@ -351,8 +343,7 @@ export class OpenID4VCIClient { jti?: string; deferredCredentialAwait?: boolean; deferredCredentialIntervalInMS?: number; - experimentalHolderIssuanceSupported?: boolean; - }): Promise { + }): Promise { if ([jwk, kid].filter((v) => v !== undefined).length > 1) { throw new Error(KID_JWK_X5C_ERROR + `. jwk: ${jwk !== undefined}, kid: ${kid !== undefined}`); } @@ -362,37 +353,24 @@ export class OpenID4VCIClient { if (kid) this._state.kid = kid; const requestBuilder = this.credentialOffer - ? CredentialRequestClientBuilder.fromCredentialOffer({ + ? CredentialRequestClientBuilderV1_0_11.fromCredentialOffer({ credentialOffer: this.credentialOffer, metadata: this.endpointMetadata, }) - : CredentialRequestClientBuilder.fromCredentialIssuer({ + : CredentialRequestClientBuilderV1_0_11.fromCredentialIssuer({ credentialIssuer: this.getIssuer(), - credentialIdentifier: credentialIdentifier, + credentialTypes, metadata: this.endpointMetadata, version: this.version(), }); requestBuilder.withTokenFromResponse(this.accessTokenResponse); requestBuilder.withDeferredCredentialAwait(deferredCredentialAwait ?? false, deferredCredentialIntervalInMS); - let subjectIssuance: ExperimentalSubjectIssuance | undefined; if (this.endpointMetadata?.credentialIssuerMetadata) { const metadata = this.endpointMetadata.credentialIssuerMetadata; - const types = credentialTypes ? (Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes]) : undefined; + const types = Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes]; - if (credentialIdentifier) { - if (typeof metadata.credential_configurations_supported !== 'object') { - throw Error( - `Credentials_supported should be an object, current ${typeof metadata.credential_configurations_supported} when credential_identifier is used`, - ); - } - const credentialsSupported = metadata.credential_configurations_supported; - if (!metadata.credential_configurations_supported || !credentialsSupported[credentialIdentifier]) { - throw new Error(`Credential type ${credentialIdentifier} is not supported by issuer ${this.getIssuer()}`); - } - } else if (!types) { - throw Error(`If no credential_identifier is used, we expect types`); - } else if (metadata.credentials_supported && Array.isArray(metadata.credentials_supported)) { + if (metadata.credentials_supported && Array.isArray(metadata.credentials_supported)) { let typeSupported = false; metadata.credentials_supported.forEach((supportedCredential) => { @@ -402,9 +380,6 @@ export class OpenID4VCIClient { (types.length === 1 && (types[0] === supportedCredential.id || subTypes.includes(types[0]))) ) { typeSupported = true; - if (supportedCredential.credential_subject_issuance) { - subjectIssuance = { credential_subject_issuance: supportedCredential.credential_subject_issuance }; - } } }); @@ -412,18 +387,14 @@ export class OpenID4VCIClient { console.log(`Not all credential types ${JSON.stringify(credentialTypes)} are present in metadata for ${this.getIssuer()}`); // throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`); } - } else if (metadata.credential_configurations_supported && !Array.isArray(metadata.credential_configurations_supported)) { - const credentialsSupported = metadata.credential_configurations_supported; - if (types.some((type) => !metadata.credential_configurations_supported || !credentialsSupported[type])) { + } else if (metadata.credentials_supported && !Array.isArray(metadata.credentials_supported)) { + const credentialsSupported = metadata.credentials_supported; + if (types.some((type) => !metadata.credentials_supported || !credentialsSupported[type])) { throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`); } } // todo: Format check? We might end up with some disjoint type / format combinations supported by the server } - if (subjectIssuance) { - requestBuilder.withSubjectIssuance(subjectIssuance); - } - const credentialRequestClient = requestBuilder.build(); const proofBuilder = ProofOfPossessionBuilder.fromAccessTokenResponse({ accessTokenResponse: this.accessTokenResponse, @@ -448,7 +419,9 @@ export class OpenID4VCIClient { } const response = await credentialRequestClient.acquireCredentialsUsingProof({ proofInput: proofBuilder, - ...(credentialIdentifier ? { credentialIdentifier, subjectIssuance } : { format, context, credentialTypes, subjectIssuance }), + credentialTypes, + context, + format, }); if (response.errorBody) { debug(`Credential request error:\r\n${JSON.stringify(response.errorBody)}`); @@ -465,33 +438,41 @@ export class OpenID4VCIClient { } for issuer ${this.getIssuer()} failed as there was no success response body`, ); } - return { ...response.successBody, access_token: response.access_token }; + return response.successBody; } public async exportState(): Promise { return JSON.stringify(this._state); } - getCredentialsSupported( + // FIXME: We really should convert { + ): Record { return getSupportedCredentials({ issuerMetadata: this.endpointMetadata.credentialIssuerMetadata, version: this.version(), format: format, - types: undefined, - }); + types: restrictToInitiationTypes ? this.getCredentialOfferTypes() : undefined, + }) as Record; } - public async sendNotification( - credentialRequestOpts: Partial, - request: NotificationRequest, - accessToken?: string, - ): Promise { - return sendNotification(credentialRequestOpts, request, accessToken ?? this._state.accessToken ?? this._state.accessTokenResponse?.access_token); + getCredentialsSupported( + format?: (OID4VCICredentialFormat | string) | (OID4VCICredentialFormat | string)[], + ): Record { + return getSupportedCredentials({ + issuerMetadata: this.endpointMetadata.credentialIssuerMetadata, + version: this.version(), + format: format, + types: undefined, + }) as Record; } - /* getCredentialOfferTypes(): string[][] { + getCredentialOfferTypes(): string[][] { if (!this.credentialOffer) { return []; } else if (this.credentialOffer.version < OpenId4VCIVersion.VER_1_0_11) { @@ -500,8 +481,8 @@ export class OpenID4VCIClient { const result: string[][] = []; result[0] = types; return result; - } else { - return this.credentialOffer.credential_offer.credentials.map((c) => { + } else if (this.credentialOffer.version < OpenId4VCIVersion.VER_1_0_13) { + return (this.credentialOffer.credential_offer as CredentialOfferPayloadV1_0_11).credentials.map((c) => { if (typeof c === 'string') { return [c]; } else if ('types' in c) { @@ -513,7 +494,9 @@ export class OpenID4VCIClient { } }); } - }*/ + // we don't have this for v13. v13 only has credential_configuration_ids which is not translatable to type + return []; + } issuerSupportedFlowTypes(): AuthzFlowType[] { return ( @@ -526,14 +509,14 @@ export class OpenID4VCIClient { return this.issuerSupportedFlowTypes().includes(flowType); } - public hasAuthorizationURL(): boolean { - return !!this.authorizationURL; - } - get authorizationURL(): string | undefined { return this._state.authorizationURL; } + public hasAuthorizationURL(): boolean { + return !!this.authorizationURL; + } + get credentialOffer(): CredentialOfferRequestWithBaseUrl | undefined { return this._state.credentialOffer; } @@ -542,7 +525,7 @@ export class OpenID4VCIClient { return this.credentialOffer?.version ?? OpenId4VCIVersion.VER_1_0_11; } - public get endpointMetadata(): EndpointMetadataResultV1_0_13 { + public get endpointMetadata(): EndpointMetadataResultV1_0_11 | EndpointMetadataResultV1_0_13 { this.assertServerMetadata(); // eslint-disable-next-line @typescript-eslint/no-non-null-assertion return this._state.endpointMetadata!; @@ -591,7 +574,7 @@ export class OpenID4VCIClient { this.assertIssuerData(); return this.endpointMetadata ? this.endpointMetadata.token_endpoint - : AccessTokenClient.determineTokenURL({ issuerOpts: { issuer: this.getIssuer() } }); + : AccessTokenClientV1_0_11.determineTokenURL({ issuerOpts: { issuer: this.getIssuer() } }); } public getCredentialEndpoint(): string { @@ -611,33 +594,20 @@ export class OpenID4VCIClient { /** * Too bad we need a method like this, but EBSI is not exposing metadata */ - public isEBSI(): boolean { - const credentialOffer = this.credentialOffer?.credential_offer as CredentialOfferPayloadV1_0_13; - - if (credentialOffer?.credential_configuration_ids) { - const credentialConfigurations = this.endpointMetadata.credentialIssuerMetadata?.credential_configurations_supported; - - if (credentialConfigurations) { - const isEBSITrustFramework = credentialOffer.credential_configuration_ids - .map((id) => credentialConfigurations[id]) - .filter( - (config): config is CredentialConfigurationSupportedV1_0_13 => - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore - config !== undefined && 'trust_framework' in config && 'name' in config.trust_framework, - ) + public isEBSI() { + if ( + (this.credentialOffer?.credential_offer as CredentialOfferPayloadV1_0_11)['credentials'] && + (this.credentialOffer?.credential_offer as CredentialOfferPayloadV1_0_11).credentials.find( + (cred) => // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - .some((config) => config.trust_framework.name.includes('ebsi')); - - if (isEBSITrustFramework) { - return true; - } - } + typeof cred !== 'string' && 'trust_framework' in cred && 'name' in cred.trust_framework && cred.trust_framework.name.includes('ebsi'), + ) + ) { + return true; } - this.assertIssuerData(); - return this.endpointMetadata.credentialIssuerMetadata?.authorization_endpoint?.includes('ebsi.eu') ?? false; + return this.endpointMetadata.credentialIssuerMetadata?.authorization_endpoint?.includes('ebsi.eu'); } private assertIssuerData(): void { diff --git a/packages/client/lib/OpenID4VCIClientV1_0_11.ts b/packages/client/lib/OpenID4VCIClientV1_0_11.ts index 24d38122..9e66a7b7 100644 --- a/packages/client/lib/OpenID4VCIClientV1_0_11.ts +++ b/packages/client/lib/OpenID4VCIClientV1_0_11.ts @@ -50,6 +50,7 @@ export interface OpenID4VCIClientStateV1_0_11 { authorizationRequestOpts?: AuthorizationRequestOpts; authorizationCodeResponse?: AuthorizationResponse; pkce: PKCEOpts; + accessToken?: string; authorizationURL?: string; } @@ -212,7 +213,7 @@ export class OpenID4VCIClientV1_0_11 { throw Error(`No Authorization Request options present or provided in this call`); } - // todo: Probably can go with current logic in MetadataClient who will always set the authorization_endpoint when found + // todo: Probably can go with current logic in MetadataClientV1_0_13 who will always set the authorization_endpoint when found // handling this because of the support for v1_0-08 if ( this._state.endpointMetadata?.credentialIssuerMetadata && @@ -605,7 +606,7 @@ export class OpenID4VCIClientV1_0_11 { return true; } this.assertIssuerData(); - return this.endpointMetadata.credentialIssuerMetadata?.authorization_endpoint?.includes('ebsi.eu'); + return this.endpointMetadata.credentialIssuerMetadata?.authorization_endpoint?.includes('ebsi.eu') === true; } private assertIssuerData(): void { diff --git a/packages/client/lib/OpenID4VCIClientV1_0_13.ts b/packages/client/lib/OpenID4VCIClientV1_0_13.ts new file mode 100644 index 00000000..1f23344c --- /dev/null +++ b/packages/client/lib/OpenID4VCIClientV1_0_13.ts @@ -0,0 +1,677 @@ +import { + AccessTokenResponse, + Alg, + AuthorizationRequestOpts, + AuthorizationResponse, + AuthzFlowType, + CodeChallengeMethod, + CredentialConfigurationSupportedV1_0_13, + CredentialOfferPayloadV1_0_13, + CredentialOfferRequestWithBaseUrl, + CredentialResponse, + DefaultURISchemes, + EndpointMetadataResultV1_0_13, + ExperimentalSubjectIssuance, + getClientIdFromCredentialOfferPayload, + getIssuerFromCredentialOfferPayload, + getSupportedCredentials, + getTypesFromCredentialSupported, + JWK, + KID_JWK_X5C_ERROR, + NotificationRequest, + NotificationResult, + OID4VCICredentialFormat, + OpenId4VCIVersion, + PKCEOpts, + ProofOfPossessionCallbacks, + toAuthorizationResponsePayload, +} from '@sphereon/oid4vci-common'; +import { CredentialFormat } from '@sphereon/ssi-types'; +import Debug from 'debug'; + +import { AccessTokenClient } from './AccessTokenClient'; +import { createAuthorizationRequestUrl } from './AuthorizationCodeClient'; +import { CredentialOfferClient } from './CredentialOfferClient'; +import { CredentialRequestOpts } from './CredentialRequestClient'; +import { CredentialRequestClientBuilder } from './CredentialRequestClientBuilder'; +import { MetadataClientV1_0_13 } from './MetadataClientV1_0_13'; +import { ProofOfPossessionBuilder } from './ProofOfPossessionBuilder'; +import { generateMissingPKCEOpts } from './functions/AuthorizationUtil'; +import { sendNotification } from './functions/notifications'; + +const debug = Debug('sphereon:oid4vci'); + +export interface OpenID4VCIClientStateV1_0_13 { + credentialIssuer: string; + credentialOffer?: CredentialOfferRequestWithBaseUrl; + clientId?: string; + kid?: string; + jwk?: JWK; + alg?: Alg | string; + endpointMetadata?: EndpointMetadataResultV1_0_13; + accessTokenResponse?: AccessTokenResponse; + authorizationRequestOpts?: AuthorizationRequestOpts; + authorizationCodeResponse?: AuthorizationResponse; + pkce: PKCEOpts; + accessToken?: string; + authorizationURL?: string; +} + +export class OpenID4VCIClientV1_0_13 { + private readonly _state: OpenID4VCIClientStateV1_0_13; + + private constructor({ + credentialOffer, + clientId, + kid, + alg, + credentialIssuer, + pkce, + authorizationRequest, + accessToken, + jwk, + endpointMetadata, + accessTokenResponse, + authorizationRequestOpts, + authorizationCodeResponse, + authorizationURL, + }: { + credentialOffer?: CredentialOfferRequestWithBaseUrl; + kid?: string; + alg?: Alg | string; + clientId?: string; + credentialIssuer?: string; + pkce?: PKCEOpts; + authorizationRequest?: AuthorizationRequestOpts; // Can be provided here, or when manually calling createAuthorizationUrl + jwk?: JWK; + accessToken?: string; + endpointMetadata?: EndpointMetadataResultV1_0_13; + accessTokenResponse?: AccessTokenResponse; + authorizationRequestOpts?: AuthorizationRequestOpts; + authorizationCodeResponse?: AuthorizationResponse; + authorizationURL?: string; + }) { + const issuer = credentialIssuer ?? (credentialOffer ? getIssuerFromCredentialOfferPayload(credentialOffer.credential_offer) : undefined); + if (!issuer) { + throw Error('No credential issuer supplied or deduced from offer'); + } + this._state = { + credentialOffer, + credentialIssuer: issuer, + kid, + alg, + // TODO: We need to refactor this and always explicitly call createAuthorizationRequestUrl, so we can have a credential selection first and use the kid as a default for the client id + clientId: clientId ?? (credentialOffer && getClientIdFromCredentialOfferPayload(credentialOffer.credential_offer)) ?? kid?.split('#')[0], + pkce: { disabled: false, codeChallengeMethod: CodeChallengeMethod.S256, ...pkce }, + authorizationRequestOpts, + authorizationCodeResponse, + accessToken, + jwk, + endpointMetadata, + accessTokenResponse, + authorizationURL, + }; + // Running syncAuthorizationRequestOpts later as it is using the state + if (!this._state.authorizationRequestOpts) { + this._state.authorizationRequestOpts = this.syncAuthorizationRequestOpts(authorizationRequest); + } + debug(`Authorization req options: ${JSON.stringify(this._state.authorizationRequestOpts, null, 2)}`); + } + + public static async fromCredentialIssuer({ + kid, + alg, + retrieveServerMetadata, + clientId, + credentialIssuer, + pkce, + authorizationRequest, + createAuthorizationRequestURL, + }: { + credentialIssuer: string; + kid?: string; + alg?: Alg | string; + retrieveServerMetadata?: boolean; + clientId?: string; + createAuthorizationRequestURL?: boolean; + authorizationRequest?: AuthorizationRequestOpts; // Can be provided here, or when manually calling createAuthorizationUrl + pkce?: PKCEOpts; + }) { + const client = new OpenID4VCIClientV1_0_13({ + kid, + alg, + clientId: clientId ?? authorizationRequest?.clientId, + credentialIssuer, + pkce, + authorizationRequest, + }); + if (retrieveServerMetadata === undefined || retrieveServerMetadata) { + await client.retrieveServerMetadata(); + } + if (createAuthorizationRequestURL === undefined || createAuthorizationRequestURL) { + await client.createAuthorizationRequestUrl({ authorizationRequest, pkce }); + } + return client; + } + + public static async fromState({ state }: { state: OpenID4VCIClientStateV1_0_13 | string }): Promise { + const clientState = typeof state === 'string' ? JSON.parse(state) : state; + + return new OpenID4VCIClientV1_0_13(clientState); + } + + public static async fromURI({ + uri, + kid, + alg, + retrieveServerMetadata, + clientId, + pkce, + createAuthorizationRequestURL, + authorizationRequest, + resolveOfferUri, + }: { + uri: string; + kid?: string; + alg?: Alg | string; + retrieveServerMetadata?: boolean; + createAuthorizationRequestURL?: boolean; + resolveOfferUri?: boolean; + pkce?: PKCEOpts; + clientId?: string; + authorizationRequest?: AuthorizationRequestOpts; // Can be provided here, or when manually calling createAuthorizationUrl + }): Promise { + const credentialOfferClient = await CredentialOfferClient.fromURI(uri, { resolve: resolveOfferUri }); + const client = new OpenID4VCIClientV1_0_13({ + credentialOffer: credentialOfferClient, + kid, + alg, + clientId: clientId ?? authorizationRequest?.clientId ?? credentialOfferClient.clientId, + pkce, + authorizationRequest, + }); + + if (retrieveServerMetadata === undefined || retrieveServerMetadata) { + await client.retrieveServerMetadata(); + } + if ( + credentialOfferClient.supportedFlows.includes(AuthzFlowType.AUTHORIZATION_CODE_FLOW) && + (createAuthorizationRequestURL === undefined || createAuthorizationRequestURL) + ) { + await client.createAuthorizationRequestUrl({ authorizationRequest, pkce }); + debug(`Authorization Request URL: ${client._state.authorizationURL}`); + } + + return client; + } + + /** + * Allows you to create an Authorization Request URL when using an Authorization Code flow. This URL needs to be accessed using the front channel (browser) + * + * The Identity provider would present a login screen typically; after you authenticated, it would redirect to the provided redirectUri; which can be same device or cross-device + * @param opts + */ + public async createAuthorizationRequestUrl(opts?: { authorizationRequest?: AuthorizationRequestOpts; pkce?: PKCEOpts }): Promise { + if (!this._state.authorizationURL) { + this.calculatePKCEOpts(opts?.pkce); + this._state.authorizationRequestOpts = this.syncAuthorizationRequestOpts(opts?.authorizationRequest); + if (!this._state.authorizationRequestOpts) { + throw Error(`No Authorization Request options present or provided in this call`); + } + + // todo: Probably can go with current logic in MetadataClientV1_0_13 who will always set the authorization_endpoint when found + // handling this because of the support for v1_0-08 + if ( + this._state.endpointMetadata?.credentialIssuerMetadata && + 'authorization_endpoint' in this._state.endpointMetadata.credentialIssuerMetadata + ) { + this._state.endpointMetadata.authorization_endpoint = this._state.endpointMetadata.credentialIssuerMetadata.authorization_endpoint as string; + } + this._state.authorizationURL = await createAuthorizationRequestUrl({ + pkce: this._state.pkce, + endpointMetadata: this.endpointMetadata, + authorizationRequest: this._state.authorizationRequestOpts, + credentialOffer: this.credentialOffer, + credentialConfigurationSupported: this.getCredentialsSupported(), + version: this.version(), + }); + } + return this._state.authorizationURL; + } + + public async retrieveServerMetadata(): Promise { + this.assertIssuerData(); + if (!this._state.endpointMetadata) { + if (this.credentialOffer) { + this._state.endpointMetadata = await MetadataClientV1_0_13.retrieveAllMetadataFromCredentialOffer(this.credentialOffer); + } else if (this._state.credentialIssuer) { + this._state.endpointMetadata = await MetadataClientV1_0_13.retrieveAllMetadata(this._state.credentialIssuer); + } else { + throw Error(`Cannot retrieve issuer metadata without either a credential offer, or issuer value`); + } + } + + return this.endpointMetadata; + } + + private calculatePKCEOpts(pkce?: PKCEOpts) { + this._state.pkce = generateMissingPKCEOpts({ ...this._state.pkce, ...pkce }); + } + + public async acquireAccessToken(opts?: { + pin?: string; + clientId?: string; + codeVerifier?: string; + authorizationResponse?: string | AuthorizationResponse; // Pass in an auth response, either as URI/redirect, or object + code?: string; // Directly pass in a code from an auth response + redirectUri?: string; + }): Promise { + const { pin, clientId } = opts ?? {}; + let { redirectUri } = opts ?? {}; + if (opts?.authorizationResponse) { + this._state.authorizationCodeResponse = { ...toAuthorizationResponsePayload(opts.authorizationResponse) }; + } else if (opts?.code) { + this._state.authorizationCodeResponse = { code: opts.code }; + } + const code = this._state.authorizationCodeResponse?.code; + + if (opts?.codeVerifier) { + this._state.pkce.codeVerifier = opts.codeVerifier; + } + this.assertIssuerData(); + + if (clientId) { + this._state.clientId = clientId; + } + if (!this._state.accessTokenResponse) { + const accessTokenClient = new AccessTokenClient(); + + if (redirectUri && redirectUri !== this._state.authorizationRequestOpts?.redirectUri) { + console.log( + `Redirect URI mismatch between access-token (${redirectUri}) and authorization request (${this._state.authorizationRequestOpts?.redirectUri}). According to the specification that is not allowed.`, + ); + } + if (this._state.authorizationRequestOpts?.redirectUri && !redirectUri) { + redirectUri = this._state.authorizationRequestOpts.redirectUri; + } + const response = await accessTokenClient.acquireAccessToken({ + credentialOffer: this.credentialOffer, + metadata: this.endpointMetadata, + credentialIssuer: this.getIssuer(), + pin, + ...(!this._state.pkce.disabled && { codeVerifier: this._state.pkce.codeVerifier }), + code, + redirectUri, + asOpts: { clientId: this.clientId }, + }); + + if (response.errorBody) { + debug(`Access token error:\r\n${JSON.stringify(response.errorBody)}`); + throw Error( + `Retrieving an access token from ${this._state.endpointMetadata?.token_endpoint} for issuer ${this.getIssuer()} failed with status: ${ + response.origResponse.status + }`, + ); + } else if (!response.successBody) { + debug(`Access token error. No success body`); + throw Error( + `Retrieving an access token from ${ + this._state.endpointMetadata?.token_endpoint + } for issuer ${this.getIssuer()} failed as there was no success response body`, + ); + } + this._state.accessTokenResponse = response.successBody; + this._state.accessToken = response.successBody.access_token; + } + + return this.accessTokenResponse; + } + + public async acquireCredentials({ + credentialIdentifier, + credentialTypes, + context, + proofCallbacks, + format, + kid, + jwk, + alg, + jti, + deferredCredentialAwait, + deferredCredentialIntervalInMS, + }: { + credentialIdentifier?: string; + credentialTypes?: string | string[]; + context?: string[]; + proofCallbacks: ProofOfPossessionCallbacks; + format?: CredentialFormat | OID4VCICredentialFormat; + kid?: string; + jwk?: JWK; + alg?: Alg | string; + jti?: string; + deferredCredentialAwait?: boolean; + deferredCredentialIntervalInMS?: number; + experimentalHolderIssuanceSupported?: boolean; + }): Promise { + if ([jwk, kid].filter((v) => v !== undefined).length > 1) { + throw new Error(KID_JWK_X5C_ERROR + `. jwk: ${jwk !== undefined}, kid: ${kid !== undefined}`); + } + + if (alg) this._state.alg = alg; + if (jwk) this._state.jwk = jwk; + if (kid) this._state.kid = kid; + + const requestBuilder = this.credentialOffer + ? CredentialRequestClientBuilder.fromCredentialOffer({ + credentialOffer: this.credentialOffer, + metadata: this.endpointMetadata, + }) + : CredentialRequestClientBuilder.fromCredentialIssuer({ + credentialIssuer: this.getIssuer(), + credentialIdentifier: credentialIdentifier, + metadata: this.endpointMetadata, + version: this.version(), + }); + + requestBuilder.withTokenFromResponse(this.accessTokenResponse); + requestBuilder.withDeferredCredentialAwait(deferredCredentialAwait ?? false, deferredCredentialIntervalInMS); + let subjectIssuance: ExperimentalSubjectIssuance | undefined; + if (this.endpointMetadata?.credentialIssuerMetadata) { + const metadata = this.endpointMetadata.credentialIssuerMetadata; + const types = credentialTypes ? (Array.isArray(credentialTypes) ? credentialTypes : [credentialTypes]) : undefined; + + if (credentialIdentifier) { + if (typeof metadata.credential_configurations_supported !== 'object') { + throw Error( + `Credentials_supported should be an object, current ${typeof metadata.credential_configurations_supported} when credential_identifier is used`, + ); + } + const credentialsSupported = metadata.credential_configurations_supported; + if (!metadata.credential_configurations_supported || !credentialsSupported[credentialIdentifier]) { + throw new Error(`Credential type ${credentialIdentifier} is not supported by issuer ${this.getIssuer()}`); + } + } else if (!types) { + throw Error(`If no credential_identifier is used, we expect types`); + } else if (metadata.credentials_supported && Array.isArray(metadata.credentials_supported)) { + let typeSupported = false; + + metadata.credentials_supported.forEach((supportedCredential) => { + const subTypes = getTypesFromCredentialSupported(supportedCredential); + if ( + subTypes.every((t, i) => types[i] === t) || + (types.length === 1 && (types[0] === supportedCredential.id || subTypes.includes(types[0]))) + ) { + typeSupported = true; + if (supportedCredential.credential_subject_issuance) { + subjectIssuance = { credential_subject_issuance: supportedCredential.credential_subject_issuance }; + } + } + }); + + if (!typeSupported) { + console.log(`Not all credential types ${JSON.stringify(credentialTypes)} are present in metadata for ${this.getIssuer()}`); + // throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`); + } + } else if (metadata.credential_configurations_supported && !Array.isArray(metadata.credential_configurations_supported)) { + const credentialsSupported = metadata.credential_configurations_supported; + if (types.some((type) => !metadata.credential_configurations_supported || !credentialsSupported[type])) { + throw Error(`Not all credential types ${JSON.stringify(credentialTypes)} are supported by issuer ${this.getIssuer()}`); + } + } + // todo: Format check? We might end up with some disjoint type / format combinations supported by the server + } + if (subjectIssuance) { + requestBuilder.withSubjectIssuance(subjectIssuance); + } + + const credentialRequestClient = requestBuilder.build(); + const proofBuilder = ProofOfPossessionBuilder.fromAccessTokenResponse({ + accessTokenResponse: this.accessTokenResponse, + callbacks: proofCallbacks, + version: this.version(), + }) + .withIssuer(this.getIssuer()) + .withAlg(this.alg); + + if (this._state.jwk) { + proofBuilder.withJWK(this._state.jwk); + } + if (this._state.kid) { + proofBuilder.withKid(this._state.kid); + } + + if (this.clientId) { + proofBuilder.withClientId(this.clientId); + } + if (jti) { + proofBuilder.withJti(jti); + } + const response = await credentialRequestClient.acquireCredentialsUsingProof({ + proofInput: proofBuilder, + ...(credentialIdentifier ? { credentialIdentifier, subjectIssuance } : { format, context, credentialTypes, subjectIssuance }), + }); + if (response.errorBody) { + debug(`Credential request error:\r\n${JSON.stringify(response.errorBody)}`); + throw Error( + `Retrieving a credential from ${this._state.endpointMetadata?.credential_endpoint} for issuer ${this.getIssuer()} failed with status: ${ + response.origResponse.status + }`, + ); + } else if (!response.successBody) { + debug(`Credential request error. No success body`); + throw Error( + `Retrieving a credential from ${ + this._state.endpointMetadata?.credential_endpoint + } for issuer ${this.getIssuer()} failed as there was no success response body`, + ); + } + return { ...response.successBody, access_token: response.access_token }; + } + + public async exportState(): Promise { + return JSON.stringify(this._state); + } + + getCredentialsSupported( + format?: (OID4VCICredentialFormat | string) | (OID4VCICredentialFormat | string)[], + ): Record { + return getSupportedCredentials({ + issuerMetadata: this.endpointMetadata.credentialIssuerMetadata, + version: this.version(), + format: format, + types: undefined, + }); + } + + public async sendNotification( + credentialRequestOpts: Partial, + request: NotificationRequest, + accessToken?: string, + ): Promise { + return sendNotification(credentialRequestOpts, request, accessToken ?? this._state.accessToken ?? this._state.accessTokenResponse?.access_token); + } + + /* getCredentialOfferTypes(): string[][] { + if (!this.credentialOffer) { + return []; + } else if (this.credentialOffer.version < OpenId4VCIVersion.VER_1_0_11) { + const orig = this.credentialOffer.original_credential_offer as CredentialOfferPayloadV1_0_08; + const types: string[] = typeof orig.credential_type === 'string' ? [orig.credential_type] : orig.credential_type; + const result: string[][] = []; + result[0] = types; + return result; + } else { + return this.credentialOffer.credential_offer.credentials.map((c) => { + if (typeof c === 'string') { + return [c]; + } else if ('types' in c) { + return c.types; + } else if ('vct' in c) { + return [c.vct]; + } else { + return c.credential_definition.types; + } + }); + } + }*/ + + issuerSupportedFlowTypes(): AuthzFlowType[] { + return ( + this.credentialOffer?.supportedFlows ?? + (this._state.endpointMetadata?.credentialIssuerMetadata?.authorization_endpoint ? [AuthzFlowType.AUTHORIZATION_CODE_FLOW] : []) + ); + } + + isFlowTypeSupported(flowType: AuthzFlowType): boolean { + return this.issuerSupportedFlowTypes().includes(flowType); + } + + public hasAuthorizationURL(): boolean { + return !!this.authorizationURL; + } + + get authorizationURL(): string | undefined { + return this._state.authorizationURL; + } + + get credentialOffer(): CredentialOfferRequestWithBaseUrl | undefined { + return this._state.credentialOffer; + } + + public version(): OpenId4VCIVersion { + return this.credentialOffer?.version ?? OpenId4VCIVersion.VER_1_0_11; + } + + public get endpointMetadata(): EndpointMetadataResultV1_0_13 { + this.assertServerMetadata(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._state.endpointMetadata!; + } + + get kid(): string { + this.assertIssuerData(); + if (!this._state.kid) { + throw new Error('No value for kid is supplied'); + } + return this._state.kid; + } + + get alg(): string { + this.assertIssuerData(); + if (!this._state.alg) { + throw new Error('No value for alg is supplied'); + } + return this._state.alg; + } + + set clientId(value: string | undefined) { + this._state.clientId = value; + } + + get clientId(): string | undefined { + return this._state.clientId; + } + + public hasAccessTokenResponse(): boolean { + return !!this._state.accessTokenResponse; + } + + get accessTokenResponse(): AccessTokenResponse { + this.assertAccessToken(); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return this._state.accessTokenResponse!; + } + + public getIssuer(): string { + this.assertIssuerData(); + return this._state.credentialIssuer; + } + + public getAccessTokenEndpoint(): string { + this.assertIssuerData(); + return this.endpointMetadata + ? this.endpointMetadata.token_endpoint + : AccessTokenClient.determineTokenURL({ issuerOpts: { issuer: this.getIssuer() } }); + } + + public getCredentialEndpoint(): string { + this.assertIssuerData(); + return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`; + } + + public hasDeferredCredentialEndpoint(): boolean { + return !!this.getAccessTokenEndpoint(); + } + + public getDeferredCredentialEndpoint(): string { + this.assertIssuerData(); + return this.endpointMetadata ? this.endpointMetadata.credential_endpoint : `${this.getIssuer()}/credential`; + } + + /** + * Too bad we need a method like this, but EBSI is not exposing metadata + */ + public isEBSI(): boolean { + const credentialOffer = this.credentialOffer?.credential_offer as CredentialOfferPayloadV1_0_13; + + if (credentialOffer?.credential_configuration_ids) { + const credentialConfigurations = this.endpointMetadata.credentialIssuerMetadata?.credential_configurations_supported; + + if (credentialConfigurations) { + const isEBSITrustFramework = credentialOffer.credential_configuration_ids + .map((id) => credentialConfigurations[id]) + .filter( + (config): config is CredentialConfigurationSupportedV1_0_13 => + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + config !== undefined && 'trust_framework' in config && 'name' in config.trust_framework, + ) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + .some((config) => config.trust_framework.name.includes('ebsi')); + + if (isEBSITrustFramework) { + return true; + } + } + } + + this.assertIssuerData(); + return this.endpointMetadata.credentialIssuerMetadata?.authorization_endpoint?.includes('ebsi.eu') ?? false; + } + + private assertIssuerData(): void { + if (!this._state.credentialIssuer) { + throw Error(`No credential issuer value present`); + } else if (!this._state.credentialOffer && this._state.endpointMetadata && this.issuerSupportedFlowTypes().length === 0) { + throw Error(`No issuance initiation or credential offer present`); + } + } + + private assertServerMetadata(): void { + if (!this._state.endpointMetadata) { + throw Error('No server metadata'); + } + } + + private assertAccessToken(): void { + if (!this._state.accessTokenResponse) { + throw Error(`No access token present`); + } + } + + private syncAuthorizationRequestOpts(opts?: AuthorizationRequestOpts): AuthorizationRequestOpts { + let authorizationRequestOpts = { ...this._state?.authorizationRequestOpts, ...opts } as AuthorizationRequestOpts; + if (!authorizationRequestOpts) { + // We only set a redirectUri if no options are provided. + // Note that this only works for mobile apps, that can handle a code query param on the default openid-credential-offer deeplink. + // Provide your own options if that is not desired! + authorizationRequestOpts = { redirectUri: `${DefaultURISchemes.CREDENTIAL_OFFER}://` }; + } + const clientId = authorizationRequestOpts.clientId ?? this._state.clientId; + // sync clientId + this._state.clientId = clientId; + authorizationRequestOpts.clientId = clientId; + return authorizationRequestOpts; + } +} diff --git a/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts b/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts index f5ef60b8..216a2ac3 100644 --- a/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts +++ b/packages/client/lib/__tests__/CredentialRequestClientV1_0_11.spec.ts @@ -16,7 +16,7 @@ import * as jose from 'jose'; // @ts-ignore import nock from 'nock'; -import { CredentialOfferClientV1_0_11, CredentialRequestClientBuilderV1_0_11, MetadataClient, ProofOfPossessionBuilder } from '..'; +import { CredentialOfferClientV1_0_11, CredentialRequestClientBuilderV1_0_11, MetadataClientV1_0_13, ProofOfPossessionBuilder } from '..'; import { IDENTIPROOF_ISSUER_URL, @@ -177,7 +177,7 @@ describe('Credential Request Client with Walt.id ', () => { const credentialOffer = await CredentialOfferClientV1_0_11.fromURI(WALT_IRR_URI); const request = credentialOffer.credential_offer; - const metadata = await MetadataClient.retrieveAllMetadata(getIssuerFromCredentialOfferPayload(request) as string); + const metadata = await MetadataClientV1_0_13.retrieveAllMetadata(getIssuerFromCredentialOfferPayload(request) as string); expect(metadata.credential_endpoint).toEqual(WALT_OID4VCI_METADATA.credential_endpoint); expect(metadata.token_endpoint).toEqual(WALT_OID4VCI_METADATA.token_endpoint); diff --git a/packages/client/lib/__tests__/MetadataClient.spec.ts b/packages/client/lib/__tests__/MetadataClient.spec.ts index 2854829e..ea57d0b6 100644 --- a/packages/client/lib/__tests__/MetadataClient.spec.ts +++ b/packages/client/lib/__tests__/MetadataClient.spec.ts @@ -22,7 +22,7 @@ import { import { getMockData } from './data/VciDataFixtures'; //todo: skipping this. it was written for pre v13 version and we have to do some modifications to make it work -describe.skip('MetadataClient with IdentiProof Issuer should', () => { +describe('MetadataClient with IdentiProof Issuer should', () => { beforeAll(() => { nock.cleanAll(); }); @@ -99,8 +99,7 @@ describe.skip('MetadataClient with IdentiProof Issuer should', () => { ); }); - // skipping because this metadata is for an older version. update it with a new metadata - it.skip('Fail if there is no credential endpoint with errors enabled', async () => { + it('Fail if there is no credential endpoint with errors enabled', async () => { const meta = JSON.parse(JSON.stringify(IDENTIPROOF_OID4VCI_METADATA)); delete meta.credential_endpoint; nock(IDENTIPROOF_ISSUER_URL).get(WellKnownEndpoints.OPENID4VCI_ISSUER).reply(200, JSON.stringify(meta)); @@ -112,7 +111,7 @@ describe.skip('MetadataClient with IdentiProof Issuer should', () => { ); }); - it.skip('Succeed with default value if there is no credential endpoint with errors disabled', async () => { + it('Succeed with default value if there is no credential endpoint with errors disabled', async () => { nock(IDENTIPROOF_ISSUER_URL).get(WellKnownEndpoints.OPENID4VCI_ISSUER).reply(200, JSON.stringify(IDENTIPROOF_OID4VCI_METADATA)); nock(IDENTIPROOF_AS_URL).get(WellKnownEndpoints.OAUTH_AS).reply(200, JSON.stringify(IDENTIPROOF_AS_METADATA)); nock(IDENTIPROOF_AS_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404); diff --git a/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts b/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts index 32ec562c..0450d2d4 100644 --- a/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts +++ b/packages/client/lib/__tests__/OpenID4VCIClient.spec.ts @@ -203,21 +203,25 @@ describe('OpenID4VCIClient should', () => { }); }); describe('should successfully handle isEbsi function', () => { - it.skip('should return true when calling isEbsi function', async () => { + it('should return true when calling isEbsi function', async () => { + nock(MOCK_URL).get(/.*/).reply(200, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(404, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {}); const client = await OpenID4VCIClient.fromURI({ clientId: 'test-client', - uri: 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fserver.example.com%22%2C%22credential_configuration_ids%22%3A%5B%22TestCredential%22%5D%7D', + uri: 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fserver.example.com%22%2C%20%22credentials%22%3A%5B%7B%22format%22%3A%22jwt_vc%22%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22VerifiableAttestation%22%2C%22CTWalletSameAuthorisedInTime%22%5D%2C%22trust_framework%22%3A%7B%22name%22%3A%22ebsi%22%2C%22type%22%3A%22Accreditation%22%2C%22uri%22%3A%22TIR%20link%20towards%20accreditation%22%7D%7D%5D%7D', createAuthorizationRequestURL: false, }); - nock(MOCK_URL).get(/.*/).reply(200, {}); - nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(404, {}); - nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {}); // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore - client._state.endpointMetadata?.credentialIssuerMetadata?.credential_configurations_supported['TestCredential'] = { - trust_framework: { - name: 'ebsi_trust', + client._state.endpointMetadata?.credentialIssuerMetadata = { + credentials_supported: { + TestCredential: { + trust_framework: { + name: 'ebsi_trust', + }, + }, }, }; expect(client.isEBSI()).toBe(true); diff --git a/packages/client/lib/__tests__/OpenID4VCIClientV1_0_11.spec.ts b/packages/client/lib/__tests__/OpenID4VCIClientV1_0_11.spec.ts index 4332bce3..ed89321a 100644 --- a/packages/client/lib/__tests__/OpenID4VCIClientV1_0_11.spec.ts +++ b/packages/client/lib/__tests__/OpenID4VCIClientV1_0_11.spec.ts @@ -200,3 +200,27 @@ describe('OpenID4VCIClientV1_0_11 should', () => { ); }); }); + +it('should return true when calling isEbsi function', async () => { + nock(MOCK_URL).get(/.*/).reply(200, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(404, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {}); + const client = await OpenID4VCIClientV1_0_11.fromURI({ + clientId: 'test-client', + uri: 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fserver.example.com%22%2C%20%22credentials%22%3A%5B%7B%22format%22%3A%22jwt_vc%22%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22VerifiableAttestation%22%2C%22CTWalletSameAuthorisedInTime%22%5D%2C%22trust_framework%22%3A%7B%22name%22%3A%22ebsi%22%2C%22type%22%3A%22Accreditation%22%2C%22uri%22%3A%22TIR%20link%20towards%20accreditation%22%7D%7D%5D%7D', + createAuthorizationRequestURL: false, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata?.credentialIssuerMetadata = { + credentials_supported: { + TestCredential: { + trust_framework: { + name: 'ebsi_trust', + }, + }, + }, + }; + expect(client.isEBSI()).toBe(true); +}); diff --git a/packages/client/lib/__tests__/OpenID4VCIClientV1_0_13.spec.ts b/packages/client/lib/__tests__/OpenID4VCIClientV1_0_13.spec.ts new file mode 100644 index 00000000..4ef5a1a6 --- /dev/null +++ b/packages/client/lib/__tests__/OpenID4VCIClientV1_0_13.spec.ts @@ -0,0 +1,204 @@ +import { CodeChallengeMethod, WellKnownEndpoints } from '@sphereon/oid4vci-common'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import nock from 'nock'; + +import { OpenID4VCIClientV1_0_13 } from '../OpenID4VCIClientV1_0_13'; + +const MOCK_URL = 'https://server.example.com/'; + +describe('OpenID4VCIClientV1_0_13 should', () => { + let client: OpenID4VCIClientV1_0_13; + + beforeEach(async () => { + nock(MOCK_URL).get(/.*/).reply(200, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(404, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(404, {}); + client = await OpenID4VCIClientV1_0_13.fromURI({ + clientId: 'test-client', + uri: 'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fserver.example.com%22%2C%22credential_configuration_ids%22%3A%5B%22TestCredential%22%5D%7D', + createAuthorizationRequestURL: false, + }); + }); + + afterEach(() => { + nock.cleanAll(); + }); + + it('should successfully construct an authorization request url', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + const url = await client.createAuthorizationRequestUrl({ + authorizationRequest: { + scope: 'openid TestCredential', + redirectUri: 'http://localhost:8881/cb', + }, + }); + + const urlSearchParams = new URLSearchParams(url.split('?')[1]); + const scope = urlSearchParams.get('scope')?.split(' '); + + expect(scope?.[0]).toBe('openid'); + }); + it('throw an error if authorization endpoint is not set in server metadata', async () => { + await expect( + client.createAuthorizationRequestUrl({ + authorizationRequest: { + scope: 'openid TestCredential', + redirectUri: 'http://localhost:8881/cb', + }, + }), + ).rejects.toThrow(Error('Server metadata does not contain authorization endpoint')); + }); + it("injects 'openid' as the first scope if not provided", async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + const url = await client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, + authorizationRequest: { + scope: 'TestCredential', + redirectUri: 'http://localhost:8881/cb', + }, + }); + + const urlSearchParams = new URLSearchParams(url.split('?')[1]); + const scope = urlSearchParams.get('scope')?.split(' '); + + expect(scope?.[0]).toBe('openid'); + }); + it('throw an error if no scope and no authorization_details is provided', async () => { + nock(MOCK_URL).get(/.*/).reply(200, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OAUTH_AS).reply(200, {}); + nock(MOCK_URL).get(WellKnownEndpoints.OPENID_CONFIGURATION).reply(200, {}); + // Use a client with issuer only to trigger the error + client = await OpenID4VCIClientV1_0_13.fromCredentialIssuer({ + credentialIssuer: MOCK_URL, + createAuthorizationRequestURL: false, + retrieveServerMetadata: false, + }); + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata = { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + credentialIssuerMetadata: { + authorization_endpoint: `${MOCK_URL}v1/auth/authorize`, + token_endpoint: `${MOCK_URL}/token`, + }, + }; + // client._state.endpointMetadata.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + await expect( + client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, + authorizationRequest: { + clientId: 'clientId', + redirectUri: 'http://localhost:8881/cb', + }, + }), + ).rejects.toThrow(Error('Please provide a scope or authorization_details if no credential offer is present')); + }); + it('create an authorization request url with authorization_details array property', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + await expect( + client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, + authorizationRequest: { + authorizationDetails: [ + { + type: 'openid_credential', + format: 'ldp_vc', + credential_definition: { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://www.w3.org/2018/credentials/examples/v1'], + types: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + { + type: 'openid_credential', + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + format: 'mso_mdoc', + doctype: 'org.iso.18013.5.1.mDL', + }, + ], + redirectUri: 'http://localhost:8881/cb', + }, + }), + ).resolves.toEqual( + 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%5B%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%2C%22locations%22%3A%5B%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%5D%7D%2C%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22mso_mdoc%22%2C%22doctype%22%3A%22org%2Eiso%2E18013%2E5%2E1%2EmDL%22%2C%22locations%22%3A%5B%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%5D%7D%5D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&client_id=test-client&scope=openid', + ); + }); + it('create an authorization request url with authorization_details object property', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata?.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + await expect( + client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, + authorizationRequest: { + authorizationDetails: { + type: 'openid_credential', + format: 'ldp_vc', + credential_definition: { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://www.w3.org/2018/credentials/examples/v1'], + types: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + redirectUri: 'http://localhost:8881/cb', + }, + }), + ).resolves.toEqual( + 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%2C%22locations%22%3A%5B%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%5D%7D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&client_id=test-client&scope=openid', + ); + }); + + it('create an authorization request url with authorization_details and scope', async () => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + client._state.endpointMetadata.credentialIssuerMetadata.authorization_endpoint = `${MOCK_URL}v1/auth/authorize`; + + await expect( + client.createAuthorizationRequestUrl({ + pkce: { + codeChallengeMethod: CodeChallengeMethod.S256, + codeChallenge: 'mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs', + }, + authorizationRequest: { + authorizationDetails: { + type: 'openid_credential', + format: 'ldp_vc', + locations: ['https://test.com'], + credential_definition: { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://www.w3.org/2018/credentials/examples/v1'], + types: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + scope: 'openid', + redirectUri: 'http://localhost:8881/cb', + }, + }), + ).resolves.toEqual( + 'https://server.example.com/v1/auth/authorize?response_type=code&code_challenge_method=S256&code_challenge=mE2kPHmIprOqtkaYmESWj35yz-PB5vzdiSu0tAZ8sqs&authorization_details=%7B%22type%22%3A%22openid_credential%22%2C%22format%22%3A%22ldp_vc%22%2C%22locations%22%3A%5B%22https%3A%2F%2Ftest%2Ecom%22%2C%22https%3A%2F%2Fserver%2Eexample%2Ecom%22%5D%2C%22credential_definition%22%3A%7B%22%40context%22%3A%5B%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fv1%22%2C%22https%3A%2F%2Fwww%2Ew3%2Eorg%2F2018%2Fcredentials%2Fexamples%2Fv1%22%5D%2C%22types%22%3A%5B%22VerifiableCredential%22%2C%22UniversityDegreeCredential%22%5D%7D%7D&redirect_uri=http%3A%2F%2Flocalhost%3A8881%2Fcb&client_id=test-client&scope=openid', + ); + }); +}); diff --git a/packages/client/lib/__tests__/SdJwt.spec.ts b/packages/client/lib/__tests__/SdJwt.spec.ts index e97aa481..c3abc296 100644 --- a/packages/client/lib/__tests__/SdJwt.spec.ts +++ b/packages/client/lib/__tests__/SdJwt.spec.ts @@ -8,7 +8,7 @@ import { // @ts-ignore import nock from 'nock'; -import { OpenID4VCIClient } from '..'; +import { OpenID4VCIClientV1_0_13 } from '..'; import { createAccessTokenResponse, IssuerMetadataBuilderV1_13, VcIssuerBuilder } from '../../../issuer'; export const UNIT_TEST_TIMEOUT = 30000; @@ -88,7 +88,7 @@ describe('sd-jwt vc', () => { 'openid-credential-offer://?credential_offer=%7B%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22123%22%2C%22tx_code%22%3A%7B%22input_mode%22%3A%22text%22%2C%22length%22%3A3%7D%7D%7D%2C%22credential_configuration_ids%22%3A%5B%22SdJwtCredential%22%5D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fexample.com%22%7D', ); - const client = await OpenID4VCIClient.fromURI({ + const client = await OpenID4VCIClientV1_0_13.fromURI({ uri: offerUri.uri, }); diff --git a/packages/client/lib/__tests__/SphereonE2E.spec.test.ts b/packages/client/lib/__tests__/SphereonE2E.spec.test.ts index 73b37ad1..8ae5e827 100644 --- a/packages/client/lib/__tests__/SphereonE2E.spec.test.ts +++ b/packages/client/lib/__tests__/SphereonE2E.spec.test.ts @@ -25,7 +25,8 @@ const jwk: JWK = { // priv hex: 913466d1a38d1d8c0d3c0fb0fc3b633075085a31372bbd2a8022215a88d9d1e5 const did = `did:key:z6Mki5ZwZKN1dBQprfJTikUvkDxrHijiiQngkWviMF5gw2Hv`; const kid = `${did}#z6Mki5ZwZKN1dBQprfJTikUvkDxrHijiiQngkWviMF5gw2Hv`; -describe('OID4VCI-Client using Sphereon issuer should', () => { +// Sphereon infra down rn +describe.skip('OID4VCI-Client using Sphereon issuer should', () => { async function test(format: 'ldp_vc' | 'jwt_vc_json') { debug.enable('*'); const offer = await getCredentialOffer(format); @@ -59,14 +60,14 @@ describe('OID4VCI-Client using Sphereon issuer should', () => { expect(format.startsWith(wrappedVC.format)).toEqual(true); } - xit( + it( 'succeed in a full flow with the client using OpenID4VCI version 11 and ldp_vc', async () => { await test('ldp_vc'); }, UNIT_TEST_TIMEOUT, ); - xit( + it( 'succeed in a full flow with the client using OpenID4VCI version 11 and jwt_vc_json', async () => { await test('jwt_vc_json'); diff --git a/packages/client/lib/index.ts b/packages/client/lib/index.ts index 629911ea..86b010f8 100644 --- a/packages/client/lib/index.ts +++ b/packages/client/lib/index.ts @@ -9,8 +9,8 @@ export * from './CredentialRequestClientV1_0_11'; export * from './CredentialRequestClientBuilder'; export * from './CredentialRequestClientBuilderV1_0_11'; export * from './functions'; -export * from './MetadataClient'; +export * from './MetadataClientV1_0_13'; export * from './MetadataClientV1_0_11'; -export * from './OpenID4VCIClient'; +export * from './OpenID4VCIClientV1_0_13'; export * from './OpenID4VCIClientV1_0_11'; export * from './ProofOfPossessionBuilder'; diff --git a/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts b/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts index f7380d8b..eba1f727 100644 --- a/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts +++ b/packages/common/lib/__tests__/CredentialOfferUtil.spec.ts @@ -39,7 +39,7 @@ describe('CredentialOfferUtil should', () => { 'get exception for mixed attributes in URL', async () => { expect(() => determineSpecVersionFromURI(INITIATE_QR_DATA_MIXED_V9)).toThrow( - Error("Invalid param. Some keys have been used from version: 1008 version while 'credentials' is used from version: 1011"), + Error("Invalid param. Some keys have been used from version: 1008 version while 'credentials' is used from version: [1011]"), ); }, UNIT_TEST_TIMEOUT, diff --git a/packages/common/lib/functions/CredentialOfferUtil.ts b/packages/common/lib/functions/CredentialOfferUtil.ts index f8fb975b..950fb178 100644 --- a/packages/common/lib/functions/CredentialOfferUtil.ts +++ b/packages/common/lib/functions/CredentialOfferUtil.ts @@ -29,18 +29,19 @@ export function determineSpecVersionFromURI(uri: string): OpenId4VCIVersion { let version: OpenId4VCIVersion = OpenId4VCIVersion.VER_UNKNOWN; version = determineSpecVersionFromScheme(uri, version); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_08, 'initiate_issuance'); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_08, 'credential_type'); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_08, 'op_state'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'initiate_issuance'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'credential_type'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_08], 'op_state'); // version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_09, 'credentials'); // version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_09, 'initiate_issuance_uri') - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_11, 'credentials'); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_11, 'grants.user_pin_required'); + // version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_11, 'credential_offer='); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_11], 'credentials'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_11], 'grants.user_pin_required'); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_13, 'credential_configuration_ids'); - version = getVersionFromURIParam(uri, version, OpenId4VCIVersion.VER_1_0_13, 'tx_code'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_13], 'credential_configuration_ids'); + version = getVersionFromURIParam(uri, version, [OpenId4VCIVersion.VER_1_0_13], 'tx_code'); if (version === OpenId4VCIVersion.VER_UNKNOWN) { version = OpenId4VCIVersion.VER_1_0_13; } @@ -50,14 +51,16 @@ export function determineSpecVersionFromURI(uri: string): OpenId4VCIVersion { export function determineSpecVersionFromScheme(credentialOfferURI: string, openId4VCIVersion: OpenId4VCIVersion) { const scheme = getScheme(credentialOfferURI); if (credentialOfferURI.includes(DefaultURISchemes.INITIATE_ISSUANCE)) { - return recordVersion(openId4VCIVersion, OpenId4VCIVersion.VER_1_0_08, scheme); + return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_08], scheme); } // todo: drop support for v1_0_8. version 11 and version 13 have the same scheme 'openid-credential-offer' - /*else if (credentialOfferURI.includes(DefaultURISchemes.CREDENTIAL_OFFER)) { - return recordVersion(openId4VCIVersion, OpenId4VCIVersion.VER_1_0_11, scheme); - }*/ - else { - return recordVersion(openId4VCIVersion, OpenId4VCIVersion.VER_UNKNOWN, scheme); + else if (credentialOfferURI.includes(DefaultURISchemes.CREDENTIAL_OFFER)) { + if (credentialOfferURI.includes('credentials:') || credentialOfferURI.includes('credentials%22')) { + return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_11], scheme); + } + return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_1_0_13], scheme); + } else { + return recordVersion(openId4VCIVersion, [OpenId4VCIVersion.VER_UNKNOWN], scheme); } } @@ -211,6 +214,8 @@ function isCredentialOfferV1_0_12(offer: CredentialOfferPayload | CredentialOffe function isCredentialOfferV1_0_13(offer: CredentialOfferPayload | CredentialOffer): boolean { if (!offer) { return false; + } else if (typeof offer === 'string' && (offer as string).startsWith('{')) { + offer = JSON.parse(offer); } if ('credential_issuer' in offer && 'credential_configuration_ids' in offer) { // payload @@ -223,40 +228,8 @@ function isCredentialOfferV1_0_13(offer: CredentialOfferPayload | CredentialOffe return 'credential_offer_uri' in offer; } -export async function toUniformCredentialOfferRequestV1_0_11( - offer: CredentialOffer, - opts?: { - resolve?: boolean; - version?: OpenId4VCIVersion; - }, -): Promise { - const version = opts?.version ?? determineSpecVersionFromOffer(offer); - let originalCredentialOffer = offer.credential_offer; - let credentialOfferURI: string | undefined; - if ('credential_offer_uri' in offer && offer?.credential_offer_uri !== undefined) { - credentialOfferURI = offer.credential_offer_uri; - if (opts?.resolve || opts?.resolve === undefined) { - originalCredentialOffer = (await resolveCredentialOfferURI(credentialOfferURI)) as CredentialOfferPayloadV1_0_11; - } else if (!originalCredentialOffer) { - throw Error(`Credential offer uri (${credentialOfferURI}) found, but resolution was explicitly disabled and credential_offer was supplied`); - } - } - if (!originalCredentialOffer) { - throw Error('No credential offer available'); - } - const payload = toUniformCredentialOfferPayload(originalCredentialOffer, opts); - const supportedFlows = determineFlowType(payload, version); - return { - credential_offer: payload, - original_credential_offer: originalCredentialOffer, - ...(credentialOfferURI && { credential_offer_uri: credentialOfferURI }), - supportedFlows, - version, - }; -} - export async function toUniformCredentialOfferRequest( - offer: CredentialOfferV1_0_13, + offer: CredentialOffer | CredentialOfferV1_0_13, opts?: { resolve?: boolean; version?: OpenId4VCIVersion; @@ -268,7 +241,9 @@ export async function toUniformCredentialOfferRequest( if ('credential_offer_uri' in offer && offer?.credential_offer_uri !== undefined) { credentialOfferURI = offer.credential_offer_uri; if (opts?.resolve || opts?.resolve === undefined) { - originalCredentialOffer = (await resolveCredentialOfferURI(credentialOfferURI)) as CredentialOfferPayloadV1_0_13; + originalCredentialOffer = (await resolveCredentialOfferURI(credentialOfferURI)) as + | CredentialOfferPayloadV1_0_11 + | CredentialOfferPayloadV1_0_13; } else if (!originalCredentialOffer) { throw Error(`Credential offer uri (${credentialOfferURI}) found, but resolution was explicitly disabled and credential_offer was supplied`); } @@ -440,20 +415,32 @@ export function determineGrantTypes( return types; } -function getVersionFromURIParam(credentialOfferURI: string, currentVersion: OpenId4VCIVersion, matchingVersion: OpenId4VCIVersion, param: string) { +function getVersionFromURIParam( + credentialOfferURI: string, + currentVersion: OpenId4VCIVersion, + matchingVersion: OpenId4VCIVersion[], + param: string, + allowUpgrade = true, +) { if (credentialOfferURI.includes(param)) { - return recordVersion(currentVersion, matchingVersion, param); + return recordVersion(currentVersion, matchingVersion, param, allowUpgrade); } return currentVersion; } -function recordVersion(currentVersion: OpenId4VCIVersion, matchingVersion: OpenId4VCIVersion, key: string) { - if (currentVersion === OpenId4VCIVersion.VER_UNKNOWN || matchingVersion === currentVersion) { - return matchingVersion; +function recordVersion(currentVersion: OpenId4VCIVersion, matchingVersion: OpenId4VCIVersion[], key: string, allowUpgrade = true) { + matchingVersion = matchingVersion.sort().reverse(); + if (currentVersion === OpenId4VCIVersion.VER_UNKNOWN) { + return matchingVersion[0]; + } else if (matchingVersion.includes(currentVersion)) { + if (!allowUpgrade) { + return currentVersion; + } + return matchingVersion[0]; } throw new Error( - `Invalid param. Some keys have been used from version: ${currentVersion} version while '${key}' is used from version: ${matchingVersion}`, + `Invalid param. Some keys have been used from version: ${currentVersion} version while '${key}' is used from version: ${JSON.stringify(matchingVersion)}`, ); } diff --git a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts index 6c9adaa8..80dcb067 100644 --- a/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts +++ b/packages/issuer-rest/lib/__tests__/ClientIssuerIT.spec.ts @@ -1,7 +1,7 @@ import { KeyObject } from 'crypto' import * as didKeyDriver from '@digitalcredentials/did-method-key' -import { OpenID4VCIClient } from '@sphereon/oid4vci-client' +import { OpenID4VCIClientV1_0_13 } from '@sphereon/oid4vci-client' import { AccessTokenResponse, Alg, @@ -181,7 +181,7 @@ describe('VcIssuer', () => { let credOfferSession: CredentialOfferSession let uri: string - let client: OpenID4VCIClient + let client: OpenID4VCIClientV1_0_13 it('should create credential offer', async () => { expect(server.issuer).toBeDefined() uri = await vcIssuer @@ -208,7 +208,7 @@ describe('VcIssuer', () => { }) it('should create client from credential offer URI', async () => { - client = await OpenID4VCIClient.fromURI({ + client = await OpenID4VCIClientV1_0_13.fromURI({ uri: `http://localhost:3456/test?credential_offer=%7B%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22previously-created-state%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22test_code%22%7D%7D%2C%22credential_configuration_ids%22%3A%5B%22UniversityDegree_JWT%22%5D%2C%22credential_issuer%22%3A%22http%3A%2F%2Flocalhost%3A3456%2Ftest%22%2C%22credential_configuration_ids%22%3A%5B%22UniversityDegree_JWT%22%5D%7D`, kid: subjectDIDKey.didDocument.authentication[0], alg: 'ES256', @@ -308,7 +308,7 @@ describe('VcIssuer', () => { // TODO: ksadjad remove the skipped test it.skip('should acquire access token', async () => { - client = await OpenID4VCIClient.fromURI({ + client = await OpenID4VCIClientV1_0_13.fromURI({ uri: `http://localhost:3456/test?credential_offer=%7B%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22previously-created-state%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22testcode%22%7D%7D%2C%22credential_configuration_ids%22%3A%5B%22UniversityDegree_JWT%22%5D%2C%22credential_issuer%22%3A%22http%3A%2F%2Flocalhost%3A3456%2Ftest%22%7D`, kid: subjectDIDKey.didDocument.authentication[0], alg: 'ES256', @@ -329,7 +329,7 @@ describe('VcIssuer', () => { .setExpirationTime('2h') .sign(subjectKeypair.privateKey) } - client = await OpenID4VCIClient.fromURI({ + client = await OpenID4VCIClientV1_0_13.fromURI({ uri: `http://localhost:3456/test?credential_offer=%7B%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22previously-created-state%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22testcode%22%7D%7D%2C%22credential_configuration_ids%22%3A%5B%22UniversityDegree_JWT%22%5D%2C%22credential_issuer%22%3A%22http%3A%2F%2Flocalhost%3A3456%2Ftest%22%2C%22credential_configuration_ids%22%3A%5B%22UniversityDegree_JWT%22%5D%7D`, kid: subjectDIDKey.didDocument.authentication[0], alg: 'ES256', diff --git a/packages/issuer/lib/__tests__/VcIssuer.spec.ts b/packages/issuer/lib/__tests__/VcIssuer.spec.ts index ff6caa9e..aa037c77 100644 --- a/packages/issuer/lib/__tests__/VcIssuer.spec.ts +++ b/packages/issuer/lib/__tests__/VcIssuer.spec.ts @@ -1,4 +1,4 @@ -import { OpenID4VCIClient } from '@sphereon/oid4vci-client' +import { OpenID4VCIClientV1_0_13 } from '@sphereon/oid4vci-client' import { Alg, ALG_ERROR, @@ -165,7 +165,7 @@ describe('VcIssuer', () => { 'http://issuer-example.com?credential_offer=%7B%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22previously-created-state%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22test_code%22%2C%22user_pin_required%22%3Atrue%7D%7D%2C%22credential_issuer%22%3A%22https%3A%2F%2Fissuer.research.identiproof.io%22%2C%22credentials%22%3A%5B%7B%22format%22%3A%22jwt_vc_json%22%2C%22types%22%3A%5B%22VerifiableCredential%22%5D%2C%22credentialSubject%22%3A%7B%22given_name%22%3A%7B%22name%22%3A%22given%20name%22%2C%22locale%22%3A%22en-US%22%7D%7D%2C%22cryptographic_suites_supported%22%3A%5B%22ES256K%22%5D%2C%22cryptographic_binding_methods_supported%22%3A%5B%22did%22%5D%2C%22id%22%3A%22UniversityDegree_JWT%22%2C%22display%22%3A%5B%7B%22name%22%3A%22University%20Credential%22%2C%22locale%22%3A%22en-US%22%2C%22logo%22%3A%7B%22url%22%3A%22https%3A%2F%2Fexampleuniversity.com%2Fpublic%2Flogo.png%22%2C%22alt_text%22%3A%22a%20square%20logo%20of%20a%20university%22%7D%2C%22background_color%22%3A%22%2312107c%22%2C%22text_color%22%3A%22%23FFFFFF%22%7D%5D%7D%5D%7D', ) - const client = await OpenID4VCIClient.fromURI({ uri }) + const client = await OpenID4VCIClientV1_0_13.fromURI({ uri }) expect(client.credentialOffer).toEqual({ baseUrl: 'http://issuer-example.com', credential_offer: {