Skip to content

Commit 95b21c2

Browse files
authored
Merge pull request #389 from credebl/feat/JSONLD_JWTVC
feat: support W3C JSON-LD credential issuance and presentation
2 parents eb93e12 + cddee2e commit 95b21c2

12 files changed

Lines changed: 1214 additions & 507 deletions

File tree

docker-compose.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ services:
77
environment:
88
# possible to set values using env variables
99
AFJ_REST_LOG_LEVEL: 1
10+
env_file:
11+
- .env
1012
volumes:
1113
# also possible to set values using json
1214
- ./samples/cliConfig.json:/config.json

src/controllers/openid4vc/holder/credentialBindingResolver.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ export function getCredentialBindingResolver({
102102
supportsJwk &&
103103
(credentialFormat === OpenId4VciCredentialFormatProfile.SdJwtVc ||
104104
credentialFormat === OpenId4VciCredentialFormatProfile.SdJwtDc ||
105+
credentialFormat === OpenId4VciCredentialFormatProfile.JwtVcJsonLd ||
106+
credentialFormat === OpenId4VciCredentialFormatProfile.JwtVcJson ||
107+
credentialFormat === OpenId4VciCredentialFormatProfile.LdpVc ||
105108
credentialFormat === OpenId4VciCredentialFormatProfile.MsoMdoc)
106109
) {
107110
return {

src/controllers/openid4vc/holder/holder.Controller.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,14 @@ export class HolderController extends Controller {
3434
return await holderService.getMdocCredentials(request)
3535
}
3636

37+
/**
38+
* Fetch all W3C credentials in wallet
39+
*/
40+
@Get('/w3c-vcs')
41+
public async getW3cCredentials(@Request() request: Req) {
42+
return await holderService.getW3cCredentials(request)
43+
}
44+
3745
/**
3846
* Decode mso mdoc credential in wallet
3947
*/

src/controllers/openid4vc/holder/holder.service.ts

Lines changed: 80 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,15 @@ import type {
1414
} from '@credo-ts/openid4vc'
1515
import type { Request as Req } from 'express'
1616

17-
import { Mdoc, SdJwtVcRecord, MdocRecord } from '@credo-ts/core'
17+
import {
18+
Mdoc,
19+
SdJwtVcRecord,
20+
MdocRecord,
21+
W3cCredentialRecord,
22+
W3cCredentialService,
23+
// W3cV2CredentialRecord,
24+
// W3cV2CredentialService,
25+
} from '@credo-ts/core'
1826
import {
1927
OpenId4VciAuthorizationFlow,
2028
authorizationCodeGrantIdentifier,
@@ -36,6 +44,19 @@ export class HolderService {
3644
return await agentReq.agent.mdoc.getAll()
3745
}
3846

47+
public async getW3cCredentials(agentReq: Req) {
48+
/*
49+
// W3C V2.0 Support
50+
const [v1Records, v2Records] = await Promise.all([
51+
agentReq.agent.w3cCredentials.getAll(),
52+
agentReq.agent.w3cV2Credentials.getAll(),
53+
])
54+
55+
return [...v1Records, ...v2Records]
56+
*/
57+
return await agentReq.agent.w3cCredentials.getAll()
58+
}
59+
3960
public async decodeMdocCredential(
4061
agentReq: Req,
4162
options: {
@@ -129,10 +150,28 @@ export class HolderService {
129150
const storedCredentials = await Promise.all(
130151
credentialResponse.credentials.map(async (response) => {
131152
const credentialRecord = response.record
132-
// TODO: We can add this later
133-
// if (credential instanceof W3cJwtVerifiableCredential || credential instanceof W3cJsonLdVerifiableCredential) {
134-
// return await agentReq.agent.w3cCredentials.storeCredential({ credential })
135-
// }
153+
154+
if (
155+
credentialRecord instanceof W3cCredentialRecord ||
156+
(credentialRecord as any).type === 'W3cCredentialRecord'
157+
) {
158+
return await agentReq.agent.w3cCredentials.store({
159+
record: credentialRecord as W3cCredentialRecord,
160+
})
161+
}
162+
163+
/*
164+
W3C V2.0 Support
165+
if (
166+
credentialRecord instanceof W3cV2CredentialRecord ||
167+
(credentialRecord as any).type === 'W3cV2CredentialRecord'
168+
) {
169+
return await agentReq.agent.w3cV2Credentials.store({
170+
record: credentialRecord as W3cV2CredentialRecord,
171+
})
172+
}
173+
*/
174+
136175
if (credentialRecord instanceof MdocRecord) {
137176
return await agentReq.agent.mdoc.store({ record: credentialRecord })
138177
}
@@ -141,7 +180,9 @@ export class HolderService {
141180
record: credentialRecord,
142181
})
143182
}
144-
throw new Error(`Unsupported credential record type`)
183+
throw new Error(
184+
`Unsupported credential record type: ${(credentialRecord as any)?.type || typeof credentialRecord}`,
185+
)
145186
}),
146187
)
147188

@@ -211,23 +252,28 @@ export class HolderService {
211252
)
212253
// const presentationExchangeService = agent.dependencyManager.resolve(DifPresentationExchangeService)
213254

214-
if (!resolved.dcql) throw new Error('Missing DCQL on request')
215-
//
216-
let dcqlCredentials
217-
try {
218-
dcqlCredentials = await agentReq.agent.modules.openid4vc.holder.selectCredentialsForDcqlRequest(
255+
let acceptOptions: any = {
256+
authorizationRequestPayload: resolved.authorizationRequestPayload,
257+
origin: body.options?.origin,
258+
}
259+
260+
if (resolved.dcql) {
261+
const dcqlCredentials = await agentReq.agent.modules.openid4vc.holder.selectCredentialsForDcqlRequest(
219262
resolved.dcql.queryResult,
220263
)
221-
} catch (error) {
222-
throw error
264+
acceptOptions.dcql = { credentials: dcqlCredentials as DcqlCredentialsForRequest }
265+
} else if (resolved.presentationExchange) {
266+
const pexCredentials =
267+
await agentReq.agent.modules.openid4vc.holder.selectCredentialsForPresentationExchangeRequest(
268+
resolved.presentationExchange.credentialsForRequest,
269+
)
270+
acceptOptions.presentationExchange = { credentials: pexCredentials }
271+
} else {
272+
throw new Error('Missing DCQL or Presentation Exchange on request')
223273
}
224-
const submissionResult = await agentReq.agent.modules.openid4vc.holder.acceptOpenId4VpAuthorizationRequest({
225-
authorizationRequestPayload: resolved.authorizationRequestPayload,
226-
dcql: {
227-
credentials: dcqlCredentials as DcqlCredentialsForRequest,
228-
},
229-
origin: body.options?.origin,
230-
})
274+
275+
const submissionResult =
276+
await agentReq.agent.modules.openid4vc.holder.acceptOpenId4VpAuthorizationRequest(acceptOptions)
231277
if (submissionResult.serverResponse) {
232278
const { serverResponse, ...rest } = submissionResult
233279

@@ -243,7 +289,20 @@ export class HolderService {
243289
}
244290

245291
public async deleteCredential(agentReq: Req, { credentialId, credentialType }: DeleteCredentialBody) {
246-
if (credentialType === CredentialType.SD_JWT) {
292+
if (credentialType === CredentialType.W3C_VC) {
293+
const w3cCredentialService = await agentReq.agent.dependencyManager.resolve(W3cCredentialService)
294+
// const w3cV2CredentialService = await agentReq.agent.dependencyManager.resolve(W3cV2CredentialService)
295+
296+
try {
297+
return await w3cCredentialService.removeCredentialRecord(agentReq.agent.context, credentialId)
298+
} catch (error) {
299+
/*
300+
// W3C V2.0 Support
301+
return await w3cV2CredentialService.removeCredentialRecord(agentReq.agent.context, credentialId)
302+
*/
303+
throw error
304+
}
305+
} else if (credentialType === CredentialType.SD_JWT) {
247306
const sdJwtRecord = await agentReq.agent.sdJwtVc.getById(credentialId)
248307
if (sdJwtRecord) {
249308
return await agentReq.agent.sdJwtVc.deleteById(credentialId)

src/controllers/openid4vc/issuance-sessions/issuance-sessions.service.ts

Lines changed: 117 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import type { OpenId4VcIssuanceSessionsCreateOffer } from '../types/issuer.types'
22
import type { Request as Req } from 'express'
33

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

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

27-
this.validateCredentialConfig(cred, supported)
27+
const format = cred.format as unknown as CredentialFormat
28+
const isJsonLdFormat = format === CredentialFormat.JwtVcJsonLd || format === CredentialFormat.LdpVc
29+
const effectiveVersion = options.version === 'v2.0' && isJsonLdFormat ? 'v2.0' : undefined
30+
31+
this.validateCredentialConfig(cred, supported, effectiveVersion)
2832

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

3135
const currentVct = cred.payload && 'vct' in cred.payload ? cred.payload.vct : undefined
32-
return {
33-
...cred,
34-
payload: {
36+
const transformedPayload = this.transformPayloadForVersion(
37+
{
3538
...cred.payload,
3639
vct: currentVct ?? (typeof supported.vct === 'string' ? supported.vct : undefined),
3740
...(statusBlock ? { status: statusBlock } : {}),
3841
},
42+
effectiveVersion,
43+
)
44+
45+
return {
46+
...cred,
47+
payload: transformedPayload,
3948
}
4049
}),
4150
)
@@ -53,56 +62,52 @@ class IssuanceSessionsService {
5362
if (!issuerModule) {
5463
throw new Error('OID4VC issuer module not initialized')
5564
}
56-
const preAuthorizedCodeFlowConfig = this.resolvePreAuthorizedCodeFlowConfig(options.preAuthorizedCodeFlowConfig)
57-
5865
const { credentialOffer, issuanceSession } = await issuerModule.createCredentialOffer({
5966
issuerId: publicIssuerId,
6067
issuanceMetadata: options.issuanceMetadata,
6168
credentialConfigurationIds: credentials.map((c) => c.credentialSupportedId),
62-
preAuthorizedCodeFlowConfig,
69+
preAuthorizedCodeFlowConfig: options.preAuthorizedCodeFlowConfig,
6370
authorizationCodeFlowConfig: options.authorizationCodeFlowConfig,
71+
version: 'v1',
6472
})
6573

6674
return { credentialOffer, issuanceSession }
6775
}
6876

69-
private resolvePreAuthorizedCodeFlowConfig(
70-
config: OpenId4VcIssuanceSessionsCreateOffer['preAuthorizedCodeFlowConfig'],
71-
) {
72-
if (!config) return undefined
73-
74-
const hasTxCode = config.txCode != null
75-
const hasAuthServerUrl = config.authorizationServerUrl != null
76-
77-
if (hasTxCode !== hasAuthServerUrl) {
78-
throw new BadRequestError(
79-
'Both txCode and authorizationServerUrl must be provided together for normal flow, or both must be omitted for no-auth flow',
77+
private validateCredentialConfig(cred: any, supported: any, version?: string) {
78+
if (!supported) {
79+
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
80+
}
81+
if (supported.format !== cred.format) {
82+
throw new Error(
83+
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
8084
)
8185
}
8286

83-
if (!hasTxCode) return {}
87+
const isW3cFormat =
88+
cred.format === CredentialFormat.JwtVcJson ||
89+
cred.format === CredentialFormat.JwtVcJsonLd ||
90+
cred.format === CredentialFormat.LdpVc
8491

85-
if (Object.keys(config.txCode!).length === 0) {
86-
throw new BadRequestError('txCode must not be an empty object when provided')
92+
if (isW3cFormat && !cred.payload?.credentialSubject) {
93+
throw new BadRequestError(
94+
`Credential payload for '${cred.credentialSupportedId}' must contain 'credentialSubject'`,
95+
)
8796
}
8897

89-
if (config.authorizationServerUrl!.trim() === '') {
90-
throw new BadRequestError('authorizationServerUrl must not be an empty string when provided')
98+
if (
99+
version === 'v2.0' &&
100+
cred.payload?.issuer &&
101+
typeof cred.payload.issuer === 'object' &&
102+
!cred.payload.issuer.id
103+
) {
104+
throw new BadRequestError(`Issuer object for '${cred.credentialSupportedId}' must contain 'id' property`)
91105
}
92106

93-
return { txCode: config.txCode, authorizationServerUrl: config.authorizationServerUrl }
107+
this.validateSignerOptions(cred)
94108
}
95109

96-
private validateCredentialConfig(cred: any, supported: any) {
97-
if (!supported) {
98-
throw new Error(`CredentialSupportedId '${cred.credentialSupportedId}' is not supported by issuer`)
99-
}
100-
if (supported.format !== cred.format) {
101-
throw new Error(
102-
`Format mismatch for '${cred.credentialSupportedId}': expected '${supported.format}', got '${cred.format}'`,
103-
)
104-
}
105-
110+
private validateSignerOptions(cred: any) {
106111
if (!cred.signerOptions?.method) {
107112
throw new BadRequestError(
108113
`signerOptions must be provided and allowed methods are ${Object.values(SignerMethod).join(', ')}`,
@@ -122,6 +127,82 @@ class IssuanceSessionsService {
122127
}
123128
}
124129

130+
private transformPayloadForVersion(payload: any, version: 'v1.1' | 'v2.0' | undefined) {
131+
if (version !== 'v2.0') {
132+
return payload
133+
}
134+
135+
const transformed = { ...payload }
136+
137+
// Rule: issuanceDate -> validFrom
138+
if (transformed.issuanceDate && !transformed.validFrom) {
139+
transformed.validFrom = transformed.issuanceDate
140+
}
141+
142+
// Rule: expirationDate -> validUntil
143+
if (transformed.expirationDate && !transformed.validUntil) {
144+
transformed.validUntil = transformed.expirationDate
145+
delete transformed.expirationDate
146+
}
147+
148+
// Normalize dates to ISO format
149+
if (transformed.validFrom) transformed.validFrom = this.formatDate(transformed.validFrom)
150+
if (transformed.validUntil) transformed.validUntil = this.formatDate(transformed.validUntil)
151+
152+
// Rule: issuer string -> object (standardizing for v2.0 if it is a DID)
153+
if (typeof transformed.issuer === 'string' && transformed.issuer.startsWith('did:')) {
154+
transformed.issuer = { id: transformed.issuer }
155+
}
156+
157+
this.updateContextForVersion(transformed, version)
158+
159+
return transformed
160+
}
161+
162+
private formatDate(date: any): any {
163+
if (!date) return undefined
164+
if (date instanceof Date) return date.toISOString()
165+
if (typeof date === 'string') {
166+
try {
167+
const d = new Date(date)
168+
if (Number.isNaN(d.getTime())) return date
169+
return d.toISOString()
170+
} catch {
171+
return date
172+
}
173+
}
174+
return date
175+
}
176+
177+
private updateContextForVersion(transformed: any, version: 'v1.1' | 'v2.0' | undefined) {
178+
const v1Context = CREDENTIALS_CONTEXT_V1_URL
179+
const v2Context = CREDENTIALS_CONTEXT_V2_URL
180+
181+
if (version === 'v2.0') {
182+
let currentCtx: any[] = []
183+
if (Array.isArray(transformed['@context'])) {
184+
currentCtx = transformed['@context']
185+
} else if (typeof transformed['@context'] === 'string') {
186+
currentCtx = [transformed['@context']]
187+
}
188+
189+
const ctxSet = new Set(currentCtx)
190+
ctxSet.delete(v1Context)
191+
ctxSet.delete(v2Context)
192+
// W3C V2.0 requires the V2 context to be the very first element.
193+
transformed['@context'] = [v2Context, v1Context, ...Array.from(ctxSet)]
194+
} else if (!transformed['@context']) {
195+
// W3C V1.1 / Default behavior
196+
transformed['@context'] = [v1Context]
197+
} else if (Array.isArray(transformed['@context'])) {
198+
const ctxSet = new Set(transformed['@context'])
199+
ctxSet.delete(v1Context)
200+
transformed['@context'] = [v1Context, ...Array.from(ctxSet)]
201+
} else if (typeof transformed['@context'] === 'string') {
202+
transformed['@context'] = [v1Context, transformed['@context']]
203+
}
204+
}
205+
125206
private async processStatusList(
126207
cred: any,
127208
options: OpenId4VcIssuanceSessionsCreateOffer,

src/controllers/openid4vc/types/holder.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ export interface DeleteCredentialBody {
3535
export enum CredentialType {
3636
SD_JWT = 'sd-jwt-vc',
3737
MSO_MDOC = 'mso_mdoc',
38+
W3C_VC = 'w3c-vc',
3839
}

0 commit comments

Comments
 (0)