diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 650ecd03f..21350520e 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -15,6 +15,7 @@
"dev.containers.mountWaylandSocket": false
},
"extensions": [
+ "golang.Go",
"GitHub.vscode-pull-request-github",
"streetsidesoftware.code-spell-checker",
"DavidAnson.vscode-markdownlint",
diff --git a/.vscode/settings.json b/.vscode/settings.json
index f7edb86ba..b84dffae5 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -18,6 +18,7 @@
"cryptosuite",
"datastoreclient",
"DCQL",
+ "DCQLJSON",
"deadcode",
"deployers",
"deviceauth",
diff --git a/internal/apigw/apiv1/handlers_verifier.go b/internal/apigw/apiv1/handlers_verifier.go
index 967432b40..a5ff4f757 100644
--- a/internal/apigw/apiv1/handlers_verifier.go
+++ b/internal/apigw/apiv1/handlers_verifier.go
@@ -49,7 +49,7 @@ func (c *Client) VerificationRequestObject(ctx context.Context, req *Verificatio
claimQueries := make([]openid4vp.ClaimQuery, 0, len(vpAuth.AuthClaims))
for _, claim := range vpAuth.AuthClaims {
claimQueries = append(claimQueries, openid4vp.ClaimQuery{
- Path: []string{claim},
+ Path: openid4vp.StringPath(claim),
})
}
diff --git a/internal/verifier/apiv1/client.go b/internal/verifier/apiv1/client.go
index 245339609..c0915dfc2 100644
--- a/internal/verifier/apiv1/client.go
+++ b/internal/verifier/apiv1/client.go
@@ -101,10 +101,12 @@ func New(ctx context.Context, db *db.Service, notify *notify.Service, cacheServi
// Initialize claims extractor
c.claimsExtractor = openid4vp.NewClaimsExtractor()
- // Override Attributes with filtered variant (excludes nested object claims)
- // since verifier only exposes leaf-level attributes to the UI.
+ // Use full Attributes (including nested object/array claims) so the UI
+ // can render them as a tree and let users select individual sub-fields.
for _, credentialInfo := range cfg.Common.CredentialMetadata {
- credentialInfo.Attributes = credentialInfo.VCTM.AttributesWithoutObjects()
+ if vctm := credentialInfo.GetVCTM(); vctm != nil {
+ credentialInfo.Attributes = vctm.Attributes()
+ }
}
c.trustService = &openid4vp.TrustService{}
@@ -333,12 +335,11 @@ func (c *Client) buildDCQLQueryFromConfig(scopes []string) (*openid4vp.DCQL, err
// Add claims from VCTM claim paths
if credInfo.VCTM != nil {
for _, claim := range credInfo.VCTM.Claims {
- // Skip object claims (nested paths) — only leaf claims
- if len(claim.Path) != 1 || claim.Path[0] == nil {
+ if len(claim.Path) == 0 {
continue
}
cred.Claims = append(cred.Claims, openid4vp.ClaimQuery{
- Path: []string{*claim.Path[0]},
+ Path: claim.Path,
})
}
}
@@ -350,9 +351,15 @@ func (c *Client) buildDCQLQueryFromConfig(scopes []string) (*openid4vp.DCQL, err
return nil, fmt.Errorf("no valid credentials found for requested scopes")
}
- return &openid4vp.DCQL{
+ dcql := &openid4vp.DCQL{
Credentials: credentials,
- }, nil
+ }
+
+ // Normalize: remove redundant parent paths that are superseded by more
+ // specific child or array-element paths (same logic used in UI queries).
+ c.augmentDCQLFromVCTM(dcql)
+
+ return dcql, nil
}
// extractAndMapClaims extracts claims from a VP token and maps them to OIDC claims
diff --git a/internal/verifier/apiv1/handler_session_preference.go b/internal/verifier/apiv1/handler_session_preference.go
index d0024e184..a791507fe 100644
--- a/internal/verifier/apiv1/handler_session_preference.go
+++ b/internal/verifier/apiv1/handler_session_preference.go
@@ -180,7 +180,7 @@ func (c *Client) GetCredentialDisplayData(ctx context.Context, req *GetCredentia
response := &GetCredentialDisplayDataResponse{
SessionID: authCtx.SessionID,
VPToken: authCtx.VPToken,
- Claims: authCtx.VerifiedClaims,
+ Claims: flattenClaimsForDisplay(authCtx.VerifiedClaims),
ClientID: authCtx.ClientID,
RedirectURI: authCtx.RedirectURI,
State: authCtx.State,
@@ -201,3 +201,31 @@ func (c *Client) GetCredentialDisplayData(ctx context.Context, req *GetCredentia
return response, nil
}
+
+// flattenClaimsForDisplay flattens nested maps into dot-notation keys for display.
+// For example, {"address": {"street": "Main St"}} becomes {"address.street": "Main St"}.
+// Non-map values are kept as-is. Arrays are kept as-is (rendered by the template).
+func flattenClaimsForDisplay(claims map[string]any) map[string]any {
+ if claims == nil {
+ return nil
+ }
+ result := make(map[string]any)
+ flattenRecursive(result, "", claims)
+ return result
+}
+
+func flattenRecursive(result map[string]any, prefix string, m map[string]any) {
+ for key, value := range m {
+ fullKey := key
+ if prefix != "" {
+ fullKey = prefix + "." + key
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ flattenRecursive(result, fullKey, v)
+ default:
+ result[fullKey] = value
+ }
+ }
+}
diff --git a/internal/verifier/apiv1/handler_session_preference_test.go b/internal/verifier/apiv1/handler_session_preference_test.go
index 5a8123a9b..7fe6f95f0 100644
--- a/internal/verifier/apiv1/handler_session_preference_test.go
+++ b/internal/verifier/apiv1/handler_session_preference_test.go
@@ -1,6 +1,7 @@
package apiv1
import (
+ "fmt"
"testing"
"time"
@@ -279,3 +280,85 @@ func createTestDBSessionForPrefs(sessionID string) *cache.AuthorizationContext {
return authCtx
}
+
+func TestFlattenClaimsForDisplay(t *testing.T) {
+ tests := []struct {
+ name string
+ input map[string]any
+ expect map[string]any
+ }{
+ {
+ name: "nil input",
+ input: nil,
+ expect: nil,
+ },
+ {
+ name: "flat claims unchanged",
+ input: map[string]any{
+ "given_name": "Helen",
+ "birthdate": "1996-01-30",
+ },
+ expect: map[string]any{
+ "given_name": "Helen",
+ "birthdate": "1996-01-30",
+ },
+ },
+ {
+ name: "nested maps flattened",
+ input: map[string]any{
+ "given_name": "Helen",
+ "place_of_birth": map[string]any{
+ "locality": "Stockholm",
+ "country": "SE",
+ },
+ "address": map[string]any{
+ "street_address": "Tulegatan",
+ "postal_code": "11353",
+ },
+ },
+ expect: map[string]any{
+ "given_name": "Helen",
+ "place_of_birth.locality": "Stockholm",
+ "place_of_birth.country": "SE",
+ "address.street_address": "Tulegatan",
+ "address.postal_code": "11353",
+ },
+ },
+ {
+ name: "arrays preserved as-is",
+ input: map[string]any{
+ "nationalities": []any{"SE", "EU"},
+ },
+ expect: map[string]any{
+ "nationalities": []any{"SE", "EU"},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := flattenClaimsForDisplay(tt.input)
+ if tt.expect == nil {
+ if result != nil {
+ t.Errorf("expected nil, got %v", result)
+ }
+ return
+ }
+ if len(result) != len(tt.expect) {
+ t.Errorf("expected %d keys, got %d: %v", len(tt.expect), len(result), result)
+ return
+ }
+ for k, v := range tt.expect {
+ got, ok := result[k]
+ if !ok {
+ t.Errorf("missing key %q in result", k)
+ continue
+ }
+ // Compare string representations for simplicity
+ if fmt.Sprintf("%v", got) != fmt.Sprintf("%v", v) {
+ t.Errorf("key %q: expected %v, got %v", k, v, got)
+ }
+ }
+ })
+ }
+}
diff --git a/internal/verifier/apiv1/handlers_ui.go b/internal/verifier/apiv1/handlers_ui.go
index e40c5c697..652729034 100644
--- a/internal/verifier/apiv1/handlers_ui.go
+++ b/internal/verifier/apiv1/handlers_ui.go
@@ -19,8 +19,8 @@ import (
// UICredentialInfo is a sanitized view of a credential for the UI.
type UICredentialInfo struct {
- VCT string `json:"vct"`
- Attributes map[string]map[string][]string `json:"attributes"`
+ VCT string `json:"vct"`
+ Attributes map[string]map[string][]*string `json:"attributes"`
}
// UIPreset is a verification preset served to the UI.
@@ -45,7 +45,7 @@ type UIPresetMeta struct {
// UIPresetClaim is a claim path within a preset credential.
type UIPresetClaim struct {
- Path []string `json:"path"`
+ Path []*string `json:"path"`
}
type UIMetadataReply struct {
@@ -129,33 +129,33 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) {
// Resolve claims: use explicit claims, or fall back to VCTM claims
if len(claims) == 0 && meta != nil {
if vctm := meta.GetVCTM(); vctm != nil {
- // First pass: collect all valid leaf paths
- var allPaths [][]string
+ // First pass: collect all valid paths (including array-element paths with null)
+ var allPaths [][]*string
for _, vc := range vctm.Claims {
- path := make([]string, 0, len(vc.Path))
- isLeaf := true
- for _, seg := range vc.Path {
- if seg == nil {
- isLeaf = false
- break
- }
- path = append(path, *seg)
+ if len(vc.Path) == 0 {
+ continue
}
- if isLeaf && len(path) > 0 && !jwtRegisteredClaim(path[0]) {
- allPaths = append(allPaths, path)
+ // Skip claims whose first element is a JWT registered claim
+ if vc.Path[0] != nil && jwtRegisteredClaim(*vc.Path[0]) {
+ continue
}
+ path := make([]*string, len(vc.Path))
+ copy(path, vc.Path)
+ allPaths = append(allPaths, path)
}
- // Build set of parent prefixes: for each path, mark all its proper prefixes
+ // Build set of parent prefixes: for each path, mark all its proper prefixes.
+ // Array-element paths (e.g. ["nationalities", nil]) supersede their parent
+ // (["nationalities"]), so mark the parent as a prefix to exclude.
parentSet := make(map[string]bool)
for _, p := range allPaths {
for i := 1; i < len(p); i++ {
- parentSet[claimPathKey(p[:i])] = true
+ parentSet[claimPtrPathKey(p[:i])] = true
}
}
// Second pass: only include non-parent claims
for _, path := range allPaths {
- if !parentSet[claimPathKey(path)] {
- claims = append(claims, model.VerificationPresetClaim{Path: path})
+ if !parentSet[claimPtrPathKey(path)] {
+ claims = append(claims, model.VerificationPresetClaim{Path: ptrPathToStringPath(path)})
}
}
}
@@ -163,7 +163,7 @@ func (c *Client) UIMetadata(ctx context.Context) (*UIMetadataReply, error) {
for _, claim := range claims {
if !excludeSet[claimPathKey(claim.Path)] {
- uiCred.Claims = append(uiCred.Claims, UIPresetClaim{Path: claim.Path})
+ uiCred.Claims = append(uiCred.Claims, UIPresetClaim{Path: stringPathToPtrPath(claim.Path)})
}
}
uiPreset.Credentials = append(uiPreset.Credentials, uiCred)
@@ -208,6 +208,11 @@ func (c *Client) UIInteraction(ctx context.Context, req *UIInteractionRequest) (
scopes = append(scopes, credential.ID)
}
+ // Augment DCQL with child paths from VCTM (array null paths and nested
+ // object sub-paths). The UI only sends top-level string paths; we expand
+ // them using the VCTM so wallets disclose nested content correctly.
+ c.augmentDCQLFromVCTM(req.DCQLQuery)
+
host, err := helpers.HostFromURL(c.cfg.Verifier.PublicURL)
if err != nil {
return nil, fmt.Errorf("failed to extract host from PublicURL: %w", err)
@@ -315,3 +320,129 @@ var jwtRegisteredClaims = map[string]bool{
func jwtRegisteredClaim(name string) bool {
return jwtRegisteredClaims[name]
}
+
+// stringPathToPtrPath converts a []string path to a []*string path.
+// The sentinel string "null" is converted back to nil (representing JSON null
+// for DCQL array element access).
+func stringPathToPtrPath(path []string) []*string {
+ out := make([]*string, len(path))
+ for i := range path {
+ if path[i] == "null" {
+ out[i] = nil
+ } else {
+ s := path[i]
+ out[i] = &s
+ }
+ }
+ return out
+}
+
+// ptrPathToStringPath converts a []*string path to a []string path.
+// Nil elements are represented as the literal string "null" for use in
+// config-level structures (e.g. exclusion sets) that don't support nil.
+func ptrPathToStringPath(path []*string) []string {
+ out := make([]string, len(path))
+ for i, p := range path {
+ if p == nil {
+ out[i] = "null"
+ } else {
+ out[i] = *p
+ }
+ }
+ return out
+}
+
+// augmentDCQLFromVCTM enriches the DCQL query using VCTM metadata.
+// It:
+// - Replaces parent object paths with their nested sub-paths when the VCTM
+// defines children (e.g. ["address"] → ["address", "street_address"]).
+// - Removes redundant parent paths when an array-element path is also present
+// (e.g. removes ["nationalities"] when ["nationalities", null] exists),
+// because sending both causes wallets to disclose only the parent array
+// without including element-level disclosures.
+func (c *Client) augmentDCQLFromVCTM(dcql *openid4vp.DCQL) {
+ if dcql == nil {
+ return
+ }
+ for i, cred := range dcql.Credentials {
+ meta := c.cfg.Common.CredentialMetadata[cred.ID]
+ if meta == nil || meta.VCTM == nil {
+ continue
+ }
+
+ // Collect nested object sub-paths from VCTM (paths with len>=2 where all elements are non-nil)
+ childPaths := make(map[string][][]*string) // parentKey -> list of child paths
+ for _, vc := range meta.VCTM.Claims {
+ if len(vc.Path) >= 2 && vc.Path[0] != nil && vc.Path[len(vc.Path)-1] != nil {
+ parentKey := claimPtrPathKey(vc.Path[:1])
+ childPaths[parentKey] = append(childPaths[parentKey], vc.Path)
+ }
+ }
+
+ // Build set of existing claim paths
+ existing := make(map[string]bool, len(cred.Claims))
+ for _, claim := range cred.Claims {
+ existing[claimPtrPathKey(claim.Path)] = true
+ }
+
+ // Identify parent paths that should be removed:
+ // 1. Parents that have nested object sub-paths (replace with children)
+ // 2. Parents that have a corresponding array-element path ["x", null]
+ // (the null path already implies element-level disclosure; keeping the
+ // parent causes wallets to skip element disclosures)
+ parentsToRemove := make(map[string]bool)
+ for _, claim := range cred.Claims {
+ if len(claim.Path) != 1 || claim.Path[0] == nil {
+ continue
+ }
+ parentKey := claimPtrPathKey(claim.Path)
+
+ // Check for array-element path: if ["x", null] is also in the query,
+ // remove the parent ["x"]
+ arrayPath := []*string{claim.Path[0], nil}
+ if existing[claimPtrPathKey(arrayPath)] {
+ parentsToRemove[parentKey] = true
+ continue
+ }
+
+ // Check for nested object children from VCTM
+ children, ok := childPaths[parentKey]
+ if !ok {
+ continue
+ }
+ parentsToRemove[parentKey] = true
+ for _, childPath := range children {
+ childKey := claimPtrPathKey(childPath)
+ if !existing[childKey] {
+ dcql.Credentials[i].Claims = append(dcql.Credentials[i].Claims, openid4vp.ClaimQuery{Path: childPath})
+ existing[childKey] = true
+ }
+ }
+ }
+
+ // Remove parent paths that were expanded or superseded
+ if len(parentsToRemove) > 0 {
+ filtered := make([]openid4vp.ClaimQuery, 0, len(dcql.Credentials[i].Claims))
+ for _, claim := range dcql.Credentials[i].Claims {
+ key := claimPtrPathKey(claim.Path)
+ if !parentsToRemove[key] {
+ filtered = append(filtered, claim)
+ }
+ }
+ dcql.Credentials[i].Claims = filtered
+ }
+ }
+}
+
+// claimPtrPathKey returns a string key for a []*string path.
+func claimPtrPathKey(path []*string) string {
+ parts := make([]string, len(path))
+ for i, p := range path {
+ if p == nil {
+ parts[i] = "\x01" // sentinel for null
+ } else {
+ parts[i] = *p
+ }
+ }
+ return strings.Join(parts, "\x00")
+}
diff --git a/internal/verifier/apiv1/handlers_ui_test.go b/internal/verifier/apiv1/handlers_ui_test.go
index 5f88e3263..9eef89f2a 100644
--- a/internal/verifier/apiv1/handlers_ui_test.go
+++ b/internal/verifier/apiv1/handlers_ui_test.go
@@ -1,6 +1,7 @@
package apiv1
import (
+ "encoding/json"
"fmt"
"testing"
"time"
@@ -30,8 +31,8 @@ func TestUIMetadata(t *testing.T) {
"pid": {
VCTMFilePath: "/path/to/vctm",
VCTM: &sdjwtvc.VCTM{VCT: "urn:eudi:pid:1"},
- Attributes: map[string]map[string][]string{
- "en-US": {"given_name": {"given_name"}},
+ Attributes: map[string]map[string][]*string{
+ "en-US": {"given_name": {new("given_name")}},
},
},
"diploma": {
@@ -185,7 +186,9 @@ func TestUIMetadataPresetValidationsPerScope(t *testing.T) {
// Only explicit claims should be included (birthdate), others excluded
require.Len(t, pidCred.Claims, 1)
- assert.Equal(t, []string{"birthdate"}, pidCred.Claims[0].Path)
+ require.Len(t, pidCred.Claims[0].Path, 1)
+ require.NotNil(t, pidCred.Claims[0].Path[0])
+ assert.Equal(t, "birthdate", *pidCred.Claims[0].Path[0])
})
t.Run("multi scope preset scopes validations correctly", func(t *testing.T) {
@@ -232,7 +235,9 @@ func TestUIMetadataPresetValidationsPerScope(t *testing.T) {
// PID should exclude family_name, have given_name and birthdate
pidPaths := make([]string, 0, len(pidCred.Claims))
for _, c := range pidCred.Claims {
- pidPaths = append(pidPaths, c.Path[0])
+ if c.Path[0] != nil {
+ pidPaths = append(pidPaths, *c.Path[0])
+ }
}
assert.Contains(t, pidPaths, "given_name")
assert.Contains(t, pidPaths, "birthdate")
@@ -241,7 +246,9 @@ func TestUIMetadataPresetValidationsPerScope(t *testing.T) {
// EHIC should have all its claims (no exclusions)
ehicPaths := make([]string, 0, len(ehicCred.Claims))
for _, c := range ehicCred.Claims {
- ehicPaths = append(ehicPaths, c.Path[0])
+ if c.Path[0] != nil {
+ ehicPaths = append(ehicPaths, *c.Path[0])
+ }
}
assert.Contains(t, ehicPaths, "card_number")
assert.Contains(t, ehicPaths, "expiry_date")
@@ -284,6 +291,171 @@ func TestUIMetadataPresetFormatFromMetadata(t *testing.T) {
assert.Equal(t, openid4vp.FormatSDJWTVC, preset.Credentials[0].Format)
}
+// TestAugmentDCQLFromVCTM_ArraySelectiveDisclosure verifies that augmentDCQLFromVCTM
+// removes the parent path when an array-element path (with null) is also present,
+// so the wallet discloses individual array elements instead of only the opaque parent.
+func TestAugmentDCQLFromVCTM_ArraySelectiveDisclosure(t *testing.T) {
+ tests := []struct {
+ name string
+ vctmClaims []sdjwtvc.Claim
+ inputClaims []openid4vp.ClaimQuery
+ expectedPaths [][]*string
+ removedPaths [][]*string
+ }{
+ {
+ name: "parent removed when array-element path present",
+ vctmClaims: []sdjwtvc.Claim{
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ expectedPaths: [][]*string{
+ {new("nationalities"), nil},
+ },
+ removedPaths: [][]*string{
+ {new("nationalities")},
+ },
+ },
+ {
+ name: "array-element path only - no change",
+ vctmClaims: []sdjwtvc.Claim{
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ expectedPaths: [][]*string{
+ {new("nationalities"), nil},
+ },
+ },
+ {
+ name: "parent only without array-element path - no change",
+ vctmClaims: []sdjwtvc.Claim{
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("nationalities")}},
+ },
+ expectedPaths: [][]*string{
+ {new("nationalities")},
+ },
+ },
+ {
+ name: "nested object parent replaced with children",
+ vctmClaims: []sdjwtvc.Claim{
+ {Path: []*string{new("address")}},
+ {Path: []*string{new("address"), new("street")}},
+ {Path: []*string{new("address"), new("city")}},
+ },
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("address")}},
+ },
+ expectedPaths: [][]*string{
+ {new("address"), new("street")},
+ {new("address"), new("city")},
+ },
+ removedPaths: [][]*string{
+ {new("address")},
+ },
+ },
+ {
+ name: "mixed: array and simple claims coexist",
+ vctmClaims: []sdjwtvc.Claim{
+ {Path: []*string{new("given_name")}},
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("given_name")}},
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ },
+ expectedPaths: [][]*string{
+ {new("given_name")},
+ {new("nationalities"), nil},
+ },
+ removedPaths: [][]*string{
+ {new("nationalities")},
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &model.Cfg{
+ Common: &model.Common{
+ CredentialMetadata: map[string]*model.CredentialMetadata{
+ "pid": {
+ VCTM: &sdjwtvc.VCTM{
+ VCT: "urn:eudi:pid:1",
+ Claims: tt.vctmClaims,
+ },
+ },
+ },
+ },
+ Verifier: &model.Verifier{},
+ }
+
+ client, _ := CreateTestClientWithMock(cfg)
+ client.cfg = cfg
+
+ dcql := &openid4vp.DCQL{
+ Credentials: []openid4vp.CredentialQuery{
+ {
+ ID: "pid",
+ Format: "dc+sd-jwt",
+ Claims: tt.inputClaims,
+ },
+ },
+ }
+
+ client.augmentDCQLFromVCTM(dcql)
+
+ resultPaths := make([][]*string, 0, len(dcql.Credentials[0].Claims))
+ for _, claim := range dcql.Credentials[0].Claims {
+ resultPaths = append(resultPaths, claim.Path)
+ }
+
+ // Check expected paths are present
+ require.Len(t, resultPaths, len(tt.expectedPaths), "unexpected number of claims")
+ for i, expected := range tt.expectedPaths {
+ require.Len(t, resultPaths[i], len(expected), "path %d has wrong length", i)
+ for j, seg := range expected {
+ if seg == nil {
+ assert.Nil(t, resultPaths[i][j], "path[%d][%d] should be nil", i, j)
+ } else {
+ require.NotNil(t, resultPaths[i][j], "path[%d][%d] should not be nil", i, j)
+ assert.Equal(t, *seg, *resultPaths[i][j], "path[%d][%d] mismatch", i, j)
+ }
+ }
+ }
+
+ // Check removed paths are absent
+ for _, removed := range tt.removedPaths {
+ for _, resultPath := range resultPaths {
+ if len(resultPath) != len(removed) {
+ continue
+ }
+ match := true
+ for j, seg := range removed {
+ if seg == nil && resultPath[j] != nil {
+ match = false
+ } else if seg != nil && (resultPath[j] == nil || *seg != *resultPath[j]) {
+ match = false
+ }
+ }
+ assert.False(t, match, "path %v should have been removed", removed)
+ }
+ }
+ })
+ }
+}
+
// TestPerScopeValidationApplication tests that the verification handler
// applies validations only to the credential that matches the scope.
func TestPerScopeValidationApplication(t *testing.T) {
@@ -419,6 +591,236 @@ func TestPerScopeValidationApplication(t *testing.T) {
}
}
+// TestAugmentDCQLFromVCTM_ComplexCredential tests augmentDCQLFromVCTM with a credential
+// containing nested objects, arrays of objects, and simple arrays — verifying correct
+// path handling for each DCQL claim type.
+func TestAugmentDCQLFromVCTM_ComplexCredential(t *testing.T) {
+ // VCTM claims model a credential like:
+ // {
+ // "name": "Arthur Dent",
+ // "address": { "street_address": "42 Market Street", "locality": "Milliways", "postal_code": "12345" },
+ // "degrees": [{ "type": "...", "university": "..." }, ...],
+ // "nationalities": ["British", "Betelgeusian"]
+ // }
+ vctmClaims := []sdjwtvc.Claim{
+ {Path: []*string{new("name")}},
+ {Path: []*string{new("address")}},
+ {Path: []*string{new("address"), new("street_address")}},
+ {Path: []*string{new("address"), new("locality")}},
+ {Path: []*string{new("address"), new("postal_code")}},
+ {Path: []*string{new("degrees")}},
+ {Path: []*string{new("degrees"), nil}},
+ {Path: []*string{new("degrees"), nil, new("type")}},
+ {Path: []*string{new("degrees"), nil, new("university")}},
+ {Path: []*string{new("nationalities")}},
+ {Path: []*string{new("nationalities"), nil}},
+ }
+
+ // The credential that the DCQL paths will be resolved against.
+ credential := map[string]any{
+ "name": "Arthur Dent",
+ "address": map[string]any{
+ "street_address": "42 Market Street",
+ "locality": "Milliways",
+ "postal_code": "12345",
+ },
+ "degrees": []any{
+ map[string]any{"type": "Bachelor of Science", "university": "University of Betelgeuse"},
+ map[string]any{"type": "Master of Science", "university": "University of Betelgeuse"},
+ },
+ "nationalities": []any{"British", "Betelgeusian"},
+ }
+
+ tests := []struct {
+ name string
+ inputClaims []openid4vp.ClaimQuery
+ expectedPaths [][]*string
+ expectedDCQLJSON []string // expected JSON for each claim after marshal
+ expectedValues []any // expected resolved values from the credential
+ }{
+ {
+ name: "array of objects element field: degrees null type",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("degrees"), nil, new("type")}},
+ },
+ expectedPaths: [][]*string{
+ {new("degrees"), nil, new("type")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["degrees",null,"type"]}`,
+ },
+ expectedValues: []any{
+ []any{"Bachelor of Science", "Master of Science"},
+ },
+ },
+ {
+ name: "simple array element: nationalities 1",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("nationalities"), new("1")}},
+ },
+ expectedPaths: [][]*string{
+ {new("nationalities"), new("1")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["nationalities","1"]}`,
+ },
+ expectedValues: []any{
+ "Betelgeusian",
+ },
+ },
+ {
+ name: "simple scalar: name",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("name")}},
+ },
+ expectedPaths: [][]*string{
+ {new("name")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["name"]}`,
+ },
+ expectedValues: []any{
+ "Arthur Dent",
+ },
+ },
+ {
+ name: "object parent replaced with children: address",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("address")}},
+ },
+ expectedPaths: [][]*string{
+ {new("address"), new("street_address")},
+ {new("address"), new("locality")},
+ {new("address"), new("postal_code")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["address","street_address"]}`,
+ `{"path":["address","locality"]}`,
+ `{"path":["address","postal_code"]}`,
+ },
+ expectedValues: []any{
+ "42 Market Street",
+ "Milliways",
+ "12345",
+ },
+ },
+ {
+ name: "object child directly: address street_address",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("address"), new("street_address")}},
+ },
+ expectedPaths: [][]*string{
+ {new("address"), new("street_address")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["address","street_address"]}`,
+ },
+ expectedValues: []any{
+ "42 Market Street",
+ },
+ },
+ {
+ name: "all five claims together",
+ inputClaims: []openid4vp.ClaimQuery{
+ {Path: []*string{new("degrees"), nil, new("type")}},
+ {Path: []*string{new("nationalities"), new("1")}},
+ {Path: []*string{new("name")}},
+ {Path: []*string{new("address")}},
+ {Path: []*string{new("address"), new("street_address")}},
+ },
+ expectedPaths: [][]*string{
+ {new("degrees"), nil, new("type")},
+ {new("nationalities"), new("1")},
+ {new("name")},
+ // "address" parent replaced by children from VCTM
+ {new("address"), new("street_address")},
+ {new("address"), new("locality")},
+ {new("address"), new("postal_code")},
+ },
+ expectedDCQLJSON: []string{
+ `{"path":["degrees",null,"type"]}`,
+ `{"path":["nationalities","1"]}`,
+ `{"path":["name"]}`,
+ `{"path":["address","street_address"]}`,
+ `{"path":["address","locality"]}`,
+ `{"path":["address","postal_code"]}`,
+ },
+ expectedValues: []any{
+ []any{"Bachelor of Science", "Master of Science"},
+ "Betelgeusian",
+ "Arthur Dent",
+ "42 Market Street",
+ "Milliways",
+ "12345",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ cfg := &model.Cfg{
+ Common: &model.Common{
+ CredentialMetadata: map[string]*model.CredentialMetadata{
+ "complex": {
+ VCTM: &sdjwtvc.VCTM{
+ VCT: "urn:test:complex:1",
+ Claims: vctmClaims,
+ },
+ },
+ },
+ },
+ Verifier: &model.Verifier{},
+ }
+
+ client, _ := CreateTestClientWithMock(cfg)
+ client.cfg = cfg
+
+ dcql := &openid4vp.DCQL{
+ Credentials: []openid4vp.CredentialQuery{
+ {
+ ID: "complex",
+ Format: "dc+sd-jwt",
+ Claims: tt.inputClaims,
+ },
+ },
+ }
+
+ client.augmentDCQLFromVCTM(dcql)
+
+ resultPaths := make([][]*string, 0, len(dcql.Credentials[0].Claims))
+ for _, claim := range dcql.Credentials[0].Claims {
+ resultPaths = append(resultPaths, claim.Path)
+ }
+
+ require.Len(t, resultPaths, len(tt.expectedPaths), "unexpected number of claims")
+ for i, expected := range tt.expectedPaths {
+ require.Len(t, resultPaths[i], len(expected), "path %d has wrong length", i)
+ for j, seg := range expected {
+ if seg == nil {
+ assert.Nil(t, resultPaths[i][j], "path[%d][%d] should be nil", i, j)
+ } else {
+ require.NotNil(t, resultPaths[i][j], "path[%d][%d] should not be nil", i, j)
+ assert.Equal(t, *seg, *resultPaths[i][j], "path[%d][%d] mismatch", i, j)
+ }
+ }
+ }
+
+ // Verify JSON serialization produces correct output (null vs "null", etc.)
+ for i, claim := range dcql.Credentials[0].Claims {
+ got, err := json.Marshal(claim)
+ require.NoError(t, err, "claim[%d] marshal error", i)
+ assert.Equal(t, tt.expectedDCQLJSON[i], string(got), "claim[%d] JSON mismatch", i)
+ }
+
+ // Verify resolved values from the credential match expectations
+ for i, claim := range dcql.Credentials[0].Claims {
+ resolved := openid4vp.ResolveDCQLPath(credential, claim.Path)
+ assert.Equal(t, tt.expectedValues[i], resolved, "claim[%d] resolved value mismatch", i)
+ }
+ })
+ }
+}
+
// applyPerScopeValidations mirrors the verification handler's per-scope validation loop
// (handlers_verification.go) for isolated unit testing without a full client setup.
// Each scope maps to zero or more credentials; validations are applied against the verified
diff --git a/internal/verifier/apiv1/handlers_verification.go b/internal/verifier/apiv1/handlers_verification.go
index 36f0b96bd..cbb6371a1 100644
--- a/internal/verifier/apiv1/handlers_verification.go
+++ b/internal/verifier/apiv1/handlers_verification.go
@@ -136,8 +136,19 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi
for _, scope := range authCtx.Scopes {
vpTokens, ok := vpResponse.VPToken[scope]
if !ok || len(vpTokens) == 0 {
- c.log.Error(nil, "VP token not found for scope", "scope", scope)
- return nil, fmt.Errorf("VP token not found for scope: %s", scope)
+ // Fallback: wallet sent vp_token as a plain string (single credential).
+ // Only allow this when exactly one scope was requested; otherwise
+ // the same credential would be reused for every scope with potentially
+ // wrong validations.
+ if len(authCtx.Scopes) != 1 {
+ c.log.Error(nil, "VP token not found for scope and multiple scopes requested", "scope", scope)
+ return nil, fmt.Errorf("VP token not found for scope %s: _default fallback is only allowed when a single scope is requested", scope)
+ }
+ vpTokens, ok = vpResponse.VPToken["_default"]
+ if !ok || len(vpTokens) == 0 {
+ c.log.Error(nil, "VP token not found for scope", "scope", scope)
+ return nil, fmt.Errorf("VP token not found for scope: %s", scope)
+ }
}
if len(vpTokens) > 1 {
c.log.Info("multiple VP tokens received for scope, using first", "scope", scope, "count", len(vpTokens))
@@ -182,29 +193,29 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi
}
// Parse SD-JWT credential
- _, _, _, selectiveDisclosure, _, err := sdjwtvc.Token(responseParams.VPToken).Split()
- if err != nil {
- c.log.Error(err, "failed to split sd-jwt", "scope", scope)
- return nil, err
- }
-
- // Parse credential claims
+ // Parse credential claims (recursively resolves nested _sd disclosures)
parsed, err := sdjwtvc.Token(responseParams.VPToken).Parse()
if err != nil {
c.log.Error(err, "failed to parse sd-jwt credential", "scope", scope)
return nil, err
}
- selectiveDisclosureClaims, err := sdjwtvc.ParseSelectiveDisclosure(selectiveDisclosure)
- if err != nil {
- c.log.Error(err, "failed to parse selective disclosures", "scope", scope)
- return nil, err
- }
+ c.log.Debug("Parsed SD-JWT credential",
+ "scope", scope,
+ "disclosures_count", len(parsed.Disclosures),
+ "claims_keys", claimKeys(parsed.Claims),
+ )
+
+ // Build display claims from the resolved credential map.
+ // This ensures nested disclosures (place_of_birth, address, etc.)
+ // are shown with their resolved values rather than raw _sd hashes.
+ displayClaims := credentialToDisclosers(parsed.Claims)
// Add to per-scope credential cache
scopeCredentials[scope] = append(scopeCredentials[scope], sdjwtvc.CredentialCache{
+ Scope: scope,
Credential: parsed.Claims,
- Claims: selectiveDisclosureClaims,
+ Claims: displayClaims,
})
case FormatMDoc:
@@ -245,6 +256,7 @@ func (c *Client) VerificationDirectPost(ctx context.Context, req *VerificationDi
}
verifiedClaims["namespaces"] = nsMap
scopeCredentials[scope] = append(scopeCredentials[scope], sdjwtvc.CredentialCache{
+ Scope: scope,
Credential: verifiedClaims,
Claims: disclosers,
})
@@ -393,3 +405,63 @@ func mapToDisclosers(claims map[string]any) []sdjwtvc.Discloser {
}
return disclosers
}
+
+// credentialToDisclosers converts a resolved credential claims map into a flat
+// []Discloser suitable for display. Nested maps are flattened using dot notation
+// (e.g., "address.street_address"). JWT metadata claims (iss, iat, exp, etc.) are
+// excluded since they are not user-facing credential attributes.
+func credentialToDisclosers(claims map[string]any) []sdjwtvc.Discloser {
+ result := make([]sdjwtvc.Discloser, 0)
+ flattenCredentialClaims(&result, "", claims)
+ return result
+}
+
+// jwtMetadataClaims contains claim names that are JWT/SD-JWT infrastructure
+// and should not be displayed as credential attributes.
+var jwtMetadataClaims = map[string]bool{
+ "iss": true,
+ "sub": true,
+ "iat": true,
+ "exp": true,
+ "nbf": true,
+ "jti": true,
+ "cnf": true,
+ "vct": true,
+ "vct#integrity": true,
+ "status": true,
+ "_sd": true,
+ "_sd_alg": true,
+}
+
+func flattenCredentialClaims(result *[]sdjwtvc.Discloser, prefix string, m map[string]any) {
+ for key, value := range m {
+ // Skip JWT metadata at top level
+ if prefix == "" && jwtMetadataClaims[key] {
+ continue
+ }
+
+ fullKey := key
+ if prefix != "" {
+ fullKey = prefix + "." + key
+ }
+
+ switch v := value.(type) {
+ case map[string]any:
+ // Recurse into nested objects
+ flattenCredentialClaims(result, fullKey, v)
+ default:
+ *result = append(*result, sdjwtvc.Discloser{
+ ClaimName: fullKey,
+ Value: value,
+ })
+ }
+ }
+}
+
+func claimKeys(m map[string]any) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ return keys
+}
diff --git a/internal/verifier/apiv1/helpers_test.go b/internal/verifier/apiv1/helpers_test.go
index 755f39ebc..6ef42f83b 100644
--- a/internal/verifier/apiv1/helpers_test.go
+++ b/internal/verifier/apiv1/helpers_test.go
@@ -56,8 +56,8 @@ func createSimplePresentationTemplate(t *testing.T, scopes []string) *configurat
VCTValues: []string{"https://example.com/test"},
},
Claims: []openid4vp.ClaimQuery{
- {Path: []string{"given_name"}},
- {Path: []string{"family_name"}},
+ {Path: openid4vp.StringPath("given_name")},
+ {Path: openid4vp.StringPath("family_name")},
},
},
},
diff --git a/internal/verifier/httpserver/service.go b/internal/verifier/httpserver/service.go
index 49a22fe9d..008fc5f87 100644
--- a/internal/verifier/httpserver/service.go
+++ b/internal/verifier/httpserver/service.go
@@ -3,8 +3,11 @@ package httpserver
import (
"context"
"encoding/json"
+ "fmt"
"html/template"
"net/http"
+ "sort"
+ "strings"
"time"
"github.com/SUNET/vc/internal/verifier/apiv1"
@@ -109,7 +112,8 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif
tmpl := template.New("").Funcs(template.FuncMap{
"toJSON": func(v any) string {
- b, _ := json.MarshalIndent(v, "", " ")
+ cleaned := cleanUnresolvedMarkersForDisplay(v)
+ b, _ := json.MarshalIndent(cleaned, "", " ")
return string(b)
},
"json": func(v any) (any, error) {
@@ -119,6 +123,9 @@ func New(ctx context.Context, cfg *model.Cfg, apiv1 *apiv1.Client, notify *notif
}
return template.JS(string(jsonBytes)), nil //#nosec G203 -- json.Marshal output is safe
},
+ "claimsTree": func(credential map[string]any) template.HTML {
+ return template.HTML(renderClaimsTree(credential)) //#nosec G203 -- internally generated HTML
+ },
})
s.gin.SetHTMLTemplate(template.Must(tmpl.ParseFS(staticembed.FS, "*.html")))
@@ -241,3 +248,120 @@ func (s *Service) handleOAuthError(c *gin.Context, err error) {
"error_description": err.Error(),
})
}
+
+// jwtMetadataClaims are JWT/SD-JWT infrastructure claims excluded from display.
+var jwtMetadataClaims = map[string]bool{
+ "iss": true, "sub": true, "iat": true, "exp": true, "nbf": true,
+ "jti": true, "cnf": true, "vct": true, "vct#integrity": true,
+ "status": true, "_sd": true, "_sd_alg": true,
+}
+
+// renderClaimsTree renders a credential claims map as HTML table rows with
+// tree-style indentation for nested objects.
+func renderClaimsTree(claims map[string]any) string {
+ var sb strings.Builder
+ keys := sortedKeys(claims)
+ for _, key := range keys {
+ if jwtMetadataClaims[key] {
+ continue
+ }
+ renderNode(&sb, key, claims[key], 0)
+ }
+ return sb.String()
+}
+
+func renderNode(sb *strings.Builder, name string, value any, depth int) {
+ indent := depth * 24 // pixels for indentation
+
+ switch v := value.(type) {
+ case map[string]any:
+ // Group header row
+ fmt.Fprintf(sb,
+ `
%s
`,
+ indent, template.HTMLEscapeString(name),
+ )
+ // Recurse into children
+ keys := sortedKeys(v)
+ for _, childKey := range keys {
+ if jwtMetadataClaims[childKey] {
+ continue
+ }
+ renderNode(sb, childKey, v[childKey], depth+1)
+ }
+ case []any:
+ // Render array elements, handling unresolved SD-JWT markers {"...": hash}
+ parts := make([]string, 0, len(v))
+ for _, elem := range v {
+ if m, ok := elem.(map[string]any); ok {
+ if _, isMarker := m["..."]; isMarker && len(m) == 1 {
+ // Unresolved array element disclosure — wallet didn't include element disclosure.
+ // Skip the marker silently; we'll show whatever was actually disclosed.
+ continue
+ }
+ }
+ parts = append(parts, fmt.Sprintf("%v", elem))
+ }
+ if len(parts) > 0 {
+ fmt.Fprintf(sb,
+ `
%s
%s
`,
+ indent, template.HTMLEscapeString(name), template.HTMLEscapeString(strings.Join(parts, ", ")),
+ )
+ } else {
+ // Array was disclosed but contained only unresolved element markers.
+ // Show the claim name to confirm it was disclosed.
+ fmt.Fprintf(sb,
+ `