diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index d56d6b0bae..09d3213fa9 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -33,6 +33,7 @@ "@sphereon/oid4vci-common": "0.16.1-fix.173", "@sphereon/oid4vci-issuer": "0.16.1-fix.173", "@sphereon/ssi-types": "0.30.2-next.135", + "@openid-federation/core": "0.1.1-alpha.13", "class-transformer": "^0.5.1", "rxjs": "^7.8.0" }, diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts index 1a9dd4ecd8..4b4db015d2 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts @@ -8,7 +8,11 @@ import type { OpenId4VciSendNotificationOptions, OpenId4VciRequestTokenResponse, } from './OpenId4VciHolderServiceOptions' -import type { OpenId4VcSiopAcceptAuthorizationRequestOptions } from './OpenId4vcSiopHolderServiceOptions' +import type { + OpenId4VcSiopAcceptAuthorizationRequestOptions, + OpenId4VcSiopResolveAuthorizationRequestOptions, + OpenId4VcSiopResolveTrustChainsOptions, +} from './OpenId4vcSiopHolderServiceOptions' import { injectable, AgentContext } from '@credo-ts/core' @@ -40,8 +44,11 @@ export class OpenId4VcHolderApi { * @param requestJwtOrUri JWT or an SIOPv2 request URI * @returns the resolved and verified authentication request. */ - public async resolveSiopAuthorizationRequest(requestJwtOrUri: string) { - return this.openId4VcSiopHolderService.resolveAuthorizationRequest(this.agentContext, requestJwtOrUri) + public async resolveSiopAuthorizationRequest( + requestJwtOrUri: string, + options: OpenId4VcSiopResolveAuthorizationRequestOptions = {} + ) { + return this.openId4VcSiopHolderService.resolveAuthorizationRequest(this.agentContext, requestJwtOrUri, options) } /** @@ -181,4 +188,8 @@ export class OpenId4VcHolderApi { public async sendNotification(options: OpenId4VciSendNotificationOptions) { return this.openId4VciHolderService.sendNotification(options) } + + public async resolveOpenIdFederationChains(options: OpenId4VcSiopResolveTrustChainsOptions) { + return this.openId4VcSiopHolderService.resolveOpenIdFederationChains(this.agentContext, options) + } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts index 09f1629fe0..413824a8a8 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts @@ -1,8 +1,11 @@ import type { OpenId4VcSiopAcceptAuthorizationRequestOptions, + OpenId4VcSiopGetOpenIdProviderOptions, + OpenId4VcSiopResolveAuthorizationRequestOptions, OpenId4VcSiopResolvedAuthorizationRequest, + OpenId4VcSiopResolveTrustChainsOptions, } from './OpenId4vcSiopHolderServiceOptions' -import type { OpenId4VcJwtIssuer } from '../shared' +import type { OpenId4VcJwtIssuer, OpenId4VcJwtIssuerFederation } from '../shared' import type { AgentContext, JwkJson, VerifiablePresentation } from '@credo-ts/core' import type { AuthorizationResponsePayload, @@ -26,7 +29,12 @@ import { injectable, parseDid, MdocDeviceResponse, + JwsService, } from '@credo-ts/core' +import { + resolveTrustChains as federationResolveTrustChains, + fetchEntityConfiguration as federationFetchEntityConfiguration, +} from '@openid-federation/core' import { OP, ResponseIss, ResponseMode, ResponseType, SupportedVersion, VPTokenLocation } from '@sphereon/did-auth-siop' import { getSphereonVerifiablePresentation } from '../shared/transform' @@ -38,9 +46,12 @@ export class OpenId4VcSiopHolderService { public async resolveAuthorizationRequest( agentContext: AgentContext, - requestJwtOrUri: string + requestJwtOrUri: string, + options: OpenId4VcSiopResolveAuthorizationRequestOptions = {} ): Promise { - const openidProvider = await this.getOpenIdProvider(agentContext) + const openidProvider = await this.getOpenIdProvider(agentContext, { + federation: options.federation, + }) // parsing happens automatically in verifyAuthorizationRequest const verifiedAuthorizationRequest = await openidProvider.verifyAuthorizationRequest(requestJwtOrUri) @@ -59,6 +70,34 @@ export class OpenId4VcSiopHolderService { const presentationDefinition = verifiedAuthorizationRequest.presentationDefinitions?.[0]?.definition + if (verifiedAuthorizationRequest.clientIdScheme === 'entity_id') { + const clientId = verifiedAuthorizationRequest.authorizationRequestPayload.client_id + if (!clientId) { + throw new CredoError("Unable to extract 'client_id' from authorization request") + } + + const jwsService = agentContext.dependencyManager.resolve(JwsService) + + const entityConfiguration = await federationFetchEntityConfiguration({ + entityId: clientId, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwkResolver: () => getJwkFromJson(jwk), + }) + + return res.isValid + }, + }) + if (!entityConfiguration) throw new CredoError(`Unable to fetch entity configuration for entityId '${clientId}'`) + + const openidRelyingPartyMetadata = entityConfiguration.metadata?.openid_relying_party + // When the metadata is present in the federation we want to use that instead of what is passed with the request + if (openidRelyingPartyMetadata) { + verifiedAuthorizationRequest.authorizationRequestPayload.client_metadata = openidRelyingPartyMetadata + } + } + return { authorizationRequest: verifiedAuthorizationRequest, @@ -231,7 +270,7 @@ export class OpenId4VcSiopHolderService { } } - private async getOpenIdProvider(agentContext: AgentContext) { + private async getOpenIdProvider(agentContext: AgentContext, options: OpenId4VcSiopGetOpenIdProviderOptions = {}) { const builder = OP.builder() .withExpiresIn(6000) .withIssuer(ResponseIss.SELF_ISSUED_V2) @@ -242,7 +281,11 @@ export class OpenId4VcSiopHolderService { SupportedVersion.SIOPv2_D12_OID4VP_D20, ]) .withCreateJwtCallback(getCreateJwtCallback(agentContext)) - .withVerifyJwtCallback(getVerifyJwtCallback(agentContext)) + .withVerifyJwtCallback( + getVerifyJwtCallback(agentContext, { + federation: options.federation, + }) + ) .withHasher(Hasher.hash) const openidProvider = builder.build() @@ -252,7 +295,7 @@ export class OpenId4VcSiopHolderService { private getOpenIdTokenIssuerFromVerifiablePresentation( verifiablePresentation: VerifiablePresentation - ): OpenId4VcJwtIssuer { + ): Exclude { let openIdTokenIssuer: OpenId4VcJwtIssuer if (verifiablePresentation instanceof W3cJsonLdVerifiablePresentation) { @@ -395,4 +438,26 @@ export class OpenId4VcSiopHolderService { return jwe } + + public async resolveOpenIdFederationChains( + agentContext: AgentContext, + options: OpenId4VcSiopResolveTrustChainsOptions + ) { + 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, + jwkResolver: () => getJwkFromJson(jwk), + }) + + return res.isValid + }, + }) + } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts index c59a9dd53f..559e690210 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts @@ -2,6 +2,7 @@ import type { OpenId4VcJwtIssuer, OpenId4VcSiopVerifiedAuthorizationRequest, OpenId4VcSiopAuthorizationResponsePayload, + OpenId4VcJwtIssuerFederation, } from '../shared' import type { DifPexCredentialsForRequest, @@ -42,12 +43,23 @@ export interface OpenId4VcSiopAcceptAuthorizationRequestOptions { * In case presentation exchange is used, and `openIdTokenIssuer` is not provided, the issuer of the ID Token * will be extracted from the signer of the first verifiable presentation. */ - openIdTokenIssuer?: OpenId4VcJwtIssuer + openIdTokenIssuer?: Exclude /** * The verified authorization request. */ authorizationRequest: OpenId4VcSiopVerifiedAuthorizationRequest + + // TODO: Not sure if this also needs the federation because the validation of the authorization is already done with the ResolveAuthorizationRequest +} + +export interface OpenId4VcSiopResolveAuthorizationRequestOptions { + federation?: { + /** + * The entity IDs of the trusted issuers. + */ + trustedEntityIds?: string[] + } } // FIXME: rethink properties @@ -56,3 +68,17 @@ export interface OpenId4VcSiopAuthorizationResponseSubmission { status: number submittedResponse: OpenId4VcSiopAuthorizationResponsePayload } + +export interface OpenId4VcSiopGetOpenIdProviderOptions { + federation?: { + /** + * The entity IDs of the trusted issuers. + */ + trustedEntityIds?: string[] + } +} + +export interface OpenId4VcSiopResolveTrustChainsOptions { + entityId: string + trustAnchorEntityIds: [string, ...string[]] +} diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts index 44f4f6e84c..083297286b 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts @@ -17,6 +17,7 @@ import { configureCredentialEndpoint, configureIssuerMetadataEndpoint, } from './router' +import { configureFederationEndpoint } from './router/federationEndpoint' /** * @public @@ -119,6 +120,7 @@ export class OpenId4VcIssuerModule implements Module { configureCredentialOfferEndpoint(endpointRouter, this.config.credentialOfferEndpoint) configureAccessTokenEndpoint(endpointRouter, this.config.accessTokenEndpoint) configureCredentialEndpoint(endpointRouter, this.config.credentialEndpoint) + configureFederationEndpoint(endpointRouter) // First one will be called for all requests (when next is called) contextRouter.use(async (req: OpenId4VcIssuanceRequest, _res: unknown, next) => { diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts index 71eaa43c9a..ae8f7418f1 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts @@ -3,6 +3,7 @@ import type { OpenId4VciCredentialEndpointConfig, OpenId4VciCredentialOfferEndpointConfig, } from './router' +import type { OpenId4VcSiopFederationEndpointConfig } from './router/federationEndpoint' import type { Optional } from '@credo-ts/core' import type { Router } from 'express' @@ -35,6 +36,7 @@ export interface OpenId4VcIssuerModuleConfigOptions { OpenId4VciAccessTokenEndpointConfig, 'cNonceExpiresInSeconds' | 'endpointPath' | 'preAuthorizedCodeExpirationInSeconds' | 'tokenExpiresInSeconds' > + federation?: Optional } } 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..788dea94f3 --- /dev/null +++ b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts @@ -0,0 +1,108 @@ +import type { OpenId4VcIssuanceRequest } from './requestContext' +import type { Buffer } from '@credo-ts/core' +import type { Router, Response } from 'express' + +import { Key, getJwkFromKey, KeyType } from '@credo-ts/core' +import { createEntityConfiguration } from '@openid-federation/core' + +import { getRequestContext, sendErrorResponse } from '../../shared/router' +import { OpenId4VcIssuerService } from '../OpenId4VcIssuerService' + +// 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) + const openId4VcIssuerService = agentContext.dependencyManager.resolve(OpenId4VcIssuerService) + + try { + // TODO: Should be only created once per issuer and be used between instances + const federationKey = await agentContext.wallet.createKey({ + keyType: KeyType.Ed25519, + }) + + const issuerMetadata = openId4VcIssuerService.getIssuerMetadata(agentContext, issuer) + + 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 = issuerMetadata.issuerDisplay?.[0] + + const accessTokenSigningKey = Key.fromFingerprint(issuer.accessTokenPublicKeyFingerprint) + + const entityConfiguration = await createEntityConfiguration({ + claims: { + sub: issuerMetadata.issuerUrl, + iss: issuerMetadata.issuerUrl, + iat: now, + exp: expires, + jwks: { + keys: [{ kid, alg, ...jwk.toJson() }], + }, + metadata: { + federation_entity: issuerDisplay + ? { + organization_name: issuerDisplay.name, + logo_uri: issuerDisplay.logo?.url, + } + : 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, agentContext.config.logger, 500, 'invalid_request', error) + } + + // NOTE: if we don't call next, the agentContext session handler will NOT be called + next() + }) +} diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index 7ff3e15a64..50781786e7 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -107,12 +107,19 @@ export class OpenId4VcSiopVerifierService { this.config.authorizationEndpoint.endpointPath, ]) + const federationClientId = joinUriParts(this.config.baseUrl, [options.verifier.verifierId]) + const jwtIssuer = options.requestSigner.method === 'x5c' ? await openIdTokenIssuerToJwtIssuer(agentContext, { ...options.requestSigner, issuer: authorizationResponseUrl, }) + : options.requestSigner.method === 'openid-federation' + ? await openIdTokenIssuerToJwtIssuer(agentContext, { + ...options.requestSigner, + entityId: federationClientId, + }) : await openIdTokenIssuerToJwtIssuer(agentContext, options.requestSigner) let clientIdScheme: ClientIdScheme @@ -142,9 +149,18 @@ export class OpenId4VcSiopVerifierService { } else if (jwtIssuer.method === 'did') { clientId = jwtIssuer.didUrl.split('#')[0] clientIdScheme = 'did' + } else if (jwtIssuer.method === 'custom') { + if (jwtIssuer.options?.method === 'openid-federation') { + clientIdScheme = 'entity_id' + clientId = federationClientId + } else { + throw new CredoError( + `jwtIssuer 'method' 'custom' must have a 'method' property with value 'openid-federation' when using the 'custom' method.` + ) + } } 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 'custom' are supported.` ) } @@ -231,6 +247,8 @@ export class OpenId4VcSiopVerifierService { ) const requestClientId = await authorizationRequest.getMergedProperty('client_id') + // TODO: Is this needed for the verification of the federation? + const requestClientIdScheme = await authorizationRequest.getMergedProperty('client_id_scheme') const requestNonce = await authorizationRequest.getMergedProperty('nonce') const requestState = await authorizationRequest.getMergedProperty('state') const responseUri = await authorizationRequest.getMergedProperty('response_uri') @@ -251,6 +269,7 @@ export class OpenId4VcSiopVerifierService { presentationDefinition: presentationDefinitionsWithLocation?.[0]?.definition, authorizationResponseUrl, clientId: requestClientId, + clientIdScheme: requestClientIdScheme, }) // This is very unfortunate, but storing state in sphereon's SiOP-OID4VP library @@ -463,7 +482,7 @@ export class OpenId4VcSiopVerifierService { return this.openId4VcVerificationSessionRepository.getById(agentContext, verificationSessionId) } - private async getRelyingParty( + public async getRelyingParty( agentContext: AgentContext, verifierId: string, { diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts index 4e44b2883e..264709bfb1 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 { OpenId4VcVerifierRepository } from './repository' import { OpenId4VcRelyingPartyEventHandler } from './repository/OpenId4VcRelyingPartyEventEmitter' -import { configureAuthorizationEndpoint } from './router' +import { configureAuthorizationEndpoint, configureFederationEndpoint } from './router' import { configureAuthorizationRequestEndpoint } from './router/authorizationRequestEndpoint' /** @@ -116,6 +116,10 @@ export class OpenId4VcVerifierModule implements Module { configureAuthorizationEndpoint(endpointRouter, this.config.authorizationEndpoint) configureAuthorizationRequestEndpoint(endpointRouter, this.config.authorizationRequestEndpoint) + // 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) + // 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/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts new file mode 100644 index 0000000000..e3fa74fcea --- /dev/null +++ b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts @@ -0,0 +1,147 @@ +import type { OpenId4VcVerificationRequest } from './requestContext' +import type { Key, Buffer } from '@credo-ts/core' +import type { RPRegistrationMetadataPayload } from '@sphereon/did-auth-siop' +import type { Router, Response } from 'express' + +import { getJwkFromKey, KeyType } from '@credo-ts/core' +import { createEntityConfiguration } from '@openid-federation/core' +import { LanguageTagUtils, removeNullUndefined } from '@sphereon/did-auth-siop' + +import { getRequestContext, sendErrorResponse } from '../../shared/router' +import { OpenId4VcSiopVerifierService } from '../OpenId4VcSiopVerifierService' +import { OpenId4VcVerifierModuleConfig } from '../OpenId4VcVerifierModuleConfig' + +// TODO: Add types but this function is originally from the @ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const createRPRegistrationMetadataPayload = (opts: any): RPRegistrationMetadataPayload => { + const rpRegistrationMetadataPayload: RPRegistrationMetadataPayload = { + id_token_signing_alg_values_supported: opts.idTokenSigningAlgValuesSupported, + request_object_signing_alg_values_supported: opts.requestObjectSigningAlgValuesSupported, + response_types_supported: opts.responseTypesSupported, + scopes_supported: opts.scopesSupported, + subject_types_supported: opts.subjectTypesSupported, + subject_syntax_types_supported: opts.subject_syntax_types_supported || ['did:web:', 'did:ion:'], + vp_formats: opts.vpFormatsSupported, + client_name: opts.clientName, + logo_uri: opts.logo_uri, + tos_uri: opts.tos_uri, + client_purpose: opts.clientPurpose, + client_id: opts.client_id, + } + + const languageTagEnabledFieldsNamesMapping = new Map() + languageTagEnabledFieldsNamesMapping.set('clientName', 'client_name') + languageTagEnabledFieldsNamesMapping.set('clientPurpose', 'client_purpose') + + // TODO: Do we need this? + const languageTaggedFields: Map = LanguageTagUtils.getLanguageTaggedPropertiesMapped( + opts, + languageTagEnabledFieldsNamesMapping + ) + + languageTaggedFields.forEach((value: string, key: string) => { + const _key = key as keyof typeof rpRegistrationMetadataPayload + rpRegistrationMetadataPayload[_key] = value + }) + + return removeNullUndefined(rpRegistrationMetadataPayload) +} + +export function configureFederationEndpoint(router: Router) { + // 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(OpenId4VcSiopVerifierService) + 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 relyingParty = await verifierService.getRelyingParty(agentContext, verifier.verifierId, { + clientId: verifierConfig.baseUrl, + clientIdScheme: 'entity_id', + authorizationResponseUrl: `${verifierConfig.baseUrl}/siop/${verifier.verifierId}/authorize`, + }) + + const verifierEntityId = `${verifierConfig.baseUrl}/${verifier.verifierId}` + + const rpMetadata = createRPRegistrationMetadataPayload(relyingParty.createRequestOptions.clientMetadata) + + // 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 = new Date(now.getTime() + 1000 * 60 * 60 * 24) // 1 day + + const jwk = getJwkFromKey(federationKey) + const alg = jwk.supportedSignatureAlgorithms[0] + const kid = federationKey.fingerprint + + 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() }], + }, + metadata: { + federation_entity: { + organization_name: rpMetadata.client_name, + logo_uri: rpMetadata.logo_uri, + }, + openid_relying_party: { + ...rpMetadata, + jwks: { + keys: [{ kid, alg, ...getJwkFromKey(rpSigningKey).toJson() }], + }, + 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, agentContext.config.logger, 500, 'invalid_request', error) + } + + // NOTE: if we don't call next, the agentContext session handler will NOT be called + next() + } + ) +} 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/models/OpenId4VcJwtIssuer.ts b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts index edbd9574b3..49c296211b 100644 --- a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts +++ b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts @@ -30,4 +30,12 @@ interface OpenId4VcJwtIssuerJwk { jwk: Jwk } -export type OpenId4VcJwtIssuer = OpenId4VcJwtIssuerDid | OpenId4VcIssuerX5c | OpenId4VcJwtIssuerJwk +export interface OpenId4VcJwtIssuerFederation { + method: 'openid-federation' +} + +export type OpenId4VcJwtIssuer = + | OpenId4VcJwtIssuerDid + | OpenId4VcIssuerX5c + | OpenId4VcJwtIssuerJwk + | OpenId4VcJwtIssuerFederation diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index f47fca1d1f..5c868f6732 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -1,4 +1,4 @@ -import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuer } from './models' +import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuer, OpenId4VcJwtIssuerFederation } from './models' import type { AgentContext, JwaSignatureAlgorithm, JwkJson, Key } from '@credo-ts/core' import type { JwtIssuerWithContext as VpJwtIssuerWithContext, VerifyJwtCallback } from '@sphereon/did-auth-siop' import type { DPoPJwtIssuerWithContext, CreateJwtCallback, JwtIssuer } from '@sphereon/oid4vc-common' @@ -17,6 +17,7 @@ import { getJwkFromKey, getKeyFromVerificationMethod, } from '@credo-ts/core' +import { fetchEntityConfiguration, resolveTrustChains } from '@openid-federation/core' /** * Returns the JWA Signature Algorithms that are supported by the wallet. @@ -49,21 +50,81 @@ async function getKeyFromDid(agentContext: AgentContext, didUrl: string) { return getKeyFromVerificationMethod(verificationMethod) } -export function getVerifyJwtCallback(agentContext: AgentContext): VerifyJwtCallback { +type VerifyJwtCallbackOptions = { + federation?: { + trustedEntityIds?: string[] + } +} + +export function getVerifyJwtCallback( + agentContext: AgentContext, + options: VerifyJwtCallbackOptions = {} +): VerifyJwtCallback { + const logger = agentContext.config.logger + return async (jwtVerifier, jwt) => { const jwsService = agentContext.dependencyManager.resolve(JwsService) + if (jwtVerifier.method === 'did') { const key = await getKeyFromDid(agentContext, jwtVerifier.didUrl) const jwk = getJwkFromKey(key) const res = await jwsService.verifyJws(agentContext, { jws: jwt.raw, jwkResolver: () => jwk }) return res.isValid - } else if (jwtVerifier.method === 'x5c' || jwtVerifier.method === 'jwk') { + } + + if (jwtVerifier.method === 'x5c' || jwtVerifier.method === 'jwk') { const res = await jwsService.verifyJws(agentContext, { jws: jwt.raw }) return res.isValid - } else { - throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) } + + if (jwtVerifier.method === 'openid-federation') { + const { entityId } = jwtVerifier + const trustedEntityIds = options.federation?.trustedEntityIds + if (!trustedEntityIds) { + logger.error('No trusted entity ids provided but is required for the "openid-federation" method.') + return false + } + + const validTrustChains = await resolveTrustChains({ + entityId, + trustAnchorEntityIds: trustedEntityIds, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { + jws: jwt, + jwkResolver: () => getJwkFromJson(jwk), + }) + + return res.isValid + }, + }) + // When the chain is already invalid we can return false immediately + if (validTrustChains.length === 0) { + logger.error(`${entityId} is not part of a trusted federation.`) + return 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 + if (!rpSigningKeys || rpSigningKeys.length === 0) + throw new CredoError('No rp signing keys found in the entity configuration.') + + const res = await jwsService.verifyJws(agentContext, { + jws: jwt.raw, + jwkResolver: () => getJwkFromJson(rpSigningKeys[0]), + }) + if (!res.isValid) { + logger.error(`${entityId} does not match the expected signing key.`) + } + + // TODO: There is no check yet for the policies + + return res.isValid + } + + throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) } } @@ -82,7 +143,9 @@ export function getCreateJwtCallback( }) return jws - } else if (jwtIssuer.method === 'jwk') { + } + + if (jwtIssuer.method === 'jwk') { if (!jwtIssuer.jwk.kty) { throw new CredoError('Missing required key type (kty) in the jwk.') } @@ -95,7 +158,9 @@ export function getCreateJwtCallback( }) return jws - } else if (jwtIssuer.method === 'x5c') { + } + + if (jwtIssuer.method === 'x5c') { const leafCertificate = X509Service.getLeafCertificate(agentContext, { certificateChain: jwtIssuer.x5c }) const jws = await jwsService.createJwsCompact(agentContext, { @@ -107,13 +172,62 @@ export function getCreateJwtCallback( return jws } + if (jwtIssuer.method === 'custom') { + // TODO: This could be used as the issuer and verifier. Based on that we need to search for a jwk in the entity configuration + const { options } = jwtIssuer + if (!options) throw new CredoError(`Custom jwtIssuer must have options defined.`) + if (!options.method) throw new CredoError(`Custom jwtIssuer's options must have a 'method' property defined.`) + if (options.method !== 'openid-federation') + throw new CredoError( + `Custom jwtIssuer's options 'method' property must be 'openid-federation' when using the 'custom' method.` + ) + if (!options.entityId) throw new CredoError(`Custom jwtIssuer must have entityId defined.`) + if (typeof options.entityId !== 'string') throw new CredoError(`Custom jwtIssuer's entityId must be a string.`) + + const { entityId } = options + + const entityConfiguration = await fetchEntityConfiguration({ + entityId, + verifyJwtCallback: async ({ jwt, jwk }) => { + const res = await jwsService.verifyJws(agentContext, { jws: jwt, jwkResolver: () => 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. + + // NOTE: Hardcoded part for the verifier + const openIdRelyingParty = entityConfiguration.metadata?.openid_relying_party + if (!openIdRelyingParty) throw new CredoError('No openid-relying-party 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: Not 100% sure what key to pick here I think the one that matches the kid in the jwt header of the entity configuration or we should pass a alg and pick a jwk based on that? + const jwk = getJwkFromJson(jwks.keys[0]) + + // TODO: This gives a weird error when the private key is not available in the wallet so we should handle that better + const jws = await jwsService.createJwsCompact(agentContext, { + protectedHeaderOptions: { ...jwt.header, jwk, alg: jwk.supportedSignatureAlgorithms[0] }, + payload: JwtPayload.fromJson(jwt.payload), + key: jwk.key, + }) + + return jws + } + + // @ts-expect-error - All methods are supported currently so there is no unsupported method anymore throw new Error(`Unsupported jwt issuer method '${jwtIssuer.method}'`) } } export async function openIdTokenIssuerToJwtIssuer( agentContext: AgentContext, - openId4VcTokenIssuer: Exclude | (OpenId4VcIssuerX5c & { issuer: string }) + openId4VcTokenIssuer: + | Exclude + | (OpenId4VcIssuerX5c & { issuer: string }) + | (OpenId4VcJwtIssuerFederation & { entityId: string }) ): Promise { if (openId4VcTokenIssuer.method === 'did') { const key = await getKeyFromDid(agentContext, openId4VcTokenIssuer.didUrl) @@ -125,7 +239,9 @@ export async function openIdTokenIssuerToJwtIssuer( didUrl: openId4VcTokenIssuer.didUrl, alg, } - } else if (openId4VcTokenIssuer.method === 'x5c') { + } + + if (openId4VcTokenIssuer.method === 'x5c') { const leafCertificate = X509Service.getLeafCertificate(agentContext, { certificateChain: openId4VcTokenIssuer.x5c, }) @@ -153,7 +269,9 @@ export async function openIdTokenIssuerToJwtIssuer( ...openId4VcTokenIssuer, alg, } - } else if (openId4VcTokenIssuer.method === 'jwk') { + } + + if (openId4VcTokenIssuer.method === 'jwk') { const alg = openId4VcTokenIssuer.jwk.supportedSignatureAlgorithms[0] if (!alg) { throw new CredoError(`No supported signature algorithms for key type: '${openId4VcTokenIssuer.jwk.keyType}'`) @@ -165,6 +283,17 @@ export async function openIdTokenIssuerToJwtIssuer( } } + if (openId4VcTokenIssuer.method === 'openid-federation') { + // TODO: Not sure what we want here if we need to add a new type to the sphereon library or that we can do it with the custom issuer + return { + method: 'custom', + options: { + method: 'openid-federation', + entityId: openId4VcTokenIssuer.entityId, + }, + } + } + throw new CredoError(`Unsupported jwt issuer method '${(openId4VcTokenIssuer 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..f0f7f874b4 --- /dev/null +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -0,0 +1,519 @@ +import type { AgentType, TenantType } from './utils' +import type { OpenId4VciSignMdocCredential } from '../src' +import type { Server } from 'http' + +import { + ClaimFormat, + DidsApi, + DifPresentationExchangeService, + JwaSignatureAlgorithm, + W3cCredential, + W3cCredentialSubject, + w3cDate, + W3cIssuer, + X509Module, + X509ModuleConfig, +} 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 { waitForVerificationSessionRecordSubject, createAgentFromModules, createTenantForAgent } from './utils' +import { + universityDegreeCredentialConfigurationSupportedMdoc, + universityDegreeCredentialSdJwt, + universityDegreeCredentialSdJwt2, +} from './utilsVci' +import { openBadgePresentationDefinition, universityDegreePresentationDefinition } from './utilsVp' + +const serverPort = 1234 +const baseUrl = `http://localhost:${serverPort}` +const issuanceBaseUrl = `${baseUrl}/oid4vci` +const verificationBaseUrl = `${baseUrl}/oid4vp` + +// TODO: Add tests for invalid configurations so unhappy tests + +describe('OpenId4Vc', () => { + let expressApp: Express + let expressServer: Server + + let issuer: AgentType<{ + openId4VcIssuer: OpenId4VcIssuerModule + tenants: TenantsModule<{ openId4VcIssuer: OpenId4VcIssuerModule }> + x509: X509Module + }> + + 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 + + beforeEach(async () => { + expressApp = express() + + issuer = (await createAgentFromModules( + 'issuer', + { + x509: new X509Module(), + openId4VcIssuer: new OpenId4VcIssuerModule({ + baseUrl: issuanceBaseUrl, + endpoints: { + credential: { + credentialRequestToCredentialMapper: async ({ agentContext, credentialRequest, holderBinding }) => { + // 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 { + credentialSupportedId: + credentialRequest.vct === 'UniversityDegreeCredential' + ? universityDegreeCredentialSdJwt.id + : universityDegreeCredentialSdJwt2.id, + format: credentialRequest.format, + payload: { vct: credentialRequest.vct, university: 'innsbruck', degree: 'bachelor' }, + holder: holderBinding, + issuer: { + method: 'did', + didUrl: verificationMethod.id, + }, + disclosureFrame: { _sd: ['university', 'degree'] }, + } + } else if (credentialRequest.format === 'mso_mdoc') { + const trustedCertificates = + agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates + if (trustedCertificates?.length !== 1) { + throw new Error('Expected exactly one trusted certificate. Received 0.') + } + + return { + credentialSupportedId: '', + format: ClaimFormat.MsoMdoc, + docType: universityDegreeCredentialConfigurationSupportedMdoc.doctype, + issuerCertificate: trustedCertificates[0], + holderKey: holderBinding.key, + namespaces: { + 'Leopold-Franzens-University': { + degree: 'bachelor', + }, + }, + } satisfies OpenId4VciSignMdocCredential + } else { + throw new Error('Invalid request') + } + }, + }, + }, + }), + askar: new AskarModule(askarModuleConfig), + tenants: new TenantsModule(), + }, + '96213c3d7fc8d4d6754c7a0fd969598g' + )) as unknown as typeof issuer + + holder = (await createAgentFromModules( + 'holder', + { + openId4VcHolder: new OpenId4VcHolderModule(), + askar: new AskarModule(askarModuleConfig), + tenants: new TenantsModule(), + x509: new X509Module(), + }, + '96213c3d7fc8d4d6754c7a0fd969598e' + )) as unknown as typeof holder + holder1 = await createTenantForAgent(holder.agent, 'hTenant1') + + verifier = (await createAgentFromModules( + 'verifier', + { + openId4VcVerifier: new OpenId4VcVerifierModule({ + baseUrl: verificationBaseUrl, + }), + 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() + }) + + it('e2e flow with tenants, 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: 'openid-federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'openid-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)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + const resolvedProofRequest1 = await holderTenant.modules.openId4VcHolder.resolveSiopAuthorizationRequest( + authorizationRequestUri1, + { + federation: { + trustedEntityIds: [`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.resolveSiopAuthorizationRequest( + authorizationRequestUri2, + { + federation: { + trustedEntityIds: [`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 presentationExchangeService = holderTenant.dependencyManager.resolve(DifPresentationExchangeService) + const selectedCredentials = presentationExchangeService.selectCredentialsForRequest( + resolvedProofRequest1.presentationExchange.credentialsForRequest + ) + + const { submittedResponse: submittedResponse1, serverResponse: serverResponse1 } = + await holderTenant.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ + authorizationRequest: resolvedProofRequest1.authorizationRequest, + 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 { idToken: idToken1, presentationExchange: presentationExchange1 } = + await verifierTenant1_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession1.id) + + expect(idToken1).toBeUndefined() + 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.acceptSiopAuthorizationRequest({ + authorizationRequest: resolvedProofRequest2.authorizationRequest, + 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 { idToken: idToken2, presentationExchange: presentationExchange2 } = + await verifierTenant2_2.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession2.id) + expect(idToken2).toBeUndefined() + + expect(presentationExchange2).toMatchObject({ + definition: universityDegreePresentationDefinition, + submission: { + definition_id: 'UniversityDegreeCredential', + }, + presentations: [ + { + verifiableCredential: [ + { + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + }, + ], + }, + ], + }) + }) + + it('e2e flow with tenants, 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: 'openid-federation', + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent( + `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}` + )}&request_uri=${encodeURIComponent(verificationSession1.authorizationRequestUri)}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'openid-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)}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + const resolvedProofRequestWithFederationPromise = + holderTenant.modules.openId4VcHolder.resolveSiopAuthorizationRequest(authorizationRequestUri1, { + federation: { + // This will look for a whole different trusted entity + trustedEntityIds: [`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 verifying the DID Auth Token signature.` + ) + + const resolvedProofRequestWithoutFederationPromise = + holderTenant.modules.openId4VcHolder.resolveSiopAuthorizationRequest(authorizationRequestUri2) + await expect(resolvedProofRequestWithoutFederationPromise).rejects.toThrow( + `Error verifying the DID Auth Token signature.` + ) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c3e021d5..04f91bfb38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -692,6 +692,9 @@ importers: '@credo-ts/core': specifier: workspace:* version: link:../core + '@openid-federation/core': + specifier: 0.1.1-alpha.13 + version: 0.1.1-alpha.13 '@sphereon/did-auth-siop': specifier: 0.16.1-fix.173 version: 0.16.1-fix.173(ts-node@10.9.2(@swc/core@1.7.40)(@types/node@18.18.8)(typescript@5.5.4))(typescript@5.5.4) @@ -2224,6 +2227,9 @@ packages: resolution: {integrity: sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + '@openid-federation/core@0.1.1-alpha.13': + resolution: {integrity: sha512-QC4DSbiJ7eWstLs1O3XrX/yKFgaj+3ch8cA4N/02BywVNmkiYgW9qXhcvY50ULINuCeYdqIMIqCuHbaTa0A1hw==} + '@peculiar/asn1-cms@2.3.13': resolution: {integrity: sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==} @@ -2464,6 +2470,7 @@ packages: '@sphereon/kmp-mdl-mdoc@0.2.0-SNAPSHOT.22': resolution: {integrity: sha512-uAZZExVy+ug9JLircejWa5eLtAZ7bnBP6xb7DO2+86LRsHNLh2k2jMWJYxp+iWtGHTsh6RYsZl14ScQLvjiQ/A==} + bundledDependencies: [] '@sphereon/oid4vc-common@0.16.1-fix.173': resolution: {integrity: sha512-+AAUvEEFs0vzz1mrgjSgvDkcBtr18d2XEVgJex7QlAqxCKVGfjzZlqL2Q2vOLKYVaXsazhD5LnYiY6B5WMTC3Q==} @@ -9802,6 +9809,11 @@ snapshots: dependencies: semver: 7.6.3 + '@openid-federation/core@0.1.1-alpha.13': + dependencies: + buffer: 6.0.3 + zod: 3.23.8 + '@peculiar/asn1-cms@2.3.13': dependencies: '@peculiar/asn1-schema': 2.3.13 @@ -12542,7 +12554,7 @@ snapshots: debug: 4.3.6 enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.6 @@ -12554,7 +12566,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -12575,7 +12587,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.0 is-glob: 4.0.3