Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3da4505
work
reinkrul Jan 14, 2026
ccd00b9
fix compoilcation error
reinkrul Feb 2, 2026
efd2670
implement JWT bearer token server-side
reinkrul Mar 10, 2026
71aff84
wip
reinkrul Mar 10, 2026
c57d598
Resolve rebase conflicts with master: combine policyId + credentialSe…
reinkrul Apr 13, 2026
5e97272
Fix broken tests in bearer_token_test.go
reinkrul Apr 13, 2026
934f2bb
Rename CredentialProfileValidatorFunc to CredentialProfileFunc
reinkrul Apr 13, 2026
fd446d0
Revert rename: handleAuthzCodeTokenRequest -> handleAccessTokenRequest
reinkrul Apr 13, 2026
e779a6e
Fix review findings; revert handleRFC021VPTokenRequest rename
reinkrul Apr 13, 2026
2d59d5f
Rename CredentialProfile(Func) to PresentationEvaluatorFunc
reinkrul Apr 13, 2026
233a334
Rename SubmissionCredentialProfile and BasicCredentialProfile
reinkrul Apr 13, 2026
d15f020
Add e2e test for JWT bearer grant type (RFC7523)
reinkrul Apr 13, 2026
0964b88
make grant types configurable
reinkrul Apr 13, 2026
af3824c
Move Jaeger tracing e2e test to standalone single-node test
reinkrul Apr 13, 2026
df42112
Fix tracing e2e test: disable strictmode, bind internal HTTP external…
reinkrul Apr 13, 2026
4c05da1
Address shellcheck issues
reinkrul Apr 13, 2026
e74e0f7
Fix tracing e2e test CI failure: use nuts.yaml, disable IRMA
reinkrul Apr 13, 2026
758dd48
Merge branch 'e2e-test/tracing' into support-jwtbearer
reinkrul Apr 13, 2026
b45694f
Merge remote-tracking branch 'origin/master' into support-jwtbearer
reinkrul Apr 13, 2026
cd2d9f8
improve
reinkrul Apr 13, 2026
797504f
Populate InputDescriptorConstraintIdMap in OpenID4VP access token
reinkrul Apr 14, 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
1 change: 1 addition & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ The following options can be configured on the server:
verbosity info Log level (trace, debug, info, warn, error)
httpclient.timeout 30s Request time-out for HTTP clients, such as '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax.
**Auth**
auth.granttypes [authorization_code,vp_token-bearer,urn:ietf:params:oauth:grant-type:jwt-bearer] enables OAuth2 grant types for the Authorization Server, options: authorization_code, urn:ietf:params:oauth:grant-type:pre-authorized_code, vp_token-bearer, urn:ietf:params:oauth:grant-type:jwt-bearer
auth.authorizationendpoint.enabled false enables the v2 API's OAuth2 Authorization Endpoint, used by OpenID4VP and OpenID4VCI. This flag might be removed in a future version (or its default become 'true') as the use cases and implementation of OpenID4VP and OpenID4VCI mature.
**Crypto**
crypto.storage Storage to use, 'fs' for file system (for development purposes), 'vaultkv' for HashiCorp Vault KV store, 'azure-keyvault' for Azure Key Vault, 'external' for an external backend (deprecated).
Expand Down
15 changes: 10 additions & 5 deletions auth/api/auth/v1/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ package v1
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"

ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
Expand All @@ -40,11 +46,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
)

type TestContext struct {
Expand Down Expand Up @@ -98,6 +99,10 @@ func (m *mockAuthClient) SupportedDIDMethods() []string {
return m.supportedDIDMethods
}

func (m *mockAuthClient) GrantTypes() []string {
return oauth2.SupportedGrantTypes()
}

func createContext(t *testing.T) *TestContext {
t.Helper()
ctrl := gomock.NewController(t)
Expand Down
56 changes: 31 additions & 25 deletions auth/api/iam/access_token.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,12 @@ package iam

import (
"fmt"
"reflect"
"time"

"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core/to"
"github.com/nuts-foundation/nuts-node/crypto"
"time"

"github.com/nuts-foundation/nuts-node/crypto/dpop"
"github.com/nuts-foundation/nuts-node/vcr/pe"
Expand Down Expand Up @@ -59,34 +61,38 @@ type AccessToken struct {
PresentationDefinitions pe.WalletOwnerMapping `json:"presentation_definitions,omitempty"`
}

// createAccessToken is used in both the s2s and openid4vp flows
func (r Wrapper) createAccessToken(issuerURL string, clientID string, issueTime time.Time, scope string, pexState PEXConsumer, dpopToken *dpop.DPoP) (*oauth.TokenResponse, error) {
credentialMap, err := pexState.credentialMap()
if err != nil {
return nil, err
// AddInputDescriptorConstraintIdMap adds the given map to the access token.
// If there are already values in the map, they MUST equal the new values, otherwise an error is returned.
// This is used for having claims from multiple access policies/presentation definitions in the same access token,
// while preventing conflicts between them (2 policies specifying the same credential ID field for different credentials).
func (a *AccessToken) AddInputDescriptorConstraintIdMap(claims map[string]any) error {
if a.InputDescriptorConstraintIdMap == nil {
a.InputDescriptorConstraintIdMap = make(map[string]any)
}
fieldsMap, err := resolveInputDescriptorValues(pexState.RequiredPresentationDefinitions, credentialMap)
if err != nil {
return nil, err
for k, v := range claims {
if existing, ok := a.InputDescriptorConstraintIdMap[k]; ok {
if !reflect.DeepEqual(existing, v) {
return fmt.Errorf("conflicting values for input descriptor constraint id %s: existing value %v, new value %v", k, existing, v)
}
} else {
a.InputDescriptorConstraintIdMap[k] = v
}
}
return nil
}

accessToken := AccessToken{
DPoP: dpopToken,
Token: crypto.GenerateNonce(),
Issuer: issuerURL,
IssuedAt: issueTime,
ClientId: clientID,
Expiration: issueTime.Add(accessTokenValidity),
Scope: scope,
PresentationSubmissions: pexState.Submissions,
PresentationDefinitions: pexState.RequiredPresentationDefinitions,
InputDescriptorConstraintIdMap: fieldsMap,
}
for _, envelope := range pexState.SubmittedEnvelopes {
accessToken.VPToken = append(accessToken.VPToken, envelope.Presentations...)
}
// createAccessToken is used in both the s2s and openid4vp flows
func (r Wrapper) createAccessToken(issuerURL string, clientID string, issueTime time.Time, scope string, template AccessToken, dpopToken *dpop.DPoP) (*oauth.TokenResponse, error) {
accessToken := template
accessToken.DPoP = dpopToken
accessToken.Token = crypto.GenerateNonce()
accessToken.Issuer = issuerURL
accessToken.IssuedAt = issueTime
accessToken.ClientId = clientID
accessToken.Expiration = issueTime.Add(accessTokenValidity)
accessToken.Scope = scope

err = r.accessTokenServerStore().Put(accessToken.Token, accessToken)
err := r.accessTokenServerStore().Put(accessToken.Token, accessToken)
if err != nil {
return nil, fmt.Errorf("unable to store access token: %w", err)
}
Expand Down
47 changes: 33 additions & 14 deletions auth/api/iam/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,14 +29,15 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/core/to"
"html/template"
"net/http"
"net/url"
"slices"
"strings"
"time"

"github.com/nuts-foundation/nuts-node/core/to"

"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
Expand Down Expand Up @@ -234,6 +235,16 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
Code: oauth.UnsupportedGrantType,
Description: "not implemented yet",
}
case oauth.JWTBearerGrantType:
// Twinn TA NP & LSPxNuts flow
// TODO: support client_assertion
if request.Body.Assertion == nil || request.Body.Scope == nil || request.Body.ClientId == nil {
return nil, oauth.OAuth2Error{
Code: oauth.InvalidRequest,
Description: "missing required parameters",
}
}
return r.handleJWTBearerTokenRequest(ctx, *request.Body.ClientId, request.SubjectID, *request.Body.Scope, *request.Body.Assertion)
case oauth.VpTokenGrantType:
// Nuts RFC021 vp_token bearer flow
if request.Body.PresentationSubmission == nil || request.Body.Scope == nil || request.Body.Assertion == nil || request.Body.ClientId == nil {
Expand Down Expand Up @@ -419,16 +430,20 @@ func (r Wrapper) introspectAccessToken(input string) (*ExtendedTokenIntrospectio
iat := int(token.IssuedAt.Unix())
exp := int(token.Expiration.Unix())
response := ExtendedTokenIntrospectionResponse{
Active: true,
Cnf: cnf,
Iat: &iat,
Exp: &exp,
Iss: &token.Issuer,
ClientId: &token.ClientId,
Scope: &token.Scope,
Vps: &token.VPToken,
PresentationDefinitions: &token.PresentationDefinitions,
PresentationSubmissions: &token.PresentationSubmissions,
Active: true,
Cnf: cnf,
Iat: &iat,
Exp: &exp,
Iss: &token.Issuer,
ClientId: &token.ClientId,
Scope: &token.Scope,
Vps: &token.VPToken,
}
if token.PresentationDefinitions != nil {
response.PresentationDefinitions = &token.PresentationDefinitions
}
if token.PresentationSubmissions != nil {
response.PresentationSubmissions = &token.PresentationSubmissions
}

if token.InputDescriptorConstraintIdMap != nil {
Expand Down Expand Up @@ -615,7 +630,7 @@ func (r Wrapper) OAuthAuthorizationServerMetadata(_ context.Context, request OAu
}

func (r Wrapper) oauthAuthorizationServerMetadata(clientID url.URL) (*oauth.AuthorizationServerMetadata, error) {
md := authorizationServerMetadata(&clientID, r.auth.SupportedDIDMethods())
md := authorizationServerMetadata(&clientID, r.auth.SupportedDIDMethods(), r.auth.SupportedDIDMethods())
if !r.auth.AuthorizationEndpointEnabled() {
md.AuthorizationEndpoint = ""
}
Expand Down Expand Up @@ -679,7 +694,7 @@ func (r Wrapper) OpenIDConfiguration(ctx context.Context, request OpenIDConfigur
// this is a shortcoming of the openID federation vs OpenID4VP/DID worlds
// issuer URL equals server baseURL + :/oauth2/:subject
issuerURL := r.subjectToBaseURL(request.SubjectID)
configuration := openIDConfiguration(issuerURL, set, r.auth.SupportedDIDMethods())
configuration := openIDConfiguration(issuerURL, set, r.auth.SupportedDIDMethods(), r.auth.GrantTypes())
claims := make(map[string]interface{})
asJson, _ := json.Marshal(configuration)
_ = json.Unmarshal(asJson, &claims)
Expand Down Expand Up @@ -781,7 +796,11 @@ 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)
var policyId string
if request.Body.PolicyId != nil {
policyId = *request.Body.PolicyId
}
tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, policyId, useDPoP, credentials, credentialSelection)
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
19 changes: 10 additions & 9 deletions auth/api/iam/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil, nil).Return(response, nil).Times(2)
token, err := ctx.client.RequestServiceAccessToken(nil, request)

// Test call to check cache is bypassed
Expand All @@ -907,7 +907,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil, nil).Return(response, nil)

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

Expand Down Expand Up @@ -946,7 +946,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil)

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

Expand All @@ -963,7 +963,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", false, nil, nil).Return(&oauth.TokenResponse{}, nil)

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

Expand All @@ -972,7 +972,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)

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

Expand All @@ -981,7 +981,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil, nil).Return(nil, pe.ErrNoCredentials)

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

Expand All @@ -997,8 +997,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().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)

token1, err := ctx.client.RequestServiceAccessToken(nil, request)
require.NoError(t, err)
Expand All @@ -1023,7 +1023,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().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, *body.Credentials, nil).Return(response, nil)

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

Expand Down Expand Up @@ -1616,6 +1616,7 @@ func newCustomTestClient(t testing.TB, publicURL *url.URL, authEndpointEnabled b
authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes()
authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes()
authnServices.EXPECT().SupportedDIDMethods().Return([]string{"web"}).AnyTimes()
authnServices.EXPECT().GrantTypes().Return(oauth.SupportedGrantTypes()).AnyTimes()
mockVCR.EXPECT().Issuer().Return(vcIssuer).AnyTimes()
mockVCR.EXPECT().Verifier().Return(vcVerifier).AnyTimes()
mockVCR.EXPECT().Wallet().Return(mockWallet).AnyTimes()
Expand Down
Loading
Loading