UNPKG

@sphereon/ssi-sdk.ebsi-support

Version:

294 lines (273 loc) • 11.8 kB
import { OpenID4VCIClient } from '@sphereon/oid4vci-client' import { Alg, AuthorizationDetailsV1_0_15, AuthorizationRequestOpts, AuthzFlowType, CredentialConfigurationSupported, CredentialConfigurationSupportedV1_0_15, getJson, getTypesFromCredentialSupported, ProofOfPossessionCallbacks, } from '@sphereon/oid4vci-common' import { getAuthenticationKey, SupportedDidMethodEnum } from '@sphereon/ssi-sdk-ext.did-utils' import { ManagedIdentifierDidResult } from '@sphereon/ssi-sdk-ext.identifier-resolution' import { calculateJwkThumbprintForKey, signatureAlgorithmFromKey } from '@sphereon/ssi-sdk-ext.key-utils' import { IssuanceOpts, OID4VCICallbackStateListener, OID4VCIMachineInterpreter, OID4VCIMachineState, OID4VCIMachineStates, PrepareStartArgs, signCallback, } from '@sphereon/ssi-sdk.oid4vci-holder' import { OID4VPCallbackStateListener, Siopv2MachineInterpreter, Siopv2MachineState, Siopv2MachineStates, Siopv2OID4VPLinkHandler, } from '@sphereon/ssi-sdk.siopv2-oid4vp-op-auth' import { _ExtendedIKey } from '@veramo/utils' import { waitFor } from 'xstate/lib/waitFor.js' import { logger } from '../index' import { AttestationResult, CreateAttestationAuthRequestURLArgs, EbsiEnvironment, GetAttestationArgs, IRequiredContext } from '../types/IEbsiSupport' import { addContactCallback, authorizationCodeUrlCallback, handleErrorCallback, reviewCredentialsCallback, selectCredentialsCallback, siopDoneCallback, } from './AttestationHeadlessCallbacks' import { getEbsiApiBaseUrl } from './index' export interface AttestationAuthRequestUrlResult extends Omit<Required<PrepareStartArgs>, 'issuanceOpt'> { issuanceOpt?: IssuanceOpts authorizationCodeURL: string identifier: ManagedIdentifierDidResult authKey: _ExtendedIKey } /** * Method to generate an authz url for getting attestation credentials from a (R)TAO on EBSI using a cloud/service wallet * * This method can be used standalone. But it can also be used as input for the `oid4vciHolderStart` agent method, * to start a OID4VCI holder flow. * * @param opts * @param context */ export const ebsiCreateAttestationAuthRequestURL = async ( { clientId: clientIdArg, credentialIssuer, credentialType, idOpts, redirectUri, requestObjectOpts, formats = ['jwt_vc', 'jwt_vc_json'], }: CreateAttestationAuthRequestURLArgs, context: IRequiredContext, ): Promise<AttestationAuthRequestUrlResult> => { logger.info(`create attestation ${credentialType} auth req URL for ${clientIdArg} and issuer ${credentialIssuer}`) const resolution = await context.agent.identifierManagedGetByDid(idOpts) const identifier = resolution.identifier if (identifier.provider !== 'did:ebsi' && identifier.provider !== 'did:key') { throw Error( `EBSI only supports did:key for natural persons and did:ebsi for legal persons. Provider: ${identifier.provider}, did: ${identifier.did}`, ) } // This only works if the DID is actually registered, otherwise use our internal KMS; // that is why the offline argument is passed in when type is Verifiable Auth to Onboard, as no DID is present at that point yet // We are getting the ES256 key here, as that is the one needed for auth in EBSI const authKey = await getAuthenticationKey( { identifier, offlineWhenNoDIDRegistered: credentialType === 'VerifiableAuthorisationToOnboard', noVerificationMethodFallback: true, keyType: 'Secp256r1', }, context, ) const kid = authKey.meta?.jwkThumbprint ?? calculateJwkThumbprintForKey({ key: authKey }) const clientId = clientIdArg ?? identifier.did const vciClient = await OpenID4VCIClient.fromCredentialIssuer({ credentialIssuer, kid, clientId, createAuthorizationRequestURL: false, // We will do that down below retrieveServerMetadata: true, }) const allMatches = {} as Record<string, CredentialConfigurationSupportedV1_0_15> | Array<CredentialConfigurationSupported> // vciClient.getCredentialsSupported(format) FIXME SSISDK-40 let arrayMatches: Array<CredentialConfigurationSupported> if (Array.isArray(allMatches)) { arrayMatches = allMatches } else { arrayMatches = Object.entries(allMatches).map(([id, supported]) => { supported.id = id return supported }) } const supportedConfigurations = arrayMatches .filter((supported) => getTypesFromCredentialSupported(supported, { filterVerifiableCredential: false }).includes(credentialType)) .filter((supported) => (supported.format === 'jwt_vc' || supported.format === 'jwt_vc_json') && formats.includes(supported.format)) if (supportedConfigurations.length === 0) { throw Error(`Could not find '${credentialType}' with format(s) '${formats.join(',')}' in list of supported types for issuer: ${credentialIssuer}`) } const authorizationDetails = supportedConfigurations.map((supported) => { const credential_configuration_id = supported.id as string if (!credential_configuration_id) { throw Error(`Credential configuration id missing for credential type: ${credentialType}`) } return { type: 'openid_credential', credential_configuration_id, credential_identifiers: getTypesFromCredentialSupported(supported), } satisfies AuthorizationDetailsV1_0_15 }) const signCallbacks: ProofOfPossessionCallbacks = requestObjectOpts.signCallbacks ?? { signCallback: signCallback(idOpts, context), } const authorizationRequestOpts = { redirectUri, clientId, authorizationDetails, requestObjectOpts: { ...requestObjectOpts, signCallbacks, kid: requestObjectOpts.kid ?? kid, }, } satisfies AuthorizationRequestOpts // todo: Do we really need to do this, or can we just set the create option to true at this point? We are passing in the authzReq opts const authorizationCodeURL = await vciClient.createAuthorizationRequestUrl({ authorizationRequest: authorizationRequestOpts, }) logger.info(`create attestation ${credentialType} auth req URL for ${clientIdArg} and issuer ${credentialIssuer}, result: ${authorizationCodeURL}`) const jwaAlg = await signatureAlgorithmFromKey({ key: authKey }) if (!(jwaAlg in Alg)) { return Promise.reject(Error(`${jwaAlg} is not supported`)) } // @ts-ignore const alg: Alg = Alg[jwaAlg] return { requestData: { createAuthorizationRequestURL: false, flowType: AuthzFlowType.AUTHORIZATION_CODE_FLOW, uri: credentialIssuer, existingClientState: JSON.parse(await vciClient.exportState()), }, accessTokenOpts: { clientOpts: { alg, clientId, kid, signCallbacks, clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', }, }, authorizationRequestOpts, authorizationCodeURL, identifier: resolution, authKey, didMethodPreferences: [SupportedDidMethodEnum.DID_EBSI, SupportedDidMethodEnum.DID_KEY], } } export const ebsiGetAttestationInterpreter = async ( { clientId, authReqResult, walletType }: Omit<GetAttestationArgs, 'opts'>, context: IRequiredContext, ): Promise<OID4VCIMachineInterpreter> => { const identifier = authReqResult.identifier const vciStateCallbacks = new Map<OID4VCIMachineStates, (oid4vciMachine: OID4VCIMachineInterpreter, state: OID4VCIMachineState) => Promise<void>>() const vpStateCallbacks = new Map<Siopv2MachineStates, (oid4vpMachine: Siopv2MachineInterpreter, state: Siopv2MachineState) => Promise<void>>() const oid4vciMachine = await context.agent.oid4vciHolderGetMachineInterpreter({ ...authReqResult, issuanceOpt: { identifier, supportedPreferredDidMethod: SupportedDidMethodEnum.DID_EBSI, kid: authReqResult.authKey.meta?.jwkThumbprint ?? authReqResult.authKey.kid, }, clientOpts: { clientAssertionType: 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer', kid: authReqResult.authKey.meta?.jwkThumbprint ?? authReqResult.authKey.kid, clientId, }, didMethodPreferences: [SupportedDidMethodEnum.DID_EBSI, SupportedDidMethodEnum.DID_KEY], stateNavigationListener: OID4VCICallbackStateListener(vciStateCallbacks), walletType: walletType ?? 'NATURAL_PERSON', }) const vpLinkHandler = new Siopv2OID4VPLinkHandler({ protocols: ['openid:'], // @ts-ignore context, noStateMachinePersistence: true, stateNavigationListener: OID4VPCallbackStateListener(vpStateCallbacks), }) vpStateCallbacks .set(Siopv2MachineStates.done, siopDoneCallback({ oid4vciMachine }, context)) .set(Siopv2MachineStates.handleError, handleErrorCallback(context)) .set(Siopv2MachineStates.error, handleErrorCallback(context)) vciStateCallbacks .set(OID4VCIMachineStates.handleError, handleErrorCallback(context)) .set(OID4VCIMachineStates.addContact, addContactCallback(context)) .set(OID4VCIMachineStates.selectCredentials, selectCredentialsCallback(context)) .set( OID4VCIMachineStates.initiateAuthorizationRequest, authorizationCodeUrlCallback( { authReqResult, vpLinkHandler, }, context, ), ) .set(OID4VCIMachineStates.reviewCredentials, reviewCredentialsCallback(context)) return oid4vciMachine.interpreter } export const ebsiGetAttestation = async ( { clientId, authReqResult, opts = { timeout: 30_000 } }: GetAttestationArgs, agentContext: IRequiredContext, ): Promise<AttestationResult> => { logger.info(`Getting EBSI attestation for ${authReqResult.identifier.did} and ${clientId}`) const interpreter = await ebsiGetAttestationInterpreter({ clientId, authReqResult }, agentContext) const state = await waitFor(interpreter.start(), (state) => state.matches('done') || state.matches('handleError') || state.matches('error'), { timeout: opts.timeout ?? 30_000, }) const { contactAlias, contact, credentialBranding, issuanceOpt, error, credentialsToAccept } = state.context if (state.matches('handleError') || state.matches('error')) { logger.error(JSON.stringify(state.context.error)) throw Error(JSON.stringify(state.context.error)) } const result = { contactAlias, contact: contact!, credentialBranding, identifier: issuanceOpt?.identifier ? ((await agentContext.agent.identifierManagedGet(issuanceOpt.identifier)) as ManagedIdentifierDidResult) : authReqResult.identifier, error, credentials: credentialsToAccept, } logger.info(`EBSI attestation for ${authReqResult.identifier.did} and ${clientId}`, result) return result } /** * Normally you would use the browser to let the user make this call in the front channel, * however EBSI mainly uses mocks at present, and we want to be able to test as well */ export const ebsiAuthRequestExecution = async (authRequestResult: AttestationAuthRequestUrlResult, opts?: {}) => { const { requestData, authorizationCodeURL } = authRequestResult const vciClient = await OpenID4VCIClient.fromState({ state: requestData?.existingClientState! }) logger.debug(`URL: ${authorizationCodeURL}, according to client: ${vciClient.authorizationURL}`) const authResponse = await getJson<any>(authorizationCodeURL) const location: string | null = authResponse.origResponse.headers.get('location') logger.debug(`LOCATION: ${location}`) } export const ebsiGetIssuer = ({ credentialIssuer, environment = 'pilot' }: { credentialIssuer?: string; environment?: EbsiEnvironment }): string => { if (credentialIssuer) { return credentialIssuer } if (environment !== 'pilot') { return `${getEbsiApiBaseUrl({ environment, version: 'v3' })}/issuer-mock` } throw Error(`EBSI environment ${environment} needs explicit credential issuer`) }