From 8eb14e84b41f13e131150aeef367e7d27459808d Mon Sep 17 00:00:00 2001 From: Victor Ramperez Date: Fri, 15 May 2026 11:20:00 +0200 Subject: [PATCH] feat(openapi): accept OID4VP draft 25+ array-shape DCQL vp_token --- openapi/api_api.go | 58 ++++++++++++++++++++++++++----------- openapi/api_api_test.go | 64 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 17 deletions(-) diff --git a/openapi/api_api.go b/openapi/api_api.go index 09ac2d9..925f052 100644 --- a/openapi/api_api.go +++ b/openapi/api_api.go @@ -672,26 +672,50 @@ func tokenToPresentation(c *gin.Context, vpToken string) (parsedPresentation *co func getPresentationFromQuery(c *gin.Context, vpToken string) (parsedPresentation *common.Presentation, err error) { tokenBytes := decodeVpString(vpToken) - var queryMap map[string]string - //unmarshal - err = json.Unmarshal(tokenBytes, &queryMap) - if err != nil { - logging.Log().Debug("VP Token does not contain query map. Checking the other options.", err) - return nil, nil - } - - for _, v := range queryMap { - p, err := tokenToPresentation(c, v) - if err != nil { - return nil, err + // First, try the OID4VP drafts 22-24 shape: + // {"": ""} + // Kept as-is for backward compatibility with wallets that follow drafts 22-24. + var stringQueryMap map[string]string + if legacyErr := json.Unmarshal(tokenBytes, &stringQueryMap); legacyErr == nil { + for _, v := range stringQueryMap { + p, parseErr := tokenToPresentation(c, v) + if parseErr != nil { + return nil, parseErr + } + if parsedPresentation == nil { + parsedPresentation = p + } else { + parsedPresentation.AddCredentials(p.Credentials()...) + } } - if parsedPresentation == nil { - parsedPresentation = p - } else { - parsedPresentation.AddCredentials(p.Credentials()...) + return parsedPresentation, nil + } + + // Fall back to the OID4VP draft 25+ shape: + // {"": ["", ...]} + // Since draft 25 the value is REQUIRED to be an array, even when there is a single + // presentation (multiple=false in DCQL controls how many the wallet may return, + // not the JSON shape). + var arrayQueryMap map[string][]string + if draft22Err := json.Unmarshal(tokenBytes, &arrayQueryMap); draft22Err == nil { + for _, presentations := range arrayQueryMap { + for _, v := range presentations { + p, parseErr := tokenToPresentation(c, v) + if parseErr != nil { + return nil, parseErr + } + if parsedPresentation == nil { + parsedPresentation = p + } else { + parsedPresentation.AddCredentials(p.Credentials()...) + } + } } + return parsedPresentation, nil } - return parsedPresentation, err + + logging.Log().Debug("VP Token is not a DCQL query map (neither drafts 22-24 single-string nor draft 25+ array shape). Checking the other options.") + return nil, nil } // checks if the presented token contains a single sd-jwt credential. Will be repackage to a presentation for further validation diff --git a/openapi/api_api_test.go b/openapi/api_api_test.go index 879167a..74c158b 100644 --- a/openapi/api_api_test.go +++ b/openapi/api_api_test.go @@ -625,6 +625,70 @@ func buildSignedVPToken(t *testing.T) string { return string(vpSigned) } +// TestGetPresentationFromQuery exercises both shapes of the DCQL vp_token map: +// - OID4VP drafts 22-24: {"": ""} +// - OID4VP draft 25+: {"": ["", ...]} +// Both must be accepted (backward-compatible). Inputs that are neither a query +// map nor parseable as a single token must return (nil, nil) so the caller +// falls through to the flat-string presentation parsing path. +func TestGetPresentationFromQuery(t *testing.T) { + + logging.Configure(LOGGING_CONFIG) + + sdJwt := getValidSDJwtToken() + + stringShapeMap, err := json.Marshal(map[string]string{"lpc-query": sdJwt}) + if err != nil { + t.Fatalf("Failed to marshal drafts 22-24 query map: %v", err) + } + arrayShapeMap, err := json.Marshal(map[string][]string{"lpc-query": {sdJwt}}) + if err != nil { + t.Fatalf("Failed to marshal draft 25+ query map: %v", err) + } + + type test struct { + testName string + vpToken string + expectedNonNil bool + } + + tests := []test{ + {"OID4VP drafts 22-24 single-string-shape query map should be parsed.", string(stringShapeMap), true}, + {"OID4VP draft 25+ array-shape query map should be parsed.", string(arrayShapeMap), true}, + {"A flat sd-jwt (no query map) should return nil so the caller falls through.", sdJwt, false}, + {"A non-JSON token should return nil so the caller falls through.", "this-is-not-json", false}, + } + + for _, tc := range tests { + + t.Run(tc.testName, func(t *testing.T) { + + presentationParser = &verifier.ConfigurablePresentationParser{ + ProofChecker: newTestProofChecker()} + sdJwtParser = &verifier.ConfigurableSdJwtParser{ + ProofChecker: newTestProofChecker()} + + recorder := httptest.NewRecorder() + testContext, _ := gin.CreateTestContext(recorder) + + parsed, parseErr := getPresentationFromQuery(testContext, tc.vpToken) + + if parseErr != nil { + t.Errorf("%s - Unexpected error: %v", tc.testName, parseErr) + return + } + if tc.expectedNonNil && parsed == nil { + t.Errorf("%s - Expected a non-nil presentation but got nil.", tc.testName) + return + } + if !tc.expectedNonNil && parsed != nil { + t.Errorf("%s - Expected nil presentation (so caller can fall through) but got %v.", tc.testName, parsed) + return + } + }) + } +} + func getNoHolderVPToken() string { return "ewogICJAY29udGV4dCI6IFsKICAgICJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIKICBdLAogICJ0eXBlIjogWwogICAgIlZlcmlmaWFibGVQcmVzZW50YXRpb24iCiAgXSwKICAidmVyaWZpYWJsZUNyZWRlbnRpYWwiOiBbCiAgICB7CiAgICAgICJ0eXBlcyI6IFsKICAgICAgICAiUGFja2V0RGVsaXZlcnlTZXJ2aWNlIiwKICAgICAgICAiVmVyaWZpYWJsZUNyZWRlbnRpYWwiCiAgICAgIF0sCiAgICAgICJAY29udGV4dCI6IFsKICAgICAgICAiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLAogICAgICAgICJodHRwczovL3czaWQub3JnL3NlY3VyaXR5L3N1aXRlcy9qd3MtMjAyMC92MSIKICAgICAgXSwKICAgICAgImNyZWRlbnRpYWxzU3ViamVjdCI6IHt9LAogICAgICAiYWRkaXRpb25hbFByb3AxIjoge30KICAgIH0KICBdLAogICJpZCI6ICJlYmM2ZjFjMiIsCiAgImhvbGRlciI6IHsKICAgICJub3RhIjogImhvbGRlciIKICB9LAogICJwcm9vZiI6IHsKICAgICJ0eXBlIjogIkpzb25XZWJTaWduYXR1cmUyMDIwIiwKICAgICJjcmVhdG9yIjogImRpZDprZXk6ejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwKICAgICJjcmVhdGVkIjogIjIwMjMtMDEtMDZUMDc6NTE6MzZaIiwKICAgICJ2ZXJpZmljYXRpb25NZXRob2QiOiAiZGlkOmtleTp6Nk1rczltOWlmTHd5M0pXcUg0YzU3RWJCUVZTMlNwUkNqZmE3OXdIYjV2V002dmgjejZNa3M5bTlpZkx3eTNKV3FINGM1N0ViQlFWUzJTcFJDamZhNzl3SGI1dldNNnZoIiwKICAgICJqd3MiOiAiZXlKaU5qUWlPbVpoYkhObExDSmpjbWwwSWpwYkltSTJOQ0pkTENKaGJHY2lPaUpGWkVSVFFTSjkuLjZ4U3FvWmphME53akYwYWY5WmtucXgzQ2JoOUdFTnVuQmY5Qzh1TDJ1bEdmd3VzM1VGTV9abmhQald0SFBsLTcyRTlwM0JUNWYycHRab1lrdE1LcERBIgogIH0KfQ" }