From b72348578b0461d4a5f3de5eab3e889857b117a1 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Tue, 5 Nov 2024 11:34:39 +0100 Subject: [PATCH 1/7] feat: working version Signed-off-by: Tom Lanser --- packages/openid4vc/package.json | 1 + .../OpenId4VciHolderService.ts | 2 + .../openid4vc-issuer/OpenId4VcIssuerModule.ts | 6 + .../OpenId4VcIssuerModuleConfig.ts | 12 + .../router/federationEndpoint.ts | 102 ++++ .../OpenId4VcSiopVerifierService.ts | 13 +- .../OpenId4VcVerifierModule.ts | 5 +- .../OpenId4VcVerifierModuleConfig.ts | 13 + .../__tests__/openid4vc-verifier.test.ts | 39 ++ .../router/federationEndpoint.ts | 133 ++++++ .../src/openid4vc-verifier/router/index.ts | 1 + packages/openid4vc/src/shared/federation.ts | 9 + packages/openid4vc/src/shared/index.ts | 1 + .../src/shared/models/OpenId4VcJwtIssuer.ts | 11 +- packages/openid4vc/src/shared/utils.ts | 92 +++- .../tests/openid4vc-federation.e2e.test.ts | 445 ++++++++++++++++++ pnpm-lock.yaml | 30 +- 17 files changed, 903 insertions(+), 12 deletions(-) create mode 100644 packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts create mode 100644 packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts create mode 100644 packages/openid4vc/src/shared/federation.ts create mode 100644 packages/openid4vc/tests/openid4vc-federation.e2e.test.ts diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index d56d6b0bae..61defe7440 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.5", "class-transformer": "^0.5.1", "rxjs": "^7.8.0" }, diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts index e46bed9a18..c111f30492 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts @@ -871,4 +871,6 @@ export class OpenId4VciHolderService { return jws } } + + // TODO: Add a function for resolving the entity statement. Which will be used in the holder to verify the entity statement and to show to the user } diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts index 44f4f6e84c..7d7e199a03 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 @@ -120,6 +121,11 @@ export class OpenId4VcIssuerModule implements Module { configureAccessTokenEndpoint(endpointRouter, this.config.accessTokenEndpoint) configureCredentialEndpoint(endpointRouter, this.config.credentialEndpoint) + // The federation endpoint is optional + if (this.config.federationEndpoint) { + configureFederationEndpoint(endpointRouter, this.config.federationEndpoint) + } + // First one will be called for all requests (when next is called) contextRouter.use(async (req: OpenId4VcIssuanceRequest, _res: unknown, next) => { const { agentContext } = getRequestContext(req) diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts index 71eaa43c9a..7697cc637c 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 } } @@ -94,4 +96,14 @@ export class OpenId4VcIssuerModuleConfig { endpointPath: userOptions.endpointPath ?? '/offers', } } + + public get federationEndpoint(): OpenId4VcSiopFederationEndpointConfig | undefined { + const userOptions = this.options.endpoints.federation + if (!userOptions) return undefined + + return { + ...userOptions, + endpointPath: userOptions.endpointPath ?? '/.well-known/openid-federation', + } + } } 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..15f656a32c --- /dev/null +++ b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts @@ -0,0 +1,102 @@ +import type { OpenId4VcIssuanceRequest } from './requestContext' +import type { FederationKeyCallback } from '../../shared/federation' +import type { Buffer } from '@credo-ts/core' +import type { Router, Response } from 'express' + +import { getJwkFromKey } from '@credo-ts/core' +import { createEntityConfiguration } from '@openid-federation/core' + +import { getRequestContext, sendErrorResponse } from '../../shared/router' +import { OpenId4VcIssuerService } from '../OpenId4VcIssuerService' + +export interface OpenId4VcSiopFederationEndpointConfig { + /** + * The path at which the credential endpoint should be made available. Note that it will be + * hosted at a subpath to take into account multiple tenants and issuers. + * + * @default /.well-known/openid-federation + */ + endpointPath: string + + // TODO: Not sure about the property name yet. + //TODO: More information is needed than only the key also the client id etc + keyCallback: FederationKeyCallback<{ + issuerId: string + }> +} + +// 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, config: OpenId4VcSiopFederationEndpointConfig) { + router.get(config.endpointPath, async (request: OpenId4VcIssuanceRequest, response: Response, next) => { + const { agentContext, issuer } = getRequestContext(request) + const openId4VcIssuerService = agentContext.dependencyManager.resolve(OpenId4VcIssuerService) + + try { + const issuerMetadata = openId4VcIssuerService.getIssuerMetadata(agentContext, issuer) + // TODO: Use a type here from sphreon + const transformedMetadata = { + 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, + } as const + + const now = new Date() + const expires = new Date(now.getTime() + 1000 * 60 * 60 * 24) // 1 day from now + + const { key } = await config.keyCallback(agentContext, { + issuerId: issuer.issuerId, + }) + + const jwk = getJwkFromKey(key) + const kid = 'key-1' + const alg = jwk.supportedSignatureAlgorithms[0] + + const issuerDisplay = issuerMetadata.issuerDisplay?.[0] + + 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.organization_name, + logo_uri: issuerDisplay.logo_uri, + } + : undefined, + openid_credential_issuer: transformedMetadata, + }, + }, + header: { + kid, + alg, + typ: 'entity-statement+jwt', + }, + signJwtCallback: ({ toBeSigned }) => + agentContext.wallet.sign({ + data: toBeSigned as Buffer, + key, + }), + }) + + response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityConfiguration) + } catch (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..8bb73db0d5 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -142,6 +142,14 @@ export class OpenId4VcSiopVerifierService { } else if (jwtIssuer.method === 'did') { clientId = jwtIssuer.didUrl.split('#')[0] clientIdScheme = 'did' + } else if (jwtIssuer.method === 'custom') { + if (!jwtIssuer.options) throw new CredoError(`Custom jwtIssuer must have options defined.`) + if (!jwtIssuer.options.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) + if (typeof jwtIssuer.options.clientId !== 'string') + throw new CredoError(`Custom jwtIssuer's clientId must be a string.`) + + clientIdScheme = 'entity_id' + clientId = jwtIssuer.options.clientId } else { throw new CredoError( `Unsupported jwt issuer method '${options.requestSigner.method}'. Only 'did' and 'x5c' are supported.` @@ -231,6 +239,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 +261,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 +474,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..9c81150c2e 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' /** @@ -115,6 +115,9 @@ export class OpenId4VcVerifierModule implements Module { // Configure endpoints configureAuthorizationEndpoint(endpointRouter, this.config.authorizationEndpoint) configureAuthorizationRequestEndpoint(endpointRouter, this.config.authorizationRequestEndpoint) + if (this.config.federationEndpoint) { + configureFederationEndpoint(endpointRouter, this.config.federationEndpoint) + } // First one will be called for all requests (when next is called) contextRouter.use(async (req: OpenId4VcVerificationRequest, _res: unknown, next) => { diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts index b2ec763cbc..0360b3cf14 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts @@ -1,5 +1,6 @@ import type { OpenId4VcSiopAuthorizationEndpointConfig } from './router/authorizationEndpoint' import type { OpenId4VcSiopAuthorizationRequestEndpointConfig } from './router/authorizationRequestEndpoint' +import type { OpenId4VcSiopFederationEndpointConfig } from './router/federationEndpoint' import type { Optional } from '@credo-ts/core' import type { Router } from 'express' @@ -24,6 +25,7 @@ export interface OpenId4VcVerifierModuleConfigOptions { endpoints?: { authorization?: Optional authorizationRequest?: Optional + federation?: Optional } } @@ -60,4 +62,15 @@ export class OpenId4VcVerifierModuleConfig { endpointPath: userOptions?.endpointPath ?? '/authorize', } } + + public get federationEndpoint(): OpenId4VcSiopFederationEndpointConfig | undefined { + // Use user supplied options, or return defaults. + const userOptions = this.options.endpoints?.federation + if (!userOptions) return undefined + + return { + ...userOptions, + endpointPath: userOptions.endpointPath ?? '/.well-known/openid-federation', + } + } } diff --git a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts index e40ef70579..883b5cb8a7 100644 --- a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts +++ b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts @@ -113,5 +113,44 @@ describe('OpenId4VcVerifier', () => { expect(jwt.payload.iss).toEqual(verifier.did) expect(jwt.payload.sub).toEqual(verifier.did) }) + + it('check openid proof request format (entity id)', async () => { + const openIdVerifier = await verifier.agent.modules.openId4VcVerifier.createVerifier() + const { authorizationRequest, verificationSession } = + await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'openid-federation', + clientId: 'http://localhost:3001/verifier', + }, + verifierId: openIdVerifier.verifierId, + }) + + expect( + authorizationRequest.startsWith( + `openid://?client_id=${encodeURIComponent(verifier.did)}&request_uri=http%3A%2F%2Fredirect-uri%2F${ + openIdVerifier.verifierId + }%2Fauthorization-requests%2F` + ) + ).toBe(true) + + const jwt = Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt) + + expect(jwt.header.kid) + + expect(jwt.header.kid).toEqual(verifier.kid) + expect(jwt.header.alg).toEqual(SigningAlgo.EDDSA) + expect(jwt.header.typ).toEqual('JWT') + expect(jwt.payload.additionalClaims.scope).toEqual('openid') + expect(jwt.payload.additionalClaims.client_id).toEqual(verifier.did) + expect(jwt.payload.additionalClaims.response_uri).toEqual( + `http://redirect-uri/${openIdVerifier.verifierId}/authorize` + ) + expect(jwt.payload.additionalClaims.response_mode).toEqual('direct_post') + expect(jwt.payload.additionalClaims.nonce).toBeDefined() + expect(jwt.payload.additionalClaims.state).toBeDefined() + expect(jwt.payload.additionalClaims.response_type).toEqual('id_token') + expect(jwt.payload.iss).toEqual(verifier.did) + expect(jwt.payload.sub).toEqual(verifier.did) + }) }) }) 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..0e02d97271 --- /dev/null +++ b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts @@ -0,0 +1,133 @@ +import type { OpenId4VcVerificationRequest } from './requestContext' +import type { FederationKeyCallback } from '../../shared/federation' +import type { RPRegistrationMetadataPayload } from '@sphereon/did-auth-siop' +import type { Router, Response } from 'express' + +import { getJwkFromKey, type Buffer } 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: Think about how we can have multiple issuers over the federation endpoint +export interface OpenId4VcSiopFederationEndpointConfig { + /** + * The path at which the authorization request should be made available. Note that it will be + * hosted at a subpath to take into account multiple tenants and verifiers. + * + * @default /.well-known/openid-federation + */ + endpointPath: string + + // TODO: Not sure about the property name yet. + keyCallback: FederationKeyCallback<{ + verifierId: string + }> +} + +// 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, config: OpenId4VcSiopFederationEndpointConfig) { + router.get(config.endpointPath, async (request: OpenId4VcVerificationRequest, response: Response, next) => { + const { agentContext, verifier } = getRequestContext(request) + const verifierService = agentContext.dependencyManager.resolve(OpenId4VcSiopVerifierService) + const verifierConfig = agentContext.dependencyManager.resolve(OpenId4VcVerifierModuleConfig) + + try { + const { key } = await config.keyCallback(agentContext, { + verifierId: verifier.verifierId, + }) + + 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(key) + const alg = jwk.supportedSignatureAlgorithms[0] + const kid = 'key-1' + + 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_credential_verifier: rpMetadata, + }, + }, + signJwtCallback: ({ toBeSigned }) => + agentContext.wallet.sign({ + data: toBeSigned as Buffer, + key, + }), + }) + + response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityConfiguration) + } catch (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/federation.ts b/packages/openid4vc/src/shared/federation.ts new file mode 100644 index 0000000000..fefafdb426 --- /dev/null +++ b/packages/openid4vc/src/shared/federation.ts @@ -0,0 +1,9 @@ +import type { AgentContext, Key } from '@credo-ts/core' + +// TODO: Not really sure about this type yet but it's a start. +export type FederationKeyCallback = Record> = ( + agentContext: AgentContext, + context: TContext +) => Promise<{ + key: Key +}> diff --git a/packages/openid4vc/src/shared/index.ts b/packages/openid4vc/src/shared/index.ts index 8eacb927b2..3e3b26e4ce 100644 --- a/packages/openid4vc/src/shared/index.ts +++ b/packages/openid4vc/src/shared/index.ts @@ -1,2 +1,3 @@ export * from './models' export * from './issuerMetadataUtils' +export * from './federation' diff --git a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts index edbd9574b3..c3fada42d4 100644 --- a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts +++ b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts @@ -30,4 +30,13 @@ interface OpenId4VcJwtIssuerJwk { jwk: Jwk } -export type OpenId4VcJwtIssuer = OpenId4VcJwtIssuerDid | OpenId4VcIssuerX5c | OpenId4VcJwtIssuerJwk +interface OpenId4VcJwtIssuerFederation { + method: 'openid-federation' + clientId: 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 f47fca1d1f..efdd3dfde3 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -1,7 +1,7 @@ import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuer } 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' +import type { DPoPJwtIssuerWithContext, CreateJwtCallback, JwtIssuer, JwtIssuerBase } from '@sphereon/oid4vc-common' import type { CredentialOfferPayloadV1_0_11, CredentialOfferPayloadV1_0_13 } from '@sphereon/oid4vci-common' import { @@ -10,6 +10,7 @@ import { JwsService, JwtPayload, SignatureSuiteRegistry, + TypedArrayEncoder, X509Service, getDomainFromUrl, getJwkClassFromKeyType, @@ -17,6 +18,7 @@ import { getJwkFromKey, getKeyFromVerificationMethod, } from '@credo-ts/core' +import { fetchEntityConfiguration, fetchEntityConfigurationChains } from '@openid-federation/core' /** * Returns the JWA Signature Algorithms that are supported by the wallet. @@ -49,7 +51,14 @@ async function getKeyFromDid(agentContext: AgentContext, didUrl: string) { return getKeyFromVerificationMethod(verificationMethod) } -export function getVerifyJwtCallback(agentContext: AgentContext): VerifyJwtCallback { +type VerifyJwtCallbackOptions = { + trustedEntityIds?: string[] +} + +export function getVerifyJwtCallback( + agentContext: AgentContext, + options: VerifyJwtCallbackOptions = {} +): VerifyJwtCallback { return async (jwtVerifier, jwt) => { const jwsService = agentContext.dependencyManager.resolve(JwsService) if (jwtVerifier.method === 'did') { @@ -61,6 +70,30 @@ export function getVerifyJwtCallback(agentContext: AgentContext): VerifyJwtCallb } else if (jwtVerifier.method === 'x5c' || jwtVerifier.method === 'jwk') { const res = await jwsService.verifyJws(agentContext, { jws: jwt.raw }) return res.isValid + } else if (jwtVerifier.method === 'openid-federation') { + const { entityId } = jwtVerifier + const trustedEntityIds = options.trustedEntityIds ?? [entityId] // TODO: Just for testing + if (!trustedEntityIds) + throw new CredoError('No trusted entity ids provided but is required for the openid-federation method.') + + const entityConfigurationChains = await fetchEntityConfigurationChains({ + leafEntityId: entityId, + trustAnchorEntityIds: trustedEntityIds, + verifyJwtCallback: async ({ data, signature, jwk }) => { + const jws = `${TypedArrayEncoder.toUtf8String(data)}.${TypedArrayEncoder.toBase64URL(signature)}` + + const res = await jwsService.verifyJws(agentContext, { + jws, + jwkResolver: () => getJwkFromJson(jwk), + }) + return res.isValid + }, + }) + + // TODO: There is no check yet for the policies + + // TODO: I think this is correct but not sure? + return entityConfigurationChains.length > 0 } else { throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) } @@ -82,7 +115,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 +130,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,6 +144,35 @@ export function getCreateJwtCallback( return jws } + if (jwtIssuer.method === 'custom') { + const { options } = jwtIssuer + if (!options) throw new CredoError(`Custom jwtIssuer must have options defined.`) + if (!options.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) + if (typeof options.clientId !== 'string') throw new CredoError(`Custom jwtIssuer's clientId must be a string.`) + + const clientId = options.clientId + + const entityConfiguration = await fetchEntityConfiguration({ + entityId: clientId as string, + verifyJwtCallback: async ({ data, signature, jwk }) => { + const jws = `${TypedArrayEncoder.toUtf8String(data)}.${TypedArrayEncoder.toBase64URL(signature)}` + const res = await jwsService.verifyJws(agentContext, { jws, jwkResolver: () => getJwkFromJson(jwk) }) + return res.isValid + }, + }) + + // 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(entityConfiguration.jwks.keys[0]) + + const jws = await jwsService.createJwsCompact(agentContext, { + protectedHeaderOptions: { ...jwt.header, jwk, alg: jwk.supportedSignatureAlgorithms[0] }, + payload: JwtPayload.fromJson(jwt.payload), + key: jwk.key, + }) + + return jws + } + throw new Error(`Unsupported jwt issuer method '${jwtIssuer.method}'`) } } @@ -125,7 +191,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 +221,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 +235,16 @@ 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: { + clientId: openId4VcTokenIssuer.clientId, + }, + } + } + 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..a0d0c9ad2b --- /dev/null +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -0,0 +1,445 @@ +import type { AgentType, TenantType } from './utils' +import type { OpenId4VciSignMdocCredential } from '../src' +import type { Server } from 'http' + +import { + ClaimFormat, + DidsApi, + DifPresentationExchangeService, + JwaSignatureAlgorithm, + KeyType, + W3cCredential, + W3cCredentialSubject, + w3cDate, + W3cIssuer, + WalletApi, + 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` + +describe('OpenId4Vc', () => { + let expressApp: Express + let expressServer: Server + + let issuer: AgentType<{ + openId4VcIssuer: OpenId4VcIssuerModule + tenants: TenantsModule<{ openId4VcIssuer: OpenId4VcIssuerModule }> + x509: X509Module + }> + 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 + + 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') + } + }, + }, + federation: { + keyCallback: async (agentContext) => { + const walletApi = agentContext.dependencyManager.resolve(WalletApi) + const key = await walletApi.createKey({ keyType: KeyType.Ed25519 }) + + return { + key, + } + }, + }, + }, + }), + 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(), + x509: new X509Module(), + }, + '96213c3d7fc8d4d6754c7a0fd969598e' + )) as unknown as typeof holder + holder1 = await createTenantForAgent(holder.agent, 'hTenant1') + + verifier = (await createAgentFromModules( + 'verifier', + { + openId4VcVerifier: new OpenId4VcVerifierModule({ + baseUrl: verificationBaseUrl, + endpoints: { + federation: { + keyCallback: async (agentContext) => { + const walletApi = agentContext.dependencyManager.resolve(WalletApi) + const key = await walletApi.createKey({ keyType: KeyType.Ed25519 }) + + return { + key, + } + }, + }, + }, + }), + 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', + clientId: `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}`, + }, + 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', + clientId: `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`, + }, + 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 + ) + + 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 + ) + + 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'], + }, + ], + }, + ], + }) + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 13c3e021d5..c6f35137cb 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.5 + version: 0.1.1-alpha.5 '@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.5': + resolution: {integrity: sha512-DDrFtsrIpvw7JScIEm8KJU7H19lkgQ3AKweJld0Os3AQyOPlb0EvFaLdhS1EMmiWduGuxq3nHnWrWrod0MwyJA==} + '@peculiar/asn1-cms@2.3.13': resolution: {integrity: sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==} @@ -2524,6 +2530,9 @@ packages: '@sphereon/ssi-types@0.29.1-unstable.161': resolution: {integrity: sha512-ifMADjk6k0f97/isK/4Qw/PX6n4k+qS5k6mmmH47MTD3KMDddVghoXycsvNw7wObJdLUalHBX630ghr+u21oMg==} + '@sphereon/ssi-types@0.29.1-unstable.208': + resolution: {integrity: sha512-3YAFzy//BojsYN+RYoEjndWP3w5a8a3qRZi5dS0Gh6s4yMCiykqTJM1agJVeoaLce8JxFFaCWSpkzwbmJYGTaQ==} + '@sphereon/ssi-types@0.30.1': resolution: {integrity: sha512-vbYaxQXb71sOPwDj7TRDlUGfIHKVVs8PiHfImPBgSBshrD7VpEHOrB+EwwavMm5MAQvWK/yblGmzk7FHds7SHA==} @@ -9802,6 +9811,11 @@ snapshots: dependencies: semver: 7.6.3 + '@openid-federation/core@0.1.1-alpha.5': + dependencies: + buffer: 6.0.3 + zod: 3.23.8 + '@peculiar/asn1-cms@2.3.13': dependencies: '@peculiar/asn1-schema': 2.3.13 @@ -10707,6 +10721,16 @@ snapshots: transitivePeerDependencies: - supports-color + '@sphereon/ssi-types@0.29.1-unstable.208': + dependencies: + '@sd-jwt/decode': 0.6.1 + '@sphereon/kmp-mdl-mdoc': 0.2.0-SNAPSHOT.22 + debug: 4.3.6 + events: 3.3.0 + jwt-decode: 3.1.2 + transitivePeerDependencies: + - supports-color + '@sphereon/ssi-types@0.30.1(ts-node@10.9.2(@swc/core@1.7.40)(@types/node@18.18.8)(typescript@5.5.4))': dependencies: '@sd-jwt/decode': 0.7.2 @@ -12542,7 +12566,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 +12578,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 +12599,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 From bcaed4d3ef056ccfce10d6090621f6d04d406c26 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Wed, 6 Nov 2024 20:49:29 +0100 Subject: [PATCH 2/7] feat: Littlebit of a cleanup for the verifier Signed-off-by: Tom Lanser --- packages/openid4vc/package.json | 2 +- .../openid4vc-holder/OpenId4VcHolderApi.ts | 12 ++++-- .../OpenId4vcSiopHolderService.ts | 17 ++++++-- .../OpenId4vcSiopHolderServiceOptions.ts | 20 +++++++++ .../OpenId4VcSiopVerifierService.ts | 1 + packages/openid4vc/src/shared/utils.ts | 35 ++++++++++------ .../tests/openid4vc-federation.e2e.test.ts | 18 +++++--- pnpm-lock.yaml | 42 +++++++++++-------- 8 files changed, 103 insertions(+), 44 deletions(-) diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index 61defe7440..1e0e13d9b2 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -33,7 +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.5", + "@openid-federation/core": "0.1.1-alpha.6", "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..754f029238 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts @@ -8,7 +8,10 @@ import type { OpenId4VciSendNotificationOptions, OpenId4VciRequestTokenResponse, } from './OpenId4VciHolderServiceOptions' -import type { OpenId4VcSiopAcceptAuthorizationRequestOptions } from './OpenId4vcSiopHolderServiceOptions' +import type { + OpenId4VcSiopAcceptAuthorizationRequestOptions, + OpenId4VcSiopResolveAuthorizationRequestOptions, +} from './OpenId4vcSiopHolderServiceOptions' import { injectable, AgentContext } from '@credo-ts/core' @@ -40,8 +43,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) } /** diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts index 09f1629fe0..f84200ef87 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts @@ -1,5 +1,7 @@ import type { OpenId4VcSiopAcceptAuthorizationRequestOptions, + OpenId4VcSiopGetOpenIdProviderOptions, + OpenId4VcSiopResolveAuthorizationRequestOptions, OpenId4VcSiopResolvedAuthorizationRequest, } from './OpenId4vcSiopHolderServiceOptions' import type { OpenId4VcJwtIssuer } from '../shared' @@ -38,9 +40,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) @@ -231,7 +236,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 +247,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() diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts index c59a9dd53f..40a5d48d69 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts @@ -48,6 +48,17 @@ export interface OpenId4VcSiopAcceptAuthorizationRequestOptions { * 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 +67,12 @@ export interface OpenId4VcSiopAuthorizationResponseSubmission { status: number submittedResponse: OpenId4VcSiopAuthorizationResponsePayload } + +export interface OpenId4VcSiopGetOpenIdProviderOptions { + federation?: { + /** + * The entity IDs of the trusted issuers. + */ + trustedEntityIds?: string[] + } +} diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index 8bb73db0d5..ef1b7319ea 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -143,6 +143,7 @@ export class OpenId4VcSiopVerifierService { clientId = jwtIssuer.didUrl.split('#')[0] clientIdScheme = 'did' } else if (jwtIssuer.method === 'custom') { + // TODO: Currently used as openid federation, but the jwtIssuer should also be openid-federation if (!jwtIssuer.options) throw new CredoError(`Custom jwtIssuer must have options defined.`) if (!jwtIssuer.options.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) if (typeof jwtIssuer.options.clientId !== 'string') diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index efdd3dfde3..1ccc968a54 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -1,7 +1,7 @@ import type { OpenId4VcIssuerX5c, OpenId4VcJwtIssuer } 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, JwtIssuerBase } from '@sphereon/oid4vc-common' +import type { DPoPJwtIssuerWithContext, CreateJwtCallback, JwtIssuer } from '@sphereon/oid4vc-common' import type { CredentialOfferPayloadV1_0_11, CredentialOfferPayloadV1_0_13 } from '@sphereon/oid4vci-common' import { @@ -18,7 +18,7 @@ import { getJwkFromKey, getKeyFromVerificationMethod, } from '@credo-ts/core' -import { fetchEntityConfiguration, fetchEntityConfigurationChains } from '@openid-federation/core' +import { fetchEntityConfiguration, resolveTrustChains } from '@openid-federation/core' /** * Returns the JWA Signature Algorithms that are supported by the wallet. @@ -52,7 +52,9 @@ async function getKeyFromDid(agentContext: AgentContext, didUrl: string) { } type VerifyJwtCallbackOptions = { - trustedEntityIds?: string[] + federation?: { + trustedEntityIds?: string[] + } } export function getVerifyJwtCallback( @@ -61,23 +63,28 @@ export function getVerifyJwtCallback( ): VerifyJwtCallback { 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 if (jwtVerifier.method === 'openid-federation') { + } + + if (jwtVerifier.method === 'openid-federation') { const { entityId } = jwtVerifier - const trustedEntityIds = options.trustedEntityIds ?? [entityId] // TODO: Just for testing + const trustedEntityIds = options.federation?.trustedEntityIds if (!trustedEntityIds) throw new CredoError('No trusted entity ids provided but is required for the openid-federation method.') - const entityConfigurationChains = await fetchEntityConfigurationChains({ - leafEntityId: entityId, + const validTrustChains = await resolveTrustChains({ + entityId, trustAnchorEntityIds: trustedEntityIds, verifyJwtCallback: async ({ data, signature, jwk }) => { const jws = `${TypedArrayEncoder.toUtf8String(data)}.${TypedArrayEncoder.toBase64URL(signature)}` @@ -86,17 +93,20 @@ export function getVerifyJwtCallback( jws, jwkResolver: () => getJwkFromJson(jwk), }) + return res.isValid }, }) // TODO: There is no check yet for the policies + // TODO: When this function results in a `false` it gives a really misleading error message: 'Error verifying the DID Auth Token signature.' + // TODO: I think this is correct but not sure? - return entityConfigurationChains.length > 0 - } else { - throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) + return validTrustChains.length > 0 } + + throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) } } @@ -150,7 +160,7 @@ export function getCreateJwtCallback( if (!options.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) if (typeof options.clientId !== 'string') throw new CredoError(`Custom jwtIssuer's clientId must be a string.`) - const clientId = options.clientId + const { clientId } = options const entityConfiguration = await fetchEntityConfiguration({ entityId: clientId as string, @@ -164,6 +174,7 @@ export function getCreateJwtCallback( // 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(entityConfiguration.jwks.keys[0]) + // TODO: This gives a weird error when the private key is not available in the wallet const jws = await jwsService.createJwsCompact(agentContext, { protectedHeaderOptions: { ...jwt.header, jwk, alg: jwk.supportedSignatureAlgorithms[0] }, payload: JwtPayload.fromJson(jwt.payload), diff --git a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts index a0d0c9ad2b..7eb667a143 100644 --- a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -50,8 +50,6 @@ describe('OpenId4Vc', () => { tenants: TenantsModule<{ openId4VcIssuer: OpenId4VcIssuerModule }> x509: X509Module }> - let issuer1: TenantType - let issuer2: TenantType let holder: AgentType<{ openId4VcHolder: OpenId4VcHolderModule @@ -143,8 +141,6 @@ describe('OpenId4Vc', () => { }, '96213c3d7fc8d4d6754c7a0fd969598g' )) as unknown as typeof issuer - issuer1 = await createTenantForAgent(issuer.agent, 'iTenant1') - issuer2 = await createTenantForAgent(issuer.agent, 'iTenant2') holder = (await createAgentFromModules( 'holder', @@ -276,7 +272,12 @@ describe('OpenId4Vc', () => { await verifierTenant2.endSession() const resolvedProofRequest1 = await holderTenant.modules.openId4VcHolder.resolveSiopAuthorizationRequest( - authorizationRequestUri1 + authorizationRequestUri1, + { + federation: { + trustedEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}`], + }, + } ) expect(resolvedProofRequest1.presentationExchange?.credentialsForRequest).toMatchObject({ @@ -302,7 +303,12 @@ describe('OpenId4Vc', () => { }) const resolvedProofRequest2 = await holderTenant.modules.openId4VcHolder.resolveSiopAuthorizationRequest( - authorizationRequestUri2 + authorizationRequestUri2, + { + federation: { + trustedEntityIds: [`http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`], + }, + } ) expect(resolvedProofRequest2.presentationExchange?.credentialsForRequest).toMatchObject({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c6f35137cb..1601d97c54 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -693,8 +693,8 @@ importers: specifier: workspace:* version: link:../core '@openid-federation/core': - specifier: 0.1.1-alpha.5 - version: 0.1.1-alpha.5 + specifier: 0.1.1-alpha.6 + version: 0.1.1-alpha.6 '@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) @@ -2227,8 +2227,8 @@ 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.5': - resolution: {integrity: sha512-DDrFtsrIpvw7JScIEm8KJU7H19lkgQ3AKweJld0Os3AQyOPlb0EvFaLdhS1EMmiWduGuxq3nHnWrWrod0MwyJA==} + '@openid-federation/core@0.1.1-alpha.6': + resolution: {integrity: sha512-ipQtZYtFMUr2BvUmOxlQNVF7eILEq8isoO7rDYwIj4xafifdPAMxznzDxqlu3sHqbOO49PRDRjo9ESsHUfJLfg==} '@peculiar/asn1-cms@2.3.13': resolution: {integrity: sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==} @@ -2470,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==} @@ -2530,9 +2531,6 @@ packages: '@sphereon/ssi-types@0.29.1-unstable.161': resolution: {integrity: sha512-ifMADjk6k0f97/isK/4Qw/PX6n4k+qS5k6mmmH47MTD3KMDddVghoXycsvNw7wObJdLUalHBX630ghr+u21oMg==} - '@sphereon/ssi-types@0.29.1-unstable.208': - resolution: {integrity: sha512-3YAFzy//BojsYN+RYoEjndWP3w5a8a3qRZi5dS0Gh6s4yMCiykqTJM1agJVeoaLce8JxFFaCWSpkzwbmJYGTaQ==} - '@sphereon/ssi-types@0.30.1': resolution: {integrity: sha512-vbYaxQXb71sOPwDj7TRDlUGfIHKVVs8PiHfImPBgSBshrD7VpEHOrB+EwwavMm5MAQvWK/yblGmzk7FHds7SHA==} @@ -9811,7 +9809,7 @@ snapshots: dependencies: semver: 7.6.3 - '@openid-federation/core@0.1.1-alpha.5': + '@openid-federation/core@0.1.1-alpha.6': dependencies: buffer: 6.0.3 zod: 3.23.8 @@ -10524,7 +10522,25 @@ snapshots: nanoid: 3.3.7 uint8arrays: 3.1.1 transitivePeerDependencies: + - '@google-cloud/spanner' + - '@sap/hana-client' + - better-sqlite3 + - encoding + - hdb-pool + - ioredis + - mongodb + - mssql + - mysql2 + - oracledb + - pg + - pg-native + - pg-query-stream + - redis + - sql.js + - sqlite3 - supports-color + - ts-node + - typeorm-aurora-data-api-driver '@sphereon/ssi-sdk-ext.did-utils@0.24.1-unstable.130(ts-node@10.9.2(@swc/core@1.7.40)(@types/node@18.18.8)(typescript@5.5.4))': dependencies: @@ -10721,16 +10737,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@sphereon/ssi-types@0.29.1-unstable.208': - dependencies: - '@sd-jwt/decode': 0.6.1 - '@sphereon/kmp-mdl-mdoc': 0.2.0-SNAPSHOT.22 - debug: 4.3.6 - events: 3.3.0 - jwt-decode: 3.1.2 - transitivePeerDependencies: - - supports-color - '@sphereon/ssi-types@0.30.1(ts-node@10.9.2(@swc/core@1.7.40)(@types/node@18.18.8)(typescript@5.5.4))': dependencies: '@sd-jwt/decode': 0.7.2 From 1743fb1b3a9a63badcd234fa51fd1ddfaf61f4e7 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Thu, 7 Nov 2024 11:42:02 +0100 Subject: [PATCH 3/7] fix: typescript error Signed-off-by: Tom Lanser --- packages/openid4vc/src/shared/utils.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index 1ccc968a54..de0f72588b 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -184,6 +184,7 @@ export function getCreateJwtCallback( 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}'`) } } From dcd810d22a398ac340755d8365302d69ca988eb7 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 18 Nov 2024 12:37:18 +0100 Subject: [PATCH 4/7] feat: Processed feedback and used the right keys for the verifier Signed-off-by: Tom Lanser --- packages/openid4vc/package.json | 2 +- .../OpenId4vcSiopHolderService.ts | 34 +++- .../OpenId4vcSiopHolderServiceOptions.ts | 3 +- .../openid4vc-issuer/OpenId4VcIssuerModule.ts | 6 +- .../router/federationEndpoint.ts | 64 ++++--- .../OpenId4VcSiopVerifierService.ts | 15 +- .../OpenId4VcVerifierModule.ts | 7 +- .../OpenId4VcVerifierModuleConfig.ts | 13 -- .../router/federationEndpoint.ts | 176 ++++++++++-------- packages/openid4vc/src/shared/federation.ts | 9 - packages/openid4vc/src/shared/index.ts | 1 - .../src/shared/models/OpenId4VcJwtIssuer.ts | 3 +- packages/openid4vc/src/shared/utils.ts | 53 ++++-- .../tests/openid4vc-federation.e2e.test.ts | 28 +-- pnpm-lock.yaml | 28 +-- 15 files changed, 225 insertions(+), 217 deletions(-) delete mode 100644 packages/openid4vc/src/shared/federation.ts diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index 1e0e13d9b2..4bba471c05 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -33,7 +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.6", + "@openid-federation/core": "0.1.1-alpha.12", "class-transformer": "^0.5.1", "rxjs": "^7.8.0" }, diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts index f84200ef87..49cf592f7c 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts @@ -4,7 +4,7 @@ import type { OpenId4VcSiopResolveAuthorizationRequestOptions, OpenId4VcSiopResolvedAuthorizationRequest, } 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, @@ -28,7 +28,9 @@ import { injectable, parseDid, MdocDeviceResponse, + JwsService, } from '@credo-ts/core' +import { fetchEntityConfiguration } from '@openid-federation/core' import { OP, ResponseIss, ResponseMode, ResponseType, SupportedVersion, VPTokenLocation } from '@sphereon/did-auth-siop' import { getSphereonVerifiablePresentation } from '../shared/transform' @@ -64,6 +66,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 fetchEntityConfiguration({ + 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, @@ -261,7 +291,7 @@ export class OpenId4VcSiopHolderService { private getOpenIdTokenIssuerFromVerifiablePresentation( verifiablePresentation: VerifiablePresentation - ): OpenId4VcJwtIssuer { + ): Exclude { let openIdTokenIssuer: OpenId4VcJwtIssuer if (verifiablePresentation instanceof W3cJsonLdVerifiablePresentation) { diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts index 40a5d48d69..04aa764610 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,7 +43,7 @@ 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. diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts index 7d7e199a03..083297286b 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts @@ -120,11 +120,7 @@ export class OpenId4VcIssuerModule implements Module { configureCredentialOfferEndpoint(endpointRouter, this.config.credentialOfferEndpoint) configureAccessTokenEndpoint(endpointRouter, this.config.accessTokenEndpoint) configureCredentialEndpoint(endpointRouter, this.config.credentialEndpoint) - - // The federation endpoint is optional - if (this.config.federationEndpoint) { - configureFederationEndpoint(endpointRouter, this.config.federationEndpoint) - } + 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/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts index 15f656a32c..dacab4c2e9 100644 --- a/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts +++ b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts @@ -1,38 +1,28 @@ import type { OpenId4VcIssuanceRequest } from './requestContext' -import type { FederationKeyCallback } from '../../shared/federation' import type { Buffer } from '@credo-ts/core' import type { Router, Response } from 'express' -import { getJwkFromKey } from '@credo-ts/core' +import { Key, getJwkFromKey, KeyType } from '@credo-ts/core' import { createEntityConfiguration } from '@openid-federation/core' import { getRequestContext, sendErrorResponse } from '../../shared/router' import { OpenId4VcIssuerService } from '../OpenId4VcIssuerService' -export interface OpenId4VcSiopFederationEndpointConfig { - /** - * The path at which the credential endpoint should be made available. Note that it will be - * hosted at a subpath to take into account multiple tenants and issuers. - * - * @default /.well-known/openid-federation - */ - endpointPath: string - - // TODO: Not sure about the property name yet. - //TODO: More information is needed than only the key also the client id etc - keyCallback: FederationKeyCallback<{ - issuerId: string - }> -} - // 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, config: OpenId4VcSiopFederationEndpointConfig) { - router.get(config.endpointPath, async (request: OpenId4VcIssuanceRequest, response: Response, next) => { +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) // TODO: Use a type here from sphreon const transformedMetadata = { @@ -50,16 +40,17 @@ export function configureFederationEndpoint(router: Router, config: OpenId4VcSio const now = new Date() const expires = new Date(now.getTime() + 1000 * 60 * 60 * 24) // 1 day from now - const { key } = await config.keyCallback(agentContext, { - issuerId: issuer.issuerId, - }) + // TODO: We need to generate a key and always use that for the entity configuration + + const jwk = getJwkFromKey(federationKey) - const jwk = getJwkFromKey(key) - const kid = 'key-1' + 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, @@ -72,11 +63,23 @@ export function configureFederationEndpoint(router: Router, config: OpenId4VcSio metadata: { federation_entity: issuerDisplay ? { - organization_name: issuerDisplay.organization_name, - logo_uri: issuerDisplay.logo_uri, + organization_name: issuerDisplay.name, + logo_uri: issuerDisplay.logo?.url, } : undefined, - openid_credential_issuer: transformedMetadata, + openid_provider: { + ...transformedMetadata, + 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: { @@ -87,12 +90,15 @@ export function configureFederationEndpoint(router: Router, config: OpenId4VcSio signJwtCallback: ({ toBeSigned }) => agentContext.wallet.sign({ data: toBeSigned as Buffer, - key, + 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) } diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index ef1b7319ea..2980c22b83 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, + clientId: federationClientId, + }) : await openIdTokenIssuerToJwtIssuer(agentContext, options.requestSigner) let clientIdScheme: ClientIdScheme @@ -144,16 +151,12 @@ export class OpenId4VcSiopVerifierService { clientIdScheme = 'did' } else if (jwtIssuer.method === 'custom') { // TODO: Currently used as openid federation, but the jwtIssuer should also be openid-federation - if (!jwtIssuer.options) throw new CredoError(`Custom jwtIssuer must have options defined.`) - if (!jwtIssuer.options.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) - if (typeof jwtIssuer.options.clientId !== 'string') - throw new CredoError(`Custom jwtIssuer's clientId must be a string.`) clientIdScheme = 'entity_id' - clientId = jwtIssuer.options.clientId + clientId = federationClientId } 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.` ) } diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts index 9c81150c2e..264709bfb1 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModule.ts @@ -115,9 +115,10 @@ export class OpenId4VcVerifierModule implements Module { // Configure endpoints configureAuthorizationEndpoint(endpointRouter, this.config.authorizationEndpoint) configureAuthorizationRequestEndpoint(endpointRouter, this.config.authorizationRequestEndpoint) - if (this.config.federationEndpoint) { - configureFederationEndpoint(endpointRouter, this.config.federationEndpoint) - } + + // 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) => { diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts index 0360b3cf14..b2ec763cbc 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcVerifierModuleConfig.ts @@ -1,6 +1,5 @@ import type { OpenId4VcSiopAuthorizationEndpointConfig } from './router/authorizationEndpoint' import type { OpenId4VcSiopAuthorizationRequestEndpointConfig } from './router/authorizationRequestEndpoint' -import type { OpenId4VcSiopFederationEndpointConfig } from './router/federationEndpoint' import type { Optional } from '@credo-ts/core' import type { Router } from 'express' @@ -25,7 +24,6 @@ export interface OpenId4VcVerifierModuleConfigOptions { endpoints?: { authorization?: Optional authorizationRequest?: Optional - federation?: Optional } } @@ -62,15 +60,4 @@ export class OpenId4VcVerifierModuleConfig { endpointPath: userOptions?.endpointPath ?? '/authorize', } } - - public get federationEndpoint(): OpenId4VcSiopFederationEndpointConfig | undefined { - // Use user supplied options, or return defaults. - const userOptions = this.options.endpoints?.federation - if (!userOptions) return undefined - - return { - ...userOptions, - endpointPath: userOptions.endpointPath ?? '/.well-known/openid-federation', - } - } } diff --git a/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts index 0e02d97271..e3fa74fcea 100644 --- a/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts +++ b/packages/openid4vc/src/openid4vc-verifier/router/federationEndpoint.ts @@ -1,9 +1,9 @@ import type { OpenId4VcVerificationRequest } from './requestContext' -import type { FederationKeyCallback } from '../../shared/federation' +import type { Key, Buffer } from '@credo-ts/core' import type { RPRegistrationMetadataPayload } from '@sphereon/did-auth-siop' import type { Router, Response } from 'express' -import { getJwkFromKey, type Buffer } from '@credo-ts/core' +import { getJwkFromKey, KeyType } from '@credo-ts/core' import { createEntityConfiguration } from '@openid-federation/core' import { LanguageTagUtils, removeNullUndefined } from '@sphereon/did-auth-siop' @@ -11,22 +11,6 @@ import { getRequestContext, sendErrorResponse } from '../../shared/router' import { OpenId4VcSiopVerifierService } from '../OpenId4VcSiopVerifierService' import { OpenId4VcVerifierModuleConfig } from '../OpenId4VcVerifierModuleConfig' -// TODO: Think about how we can have multiple issuers over the federation endpoint -export interface OpenId4VcSiopFederationEndpointConfig { - /** - * The path at which the authorization request should be made available. Note that it will be - * hosted at a subpath to take into account multiple tenants and verifiers. - * - * @default /.well-known/openid-federation - */ - endpointPath: string - - // TODO: Not sure about the property name yet. - keyCallback: FederationKeyCallback<{ - verifierId: string - }> -} - // TODO: Add types but this function is originally from the @ // eslint-disable-next-line @typescript-eslint/no-explicit-any const createRPRegistrationMetadataPayload = (opts: any): RPRegistrationMetadataPayload => { @@ -63,71 +47,101 @@ const createRPRegistrationMetadataPayload = (opts: any): RPRegistrationMetadataP return removeNullUndefined(rpRegistrationMetadataPayload) } -export function configureFederationEndpoint(router: Router, config: OpenId4VcSiopFederationEndpointConfig) { - router.get(config.endpointPath, async (request: OpenId4VcVerificationRequest, response: Response, next) => { - const { agentContext, verifier } = getRequestContext(request) - const verifierService = agentContext.dependencyManager.resolve(OpenId4VcSiopVerifierService) - const verifierConfig = agentContext.dependencyManager.resolve(OpenId4VcVerifierModuleConfig) - - try { - const { key } = await config.keyCallback(agentContext, { - verifierId: verifier.verifierId, - }) - - 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(key) - const alg = jwk.supportedSignatureAlgorithms[0] - const kid = 'key-1' - - 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() }], +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', }, - metadata: { - federation_entity: { - organization_name: rpMetadata.client_name, - logo_uri: rpMetadata.logo_uri, + 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 + }, }, - openid_credential_verifier: rpMetadata, }, - }, - signJwtCallback: ({ toBeSigned }) => - agentContext.wallet.sign({ - data: toBeSigned as Buffer, - key, - }), - }) - - response.writeHead(200, { 'Content-Type': 'application/entity-statement+jwt' }).end(entityConfiguration) - } catch (error) { - sendErrorResponse(response, agentContext.config.logger, 500, 'invalid_request', error) + 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() } - - // NOTE: if we don't call next, the agentContext session handler will NOT be called - next() - }) + ) } diff --git a/packages/openid4vc/src/shared/federation.ts b/packages/openid4vc/src/shared/federation.ts deleted file mode 100644 index fefafdb426..0000000000 --- a/packages/openid4vc/src/shared/federation.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { AgentContext, Key } from '@credo-ts/core' - -// TODO: Not really sure about this type yet but it's a start. -export type FederationKeyCallback = Record> = ( - agentContext: AgentContext, - context: TContext -) => Promise<{ - key: Key -}> diff --git a/packages/openid4vc/src/shared/index.ts b/packages/openid4vc/src/shared/index.ts index 3e3b26e4ce..8eacb927b2 100644 --- a/packages/openid4vc/src/shared/index.ts +++ b/packages/openid4vc/src/shared/index.ts @@ -1,3 +1,2 @@ export * from './models' export * from './issuerMetadataUtils' -export * from './federation' diff --git a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts index c3fada42d4..49c296211b 100644 --- a/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts +++ b/packages/openid4vc/src/shared/models/OpenId4VcJwtIssuer.ts @@ -30,9 +30,8 @@ interface OpenId4VcJwtIssuerJwk { jwk: Jwk } -interface OpenId4VcJwtIssuerFederation { +export interface OpenId4VcJwtIssuerFederation { method: 'openid-federation' - clientId: string } export type OpenId4VcJwtIssuer = diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index de0f72588b..d85f823060 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' @@ -10,7 +10,6 @@ import { JwsService, JwtPayload, SignatureSuiteRegistry, - TypedArrayEncoder, X509Service, getDomainFromUrl, getJwkClassFromKeyType, @@ -86,24 +85,35 @@ export function getVerifyJwtCallback( const validTrustChains = await resolveTrustChains({ entityId, trustAnchorEntityIds: trustedEntityIds, - verifyJwtCallback: async ({ data, signature, jwk }) => { - const jws = `${TypedArrayEncoder.toUtf8String(data)}.${TypedArrayEncoder.toBase64URL(signature)}` - + verifyJwtCallback: async ({ jwt, jwk }) => { const res = await jwsService.verifyJws(agentContext, { - jws, + jws: jwt, jwkResolver: () => getJwkFromJson(jwk), }) return res.isValid }, }) + // When the chain is already invalid we can return false immediately + if (validTrustChains.length === 0) return false + + // Pick the first valid trust chain for validation of the leaf entity jwks + const { entityConfiguration } = validTrustChains[0] + // TODO: No support yet for signed jwks and external jwks + const rpSigningKeys = entityConfiguration?.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]), + }) // TODO: There is no check yet for the policies // TODO: When this function results in a `false` it gives a really misleading error message: 'Error verifying the DID Auth Token signature.' - // TODO: I think this is correct but not sure? - return validTrustChains.length > 0 + return res.isValid } throw new Error(`Unsupported jwt verifier method: '${jwtVerifier.method}'`) @@ -155,6 +165,7 @@ export function getCreateJwtCallback( } 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.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) @@ -163,18 +174,27 @@ export function getCreateJwtCallback( const { clientId } = options const entityConfiguration = await fetchEntityConfiguration({ - entityId: clientId as string, - verifyJwtCallback: async ({ data, signature, jwk }) => { - const jws = `${TypedArrayEncoder.toUtf8String(data)}.${TypedArrayEncoder.toBase64URL(signature)}` - const res = await jwsService.verifyJws(agentContext, { jws, jwkResolver: () => getJwkFromJson(jwk) }) + entityId: clientId, + 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(entityConfiguration.jwks.keys[0]) + const jwk = getJwkFromJson(jwks.keys[0]) - // TODO: This gives a weird error when the private key is not available in the wallet + // 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), @@ -191,7 +211,10 @@ export function getCreateJwtCallback( export async function openIdTokenIssuerToJwtIssuer( agentContext: AgentContext, - openId4VcTokenIssuer: Exclude | (OpenId4VcIssuerX5c & { issuer: string }) + openId4VcTokenIssuer: + | Exclude + | (OpenId4VcIssuerX5c & { issuer: string }) + | (OpenId4VcJwtIssuerFederation & { clientId: string }) ): Promise { if (openId4VcTokenIssuer.method === 'did') { const key = await getKeyFromDid(agentContext, openId4VcTokenIssuer.didUrl) diff --git a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts index 7eb667a143..3abd06dc9e 100644 --- a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -7,12 +7,10 @@ import { DidsApi, DifPresentationExchangeService, JwaSignatureAlgorithm, - KeyType, W3cCredential, W3cCredentialSubject, w3cDate, W3cIssuer, - WalletApi, X509Module, X509ModuleConfig, } from '@credo-ts/core' @@ -41,6 +39,8 @@ 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 @@ -124,16 +124,6 @@ describe('OpenId4Vc', () => { } }, }, - federation: { - keyCallback: async (agentContext) => { - const walletApi = agentContext.dependencyManager.resolve(WalletApi) - const key = await walletApi.createKey({ keyType: KeyType.Ed25519 }) - - return { - key, - } - }, - }, }, }), askar: new AskarModule(askarModuleConfig), @@ -159,18 +149,6 @@ describe('OpenId4Vc', () => { { openId4VcVerifier: new OpenId4VcVerifierModule({ baseUrl: verificationBaseUrl, - endpoints: { - federation: { - keyCallback: async (agentContext) => { - const walletApi = agentContext.dependencyManager.resolve(WalletApi) - const key = await walletApi.createKey({ keyType: KeyType.Ed25519 }) - - return { - key, - } - }, - }, - }, }), askar: new AskarModule(askarModuleConfig), tenants: new TenantsModule(), @@ -237,7 +215,6 @@ describe('OpenId4Vc', () => { verifierId: openIdVerifierTenant1.verifierId, requestSigner: { method: 'openid-federation', - clientId: `http://localhost:1234/oid4vp/${openIdVerifierTenant1.verifierId}`, }, presentationExchange: { definition: openBadgePresentationDefinition, @@ -254,7 +231,6 @@ describe('OpenId4Vc', () => { await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ requestSigner: { method: 'openid-federation', - clientId: `http://localhost:1234/oid4vp/${openIdVerifierTenant2.verifierId}`, }, presentationExchange: { definition: universityDegreePresentationDefinition, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1601d97c54..b1b08f440b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -693,8 +693,8 @@ importers: specifier: workspace:* version: link:../core '@openid-federation/core': - specifier: 0.1.1-alpha.6 - version: 0.1.1-alpha.6 + specifier: 0.1.1-alpha.12 + version: 0.1.1-alpha.12 '@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) @@ -2227,8 +2227,8 @@ 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.6': - resolution: {integrity: sha512-ipQtZYtFMUr2BvUmOxlQNVF7eILEq8isoO7rDYwIj4xafifdPAMxznzDxqlu3sHqbOO49PRDRjo9ESsHUfJLfg==} + '@openid-federation/core@0.1.1-alpha.12': + resolution: {integrity: sha512-pGEt0Zz0Y+l0mlayeT5oeHILd0XKmzfpgVJcKM/DgBYaMTa8MdEdVZj6GLpBIqZWHzxoJXM+DB6OeNi9EemAlQ==} '@peculiar/asn1-cms@2.3.13': resolution: {integrity: sha512-joqu8A7KR2G85oLPq+vB+NFr2ro7Ls4ol13Zcse/giPSzUNN0n2k3v8kMpf6QdGUhI13e5SzQYN8AKP8sJ8v4w==} @@ -9809,7 +9809,7 @@ snapshots: dependencies: semver: 7.6.3 - '@openid-federation/core@0.1.1-alpha.6': + '@openid-federation/core@0.1.1-alpha.12': dependencies: buffer: 6.0.3 zod: 3.23.8 @@ -10522,25 +10522,7 @@ snapshots: nanoid: 3.3.7 uint8arrays: 3.1.1 transitivePeerDependencies: - - '@google-cloud/spanner' - - '@sap/hana-client' - - better-sqlite3 - - encoding - - hdb-pool - - ioredis - - mongodb - - mssql - - mysql2 - - oracledb - - pg - - pg-native - - pg-query-stream - - redis - - sql.js - - sqlite3 - supports-color - - ts-node - - typeorm-aurora-data-api-driver '@sphereon/ssi-sdk-ext.did-utils@0.24.1-unstable.130(ts-node@10.9.2(@swc/core@1.7.40)(@types/node@18.18.8)(typescript@5.5.4))': dependencies: From cb6d70ffa2d466e2c283432bc6331adf8fe2a2a5 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 18 Nov 2024 14:51:43 +0100 Subject: [PATCH 5/7] feat: Added more logging and added unhappy tests Signed-off-by: Tom Lanser --- .../OpenId4VcIssuerModuleConfig.ts | 10 -- .../router/federationEndpoint.ts | 26 +++--- .../__tests__/openid4vc-verifier.test.ts | 39 -------- packages/openid4vc/src/shared/utils.ts | 18 +++- .../tests/openid4vc-federation.e2e.test.ts | 92 +++++++++++++++++++ 5 files changed, 118 insertions(+), 67 deletions(-) diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts index 7697cc637c..ae8f7418f1 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModuleConfig.ts @@ -96,14 +96,4 @@ export class OpenId4VcIssuerModuleConfig { endpointPath: userOptions.endpointPath ?? '/offers', } } - - public get federationEndpoint(): OpenId4VcSiopFederationEndpointConfig | undefined { - const userOptions = this.options.endpoints.federation - if (!userOptions) return undefined - - return { - ...userOptions, - endpointPath: userOptions.endpointPath ?? '/.well-known/openid-federation', - } - } } diff --git a/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts index dacab4c2e9..788dea94f3 100644 --- a/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts +++ b/packages/openid4vc/src/openid4vc-issuer/router/federationEndpoint.ts @@ -24,18 +24,6 @@ export function configureFederationEndpoint(router: Router) { }) const issuerMetadata = openId4VcIssuerService.getIssuerMetadata(agentContext, issuer) - // TODO: Use a type here from sphreon - const transformedMetadata = { - 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, - } as const const now = new Date() const expires = new Date(now.getTime() + 1000 * 60 * 60 * 24) // 1 day from now @@ -68,7 +56,19 @@ export function configureFederationEndpoint(router: Router) { } : undefined, openid_provider: { - ...transformedMetadata, + // 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: [ diff --git a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts index 883b5cb8a7..e40ef70579 100644 --- a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts +++ b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts @@ -113,44 +113,5 @@ describe('OpenId4VcVerifier', () => { expect(jwt.payload.iss).toEqual(verifier.did) expect(jwt.payload.sub).toEqual(verifier.did) }) - - it('check openid proof request format (entity id)', async () => { - const openIdVerifier = await verifier.agent.modules.openId4VcVerifier.createVerifier() - const { authorizationRequest, verificationSession } = - await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ - requestSigner: { - method: 'openid-federation', - clientId: 'http://localhost:3001/verifier', - }, - verifierId: openIdVerifier.verifierId, - }) - - expect( - authorizationRequest.startsWith( - `openid://?client_id=${encodeURIComponent(verifier.did)}&request_uri=http%3A%2F%2Fredirect-uri%2F${ - openIdVerifier.verifierId - }%2Fauthorization-requests%2F` - ) - ).toBe(true) - - const jwt = Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt) - - expect(jwt.header.kid) - - expect(jwt.header.kid).toEqual(verifier.kid) - expect(jwt.header.alg).toEqual(SigningAlgo.EDDSA) - expect(jwt.header.typ).toEqual('JWT') - expect(jwt.payload.additionalClaims.scope).toEqual('openid') - expect(jwt.payload.additionalClaims.client_id).toEqual(verifier.did) - expect(jwt.payload.additionalClaims.response_uri).toEqual( - `http://redirect-uri/${openIdVerifier.verifierId}/authorize` - ) - expect(jwt.payload.additionalClaims.response_mode).toEqual('direct_post') - expect(jwt.payload.additionalClaims.nonce).toBeDefined() - expect(jwt.payload.additionalClaims.state).toBeDefined() - expect(jwt.payload.additionalClaims.response_type).toEqual('id_token') - expect(jwt.payload.iss).toEqual(verifier.did) - expect(jwt.payload.sub).toEqual(verifier.did) - }) }) }) diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index d85f823060..fb6bbf3ebe 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -60,6 +60,8 @@ export function getVerifyJwtCallback( agentContext: AgentContext, options: VerifyJwtCallbackOptions = {} ): VerifyJwtCallback { + const logger = agentContext.config.logger + return async (jwtVerifier, jwt) => { const jwsService = agentContext.dependencyManager.resolve(JwsService) @@ -79,8 +81,10 @@ export function getVerifyJwtCallback( if (jwtVerifier.method === 'openid-federation') { const { entityId } = jwtVerifier const trustedEntityIds = options.federation?.trustedEntityIds - if (!trustedEntityIds) - throw new CredoError('No trusted entity ids provided but is required for the openid-federation method.') + if (!trustedEntityIds) { + logger.error('No trusted entity ids provided but is required for the "openid-federation" method.') + return false + } const validTrustChains = await resolveTrustChains({ entityId, @@ -95,7 +99,10 @@ export function getVerifyJwtCallback( }, }) // When the chain is already invalid we can return false immediately - if (validTrustChains.length === 0) return false + 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 { entityConfiguration } = validTrustChains[0] @@ -108,11 +115,12 @@ export function getVerifyJwtCallback( 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 - // TODO: When this function results in a `false` it gives a really misleading error message: 'Error verifying the DID Auth Token signature.' - return res.isValid } diff --git a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts index 3abd06dc9e..f0f7f874b4 100644 --- a/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc-federation.e2e.test.ts @@ -424,4 +424,96 @@ describe('OpenId4Vc', () => { ], }) }) + + 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.` + ) + }) }) From b06c546e59e94180ebfd144ef4503bdfb67a4d37 Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 18 Nov 2024 15:29:32 +0100 Subject: [PATCH 6/7] chore: Made some things more logic Signed-off-by: Tom Lanser --- .../OpenId4VcSiopVerifierService.ts | 14 +++++++++----- packages/openid4vc/src/shared/utils.ts | 18 ++++++++++++------ 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index 2980c22b83..50781786e7 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -118,7 +118,7 @@ export class OpenId4VcSiopVerifierService { : options.requestSigner.method === 'openid-federation' ? await openIdTokenIssuerToJwtIssuer(agentContext, { ...options.requestSigner, - clientId: federationClientId, + entityId: federationClientId, }) : await openIdTokenIssuerToJwtIssuer(agentContext, options.requestSigner) @@ -150,10 +150,14 @@ export class OpenId4VcSiopVerifierService { clientId = jwtIssuer.didUrl.split('#')[0] clientIdScheme = 'did' } else if (jwtIssuer.method === 'custom') { - // TODO: Currently used as openid federation, but the jwtIssuer should also be openid-federation - - clientIdScheme = 'entity_id' - clientId = federationClientId + 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', 'x5c' and 'custom' are supported.` diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index fb6bbf3ebe..5eea5dd8bc 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -176,13 +176,18 @@ export function getCreateJwtCallback( // 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.clientId) throw new CredoError(`Custom jwtIssuer must have clientId defined.`) - if (typeof options.clientId !== 'string') throw new CredoError(`Custom jwtIssuer's clientId must be a string.`) + 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 { clientId } = options + const { entityId } = options const entityConfiguration = await fetchEntityConfiguration({ - entityId: clientId, + entityId, verifyJwtCallback: async ({ jwt, jwk }) => { const res = await jwsService.verifyJws(agentContext, { jws: jwt, jwkResolver: () => getJwkFromJson(jwk) }) return res.isValid @@ -222,7 +227,7 @@ export async function openIdTokenIssuerToJwtIssuer( openId4VcTokenIssuer: | Exclude | (OpenId4VcIssuerX5c & { issuer: string }) - | (OpenId4VcJwtIssuerFederation & { clientId: string }) + | (OpenId4VcJwtIssuerFederation & { entityId: string }) ): Promise { if (openId4VcTokenIssuer.method === 'did') { const key = await getKeyFromDid(agentContext, openId4VcTokenIssuer.didUrl) @@ -283,7 +288,8 @@ export async function openIdTokenIssuerToJwtIssuer( return { method: 'custom', options: { - clientId: openId4VcTokenIssuer.clientId, + method: 'openid-federation', + entityId: openId4VcTokenIssuer.entityId, }, } } From 06999df526167c3a3c0bd8b48a405a4042f7512d Mon Sep 17 00:00:00 2001 From: Tom Lanser Date: Mon, 18 Nov 2024 16:30:07 +0100 Subject: [PATCH 7/7] feat: Holder side api for getting more context information Signed-off-by: Tom Lanser --- packages/openid4vc/package.json | 2 +- .../openid4vc-holder/OpenId4VcHolderApi.ts | 5 ++++ .../OpenId4VciHolderService.ts | 2 -- .../OpenId4vcSiopHolderService.ts | 30 +++++++++++++++++-- .../OpenId4vcSiopHolderServiceOptions.ts | 5 ++++ packages/openid4vc/src/shared/utils.ts | 4 +-- pnpm-lock.yaml | 10 +++---- 7 files changed, 46 insertions(+), 12 deletions(-) diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index 4bba471c05..09d3213fa9 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -33,7 +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.12", + "@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 754f029238..4b4db015d2 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VcHolderApi.ts @@ -11,6 +11,7 @@ import type { import type { OpenId4VcSiopAcceptAuthorizationRequestOptions, OpenId4VcSiopResolveAuthorizationRequestOptions, + OpenId4VcSiopResolveTrustChainsOptions, } from './OpenId4vcSiopHolderServiceOptions' import { injectable, AgentContext } from '@credo-ts/core' @@ -187,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/OpenId4VciHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts index c111f30492..e46bed9a18 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts @@ -871,6 +871,4 @@ export class OpenId4VciHolderService { return jws } } - - // TODO: Add a function for resolving the entity statement. Which will be used in the holder to verify the entity statement and to show to the user } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts index 49cf592f7c..413824a8a8 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts @@ -3,6 +3,7 @@ import type { OpenId4VcSiopGetOpenIdProviderOptions, OpenId4VcSiopResolveAuthorizationRequestOptions, OpenId4VcSiopResolvedAuthorizationRequest, + OpenId4VcSiopResolveTrustChainsOptions, } from './OpenId4vcSiopHolderServiceOptions' import type { OpenId4VcJwtIssuer, OpenId4VcJwtIssuerFederation } from '../shared' import type { AgentContext, JwkJson, VerifiablePresentation } from '@credo-ts/core' @@ -30,7 +31,10 @@ import { MdocDeviceResponse, JwsService, } from '@credo-ts/core' -import { fetchEntityConfiguration } from '@openid-federation/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' @@ -74,7 +78,7 @@ export class OpenId4VcSiopHolderService { const jwsService = agentContext.dependencyManager.resolve(JwsService) - const entityConfiguration = await fetchEntityConfiguration({ + const entityConfiguration = await federationFetchEntityConfiguration({ entityId: clientId, verifyJwtCallback: async ({ jwt, jwk }) => { const res = await jwsService.verifyJws(agentContext, { @@ -434,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 04aa764610..559e690210 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderServiceOptions.ts @@ -77,3 +77,8 @@ export interface OpenId4VcSiopGetOpenIdProviderOptions { trustedEntityIds?: string[] } } + +export interface OpenId4VcSiopResolveTrustChainsOptions { + entityId: string + trustAnchorEntityIds: [string, ...string[]] +} diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index 5eea5dd8bc..5c868f6732 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -105,9 +105,9 @@ export function getVerifyJwtCallback( } // Pick the first valid trust chain for validation of the leaf entity jwks - const { entityConfiguration } = validTrustChains[0] + const { leafEntityConfiguration } = validTrustChains[0] // TODO: No support yet for signed jwks and external jwks - const rpSigningKeys = entityConfiguration?.metadata?.openid_relying_party?.jwks?.keys + 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.') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b1b08f440b..04f91bfb38 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -693,8 +693,8 @@ importers: specifier: workspace:* version: link:../core '@openid-federation/core': - specifier: 0.1.1-alpha.12 - version: 0.1.1-alpha.12 + 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) @@ -2227,8 +2227,8 @@ 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.12': - resolution: {integrity: sha512-pGEt0Zz0Y+l0mlayeT5oeHILd0XKmzfpgVJcKM/DgBYaMTa8MdEdVZj6GLpBIqZWHzxoJXM+DB6OeNi9EemAlQ==} + '@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==} @@ -9809,7 +9809,7 @@ snapshots: dependencies: semver: 7.6.3 - '@openid-federation/core@0.1.1-alpha.12': + '@openid-federation/core@0.1.1-alpha.13': dependencies: buffer: 6.0.3 zod: 3.23.8