Skip to content

Commit ab819cb

Browse files
committed
UPDATE oid4vp credential error handling
- mismatch profile and presets are caught before submission - Improvements to mobile experience - docs updated
1 parent bd61eae commit ab819cb

6 files changed

Lines changed: 196 additions & 21 deletions

File tree

docs/packages/wallet.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ services:
8282
- **`session isolation key is required`:** supply `looking_glass_session_id` or `request_id` in `/submit`.
8383
- **`response_uri ... does not match trusted verifier callback`:** request object callback does not match `WALLET_TARGET_BASE_URL`.
8484
- **`credential_jwt sub does not match wallet_subject`:** provided credential is bound to a different holder.
85+
- **`wallet does not have a credential that satisfies the presentation request`:** ensure the selected OID4VCI credential profile issues a format requested by the OID4VP DCQL preset, or provide a matching `credential_jwt`.
8586
- **Upstream timeout/failure:** check `WALLET_HTTP_TIMEOUT` and VC target health.
8687

8788
## Versioning And Tags

docs/starlight/src/content/docs/protocols/oid4vp.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ description: "OpenID4VP verifier flows with DCQL request contracts, direct_post
2525
- **DCQL direct_post** -- Create request object, hand off to wallet, evaluate verifier policy
2626
- **DCQL direct_post.jwt** -- Same flow with encrypted response transport and inner JWT validation
2727

28+
## Wallet Credential Profile Matching
29+
30+
The Looking Glass OID4VP controls preflight the selected VC credential profile against the DCQL preset before wallet submission. If the preset requests `dc+sd-jwt`, `jwt_vc_json`, `jwt_vc_json-ld`, or `ldp_vc`, the wallet auto-issue profile must issue one of those formats. Otherwise the wallet action is blocked with an explanation, because the wallet cannot build a matching `vp_token` from the selected profile.
31+
32+
When a `credential_jwt` is pasted manually, the UI allows submission but warns when the selected auto-issue profile does not match the DCQL format. The wallet and verifier still validate the actual credential and will reject the presentation if the credential evidence does not satisfy the request.
33+
2834
## Example DCQL Query
2935

3036
Example DCQL query requesting a `university_degree` credential with `degree` and `graduation_year` claims:

frontend/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

frontend/src/lookingglass/components/OID4VPWalletModal.tsx

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ interface OID4VPWalletModalProps {
1818
walletHandoffQRCodeError: string | null
1919
walletSubjectInput: string
2020
onWalletSubjectInputChange: (value: string) => void
21+
credentialProfileLabel: string
22+
walletCompatibilityError: string | null
23+
walletCompatibilityWarning: string | null
2124
credentialJWTInput: string
2225
onCredentialJWTInputChange: (value: string) => void
2326
disclosureOptions: string[]
@@ -47,6 +50,9 @@ export function OID4VPWalletModal({
4750
walletHandoffQRCodeError,
4851
walletSubjectInput,
4952
onWalletSubjectInputChange,
53+
credentialProfileLabel,
54+
walletCompatibilityError,
55+
walletCompatibilityWarning,
5056
credentialJWTInput,
5157
onCredentialJWTInputChange,
5258
disclosureOptions,
@@ -62,6 +68,8 @@ export function OID4VPWalletModal({
6268
submitMessage,
6369
onSubmitWalletResponse,
6470
}: OID4VPWalletModalProps) {
71+
const hasWalletGateError = Boolean(walletCompatibilityError)
72+
6573
return (
6674
<motion.div
6775
initial={{ opacity: 0 }}
@@ -116,6 +124,11 @@ export function OID4VPWalletModal({
116124
<span className="text-surface-400">did:web allowlist:</span> <code>{didWebAllowedHosts.join(', ')}</code>
117125
</div>
118126
)}
127+
{credentialProfileLabel && (
128+
<div>
129+
<span className="text-surface-400">auto-issue profile:</span> <code>{credentialProfileLabel}</code>
130+
</div>
131+
)}
119132
</div>
120133
</div>
121134

@@ -167,6 +180,19 @@ export function OID4VPWalletModal({
167180
/>
168181
</div>
169182

183+
{(walletCompatibilityError || walletCompatibilityWarning) && (
184+
<div className={`rounded-lg border p-3 text-[11px] sm:text-xs leading-relaxed ${
185+
walletCompatibilityError
186+
? 'border-red-500/30 bg-red-500/5 text-red-300'
187+
: 'border-amber-500/30 bg-amber-500/5 text-amber-300'
188+
}`}>
189+
<div className="font-medium mb-1">
190+
{walletCompatibilityError ? 'Wallet credential gate failed' : 'Wallet credential gate warning'}
191+
</div>
192+
<div>{walletCompatibilityError || walletCompatibilityWarning}</div>
193+
</div>
194+
)}
195+
170196
<div className="space-y-1.5">
171197
<div className="text-xs sm:text-sm font-medium text-surface-300">Selective disclosure claims</div>
172198
<p className="text-[11px] sm:text-xs text-surface-400">
@@ -226,9 +252,9 @@ export function OID4VPWalletModal({
226252
<div className="text-[11px] sm:text-xs text-violet-200 font-medium">Expert stepwise ceremony</div>
227253
<div className="grid grid-cols-2 gap-2">
228254
<button type="button" onClick={() => onExecuteWalletStep('bootstrap')} disabled={submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">1) Bootstrap wallet</button>
229-
<button type="button" onClick={() => onExecuteWalletStep('issue_credential')} disabled={submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">2) Issue credential</button>
230-
<button type="button" onClick={() => onExecuteWalletStep('build_presentation')} disabled={!canSubmitWalletInteraction || submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">3) Build vp_token</button>
231-
<button type="button" onClick={() => onExecuteWalletStep('submit_response')} disabled={!canSubmitWalletInteraction || submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">4) Submit response</button>
255+
<button type="button" onClick={() => onExecuteWalletStep('issue_credential')} disabled={hasWalletGateError || submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">2) Issue credential</button>
256+
<button type="button" onClick={() => onExecuteWalletStep('build_presentation')} disabled={!canSubmitWalletInteraction || hasWalletGateError || submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">3) Build vp_token</button>
257+
<button type="button" onClick={() => onExecuteWalletStep('submit_response')} disabled={!canSubmitWalletInteraction || hasWalletGateError || submitPending} className="px-2 py-1.5 rounded border border-white/10 bg-surface-900 text-[11px] sm:text-xs text-surface-200 hover:text-white disabled:opacity-50">4) Submit response</button>
232258
</div>
233259
<div className="text-[11px] sm:text-xs text-surface-300">
234260
Last step: <code>{stepwiseLastStep || 'none'}</code>
@@ -237,7 +263,7 @@ export function OID4VPWalletModal({
237263
</div>
238264
)}
239265

240-
{walletMode === 'one_click' && !canSubmitWalletInteraction && (
266+
{walletMode === 'one_click' && !canSubmitWalletInteraction && !walletCompatibilityError && (
241267
<p className="text-[11px] sm:text-xs text-amber-400">
242268
Missing request context. Re-run OID4VP request creation.
243269
</p>
@@ -271,7 +297,7 @@ export function OID4VPWalletModal({
271297
{walletMode === 'one_click' && (
272298
<button
273299
onClick={onSubmitWalletResponse}
274-
disabled={!canSubmitWalletInteraction || submitPending}
300+
disabled={!canSubmitWalletInteraction || hasWalletGateError || submitPending}
275301
className="inline-flex items-center gap-2 px-3 py-2 rounded-lg bg-violet-500/20 border border-violet-500/30 text-violet-200 text-xs sm:text-sm font-medium hover:bg-violet-500/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
276302
>
277303
{submitPending && <Loader2 className="w-4 h-4 animate-spin" />}

frontend/src/protocols/config/oid4vp.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ export interface OID4VPDCQLPreset {
77
query: string
88
}
99

10+
export interface OID4VPDCQLCredentialRequirement {
11+
id: string
12+
format: string
13+
}
14+
1015
export const OID4VP_DEFAULT_DISCLOSURE_HINTS = [
1116
'degree',
1217
'graduation_year',
@@ -133,6 +138,46 @@ export const OID4VP_DCQL_PRESETS: OID4VPDCQLPreset[] = [
133138

134139
export const DEFAULT_OID4VP_DCQL_PRESET_ID = OID4VP_DCQL_PRESETS[0]?.id || 'degree-core'
135140

141+
function asRecord(value: unknown): Record<string, unknown> | null {
142+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
143+
return null
144+
}
145+
return value as Record<string, unknown>
146+
}
147+
148+
export function parseOID4VPDCQLCredentialRequirements(rawQuery: string): OID4VPDCQLCredentialRequirement[] {
149+
const normalized = rawQuery.trim()
150+
if (!normalized) {
151+
return []
152+
}
153+
154+
const payload = asRecord(JSON.parse(normalized))
155+
if (!payload || !Array.isArray(payload.credentials)) {
156+
return []
157+
}
158+
159+
return payload.credentials
160+
.map((rawCredential) => {
161+
const credential = asRecord(rawCredential)
162+
if (!credential) {
163+
return null
164+
}
165+
return {
166+
id: typeof credential.id === 'string' ? credential.id.trim() : '',
167+
format: typeof credential.format === 'string' ? credential.format.trim() : '',
168+
}
169+
})
170+
.filter((requirement): requirement is OID4VPDCQLCredentialRequirement => Boolean(requirement))
171+
}
172+
173+
export function getOID4VPDCQLCredentialFormats(rawQuery: string): string[] {
174+
const formats = parseOID4VPDCQLCredentialRequirements(rawQuery)
175+
.map((requirement) => requirement.format)
176+
.filter(Boolean)
177+
178+
return Array.from(new Set(formats)).sort()
179+
}
180+
136181
export function parseSDJWTDisclosureClaimNames(rawCredential: string): string[] {
137182
const normalized = rawCredential.trim()
138183
if (!normalized) {

0 commit comments

Comments
 (0)