@sphereon/did-auth-siop
Version:
Self Issued OpenID V2 (SIOPv2) and OpenID 4 Verifiable Presentations (OID4VP)
485 lines (443 loc) • 19.6 kB
text/typescript
import { EventEmitter } from 'events'
import {
jarmAuthResponseDirectPostJwtValidate,
JarmAuthResponseParams,
JarmDirectPostJwtAuthResponseValidationContext,
JarmDirectPostJwtResponseParams,
} from '@sphereon/jarm'
import { decodeProtectedHeader, JwtIssuer } from '@sphereon/oid4vc-common'
import { Hasher } from '@sphereon/ssi-types'
import { DcqlQuery } from 'dcql'
import {
AuthorizationRequest,
ClaimPayloadCommonOpts,
CreateAuthorizationRequestOpts,
PropertyTarget,
RequestObjectPayloadOpts,
RequestPropertyWithTargets,
URI,
} from '../authorization-request'
import { mergeVerificationOpts } from '../authorization-request/Opts'
import {
AuthorizationResponse,
extractPresentationsFromDcqlVpToken,
extractPresentationsFromVpToken,
PresentationDefinitionWithLocation,
VerifyAuthorizationResponseOpts,
} from '../authorization-response'
import { base64urlToString, getNonce, getState } from '../helpers'
import {
AuthorizationEvent,
AuthorizationEvents,
AuthorizationResponsePayload,
DecryptCompact,
PassBy,
RegisterEventListener,
RequestObjectPayload,
ResponseURIType,
SIOPErrors,
SupportedVersion,
Verification,
VerifiedAuthorizationResponse,
} from '../types'
import { createRequestOptsFromBuilderOrExistingOpts, createVerifyResponseOptsFromBuilderOrExistingOpts, isTargetOrNoTargets } from './Opts'
import { RPBuilder } from './RPBuilder'
import { IRPSessionManager } from './types'
export class RP {
get sessionManager(): IRPSessionManager {
return this._sessionManager
}
private readonly _createRequestOptions: CreateAuthorizationRequestOpts
private readonly _verifyResponseOptions: Partial<VerifyAuthorizationResponseOpts>
private readonly _eventEmitter?: EventEmitter
private readonly _sessionManager?: IRPSessionManager
private readonly _responseRedirectUri?: string
private constructor(opts: {
builder?: RPBuilder
createRequestOpts?: CreateAuthorizationRequestOpts
verifyResponseOpts?: VerifyAuthorizationResponseOpts
}) {
// const claims = opts.builder?.claims || opts.createRequestOpts?.payload.claims;
this._createRequestOptions = createRequestOptsFromBuilderOrExistingOpts(opts)
this._verifyResponseOptions = { ...createVerifyResponseOptsFromBuilderOrExistingOpts(opts) }
this._eventEmitter = opts.builder?.eventEmitter
this._sessionManager = opts.builder?.sessionManager
this._responseRedirectUri = opts.builder?._responseRedirectUri
}
public static fromRequestOpts(opts: CreateAuthorizationRequestOpts): RP {
return new RP({ createRequestOpts: opts })
}
public static builder(opts?: { requestVersion?: SupportedVersion }): RPBuilder {
return RPBuilder.newInstance(opts?.requestVersion)
}
public async createAuthorizationRequest(opts: {
correlationId: string
nonce: string | RequestPropertyWithTargets<string>
state: string | RequestPropertyWithTargets<string>
jwtIssuer?: JwtIssuer
claims?: ClaimPayloadCommonOpts | RequestPropertyWithTargets<ClaimPayloadCommonOpts>
version?: SupportedVersion
requestByReferenceURI?: string
responseURI?: string
responseURIType?: ResponseURIType
}): Promise<AuthorizationRequest> {
const authorizationRequestOpts = this.newAuthorizationRequestOpts(opts)
return AuthorizationRequest.fromOpts(authorizationRequestOpts)
.then((authorizationRequest: AuthorizationRequest) => {
void this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_CREATED_SUCCESS, {
correlationId: opts.correlationId,
subject: authorizationRequest,
})
return authorizationRequest
})
.catch((error: Error) => {
void this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_CREATED_FAILED, {
correlationId: opts.correlationId,
error,
})
throw error
})
}
public async createAuthorizationRequestURI(opts: {
correlationId: string
nonce: string | RequestPropertyWithTargets<string>
state: string | RequestPropertyWithTargets<string>
jwtIssuer?: JwtIssuer
claims?: ClaimPayloadCommonOpts | RequestPropertyWithTargets<ClaimPayloadCommonOpts>
version?: SupportedVersion
requestByReferenceURI?: string
responseURI?: string
responseURIType?: ResponseURIType
}): Promise<URI> {
const authorizationRequestOpts = this.newAuthorizationRequestOpts(opts)
try {
const uri = await URI.fromOpts(authorizationRequestOpts)
const authRequest = await AuthorizationRequest.fromOpts(authorizationRequestOpts)
this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_CREATED_SUCCESS, {
correlationId: opts.correlationId,
subject: authRequest,
})
return uri
} catch (error) {
this.emitEvent(AuthorizationEvents.ON_AUTH_REQUEST_CREATED_FAILED, {
correlationId: opts.correlationId,
error,
})
throw error
}
}
public async signalAuthRequestRetrieved(opts: { correlationId: string; error?: Error }) {
if (!this.sessionManager) {
throw Error(`Cannot signal auth request retrieval when no session manager is registered`)
}
const state = await this.sessionManager.getRequestStateByCorrelationId(opts.correlationId, true)
void this.emitEvent(opts?.error ? AuthorizationEvents.ON_AUTH_REQUEST_SENT_FAILED : AuthorizationEvents.ON_AUTH_REQUEST_SENT_SUCCESS, {
correlationId: opts.correlationId,
...(!opts?.error ? { subject: state.request } : {}),
...(opts?.error ? { error: opts.error } : {}),
})
}
static async processJarmAuthorizationResponse(
response: string,
opts: {
decryptCompact: DecryptCompact
getAuthRequestPayload: (input: JarmDirectPostJwtResponseParams | JarmAuthResponseParams) => Promise<{
authRequestParams: RequestObjectPayload
}>
hasher?: Hasher
},
) {
const { decryptCompact, getAuthRequestPayload, hasher } = opts
const getParams = getAuthRequestPayload as JarmDirectPostJwtAuthResponseValidationContext['openid4vp']['authRequest']['getParams']
const validatedResponse = await jarmAuthResponseDirectPostJwtValidate(
{ response },
{
openid4vp: { authRequest: { getParams } },
jwe: { decryptCompact },
},
)
const presentations = validatedResponse.authRequestParams.dcql_query
? extractPresentationsFromDcqlVpToken(validatedResponse.authResponseParams.vp_token as string, { hasher })
: extractPresentationsFromVpToken(validatedResponse.authResponseParams.vp_token, { hasher })
const mdocVerifiablePresentations = (Array.isArray(presentations) ? presentations : [presentations]).filter((p) => p.format === 'mso_mdoc')
if (mdocVerifiablePresentations.length) {
if (validatedResponse.type !== 'encrypted') {
throw new Error(`Cannot verify mdoc request nonce. Response should be 'encrypted' but is '${validatedResponse.type}'`)
}
const requestParamsNonce = validatedResponse.authRequestParams.nonce
const jweProtectedHeader = decodeProtectedHeader(response) as { apv?: string; apu?: string }
const apv = jweProtectedHeader.apv
if (!apv) {
throw new Error(`Missing required apv parameter in the protected header of the jarm response.`)
}
const requestNonce = base64urlToString(apv)
if (!requestParamsNonce || requestParamsNonce !== requestNonce) {
throw new Error(`Invalid request nonce found in the jarm protected Header. Expected '${requestParamsNonce}' received '${requestNonce}'`)
}
}
return validatedResponse
}
public async verifyAuthorizationResponse(
authorizationResponsePayload: AuthorizationResponsePayload,
opts?: {
correlationId?: string
hasher?: Hasher
audience?: string
state?: string
nonce?: string
verification?: Verification
presentationDefinitions?: PresentationDefinitionWithLocation | PresentationDefinitionWithLocation[]
dcqlQuery?: DcqlQuery
},
): Promise<VerifiedAuthorizationResponse> {
const state = opts?.state ?? authorizationResponsePayload.state
let correlationId: string | undefined = opts?.correlationId ?? (await this.sessionManager.getCorrelationIdByState(state, true))
let authorizationResponse: AuthorizationResponse
try {
authorizationResponse = await AuthorizationResponse.fromPayload(authorizationResponsePayload)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_RECEIVED_FAILED, {
correlationId,
subject: authorizationResponsePayload,
error,
})
throw error
}
try {
const verifyAuthenticationResponseOpts = await this.newVerifyAuthorizationResponseOpts(authorizationResponse, {
...opts,
correlationId,
})
correlationId = verifyAuthenticationResponseOpts.correlationId ?? correlationId
void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_RECEIVED_SUCCESS, {
correlationId,
subject: authorizationResponse,
})
const verifiedAuthorizationResponse = await authorizationResponse.verify(verifyAuthenticationResponseOpts)
void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_VERIFIED_SUCCESS, {
correlationId,
subject: authorizationResponse,
})
return verifiedAuthorizationResponse
} catch (error) {
void this.emitEvent(AuthorizationEvents.ON_AUTH_RESPONSE_VERIFIED_FAILED, {
correlationId,
subject: authorizationResponse,
error,
})
throw error
}
}
get createRequestOptions(): CreateAuthorizationRequestOpts {
return this._createRequestOptions
}
get verifyResponseOptions(): Partial<VerifyAuthorizationResponseOpts> {
return this._verifyResponseOptions
}
public getResponseRedirectUri(mappings?: Record<string, string>): string | undefined {
if (!this._responseRedirectUri) {
return undefined
}
if (!mappings) {
return this._responseRedirectUri
}
return Object.entries(mappings).reduce((uri, [key, value]) => uri.replace(`:${key}`, value), this._responseRedirectUri)
}
private newAuthorizationRequestOpts(opts: {
correlationId: string
nonce: string | RequestPropertyWithTargets<string>
state: string | RequestPropertyWithTargets<string>
jwtIssuer?: JwtIssuer
claims?: ClaimPayloadCommonOpts | RequestPropertyWithTargets<ClaimPayloadCommonOpts>
version?: SupportedVersion
requestByReferenceURI?: string
responseURIType?: ResponseURIType
responseURI?: string
}): CreateAuthorizationRequestOpts {
const nonceWithTarget =
typeof opts.nonce === 'string'
? { propertyValue: opts.nonce, targets: PropertyTarget.REQUEST_OBJECT }
: (opts?.nonce as RequestPropertyWithTargets<string>)
const stateWithTarget =
typeof opts.state === 'string'
? { propertyValue: opts.state, targets: PropertyTarget.REQUEST_OBJECT }
: (opts?.state as RequestPropertyWithTargets<string>)
const claimsWithTarget =
opts?.claims && !('propertyValue' in opts.claims)
? { propertyValue: opts.claims, targets: PropertyTarget.REQUEST_OBJECT }
: (opts?.claims as RequestPropertyWithTargets<ClaimPayloadCommonOpts>)
const version = opts?.version ?? this._createRequestOptions.version
if (!version) {
throw Error(SIOPErrors.NO_REQUEST_VERSION)
}
const referenceURI = opts.requestByReferenceURI ?? this._createRequestOptions?.requestObject?.reference_uri
let responseURIType: ResponseURIType = opts?.responseURIType
let responseURI = this._createRequestOptions.requestObject.payload?.redirect_uri ?? this._createRequestOptions.payload?.redirect_uri
if (responseURI) {
responseURIType = 'redirect_uri'
} else {
responseURI =
opts.responseURI ?? this._createRequestOptions.requestObject.payload?.response_uri ?? this._createRequestOptions.payload?.response_uri
responseURIType = opts?.responseURIType ?? 'response_uri'
}
if (!responseURI) {
throw Error(`A response or redirect URI is required at this point`)
} else {
if (responseURIType === 'redirect_uri') {
if (this._createRequestOptions?.requestObject?.payload) {
this._createRequestOptions.requestObject.payload.redirect_uri = responseURI
}
if (!referenceURI && !this._createRequestOptions.payload?.redirect_uri) {
this._createRequestOptions.payload.redirect_uri = responseURI
}
} else if (responseURIType === 'response_uri') {
if (this._createRequestOptions?.requestObject?.payload) {
this._createRequestOptions.requestObject.payload.response_uri = responseURI
}
if (!referenceURI && !this._createRequestOptions.payload?.response_uri) {
this._createRequestOptions.payload.response_uri = responseURI
}
}
}
const newOpts = { ...this._createRequestOptions, version }
newOpts.requestObject = { ...newOpts.requestObject, jwtIssuer: opts.jwtIssuer }
newOpts.requestObject.payload = newOpts.requestObject.payload ?? ({} as RequestObjectPayloadOpts<ClaimPayloadCommonOpts>)
newOpts.payload = newOpts.payload ?? {}
if (referenceURI) {
if (newOpts.requestObject.passBy && newOpts.requestObject.passBy !== PassBy.REFERENCE) {
throw Error(`Cannot pass by reference with uri ${referenceURI} when mode is ${newOpts.requestObject.passBy}`)
}
newOpts.requestObject.reference_uri = referenceURI
newOpts.requestObject.passBy = PassBy.REFERENCE
}
const state = getState(stateWithTarget.propertyValue)
if (stateWithTarget.propertyValue) {
if (isTargetOrNoTargets(PropertyTarget.AUTHORIZATION_REQUEST, stateWithTarget.targets)) {
newOpts.payload.state = state
}
if (isTargetOrNoTargets(PropertyTarget.REQUEST_OBJECT, stateWithTarget.targets)) {
newOpts.requestObject.payload.state = state
}
}
const nonce = getNonce(state, nonceWithTarget.propertyValue)
if (nonceWithTarget.propertyValue) {
if (isTargetOrNoTargets(PropertyTarget.AUTHORIZATION_REQUEST, nonceWithTarget.targets)) {
newOpts.payload.nonce = nonce
}
if (isTargetOrNoTargets(PropertyTarget.REQUEST_OBJECT, nonceWithTarget.targets)) {
newOpts.requestObject.payload.nonce = nonce
}
}
if (claimsWithTarget?.propertyValue) {
if (isTargetOrNoTargets(PropertyTarget.AUTHORIZATION_REQUEST, claimsWithTarget.targets)) {
newOpts.payload.claims = { ...newOpts.payload.claims, ...claimsWithTarget.propertyValue }
}
if (isTargetOrNoTargets(PropertyTarget.REQUEST_OBJECT, claimsWithTarget.targets)) {
newOpts.requestObject.payload.claims = { ...newOpts.requestObject.payload.claims, ...claimsWithTarget.propertyValue }
}
}
return newOpts
}
private async newVerifyAuthorizationResponseOpts(
authorizationResponse: AuthorizationResponse,
opts: {
correlationId: string
hasher?: Hasher
state?: string
nonce?: string
verification?: Verification
audience?: string
presentationDefinitions?: PresentationDefinitionWithLocation | PresentationDefinitionWithLocation[]
dcqlQuery?: DcqlQuery
},
): Promise<VerifyAuthorizationResponseOpts> {
let correlationId = opts?.correlationId ?? this._verifyResponseOptions.correlationId
let state = opts?.state ?? this._verifyResponseOptions.state
let nonce = opts?.nonce ?? this._verifyResponseOptions.nonce
if (this.sessionManager) {
const resNonce = (await authorizationResponse.getMergedProperty('nonce', {
consistencyCheck: false,
hasher: opts.hasher ?? this._verifyResponseOptions.hasher,
})) as string
const resState = (await authorizationResponse.getMergedProperty('state', {
consistencyCheck: false,
hasher: opts.hasher ?? this._verifyResponseOptions.hasher,
})) as string
if (resNonce && !correlationId) {
correlationId = await this.sessionManager.getCorrelationIdByNonce(resNonce, false)
}
if (!correlationId) {
correlationId = await this.sessionManager.getCorrelationIdByState(resState, false)
}
if (!correlationId) {
correlationId = nonce
}
const requestState = await this.sessionManager.getRequestStateByCorrelationId(correlationId, false)
if (requestState) {
const reqNonce: string = requestState.request.getMergedProperty('nonce')
const reqState: string = requestState.request.getMergedProperty('state')
nonce = nonce ?? reqNonce
state = state ?? reqState
}
}
const hasPD =
(this._verifyResponseOptions.presentationDefinitions !== undefined && this._verifyResponseOptions.presentationDefinitions !== null) ||
(Array.isArray(this._verifyResponseOptions.presentationDefinitions) && this._verifyResponseOptions.presentationDefinitions.length > 0) ||
(opts.presentationDefinitions !== undefined && opts.presentationDefinitions !== null) ||
(Array.isArray(opts.presentationDefinitions) && opts.presentationDefinitions.length > 0)
const hasDcql =
(this._verifyResponseOptions.dcqlQuery !== undefined && this._verifyResponseOptions.dcqlQuery !== null) ||
(opts.dcqlQuery !== undefined && opts.dcqlQuery !== null)
if (hasPD && hasDcql) {
throw Error(`Only Presentation Definitions or DCQL is required`)
} else if (!hasPD && !hasDcql) {
throw Error(`Either a Presentation Definition or DCQL is required`)
}
return {
...this._verifyResponseOptions,
verifyJwtCallback: this._verifyResponseOptions.verifyJwtCallback,
...opts,
correlationId,
audience: opts?.audience ?? this._verifyResponseOptions.audience ?? this._createRequestOptions.payload.client_id,
state,
nonce,
verification: mergeVerificationOpts(this._verifyResponseOptions, opts),
...(opts?.presentationDefinitions &&
!opts?.dcqlQuery && {
presentationDefinitions: this._verifyResponseOptions.presentationDefinitions ?? opts?.presentationDefinitions,
}),
...(opts?.dcqlQuery /*&&
!opts?.presentationDefinitions */ && {
// FIXME presentationDefinitions will be there until we fix the OID4VC-DEMO, it wants a PD purpose field for the screens
dcqlQuery: this._verifyResponseOptions.dcqlQuery ?? opts?.dcqlQuery,
}),
}
}
private emitEvent(
type: AuthorizationEvents,
payload: {
correlationId: string
subject?: AuthorizationRequest | AuthorizationResponse | AuthorizationResponsePayload
error?: Error
},
): void {
if (this._eventEmitter) {
try {
this._eventEmitter.emit(type, new AuthorizationEvent(payload))
} catch (e) {
//Let's make sure events do not cause control flow issues
console.log(`Could not emit event ${type} for ${payload.correlationId} initial error if any: ${payload?.error}`)
}
}
}
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)
}
}
}