Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
7 changes: 7 additions & 0 deletions auth/api/iam/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions auth/api/iam/openid4vci.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -238,6 +244,7 @@ func (r Wrapper) requestCredentialWithProof(ctx context.Context, oauthSession *O
CredentialConfigurationID: oauthSession.IssuerCredentialConfigurationID,
CredentialIdentifier: credentialIdentifier,
ProofJWT: proofJWT,
CredentialRequestParams: oauthSession.CredentialRequestParams,
})
}

Expand Down
43 changes: 43 additions & 0 deletions auth/api/iam/openid4vci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = &params

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)
Expand Down Expand Up @@ -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"
Expand Down
5 changes: 5 additions & 0 deletions auth/api/iam/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 24 additions & 11 deletions auth/openid4vci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
31 changes: 31 additions & 0 deletions auth/openid4vci/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions docs/_static/auth/v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
1 change: 1 addition & 0 deletions docs/pages/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 7 additions & 0 deletions e2e-tests/browser/client/iam/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading