Skip to content

Commit f1161f2

Browse files
authored
feat: DC API oid4vci support (#78)
1 parent 42757e3 commit f1161f2

8 files changed

Lines changed: 175 additions & 33 deletions

File tree

agent/src/endpoints.ts

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -261,44 +261,42 @@ apiRouter.post('/requests/create', async (request: Request, response: Response)
261261

262262
console.log('Requesting definition', JSON.stringify(definition, null, 2))
263263
const queryRequest = dcqlQueryFromRequest(definition, purpose)
264+
const pidSdJwtVct = pidSdJwtCredential({ fields: [] }).vcts[0]
265+
const pidMdocDoctype = pidMdocCredential({ fields: [] }).doctype
266+
264267
let pidCredentialIds = findCredentials(queryRequest.credentials, {
265-
vcts: [pidSdJwtCredential({ fields: [] }).vcts[0]],
266-
doctypes: [pidMdocCredential({ fields: [] }).doctype],
268+
vcts: [pidSdJwtVct],
267269
}).map((query) => query.id)
268270

269-
// create qes credentials if non is present
271+
// create qes credentials if none is present (SD-JWT only)
270272
if (qesRequest && !pidCredentialIds.length) {
271273
addOneOfCredentials(queryRequest, [
272274
{
273275
id: 'qes_pid_sd_jwt',
274276
format: 'dc+sd-jwt',
275277
meta: {
276-
vct_values: [pidSdJwtCredential({ fields: [] }).vcts[0]],
277-
},
278-
require_cryptographic_holder_binding: true,
279-
},
280-
{
281-
id: 'qes_pid_mdoc',
282-
format: 'mso_mdoc',
283-
meta: {
284-
doctype_value: pidMdocCredential({ fields: [] }).doctype,
278+
vct_values: [pidSdJwtVct],
285279
},
286280
require_cryptographic_holder_binding: true,
287281
},
288282
])
289-
pidCredentialIds = ['qes_pid_sd_jwt', 'qes_pid_mdoc']
283+
pidCredentialIds = ['qes_pid_sd_jwt']
290284
}
291285
// create payment credential
292286
const scaId = 'sca_credential'
293287
if (paymentRequest) {
294-
addOneOfCredentials(queryRequest, [
295-
{
296-
id: scaId,
297-
format: 'dc+sd-jwt',
298-
meta: {},
299-
require_cryptographic_holder_binding: true,
300-
},
301-
])
288+
addOneOfCredentials(
289+
queryRequest,
290+
[
291+
{
292+
id: scaId,
293+
format: 'dc+sd-jwt',
294+
meta: {},
295+
require_cryptographic_holder_binding: true,
296+
},
297+
],
298+
{ position: 'prepend' }
299+
)
302300
}
303301

304302
const definitionTransactionData = definition.transaction_data ?? []

agent/src/server.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ async function run() {
129129
AGENT_HOST.includes('ngrok') ||
130130
AGENT_HOST.includes('.ts.net') ||
131131
AGENT_HOST.includes('.serveousercontent.com') ||
132+
AGENT_HOST.includes('trycloudflare.com') ||
132133
AGENT_HOST.includes('localhost')
133134
) {
134135
console.log(path.join(dirname, '../../app/public/assets'))

agent/src/utils/dcql.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ export function findCredentials(
2020
/**
2121
* Adds a new credential set with OR relationship
2222
*/
23-
export function addOneOfCredentials(query: DcqlQuery, newCredentials: DcqlQuery['credentials'][number][]) {
23+
export function addOneOfCredentials(
24+
query: DcqlQuery,
25+
newCredentials: DcqlQuery['credentials'][number][],
26+
options?: { position?: 'append' | 'prepend' }
27+
) {
2428
if (newCredentials.length === 0) return
2529

30+
const position = options?.position ?? 'append'
2631
const existingIds = query.credentials.map((c) => c.id)
2732
if (!query.credential_sets || query.credential_sets.length === 0) {
2833
query.credential_sets = [{ options: [existingIds], required: true }]
@@ -35,8 +40,13 @@ export function addOneOfCredentials(query: DcqlQuery, newCredentials: DcqlQuery[
3540
// biome-ignore lint/suspicious/noExplicitAny: match existing pattern
3641
query.credentials.push(credential as any)
3742
}
38-
query.credential_sets.push({
43+
const newSet = {
3944
options: newIds.map((it) => [it]) as NonEmptyArray<string[]>,
4045
required: true,
41-
})
46+
}
47+
if (position === 'prepend') {
48+
query.credential_sets.unshift(newSet)
49+
} else {
50+
query.credential_sets.push(newSet)
51+
}
4252
}

agent/src/verifier.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,14 @@ export interface PlaygroundVerifierOptions {
1010
purpose: string
1111
credentials: Array<SdJwtCredential | MdocCredential>
1212
// Indexes
13-
credential_sets?: Array<number[]>
13+
credential_sets?: Array<
14+
| number[]
15+
| {
16+
options: number[]
17+
required?: boolean
18+
purpose?: string
19+
}
20+
>
1421
transaction_data?: Array<{
1522
type: string
1623
subtype?: string

agent/src/verifiers/util.ts

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -93,13 +93,24 @@ export function dcqlQueryFromRequest(
9393
}
9494
),
9595
credential_sets: request.credential_sets
96-
? request.credential_sets.map((set) => ({
97-
options: set.map((v) => [`${v}`]),
98-
purpose: purpose ?? request.purpose,
99-
}))
96+
? request.credential_sets.map((set) => {
97+
if (Array.isArray(set)) {
98+
return {
99+
options: set.map((v) => [`${v}`]),
100+
required: true,
101+
purpose: purpose ?? request.purpose,
102+
}
103+
}
104+
return {
105+
options: set.options.map((v) => [`${v}`]),
106+
required: set.required ?? true,
107+
purpose: set.purpose ?? purpose ?? request.purpose,
108+
}
109+
})
100110
: [
101111
{
102112
options: [request.credentials.map((_, index) => `${index}`)],
113+
required: true,
103114
purpose: purpose ?? request.purpose,
104115
},
105116
],

agent/src/verifiers/utopiaGovernment.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,15 @@ export const utopiaGovernmentVerifier = {
127127
credentials: [pidMdocNames, mdlNames],
128128
credential_sets: [[0, 1]],
129129
},
130+
{
131+
name: 'PID (sd-jwt-vc or mdoc) + optional mDL (mdoc)',
132+
purpose: 'PID (sd-jwt-vc or mdoc) + optional mDL (mdoc)',
133+
credentials: [pidSdJwtVcNames, pidMdocNames, mdlNames],
134+
credential_sets: [
135+
{ options: [0, 1], required: true },
136+
{ options: [2], required: false },
137+
],
138+
},
130139
{
131140
name: 'PID - postal code or resident city (mdoc)',
132141
purpose: 'PID - postal code or resident city (mdoc)',

app/components/IssueTab.tsx

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
1-
import { CheckIcon, CopyIcon } from '@radix-ui/react-icons'
1+
import { CheckIcon, CopyIcon, ExclamationTriangleIcon } from '@radix-ui/react-icons'
22
import { RadioGroup } from '@radix-ui/react-radio-group'
33
import Image from 'next/image'
44
import Link from 'next/link'
55
import { type ReadonlyURLSearchParams, useRouter } from 'next/navigation'
66
import { type FormEvent, useEffect, useState } from 'react'
77
import QRCode from 'react-qr-code'
8+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
89
import { Button } from '@/components/ui/button'
910
import { Card } from '@/components/ui/card'
1011
import { Label } from '@/components/ui/label'
@@ -38,9 +39,12 @@ export function IssueTab({
3839

3940
const [selectedAuthorization, setSelectedAuthorization] = useState<string>('none')
4041
const [selectedDeferBy, setDeferBy] = useState<string>('none')
42+
const [issuanceMethod, setIssuanceMethod] = useState<'qr' | 'dcApi'>('qr')
4143

4244
const [credentialOfferUri, setCredentialOfferUri] = useState<string>()
4345
const [userPin, setUserPin] = useState<string>()
46+
const [issueStatus, setIssueStatus] = useState<'idle' | 'issuing' | 'issued' | 'error'>('idle')
47+
const [issueError, setIssueError] = useState<string>()
4448

4549
const selectedIssuer = issuers?.find((i) => i.id === selectedIssuerId)
4650
const router = useRouter()
@@ -68,6 +72,9 @@ export function IssueTab({
6872
if (query.dpop) setRequireDpop(query.dpop === 'true')
6973
if (query.walletAttestation) setRequireWalletAttestation(query.walletAttestation === 'true')
7074
if (query.keyAttestation) setRequireKeyAttestation(query.keyAttestation === 'true')
75+
if (query.initiationMethod === 'dcApi' || query.initiationMethod === 'qr') {
76+
setIssuanceMethod(query.initiationMethod)
77+
}
7178
})
7279
}, [issuers, searchParams])
7380

@@ -84,6 +91,7 @@ export function IssueTab({
8491
params.set('dpop', `${requireDpop}`)
8592
params.set('keyAttestation', `${requireKeyAttestation}`)
8693
params.set('walletAttestation', `${requireWalletAttestation}`)
94+
if (issuanceMethod) params.set('initiationMethod', issuanceMethod)
8795
if (credentialType !== undefined) params.set('credentialType', `${credentialType}`)
8896

8997
const existingSearchParams = new URLSearchParams(searchParams.toString())
@@ -101,6 +109,7 @@ export function IssueTab({
101109
selectedFormat,
102110
selectedAuthorization,
103111
selectedDeferBy,
112+
issuanceMethod,
104113
credentialType,
105114
router,
106115
searchParams,
@@ -109,6 +118,54 @@ export function IssueTab({
109118
requireWalletAttestation,
110119
])
111120

121+
useEffect(() => {
122+
setIssueStatus('idle')
123+
setIssueError(undefined)
124+
}, [issuanceMethod])
125+
126+
const buildDcApiRequestData = (offerUri: string) => {
127+
const queryIndex = offerUri.indexOf('?')
128+
if (queryIndex >= 0) {
129+
const params = new URLSearchParams(offerUri.slice(queryIndex + 1))
130+
const offerUriParam = params.get('credential_offer_uri')
131+
if (offerUriParam) {
132+
return { credential_offer_uri: offerUriParam }
133+
}
134+
const inlineOffer = params.get('credential_offer')
135+
if (inlineOffer) {
136+
try {
137+
return { credential_offer: JSON.parse(inlineOffer) }
138+
} catch {
139+
return { credential_offer: inlineOffer }
140+
}
141+
}
142+
}
143+
144+
try {
145+
return { credential_offer: JSON.parse(offerUri) }
146+
} catch {
147+
return { credential_offer_uri: offerUri }
148+
}
149+
}
150+
151+
const initiateDcIssuance = async (offerUri: string) => {
152+
if (!('credentials' in navigator) || typeof navigator.credentials.create !== 'function') {
153+
throw new Error('Digital Credentials API is not supported in this browser.')
154+
}
155+
const requestData = buildDcApiRequestData(offerUri)
156+
await navigator.credentials.create({
157+
// @ts-expect-error Digital Credentials API typings
158+
digital: {
159+
requests: [
160+
{
161+
protocol: 'openid4vci1.0',
162+
data: requestData,
163+
},
164+
],
165+
},
166+
})
167+
}
168+
112169
async function onSubmitIssueCredential(e: FormEvent) {
113170
e.preventDefault()
114171
console.log(selectedIssuer, credentialType, selectedFormat, selectedAuthorization)
@@ -128,6 +185,19 @@ export function IssueTab({
128185
})
129186
setCredentialOfferUri(offer.credentialOffer)
130187
setUserPin(offer.issuanceSession.userPin)
188+
setIssueError(undefined)
189+
setIssueStatus('idle')
190+
191+
if (issuanceMethod === 'dcApi') {
192+
setIssueStatus('issuing')
193+
try {
194+
await initiateDcIssuance(offer.credentialOffer)
195+
setIssueStatus('issued')
196+
} catch (error) {
197+
setIssueStatus('error')
198+
setIssueError(error instanceof Error ? error.message : 'Unknown error while calling Digital Credentials API')
199+
}
200+
}
131201
}
132202

133203
const copyConfiguration = async () => {
@@ -252,6 +322,19 @@ export function IssueTab({
252322
))}
253323
</RadioGroup>
254324
</div>
325+
<div className="space-y-2">
326+
<Label htmlFor="initiation-method">Initiation Method</Label>
327+
<RadioGroup
328+
name="initiation-method"
329+
required
330+
className="flex flex-col gap-2 md:gap-4 md:flex-row"
331+
onValueChange={(v) => setIssuanceMethod(v as 'qr' | 'dcApi')}
332+
value={issuanceMethod}
333+
>
334+
<MiniRadioItem value="qr" label="QR / Deeplink" />
335+
<MiniRadioItem value="dcApi" label="Digital Credentials API" />
336+
</RadioGroup>
337+
</div>
255338
<div className="space-y-2">
256339
<div>
257340
<Label htmlFor="format">Authentication</Label>
@@ -327,7 +410,11 @@ export function IssueTab({
327410
/>
328411
</div>
329412
<div className="flex justify-center items-center bg-gray-200 min-h-64 w-full rounded-md">
330-
{credentialOfferUri ? (
413+
{issuanceMethod === 'dcApi' ? (
414+
<p className="text-gray-500 text-center px-6">
415+
Digital Credentials API is selected. The credential offer will be sent directly.
416+
</p>
417+
) : credentialOfferUri ? (
331418
<TooltipProvider>
332419
<Tooltip>
333420
<div className="flex flex-col p-5 gap-2 justify-center items-center gap-6">
@@ -372,6 +459,25 @@ export function IssueTab({
372459
<p className="text-gray-500 break-all">Credential offer will be displayed here</p>
373460
)}
374461
</div>
462+
{issuanceMethod === 'dcApi' && issueStatus !== 'idle' && (
463+
<Alert variant={issueStatus === 'issued' ? 'success' : issueStatus === 'error' ? 'destructive' : 'warning'}>
464+
{issueStatus === 'error' ? (
465+
<ExclamationTriangleIcon className="h-4 w-4" />
466+
) : issueStatus === 'issued' ? (
467+
<CheckIcon className="h-5 w-5" />
468+
) : null}
469+
<AlertTitle>
470+
{issueStatus === 'issuing'
471+
? 'Requesting Credential'
472+
: issueStatus === 'issued'
473+
? 'Credential Request Sent'
474+
: 'Issuance Failed'}
475+
</AlertTitle>
476+
{issueStatus === 'error' && issueError && (
477+
<AlertDescription className="mt-2">{issueError}</AlertDescription>
478+
)}
479+
</Alert>
480+
)}
375481
<Button
376482
onClick={onSubmitIssueCredential}
377483
disabled={disabled}

app/components/VerifyBlock.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -481,13 +481,13 @@ export const VerifyBlock = ({ searchParams }: { searchParams: ReadonlyURLSearchP
481481
<div className="gap-2 w-full justify-center flex flex-1">
482482
<div>
483483
<Link href={authorizationRequestUri}>
484-
<Button>Open in Wallet</Button>
484+
<Button type="button">Open in Wallet</Button>
485485
</Link>
486486
</div>
487487
</div>
488488
<div>
489489
<Link href={authorizationRequestUri.replace('openid4vp://', 'id.animo.paradym:')}>
490-
<Button>Open in Paradym Wallet</Button>
490+
<Button type="button">Open in Paradym Wallet</Button>
491491
</Link>
492492
</div>
493493
</div>

0 commit comments

Comments
 (0)