Skip to content

feat: supported redirect_uri for openid4vp response #2276

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

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/beige-cows-explain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@credo-ts/openid4vc": patch
---

feat: supported `redirect_uri` for openid4vp response
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AgentContext,
ClaimFormat,
DcqlError,
DifPresentationExchangeDefinition,
DifPresentationExchangeSubmission,
HashName,
Expand Down Expand Up @@ -129,6 +130,12 @@ export class OpenId4VpVerifierService {
throw new CredoError(`OpenID4VP version '${version}' cannot be used with dcql. Use version 'v1.draft24' instead.`)
}

if (isDcApiRequest && options.authorizationResponseRedirectUri) {
throw new CredoError(
"'authorizationResponseRedirectUri' cannot be be used with response mode 'dc_api' and 'dc_api.jwt'."
)
}

// Check to prevent direct_post from being used with mDOC
const hasMdocRequest =
options.presentationExchange?.definition.input_descriptors.some((i) => i.format?.mso_mdoc) ||
Expand Down Expand Up @@ -250,6 +257,8 @@ export class OpenId4VpVerifierService {
})

const verificationSession = new OpenId4VcVerificationSessionRecord({
authorizationResponseRedirectUri: options.authorizationResponseRedirectUri,

// Only store payload for unsiged requests
authorizationRequestPayload: authorizationRequest.jar
? undefined
Expand Down Expand Up @@ -372,12 +381,19 @@ export class OpenId4VpVerifierService {
const { verificationSession, authorizationResponse, origin } = options
const authorizationRequest = options.verificationSession.requestPayload

verificationSession.assertState([
OpenId4VcVerificationSessionState.RequestUriRetrieved,
OpenId4VcVerificationSessionState.RequestCreated,
])
if (
verificationSession.state !== OpenId4VcVerificationSessionState.RequestUriRetrieved &&
verificationSession.state !== OpenId4VcVerificationSessionState.RequestCreated
) {
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'Invalid session',
})
}

if (verificationSession.expiresAt && Date.now() > verificationSession.expiresAt.getTime()) {
verificationSession.errorMessage = 'session expired'
await this.updateState(agentContext, verificationSession, OpenId4VcVerificationSessionState.Error)
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'session expired',
Expand Down Expand Up @@ -417,7 +433,10 @@ export class OpenId4VpVerifierService {
if (result.type === 'dcql') {
const dcqlPresentationEntries = Object.entries(result.dcql.presentations)
if (!authorizationRequest.dcql_query) {
throw new CredoError('')
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'DCQL response provided but no dcql_query found in the authorization request.',
})
}

const dcql = agentContext.dependencyManager.resolve(DcqlService)
Expand All @@ -427,9 +446,10 @@ export class OpenId4VpVerifierService {
dcqlPresentationEntries.map(async ([credentialId, presentation]) => {
const queryCredential = dcqlQuery.credentials.find((c) => c.id === credentialId)
if (!queryCredential) {
throw new CredoError(
`vp_token contains presentation for credential query id '${credentialId}', but this credential is not present in the dcql query.`
)
throw new Oauth2ServerErrorResponseError({
error: Oauth2ErrorCodes.InvalidRequest,
error_description: `vp_token contains presentation for credential query id '${credentialId}', but this credential is not present in the dcql query.`,
})
}

return {
Expand Down Expand Up @@ -460,13 +480,35 @@ export class OpenId4VpVerifierService {
},
{} as Record<string, VerifiablePresentation>
)
const presentationResult = dcql.assertValidDcqlPresentation(presentations, dcqlQuery)

let presentationResult: ReturnType<typeof dcql.assertValidDcqlPresentation>
try {
presentationResult = dcql.assertValidDcqlPresentation(presentations, dcqlQuery)
} catch (error) {
if (error instanceof DcqlError) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: error.message,
},
{ cause: error }
)
}

throw error
}

const errorMessages = presentationVerificationResults
.map((result, index) => (!result.verified ? `\t- [${index}]: ${result.reason}` : undefined))
.filter((i) => i !== undefined)
if (errorMessages.length > 0) {
throw new CredoError(`One or more presentations failed verification. \n\t${errorMessages.join('\n')}`)
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'One or more presentations failed verification.',
},
{ internalMessage: errorMessages.join('\n') }
)
}

dcqlResponse = {
Expand All @@ -484,7 +526,18 @@ export class OpenId4VpVerifierService {
const definition = result.pex.presentationDefinition as unknown as DifPresentationExchangeDefinition

pex.validatePresentationDefinition(definition)
pex.validatePresentationSubmission(submission)

try {
pex.validatePresentationSubmission(submission)
} catch (error) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'Invalid presentation submission.',
},
{ cause: error }
)
}

const presentationsArray = Array.isArray(encodedPresentations) ? encodedPresentations : [encodedPresentations]
const presentationVerificationResults = await Promise.all(
Expand All @@ -506,19 +559,35 @@ export class OpenId4VpVerifierService {
.map((result, index) => (!result.verified ? `\t- [${index}]: ${result.reason}` : undefined))
.filter((i) => i !== undefined)
if (errorMessages.length > 0) {
throw new CredoError(`One or more presentations failed verification. \n\t${errorMessages.join('\n')}`)
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'One or more presentations failed verification.',
},
{ internalMessage: errorMessages.join('\n') }
)
}

const verifiablePresentations = presentationVerificationResults
.map((p) => (p.verified ? p.presentation : undefined))
.filter((p) => p !== undefined)

pex.validatePresentation(
definition,
// vp_token MUST not be an array if only one entry
verifiablePresentations.length === 1 ? verifiablePresentations[0] : verifiablePresentations,
submission
)
try {
pex.validatePresentation(
definition,
// vp_token MUST not be an array if only one entry
verifiablePresentations.length === 1 ? verifiablePresentations[0] : verifiablePresentations,
submission
)
} catch (error) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: 'Presentation submission does not satisy presentation request.',
},
{ cause: error }
)
}

const descriptors = extractPresentationsWithDescriptorsFromSubmission(
// vp_token MUST not be an array if only one entry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,18 @@ export interface OpenId4VpCreateAuthorizationRequestOptions {
*/
responseMode?: ResponseMode

/**
* Redirect uri that should be used in the authorization response. This will be included in both error and success
* responses. It can prevent session fixation, and allows to continue the flow in the browser after redirect.
*
* For same-device flows it allows continuing the flow. Based on the redirect uri, you can retrieve the session
* and display error or success screens.
*
* NOTE: the Uri MUST include randomness so the URL cannot be guessed, recommended is to have at least 128 bits of
* randomness, which is unique for each request.
*/
authorizationResponseRedirectUri?: string

/**
* The expected origins of the authorization response.
* REQUIRED when signed requests defined in Appendix A.3.2 are used with the Digital Credentials API (DC API). An array of strings, each string representing an Origin of the Verifier that is making the request.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ export interface OpenId4VcVerificationSessionRecordProps {
authorizationRequestId: string
authorizationRequestPayload?: OpenId4VpAuthorizationRequestPayload

authorizationResponseRedirectUri?: string

expiresAt: Date

authorizationResponsePayload?: OpenId4VpAuthorizationResponsePayload
Expand Down Expand Up @@ -103,6 +105,14 @@ export class OpenId4VcVerificationSessionRecord extends BaseRecord<DefaultOpenId
*/
public presentationDuringIssuanceSession?: string

/**
* Redirect uri that should be used in the authorization response. This will be included in both error and success
* responses.
*
* @since 0.6
*/
authorizationResponseRedirectUri?: string

public constructor(props: OpenId4VcVerificationSessionRecordProps) {
super()

Expand All @@ -118,6 +128,7 @@ export class OpenId4VcVerificationSessionRecord extends BaseRecord<DefaultOpenId
this.authorizationRequestJwt = props.authorizationRequestJwt
this.authorizationRequestUri = props.authorizationRequestUri
this.authorizationRequestId = props.authorizationRequestId
this.authorizationResponseRedirectUri = props.authorizationResponseRedirectUri
this.authorizationResponsePayload = props.authorizationResponsePayload
this.expiresAt = props.expiresAt

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,16 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
const { agentContext, verifier } = getRequestContext(request)
const openId4VcVerifierService = agentContext.dependencyManager.resolve(OpenId4VpVerifierService)

let authorizationResponseRedirectUri: string | undefined = undefined

try {
const result = await getVerificationSession(agentContext, request, response, next, verifier)

// Response already handled in the method
if (!result.success) return

authorizationResponseRedirectUri = result.verificationSession.authorizationResponseRedirectUri

const { verificationSession } = await openId4VcVerifierService.verifyAuthorizationResponse(agentContext, {
authorizationResponse: request.body,
verificationSession: result.verificationSession,
Expand All @@ -42,12 +47,11 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
// Used only for presentation during issuance flow, to prevent session fixation.
presentation_during_issuance_session: verificationSession.presentationDuringIssuanceSession,

// TODO: add callback for the user of Credo, where also a redirect_uri can be returned
// callback should also be called in case of failed verification
// redirect_uri
redirect_uri: verificationSession.authorizationResponseRedirectUri,
})
} catch (error) {
if (error instanceof Oauth2ServerErrorResponseError) {
error.errorResponse.redirect_uri = authorizationResponseRedirectUri
return sendOauth2ErrorResponse(response, next, agentContext.config.logger, error)
}

Expand All @@ -61,15 +65,16 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
{
error: Oauth2ErrorCodes.InvalidRequest,
error_description: error.message,
redirect_uri: authorizationResponseRedirectUri,
},
{ cause: error }
)
)
}

// FIXME: Many CredoError will result in 500. We should either throw Oauth2ServerErrorResponseError as well
// Or have a special OpenID4VP verifier error that is similar to Oauth2ServerErrorResponseError
return sendUnknownServerErrorResponse(response, next, agentContext.config.logger, error)
return sendUnknownServerErrorResponse(response, next, agentContext.config.logger, error, {
redirect_uri: authorizationResponseRedirectUri,
})
}
})
}
Expand Down
9 changes: 8 additions & 1 deletion packages/openid4vc/src/shared/router/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,20 @@ export function sendOauth2ErrorResponse(
response.status(error.status).json(error.errorResponse)
next(error)
}
export function sendUnknownServerErrorResponse(response: Response, next: NextFunction, logger: Logger, error: unknown) {
export function sendUnknownServerErrorResponse(
response: Response,
next: NextFunction,
logger: Logger,
error: unknown,
additionalParams: Record<string, unknown> = {}
) {
logger.error('[OID4VC] Sending unknown server error response', {
error,
})

response.status(500).json({
error: 'server_error',
...additionalParams,
})

const throwError =
Expand Down
6 changes: 6 additions & 0 deletions packages/openid4vc/tests/openid4vc.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
universityDegreeCredentialSdJwt2,
} from './utilsVci'
import { openBadgePresentationDefinition, universityDegreePresentationDefinition } from './utilsVp'
import { randomUUID } from 'crypto'

const serverPort = 1234
const baseUrl = `http://localhost:${serverPort}`
Expand Down Expand Up @@ -630,6 +631,7 @@ describe('OpenId4Vc', () => {

await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 })
await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 })
const authorizationResponseRedirectUri = `https://my-website.com/${randomUUID()}`

const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } =
await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({
Expand All @@ -641,6 +643,7 @@ describe('OpenId4Vc', () => {
presentationExchange: {
definition: openBadgePresentationDefinition,
},
authorizationResponseRedirectUri,
})

expect(authorizationRequestUri1).toEqual(
Expand Down Expand Up @@ -758,6 +761,9 @@ describe('OpenId4Vc', () => {
})
expect(serverResponse1).toMatchObject({
status: 200,
body: {
redirect_uri: authorizationResponseRedirectUri,
},
})

// The RP MUST validate that the aud (audience) Claim contains the value of the client_id
Expand Down
Loading