UNPKG

@sphereon/did-auth-siop

Version:

Self Issued OpenID V2 (SIOPv2) and OpenID 4 Verifiable Presentations (OID4VP)

394 lines (356 loc) 15.4 kB
import { EventEmitter } from 'events' import { jarmAuthResponseSend, JarmClientMetadata, jarmMetadataValidate, JarmServerMetadata } from '@sphereon/jarm' import { JwtIssuer, uuidv4 } from '@sphereon/oid4vc-common' import { IIssuerId } from '@sphereon/ssi-types' import { AuthorizationRequest, URI, VerifyAuthorizationRequestOpts } from '../authorization-request' import { mergeVerificationOpts } from '../authorization-request/Opts' import { AuthorizationResponse, AuthorizationResponseOpts, AuthorizationResponseWithCorrelationId, DcqlResponseOpts, PresentationExchangeResponseOpts, } from '../authorization-response' import { encodeJsonAsURI, post } from '../helpers' import { extractJwksFromJwksMetadata, JwksMetadataParams } from '../helpers/ExtractJwks' import { authorizationRequestVersionDiscovery } from '../helpers/SIOPSpecVersion' import { AuthorizationEvent, AuthorizationEvents, AuthorizationResponsePayload, ContentType, ParsedAuthorizationRequestURI, RegisterEventListener, RequestObjectPayload, ResponseIss, ResponseMode, RPRegistrationMetadataPayload, SIOPErrors, SupportedVersion, UrlEncodingFormat, Verification, VerifiedAuthorizationRequest, } from '../types' import { OPBuilder } from './OPBuilder' import { createResponseOptsFromBuilderOrExistingOpts, createVerifyRequestOptsFromBuilderOrExistingOpts } from './Opts' // The OP publishes the formats it supports using the vp_formats_supported metadata parameter as defined above in its "openid-configuration". export class OP { private readonly _createResponseOptions: AuthorizationResponseOpts private readonly _verifyRequestOptions: Partial<VerifyAuthorizationRequestOpts> private readonly _eventEmitter?: EventEmitter private constructor(opts: { builder?: OPBuilder; responseOpts?: AuthorizationResponseOpts; verifyOpts?: VerifyAuthorizationRequestOpts }) { this._createResponseOptions = { ...createResponseOptsFromBuilderOrExistingOpts(opts) } this._verifyRequestOptions = { ...createVerifyRequestOptsFromBuilderOrExistingOpts(opts) } this._eventEmitter = opts.builder?.eventEmitter } /** * This method tries to infer the SIOP specs version based on the request payload. * If the version cannot be inferred or is not supported it throws an exception. * This method needs to be called to ensure the OP can handle the request * @param requestJwtOrUri * @param requestOpts */ public async verifyAuthorizationRequest( requestJwtOrUri: string | URI, requestOpts?: { correlationId?: string; verification?: Verification }, ): Promise<VerifiedAuthorizationRequest> { const correlationId = requestOpts?.correlationId || uuidv4() let authorizationRequest: AuthorizationRequest try { authorizationRequest = await AuthorizationRequest.fromUriOrJwt(requestJwtOrUri) await this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_RECEIVED_SUCCESS, { correlationId, subject: authorizationRequest }) } catch (error) { if (error instanceof Error) { await this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_RECEIVED_FAILED, { correlationId, subject: requestJwtOrUri, error, }) } throw error } try { const verifiedAuthorizationRequest = await authorizationRequest.verify( this.newVerifyAuthorizationRequestOpts({ ...requestOpts, correlationId }), ) await this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_VERIFIED_SUCCESS, { correlationId, subject: verifiedAuthorizationRequest.authorizationRequest, }) return verifiedAuthorizationRequest } catch (error) { await this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_VERIFIED_FAILED, { correlationId, subject: authorizationRequest, error, }) throw error } } public async createAuthorizationResponse( verifiedAuthorizationRequest: VerifiedAuthorizationRequest, responseOpts: { jwtIssuer?: JwtIssuer version?: SupportedVersion correlationId?: string audience?: string issuer?: ResponseIss | string verification?: Verification presentationExchange?: PresentationExchangeResponseOpts dcqlResponse?: DcqlResponseOpts isFirstParty?: boolean }, ): Promise<AuthorizationResponseWithCorrelationId> { if ( verifiedAuthorizationRequest.correlationId && responseOpts?.correlationId && verifiedAuthorizationRequest.correlationId !== responseOpts.correlationId ) { throw new Error( `Request correlation id ${verifiedAuthorizationRequest.correlationId} is different from option correlation id ${responseOpts.correlationId}`, ) } let version = responseOpts?.version const rpSupportedVersions = authorizationRequestVersionDiscovery(await verifiedAuthorizationRequest.authorizationRequest.mergedPayloads()) if (version && rpSupportedVersions.length > 0 && !rpSupportedVersions.includes(version)) { throw Error(`RP does not support spec version ${version}, supported versions: ${rpSupportedVersions.toString()}`) } else if (!version) { version = rpSupportedVersions.reduce( (previous, current) => (current.valueOf() > previous.valueOf() ? current : previous), SupportedVersion.SIOPv2_ID1, ) } const correlationId = responseOpts?.correlationId ?? verifiedAuthorizationRequest.correlationId ?? uuidv4() try { // IF using DIRECT_POST, the response_uri takes precedence over the redirect_uri let responseUri = verifiedAuthorizationRequest.responseURI if (verifiedAuthorizationRequest.payload?.response_mode === ResponseMode.DIRECT_POST) { responseUri = verifiedAuthorizationRequest.authorizationRequestPayload.response_uri ?? responseUri } const response = await AuthorizationResponse.fromVerifiedAuthorizationRequest( verifiedAuthorizationRequest, this.newAuthorizationResponseOpts({ ...responseOpts, version, correlationId, }), verifiedAuthorizationRequest.verifyOpts, ) void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_CREATE_SUCCESS, { correlationId, subject: response, }) return { correlationId, response, responseURI: responseUri } // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (error: any) { void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_CREATE_FAILED, { correlationId, subject: verifiedAuthorizationRequest.authorizationRequest, error, }) throw error } } public static async extractEncJwksFromClientMetadata(clientMetadata: JwksMetadataParams) { // TODO: Currently no mechanisms are in place to deal with multiple 'enc' keys in the client metadata. // TODO: Maybe we should return all 'enc' keys in the client metadata. In addition the JWKS from the jwks_uri are not fetched if jwks are present. // TODO: Is that the expected behavior? const jwks = await extractJwksFromJwksMetadata(clientMetadata) const encryptionJwk = jwks?.keys.find((key) => key.use === 'enc') if (!encryptionJwk) { throw new Error('No encryption jwk could be extracted from the client metadata.') } return encryptionJwk } // TODO SK Can you please put some documentation on it? public async submitAuthorizationResponse( authorizationResponse: AuthorizationResponseWithCorrelationId, createJarmResponse?: (opts: { authorizationResponsePayload: AuthorizationResponsePayload requestObjectPayload: RequestObjectPayload clientMetadata: JwksMetadataParams }) => Promise<{ response: string }>, ): Promise<Response> { const { correlationId, response } = authorizationResponse if (!correlationId) { throw Error('No correlation Id provided') } const isJarmResponseMode = (responseMode: string): responseMode is 'jwt' | 'direct_post.jwt' | 'query.jwt' | 'fragment.jwt' => { return responseMode === ResponseMode.DIRECT_POST_JWT || responseMode === ResponseMode.QUERY_JWT || responseMode === ResponseMode.FRAGMENT_JWT } const requestObjectPayload = await response.authorizationRequest.requestObject?.getPayload() const responseMode = requestObjectPayload?.response_mode ?? response.options?.responseMode if ( !response || (response.options?.responseMode && !( responseMode === ResponseMode.POST || responseMode === ResponseMode.FORM_POST || responseMode === ResponseMode.DIRECT_POST || isJarmResponseMode(responseMode) )) ) { throw new Error(SIOPErrors.BAD_PARAMS) } const payload = response.payload const idToken = await response.idToken?.payload() const responseUri = authorizationResponse.responseURI ?? idToken?.aud if (!responseUri) { throw Error('No response URI present') } if (isJarmResponseMode(responseMode)) { if (responseMode !== ResponseMode.DIRECT_POST_JWT) { throw new Error('Only direct_post.jwt response mode is supported for JARM at the moment.') } let responseType: 'id_token' | 'id_token vp_token' | 'vp_token' if (idToken && payload.vp_token) { responseType = 'id_token vp_token' } else if (idToken) { responseType = 'id_token' } else if (payload.vp_token) { responseType = 'vp_token' } else { throw new Error('No id_token or vp_token present in the response payload') } const clientMetadata = authorizationResponse.response.authorizationRequest.options?.clientMetadata ?? requestObjectPayload.client_metadata const { response } = await createJarmResponse({ requestObjectPayload, authorizationResponsePayload: payload, clientMetadata, }) try { const jarmResponse = await jarmAuthResponseSend({ authRequestParams: { response_uri: responseUri, response_mode: responseMode, response_type: responseType, }, authResponse: response, state: requestObjectPayload.state, }) void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_SENT_SUCCESS, { correlationId, subject: response }) return jarmResponse } catch (error) { void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_SENT_FAILED, { correlationId, subject: response, error, }) throw error } } const authResponseAsURI = encodeJsonAsURI(payload, { arraysWithIndex: ['presentation_submission'] }) try { const result = await post(responseUri, authResponseAsURI, { contentType: ContentType.FORM_URL_ENCODED, exceptionOnHttpErrorStatus: true }) await this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_SENT_SUCCESS, { correlationId, subject: response }) return result.origResponse } catch (error) { await this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_SENT_FAILED, { correlationId, subject: response, error: error as Error }) throw error } } /** * Create an Authentication Request Payload from a URI string * * @param encodedUri * @param rpRegistrationMetadata */ public async parseAuthorizationRequestURI( encodedUri: string, rpRegistrationMetadata?: RPRegistrationMetadataPayload, ): Promise<ParsedAuthorizationRequestURI> { const { scheme, requestObjectJwt, authorizationRequestPayload, registrationMetadata } = await URI.parseAndResolve( encodedUri, rpRegistrationMetadata, ) return { encodedUri, encodingFormat: UrlEncodingFormat.FORM_URL_ENCODED, scheme: scheme, requestObjectJwt, authorizationRequestPayload, registration: registrationMetadata, } } private newAuthorizationResponseOpts(opts: { correlationId: string version?: SupportedVersion issuer?: IIssuerId | ResponseIss audience?: string presentationExchange?: PresentationExchangeResponseOpts dcqlResponse?: DcqlResponseOpts }): AuthorizationResponseOpts { const version = opts.version ?? this._createResponseOptions.version let issuer = opts.issuer ?? this._createResponseOptions?.registration?.issuer if (version === SupportedVersion.JWT_VC_PRESENTATION_PROFILE_v1) { issuer = ResponseIss.JWT_VC_PRESENTATION_V1 } else if (version === SupportedVersion.SIOPv2_ID1) { issuer = ResponseIss.SELF_ISSUED_V2 } if (!issuer) { throw Error(`No issuer value present. Either use IDv1, JWT VC Presentation profile version, or provide a DID as issuer value`) } // We are taking the whole presentationExchange object from a certain location const presentationExchange = opts.presentationExchange ?? this._createResponseOptions.presentationExchange const dcqlQuery = opts.dcqlResponse ?? this._createResponseOptions.dcqlResponse const responseURI = opts.audience ?? this._createResponseOptions.responseURI return { ...this._createResponseOptions, ...opts, ...(presentationExchange && { presentationExchange }), ...(dcqlQuery && { dcqlQuery }), registration: { ...this._createResponseOptions?.registration, issuer }, responseURI, responseURIType: this._createResponseOptions.responseURIType ?? (version < SupportedVersion.SIOPv2_D12_OID4VP_D18 && responseURI ? 'redirect_uri' : undefined), } } private newVerifyAuthorizationRequestOpts(requestOpts: { correlationId: string; verification?: Verification }): VerifyAuthorizationRequestOpts { const verification: VerifyAuthorizationRequestOpts = { ...this._verifyRequestOptions, verifyJwtCallback: this._verifyRequestOptions.verifyJwtCallback, ...requestOpts, verification: mergeVerificationOpts(this._verifyRequestOptions, requestOpts), correlationId: requestOpts.correlationId, } return verification } private async emitEvent( type: AuthorizationEvents, payload: { correlationId: string subject: AuthorizationRequest | AuthorizationResponse | string | URI error?: Error }, ): Promise<void> { if (this._eventEmitter) { this._eventEmitter.emit(type, new AuthorizationEvent(payload)) } } public addEventListener(register: RegisterEventListener) { if (!this._eventEmitter) { throw Error('Cannot add listeners if no event emitter is available') } const events = Array.isArray(register.event) ? register.event : [register.event] for (const event of events) { this._eventEmitter.addListener(event, register.listener) } } public static fromOpts(responseOpts: AuthorizationResponseOpts, verifyOpts: VerifyAuthorizationRequestOpts): OP { return new OP({ responseOpts, verifyOpts }) } public static builder() { return new OPBuilder() } get createResponseOptions(): AuthorizationResponseOpts { return this._createResponseOptions } get verifyRequestOptions(): Partial<VerifyAuthorizationRequestOpts> { return this._verifyRequestOptions } public static validateJarmMetadata(input: { client_metadata: JarmClientMetadata; server_metadata: Partial<JarmServerMetadata> }) { return jarmMetadataValidate(input) } }