Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"dev.containers.mountWaylandSocket": false
},
"extensions": [
"golang.Go",
"GitHub.vscode-pull-request-github",
"streetsidesoftware.code-spell-checker",
"DavidAnson.vscode-markdownlint",
Expand Down
2 changes: 1 addition & 1 deletion internal/apigw/apiv1/handlers_verifier.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
})
}

Expand Down
5 changes: 2 additions & 3 deletions internal/verifier/apiv1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,12 +333,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
Comment thread
masv3971 marked this conversation as resolved.
}
cred.Claims = append(cred.Claims, openid4vp.ClaimQuery{
Path: []string{*claim.Path[0]},
Path: claim.Path,
})
}
}
Expand Down
30 changes: 29 additions & 1 deletion internal/verifier/apiv1/handler_session_preference.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}
}
}
83 changes: 83 additions & 0 deletions internal/verifier/apiv1/handler_session_preference_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package apiv1

import (
"fmt"
"testing"
"time"

Expand Down Expand Up @@ -279,3 +280,85 @@

return authCtx
}

func TestFlattenClaimsForDisplay(t *testing.T) {

Check failure on line 284 in internal/verifier/apiv1/handler_session_preference_test.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 22 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SUNET_vc&issues=AZ6uN4d35U_DIMxdNK5e&open=AZ6uN4d35U_DIMxdNK5e&pullRequest=469
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)
}
}
})
}
}
111 changes: 111 additions & 0 deletions internal/verifier/apiv1/handlers_ui.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,11 @@
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)
Expand Down Expand Up @@ -315,3 +320,109 @@
func jwtRegisteredClaim(name string) bool {
return jwtRegisteredClaims[name]
}

// augmentDCQLFromVCTM enriches the DCQL query using VCTM metadata.
// It adds:
// - Array element paths (["claim", null]) for claims with per-element SD (Section 7.1)
// - Nested object sub-paths (["address", "street_address"]) when only the parent is requested
//
// This ensures wallets can properly disclose nested/array content even when the
// frontend only sends top-level path selections.
func (c *Client) augmentDCQLFromVCTM(dcql *openid4vp.DCQL) {

Check failure on line 331 in internal/verifier/apiv1/handlers_ui.go

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 52 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=SUNET_vc&issues=AZ6uN4dS5U_DIMxdNK5d&open=AZ6uN4dS5U_DIMxdNK5d&pullRequest=469
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 VCTM paths by category:
// - arrayPaths: paths ending with nil (array element disclosure)
// - childPaths: paths with len>=2 where all elements are non-nil (nested object fields)
arrayPaths := make(map[string][]*string) // parentKey -> full path with nil
childPaths := make(map[string][][]*string) // parentKey -> list of child paths
for _, vc := range meta.VCTM.Claims {
if len(vc.Path) >= 2 && vc.Path[len(vc.Path)-1] == nil {
key := claimPtrPathKey(vc.Path[:len(vc.Path)-1])
arrayPaths[key] = vc.Path
} else if len(vc.Path) >= 2 && vc.Path[0] != nil {
// Nested object sub-path (e.g., ["address", "street_address"])
parentKey := claimPtrPathKey(vc.Path[:1])
childPaths[parentKey] = append(childPaths[parentKey], vc.Path)
}
}

if len(arrayPaths) == 0 && len(childPaths) == 0 {
continue
}

// Build set of existing claim paths
existing := make(map[string]bool, len(cred.Claims))
for _, claim := range cred.Claims {
existing[claimPtrPathKey(claim.Path)] = true
}

// Add missing array element paths
for _, claim := range cred.Claims {
parentKey := claimPtrPathKey(claim.Path)
if arrPath, ok := arrayPaths[parentKey]; ok {
arrKey := claimPtrPathKey(arrPath)
if !existing[arrKey] {
dcql.Credentials[i].Claims = append(dcql.Credentials[i].Claims, openid4vp.ClaimQuery{Path: arrPath})
existing[arrKey] = true
}
}
}

// Replace parent object paths with their nested sub-paths.
// Sending both ["address"] and ["address", "house_number"] causes wallets
// to crash (they mark the parent as disclosed=true, then can't set children).
// We replace the parent with explicit sub-paths so the wallet discloses each field.
parentsToRemove := make(map[string]bool)
for _, claim := range cred.Claims {
if len(claim.Path) != 1 || claim.Path[0] == nil {
continue
}
parentKey := claimPtrPathKey(claim.Path)
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 into sub-paths
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")
}
Loading