Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dcql #171

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@
"OIDC4VP",
"OID4VCI",
"OID4VP"
]
],
"packageManager": "[email protected]+sha256.dae0f7e822c56b20979bb5965e3b73b8bdabb6b8b8ef121da6d857508599ca35"
}

Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,7 @@ describe('presentation exchange manager tests', () => {
const payload = await getPayloadVID1Val()
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(payload.claims?.vp_token as any).presentation_definition_uri = EXAMPLE_PD_URL
await expect(PresentationExchange.findValidPresentationDefinitions(payload)).rejects.toThrow(
SIOPErrors.REQUEST_CLAIMS_PRESENTATION_DEFINITION_BY_REF_AND_VALUE_NON_EXCLUSIVE,
)
await expect(PresentationExchange.findValidPresentationDefinitions(payload)).rejects.toThrow(SIOPErrors.REQUEST_CLAIMS_PRESENTATION_NON_EXCLUSIVE)
})

it('validatePresentationAgainstDefinition: should pass if provided VP match the PD', async function () {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { parseJWT } from '@sphereon/oid4vc-common'
import { DcqlQuery } from 'dcql'

import { PresentationDefinitionWithLocation } from '../authorization-response'
import { findValidDcqlQuery } from '../authorization-response/Dcql'
import { PresentationExchange } from '../authorization-response/PresentationExchange'
import { fetchByReferenceOrUseByValue, removeNullUndefined } from '../helpers'
import { authorizationRequestVersionDiscovery } from '../helpers/SIOPSpecVersion'
Expand Down Expand Up @@ -66,7 +68,7 @@ export class AuthorizationRequest {

const requestObjectArg =
opts.requestObject.passBy !== PassBy.NONE ? (requestObject ? requestObject : await RequestObject.fromOpts(opts)) : undefined
// opts?.payload was removed before, but it's not clear atm why opts?.payload was removed
// opts?.payload was removed before, but it's not clear atm why opts?.payload was removed
const requestPayload = opts?.payload ? await createAuthorizationRequestPayload(opts, requestObjectArg) : undefined
return new AuthorizationRequest(requestPayload, requestObjectArg, opts)
}
Expand Down Expand Up @@ -190,14 +192,22 @@ export class AuthorizationRequest {
// TODO see if this is too naive. The OpenID conformance test explicitly tests for this
// But the spec says: The client_id and client_id_scheme MUST be omitted in unsigned requests defined in Appendix A.3.1.
// So I would expect client_id_scheme and client_id to be undefined when the JWT header has alg: none
if(mergedPayload.client_id && mergedPayload.client_id_scheme === 'redirect_uri' && mergedPayload.client_id !== responseURI) {
throw Error(`${SIOPErrors.INVALID_REQUEST}, response_uri does not match the client_id provided by the verifier which is required for client_id_scheme redirect_uri`)
if (mergedPayload.client_id && mergedPayload.client_id_scheme === 'redirect_uri' && mergedPayload.client_id !== responseURI) {
throw Error(
`${SIOPErrors.INVALID_REQUEST}, response_uri does not match the client_id provided by the verifier which is required for client_id_scheme redirect_uri`,
)
}

// TODO: we need to verify somewhere that if response_mode is direct_post, that the response_uri may be present,
// BUT not both redirect_uri and response_uri. What is the best place to do this?

const presentationDefinitions: PresentationDefinitionWithLocation[] = await PresentationExchange.findValidPresentationDefinitions(mergedPayload, await this.getSupportedVersion())
const presentationDefinitions: PresentationDefinitionWithLocation[] = await PresentationExchange.findValidPresentationDefinitions(
mergedPayload,
await this.getSupportedVersion(),
)

const dcqlQuery = await findValidDcqlQuery(mergedPayload)

return {
jwt,
payload: parsedJwt?.payload,
Expand All @@ -208,6 +218,7 @@ export class AuthorizationRequest {
correlationId: opts.correlationId,
authorizationRequest: this,
verifyOpts: opts,
dcqlQuery,
presentationDefinitions,
registrationMetadataPayload,
requestObject: this.requestObject,
Expand Down Expand Up @@ -267,8 +278,9 @@ export class AuthorizationRequest {
}

public async mergedPayloads(): Promise<RequestObjectPayload> {
const requestObjectPayload = { ...this.payload, ...(this.requestObject && (await this.requestObject.getPayload())) }
if (requestObjectPayload.scope && typeof requestObjectPayload.scope !== 'string') { // test mattr.launchpad.spec.ts does not supply a scope value
const requestObjectPayload = { ...this.payload, ...(this.requestObject && (await this.requestObject.getPayload())) }
if (requestObjectPayload.scope && typeof requestObjectPayload.scope !== 'string') {
// test mattr.launchpad.spec.ts does not supply a scope value
throw new Error('Invalid scope value')
}
return requestObjectPayload as RequestObjectPayload
Expand All @@ -277,4 +289,8 @@ export class AuthorizationRequest {
public async getPresentationDefinitions(version?: SupportedVersion): Promise<PresentationDefinitionWithLocation[] | undefined> {
return await PresentationExchange.findValidPresentationDefinitions(await this.mergedPayloads(), version)
}

public async getDcqlQuery(): Promise<DcqlQuery | undefined> {
return await findValidDcqlQuery(await this.mergedPayloads())
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved
}
}
18 changes: 13 additions & 5 deletions packages/siop-oid4vp/lib/authorization-request/Payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,28 @@ import { createRequestRegistration } from './RequestRegistration'
import { ClaimPayloadOptsVID1, CreateAuthorizationRequestOpts, PropertyTarget } from './types'

export const createPresentationDefinitionClaimsProperties = (opts: ClaimPayloadOptsVID1): ClaimPayloadVID1 => {
if (!opts || !opts.vp_token || (!opts.vp_token.presentation_definition && !opts.vp_token.presentation_definition_uri)) {
if (
!opts ||
!opts.vp_token ||
(!opts.vp_token.presentation_definition && !opts.vp_token.presentation_definition_uri && !opts.vp_token.dcql_query)
) {
return undefined
}
const discoveryResult = PEX.definitionVersionDiscovery(opts.vp_token.presentation_definition)
if (discoveryResult.error) {
throw new Error(SIOPErrors.REQUEST_CLAIMS_PRESENTATION_DEFINITION_NOT_VALID)

if (opts.vp_token.presentation_definition || opts.vp_token.presentation_definition_uri) {
const discoveryResult = PEX.definitionVersionDiscovery(opts.vp_token.presentation_definition)
if (discoveryResult.error) {
throw new Error(SIOPErrors.REQUEST_CLAIMS_PRESENTATION_DEFINITION_NOT_VALID)
}
}

return {
...(opts.id_token ? { id_token: opts.id_token } : {}),
...((opts.vp_token.presentation_definition || opts.vp_token.presentation_definition_uri) && {
...((opts.vp_token.presentation_definition || opts.vp_token.presentation_definition_uri || opts.vp_token.dcql_query) && {
vp_token: {
...(!opts.vp_token.presentation_definition_uri && { presentation_definition: opts.vp_token.presentation_definition }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This line needs to be adjusted, as the definition_uri would not be present with a dcql_query. Suggest to move the opts.vc_token.dclq_query expression to a signle line, so it makes a clear distinction between either a PD or PD URI (current logic) and a dcql_query

...(opts.vp_token.presentation_definition_uri && { presentation_definition_uri: opts.vp_token.presentation_definition_uri }),
...(opts.vp_token.dcql_query && { dcql_query: opts.vp_token.dcql_query }),
},
}),
}
Expand Down
8 changes: 5 additions & 3 deletions packages/siop-oid4vp/lib/authorization-request/URI.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { parseJWT } from '@sphereon/oid4vc-common'

import { findValidDcqlQuery } from '../authorization-response/Dcql'
import { PresentationExchange } from '../authorization-response/PresentationExchange'
import { decodeUriAsJson, encodeJsonAsURI, fetchByReferenceOrUseByValue } from '../helpers'
import { assertValidRequestObjectPayload, RequestObject } from '../request-object'
Expand Down Expand Up @@ -126,7 +127,6 @@ export class URI implements AuthorizationRequestURI {
...authorizationRequest.options.requestObject,
version: authorizationRequest.options.version,
uriScheme: authorizationRequest.options.uriScheme,

},
authorizationRequest.payload,
authorizationRequest.requestObject,
Expand Down Expand Up @@ -164,8 +164,9 @@ export class URI implements AuthorizationRequestURI {
const requestObjectPayload: RequestObjectPayload = requestObjectJwt ? (parseJWT(requestObjectJwt).payload as RequestObjectPayload) : undefined

if (requestObjectPayload) {
// Only used to validate if the request object contains presentation definition(s)
// Only used to validate if the request object contains presentation definition(s) | a dcql query
await PresentationExchange.findValidPresentationDefinitions({ ...authorizationRequestPayload, ...requestObjectPayload })
await findValidDcqlQuery({ ...authorizationRequestPayload, ...requestObjectPayload })

assertValidRequestObjectPayload(requestObjectPayload)
if (requestObjectPayload.registration) {
Expand Down Expand Up @@ -194,7 +195,8 @@ export class URI implements AuthorizationRequestURI {
}
} else {
try {
scheme = (await authorizationRequest.getSupportedVersion()) === SupportedVersion.JWT_VC_PRESENTATION_PROFILE_v1 ? 'openid-vc://' : 'openid4vp://'
scheme =
(await authorizationRequest.getSupportedVersion()) === SupportedVersion.JWT_VC_PRESENTATION_PROFILE_v1 ? 'openid-vc://' : 'openid4vp://'
} catch (error: unknown) {
scheme = 'openid4vp://'
}
Expand Down
4 changes: 2 additions & 2 deletions packages/siop-oid4vp/lib/authorization-request/types.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { SigningAlgo } from '@sphereon/oid4vc-common'
import { Hasher } from '@sphereon/ssi-types'

import { PresentationDefinitionPayloadOpts } from '../authorization-response'
import { DcqlQueryPayloadOpts, PresentationDefinitionPayloadOpts } from '../authorization-response'
import { RequestObjectOpts } from '../request-object'
import {
ClientIdScheme,
Expand All @@ -19,7 +19,7 @@ import { VerifyJwtCallback } from '../types/VpJwtVerifier'

export interface ClaimPayloadOptsVID1 extends ClaimPayloadCommonOpts {
id_token?: IdTokenClaimPayload
vp_token?: PresentationDefinitionPayloadOpts
vp_token?: PresentationDefinitionPayloadOpts | DcqlQueryPayloadOpts
}

export interface ClaimPayloadCommonOpts {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
import { CredentialMapper, Hasher } from '@sphereon/ssi-types'
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 { assertValidDcqlPresentation } from './Dcql'
import {
assertValidVerifiablePresentations,
extractNonceFromWrappedVerifiablePresentation,
extractPresentationsFromVpToken,
verifyPresentations,
} from './OpenID4VP'
import { extractPresentationsFromDcqlVpToken } from './OpenID4VP'
import { assertValidResponseOpts } from './Opts'
import { createResponsePayload } from './Payload'
import { AuthorizationResponseOpts, PresentationDefinitionWithLocation, VerifyAuthorizationResponseOpts } from './types'
Expand Down Expand Up @@ -123,7 +126,9 @@ export class AuthorizationResponse {
authorizationRequest,
})

if (hasVpToken) {
if (!hasVpToken) return response

if (responseOpts.presentationExchange) {
const wrappedPresentations = response.payload.vp_token
? await extractPresentationsFromVpToken(response.payload.vp_token, {
hasher: verifyOpts.hasher,
Expand All @@ -139,6 +144,14 @@ export class AuthorizationResponse {
hasher: verifyOpts.hasher,
},
})
} else {
const dcqlQuery = verifiedAuthorizationRequest.dcqlQuery
Copy link
Contributor

@nklomp nklomp Nov 24, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather would have seen an else if, with the below throw in an else. Right now the if expression is asymmetric. In the end it really checks for 3 states, but the if else is only 2 states with then an additional check in the else

if (!dcqlQuery) {
throw new Error('vp_token is present, but no presentation definitions or dcql query provided')
}
assertValidDcqlPresentation(responseOpts.dcqlQuery.dcqlPresentation as DcqlPresentation, dcqlQuery, {
hasher: verifyOpts.hasher,
})
}

return response
Expand All @@ -155,11 +168,20 @@ export class AuthorizationResponse {
}

const verifiedIdToken = await this.idToken?.verify(verifyOpts)
const oid4vp = await verifyPresentations(this, verifyOpts)
if (this.payload.vp_token && !verifyOpts.presentationDefinitions && !verifyOpts.dcqlQuery) {
throw Promise.reject(Error('vp_token is present, but no presentation definitions or dcql query provided'))
}
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved

const emptyPresentationDefinitions = Array.isArray(verifyOpts.presentationDefinitions) && verifyOpts.presentationDefinitions.length === 0
if (!this.payload.vp_token && ((verifyOpts.presentationDefinitions && !emptyPresentationDefinitions) || verifyOpts.dcqlQuery)) {
throw Promise.reject(Error('Presentation definitions or dcql query provided, but no vp_token present'))
}
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved

const oid4vp = this.payload.vp_token ? await verifyPresentations(this, verifyOpts) : undefined

// Gather all nonces
const allNonces = new Set<string>()
if (oid4vp && oid4vp.nonce) allNonces.add(oid4vp.nonce)
if (oid4vp && (oid4vp.dcql.nonce || oid4vp.presentationExchange.nonce)) allNonces.add(oid4vp.dcql.nonce ?? oid4vp.presentationExchange.nonce)
if (verifiedIdToken) allNonces.add(verifiedIdToken.payload.nonce)
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved
if (merged.nonce) allNonces.add(merged.nonce)

Expand All @@ -185,7 +207,8 @@ export class AuthorizationResponse {
state,
correlationId: verifyOpts.correlationId,
...(this.idToken && { idToken: verifiedIdToken }),
...(oid4vp && { oid4vpSubmission: oid4vp }),
...(oid4vp.presentationExchange && { oid4vpSubmission: oid4vp.presentationExchange }),
...(oid4vp.dcql && { oid4vpSubmissionDcql: oid4vp.dcql }),
}
}

Expand Down Expand Up @@ -213,13 +236,19 @@ export class AuthorizationResponse {
public async mergedPayloads(opts?: { consistencyCheck?: boolean; hasher?: Hasher }): Promise<AuthorizationResponsePayload> {
let nonce: string | undefined = this._payload.nonce
if (this._payload?.vp_token) {
const presentations = this.payload.vp_token ? await extractPresentationsFromVpToken(this.payload.vp_token, opts) : []
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.
Expand Down
59 changes: 59 additions & 0 deletions packages/siop-oid4vp/lib/authorization-response/Dcql.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { Hasher } from '@sphereon/ssi-types'
import { DcqlMdocCredential, DcqlPresentation, DcqlPresentationResult, DcqlQuery, DcqlSdJwtVcCredential } from 'dcql'

import { extractDataFromPath } from '../helpers'
import { AuthorizationRequestPayload, SIOPErrors } from '../types'

import { extractDcqlPresentationFromDcqlVpToken } from './OpenID4VP'

/**
* Finds a valid DcqlQuery inside the given AuthenticationRequestPayload
* throws exception if the DcqlQuery is not valid
* returns the decoded dcql query if a valid instance found
* @param authorizationRequestPayload object that can have a dcql_query inside
* @param version
*/
export const findValidDcqlQuery = async (authorizationRequestPayload: AuthorizationRequestPayload): Promise<DcqlQuery | undefined> => {
const dcqlQuery: string[] = extractDataFromPath(authorizationRequestPayload, '$.dcql_query').map((d) => d.value)
const definitions = extractDataFromPath(authorizationRequestPayload, '$.presentation_definition')
const definitionsFromList = extractDataFromPath(authorizationRequestPayload, '$.presentation_definition[*]')
const definitionRefs = extractDataFromPath(authorizationRequestPayload, '$.presentation_definition_uri')
const definitionRefsFromList = extractDataFromPath(authorizationRequestPayload, '$.presentation_definition_uri[*]')
nklomp marked this conversation as resolved.
Show resolved Hide resolved

const hasPD = (definitions && definitions.length > 0) || (definitionsFromList && definitionsFromList.length > 0)
const hasPdRef = (definitionRefs && definitionRefs.length > 0) || (definitionRefsFromList && definitionRefsFromList.length > 0)
const hasDcql = dcqlQuery && dcqlQuery.length > 0

if ([hasPD, hasPdRef, hasDcql].filter(Boolean).length > 1) {
throw new Error(SIOPErrors.REQUEST_CLAIMS_PRESENTATION_NON_EXCLUSIVE)
}

if (dcqlQuery.length === 0) return undefined

if (dcqlQuery.length > 1) {
throw new Error('Found multiple dcql_query in vp_token. Only one is allowed')
}

return DcqlQuery.parse(JSON.parse(dcqlQuery[0]))
}

export const getDcqlPresentationResult = (record: DcqlPresentation | string, dcqlQuery: DcqlQuery, opts: { hasher?: Hasher }) => {
const dcqlPresentation = Object.fromEntries(
Object.entries(extractDcqlPresentationFromDcqlVpToken(record, opts)).map(([queryId, p]) => {
if (p.format === 'mso_mdoc') {
return [queryId, { docType: p.vcs[0].credential.toJson().docType, namespaces: p.vcs[0].decoded } satisfies DcqlMdocCredential]
} else if (p.format === 'vc+sd-jwt') {
return [queryId, { vct: p.vcs[0].decoded.vct, claims: p.vcs[0].decoded } satisfies DcqlSdJwtVcCredential]
} else {
throw new Error('DcqlPresentation atm only supports mso_mdoc and vc+sd-jwt')
}
}),
)

return DcqlPresentationResult.fromDcqlPresentation(dcqlPresentation, { dcqlQuery })
}

export const assertValidDcqlPresentation = async (record: DcqlPresentation | string, dcqlQuery: DcqlQuery, opts: { hasher?: Hasher }) => {
const result = getDcqlPresentationResult(record, dcqlQuery, opts)
return DcqlPresentationResult.validate(result)
}
Loading
Loading