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, + `%s[] (element details not disclosed by wallet)`, + indent, template.HTMLEscapeString(name), + ) + } + default: + valStr := fmt.Sprintf("%v", value) + // Render picture as image + if name == "picture" { + fmt.Fprintf(sb, + `%sPicture`, + indent, template.HTMLEscapeString(name), template.HTMLEscapeString(valStr), + ) + } else { + fmt.Fprintf(sb, + `%s%s`, + indent, template.HTMLEscapeString(name), template.HTMLEscapeString(valStr), + ) + } + } +} + +func sortedKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + return keys +} + +// cleanUnresolvedMarkersForDisplay deep-copies the value, removing SD-JWT array element +// markers ({"...": hash}) so the JSON output is clean for display purposes. +func cleanUnresolvedMarkersForDisplay(v any) any { + switch val := v.(type) { + case map[string]any: + result := make(map[string]any, len(val)) + for k, child := range val { + result[k] = cleanUnresolvedMarkersForDisplay(child) + } + return result + case []any: + result := make([]any, 0, len(val)) + for _, elem := range val { + if m, ok := elem.(map[string]any); ok && len(m) == 1 { + if _, isMarker := m["..."]; isMarker { + continue + } + } + result = append(result, cleanUnresolvedMarkersForDisplay(elem)) + } + return result + default: + return v + } +} diff --git a/internal/verifier/staticembed/callback.html b/internal/verifier/staticembed/callback.html index 4d440b956..49aaa1b43 100644 --- a/internal/verifier/staticembed/callback.html +++ b/internal/verifier/staticembed/callback.html @@ -6,19 +6,11 @@ Verifier - -
-
+

@@ -36,13 +28,12 @@

-

Credential claims

- {{if eq (len .Claims) 0}} -

No claims available.

- {{else}} +
+ {{range $idx, $cred := .CredentialData}} +
+
+

Credential claims — {{$cred.Scope}}

+ {{if $cred.Credential}}
@@ -51,32 +42,22 @@

Credential claims

- {{range .Claims}} - - - - - {{end}} + + {{claimsTree $cred.Credential}} +
Value
{{.ClaimName}}{{.Value}}
+ {{else}} +

No claims available.

{{end}} +
+ Credential data +
{{toJSON $cred.Credential}}
+
-
-

Credential data

-
- {{toJSON .Credential}} -
{{end}} +
{{end}}
diff --git a/internal/verifier/staticembed/credential_display.html b/internal/verifier/staticembed/credential_display.html index 15b5de97c..595d116ff 100644 --- a/internal/verifier/staticembed/credential_display.html +++ b/internal/verifier/staticembed/credential_display.html @@ -268,7 +268,7 @@

🔍 Review Credential Details

{{range $key, $value := .Claims}}
{{$key}}
-
{{$value}}
+
{{if eq $key "picture"}}Picture{{else}}{{$value}}{{end}}
{{end}}
diff --git a/internal/verifier/staticembed/presentation-definition.html b/internal/verifier/staticembed/presentation-definition.html index 321ce0224..8d462dbe8 100644 --- a/internal/verifier/staticembed/presentation-definition.html +++ b/internal/verifier/staticembed/presentation-definition.html @@ -77,12 +77,35 @@

Create custom presentation requests

    -