@sphereon/did-auth-siop
Version:
Self Issued OpenID V2 (SIOPv2) and OpenID 4 Verifiable Presentations (OID4VP)
279 lines (242 loc) • 11.5 kB
text/typescript
import { CredentialMapper, Hasher, WrappedVerifiablePresentation } from '@sphereon/ssi-types'
import { DcqlPresentation } from 'dcql'
import { AuthorizationRequest, VerifyAuthorizationRequestOpts } from '../authorization-request'
import { assertValidVerifyAuthorizationRequestOpts } from '../authorization-request/Opts'
import { IDToken } from '../id-token'
import { AuthorizationResponsePayload, ResponseType, SIOPErrors, VerifiedAuthorizationRequest, VerifiedAuthorizationResponse } from '../types'
import { Dcql } from './Dcql'
import {
assertValidVerifiablePresentations,
extractNonceFromWrappedVerifiablePresentation,
extractPresentationsFromDcqlVpToken,
extractPresentationsFromVpToken,
verifyPresentations,
} from './OpenID4VP'
import { assertValidResponseOpts } from './Opts'
import { createResponsePayload } from './Payload'
import { AuthorizationResponseOpts, PresentationDefinitionWithLocation, VerifyAuthorizationResponseOpts } from './types'
export class AuthorizationResponse {
private readonly _authorizationRequest?: AuthorizationRequest | undefined
// private _requestObject?: RequestObject | undefined
private readonly _idToken?: IDToken
private readonly _payload: AuthorizationResponsePayload
private readonly _options?: AuthorizationResponseOpts
private constructor({
authorizationResponsePayload,
idToken,
responseOpts,
authorizationRequest,
}: {
authorizationResponsePayload: AuthorizationResponsePayload
idToken?: IDToken
responseOpts?: AuthorizationResponseOpts
authorizationRequest?: AuthorizationRequest
}) {
this._authorizationRequest = authorizationRequest
this._options = responseOpts
this._idToken = idToken
this._payload = authorizationResponsePayload
}
/**
* Creates a SIOP Response Object
*
* @param requestObject
* @param responseOpts
* @param verifyOpts
*/
static async fromRequestObject(
requestObject: string,
responseOpts: AuthorizationResponseOpts,
verifyOpts: VerifyAuthorizationRequestOpts,
): Promise<AuthorizationResponse> {
assertValidVerifyAuthorizationRequestOpts(verifyOpts)
assertValidResponseOpts(responseOpts)
if (!requestObject || !requestObject.startsWith('ey')) {
throw new Error(SIOPErrors.NO_JWT)
}
const authorizationRequest = await AuthorizationRequest.fromUriOrJwt(requestObject)
return AuthorizationResponse.fromAuthorizationRequest(authorizationRequest, responseOpts, verifyOpts)
}
static async fromPayload(
authorizationResponsePayload: AuthorizationResponsePayload,
responseOpts?: AuthorizationResponseOpts,
): Promise<AuthorizationResponse> {
if (!authorizationResponsePayload) {
throw new Error(SIOPErrors.NO_RESPONSE)
}
if (responseOpts) {
assertValidResponseOpts(responseOpts)
}
const idToken = authorizationResponsePayload.id_token ? await IDToken.fromIDToken(authorizationResponsePayload.id_token) : undefined
return new AuthorizationResponse({
authorizationResponsePayload,
idToken,
responseOpts,
})
}
static async fromAuthorizationRequest(
authorizationRequest: AuthorizationRequest,
responseOpts: AuthorizationResponseOpts,
verifyOpts: VerifyAuthorizationRequestOpts,
): Promise<AuthorizationResponse> {
assertValidResponseOpts(responseOpts)
if (!authorizationRequest) {
throw new Error(SIOPErrors.NO_REQUEST)
}
const verifiedRequest = await authorizationRequest.verify(verifyOpts)
return await AuthorizationResponse.fromVerifiedAuthorizationRequest(verifiedRequest, responseOpts, verifyOpts)
}
static async fromVerifiedAuthorizationRequest(
verifiedAuthorizationRequest: VerifiedAuthorizationRequest,
responseOpts: AuthorizationResponseOpts,
verifyOpts: VerifyAuthorizationRequestOpts,
): Promise<AuthorizationResponse> {
assertValidResponseOpts(responseOpts)
if (!verifiedAuthorizationRequest) {
throw new Error(SIOPErrors.NO_REQUEST)
}
const authorizationRequest = verifiedAuthorizationRequest.authorizationRequest
// const merged = verifiedAuthorizationRequest.authorizationRequest.requestObject, verifiedAuthorizationRequest.requestObject);
// const presentationDefinitions = await PresentationExchange.findValidPresentationDefinitions(merged, await authorizationRequest.getSupportedVersion());
const presentationDefinitions = JSON.parse(
JSON.stringify(verifiedAuthorizationRequest.presentationDefinitions),
) as PresentationDefinitionWithLocation[]
const wantsIdToken = await authorizationRequest.containsResponseType(ResponseType.ID_TOKEN)
const hasVpToken = await authorizationRequest.containsResponseType(ResponseType.VP_TOKEN)
const idToken = wantsIdToken ? await IDToken.fromVerifiedAuthorizationRequest(verifiedAuthorizationRequest, responseOpts) : undefined
const idTokenPayload = idToken ? await idToken.payload() : undefined
const authorizationResponsePayload = await createResponsePayload(authorizationRequest, responseOpts, idTokenPayload)
const response = new AuthorizationResponse({
authorizationResponsePayload,
idToken,
responseOpts,
authorizationRequest,
})
if (!hasVpToken) return response
if (responseOpts.presentationExchange) {
const wrappedPresentations = response.payload.vp_token
? extractPresentationsFromVpToken(response.payload.vp_token, {
hasher: verifyOpts.hasher,
})
: []
await assertValidVerifiablePresentations({
presentationDefinitions,
presentations: wrappedPresentations,
verificationCallback: verifyOpts.verification.presentationVerificationCallback,
opts: {
...responseOpts.presentationExchange,
hasher: verifyOpts.hasher,
},
})
} else if (verifiedAuthorizationRequest.dcqlQuery) {
await Dcql.assertValidDcqlPresentationResult(
responseOpts.dcqlResponse.dcqlPresentation as DcqlPresentation,
verifiedAuthorizationRequest.dcqlQuery,
{
hasher: verifyOpts.hasher,
},
)
} else {
throw new Error('vp_token is present, but no presentation definitions or dcql query provided')
}
return response
}
public async verify(verifyOpts: VerifyAuthorizationResponseOpts): Promise<VerifiedAuthorizationResponse> {
// Merge payloads checks for inconsistencies in properties which are present in both the auth request and request object
const merged = await this.mergedPayloads({
consistencyCheck: true,
hasher: verifyOpts.hasher,
})
if (verifyOpts.state && merged.state !== verifyOpts.state) {
throw Error(SIOPErrors.BAD_STATE)
}
const verifiedIdToken = await this.idToken?.verify(verifyOpts)
if (this.payload.vp_token && !verifyOpts.presentationDefinitions && !verifyOpts.dcqlQuery) {
return Promise.reject(Error('vp_token is present, but no presentation definitions or dcql query provided'))
}
const emptyPresentationDefinitions = Array.isArray(verifyOpts.presentationDefinitions) && verifyOpts.presentationDefinitions.length === 0
if (!this.payload.vp_token && ((verifyOpts.presentationDefinitions && !emptyPresentationDefinitions) || verifyOpts.dcqlQuery)) {
return Promise.reject(Error('Presentation definitions or dcql query provided, but no vp_token present'))
}
const oid4vp = this.payload.vp_token ? await verifyPresentations(this, verifyOpts) : undefined
// Gather all nonces
const allNonces = new Set<string>()
if (oid4vp && (oid4vp.dcql?.nonce || oid4vp.presentationExchange?.nonce)) allNonces.add(oid4vp.dcql?.nonce ?? oid4vp.presentationExchange?.nonce)
if (verifiedIdToken) allNonces.add(verifiedIdToken.payload.nonce)
if (merged.nonce) allNonces.add(merged.nonce)
// We only verify the nonce if there is one. We handle the case if the nonce is undefined
// but it should be defined elsewhere. So if the nonce is undefined we don't have to verify it
const firstNonce = Array.from(allNonces)[0]
if (allNonces.size > 1) {
throw new Error('both id token and VPs in vp token if present must have a nonce, and all nonces must be the same')
}
if (verifyOpts.nonce && firstNonce && firstNonce !== verifyOpts.nonce) {
throw Error(SIOPErrors.BAD_NONCE)
}
const state = merged.state ?? verifiedIdToken?.payload.state
if (!state) {
throw Error('State is required')
}
return {
authorizationResponse: this,
verifyOpts,
nonce: firstNonce,
state,
correlationId: verifyOpts.correlationId,
...(this.idToken && { idToken: verifiedIdToken }),
...(oid4vp?.presentationExchange && { oid4vpSubmission: oid4vp.presentationExchange }),
...(oid4vp?.dcql && { oid4vpSubmissionDcql: oid4vp.dcql }),
}
}
get authorizationRequest(): AuthorizationRequest | undefined {
return this._authorizationRequest
}
get payload(): AuthorizationResponsePayload {
return this._payload
}
get options(): AuthorizationResponseOpts | undefined {
return this._options
}
get idToken(): IDToken | undefined {
return this._idToken
}
public getMergedProperty<T>(key: string, opts?: { consistencyCheck?: boolean; hasher?: Hasher }): T | undefined {
const merged = this.mergedPayloads(opts) // FIXME this is really bad, expensive...
return merged[key] as T
}
public mergedPayloads(opts?: { consistencyCheck?: boolean; hasher?: Hasher }): AuthorizationResponsePayload {
let nonce: string | undefined = this._payload.nonce
if (this._payload?.vp_token) {
let presentations: WrappedVerifiablePresentation | WrappedVerifiablePresentation[]
try {
presentations = extractPresentationsFromDcqlVpToken(this._payload.vp_token as string, opts)
} catch (e) {
presentations = extractPresentationsFromVpToken(this._payload.vp_token, opts)
}
if (!presentations || (Array.isArray(presentations) && presentations.length === 0)) {
return Promise.reject(Error('missing presentation(s)'))
}
const presentationsArray = Array.isArray(presentations) ? presentations : [presentations]
// We do not verify them, as that is done elsewhere. So we simply can take the first nonce
nonce = presentationsArray
// FIXME toWrappedVerifiablePresentation() does not extract the nonce yet from mdocs.
// However the nonce is validated as part of the mdoc verification process (using the session transcript bytes)
// Once it is available we can also test it here, but it will be verified elsewhre as well
.filter((presentation) => !CredentialMapper.isWrappedMdocPresentation(presentation))
.map(extractNonceFromWrappedVerifiablePresentation)
.find((nonce) => nonce !== undefined)
}
const idTokenPayload = this.idToken?.payload()
if (opts?.consistencyCheck !== false && idTokenPayload) {
Object.entries(idTokenPayload).forEach((entry) => {
if (typeof entry[0] === 'string' && this.payload[entry[0]] && this.payload[entry[0]] !== entry[1]) {
throw Error(`Mismatch in Authorization Request and Request object value for ${entry[0]}`)
}
})
}
if (!nonce && this._idToken) {
nonce = idTokenPayload.nonce
}
return { ...this.payload, ...idTokenPayload, nonce }
}
}