diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index 2430ed1fcd..54d5962374 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -28,10 +28,11 @@ "class-transformer": "^0.5.1", "rxjs": "^7.8.0", "zod": "^3.24.2", - "@openid4vc/openid4vci": "0.3.0-alpha-20250330133535", - "@openid4vc/oauth2": "0.3.0-alpha-20250330133535", - "@openid4vc/openid4vp": "0.3.0-alpha-20250330133535", - "@openid4vc/utils": "0.3.0-alpha-20250330133535" + "@openid-federation/core": "0.1.1-alpha.17", + "@openid4vc/openid4vci": "0.3.0-alpha-20250404080256", + "@openid4vc/oauth2": "0.3.0-alpha-20250404080256", + "@openid4vc/openid4vp": "0.3.0-alpha-20250404080256", + "@openid4vc/utils": "0.3.0-alpha-20250404080256" }, "devDependencies": { "@credo-ts/tenants": "workspace:*", diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts index a4f9f1772b..fb3f8e2c7d 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts @@ -9,6 +9,7 @@ import type { } from './OpenId4VciHolderServiceOptions' import type { OpenId4VpAcceptAuthorizationRequestOptions, + OpenId4VpResolveTrustChainsOptions, ResolveOpenId4VpAuthorizationRequestOptions, } from './OpenId4vpHolderServiceOptions' @@ -191,4 +192,8 @@ export class OpenId4VcHolderApi { public async sendNotification(options: OpenId4VciSendNotificationOptions) { return this.openId4VciHolderService.sendNotification(this.agentContext, options) } + + public async resolveOpenIdFederationChains(options: OpenId4VpResolveTrustChainsOptions) { + return this.openId4VpHolderService.resolveOpenIdFederationChains(this.agentContext, options) + } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts index 046dddff54..1990c5220d 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts @@ -11,6 +11,8 @@ import type { } from '@credo-ts/core' import type { OpenId4VpAcceptAuthorizationRequestOptions, + OpenId4VpFetchEntityConfigurationOptions, + OpenId4VpResolveTrustChainsOptions, OpenId4VpResolvedAuthorizationRequest, ParsedTransactionDataEntry, ResolveOpenId4VpAuthorizationRequestOptions, @@ -23,9 +25,15 @@ import { DifPresentationExchangeService, DifPresentationExchangeSubmissionLocation, Hasher, + JwsService, TypedArrayEncoder, + getJwkFromJson, injectable, } from '@credo-ts/core' +import { + fetchEntityConfiguration as federationFetchEntityConfiguration, + resolveTrustChains as federationResolveTrustChains, +} from '@openid-federation/core' import { Openid4vpAuthorizationResponse, Openid4vpClient, @@ -47,10 +55,15 @@ export class OpenId4VpHolderService { private getOpenid4vpClient( agentContext: AgentContext, - options?: { trustedCertificates?: EncodedX509Certificate[]; isVerifyOpenId4VpAuthorizationRequest?: boolean } + options?: { + trustedCertificates?: EncodedX509Certificate[] + trustedFederationEntityIds?: string[] + isVerifyOpenId4VpAuthorizationRequest?: boolean + } ) { const callbacks = getOid4vcCallbacks(agentContext, { trustedCertificates: options?.trustedCertificates, + trustedFederationEntityIds: options?.trustedFederationEntityIds, isVerifyOpenId4VpAuthorizationRequest: options?.isVerifyOpenId4VpAuthorizationRequest, }) return new Openid4vpClient({ callbacks }) @@ -119,6 +132,7 @@ export class OpenId4VpHolderService { ): Promise { const openid4vpClient = this.getOpenid4vpClient(agentContext, { trustedCertificates: options?.trustedCertificates, + trustedFederationEntityIds: options?.trustedFederationEntityIds, isVerifyOpenId4VpAuthorizationRequest: true, }) const { params } = openid4vpClient.parseOpenid4vpAuthorizationRequest({ authorizationRequest }) @@ -130,10 +144,50 @@ export class OpenId4VpHolderService { const { client, pex, transactionData, dcql } = verifiedAuthorizationRequest - if (client.scheme !== 'x509_san_dns' && client.scheme !== 'did' && client.scheme !== 'web-origin') { + if ( + client.scheme !== 'x509_san_dns' && + client.scheme !== 'did' && + client.scheme !== 'web-origin' && + client.scheme !== 'https' + ) { throw new CredoError(`Client scheme '${client.scheme}' is not supported`) } + if (client.scheme === 'https') { + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const entityConfiguration = await federationFetchEntityConfiguration({ + entityId: client.identifier, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { + method: 'jwk', + jwk: getJwkFromJson(jwk), + }, + }) + + return res.isValid + }, + }) + if (!entityConfiguration) + throw new CredoError(`Unable to fetch entity configuration for entityId '${client.identifier}'`) + + const openidRelyingPartyMetadata = entityConfiguration.metadata?.openid_relying_party + if (!openidRelyingPartyMetadata) { + throw new CredoError(`Federation entity '${client.identifier}' does not have 'openid_relying_party' metadata.`) + } + + // FIXME: we probably don't want to override this, but otherwise the accept logic doesn't have + // access to the correct metadata. Should we also pass client to accept? + // @ts-ignore + verifiedAuthorizationRequest.authorizationRequestPayload.client_metadata = openidRelyingPartyMetadata + // FIXME: we should not just override the metadata? + // When federation is used we need to use the federation metadata + // @ts-ignore + client.clientMetadata = openidRelyingPartyMetadata + } + const pexResult = pex?.presentation_definition ? await this.handlePresentationExchangeRequest(agentContext, pex.presentation_definition, transactionData) : undefined @@ -147,6 +201,10 @@ export class OpenId4VpHolderService { authorizationRequestPayload: verifiedAuthorizationRequest.authorizationRequestPayload, transactionData: pexResult?.matchedTransactionData ?? dcqlResult?.matchedTransactionData, presentationExchange: pexResult?.pex, + verifier: { + clientIdScheme: client.scheme, + clientMetadata: client.clientMetadata, + }, dcql: dcqlResult?.dcql, origin: options?.origin, signedAuthorizationRequest: verifiedAuthorizationRequest.jar @@ -406,6 +464,9 @@ export class OpenId4VpHolderService { const response = await openid4vpClient.createOpenid4vpAuthorizationResponse({ authorizationRequestPayload, + // We overwrite the client metadata on the authorization request payload when using OpenID Federation + clientMetadata: authorizationRequestPayload.client_metadata, + origin: options.origin, authorizationResponsePayload: { vp_token: vpToken, presentation_submission: presentationSubmission, @@ -483,4 +544,50 @@ export class OpenId4VpHolderService { presentationDuringIssuanceSession: responseJson?.presentation_during_issuance_session as string | undefined, } as const } + + public async resolveOpenIdFederationChains(agentContext: AgentContext, options: OpenId4VpResolveTrustChainsOptions) { + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const { entityId, trustAnchorEntityIds } = options + + return federationResolveTrustChains({ + entityId, + trustAnchorEntityIds, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { + method: 'jwk', + jwk: getJwkFromJson(jwk), + }, + }) + + return res.isValid + }, + }) + } + + public async fetchOpenIdFederationEntityConfiguration( + agentContext: AgentContext, + options: OpenId4VpFetchEntityConfigurationOptions + ) { + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const { entityId } = options + + return federationFetchEntityConfiguration({ + entityId, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { + method: 'jwk', + jwk: getJwkFromJson(jwk), + }, + }) + + return res.isValid + }, + }) + } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts index 6738ea77d2..138b1a29fe 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts @@ -6,7 +6,7 @@ import type { DifPresentationExchangeDefinition, EncodedX509Certificate, } from '@credo-ts/core' -import { ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' +import { ClientIdScheme, ClientMetadata, ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' import type { OpenId4VpAuthorizationRequestPayload } from '../shared' // TODO: export from oid4vp @@ -14,6 +14,7 @@ export type ParsedTransactionDataEntry = NonNullable { diff --git a/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts new file mode 100644 index 0000000000..85a4f3003b --- /dev/null +++ b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts @@ -0,0 +1,105 @@ +import type { Buffer } from '@credo-ts/core' +import type { Response, Router } from 'express' +import type { OpenId4VcIssuanceRequest } from './requestContext' + +import { Key, KeyType, getJwkFromKey } from '@credo-ts/core' +import { createEntityConfiguration } from '@openid-federation/core' + +import { getRequestContext, sendErrorResponse } from '../../shared/router' + +// TODO: It's also possible that the issuer and the verifier can have the same openid-federation endpoint. In that case we need to combine them. + +export function configureFederationEndpoint(router: Router) { + // TODO: this whole result needs to be cached and the ttl should be the expires of this node + + router.get('/.well-known/openid-federation', async (request: OpenId4VcIssuanceRequest, response: Response, next) => { + const { agentContext, issuer } = getRequestContext(request) + + try { + // TODO: Should be only created once per issuer and be used between instances + const federationKey = await agentContext.wallet.createKey({ + keyType: KeyType.Ed25519, + }) + + const now = new Date() + const expires = new Date(now.getTime() + 1000 * 60 * 60 * 24) // 1 day from now + + // TODO: We need to generate a key and always use that for the entity configuration + + const jwk = getJwkFromKey(federationKey) + + const kid = federationKey.fingerprint + const alg = jwk.supportedSignatureAlgorithms[0] + + const issuerDisplay = issuer.display?.[0] + + const accessTokenSigningKey = Key.fromFingerprint(issuer.accessTokenPublicKeyFingerprint) + + const entityConfiguration = await createEntityConfiguration({ + claims: { + sub: issuer.issuerId, + iss: issuer.issuerId, + iat: now, + exp: expires, + jwks: { + keys: [{ kid, alg, ...jwk.toJson() }], + }, + metadata: { + federation_entity: issuerDisplay + ? { + organization_name: issuerDisplay.name, + logo_uri: issuerDisplay.logo?.uri, + } + : undefined, + openid_provider: { + // TODO: The type isn't correct yet down the line so that needs to be updated before + // credential_issuer: issuerMetadata.issuerUrl, + // token_endpoint: issuerMetadata.tokenEndpoint, + // credential_endpoint: issuerMetadata.credentialEndpoint, + // authorization_server: issuerMetadata.authorizationServer, + // authorization_servers: issuerMetadata.authorizationServer + // ? [issuerMetadata.authorizationServer] + // : undefined, + // credentials_supported: issuerMetadata.credentialsSupported, + // credential_configurations_supported: issuerMetadata.credentialConfigurationsSupported, + // display: issuerMetadata.issuerDisplay, + // dpop_signing_alg_values_supported: issuerMetadata.dpopSigningAlgValuesSupported, + + client_registration_types_supported: ['automatic'], + jwks: { + keys: [ + { + // TODO: Not 100% sure if this is the right key that we want to expose here or a different one + kid: accessTokenSigningKey.fingerprint, + ...getJwkFromKey(accessTokenSigningKey).toJson(), + }, + ], + }, + }, + }, + }, + header: { + kid, + alg, + typ: 'entity-statement+jwt', + }, + signJwtCallback: ({ toBeSigned }) => + agentContext.wallet.sign({ + data: toBeSigned as Buffer, + key: federationKey, + }), + }) + + response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityConfiguration) + } catch (error) { + agentContext.config.logger.error('Failed to create entity configuration', { + error, + }) + sendErrorResponse(response, next, agentContext.config.logger, 500, 'invalid_request', error) + return + } + + // NOTE: if we don't call next, the agentContext session handler will NOT be called + next() + }) +} diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts index 2f520a1919..6e35e6f3f2 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts @@ -12,7 +12,7 @@ import { OpenId4VcVerifierApi } from './OpenId4VcVerifierApi' import { OpenId4VcVerifierModuleConfig } from './OpenId4VcVerifierModuleConfig' import { OpenId4VpVerifierService } from './OpenId4VpVerifierService' import { OpenId4VcVerifierRepository } from './repository' -import { configureAuthorizationEndpoint } from './router' +import { configureAuthorizationEndpoint, configureFederationEndpoint } from './router' import { configureAuthorizationRequestEndpoint } from './router/authorizationRequestEndpoint' /** @@ -120,6 +120,10 @@ export class OpenId4VcVerifierModule implements Module { configureAuthorizationEndpoint(endpointRouter, this.config) configureAuthorizationRequestEndpoint(endpointRouter, this.config) + // TODO: The keys needs to be passed down to the federation endpoint to be used in the entity configuration for the openid relying party + // TODO: But the keys also needs to be available for the request signing. They also needs to get saved because it needs to survive a restart of the agent. + configureFederationEndpoint(endpointRouter, this.config.federation) + // First one will be called for all requests (when next is called) contextRouter.use(async (req: OpenId4VcVerificationRequest, _res: unknown, next) => { const { agentContext } = getRequestContext(req) diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts index 8ee6b33437..46311aed58 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts @@ -1,5 +1,6 @@ import type { Router } from 'express' +import { AgentContext } from '@credo-ts/core' import { importExpress } from '../shared/router' export interface OpenId4VcVerifierModuleConfigOptions { @@ -38,6 +39,30 @@ export interface OpenId4VcVerifierModuleConfigOptions { */ authorizationRequest?: string } + + /** + * Configuration for the federation endpoint. + */ + federation?: { + // TODO: Make this functions also compatible with the issuer side + isSubordinateEntity?: ( + agentContext: AgentContext, + options: { + verifierId: string + + issuerEntityId: string + subjectEntityId: string + } + ) => Promise + getAuthorityHints?: ( + agentContext: AgentContext, + options: { + verifierId: string + + issuerEntityId: string + } + ) => Promise + } } export class OpenId4VcVerifierModuleConfig { @@ -76,4 +101,8 @@ export class OpenId4VcVerifierModuleConfig { public get authorizationRequestExpiresInSeconds() { return this.options.authorizationRequestExpirationInSeconds ?? 300 } + + public get federation() { + return this.options.federation + } } diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierService.ts index 625649d994..acee0d60db 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierService.ts @@ -143,6 +143,7 @@ export class OpenId4VpVerifierService { // We include the `session=` in the url so we can still easily // find the session an encrypted response const authorizationResponseUrl = `${joinUriParts(this.config.baseUrl, [options.verifier.verifierId, this.config.authorizationEndpoint])}?session=${authorizationRequestId}` + const federationEntityId = joinUriParts(this.config.baseUrl, [options.verifier.verifierId]) const jwtIssuer = options.requestSigner.method === 'none' @@ -152,7 +153,12 @@ export class OpenId4VpVerifierService { ...options.requestSigner, issuer: authorizationResponseUrl, }) - : await requestSignerToJwtIssuer(agentContext, options.requestSigner) + : options.requestSigner.method === 'federation' + ? await requestSignerToJwtIssuer(agentContext, { + ...options.requestSigner, + entityId: federationEntityId, + }) + : await requestSignerToJwtIssuer(agentContext, options.requestSigner) let clientIdScheme: ClientIdScheme let clientId: string | undefined @@ -178,9 +184,12 @@ export class OpenId4VpVerifierService { } else if (jwtIssuer?.method === 'did') { clientId = jwtIssuer.didUrl.split('#')[0] clientIdScheme = 'did' + } else if (jwtIssuer.method === 'federation') { + clientId = federationEntityId + clientIdScheme = 'https' } else { throw new CredoError( - `Unsupported jwt issuer method '${options.requestSigner.method}'. Only 'did' and 'x5c' are supported.` + `Unsupported jwt issuer method '${options.requestSigner.method}'. Only 'did', 'x5c' and 'federation' are supported.` ) } @@ -196,20 +205,27 @@ export class OpenId4VpVerifierService { const client_id = // For did/https and draft 21 the client id has no special prefix - clientIdScheme === 'did' || (clientIdScheme as string) === 'https' || version === 'v1.draft21' + clientIdScheme === 'did' || clientIdScheme === 'https' || version === 'v1.draft21' ? clientId : `${clientIdScheme}:${clientId}` // for did the client_id is same in draft 21 and 24 so we could support both at the same time const legacyClientIdScheme = - version === 'v1.draft21' && clientIdScheme !== 'web-origin' ? clientIdScheme : undefined + version === 'v1.draft21' && clientIdScheme !== 'web-origin' + ? clientIdScheme === 'https' + ? 'entity_id' + : clientIdScheme + : undefined - const client_metadata = await this.getClientMetadata(agentContext, { - responseMode, - verifier: options.verifier, - authorizationResponseUrl, - version, - }) + // Do not add client metadata if OpenID Federation is used. + const client_metadata = + clientIdScheme !== 'https' + ? await this.getClientMetadata(agentContext, { + responseMode, + verifier: options.verifier, + version, + }) + : undefined const requestParamsBase = { nonce, @@ -730,12 +746,11 @@ export class OpenId4VpVerifierService { return this.openId4VcVerificationSessionRepository.getById(agentContext, verificationSessionId) } - private async getClientMetadata( + public async getClientMetadata( agentContext: AgentContext, options: { responseMode: ResponseMode verifier: OpenId4VcVerifierRecord - authorizationResponseUrl: string version: NonNullable } ) { diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierServiceOptions.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierServiceOptions.ts index eeeb53a386..a2c7ae33ff 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VpVerifierServiceOptions.ts @@ -10,7 +10,7 @@ import type { VerifiablePresentation, } from '@credo-ts/core' import type { TransactionDataEntry, createOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' -import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuerDid } from '../shared' +import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuerDid, OpenId4VcJwtIssuerFederation } from '../shared' import type { OpenId4VcVerificationSessionRecord, OpenId4VcVerifierRecordProps } from './repository' export type ResponseMode = 'direct_post' | 'direct_post.jwt' | 'dc_api' | 'dc_api.jwt' @@ -23,6 +23,7 @@ export interface OpenId4VpCreateAuthorizationRequestOptions { requestSigner: | OpenId4VcJwtIssuerDid | Omit + | Omit | { /** * Do not sign the request. Only available for DC API (responseMode is `dc_api` or `dc_api.jwt`) diff --git a/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts new file mode 100644 index 0000000000..bb3ca87e84 --- /dev/null +++ b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts @@ -0,0 +1,229 @@ +import type { Buffer, Key } from '@credo-ts/core' +import type { Response, Router } from 'express' +import type { OpenId4VcVerificationRequest } from './requestContext' + +import { JwsService, KeyType, getJwkFromJson, getJwkFromKey } from '@credo-ts/core' +import { createEntityConfiguration, createEntityStatement, fetchEntityConfiguration } from '@openid-federation/core' + +import { getRequestContext, sendErrorResponse } from '../../shared/router' +import { addSecondsToDate } from '../../shared/utils' +import { OpenId4VcVerifierModuleConfig } from '../OpenId4VcVerifierModuleConfig' +import { OpenId4VpVerifierService } from '../OpenId4VpVerifierService' + +export function configureFederationEndpoint( + router: Router, + federationConfig: OpenId4VcVerifierModuleConfig['federation'] = {} +) { + // TODO: this whole result needs to be cached and the ttl should be the expires of this node + + // TODO: This will not work for multiple instances so we have to save it in the database. + const federationKeyMapping = new Map() + const rpSigningKeyMapping = new Map() + + router.get( + '/.well-known/openid-federation', + async (request: OpenId4VcVerificationRequest, response: Response, next) => { + const { agentContext, verifier } = getRequestContext(request) + const verifierService = agentContext.dependencyManager.resolve(OpenId4VpVerifierService) + const verifierConfig = agentContext.dependencyManager.resolve(OpenId4VcVerifierModuleConfig) + + try { + let federationKey = federationKeyMapping.get(verifier.verifierId) + if (!federationKey) { + federationKey = await agentContext.wallet.createKey({ + keyType: KeyType.Ed25519, + }) + federationKeyMapping.set(verifier.verifierId, federationKey) + } + + let rpSigningKey = rpSigningKeyMapping.get(verifier.verifierId) + if (!rpSigningKey) { + rpSigningKey = await agentContext.wallet.createKey({ + keyType: KeyType.Ed25519, + }) + rpSigningKeyMapping.set(verifier.verifierId, rpSigningKey) + } + + const verifierEntityId = `${verifierConfig.baseUrl}/${verifier.verifierId}` + + const clientMetadata = await verifierService.getClientMetadata(agentContext, { + responseMode: 'direct_post.jwt', + verifier, + version: 'v1.draft24', + }) + + // TODO: We also need to cache the entity configuration until it expires + const now = new Date() + // TODO: We also need to check if the x509 certificate is still valid until this expires + const expires = addSecondsToDate(now, 60 * 60 * 24) // 1 day + + const jwk = getJwkFromKey(federationKey) + const alg = jwk.supportedSignatureAlgorithms[0] + const kid = federationKey.fingerprint + + const authorityHints = await federationConfig.getAuthorityHints?.(agentContext, { + verifierId: verifier.verifierId, + issuerEntityId: verifierEntityId, + }) + + const clientMetadataKeys = clientMetadata.jwks?.keys ?? [] + const entityConfiguration = await createEntityConfiguration({ + header: { + kid, + alg, + typ: 'entity-statement+jwt', + }, + claims: { + sub: verifierEntityId, + iss: verifierEntityId, + iat: now, + exp: expires, + jwks: { + keys: [{ kid, alg, ...jwk.toJson() }], + }, + authority_hints: authorityHints, + metadata: { + federation_entity: { + organization_name: clientMetadata.client_name, + logo_uri: clientMetadata.logo_uri, + federation_fetch_endpoint: `${verifierEntityId}/openid-federation/fetch`, + }, + openid_relying_party: { + ...clientMetadata, + jwks: { + keys: [ + { + ...getJwkFromKey(rpSigningKey).toJson(), + kid: rpSigningKey.fingerprint, + alg, + use: 'sig', + }, + // @ts-expect-error federation library expects kid to be defined, but this is optional + ...clientMetadataKeys, + ], + }, + client_registration_types: ['automatic'], // TODO: Not really sure why we need to provide this manually + }, + }, + }, + signJwtCallback: ({ toBeSigned }) => + agentContext.wallet.sign({ + data: toBeSigned as Buffer, + key: federationKey, + }), + }) + + response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityConfiguration) + } catch (error) { + agentContext.config.logger.error('Failed to create entity configuration', { + error, + }) + sendErrorResponse(response, next, agentContext.config.logger, 500, 'invalid_request', error) + return + } + + // NOTE: if we don't call next, the agentContext session handler will NOT be called + next() + } + ) + + // TODO: Currently it will fetch everything in realtime and creates a entity statement without even checking if it is allowed. + router.get('/openid-federation/fetch', async (request: OpenId4VcVerificationRequest, response: Response, next) => { + const { agentContext, verifier } = getRequestContext(request) + + const { sub } = request.query + if (!sub || typeof sub !== 'string') { + sendErrorResponse(response, next, agentContext.config.logger, 400, 'invalid_request', 'sub is required') + return + } + + const verifierConfig = agentContext.dependencyManager.resolve(OpenId4VcVerifierModuleConfig) + + const entityId = `${verifierConfig.baseUrl}/${verifier.verifierId}` + + const isSubordinateEntity = await federationConfig.isSubordinateEntity?.(agentContext, { + verifierId: verifier.verifierId, + issuerEntityId: entityId, + subjectEntityId: sub, + }) + if (!isSubordinateEntity) { + if (!federationConfig.isSubordinateEntity) { + agentContext.config.logger.warn( + 'isSubordinateEntity hook is not provided for the federation so we cannot check if this entity is a subordinate entity of the issuer', + { + verifierId: verifier.verifierId, + issuerEntityId: entityId, + subjectEntityId: sub, + } + ) + } + + sendErrorResponse( + response, + next, + agentContext.config.logger, + 403, + 'forbidden', + 'This entity is not a subordinate entity of the issuer' + ) + return + } + + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const subjectEntityConfiguration = await fetchEntityConfiguration({ + entityId: sub, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { + method: 'jwk', + jwk: getJwkFromJson(jwk), + }, + }) + + return res.isValid + }, + }) + + let federationKey = federationKeyMapping.get(verifier.verifierId) + if (!federationKey) { + federationKey = await agentContext.wallet.createKey({ + keyType: KeyType.Ed25519, + }) + federationKeyMapping.set(verifier.verifierId, federationKey) + } + + const jwk = getJwkFromKey(federationKey) + const alg = jwk.supportedSignatureAlgorithms[0] + const kid = federationKey.fingerprint + + const entityStatement = await createEntityStatement({ + header: { + kid, + alg, + typ: 'entity-statement+jwt', + }, + jwk: { + ...jwk.toJson(), + kid, + }, + claims: { + sub: sub, + iss: entityId, + iat: new Date(), + exp: new Date(Date.now() + 1000 * 60 * 60 * 24), // 1 day TODO: Might needs to be a bit lower because a day is quite long for trust + jwks: { + keys: subjectEntityConfiguration.jwks.keys, + }, + }, + signJwtCallback: ({ toBeSigned }) => + agentContext.wallet.sign({ + data: toBeSigned as Buffer, + key: federationKey, + }), + }) + + response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityStatement) + }) +} diff --git a/packages/openid4vc/src/openid4vc-verifier/router/index.ts b/packages/openid4vc/src/openid4vc-verifier/router/index.ts index 8242556be4..cfe20f0af2 100644 --- a/packages/openid4vc/src/openid4vc-verifier/router/index.ts +++ b/packages/openid4vc/src/openid4vc-verifier/router/index.ts @@ -1,2 +1,3 @@ export { configureAuthorizationEndpoint } from './authorizationEndpoint' export { OpenId4VcVerificationRequest } from './requestContext' +export { configureFederationEndpoint } from './federationEndpoint' diff --git a/packages/openid4vc/src/shared/callbacks.ts b/packages/openid4vc/src/shared/callbacks.ts index 6ff62e5157..8351a1fd53 100644 --- a/packages/openid4vc/src/shared/callbacks.ts +++ b/packages/openid4vc/src/shared/callbacks.ts @@ -27,12 +27,14 @@ import { } from '@credo-ts/core' import { clientAuthenticationDynamic, decodeJwtHeader } from '@openid4vc/oauth2' +import { resolveTrustChains } from '@openid-federation/core' import { getKeyFromDid } from './utils' export function getOid4vcJwtVerifyCallback( agentContext: AgentContext, options?: { trustedCertificates?: string[] + trustedFederationEntityIds?: string[] issuanceSessionId?: string @@ -113,6 +115,97 @@ export function getOid4vcJwtVerifyCallback( }) } + // FIXME: extend signer to include entityId (`iss` field or `client_id`) + if (signer.method === 'federation') { + // We use the `client_id` + if (!options?.isAuthorizationRequestJwt) { + agentContext.config.logger.error( + 'Verifying JWTs signed as a federation entity is only allow for signed authorization requests' + ) + return { verified: false } + } + + // I think this check is already in oid4vp lib + if ( + !payload.client_id || + typeof payload.client_id !== 'string' || + !( + payload.client_id.startsWith('https:') || + (payload.client_id.startsWith('http:') && agentContext.config.allowInsecureHttpUrls) + ) + ) { + agentContext.config.logger.error("Expected 'client_id' to be a valid OpenID Federation entity id.") + return { verified: false } + } + + const trustedEntityIds = options?.trustedFederationEntityIds + if (!trustedEntityIds) { + agentContext.config.logger.error( + 'No trusted entity ids provided but is required for verification of JWTs signed by a federation entity.' + ) + return { verified: false } + } + + const entityId = payload.client_id + const validTrustChains = await resolveTrustChains({ + entityId, + // FIXME: need option to pass a trust chain to the library + // trustChain: payload.trust_chain, + trustAnchorEntityIds: trustedEntityIds, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { + method: 'jwk', + jwk: getJwkFromJson(jwk), + }, + }) + + return res.isValid + }, + }) + // When the chain is already invalid we can return false immediately + if (validTrustChains.length === 0) { + agentContext.config.logger.error(`${entityId} is not part of a trusted federation.`) + return { verified: false } + } + + // Pick the first valid trust chain for validation of the leaf entity jwks + const { leafEntityConfiguration } = validTrustChains[0] + + // TODO: No support yet for signed jwks and external jwks + const rpSigningKeys = leafEntityConfiguration?.metadata?.openid_relying_party?.jwks?.keys + const rpSignerKeyJwkJson = rpSigningKeys?.find((key) => key.kid === signer.kid) + if (!rpSignerKeyJwkJson) { + agentContext.config.logger.error( + `Key with kid '${signer.kid}' not found in jwks of openid_relying_party configuration for entity ${entityId}.` + ) + return { + verified: false, + } + } + + const rpSignerJwk = getJwkFromJson(rpSignerKeyJwkJson) + + const res = await jwsService.verifyJws(agentContext, { + jws: compact, + jwsSigner: { + method: 'jwk', + jwk: rpSignerJwk, + }, + }) + if (!res.isValid) { + agentContext.config.logger.error(`${entityId} does not match the expected signing key.`) + } + + if (!res.isValid) { + return { verified: false } + } + + // TODO: There is no check yet for the policies + return { verified: true, signerJwk: rpSignerJwk.toJson() } + } + const alg = signer.alg as JwaSignatureAlgorithm if (!Object.values(JwaSignatureAlgorithm).includes(alg)) { throw new CredoError(`Unsupported jwa signatre algorithm '${alg}'`) @@ -246,8 +339,28 @@ export function getOid4vcJwtSignCallback(agentContext: AgentContext): SignJwtCal const jwsService = agentContext.dependencyManager.resolve(JwsService) return async (signer, { payload, header }) => { - if (signer.method === 'custom' || signer.method === 'federation') { - throw new CredoError(`Jwt signer method 'custom' and 'federation' are not supported for jwt signer.`) + if (signer.method === 'custom') { + throw new CredoError(`Jwt signer method 'custom' is not supported for jwt signer.`) + } + + if (signer.method === 'federation') { + // We use the fingerprint as the kid. This will need to be updated in the future + const key = Key.fromFingerprint(signer.kid) + const jwk = getJwkFromKey(key) + + const jws = await jwsService.createJwsCompact(agentContext, { + protectedHeaderOptions: { + ...header, + alg: signer.alg, + kid: signer.kid, + trust_chain: signer.trustChain, + jwk: undefined, + }, + payload: JwtPayload.fromJson(payload), + key: jwk.key, + }) + + return { jwt: jws, signerJwk: jwk.toJson() } } if (signer.method === 'x5c') { @@ -287,6 +400,7 @@ export function getOid4vcCallbacks( agentContext: AgentContext, options?: { trustedCertificates?: string[] + trustedFederationEntityIds?: string[] isVerifyOpenId4VpAuthorizationRequest?: boolean issuanceSessionId?: string } @@ -300,6 +414,7 @@ export function getOid4vcCallbacks( }, verifyJwt: getOid4vcJwtVerifyCallback(agentContext, { trustedCertificates: options?.trustedCertificates, + trustedFederationEntityIds: options?.trustedFederationEntityIds, isAuthorizationRequestJwt: options?.isVerifyOpenId4VpAuthorizationRequest, issuanceSessionId: options?.issuanceSessionId, }), diff --git a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts index 41acec5508..6b457839ce 100644 --- a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts +++ b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts @@ -30,4 +30,13 @@ export interface OpenId4VcJwtIssuerJwk { jwk: Jwk } -export type OpenId4VcJwtIssuer = OpenId4VcJwtIssuerDid | OpenId4VcIssuerX5c | OpenId4VcJwtIssuerJwk +export interface OpenId4VcJwtIssuerFederation { + method: 'federation' + entityId: string +} + +export type OpenId4VcJwtIssuer = + | OpenId4VcJwtIssuerDid + | OpenId4VcIssuerX5c + | OpenId4VcJwtIssuerJwk + | OpenId4VcJwtIssuerFederation diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index fc6fd431be..a8f977af57 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -5,13 +5,16 @@ import type { OpenId4VcJwtIssuer } from './models' import { CredoError, DidsApi, + JwsService, SignatureSuiteRegistry, X509Service, getDomainFromUrl, getJwkClassFromKeyType, + getJwkFromJson, getJwkFromKey, getKeyFromVerificationMethod, } from '@credo-ts/core' +import { fetchEntityConfiguration } from '@openid-federation/core' /** * Returns the JWA Signature Algorithms that are supported by the wallet. @@ -51,7 +54,15 @@ export async function getKeyFromDid( export async function requestSignerToJwtIssuer( agentContext: AgentContext, requestSigner: OpenId4VcJwtIssuer -): Promise | (JwtSignerX5c & { issuer: string })> { +): Promise< + | Exclude + | (JwtSignerX5c & { issuer: string }) + | (JwtSigner & { + // FIXME: export JwtSignerTrustChain + method: 'trustChain' + entityId: string + }) +> { if (requestSigner.method === 'did') { const key = await getKeyFromDid(agentContext, requestSigner.didUrl) const alg = getJwkClassFromKeyType(key.keyType)?.supportedSignatureAlgorithms[0] @@ -115,6 +126,50 @@ export async function requestSignerToJwtIssuer( } } + if (requestSigner.method === 'federation') { + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + // TODO: we need to retrieve the openid federation record / some persistent state + // that contains the key to be used for verification. It does not make sense to fetch + // and verify our own metadata. + + const entityConfiguration = await fetchEntityConfiguration({ + entityId: requestSigner.entityId, + // Why do we need to fetch/verify our own entity configuration? + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwsSigner: { method: 'jwk', jwk: getJwkFromJson(jwk) }, + }) + return res.isValid + }, + }) + + // TODO: Not really sure if this is also used for the issuer so if so we need to change this logic. + // But currently it's not possible to specify a issuer method with issuance so I think it's fine. + const openIdRelyingParty = entityConfiguration.metadata?.openid_relying_party + if (!openIdRelyingParty) throw new CredoError('No openid_relying_party metadata found in the entity configuration.') + + // NOTE: No support for signed jwks and external jwks + const jwks = openIdRelyingParty.jwks + if (!jwks) throw new CredoError('No jwks found in the openid-relying-party.') + + // TODO: we should specifically store which keys to use for signing + const jwkJson = jwks.keys.find((jwk) => jwk.use === 'sig' && jwk.kid !== undefined) + if (!jwkJson) { + throw new CredoError(`Could not find jwk with use 'sig' and kid defined in openid_relying_party metadata jwks.`) + } + const jwk = getJwkFromJson(jwkJson) + const alg = jwkJson.alg ?? jwk.supportedSignatureAlgorithms[0] + + return { + ...requestSigner, + method: 'federation', + kid: jwkJson.kid, + alg, + } + } + throw new CredoError(`Unsupported jwt issuer method '${(requestSigner as OpenId4VcJwtIssuer).method}'`) } diff --git a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts new file mode 100644 index 0000000000..6fd9f4051f --- /dev/null +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -0,0 +1,758 @@ +import type { Server } from 'http' +import type { OpenId4VcVerifierModuleConfig } from '../src' +import type { AgentType, TenantType } from './utils' + +import { + ClaimFormat, + DidsApi, + DifPresentationExchangeService, + JwaSignatureAlgorithm, + W3cCredential, + W3cCredentialSubject, + W3cIssuer, + w3cDate, +} from '@credo-ts/core' +import express, { type Express } from 'express' + +import { AskarModule } from '../../askar/src' +import { askarModuleConfig } from '../../askar/tests/helpers' +import { TenantsModule } from '../../tenants/src' +import { + OpenId4VcHolderModule, + OpenId4VcIssuerModule, + OpenId4VcVerificationSessionState, + OpenId4VcVerifierModule, +} from '../src' + +import { createAgentFromModules, createTenantForAgent, waitForVerificationSessionRecordSubject } from './utils' +import { openBadgePresentationDefinition, universityDegreePresentationDefinition } from './utilsVp' + +const serverPort = 1234 +const baseUrl = `http://localhost:${serverPort}` +const issuanceBaseUrl = `${baseUrl}/oid4vci` +const verificationBaseUrl = `${baseUrl}/oid4vp` + +describe('OpenId4Vc-federation', () => { + let expressApp: Express + let expressServer: Server + + let issuer: AgentType<{ + openId4VcIssuer: OpenId4VcIssuerModule + tenants: TenantsModule<{ openId4VcIssuer: OpenId4VcIssuerModule }> + }> + // let issuer1: TenantType + // let issuer2: TenantType + + let holder: AgentType<{ + openId4VcHolder: OpenId4VcHolderModule + tenants: TenantsModule<{ openId4VcHolder: OpenId4VcHolderModule }> + }> + let holder1: TenantType + + let verifier: AgentType<{ + openId4VcVerifier: OpenId4VcVerifierModule + tenants: TenantsModule<{ openId4VcVerifier: OpenId4VcVerifierModule }> + }> + let verifier1: TenantType + let verifier2: TenantType + + let federationConfig: OpenId4VcVerifierModuleConfig['federation'] | undefined + + beforeEach(async () => { + expressApp = express() + + issuer = (await createAgentFromModules( + 'issuer', + { + openId4VcIssuer: new OpenId4VcIssuerModule({ + baseUrl: issuanceBaseUrl, + credentialRequestToCredentialMapper: async ({ + agentContext, + credentialRequest, + holderBinding, + credentialConfigurationId, + }) => { + // We sign the request with the first did:key did we have + const didsApi = agentContext.dependencyManager.resolve(DidsApi) + const [firstDidKeyDid] = await didsApi.getCreatedDids({ method: 'key' }) + const didDocument = await didsApi.resolveDidDocument(firstDidKeyDid.did) + const verificationMethod = didDocument.verificationMethod?.[0] + if (!verificationMethod) { + throw new Error('No verification method found') + } + + if (credentialRequest.format === 'vc+sd-jwt') { + return { + credentialConfigurationId, + format: credentialRequest.format, + credentials: holderBinding.keys.map((holderBinding) => ({ + payload: { vct: credentialRequest.vct, university: 'innsbruck', degree: 'bachelor' }, + holder: holderBinding, + issuer: { + method: 'did', + didUrl: verificationMethod.id, + }, + disclosureFrame: { _sd: ['university', 'degree'] }, + })), + } + } + throw new Error('Invalid request') + }, + }), + askar: new AskarModule(askarModuleConfig), + tenants: new TenantsModule(), + }, + '96213c3d7fc8d4d6754c7a0fd969598g' + )) as unknown as typeof issuer + // issuer1 = await createTenantForAgent(issuer.agent, 'iTenant1') + // issuer2 = await createTenantForAgent(issuer.agent, 'iTenant2') + + holder = (await createAgentFromModules( + 'holder', + { + openId4VcHolder: new OpenId4VcHolderModule(), + askar: new AskarModule(askarModuleConfig), + tenants: new TenantsModule(), + }, + '96213c3d7fc8d4d6754c7a0fd969598e' + )) as unknown as typeof holder + holder1 = await createTenantForAgent(holder.agent, 'hTenant1') + + verifier = (await createAgentFromModules( + 'verifier', + { + openId4VcVerifier: new OpenId4VcVerifierModule({ + baseUrl: verificationBaseUrl, + federation: { + async isSubordinateEntity(agentContext, options) { + if (federationConfig?.isSubordinateEntity) { + return federationConfig.isSubordinateEntity(agentContext, options) + } + return false + }, + async getAuthorityHints(agentContext, options) { + if (federationConfig?.getAuthorityHints) { + return federationConfig.getAuthorityHints(agentContext, options) + } + return undefined + }, + }, + }), + askar: new AskarModule(askarModuleConfig), + tenants: new TenantsModule(), + }, + '96213c3d7fc8d4d6754c7a0fd969598f' + )) as unknown as typeof verifier + verifier1 = await createTenantForAgent(verifier.agent, 'vTenant1') + verifier2 = await createTenantForAgent(verifier.agent, 'vTenant2') + + // We let AFJ create the router, so we have a fresh one each time + expressApp.use('/oid4vci', issuer.agent.modules.openId4VcIssuer.config.router) + expressApp.use('/oid4vp', verifier.agent.modules.openId4VcVerifier.config.router) + + expressServer = expressApp.listen(serverPort) + }) + + afterEach(async () => { + expressServer?.close() + + await issuer.agent.shutdown() + await issuer.agent.wallet.delete() + + await holder.agent.shutdown() + await holder.agent.wallet.delete() + + await verifier.agent.shutdown() + await verifier.agent.wallet.delete() + + federationConfig = undefined + }) + + it('e2e flow with tenants and federation, verifier endpoints verifying a jwt-vc', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + const verifierTenant2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + + const openIdVerifierTenant1 = await verifierTenant1.modules.openId4VcVerifier.createVerifier() + const openIdVerifierTenant2 = await verifierTenant2.modules.openId4VcVerifier.createVerifier() + + const signedCredential1 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + const signedCredential2 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 }) + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 }) + + const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } = + await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifierTenant1.verifierId, + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri as string)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: universityDegreePresentationDefinition, + }, + verifierId: openIdVerifierTenant2.verifierId, + }) + + expect(authorizationRequestUri2).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession2.authorizationRequestUri as string)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + const resolvedProofRequest1 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri1, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}`], + } + ) + + expect(resolvedProofRequest1.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + claimFormat: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + const resolvedProofRequest2 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri2, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + } + ) + + expect(resolvedProofRequest2.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + claimFormat: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + if (!resolvedProofRequest1.presentationExchange || !resolvedProofRequest2.presentationExchange) { + throw new Error('Presentation exchange not defined') + } + + const presentationExchangeService = holderTenant.dependencyManager.resolve(DifPresentationExchangeService) + const selectedCredentials = presentationExchangeService.selectCredentialsForRequest( + resolvedProofRequest1.presentationExchange.credentialsForRequest + ) + + const { authorizationResponsePayload: submittedResponse1, serverResponse: serverResponse1 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest1.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials, + }, + }) + + expect(submittedResponse1).toEqual({ + presentation_submission: { + definition_id: 'OpenBadgeCredential', + descriptor_map: [ + { + format: 'jwt_vp', + id: 'OpenBadgeCredentialDescriptor', + path: '$', + path_nested: { + format: 'jwt_vc', + id: 'OpenBadgeCredentialDescriptor', + path: '$.vp.verifiableCredential[0]', + }, + }, + ], + id: expect.any(String), + }, + state: expect.any(String), + vp_token: expect.any(String), + }) + expect(serverResponse1).toMatchObject({ + status: 200, + }) + + // The RP MUST validate that the aud (audience) Claim contains the value of the client_id + // that the RP sent in the Authorization Request as an audience. + // When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier. + const verifierTenant1_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant1_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession1.id, + }) + + const { presentationExchange: presentationExchange1 } = + await verifierTenant1_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession1.id) + + expect(presentationExchange1).toMatchObject({ + definition: openBadgePresentationDefinition, + submission: { + definition_id: 'OpenBadgeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + ], + }, + ], + }) + + const selectedCredentials2 = presentationExchangeService.selectCredentialsForRequest( + resolvedProofRequest2.presentationExchange.credentialsForRequest + ) + + const { serverResponse: serverResponse2 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest2.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials2, + }, + }) + expect(serverResponse2).toMatchObject({ + status: 200, + }) + + // The RP MUST validate that the aud (audience) Claim contains the value of the client_id + // that the RP sent in the Authorization Request as an audience. + // When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier. + const verifierTenant2_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant2_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession2.id, + }) + const { presentationExchange: presentationExchange2 } = + await verifierTenant2_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession2.id) + + expect(presentationExchange2).toMatchObject({ + definition: universityDegreePresentationDefinition, + submission: { + definition_id: 'UniversityDegreeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + ], + }, + ], + }) + }) + + it('e2e flow with tenants and federation with multiple layers, verifier endpoints verifying a jwt-vc', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + const verifierTenant2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + + const openIdVerifierTenant1 = await verifierTenant1.modules.openId4VcVerifier.createVerifier() + const openIdVerifierTenant2 = await verifierTenant2.modules.openId4VcVerifier.createVerifier() + + const signedCredential1 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + const signedCredential2 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 }) + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 }) + + const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } = + await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifierTenant1.verifierId, + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri as string)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: universityDegreePresentationDefinition, + }, + verifierId: openIdVerifierTenant2.verifierId, + }) + + expect(authorizationRequestUri2).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession2.authorizationRequestUri as string)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + federationConfig = { + isSubordinateEntity: async (_agentContext, options) => { + // When the verifier 2 gets asked if verifier 1 is a subordinate entity, it should return true + return options.verifierId === openIdVerifierTenant2.verifierId + }, + getAuthorityHints: async (_agentContext, options) => { + // The verifier 1 says that the verifier 2 is above him + return options.verifierId === openIdVerifierTenant1.verifierId + ? [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`] + : undefined + }, + } + + // Gets a request from verifier 1 but we trust verifier 2 so if the verifier 1 is in the subordinate entity list of verifier 2 it should succeed + const resolvedProofRequest1 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri1, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + } + ) + + expect(resolvedProofRequest1.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + claimFormat: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + const resolvedProofRequest2 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri2, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + } + ) + + expect(resolvedProofRequest2.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + claimFormat: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + if (!resolvedProofRequest1.presentationExchange || !resolvedProofRequest2.presentationExchange) { + throw new Error('Presentation exchange not defined') + } + + const presentationExchangeService = holderTenant.dependencyManager.resolve(DifPresentationExchangeService) + const selectedCredentials = presentationExchangeService.selectCredentialsForRequest( + resolvedProofRequest1.presentationExchange.credentialsForRequest + ) + + const { authorizationResponsePayload: submittedResponse1, serverResponse: serverResponse1 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest1.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials, + }, + }) + + expect(submittedResponse1).toEqual({ + presentation_submission: { + definition_id: 'OpenBadgeCredential', + descriptor_map: [ + { + format: 'jwt_vp', + id: 'OpenBadgeCredentialDescriptor', + path: '$', + path_nested: { + format: 'jwt_vc', + id: 'OpenBadgeCredentialDescriptor', + path: '$.vp.verifiableCredential[0]', + }, + }, + ], + id: expect.any(String), + }, + state: expect.any(String), + vp_token: expect.any(String), + }) + expect(serverResponse1).toMatchObject({ + status: 200, + }) + + // The RP MUST validate that the aud (audience) Claim contains the value of the client_id + // that the RP sent in the Authorization Request as an audience. + // When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier. + const verifierTenant1_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant1_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession1.id, + }) + + const { presentationExchange: presentationExchange1 } = + await verifierTenant1_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession1.id) + + expect(presentationExchange1).toMatchObject({ + definition: openBadgePresentationDefinition, + submission: { + definition_id: 'OpenBadgeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + ], + }, + ], + }) + + const selectedCredentials2 = presentationExchangeService.selectCredentialsForRequest( + resolvedProofRequest2.presentationExchange.credentialsForRequest + ) + + const { serverResponse: serverResponse2 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest2.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials2, + }, + }) + expect(serverResponse2).toMatchObject({ + status: 200, + }) + + // The RP MUST validate that the aud (audience) Claim contains the value of the client_id + // that the RP sent in the Authorization Request as an audience. + // When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier. + const verifierTenant2_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant2_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession2.id, + }) + const { presentationExchange: presentationExchange2 } = + await verifierTenant2_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession2.id) + + expect(presentationExchange2).toMatchObject({ + definition: universityDegreePresentationDefinition, + submission: { + definition_id: 'UniversityDegreeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + ], + }, + ], + }) + }) + + it('e2e flow with tenants and federation, unhappy flow', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + const verifierTenant2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + + const openIdVerifierTenant1 = await verifierTenant1.modules.openId4VcVerifier.createVerifier() + const openIdVerifierTenant2 = await verifierTenant2.modules.openId4VcVerifier.createVerifier() + + const signedCredential1 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + const signedCredential2 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 }) + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 }) + + const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } = + await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifierTenant1.verifierId, + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri as string)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: universityDegreePresentationDefinition, + }, + verifierId: openIdVerifierTenant2.verifierId, + }) + + expect(authorizationRequestUri2).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession2.authorizationRequestUri as string)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + const resolvedProofRequestWithFederationPromise = + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri1, { + // This will look for a whole different trusted entity + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + }) + + // TODO: Look into this error see if we can make it more specific + await expect(resolvedProofRequestWithFederationPromise).rejects.toThrow('Error during verification of jwt.') + + const resolvedProofRequestWithoutFederationPromise = + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri2) + await expect(resolvedProofRequestWithoutFederationPromise).rejects.toThrow('Error during verification of jwt.') + }) +}) diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 1265f6da20..ccf38b81f8 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -184,6 +184,7 @@ describe('OpenId4Vc', () => { { openId4VcVerifier: new OpenId4VcVerifierModule({ baseUrl: verificationBaseUrl, + federation: {}, }), askar: new AskarModule(askarModuleConfig), tenants: new TenantsModule(), @@ -2783,6 +2784,246 @@ describe('OpenId4Vc', () => { }) }) + it('e2e flow with tenants and federation, verifier endpoints verifying a jwt-vc', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + const verifierTenant2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + + const openIdVerifierTenant1 = await verifierTenant1.modules.openId4VcVerifier.createVerifier() + const openIdVerifierTenant2 = await verifierTenant2.modules.openId4VcVerifier.createVerifier() + + const signedCredential1 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + const signedCredential2 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: JwaSignatureAlgorithm.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 }) + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 }) + + const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } = + await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifierTenant1.verifierId, + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri as string)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'federation', + }, + presentationExchange: { + definition: universityDegreePresentationDefinition, + }, + verifierId: openIdVerifierTenant2.verifierId, + }) + + expect(authorizationRequestUri2).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession2.authorizationRequestUri as string)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + const resolvedProofRequest1 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri1, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}`], + } + ) + + expect(resolvedProofRequest1.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + type: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + const resolvedProofRequest2 = await holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + authorizationRequestUri2, + { + trustedFederationEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + } + ) + + expect(resolvedProofRequest2.presentationExchange?.credentialsForRequest).toMatchObject({ + areRequirementsSatisfied: true, + requirements: [ + { + submissionEntry: [ + { + verifiableCredentials: [ + { + type: ClaimFormat.JwtVc, + credentialRecord: { + credential: { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + }, + }, + ], + }, + ], + }, + ], + }) + + if (!resolvedProofRequest1.presentationExchange || !resolvedProofRequest2.presentationExchange) { + throw new Error('Presentation exchange not defined') + } + + const selectedCredentials = holderTenant.modules.openId4VcHolder.selectCredentialsForPresentationExchangeRequest( + resolvedProofRequest1.presentationExchange.credentialsForRequest + ) + + const { authorizationResponsePayload, serverResponse: serverResponse1 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest1.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials, + }, + }) + + expect(authorizationResponsePayload).toEqual({ + presentation_submission: { + definition_id: 'OpenBadgeCredential', + descriptor_map: [ + { + format: 'jwt_vp', + id: 'OpenBadgeCredentialDescriptor', + path: '$', + path_nested: { + format: 'jwt_vc', + id: 'OpenBadgeCredentialDescriptor', + path: '$.vp.verifiableCredential[0]', + }, + }, + ], + id: expect.any(String), + }, + state: expect.any(String), + vp_token: expect.any(String), + }) + expect(serverResponse1).toMatchObject({ + status: 200, + }) + + // The RP MUST validate that the aud (audience) Claim contains the value of the client_id + // that the RP sent in the Authorization Request as an audience. + // When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier. + const verifierTenant1_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant1_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession1.id, + }) + + const { presentationExchange: presentationExchange1 } = + await verifierTenant1_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession1.id) + + expect(presentationExchange1).toMatchObject({ + definition: openBadgePresentationDefinition, + submission: { + definition_id: 'OpenBadgeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'OpenBadgeCredential'], + }, + ], + }, + ], + }) + + const selectedCredentials2 = holderTenant.modules.openId4VcHolder.selectCredentialsForPresentationExchangeRequest( + resolvedProofRequest2.presentationExchange.credentialsForRequest + ) + + const { serverResponse: serverResponse2 } = + await holderTenant.modules.openId4VcHolder.acceptOpenId4VpAuthorizationRequest({ + authorizationRequestPayload: resolvedProofRequest2.authorizationRequestPayload, + presentationExchange: { + credentials: selectedCredentials2, + }, + }) + expect(serverResponse2).toMatchObject({ + status: 200, + }) + + const verifierTenant2_2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + await waitForVerificationSessionRecordSubject(verifier.replaySubject, { + contextCorrelationId: verifierTenant2_2.context.contextCorrelationId, + state: OpenId4VcVerificationSessionState.ResponseVerified, + verificationSessionId: verificationSession2.id, + }) + const { presentationExchange: presentationExchange2 } = + await verifierTenant2_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession2.id) + + expect(presentationExchange2).toMatchObject({ + definition: universityDegreePresentationDefinition, + submission: { + definition_id: 'UniversityDegreeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + ], + }, + ], + }) + }) + it('e2e flow with verifier endpoints verifying a mdoc and sd-jwt (jarm) (dcql) (transaction data)', async () => { const openIdVerifier = await verifier.agent.modules.openId4VcVerifier.createVerifier() diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 836df0fa16..73c9105488 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -727,18 +727,21 @@ importers: '@credo-ts/core': specifier: workspace:* version: link:../core + '@openid-federation/core': + specifier: 0.1.1-alpha.17 + version: 0.1.1-alpha.17 '@openid4vc/oauth2': - specifier: 0.3.0-alpha-20250330133535 - version: 0.3.0-alpha-20250330133535 + specifier: 0.3.0-alpha-20250404080256 + version: 0.3.0-alpha-20250404080256 '@openid4vc/openid4vci': - specifier: 0.3.0-alpha-20250330133535 - version: 0.3.0-alpha-20250330133535 + specifier: 0.3.0-alpha-20250404080256 + version: 0.3.0-alpha-20250404080256 '@openid4vc/openid4vp': - specifier: 0.3.0-alpha-20250330133535 - version: 0.3.0-alpha-20250330133535 + specifier: 0.3.0-alpha-20250404080256 + version: 0.3.0-alpha-20250404080256 '@openid4vc/utils': - specifier: 0.3.0-alpha-20250330133535 - version: 0.3.0-alpha-20250330133535 + specifier: 0.3.0-alpha-20250404080256 + version: 0.3.0-alpha-20250404080256 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -2487,17 +2490,20 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@openid4vc/oauth2@0.3.0-alpha-20250330133535': - resolution: {integrity: sha512-A5UgxQDJobddp0utxQqALG4dyzrQHo8DCjaHuKtrnoAcZWJFXmYFBvKbiKoiHjSLuTcME5mDC/+m46hchGiIQA==} + '@openid-federation/core@0.1.1-alpha.17': + resolution: {integrity: sha512-Bn5JaQzQnrQ2koPisiITHN69eOEewvs26EPyD4tp5XoR6Kdh9sy2f3w4hYV4MSaDwRorpRF7ttN6PGK1piMTng==} - '@openid4vc/openid4vci@0.3.0-alpha-20250330133535': - resolution: {integrity: sha512-QNgpoPOQ/Viyq24xFbSlFccayChP6kcthjiyEuK7TPA5cU693BVgjGfyyzStgWf0MoL3aoJ47OQwYels7+MBvA==} + '@openid4vc/oauth2@0.3.0-alpha-20250404080256': + resolution: {integrity: sha512-fAtMwk4dgXiLc1UnvLZMt6dN+hW9uOxLnS8tIJHXLQfdu4zemOIxv2/ivfieI6vgsBUqfUSsv2aawIiO2I4dnQ==} - '@openid4vc/openid4vp@0.3.0-alpha-20250330133535': - resolution: {integrity: sha512-yJ/8ZnSFC3GSokGTafXYIsdCM/h38PhHWaGan7T6TuJaeDrZW8l44HV2Cbod82drgyQ3IL10bpaX1bm+K3lQyw==} + '@openid4vc/openid4vci@0.3.0-alpha-20250404080256': + resolution: {integrity: sha512-DhCR7opM0iXy9DiJ0QcXbbV3B2vlFdguofqMPQWq7Rt8RWHsAbG6yVoFKJ3ty+pAM+N2AQx5xKWeMoZPgmw7dw==} - '@openid4vc/utils@0.3.0-alpha-20250330133535': - resolution: {integrity: sha512-yx/dar8DqqXhhJ2oyKTFPHG0vj73kgQdBLH5oR6IrAZ5b8MSSaRSOjnYKWAvEDtS7ZLBIEbYnC+OF34fcIOW+g==} + '@openid4vc/openid4vp@0.3.0-alpha-20250404080256': + resolution: {integrity: sha512-cWijp9t/KZ6Q2sz9/KJXriO7AqRFTjbK59MtSbL82Mt3Nn20p6CdTbrhjger1RI1mgndwKTH3yVATdsdJC9APw==} + + '@openid4vc/utils@0.3.0-alpha-20250404080256': + resolution: {integrity: sha512-qYz/J6oEaH7UYXOFVo+65ZcbNGF7PN05BfFfEDOZ1f3KCB+ENA5Iy3dAoRBGGBGu3zMZ6J4/sNgE9N+Ng8CjUw==} '@openwallet-foundation/askar-nodejs@0.3.1': resolution: {integrity: sha512-m3L8KEPC+qgA3MAFssMtjSqJiAQtrawZEWPmW6eiB7OPjZvkKjodMhx/cuUV5YTl4eQlSix2EY4vXMzk4vt+cQ==} @@ -10097,24 +10103,29 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openid4vc/oauth2@0.3.0-alpha-20250330133535': + '@openid-federation/core@0.1.1-alpha.17': + dependencies: + buffer: 6.0.3 + zod: 3.24.2 + + '@openid4vc/oauth2@0.3.0-alpha-20250404080256': dependencies: - '@openid4vc/utils': 0.3.0-alpha-20250330133535 + '@openid4vc/utils': 0.3.0-alpha-20250404080256 zod: 3.24.2 - '@openid4vc/openid4vci@0.3.0-alpha-20250330133535': + '@openid4vc/openid4vci@0.3.0-alpha-20250404080256': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250330133535 - '@openid4vc/utils': 0.3.0-alpha-20250330133535 + '@openid4vc/oauth2': 0.3.0-alpha-20250404080256 + '@openid4vc/utils': 0.3.0-alpha-20250404080256 zod: 3.24.2 - '@openid4vc/openid4vp@0.3.0-alpha-20250330133535': + '@openid4vc/openid4vp@0.3.0-alpha-20250404080256': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250330133535 - '@openid4vc/utils': 0.3.0-alpha-20250330133535 + '@openid4vc/oauth2': 0.3.0-alpha-20250404080256 + '@openid4vc/utils': 0.3.0-alpha-20250404080256 zod: 3.24.2 - '@openid4vc/utils@0.3.0-alpha-20250330133535': + '@openid4vc/utils@0.3.0-alpha-20250404080256': dependencies: buffer: 6.0.3 zod: 3.24.2