Skip to content
Merged
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ services:
environment:
# possible to set values using env variables
AFJ_REST_LOG_LEVEL: 1
env_file:
- .env
volumes:
# also possible to set values using json
- ./samples/cliConfig.json:/config.json
Expand Down
3 changes: 3 additions & 0 deletions src/controllers/openid4vc/holder/credentialBindingResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ export function getCredentialBindingResolver({
supportsJwk &&
(credentialFormat === OpenId4VciCredentialFormatProfile.SdJwtVc ||
credentialFormat === OpenId4VciCredentialFormatProfile.SdJwtDc ||
credentialFormat === OpenId4VciCredentialFormatProfile.JwtVcJsonLd ||
credentialFormat === OpenId4VciCredentialFormatProfile.JwtVcJson ||
credentialFormat === OpenId4VciCredentialFormatProfile.LdpVc ||
credentialFormat === OpenId4VciCredentialFormatProfile.MsoMdoc)
) {
return {
Expand Down
8 changes: 8 additions & 0 deletions src/controllers/openid4vc/holder/holder.Controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,14 @@ export class HolderController extends Controller {
return await holderService.getMdocCredentials(request)
}

/**
* Fetch all W3C credentials in wallet
*/
@Get('/w3c-vcs')
public async getW3cCredentials(@Request() request: Req) {
return await holderService.getW3cCredentials(request)
}

/**
* Decode mso mdoc credential in wallet
*/
Expand Down
101 changes: 80 additions & 21 deletions src/controllers/openid4vc/holder/holder.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,15 @@ import type {
} from '@credo-ts/openid4vc'
import type { Request as Req } from 'express'

import { Mdoc, SdJwtVcRecord, MdocRecord } from '@credo-ts/core'
import {
Mdoc,
SdJwtVcRecord,
MdocRecord,
W3cCredentialRecord,
W3cCredentialService,
// W3cV2CredentialRecord,
// W3cV2CredentialService,
} from '@credo-ts/core'
import {
OpenId4VciAuthorizationFlow,
authorizationCodeGrantIdentifier,
Expand All @@ -36,6 +44,19 @@ export class HolderService {
return await agentReq.agent.mdoc.getAll()
}

public async getW3cCredentials(agentReq: Req) {
/*
// W3C V2.0 Support
const [v1Records, v2Records] = await Promise.all([
agentReq.agent.w3cCredentials.getAll(),
agentReq.agent.w3cV2Credentials.getAll(),
])

return [...v1Records, ...v2Records]
*/
return await agentReq.agent.w3cCredentials.getAll()
}

public async decodeMdocCredential(
agentReq: Req,
options: {
Expand Down Expand Up @@ -129,10 +150,28 @@ export class HolderService {
const storedCredentials = await Promise.all(
credentialResponse.credentials.map(async (response) => {
const credentialRecord = response.record
// TODO: We can add this later
// if (credential instanceof W3cJwtVerifiableCredential || credential instanceof W3cJsonLdVerifiableCredential) {
// return await agentReq.agent.w3cCredentials.storeCredential({ credential })
// }

if (
credentialRecord instanceof W3cCredentialRecord ||
(credentialRecord as any).type === 'W3cCredentialRecord'
) {
return await agentReq.agent.w3cCredentials.store({
record: credentialRecord as W3cCredentialRecord,
})
}

/*
W3C V2.0 Support
if (
credentialRecord instanceof W3cV2CredentialRecord ||
(credentialRecord as any).type === 'W3cV2CredentialRecord'
) {
return await agentReq.agent.w3cV2Credentials.store({
record: credentialRecord as W3cV2CredentialRecord,
})
}
*/

if (credentialRecord instanceof MdocRecord) {
return await agentReq.agent.mdoc.store({ record: credentialRecord })
}
Expand All @@ -141,7 +180,9 @@ export class HolderService {
record: credentialRecord,
})
}
throw new Error(`Unsupported credential record type`)
throw new Error(
`Unsupported credential record type: ${(credentialRecord as any)?.type || typeof credentialRecord}`,
)
}),
)

Expand Down Expand Up @@ -211,23 +252,28 @@ export class HolderService {
)
// const presentationExchangeService = agent.dependencyManager.resolve(DifPresentationExchangeService)

if (!resolved.dcql) throw new Error('Missing DCQL on request')
//
let dcqlCredentials
try {
dcqlCredentials = await agentReq.agent.modules.openid4vc.holder.selectCredentialsForDcqlRequest(
let acceptOptions: any = {
authorizationRequestPayload: resolved.authorizationRequestPayload,
origin: body.options?.origin,
}

if (resolved.dcql) {
const dcqlCredentials = await agentReq.agent.modules.openid4vc.holder.selectCredentialsForDcqlRequest(
resolved.dcql.queryResult,
)
} catch (error) {
throw error
acceptOptions.dcql = { credentials: dcqlCredentials as DcqlCredentialsForRequest }
} else if (resolved.presentationExchange) {
const pexCredentials =
await agentReq.agent.modules.openid4vc.holder.selectCredentialsForPresentationExchangeRequest(
resolved.presentationExchange.credentialsForRequest,
)
acceptOptions.presentationExchange = { credentials: pexCredentials }
} else {
throw new Error('Missing DCQL or Presentation Exchange on request')
}
const submissionResult = await agentReq.agent.modules.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({
authorizationRequestPayload: resolved.authorizationRequestPayload,
dcql: {
credentials: dcqlCredentials as DcqlCredentialsForRequest,
},
origin: body.options?.origin,
})

const submissionResult =
await agentReq.agent.modules.openid4vc.holder.acceptOpenId4VpAuthorizationRequest(acceptOptions)
if (submissionResult.serverResponse) {
const { serverResponse, ...rest } = submissionResult

Expand All @@ -243,7 +289,20 @@ export class HolderService {
}

public async deleteCredential(agentReq: Req, { credentialId, credentialType }: DeleteCredentialBody) {
if (credentialType === CredentialType.SD_JWT) {
if (credentialType === CredentialType.W3C_VC) {
const w3cCredentialService = await agentReq.agent.dependencyManager.resolve(W3cCredentialService)
// const w3cV2CredentialService = await agentReq.agent.dependencyManager.resolve(W3cV2CredentialService)

try {
return await w3cCredentialService.removeCredentialRecord(agentReq.agent.context, credentialId)
} catch (error) {
/*
// W3C V2.0 Support
return await w3cV2CredentialService.removeCredentialRecord(agentReq.agent.context, credentialId)
*/
throw error
}
} else if (credentialType === CredentialType.SD_JWT) {
const sdJwtRecord = await agentReq.agent.sdJwtVc.getById(credentialId)
if (sdJwtRecord) {
return await agentReq.agent.sdJwtVc.deleteById(credentialId)
Expand Down
153 changes: 117 additions & 36 deletions src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { OpenId4VcIssuanceSessionsCreateOffer } from '../types/issuer.types'
import type { Request as Req } from 'express'

import { type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc'
import { OpenId4VcIssuanceSessionRepository } from '@credo-ts/openid4vc'
import { CREDENTIALS_CONTEXT_V1_URL, CREDENTIALS_CONTEXT_V2_URL } from '@credo-ts/core'
import { OpenId4VcIssuanceSessionRepository, type OpenId4VcIssuanceSessionState } from '@credo-ts/openid4vc'

import { CredentialFormat, SignerMethod } from '../../../enums/enum'
import { BadRequestError, NotFoundError } from '../../../errors/errors'
Expand All @@ -24,18 +24,27 @@ class IssuanceSessionsService {
credentials.map(async (cred) => {
const supported = issuer.credentialConfigurationsSupported[cred.credentialSupportedId]

this.validateCredentialConfig(cred, supported)
const format = cred.format as unknown as CredentialFormat
const isJsonLdFormat = format === CredentialFormat.JwtVcJsonLd || format === CredentialFormat.LdpVc
const effectiveVersion = options.version === 'v2.0' && isJsonLdFormat ? 'v2.0' : undefined

this.validateCredentialConfig(cred, supported, effectiveVersion)

const statusBlock = await this.processStatusList(cred, options, agentReq, offerStatusInfo)

const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined
return {
...cred,
payload: {
const transformedPayload = this.transformPayloadForVersion(
{
...cred.payload,
vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined),
...(statusBlock ? { status: statusBlock } : {}),
},
effectiveVersion,
)
Comment thread
sagarkhole4 marked this conversation as resolved.

return {
...cred,
payload: transformedPayload,
}
}),
)
Expand All @@ -53,56 +62,52 @@ class IssuanceSessionsService {
if (!issuerModule) {
throw new Error('OID4VC issuer module not initialized')
}
const preAuthorizedCodeFlowConfig = this.resolvePreAuthorizedCodeFlowConfig(options.preAuthorizedCodeFlowConfig)

const { credentialOffer, issuanceSession } = await issuerModule.createCredentialOffer({
issuerId: publicIssuerId,
issuanceMetadata: options.issuanceMetadata,
credentialConfigurationIds: credentials.map((c) => c.credentialSupportedId),
preAuthorizedCodeFlowConfig,
preAuthorizedCodeFlowConfig: options.preAuthorizedCodeFlowConfig,
authorizationCodeFlowConfig: options.authorizationCodeFlowConfig,
version: 'v1',
})

return { credentialOffer, issuanceSession }
}

private resolvePreAuthorizedCodeFlowConfig(
config: OpenId4VcIssuanceSessionsCreateOffer['preAuthorizedCodeFlowConfig'],
) {
if (!config) return undefined

const hasTxCode = config.txCode != null
const hasAuthServerUrl = config.authorizationServerUrl != null

if (hasTxCode !== hasAuthServerUrl) {
throw new BadRequestError(
'Both txCode and authorizationServerUrl must be provided together for normal flow, or both must be omitted for no-auth flow',
private validateCredentialConfig(cred: any, supported: any, version?: string) {
if (!supported) {
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
}
if (supported.format !== cred.format) {
throw new Error(
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
)
}

if (!hasTxCode) return {}
const isW3cFormat =
cred.format === CredentialFormat.JwtVcJson ||
cred.format === CredentialFormat.JwtVcJsonLd ||
cred.format === CredentialFormat.LdpVc

if (Object.keys(config.txCode!).length === 0) {
throw new BadRequestError('txCode must not be an empty object when provided')
if (isW3cFormat && !cred.payload?.credentialSubject) {
throw new BadRequestError(
`Credential payload for '${cred.credentialSupportedId}' must contain 'credentialSubject'`,
)
}

if (config.authorizationServerUrl!.trim() === '') {
throw new BadRequestError('authorizationServerUrl must not be an empty string when provided')
if (
version === 'v2.0' &&
cred.payload?.issuer &&
typeof cred.payload.issuer === 'object' &&
!cred.payload.issuer.id
) {
throw new BadRequestError(`Issuer object for '${cred.credentialSupportedId}' must contain 'id' property`)
}

return { txCode: config.txCode, authorizationServerUrl: config.authorizationServerUrl }
this.validateSignerOptions(cred)
}

private validateCredentialConfig(cred: any, supported: any) {
if (!supported) {
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
}
if (supported.format !== cred.format) {
throw new Error(
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
)
}

private validateSignerOptions(cred: any) {
if (!cred.signerOptions?.method) {
throw new BadRequestError(
`signerOptions must be provided and allowed methods are ${Object.values(SignerMethod).join(', ')}`,
Expand All @@ -122,6 +127,82 @@ class IssuanceSessionsService {
}
}

private transformPayloadForVersion(payload: any, version: 'v1.1' | 'v2.0' | undefined) {
if (version !== 'v2.0') {
return payload
}

const transformed = { ...payload }

// Rule: issuanceDate -> validFrom
if (transformed.issuanceDate && !transformed.validFrom) {
transformed.validFrom = transformed.issuanceDate
}

// Rule: expirationDate -> validUntil
if (transformed.expirationDate && !transformed.validUntil) {
transformed.validUntil = transformed.expirationDate
delete transformed.expirationDate
}

// Normalize dates to ISO format
if (transformed.validFrom) transformed.validFrom = this.formatDate(transformed.validFrom)
if (transformed.validUntil) transformed.validUntil = this.formatDate(transformed.validUntil)

// Rule: issuer string -> object (standardizing for v2.0 if it is a DID)
if (typeof transformed.issuer === 'string' && transformed.issuer.startsWith('did:')) {
transformed.issuer = { id: transformed.issuer }
}

this.updateContextForVersion(transformed, version)

return transformed
}

private formatDate(date: any): any {
if (!date) return undefined
if (date instanceof Date) return date.toISOString()
if (typeof date === 'string') {
try {
const d = new Date(date)
if (Number.isNaN(d.getTime())) return date
return d.toISOString()
} catch {
return date
}
}
return date
}

private updateContextForVersion(transformed: any, version: 'v1.1' | 'v2.0' | undefined) {
const v1Context = CREDENTIALS_CONTEXT_V1_URL
const v2Context = CREDENTIALS_CONTEXT_V2_URL

if (version === 'v2.0') {
let currentCtx: any[] = []
if (Array.isArray(transformed['@context'])) {
currentCtx = transformed['@context']
} else if (typeof transformed['@context'] === 'string') {
currentCtx = [transformed['@context']]
}

const ctxSet = new Set(currentCtx)
ctxSet.delete(v1Context)
ctxSet.delete(v2Context)
// W3C V2.0 requires the V2 context to be the very first element.
transformed['@context'] = [v2Context, v1Context, ...Array.from(ctxSet)]
} else if (!transformed['@context']) {
// W3C V1.1 / Default behavior
transformed['@context'] = [v1Context]
} else if (Array.isArray(transformed['@context'])) {
const ctxSet = new Set(transformed['@context'])
ctxSet.delete(v1Context)
transformed['@context'] = [v1Context, ...Array.from(ctxSet)]
} else if (typeof transformed['@context'] === 'string') {
transformed['@context'] = [v1Context, transformed['@context']]
}
}

private async processStatusList(
cred: any,
options: OpenId4VcIssuanceSessionsCreateOffer,
Expand Down
1 change: 1 addition & 0 deletions src/controllers/openid4vc/types/holder.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,5 @@ export interface DeleteCredentialBody {
export enum CredentialType {
SD_JWT = 'sd-jwt-vc',
MSO_MDOC = 'mso_mdoc',
W3C_VC = 'w3c-vc',
}
Loading
Loading