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

feat: proposal for server agnostic handlers sample #2150

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
49 changes: 26 additions & 23 deletions packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerModule.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { OpenId4VcIssuerModuleConfigOptions } from './OpenId4VcIssuerModuleConfig'
import type { OpenId4VcIssuanceRequest } from './router'
import type { AgentContext, DependencyManager, Module } from '@credo-ts/core'
import type { NextFunction, Response } from 'express'
import type { NextFunction, Response, Router } from 'express'

import { setGlobalConfig } from '@animo-id/oauth2'
import { AgentConfig } from '@credo-ts/core'
Expand Down Expand Up @@ -30,9 +30,11 @@ import {
export class OpenId4VcIssuerModule implements Module {
public readonly api = OpenId4VcIssuerApi
public readonly config: OpenId4VcIssuerModuleConfig
public readonly contextRouter: Router

public constructor(options: OpenId4VcIssuerModuleConfigOptions) {
public constructor(options: OpenId4VcIssuerModuleConfigOptions, router?: Router) {
this.config = new OpenId4VcIssuerModuleConfig(options)
this.contextRouter = router ?? importExpress().Router()
}

/**
Expand Down Expand Up @@ -82,14 +84,13 @@ export class OpenId4VcIssuerModule implements Module {
// We use separate context router and endpoint router. Context router handles the linking of the request
// to a specific agent context. Endpoint router only knows about a single context
const endpointRouter = Router()
const contextRouter = this.config.router

// parse application/x-www-form-urlencoded
contextRouter.use(urlencoded({ extended: false }))
this.contextRouter.use(urlencoded({ extended: false }))
// parse application/json
contextRouter.use(json())
this.contextRouter.use(json())

contextRouter.param('issuerId', async (req: OpenId4VcIssuanceRequest, _res, next, issuerId: string) => {
this.contextRouter.param('issuerId', async (req: OpenId4VcIssuanceRequest, _res, next, issuerId: string) => {
if (!issuerId) {
rootAgentContext.config.logger.debug('No issuerId provided for incoming oid4vci request, returning 404')
_res.status(404).send('Not found')
Expand Down Expand Up @@ -123,7 +124,7 @@ export class OpenId4VcIssuerModule implements Module {
next()
})

contextRouter.use('/:issuerId', endpointRouter)
this.contextRouter.use('/:issuerId', endpointRouter)

// Configure endpoints
configureIssuerMetadataEndpoint(endpointRouter)
Expand All @@ -136,30 +137,32 @@ export class OpenId4VcIssuerModule implements Module {
configureCredentialEndpoint(endpointRouter, this.config)

// First one will be called for all requests (when next is called)
contextRouter.use(async (req: OpenId4VcIssuanceRequest, _res: unknown, next) => {
this.contextRouter.use(async (req: OpenId4VcIssuanceRequest, _res: unknown, next) => {
const { agentContext } = getRequestContext(req)
await agentContext.endSession()

next()
})

// This one will be called for all errors that are thrown
contextRouter.use(async (_error: unknown, req: OpenId4VcIssuanceRequest, res: Response, next: NextFunction) => {
const { agentContext } = getRequestContext(req)

if (!res.headersSent) {
agentContext.config.logger.warn(
'Error was thrown but openid4vci endpoint did not send a response. Sending generic server_error.'
)
this.contextRouter.use(
async (_error: unknown, req: OpenId4VcIssuanceRequest, res: Response, next: NextFunction) => {
const { agentContext } = getRequestContext(req)

if (!res.headersSent) {
agentContext.config.logger.warn(
'Error was thrown but openid4vci endpoint did not send a response. Sending generic server_error.'
)

res.status(500).json({
error: 'server_error',
error_description: 'An unexpected error occurred on the server.',
})
}

res.status(500).json({
error: 'server_error',
error_description: 'An unexpected error occurred on the server.',
})
await agentContext.endSession()
next()
}

await agentContext.endSession()
next()
})
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,6 @@ import type {
OpenId4VciCredentialRequestToCredentialMapper,
OpenId4VciGetVerificationSessionForIssuanceSessionAuthorization,
} from './OpenId4VcIssuerServiceOptions'
import type { Router } from 'express'

import { importExpress } from '../shared/router'

const DEFAULT_C_NONCE_EXPIRES_IN = 1 * 60 // 1 minute
const DEFAULT_AUTHORIZATION_CODE_EXPIRES_IN = 1 * 60 // 1 minute
Expand All @@ -18,15 +15,6 @@ export interface OpenId4VcIssuerModuleConfigOptions {
*/
baseUrl: string

/**
* Express router on which the openid4vci endpoints will be registered. If
* no router is provided, a new one will be created.
*
* NOTE: you must manually register the router on your express app and
* expose this on a public url that is reachable when `baseUrl` is called.
*/
router?: Router

/**
* The time after which a cNonce will expire.
*
Expand Down Expand Up @@ -130,12 +118,12 @@ export interface OpenId4VcIssuerModuleConfigOptions {

export class OpenId4VcIssuerModuleConfig {
private options: OpenId4VcIssuerModuleConfigOptions
public readonly router: Router
// public readonly router: Router

public constructor(options: OpenId4VcIssuerModuleConfigOptions) {
this.options = options

this.router = options.router ?? importExpress().Router()
// this.router = options.router ??
}

public get baseUrl() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,8 @@ describe('OpenId4VcIssuerModule', () => {
credentialRequestToCredentialMapper: () => {
throw new Error('Not implemented')
},
router: Router(),
} as const
const openId4VcClientModule = new OpenId4VcIssuerModule(options)
const openId4VcClientModule = new OpenId4VcIssuerModule(options, Router())
openId4VcClientModule.register(dependencyManager)

expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1)
Expand All @@ -43,6 +42,6 @@ describe('OpenId4VcIssuerModule', () => {

await openId4VcClientModule.initialize(agentContext)

expect(openId4VcClientModule.config.router).toBeDefined()
expect(openId4VcClientModule.contextRouter).toBeDefined()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
/*
* Copyright 2025 Velocity Team
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
Comment on lines +1 to +16
Copy link
Contributor

Choose a reason for hiding this comment

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

I know this is not important at this stage of the PR, but just as an FYI:
I would rather not add these headers to all files. IF you want copyright credited, it can be added to the main license file (see example from Ontario: https://github.com/openwallet-foundation/credo-ts/blob/main/LICENSE#L190)


import type { OpenId4VciCredentialRequest, OpenId4VcIssuerModuleConfig } from '../../..'
import type { OpenId4VCIssuanceRequestContext } from '../router/requestContext'
import type * as http from 'node:http'

import {
type HttpMethod,
Oauth2ErrorCodes,
Oauth2ResourceUnauthorizedError,
Oauth2ServerErrorResponseError,
SupportedAuthenticationScheme,
} from '@animo-id/oauth2'
import { getCredentialConfigurationsMatchingRequestFormat } from '@animo-id/oid4vci'
import { joinUriParts } from '@credo-ts/core'

import { getCredentialConfigurationsSupportedForScopes } from '../../shared'
import { addSecondsToDate } from '../../shared/utils'
import { OpenId4VcIssuanceSessionState } from '../OpenId4VcIssuanceSessionState'
import { OpenId4VcIssuerService } from '../OpenId4VcIssuerService'
import { OpenId4VcIssuanceSessionRecord, OpenId4VcIssuanceSessionRepository } from '../repository'

export function configureCredentialEndpointHandler(config: OpenId4VcIssuerModuleConfig) {
return async function credentialEndpointHandler(
credentialRequest: OpenId4VciCredentialRequest,
httpRequest: http.IncomingMessage, // or use @animo-id/oauth2/RequestLike type
{ agentContext, issuer }: OpenId4VCIssuanceRequestContext
) {
const openId4VcIssuerService = agentContext.dependencyManager.resolve(OpenId4VcIssuerService)
const issuerMetadata = await openId4VcIssuerService.getIssuerMetadata(agentContext, issuer, true)
const vcIssuer = openId4VcIssuerService.getIssuer(agentContext)
const resourceServer = openId4VcIssuerService.getResourceServer(agentContext, issuer)

const fullRequestUrl = joinUriParts(issuerMetadata.credentialIssuer.credential_issuer, [
config.credentialEndpointPath,
])
const resourceRequestResult = await resourceServer.verifyResourceRequest({
authorizationServers: issuerMetadata.authorizationServers,
resourceServer: issuerMetadata.credentialIssuer.credential_issuer,
allowedAuthenticationSchemes: config.dpopRequired ? [SupportedAuthenticationScheme.DPoP] : undefined,
request: {
headers: new Headers(httpRequest.headers as Record<string, string>),
method: httpRequest.method as HttpMethod,
url: fullRequestUrl,
},
})
if (!resourceRequestResult) return null
const { tokenPayload, accessToken, scheme, authorizationServer } = resourceRequestResult

const issuanceSessionRepository = agentContext.dependencyManager.resolve(OpenId4VcIssuanceSessionRepository)

const parsedCredentialRequest = vcIssuer.parseCredentialRequest({
credentialRequest,
})

let issuanceSession: OpenId4VcIssuanceSessionRecord | null = null
const preAuthorizedCode =
typeof tokenPayload['pre-authorized_code'] === 'string' ? tokenPayload['pre-authorized_code'] : undefined
const issuerState = typeof tokenPayload.issuer_state === 'string' ? tokenPayload.issuer_state : undefined

const subject = tokenPayload.sub
if (!subject) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.ServerError,
},
{
internalMessage: `Received token without 'sub' claim. Subject is required for binding issuance session`,
}
)
}

// Already handle request without format. Simplifies next code sections
if (!parsedCredentialRequest.format) {
throw new Oauth2ServerErrorResponseError({
error: parsedCredentialRequest.credentialIdentifier
? Oauth2ErrorCodes.InvalidCredentialRequest
: Oauth2ErrorCodes.UnsupportedCredentialFormat,
error_description: parsedCredentialRequest.credentialIdentifier
? `Credential request containing 'credential_identifier' not supported`
: `Credential format '${parsedCredentialRequest.credentialRequest.format}' not supported`,
})
}

if (preAuthorizedCode || issuerState) {
issuanceSession = await issuanceSessionRepository.findSingleByQuery(agentContext, {
issuerId: issuer.issuerId,
preAuthorizedCode,
issuerState,
})

if (!issuanceSession) {
agentContext.config.logger.warn(
`No issuance session found for incoming credential request for issuer ${
issuer.issuerId
} but access token data has ${
issuerState ? 'issuer_state' : 'pre-authorized_code'
}. Returning error response`,
{
tokenPayload,
}
)

throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.CredentialRequestDenied,
},
{
internalMessage: `No issuance session found for incoming credential request for issuer ${issuer.issuerId} and access token data`,
}
)
}

// Verify the issuance session subject
if (issuanceSession.authorization?.subject) {
if (issuanceSession.authorization.subject !== tokenPayload.sub) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.CredentialRequestDenied,
},
{
internalMessage: `Issuance session authorization subject does not match with the token payload subject for issuance session '${issuanceSession.id}'. Returning error response`,
}
)
}
}
// Statefull session expired
else if (
Date.now() >
addSecondsToDate(issuanceSession.createdAt, config.statefullCredentialOfferExpirationInSeconds).getTime()
) {
issuanceSession.errorMessage = 'Credential offer has expired'
await openId4VcIssuerService.updateState(agentContext, issuanceSession, OpenId4VcIssuanceSessionState.Error)
throw new Oauth2ServerErrorResponseError({
// What is the best error here?
error: Oauth2ErrorCodes.CredentialRequestDenied,
error_description: 'Session expired',
})
} else {
issuanceSession.authorization = {
...issuanceSession.authorization,
subject: tokenPayload.sub,
}
await issuanceSessionRepository.update(agentContext, issuanceSession)
}
}

if (!issuanceSession && config.allowDynamicIssuanceSessions) {
agentContext.config.logger.warn(
`No issuance session found for incoming credential request for issuer ${issuer.issuerId} and access token data has no issuer_state or pre-authorized_code. Creating on-demand issuance session`,
{
tokenPayload,
}
)

// All credential configurations that match the request scope and credential request
// This is just so we don't create an issuance session that will fail immediately after
const credentialConfigurationsForToken = getCredentialConfigurationsMatchingRequestFormat({
credentialConfigurations: getCredentialConfigurationsSupportedForScopes(
issuerMetadata.credentialIssuer.credential_configurations_supported,
tokenPayload.scope?.split(' ') ?? []
),
requestFormat: parsedCredentialRequest.format,
})

if (Object.keys(credentialConfigurationsForToken).length === 0) {
throw new Oauth2ResourceUnauthorizedError(
'No credential configurationss match credential request and access token scope',
{
scheme,
error: Oauth2ErrorCodes.InsufficientScope,
}
)
}

issuanceSession = new OpenId4VcIssuanceSessionRecord({
credentialOfferPayload: {
credential_configuration_ids: Object.keys(credentialConfigurationsForToken),
credential_issuer: issuerMetadata.credentialIssuer.credential_issuer,
},
issuerId: issuer.issuerId,
state: OpenId4VcIssuanceSessionState.CredentialRequestReceived,
clientId: tokenPayload.client_id,
authorization: {
subject: tokenPayload.sub,
},
})

// Save and update
await issuanceSessionRepository.save(agentContext, issuanceSession)
openId4VcIssuerService.emitStateChangedEvent(agentContext, issuanceSession, null)
} else if (!issuanceSession) {
throw new Oauth2ServerErrorResponseError(
{
error: Oauth2ErrorCodes.CredentialRequestDenied,
},
{
internalMessage: `Access token without 'issuer_state' or 'pre-authorized_code' issued by external authorization server provided, but 'allowDynamicIssuanceSessions' is disabled. Either bind the access token to a statefull credential offer, or enable 'allowDynamicIssuanceSessions'.`,
}
)
}

const { credentialResponse } = await openId4VcIssuerService.createCredentialResponse(agentContext, {
issuanceSession,
credentialRequest,
authorization: {
authorizationServer,
accessToken: {
payload: tokenPayload,
value: accessToken,
},
},
})
return credentialResponse
}
}
Loading
Loading