diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 9e214e72f..e960c6f2e 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -248,6 +248,13 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` + // CredentialRequestParams Optional JSON object overlaid on top of the OpenID4VCI Credential Request body sent to + // the issuer's credential endpoint. Any field supplied here overrides the node's default — + // including credential_configuration_id, credential_identifier and proofs. Use this for + // issuers that diverge from the OpenID4VCI 1.0 Credential Request shape; the caller is + // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). + CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. Issuer string `json:"issuer"` diff --git a/auth/api/iam/openid4vci.go b/auth/api/iam/openid4vci.go index c799895cd..1f9fd19fe 100644 --- a/auth/api/iam/openid4vci.go +++ b/auth/api/iam/openid4vci.go @@ -91,6 +91,11 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques // metadata fetches. authorizationDetails, _ := json.Marshal(request.Body.AuthorizationDetails) credentialConfigID := request.Body.AuthorizationDetails[0].CredentialConfigurationId + // Capture optional credential_request_params, used as the base body of the Credential Request later in the flow. + var credentialRequestParams map[string]any + if request.Body.CredentialRequestParams != nil { + credentialRequestParams = *request.Body.CredentialRequestParams + } // Generate the state and PKCE state := crypto.GenerateNonce() pkceParams := generatePKCEParams() @@ -113,6 +118,7 @@ func (r Wrapper) RequestOpenid4VCICredentialIssuance(ctx context.Context, reques IssuerNonceEndpoint: credentialIssuerMetadata.NonceEndpoint, IssuerCredentialConfigurationID: credentialConfigID, IssuerCredentialIssuer: credentialIssuerMetadata.CredentialIssuer, + CredentialRequestParams: credentialRequestParams, }) if err != nil { return nil, fmt.Errorf("failed to store session: %w", err) @@ -238,6 +244,7 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID, CredentialIdentifier: credentialIdentifier, ProofJWT: proofJWT, + CredentialRequestParams: oauthSession.CredentialRequestParams, }) } diff --git a/auth/api/iam/openid4vci_test.go b/auth/api/iam/openid4vci_test.go index 4e03d107c..d70844d7d 100644 --- a/auth/api/iam/openid4vci_test.go +++ b/auth/api/iam/openid4vci_test.go @@ -78,6 +78,22 @@ func TestWrapper_RequestOpenid4VCICredentialIssuance(t *testing.T) { assert.Equal(t, "code", redirectUri.Query().Get("response_type")) assert.Equal(t, `[{"credential_configuration_id":"UniversityDegreeCredential","format":"vc+sd-jwt","type":"openid_credential"}]`, redirectUri.Query().Get("authorization_details")) }) + t.Run("ok - credential_request_params persisted into session", func(t *testing.T) { + ctx := newTestClient(t) + ctx.openid4vciClient.EXPECT().OpenIDCredentialIssuerMetadata(nil, issuerClientID).Return(&metadata, nil) + ctx.iamClient.EXPECT().AuthorizationServerMetadata(nil, authServer).Return(&authzMetadata, nil) + params := map[string]interface{}{"bsn": "900184590"} + req := requestCredentials(holderSubjectID, issuerClientID, redirectURI) + req.Body.CredentialRequestParams = ¶ms + + response, err := ctx.client.RequestOpenid4VCICredentialIssuance(nil, req) + + require.NoError(t, err) + redirectUri, _ := url.Parse(response.(RequestOpenid4VCICredentialIssuance200JSONResponse).RedirectURI) + var stored OAuthSession + require.NoError(t, ctx.client.oauthClientStateStore().Get(redirectUri.Query().Get("state"), &stored)) + assert.Equal(t, params, stored.CredentialRequestParams) + }) t.Run("openid4vciMetadata", func(t *testing.T) { t.Run("ok - fallback to issuerDID on empty AuthorizationServers", func(t *testing.T) { ctx := newTestClient(t) @@ -293,6 +309,33 @@ func TestWrapper_handleOpenID4VCICallback(t *testing.T) { require.NoError(t, err) assert.NotNil(t, callback) }) + t.Run("ok - credential_request_params from session forwarded to credential endpoint", func(t *testing.T) { + ctx := newTestClient(t) + params := map[string]any{ + "bsn": "900184590", + "ura": "900030757", + } + sessionWithParams := session + sessionWithParams.CredentialRequestParams = params + ctx.iamClient.EXPECT().AccessToken(nil, code, tokenEndpoint, redirectURI, holderSubjectID, holderClientID, pkceParams.Verifier, false).Return(tokenResponse, nil) + ctx.openid4vciClient.EXPECT().RequestNonce(nil, nonceEndpoint).Return(cNonce, nil) + ctx.keyResolver.EXPECT().ResolveKey(holderDID, nil, resolver.NutsSigningKeyType).Return("kid", nil, nil) + ctx.jwtSigner.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), "kid").Return("signed-proof", nil) + ctx.openid4vciClient.EXPECT().RequestCredential(nil, openid4vci.RequestCredentialOpts{ + CredentialEndpoint: credEndpoint, + AccessToken: accessToken, + CredentialConfigurationID: credentialConfigID, + ProofJWT: "signed-proof", + CredentialRequestParams: params, + }).Return(&credentialResponse, nil) + ctx.vcVerifier.EXPECT().Verify(*verifiableCredential, true, true, nil) + ctx.wallet.EXPECT().Put(nil, *verifiableCredential) + + callback, err := ctx.client.handleOpenID4VCICallback(nil, code, &sessionWithParams) + + require.NoError(t, err) + assert.NotNil(t, callback) + }) t.Run("ok - invalid_nonce retry succeeds", func(t *testing.T) { ctx := newTestClient(t) freshNonce := "fresh-nonce" diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index 00c71b454..c7ad01d3a 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -64,6 +64,11 @@ type OAuthSession struct { // per §F.1; this can differ from IssuerURL (the AS issuer) when the metadata // declares `authorization_servers`. IssuerCredentialIssuer string `json:"issuer_credential_issuer,omitempty"` + // CredentialRequestParams is an optional JSON object provided by the API caller that is overlaid on top + // of the node-built OpenID4VCI Credential Request body. Any field supplied here overrides the node's + // default — including credential_configuration_id / credential_identifier / proofs. The caller is then + // responsible for the resulting wire shape. Contents are opaque and may contain PII; do not log this field. + CredentialRequestParams map[string]any `json:"credential_request_params,omitempty"` } // oauthClientFlow is used by a client to identify the flow a particular callback is part of diff --git a/auth/openid4vci/client.go b/auth/openid4vci/client.go index 23f34b70e..f00124c82 100644 --- a/auth/openid4vci/client.go +++ b/auth/openid4vci/client.go @@ -43,12 +43,20 @@ const wellKnownPath = "/.well-known/openid-credential-issuer" // CredentialConfigurationID MUST NOT be present); otherwise the wallet sets // CredentialConfigurationID. If both are non-empty, CredentialIdentifier // takes precedence to enforce the spec rule. +// +// CredentialRequestParams is overlaid on top of the node-built request body, so +// any field set here overrides the node's default — including +// credential_configuration_id / credential_identifier / proofs. This is +// the escape hatch for issuers with non-spec request shapes; the caller +// takes full responsibility for the wire-level result (§8.2 mutual +// exclusivity, proof binding, etc.). type RequestCredentialOpts struct { CredentialEndpoint string AccessToken string CredentialConfigurationID string CredentialIdentifier string ProofJWT string + CredentialRequestParams map[string]any } // Client is the OpenID4VCI 1.0 HTTP client interface. @@ -174,17 +182,21 @@ func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOp if err := c.validateURL("credential endpoint", opts.CredentialEndpoint); err != nil { return nil, err } - body := CredentialRequest{ - Proofs: &CredentialRequestProofs{ - JWT: []string{opts.ProofJWT}, - }, + body := make(map[string]any, len(opts.CredentialRequestParams)+2) + // Node defaults. Per §8.2, credential_identifier and + // credential_configuration_id are mutually exclusive; credential_identifier + // wins when set. + switch { + case opts.CredentialIdentifier != "": + body["credential_identifier"] = opts.CredentialIdentifier + case opts.CredentialConfigurationID != "": + body["credential_configuration_id"] = opts.CredentialConfigurationID } - // Per §8.2: CredentialIdentifier and CredentialConfigurationID are mutually - // exclusive. CredentialIdentifier wins when set. - if opts.CredentialIdentifier != "" { - body.CredentialIdentifier = opts.CredentialIdentifier - } else { - body.CredentialConfigurationID = opts.CredentialConfigurationID + body["proofs"] = CredentialRequestProofs{JWT: []string{opts.ProofJWT}} + // CredentialRequestParams overlays the node defaults — caller-supplied values + // win. The caller takes responsibility for the resulting wire shape. + for k, v := range opts.CredentialRequestParams { + body[k] = v } bodyBytes, err := json.Marshal(body) if err != nil { @@ -228,7 +240,8 @@ func (c *client) RequestCredential(ctx context.Context, opts RequestCredentialOp // inserted at the authority root, and the issuer's path is appended after. // // Example: https://example.com/oauth2/alice -// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice +// +// -> https://example.com/.well-known/openid-credential-issuer/oauth2/alice func credentialIssuerWellKnown(issuerURL string) (string, error) { u, err := url.Parse(issuerURL) if err != nil { diff --git a/auth/openid4vci/client_test.go b/auth/openid4vci/client_test.go index c88034d64..95f0e21fe 100644 --- a/auth/openid4vci/client_test.go +++ b/auth/openid4vci/client_test.go @@ -273,6 +273,37 @@ func TestClient_RequestCredential(t *testing.T) { assert.Equal(t, oauth.InvalidNonce, oauthErr.Code) }) + t.Run("CredentialRequestParams overrides node-built defaults", func(t *testing.T) { + var rawBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, json.NewDecoder(r.Body).Decode(&rawBody)) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(CredentialResponse{ + Credentials: []CredentialResponseEntry{{Credential: json.RawMessage(`"vc"`)}}, + }) + })) + defer srv.Close() + + client := NewClient(srv.Client(), false) + _, err := client.RequestCredential(context.Background(), RequestCredentialOpts{ + CredentialEndpoint: srv.URL, + AccessToken: "t", + CredentialConfigurationID: "NodeDefaultConfig", + ProofJWT: "node-proof", + CredentialRequestParams: map[string]any{ + "bsn": "900184590", + "ura": "900030757", + "credential_configuration_id": "caller-supplied-config", + "proofs": map[string]any{"jwt": []string{"caller-supplied-proof"}}, + }, + }) + require.NoError(t, err) + assert.Equal(t, "900184590", rawBody["bsn"]) + assert.Equal(t, "900030757", rawBody["ura"]) + assert.Equal(t, "caller-supplied-config", rawBody["credential_configuration_id"], "caller value must override node default") + assert.Equal(t, map[string]any{"jwt": []any{"caller-supplied-proof"}}, rawBody["proofs"], "caller proofs must override node proof") + }) + t.Run("returns generic error on non-2xx with no structured body", func(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "something went wrong", http.StatusServiceUnavailable) diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml index ce1419e30..8c9575a14 100644 --- a/docs/_static/auth/v2.yaml +++ b/docs/_static/auth/v2.yaml @@ -174,6 +174,19 @@ paths: maxItems: 1 items: $ref: '#/components/schemas/AuthorizationDetail' + credential_request_params: + type: object + additionalProperties: true + description: | + Optional JSON object overlaid on top of the OpenID4VCI Credential Request body sent to + the issuer's credential endpoint. Any field supplied here overrides the node's default — + including credential_configuration_id, credential_identifier and proofs. Use this for + issuers that diverge from the OpenID4VCI 1.0 Credential Request shape; the caller is + responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). + example: | + { + "some-requested-credential-attribute": "900184590" + } redirect_uri: type: string description: | diff --git a/docs/pages/release_notes.rst b/docs/pages/release_notes.rst index 7dd35fded..bb47ddd41 100644 --- a/docs/pages/release_notes.rst +++ b/docs/pages/release_notes.rst @@ -8,6 +8,7 @@ Unreleased ## New features * #4063: Enable ``storage.debug`` flag to log go-leia performance issues (full table scans, suboptimal index usage) by @reinkrul in https://github.com/nuts-foundation/nuts-node/pull/4064 +* #4233: ``request-credential`` API gains an optional ``credential_request_params`` JSON object overlaid on top of the OpenID4VCI Credential Request body sent to the issuer. Lets the wallet talk to issuers that accept additional fields, or to override the credential request entirely. **************** Peanut (v6.2.4) diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index 95a69b354..04fab3329 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -242,6 +242,13 @@ type RequestOpenid4VCICredentialIssuanceJSONBody struct { // issuance per call and only consumes the first entry. AuthorizationDetails []AuthorizationDetail `json:"authorization_details"` + // CredentialRequestParams Optional JSON object overlaid on top of the OpenID4VCI Credential Request body sent to + // the issuer's credential endpoint. Any field supplied here overrides the node's default — + // including credential_configuration_id, credential_identifier and proofs. Use this for + // issuers that diverge from the OpenID4VCI 1.0 Credential Request shape; the caller is + // responsible for the resulting wire shape (§8.2 mutual exclusivity, proof binding, etc.). + CredentialRequestParams *map[string]interface{} `json:"credential_request_params,omitempty"` + // Issuer The OAuth Authorization Server's identifier, that issues the Verifiable Credentials, as specified in RFC 8414 (section 2), // used to locate the OAuth2 Authorization Server metadata. Issuer string `json:"issuer"`