Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
8d0fcac
Initialize branch for PR
stevenvegt Apr 13, 2026
c7fd6ae
Rename PresentationDefinitions to FindCredentialProfile
stevenvegt Apr 13, 2026
2d16cf7
Add tests for multi-scope parsing in FindCredentialProfile
stevenvegt Apr 13, 2026
a0edb15
Add scope_policy config parsing and startup validation tests
stevenvegt Apr 13, 2026
ba9c58b
Apply self-review fixes
stevenvegt Apr 13, 2026
c05eb53
Initialize branch for PR
stevenvegt Apr 13, 2026
0c0557f
Add AuthZen client with batch evaluation support
stevenvegt Apr 14, 2026
cb22056
Add error handling and edge case tests for AuthZen client
stevenvegt Apr 14, 2026
4f08bcf
Apply self-review fixes to AuthZen client
stevenvegt Apr 14, 2026
cabd34a
Initialize branch for PR
stevenvegt Apr 13, 2026
b64927e
Add PresentationDefinitionResolver with remote PD support
stevenvegt Apr 14, 2026
747fd27
Add local PD fallback and scope policy enforcement to resolver
stevenvegt Apr 14, 2026
08e0232
Wire PresentationDefinitionResolver into client-side token flow
stevenvegt Apr 14, 2026
0b190e6
Apply self-review fixes to PresentationDefinitionResolver
stevenvegt Apr 14, 2026
f75823a
Initialize branch for PR
stevenvegt Apr 13, 2026
3facbb0
Initialize branch for PR
stevenvegt Apr 13, 2026
9af2ab5
Rename presentationDefinitionForScope to findCredentialProfile
stevenvegt Apr 16, 2026
39377f3
Enforce profile-only scope policy in server-side token handler
stevenvegt Apr 16, 2026
da8a027
Add test for passthrough scope policy
stevenvegt Apr 16, 2026
a7de2c6
Explicitly derive granted scopes from scope policy
stevenvegt Apr 16, 2026
e2da404
Expose AuthZen evaluator through PDPBackend interface
stevenvegt Apr 16, 2026
6239847
Implement dynamic scope policy via AuthZen PDP
stevenvegt Apr 16, 2026
3c23f41
Add dynamic scope policy tests: partial denial, profile denial, PDP e…
stevenvegt Apr 16, 2026
de5213b
Apply self-review fixes
stevenvegt Apr 16, 2026
0a236af
Initialize branch for PR
stevenvegt Apr 13, 2026
358de07
Initialize branch for PR
stevenvegt Apr 13, 2026
50d47ff
Initialize branch for PR
stevenvegt Apr 13, 2026
85a3e06
Add end-to-end integration tests for dynamic scope policy
stevenvegt Apr 16, 2026
28f442b
Add introspection tests for multi-scope tokens
stevenvegt Apr 16, 2026
08c3981
Add introspection test for claims on multi-scope dynamic tokens
stevenvegt Apr 16, 2026
828a1c1
Apply self-review fixes: consolidate integration and introspection tests
stevenvegt Apr 16, 2026
513810f
Address review: use core.TestResponseCode, clarify duplicate-ID check
stevenvegt Apr 21, 2026
4a920e3
Refactor PD resolver to delegate remote fetch to OpenID4VPClient
stevenvegt Apr 21, 2026
dee5489
Merge pull request #4176 from nuts-foundation/4144-1-scope-parsing-an…
stevenvegt Apr 24, 2026
e0bcb9c
Merge pull request #4177 from nuts-foundation/4144-2-authzen-client
stevenvegt Apr 24, 2026
5e83e00
Merge pull request #4178 from nuts-foundation/4144-3-client-side-flow
stevenvegt Apr 24, 2026
843ee15
Merge pull request #4179 from nuts-foundation/4144-4-server-side-flow
stevenvegt Apr 24, 2026
12fc532
Merge pull request #4180 from nuts-foundation/4144-5-introspection-an…
stevenvegt Apr 24, 2026
25c35af
Make HTTPErrorHandler idempotent on committed responses
stevenvegt Apr 27, 2026
81818a3
Initialize branch for PR
stevenvegt May 1, 2026
8c73791
Load client PD block from policy config
stevenvegt May 1, 2026
9730996
Guard schema validation of the client PD block
stevenvegt May 1, 2026
7f5d69e
Allow client-only credential profiles in policy config
stevenvegt May 1, 2026
f1c1054
Self-review fixes for client PD support
stevenvegt May 1, 2026
ab3c606
Rename client wallet-owner type to service_provider
stevenvegt May 1, 2026
156ed6d
Reword service_provider doc to state when it applies, not when it doe…
stevenvegt May 1, 2026
fe93b2e
Initialize branch for PR
stevenvegt May 1, 2026
7df21b3
Plumb serviceProviderSubjectID and the jwt-bearer feature flag
stevenvegt May 1, 2026
cfc6d48
Gate the two-VP flow behind the experimental flag
stevenvegt May 1, 2026
ef61c0f
Reject two-VP requests when AS does not advertise jwt-bearer
stevenvegt May 1, 2026
df974c9
Reject two-VP requests when no service_provider PD is configured
stevenvegt May 1, 2026
ea4bbc3
Build the jwt-bearer two-VP token request
stevenvegt May 1, 2026
6fe03d5
Capture VP1 field-id values into VP2 credential_selection
stevenvegt May 1, 2026
2ede2b6
Add PresentationSubmission.ResolveVP for single-VP callers
stevenvegt May 2, 2026
a0d28ea
Tighten the two-VP path: ResolveVP, params.DIDMethods, no client_id
stevenvegt May 2, 2026
ad8cff6
Extract requestVPTokenAccessToken so RequestRFC021AccessToken dispatches
stevenvegt May 2, 2026
7d622df
Rename RequestRFC021AccessToken to RequestServiceAccessToken
stevenvegt May 2, 2026
5023f0c
Extract loadAndValidateProfile and unit-test the rules directly
stevenvegt May 2, 2026
b2ba045
Fold the single-VP build into buildSubmissionForSubject
stevenvegt May 2, 2026
d7ed7b4
Populate Envelope.raw in ResolveVP
stevenvegt May 4, 2026
c68b7e9
Wire DPoP through the jwt-bearer two-VP flow
stevenvegt May 4, 2026
ae78489
End-to-end test for cross-VP field-id binding
stevenvegt May 4, 2026
b9d3c98
Return the resolved scope on the TokenResponse
stevenvegt May 4, 2026
c4b817e
Doc polish for the jwt-bearer two-VP flow
stevenvegt May 4, 2026
3f8def8
Move JwtBearerClientAssertionType into a dedicated const block
stevenvegt May 4, 2026
603d432
Regenerate server_options.rst
stevenvegt May 4, 2026
c5bd076
Wrap the jwt-bearer flag-flip in a tiny test helper
stevenvegt May 4, 2026
fd621f4
applyCapturedFieldsToSelection allocates a fresh selection map
stevenvegt May 4, 2026
584425e
Use typed OAuth2Errors for jwt-bearer rejections
stevenvegt May 4, 2026
c80a2ee
Replace PresentationSubmission.ResolveVP with pe.NewEnvelopeFromVP
stevenvegt May 4, 2026
4f592ca
Spell out the jwt-bearer URN in the AS-doesn't-advertise error
stevenvegt May 4, 2026
d88dc8f
Hoist the VP-assertion lifetime into a named constant
stevenvegt May 4, 2026
262aade
Generate a fresh nonce for VP2 in the jwt-bearer flow
stevenvegt May 4, 2026
410e67a
Surface the JSONPath in cross-VP binding errors
stevenvegt May 4, 2026
7348a9c
Rename vp1/vp2 locals to organizationVP/serviceProviderVP
stevenvegt May 4, 2026
e898ff3
Rename Client.RequestServiceAccessToken's subjectDID parameter to sub…
stevenvegt May 4, 2026
1c93225
Rename auth.experimental.jwt_bearer_client → jwtbearerclient
stevenvegt May 8, 2026
5bd9994
Initialize branch for PR
stevenvegt May 1, 2026
c208386
Initialize branch for PR
stevenvegt May 1, 2026
0df279a
Add service_provider_subject_id to ServiceAccessTokenRequest
stevenvegt May 4, 2026
b465d46
Rename subjectID to organizationSubjectID inside requestJwtBearerAcce…
stevenvegt May 4, 2026
71e896e
Refactor iam.NewClient to accept a ClientConfig struct
stevenvegt May 4, 2026
c19105f
Use lightweight test context for the feature-disabled gate test
stevenvegt May 4, 2026
c28d803
Add tests for SP-side wallet failures in the two-VP flow
stevenvegt May 5, 2026
e01498d
Validate service_provider_subject_id in the handler
stevenvegt May 5, 2026
dbfd435
Address self-review nits: docs and test pinning
stevenvegt May 5, 2026
c9cd139
Document the experimental two-VP flow and cross-VP binding in policy …
stevenvegt May 5, 2026
7db8c21
Update doc references to the renamed jwtbearerclient flag
stevenvegt May 8, 2026
c6d7592
Initialize branch for PR
stevenvegt May 1, 2026
83afb0a
Initialize branch for PR
stevenvegt May 1, 2026
6b711e9
Initialize branch for PR
stevenvegt May 1, 2026
e88e79c
Initialize branch for PR
stevenvegt May 1, 2026
de07e6a
Initialize branch for PR
stevenvegt May 1, 2026
72023b8
Add jwt-bearer two-VP happy-path integration test
stevenvegt May 8, 2026
d422393
test(iam): tighten jwt-bearer two-VP integration test
stevenvegt May 12, 2026
cfa5fcb
Merge pull request #4226 from nuts-foundation/4078-1-policy-client-pd
stevenvegt May 22, 2026
9abee6d
Merge pull request #4227 from nuts-foundation/4078-2-two-vp-flow
stevenvegt May 22, 2026
0c88856
Merge pull request #4228 from nuts-foundation/4078-3-api-client-id
stevenvegt May 22, 2026
93b56ce
Merge pull request #4229 from nuts-foundation/4078-4-integration-test
stevenvegt May 22, 2026
9ea1680
Merge branch 'master' into feature/4078-jwt-bearer-two-vp
stevenvegt May 22, 2026
18c5511
refactor(auth/client/iam): extract postTokenRequest helper, apply gofmt
stevenvegt May 22, 2026
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
2 changes: 1 addition & 1 deletion auth/api/auth/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@ import (
pkg2 "github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/contract"
"github.com/nuts-foundation/nuts-node/auth/openid4vci"
oauth2 "github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/auth/openid4vci"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/auth/services/dummy"
"github.com/nuts-foundation/nuts-node/auth/services/oauth"
Expand Down
13 changes: 12 additions & 1 deletion auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,17 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
if err != nil {
return nil, err
}
// service_provider_subject_id is optional. When absent the single-VP flow runs; when present it
// selects the two-VP jwt-bearer flow. An explicit empty string is ambiguous, reject it here so
// the flow doesn't route on an unset value.
if request.Body.ServiceProviderSubjectId != nil {
if *request.Body.ServiceProviderSubjectId == "" {
return nil, core.InvalidInputError("service_provider_subject_id is optional and cannot be empty: omit the field or set a non-empty value")
}
if err := r.subjectExists(ctx, *request.Body.ServiceProviderSubjectId); err != nil {
return nil, err
}
}

tokenCache := r.accessTokenCache()
cacheKey := accessTokenRequestCacheKey(request)
Expand Down Expand Up @@ -782,7 +793,7 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
}

clientID := r.subjectToBaseURL(request.SubjectID)
tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection)
tokenResult, err := r.auth.IAMClient().RequestServiceAccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials, credentialSelection, request.Body.ServiceProviderSubjectId)
if err != nil {
// this can be an internal server error, a 400 oauth error or a 412 precondition failed if the wallet does not contain the required credentials
return nil, err
Expand Down
76 changes: 67 additions & 9 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -887,7 +887,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
request.Params.CacheControl = to.Ptr("no-cache")
// Initial call to populate cache
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil).Times(2)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil).Times(2)
token, err := ctx.client.RequestServiceAccessToken(nil, request)

// Test call to check cache is bypassed
Expand All @@ -908,7 +908,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
TokenType: "Bearer",
ExpiresIn: to.Ptr(900),
}
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(response, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(response, nil)

token, err := ctx.client.RequestServiceAccessToken(nil, request)

Expand Down Expand Up @@ -947,7 +947,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
t.Run("cache expired", func(t *testing.T) {
cacheKey := accessTokenRequestCacheKey(request)
_ = ctx.client.accessTokenCache().Delete(cacheKey)
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil)

otherToken, err := ctx.client.RequestServiceAccessToken(nil, request)

Expand All @@ -964,7 +964,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
Scope: "first second",
TokenType: &tokenTypeBearer,
}
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil).Return(&oauth.TokenResponse{}, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil, nil, nil).Return(&oauth.TokenResponse{}, nil)

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body})

Expand All @@ -973,7 +973,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
t.Run("ok with expired cache by ttl", func(t *testing.T) {
ctx := newTestClient(t)
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)

_, err := ctx.client.RequestServiceAccessToken(nil, request)

Expand All @@ -982,7 +982,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
})
t.Run("error - no matching credentials", func(t *testing.T) {
ctx := newTestClient(t)
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(nil, pe.ErrNoCredentials)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(nil, pe.ErrNoCredentials)

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body})

Expand All @@ -998,8 +998,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
ctx.client.storageEngine = mockStorage

request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil)
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil)

token1, err := ctx.client.RequestServiceAccessToken(nil, request)
require.NoError(t, err)
Expand All @@ -1008,6 +1008,64 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {

assert.NotEqual(t, token1, token2)
})
t.Run("service_provider_subject_id", func(t *testing.T) {
t.Run("ok - threads through to IAM client", func(t *testing.T) {
// Verifies the wire-up of the new OpenAPI field. The dispatch and feature gating live in
// the IAM client (under #4227); the handler's job is to validate and forward the value.
ctx := newTestClient(t)
spSubject := verifierSubject // re-use the global fixture's existing Exists(true) mock
bodyWithSP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &spSubject,
}
response := &oauth.TokenResponse{AccessToken: "sp-token", TokenType: "Bearer", ExpiresIn: to.Ptr(900)}
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil, nil, &spSubject).Return(response, nil)

tokenResponse, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithSP})

require.NoError(t, err)
// Assert the response actually round-trips, not just that the call didn't error.
assert.Equal(t, "sp-token", tokenResponse.(RequestServiceAccessToken200JSONResponse).AccessToken)
})
t.Run("empty string is rejected up front", func(t *testing.T) {
// service_provider_subject_id is optional; an explicit "" must not silently dispatch into
// the two-VP flow with a meaningless subject.
ctx := newTestClient(t)
emptySP := ""
bodyWithEmptySP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &emptySP,
}

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithEmptySP})

require.Error(t, err)
assert.ErrorContains(t, err, "service_provider_subject_id")
assert.ErrorContains(t, err, "cannot be empty")
})
t.Run("unknown subject returns 400", func(t *testing.T) {
// Mirrors the existing path-param check: a non-existent service-provider subject yields a
// clear OAuth invalid_request, not the misleading 412 "did method mismatch" that would come
// from a downstream ListDIDs("") result.
ctx := newTestClient(t)
unknownSP := unknownSubjectID
bodyWithUnknownSP := &RequestServiceAccessTokenJSONRequestBody{
AuthorizationServer: verifierURL.String(),
Scope: "first second",
ServiceProviderSubjectId: &unknownSP,
}

_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: bodyWithUnknownSP})

require.Error(t, err)
var oauthErr oauth.OAuth2Error
require.ErrorAs(t, err, &oauthErr)
assert.Equal(t, oauth.InvalidRequest, oauthErr.Code)
assert.Contains(t, oauthErr.Description, "subject not found")
})
})
t.Run("self-asserted credentials", func(t *testing.T) {
response := &oauth.TokenResponse{
AccessToken: "token",
Expand All @@ -1024,7 +1082,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
{ID: to.Ptr(ssi.MustParseURI("not empty"))},
}
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil).Return(response, nil)
ctx.iamClient.EXPECT().RequestServiceAccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials, nil, nil).Return(response, nil)

_, err := ctx.client.RequestServiceAccessToken(nil, request)

Expand Down
13 changes: 13 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.

Loading
Loading