diff --git a/.github/workflows/build-images.yaml b/.github/workflows/build-images.yaml
index 8e53dda6b9..881cdb07b7 100644
--- a/.github/workflows/build-images.yaml
+++ b/.github/workflows/build-images.yaml
@@ -5,11 +5,13 @@ on:
push:
branches:
- master
+ - project-gf
tags:
- 'v*'
pull_request:
branches:
- master
+ - project-gf
# cancel build action if superseded by new commit on same branch
concurrency:
@@ -51,7 +53,7 @@ jobs:
images: nutsfoundation/nuts-node
tags: |
# generate 'master' tag for the master branch
- type=ref,event=branch,enable={{is_default_branch}},prefix=
+ type=ref,event=branch,enable=true,prefix=
# generate 5.2.1 tag
type=semver,pattern={{version}}
flavor: |
diff --git a/Dockerfile b/Dockerfile
index 8c49e7018b..17f31384bc 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -25,7 +25,10 @@ FROM alpine:3.23.3
RUN apk update \
&& apk add --no-cache \
tzdata \
- curl
+ curl \
+ ca-certificates
+COPY pki/cacerts/* /usr/local/share/ca-certificates/
+RUN update-ca-certificates
COPY --from=builder /opt/nuts/nuts /usr/bin/nuts
HEALTHCHECK --start-period=30s --timeout=5s --interval=10s \
diff --git a/LSPxNuts_README.md b/LSPxNuts_README.md
new file mode 100644
index 0000000000..169f44b691
--- /dev/null
+++ b/LSPxNuts_README.md
@@ -0,0 +1,16 @@
+# LSPxNuts Proof of Concept
+
+This is a branch that for the Proof of Concept of the LSPxNuts project.
+
+It adds or alters the following functionality versus the mainstream Nuts node:
+
+- OAuth2 `vp_bearer` token exchange: read presentation definition from local definitions instead of fetching it from the remote authorization server.
+ LSP doesn't support presentation definitions, meaning that we need to look it up locally.
+- Add support for JWT bearer grant type. If the server supports this, it uses this grant type instead of the Nuts-specific vp_token-bearer grant type.
+- Add CA certificates of Sectigo (root CA, OV and EV intermediate CA) to Docker image's OS CA bundle, because they're used by AORTA-LSP.
+- Fix marshalling of Verifiable Presentations in JWT format; `type` was marshalled as JSON-LD (single-entry-array was replaced by string)
+- Add `policy_id` field to access token request to specify the Presentation Definition that should be used.
+ The `scope` can then be specified as whatever the use case requires (e.g. SMART on FHIR-esque scopes).
+- Relax `did:x509` key usage check: the certificate from UZI smart cards that is used to sign credentials, doesn't have `serverAuth` key usage, only `digitalSignature`.
+ This broke, since we didn't specify the key usage, but `x509.Verify()` expects key usage `serverAuth` to be present by default.
+- Add support for `RS256` (RSA 2048) signatures, since that's what UZI smart cards produce.
\ No newline at end of file
diff --git a/README.rst b/README.rst
index dfb0847da7..5ffd28c727 100644
--- a/README.rst
+++ b/README.rst
@@ -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).
@@ -233,6 +234,9 @@ The following options can be configured on the server:
tracing.endpoint OTLP collector endpoint for OpenTelemetry tracing (e.g., 'localhost:4318'). When empty, tracing is disabled.
tracing.insecure false Disable TLS for the OTLP connection.
tracing.servicename Service name reported to the tracing backend. Defaults to 'nuts-node'.
+ **VCR**
+ vcr.dezi.allowedjku [] List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json).
+ vcr.verifier.revocation.maxage 15m0s Max age of revocation information. If the revocation information is older than this, it will be refreshed from the issuer. If set to 0 or negative, revocation information will always be refreshed.
**policy**
policy.directory ./config/policy Directory to read policy files from. Policy files are JSON files that contain a scope to PresentationDefinition mapping.
======================================== =================================================================================================================================================================================================================================================================================================================================================================================================================================================================== ============================================================================================================================================================================================================================================================================================================================================
diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go
index 3310ad2ba9..e67ddefbe2 100644
--- a/auth/api/auth/v1/api_test.go
+++ b/auth/api/auth/v1/api_test.go
@@ -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"
@@ -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 {
@@ -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)
diff --git a/auth/api/iam/access_token.go b/auth/api/iam/access_token.go
index 9fb0720949..6cbb48a96b 100644
--- a/auth/api/iam/access_token.go
+++ b/auth/api/iam/access_token.go
@@ -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"
@@ -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)
}
diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go
index bedbba113d..c0f75baa47 100644
--- a/auth/api/iam/api.go
+++ b/auth/api/iam/api.go
@@ -29,7 +29,6 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/nuts-foundation/nuts-node/core/to"
"html/template"
"net/http"
"net/url"
@@ -48,6 +47,7 @@ import (
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/core/to"
nutsCrypto "github.com/nuts-foundation/nuts-node/crypto"
nutsHttp "github.com/nuts-foundation/nuts-node/http"
"github.com/nuts-foundation/nuts-node/http/cache"
@@ -56,6 +56,7 @@ import (
"github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr"
+ "github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
@@ -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 {
@@ -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 {
@@ -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.GrantTypes())
if !r.auth.AuthorizationEndpointEnabled() {
md.AuthorizationEndpoint = ""
}
@@ -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)
@@ -729,31 +744,48 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
return nil, err
}
- tokenCache := r.accessTokenCache()
- cacheKey := accessTokenRequestCacheKey(request)
- if request.Params.CacheControl == nil || *request.Params.CacheControl != "no-cache" {
- // try to retrieve token from cache
- tokenCacheResult := new(TokenResponse)
- err = tokenCache.Get(cacheKey, tokenCacheResult)
- if err == nil {
- // adjust tokenCacheResult.ExpiresIn to the remaining time
- expiresAt := time.Unix(int64(*tokenCacheResult.ExpiresAt), 0)
- tokenCacheResult.ExpiresIn = to.Ptr(int(time.Until(expiresAt).Seconds()))
- return RequestServiceAccessToken200JSONResponse(*tokenCacheResult), nil
- } else if !errors.Is(err, storage.ErrNotFound) {
- // only log error, don't fail
- log.Logger().WithError(err).Warnf("Failed to retrieve access token from cache: %s", err.Error())
- }
- }
+ // PROJECT-GF: Disabled for testing credential revocation
+ //tokenCache := r.accessTokenCache()
+ //cacheKey := accessTokenRequestCacheKey(request)
+ //if request.Params.CacheControl == nil || *request.Params.CacheControl != "no-cache" {
+ // // try to retrieve token from cache
+ // tokenCacheResult := new(TokenResponse)
+ // err = tokenCache.Get(cacheKey, tokenCacheResult)
+ // if err == nil {
+ // // adjust tokenCacheResult.ExpiresIn to the remaining time
+ // expiresAt := time.Unix(int64(*tokenCacheResult.ExpiresAt), 0)
+ // tokenCacheResult.ExpiresIn = to.Ptr(int(time.Until(expiresAt).Seconds()))
+ // return RequestServiceAccessToken200JSONResponse(*tokenCacheResult), nil
+ // } else if !errors.Is(err, storage.ErrNotFound) {
+ // // only log error, don't fail
+ // log.Logger().WithError(err).Warnf("Failed to retrieve access token from cache: %s", err.Error())
+ // }
+ //}
var credentials []VerifiableCredential
if request.Body.Credentials != nil {
credentials = *request.Body.Credentials
}
+
+ idTokenCredentialIdx := -1
+ if request.Body.IdToken != nil {
+ idTokenCredential, err := credential.CreateDeziUserCredential(*request.Body.IdToken)
+ if err != nil {
+ return nil, core.InvalidInputError("failed to create id_token credential: %w", err)
+ }
+ credentials = append(credentials, *idTokenCredential)
+ idTokenCredentialIdx = len(credentials) - 1
+ }
+
// assert that self-asserted credentials do not contain an issuer or credentialSubject.id. These values must be set
// by the nuts-node to build the correct wallet for a DID. See https://github.com/nuts-foundation/nuts-node/issues/3696
- // As a sideeffect it is no longer possible to pass signed credentials to this API.
- for _, cred := range credentials {
+ // As a side effect it is no longer possible to pass signed credentials to this API.
+ for i, cred := range credentials {
+ // But not for id_token credentials, these are externally signed, meaning they have an issuer
+ if i == idTokenCredentialIdx {
+ continue
+ }
+
var credentialSubject []map[string]interface{}
if err := cred.UnmarshalCredentialSubject(&credentialSubject); err != nil {
// extremely unlikely
@@ -781,7 +813,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
@@ -792,12 +828,13 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
}
tokenResult.ExpiresAt = to.Ptr(int(time.Now().Add(ttl).Unix()))
// we reduce the ttl by accessTokenCacheOffset to make sure the token is expired when the cache expires
- ttl -= accessTokenCacheOffset
- err = tokenCache.Put(cacheKey, tokenResult, storage.WithTTL(ttl))
- if err != nil {
- // only log error, don't fail
- log.Logger().WithError(err).Warnf("Failed to cache access token: %s", err.Error())
- }
+ // PROJECT-GF: Disabled for testing credential revocation
+ //ttl -= accessTokenCacheOffset
+ //err = tokenCache.Put(cacheKey, tokenResult, storage.WithTTL(ttl))
+ //if err != nil {
+ // // only log error, don't fail
+ // log.Logger().WithError(err).Warnf("Failed to cache access token: %s", err.Error())
+ //}
return RequestServiceAccessToken200JSONResponse(*tokenResult), nil
}
diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go
index 5be4de20e1..75e1ee254d 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -877,6 +877,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
}
t.Run("ok - bypass cache (client uses Cache-Control: no-cache)", func(t *testing.T) {
+ t.Skip("PROJECT-GF: Disabled for testing credential revocation")
ctx := newTestClient(t)
response := &oauth.TokenResponse{
AccessToken: "token",
@@ -886,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().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
@@ -907,13 +908,14 @@ 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)
require.NoError(t, err)
t.Run("is cached", func(t *testing.T) {
+ t.Skip("PROJECT-GF: Disabled for testing credential revocation")
cachedToken, err := ctx.client.RequestServiceAccessToken(nil, request)
require.NoError(t, err)
@@ -925,6 +927,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
})
t.Run("check expires_at reduction", func(t *testing.T) {
+ t.Skip("PROJECT-GF: Disabled for testing credential revocation")
// get current cached value and adjust ExpiresAt
cacheKey := accessTokenRequestCacheKey(request)
var cachedTokenResponse TokenResponse
@@ -944,9 +947,10 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
})
t.Run("cache expired", func(t *testing.T) {
+ t.Skip("PROJECT-GF: Disabled for testing credential revocation")
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)
@@ -963,7 +967,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})
@@ -972,7 +976,37 @@ 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)
+
+ require.NoError(t, err)
+ assert.False(t, ctx.client.accessTokenCache().Exists(accessTokenRequestCacheKey(request)))
+ })
+ t.Run("with Dezi id_token", func(t *testing.T) {
+ ctx := newTestClient(t)
+ idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ"
+ request := RequestServiceAccessTokenRequestObject{
+ SubjectID: holderSubjectID,
+ Body: &RequestServiceAccessTokenJSONRequestBody{
+ AuthorizationServer: verifierURL.String(),
+ Scope: "first second",
+ IdToken: to.Ptr(idToken),
+ },
+ }
+
+ // Expect that the id_token is converted to a Dezi credential and passed to RequestRFC021AccessToken
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(
+ nil,
+ holderClientID,
+ holderSubjectID,
+ verifierURL.String(),
+ "first second",
+ "", // policyId
+ true,
+ gomock.Any(), // The id_token is converted to a DeziUserCredential
+ nil,
+ ).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)
_, err := ctx.client.RequestServiceAccessToken(nil, request)
@@ -981,7 +1015,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})
@@ -990,6 +1024,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
assert.Equal(t, http.StatusPreconditionFailed, statusCodeFrom(err))
})
t.Run("broken cache", func(t *testing.T) {
+ t.Skip("PROJECT-GF: Disabled for testing credential revocation")
ctx := newTestClient(t)
mockStorage := storage.NewMockEngine(ctx.ctrl)
errorSessionDatabase := storage.NewErrorSessionDatabase(assert.AnError)
@@ -997,8 +1032,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)
@@ -1023,7 +1058,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)
@@ -1616,6 +1651,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()
diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/bearer_token.go
similarity index 78%
rename from auth/api/iam/s2s_vptoken.go
rename to auth/api/iam/bearer_token.go
index c215ea4269..d97c0a2ca9 100644
--- a/auth/api/iam/s2s_vptoken.go
+++ b/auth/api/iam/bearer_token.go
@@ -23,6 +23,7 @@ import (
"errors"
"fmt"
"net/http"
+ "strings"
"time"
"github.com/nuts-foundation/go-did/did"
@@ -59,8 +60,34 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
}
}
+ response, err := r.handleBearerTokenRequest(ctx, clientID, subject, scope, pexEnvelope.Presentations, SubmissionPresentationEvaluator(*submission, *pexEnvelope))
+ if err != nil {
+ return nil, err
+ }
+ return HandleTokenRequest200JSONResponse(*response), nil
+}
+
+// handleJWTBearerTokenRequest handles the /token request with jwt_bearer grant type, as specified by RFC7523.
+func (r Wrapper) handleJWTBearerTokenRequest(ctx context.Context, clientID string, subject string, scope string, assertion string) (HandleTokenRequestResponseObject, error) {
+ // TODO: support client_assertion
+ presentation, err := vc.ParseVerifiablePresentation(assertion)
+ if err != nil {
+ return nil, oauth.OAuth2Error{
+ Code: oauth.InvalidRequest,
+ Description: "assertion parameter is invalid",
+ InternalError: fmt.Errorf("parsing assertion as verifiable presentation: %w", err),
+ }
+ }
+ response, err := r.handleBearerTokenRequest(ctx, clientID, subject, scope, []VerifiablePresentation{*presentation}, BasicPresentationEvaluator(*presentation))
+ if err != nil {
+ return nil, err
+ }
+ return HandleTokenRequest200JSONResponse(*response), nil
+}
+
+func (r Wrapper) handleBearerTokenRequest(ctx context.Context, clientID string, subject string, scope string, presentations []VerifiablePresentation, evaluator CredentialProfile) (*oauth.TokenResponse, error) {
var credentialSubjectID did.DID
- for _, presentation := range pexEnvelope.Presentations {
+ for _, presentation := range presentations {
if err := validateS2SPresentationMaxValidity(presentation); err != nil {
return nil, err
}
@@ -73,16 +100,27 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
return nil, err
}
}
- walletOwnerMapping, err := r.presentationDefinitionForScope(ctx, scope)
- if err != nil {
- return nil, err
- }
- pexConsumer := newPEXConsumer(walletOwnerMapping)
- if err := pexConsumer.fulfill(*submission, *pexEnvelope); err != nil {
- return nil, oauthError(oauth.InvalidRequest, err.Error())
+
+ // For every scope, find the required Presentation Definition and validate the VP(s) according to the required credentials.
+ // TODO: tests for multiple scopes
+ accessToken := new(AccessToken)
+ scopes := strings.Split(scope, " ")
+ for _, currScope := range scopes {
+ if currScope == "" {
+ continue
+ }
+ walletOwnerMapping, err := r.presentationDefinitionForScope(ctx, currScope)
+ if err != nil {
+ return nil, err
+ }
+ // Validate Verifiable Presentation according to the required credential profile.
+ // How this is done, depends on the grant type (RFC021 VP token or RFC7523 JWT Bearer).
+ if err = evaluator(ctx, walletOwnerMapping, accessToken); err != nil {
+ return nil, err
+ }
}
- for _, presentation := range pexEnvelope.Presentations {
+ for _, presentation := range presentations {
if err := r.validateS2SPresentationNonce(presentation); err != nil {
return nil, err
}
@@ -96,7 +134,7 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
}
// Check signatures of VP and VCs. Trust should be established by the Presentation Definition.
- for _, presentation := range pexEnvelope.Presentations {
+ for _, presentation := range presentations {
_, err = r.vcr.Verifier().VerifyVP(presentation, true, true, nil)
if err != nil {
return nil, oauth.OAuth2Error{
@@ -109,11 +147,7 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
// All OK, allow access
issuerURL := r.subjectToBaseURL(subject)
- response, err := r.createAccessToken(issuerURL.String(), clientID, time.Now(), scope, *pexConsumer, dpopProof)
- if err != nil {
- return nil, err
- }
- return HandleTokenRequest200JSONResponse(*response), nil
+ return r.createAccessToken(issuerURL.String(), clientID, time.Now(), scope, *accessToken, dpopProof)
}
func resolveInputDescriptorValues(presentationDefinitions pe.WalletOwnerMapping, credentialMap map[string]vc.VerifiableCredential) (map[string]any, error) {
diff --git a/auth/api/iam/bearer_token_test.go b/auth/api/iam/bearer_token_test.go
new file mode 100644
index 0000000000..c329089118
--- /dev/null
+++ b/auth/api/iam/bearer_token_test.go
@@ -0,0 +1,519 @@
+/*
+ * Copyright (C) 2023 Nuts community
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ *
+ */
+
+package iam
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "crypto/elliptic"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
+ "github.com/nuts-foundation/nuts-node/policy"
+ "go.uber.org/mock/gomock"
+
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ ssi "github.com/nuts-foundation/go-did"
+ "github.com/nuts-foundation/go-did/did"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/jsonld"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
+ "github.com/nuts-foundation/nuts-node/vcr/signature/proof"
+ "github.com/nuts-foundation/nuts-node/vcr/test"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWrapper_handleTokenRequest(t *testing.T) {
+ const requestedScope = "example-scope"
+ const requestedScope2 = "second-scope"
+ const requestedScopes = requestedScope + " " + requestedScope2
+ // Create issuer DID document and keys
+ keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
+ issuerDIDDocument := did.Document{
+ ID: issuerDID,
+ }
+ keyID := did.DIDURL{DID: issuerDID}
+ keyID.Fragment = "1"
+ verificationMethod, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, issuerDID, keyPair.Public())
+ require.NoError(t, err)
+ issuerDIDDocument.AddAssertionMethod(verificationMethod)
+
+ var presentationDefinition pe.PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(`
+{
+ "format": {
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ }
+ },
+ "input_descriptors": [
+ {
+ "id": "1",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ }
+ ]
+ }
+ }
+ ]
+}`), &presentationDefinition))
+
+ walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerOrganization: presentationDefinition}
+ var submission pe.PresentationSubmission
+ require.NoError(t, json.Unmarshal([]byte(`
+{
+ "descriptor_map": [
+ {
+ "id": "1",
+ "path": "$.verifiableCredential",
+ "format": "ldp_vc"
+ }
+ ]
+}`), &submission))
+ submissionJSONBytes, _ := json.Marshal(submission)
+ submissionJSON := string(submissionJSONBytes)
+ verifiableCredential := test.ValidNutsOrganizationCredential(t)
+ subjectDID, _ := verifiableCredential.SubjectDID()
+ proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
+ proof.Domain = &issuerClientID
+ })
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
+ dpopHeader, _, _ := newSignedTestDPoP()
+ httpRequest := &http.Request{
+ Header: http.Header{
+ "Dpop": []string{dpopHeader.String()},
+ },
+ }
+ contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest)
+ clientID := "https://example.com/oauth2/holder"
+
+ t.Run("shared code for all grant types", func(t *testing.T) {
+ validatorFunc := CredentialProfile(func(_ context.Context, _ pe.WalletOwnerMapping, _ *AccessToken) error {
+ return nil
+ })
+ t.Run("missing presentation expiry date", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Remove(jwt.ExpirationKey))
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
+ })
+ t.Run("missing presentation not before date", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Remove(jwt.NotBeforeKey))
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
+ })
+ t.Run("missing presentation valid for too long", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)))
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation is valid for too long (max 5s)")
+ })
+ t.Run("not all VPs have the same credential subject ID", func(t *testing.T) {
+ ctx := newTestClient(t)
+
+ secondSubjectID := did.MustParseDID("did:web:example.com:other")
+ secondPresentation := test.CreateJSONLDPresentation(t, secondSubjectID, proofVisitor, test.JWTNutsOrganizationCredential(t, secondSubjectID))
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation, secondPresentation}, validatorFunc)
+ assert.EqualError(t, err, "invalid_request - not all presentations have the same credential subject ID")
+ })
+ t.Run("nonce", func(t *testing.T) {
+ t.Run("replay attack (nonce is reused)", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil).Times(2)
+
+ _, err := ctx.client.handleBearerTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+ require.NoError(t, err)
+
+ _, err = ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+ assert.EqualError(t, err, "invalid_request - presentation nonce has already been used")
+ })
+ t.Run("JSON-LD VP is missing nonce", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
+ proof.Domain = &issuerClientID
+ proof.Nonce = nil
+ })
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+ assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
+ })
+ t.Run("JSON-LD VP has empty nonce", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
+ proof.Domain = &issuerClientID
+ proof.Nonce = new(string)
+ })
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+ assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
+ })
+ t.Run("JWT VP is missing nonce", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ _ = token.Set(jwt.AudienceKey, issuerClientID)
+ _ = token.Remove("nonce")
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
+ })
+ t.Run("JWT VP has empty nonce", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ _ = token.Set(jwt.AudienceKey, issuerClientID)
+ _ = token.Set("nonce", "")
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
+ })
+ t.Run("JWT VP nonce is not a string", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ _ = token.Set(jwt.AudienceKey, issuerClientID)
+ _ = token.Set("nonce", true)
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
+ })
+ })
+ t.Run("audience", func(t *testing.T) {
+ t.Run("missing", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, nil, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ assert.EqualError(t, err, "invalid_request - expected: https://example.com/oauth2/issuer, got: [] - presentation audience/domain is missing or does not match")
+ })
+ t.Run("not matching", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Set(jwt.AudienceKey, "did:example:other"))
+ }, verifiableCredential)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ assert.EqualError(t, err, "invalid_request - expected: https://example.com/oauth2/issuer, got: [did:example:other] - presentation audience/domain is missing or does not match")
+ })
+ })
+ t.Run("VP verification fails", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid"))
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+
+ _, err := ctx.client.handleBearerTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or credential(s) verification failed")
+ })
+ t.Run("proof of ownership", func(t *testing.T) {
+ t.Run("VC without credentialSubject.id", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{{}},
+ })
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ assert.EqualError(t, err, `invalid_request - unable to get subject DID from VC: credential subjects have no ID`)
+ })
+ t.Run("signing key is not owned by credentialSubject.id", func(t *testing.T) {
+ ctx := newTestClient(t)
+ // Copy the proof map to avoid mutating the shared presentation used by later tests
+ originalProof := presentation.Proof[0].(map[string]interface{})
+ invalidProof := make(map[string]interface{})
+ for k, v := range originalProof {
+ invalidProof[k] = v
+ }
+ invalidProof["verificationMethod"] = "did:example:other#1"
+ verifiablePresentation := test.ParsePresentation(t, vc.VerifiablePresentation{
+ VerifiableCredential: []vc.VerifiableCredential{verifiableCredential},
+ Proof: []interface{}{invalidProof},
+ })
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{verifiablePresentation}, validatorFunc)
+
+ assert.EqualError(t, err, `invalid_request - presentation signer is not credential subject`)
+ })
+ })
+ t.Run("unsupported scope", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "everything").Return(nil, policy.ErrNotFound)
+
+ _, err := ctx.client.handleBearerTokenRequest(context.Background(), clientID, issuerSubjectID, "everything", []VerifiablePresentation{presentation}, validatorFunc)
+
+ assert.EqualError(t, err, `invalid_scope - not found - unsupported scope (everything) for presentation exchange: not found`)
+ })
+ t.Run("invalid DPoP header", func(t *testing.T) {
+ ctx := newTestClient(t)
+ httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}}
+ httpRequest.Header.Set("DPoP", "invalid")
+ contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+
+ _, err := ctx.client.handleBearerTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, []VerifiablePresentation{presentation}, validatorFunc)
+
+ _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid")
+ })
+ })
+ t.Run("RFC021 vp_bearer token grant type", func(t *testing.T) {
+ t.Run("JSON-LD VP", func(t *testing.T) {
+ ctx := newTestClient(t)
+ ctx.vcVerifier.EXPECT().VerifyVP(gomock.Any(), true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ ctxWithRequest := context.WithValue(context.Background(), httpRequestContextKey{}, &http.Request{Header: http.Header{}})
+
+ resp, err := ctx.client.handleS2SAccessTokenRequest(ctxWithRequest, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
+
+ require.NoError(t, err)
+ require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
+ tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
+ assert.Equal(t, "Bearer", tokenResponse.TokenType)
+ assert.Equal(t, requestedScope, *tokenResponse.Scope)
+ assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
+ assert.NotEmpty(t, tokenResponse.AccessToken)
+ })
+ t.Run("JWT VP", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID))
+ }, verifiableCredential)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+
+ resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
+
+ require.NoError(t, err)
+ require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
+ tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
+ assert.Equal(t, "DPoP", tokenResponse.TokenType)
+ assert.Equal(t, requestedScope, *tokenResponse.Scope)
+ assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
+ assert.NotEmpty(t, tokenResponse.AccessToken)
+ })
+ t.Run("VP is not valid JSON", func(t *testing.T) {
+ ctx := newTestClient(t)
+ resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, "[true, false]")
+
+ assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: invalid JWT")
+ assert.Nil(t, resp)
+ })
+ t.Run("submission is not valid JSON", func(t *testing.T) {
+ ctx := newTestClient(t)
+
+ resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, "not-a-valid-submission", presentation.Raw())
+
+ assert.EqualError(t, err, `invalid_request - invalid presentation submission: invalid character 'o' in literal null (expecting 'u')`)
+ assert.Nil(t, resp)
+ })
+ t.Run("re-evaluation of presentation definition yields different credentials", func(t *testing.T) {
+ // This indicates the client presented credentials that don't actually match the presentation definition,
+ // which could indicate a malicious client.
+ otherVerifiableCredential := vc.VerifiableCredential{
+ CredentialSubject: []map[string]any{
+ {
+ "id": subjectDID.String(),
+ // just for demonstration purposes, what matters is that the credential does not match the presentation definition.
+ "IsAdministrator": true,
+ },
+ },
+ }
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential)
+
+ ctx := newTestClient(t)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+
+ resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
+ assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)")
+ assert.Nil(t, resp)
+ })
+ })
+ t.Run("JWT bearer token grant type", func(t *testing.T) {
+ t.Run("2 scopes", func(t *testing.T) {
+ ctx := newTestClient(t)
+ presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
+ require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID))
+ }, verifiableCredential)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope2).Return(walletOwnerMapping, nil)
+ ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+
+ resp, err := ctx.client.handleJWTBearerTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScopes, presentation.Raw())
+
+ require.NoError(t, err)
+ require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
+ tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
+ assert.Equal(t, "DPoP", tokenResponse.TokenType)
+ assert.Equal(t, requestedScopes, *tokenResponse.Scope)
+ assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
+ assert.NotEmpty(t, tokenResponse.AccessToken)
+ })
+ t.Run("invalid assertion parameter (not a valid VP)", func(t *testing.T) {
+ ctx := newTestClient(t)
+
+ resp, err := ctx.client.handleJWTBearerTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, "not-a-valid-vp")
+
+ assert.EqualError(t, err, "invalid_request - parsing assertion as verifiable presentation: invalid JWT - assertion parameter is invalid")
+ assert.Nil(t, resp)
+ })
+ })
+
+}
+
+func TestWrapper_handleJWTBearerTokenRequest(t *testing.T) {
+ t.Run("2 scopes", func(t *testing.T) {
+
+ })
+}
+
+func TestWrapper_createAccessToken(t *testing.T) {
+ credentialSubjectID := did.MustParseDID("did:nuts:B8PUHs2AUHbFF1xLLK4eZjgErEcMXHxs68FteY7NDtCY")
+ verificationMethodID := ssi.MustParseURI(credentialSubjectID.String() + "#1")
+ credential, err := vc.ParseVerifiableCredential(jsonld.TestOrganizationCredential)
+ require.NoError(t, err)
+ presentation := test.ParsePresentation(t, vc.VerifiablePresentation{
+ VerifiableCredential: []vc.VerifiableCredential{*credential},
+ Proof: []interface{}{
+ proof.LDProof{
+ VerificationMethod: verificationMethodID,
+ },
+ },
+ })
+ fieldId := "credential_type"
+ definition := pe.PresentationDefinition{
+ Id: "definitive",
+ InputDescriptors: []*pe.InputDescriptor{
+ {
+ Id: "1",
+ Constraints: &pe.Constraints{
+ Fields: []pe.Field{
+ {
+ Path: []string{"$.type"},
+ Id: &fieldId,
+ Filter: &pe.Filter{
+ Type: "string",
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+ submission := pe.PresentationSubmission{
+ Id: "submissive",
+ DefinitionId: "definitive",
+ DescriptorMap: []pe.InputDescriptorMappingObject{
+ {
+ Id: "1",
+ Path: "$.verifiableCredential",
+ Format: "ldp_vc",
+ },
+ },
+ }
+ dpopToken, _, _ := newSignedTestDPoP()
+ expectedPresentations := []vc.VerifiablePresentation{test.ParsePresentation(t, presentation)}
+ expectedSubmissions := map[string]pe.PresentationSubmission{
+ "definitive": submission,
+ }
+ expectedPresentationDefinitions := map[pe.WalletOwnerType]pe.PresentationDefinition{
+ pe.WalletOwnerOrganization: definition,
+ }
+ t.Run("ok", func(t *testing.T) {
+ ctx := newTestClient(t)
+
+ require.NoError(t, err)
+ accessToken, err := ctx.client.createAccessToken(issuerURL.String(), credentialSubjectID.String(), time.Now(), "everything", AccessToken{
+ PresentationSubmissions: expectedSubmissions,
+ PresentationDefinitions: expectedPresentationDefinitions,
+ VPToken: expectedPresentations,
+ InputDescriptorConstraintIdMap: map[string]any{
+ "credential_type": []interface{}{"NutsOrganizationCredential", "VerifiableCredential"},
+ },
+ }, dpopToken)
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, accessToken.AccessToken)
+ assert.Equal(t, "DPoP", accessToken.TokenType)
+ assert.Equal(t, 900, *accessToken.ExpiresIn)
+ assert.Equal(t, "everything", *accessToken.Scope)
+
+ var storedToken AccessToken
+ err = ctx.client.accessTokenServerStore().Get(accessToken.AccessToken, &storedToken)
+ require.NoError(t, err)
+ assert.Equal(t, accessToken.AccessToken, storedToken.Token)
+ assert.Equal(t, submission, storedToken.PresentationSubmissions["definitive"])
+ assert.Equal(t, definition, storedToken.PresentationDefinitions[pe.WalletOwnerOrganization])
+ assert.Equal(t, []interface{}{"NutsOrganizationCredential", "VerifiableCredential"}, storedToken.InputDescriptorConstraintIdMap["credential_type"])
+ expectedVPJSON, _ := presentation.MarshalJSON()
+ actualVPJSON, _ := storedToken.VPToken[0].MarshalJSON()
+ assert.JSONEq(t, string(expectedVPJSON), string(actualVPJSON))
+ assert.Equal(t, issuerURL.String(), storedToken.Issuer)
+ assert.NotEmpty(t, storedToken.Expiration)
+ })
+ t.Run("ok - bearer token", func(t *testing.T) {
+ ctx := newTestClient(t)
+ accessToken, err := ctx.client.createAccessToken(issuerURL.String(), credentialSubjectID.String(), time.Now(), "everything", AccessToken{}, nil)
+
+ require.NoError(t, err)
+ assert.NotEmpty(t, accessToken.AccessToken)
+ assert.Equal(t, "Bearer", accessToken.TokenType)
+ })
+}
diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go
index 859cff5efd..6b7bd0dbec 100644
--- a/auth/api/iam/generated.go
+++ b/auth/api/iam/generated.go
@@ -155,6 +155,17 @@ type ServiceAccessTokenRequest struct {
// - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature)
Credentials *[]VerifiableCredential `json:"credentials,omitempty"`
+ // IdToken An optional ID Token (JWT) that represents the end-user.
+ // This ID token is included in the Verifiable Presentation that is used to request the access token.
+ // It currently only supports Dezi ID tokens.
+ IdToken *string `json:"id_token,omitempty"`
+
+ // PolicyId (Optional) The ID of the policy to use when requesting the access token.
+ // If set the presentation definition is resolved from the policy with this ID.
+ // This allows you to specify scopes that don't resolve to a presentation definition automatically.
+ // If not set, the scope is used to resolve the presentation definition.
+ PolicyId *string `json:"policy_id,omitempty"`
+
// Scope The scope that will be the service for which this access token can be used.
Scope string `json:"scope"`
@@ -298,6 +309,7 @@ type HandleAuthorizeResponseFormdataBody struct {
// HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest.
type HandleTokenRequestFormdataBody struct {
Assertion *string `form:"assertion,omitempty" json:"assertion,omitempty"`
+ ClientAssertion *string `form:"client_assertion,omitempty" json:"client_assertion,omitempty"`
ClientId *string `form:"client_id,omitempty" json:"client_id,omitempty"`
Code *string `form:"code,omitempty" json:"code,omitempty"`
CodeVerifier *string `form:"code_verifier,omitempty" json:"code_verifier,omitempty"`
diff --git a/auth/api/iam/jar_test.go b/auth/api/iam/jar_test.go
index 5072dcdea9..7569086026 100644
--- a/auth/api/iam/jar_test.go
+++ b/auth/api/iam/jar_test.go
@@ -23,10 +23,11 @@ import (
"crypto"
"errors"
"fmt"
+ "testing"
+
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/nuts-foundation/nuts-node/crypto/storage/spi"
"github.com/nuts-foundation/nuts-node/test"
- "testing"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
@@ -121,7 +122,7 @@ func TestJar_Parse(t *testing.T) {
require.NoError(t, err)
token := string(bytes)
walletIssuerURL := test.MustParseURL(holderDID.String())
- verifierMetadata := authorizationServerMetadata(verifierURL, []string{"web"})
+ verifierMetadata := authorizationServerMetadata(verifierURL, []string{"web"}, oauth.SupportedGrantTypes())
configuration := &oauth.OpenIDConfiguration{
JWKs: jwkSet,
}
@@ -161,7 +162,7 @@ func TestJar_Parse(t *testing.T) {
})
t.Run("ok - post", func(t *testing.T) {
ctx := newJarTestCtx(t)
- md := authorizationServerMetadata(walletIssuerURL, []string{"web"})
+ md := authorizationServerMetadata(walletIssuerURL, []string{"web"}, oauth.SupportedGrantTypes())
ctx.iamClient.EXPECT().RequestObjectByPost(context.Background(), "request_uri", md).Return(token, nil)
ctx.keyResolver.EXPECT().ResolveKeyByID(kid, nil, resolver.AssertionMethod).Return(privateKey.Public(), nil)
ctx.iamClient.EXPECT().OpenIDConfiguration(gomock.Any(), holderClientID).Return(configuration, nil)
@@ -217,7 +218,7 @@ func TestJar_Parse(t *testing.T) {
})
t.Run("post (made by wallet)", func(t *testing.T) {
ctx := newJarTestCtx(t)
- md := authorizationServerMetadata(walletIssuerURL, []string{"web"})
+ md := authorizationServerMetadata(walletIssuerURL, []string{"web"}, oauth.SupportedGrantTypes())
ctx.iamClient.EXPECT().RequestObjectByPost(context.Background(), "request_uri", md).Return("", errors.New("server error"))
res, err := ctx.jar.Parse(context.Background(), md,
map[string][]string{
diff --git a/auth/api/iam/metadata.go b/auth/api/iam/metadata.go
index ec58fac866..e3c8547831 100644
--- a/auth/api/iam/metadata.go
+++ b/auth/api/iam/metadata.go
@@ -19,11 +19,12 @@
package iam
import (
- "github.com/lestrrat-go/jwx/v2/jwk"
"net/url"
"strings"
"time"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/core"
@@ -31,13 +32,13 @@ import (
"github.com/nuts-foundation/nuts-node/crypto/jwx"
)
-func authorizationServerMetadata(issuerURL *url.URL, supportedDIDMethods []string) oauth.AuthorizationServerMetadata {
+func authorizationServerMetadata(issuerURL *url.URL, supportedDIDMethods []string, grantTypes []string) oauth.AuthorizationServerMetadata {
metadata := &oauth.AuthorizationServerMetadata{
AuthorizationEndpoint: "openid4vp:",
ClientIdSchemesSupported: clientIdSchemesSupported,
DIDMethodsSupported: supportedDIDMethods,
DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(),
- GrantTypesSupported: grantTypesSupported,
+ GrantTypesSupported: grantTypes,
Issuer: "https://self-issued.me/v2",
PreAuthorizedGrantAnonymousAccessSupported: true,
PresentationDefinitionUriSupported: to.Ptr(true),
@@ -79,7 +80,7 @@ func clientMetadata(identity url.URL) oauth.OAuthClientMetadata {
softwareID, softwareVersion, _ := strings.Cut(core.UserAgent(), "/")
return oauth.OAuthClientMetadata{
TokenEndpointAuthMethod: "none", // defaults is "client_secret_basic" if not provided
- GrantTypes: grantTypesSupported,
+ GrantTypes: oauth.SupportedGrantTypes(),
ResponseTypes: responseTypesSupported,
SoftwareID: softwareID, // nuts-node-refimpl
SoftwareVersion: softwareVersion, // version tag or "unknown"
@@ -88,13 +89,13 @@ func clientMetadata(identity url.URL) oauth.OAuthClientMetadata {
}
}
-func openIDConfiguration(issuerURL url.URL, jwkSet jwk.Set, supportedDIDMethods []string) oauth.OpenIDConfiguration {
+func openIDConfiguration(issuerURL url.URL, jwkSet jwk.Set, supportedDIDMethods []string, grantTypes []string) oauth.OpenIDConfiguration {
return oauth.OpenIDConfiguration{
Issuer: issuerURL.String(),
IssuedAt: time.Now().Unix(),
Expiration: time.Now().Add(time.Hour).Unix(), // just a number, data is retrieved runtime. Value must be larger than clock skew to prevent technical problems.
Subject: issuerURL.String(),
JWKs: jwkSet,
- Metadata: oauth.EntityStatementMetadata{OpenIDProvider: authorizationServerMetadata(&issuerURL, supportedDIDMethods)},
+ Metadata: oauth.EntityStatementMetadata{OpenIDProvider: authorizationServerMetadata(&issuerURL, supportedDIDMethods, grantTypes)},
}
}
diff --git a/auth/api/iam/metadata_test.go b/auth/api/iam/metadata_test.go
index 8f325b4576..11f1fd5dc5 100644
--- a/auth/api/iam/metadata_test.go
+++ b/auth/api/iam/metadata_test.go
@@ -37,7 +37,7 @@ func Test_authorizationServerMetadata(t *testing.T) {
ClientIdSchemesSupported: []string{"entity_id"},
DIDMethodsSupported: []string{"test"},
DPoPSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(),
- GrantTypesSupported: []string{"authorization_code", "vp_token-bearer"},
+ GrantTypesSupported: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", "vp_token-bearer", "urn:ietf:params:oauth:grant-type:jwt-bearer"},
Issuer: "https://example.com/oauth2/example",
PreAuthorizedGrantAnonymousAccessSupported: true,
PresentationDefinitionEndpoint: "https://example.com/oauth2/example/presentation_definition",
@@ -50,7 +50,7 @@ func Test_authorizationServerMetadata(t *testing.T) {
RequestObjectSigningAlgValuesSupported: jwx.SupportedAlgorithmsAsStrings(),
}
authServerUrl := test.MustParseURL("https://example.com/oauth2/example")
- md := authorizationServerMetadata(authServerUrl, []string{"test"})
+ md := authorizationServerMetadata(authServerUrl, []string{"test"}, oauth.SupportedGrantTypes())
assert.Equal(t, baseExpected, md)
}
@@ -59,7 +59,7 @@ func Test_clientMetadata(t *testing.T) {
expected := OAuthClientMetadata{
RedirectURIs: nil,
TokenEndpointAuthMethod: "none",
- GrantTypes: []string{"authorization_code", "vp_token-bearer"},
+ GrantTypes: []string{"authorization_code", "urn:ietf:params:oauth:grant-type:pre-authorized_code", "vp_token-bearer", "urn:ietf:params:oauth:grant-type:jwt-bearer"},
ResponseTypes: []string{"code", "vp_token"},
Scope: "",
Contacts: nil,
diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go
index fe6bb045ec..e634506e7c 100644
--- a/auth/api/iam/openid4vp.go
+++ b/auth/api/iam/openid4vp.go
@@ -388,7 +388,7 @@ func (r Wrapper) sendAndHandleDirectPost(ctx context.Context, subject string, vp
// Dispatch a new HTTP request to the local OpenID4VP wallet's authorization endpoint that includes request parameters,
// but with openid4vp: as scheme.
// The context contains data from the previous request. Usage by the handler will probably result in incorrect behavior.
- userWalletMetadata := authorizationServerMetadata(nil, r.auth.SupportedDIDMethods())
+ userWalletMetadata := authorizationServerMetadata(nil, r.auth.SupportedDIDMethods(), r.auth.GrantTypes())
response, err := r.handleAuthorizeRequest(ctx, subject, userWalletMetadata, *parsedRedirectURI)
if err != nil {
return nil, err
@@ -716,8 +716,17 @@ func (r Wrapper) handleAccessTokenRequest(ctx context.Context, request HandleTok
}
// All done, issue access token
+ accessToken := AccessToken{
+ PresentationDefinitions: oauthSession.OpenID4VPVerifier.RequiredPresentationDefinitions,
+ PresentationSubmissions: oauthSession.OpenID4VPVerifier.Submissions,
+ }
+ for _, envelope := range oauthSession.OpenID4VPVerifier.SubmittedEnvelopes {
+ for _, presentation := range envelope.Presentations {
+ accessToken.VPToken = append(accessToken.VPToken, presentation)
+ }
+ }
issuerURL := r.subjectToBaseURL(*oauthSession.OwnSubject)
- response, err := r.createAccessToken(issuerURL.String(), oauthSession.ClientID, time.Now(), oauthSession.Scope, *oauthSession.OpenID4VPVerifier, dpopProof)
+ response, err := r.createAccessToken(issuerURL.String(), oauthSession.ClientID, time.Now(), oauthSession.Scope, accessToken, dpopProof)
if err != nil {
return nil, oauthError(oauth.ServerError, fmt.Sprintf("failed to create access token: %s", err.Error()))
}
diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go
deleted file mode 100644
index 7cc4504b7c..0000000000
--- a/auth/api/iam/s2s_vptoken_test.go
+++ /dev/null
@@ -1,479 +0,0 @@
-/*
- * Copyright (C) 2023 Nuts community
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- *
- */
-
-package iam
-
-import (
- "context"
- "crypto/ecdsa"
- "crypto/elliptic"
- "crypto/rand"
- "encoding/json"
- "errors"
- "github.com/nuts-foundation/nuts-node/auth/oauth"
- "github.com/nuts-foundation/nuts-node/policy"
- "go.uber.org/mock/gomock"
- "net/http"
- "testing"
- "time"
-
- "github.com/lestrrat-go/jwx/v2/jwt"
- ssi "github.com/nuts-foundation/go-did"
- "github.com/nuts-foundation/go-did/did"
- "github.com/nuts-foundation/go-did/vc"
- "github.com/nuts-foundation/nuts-node/jsonld"
- "github.com/nuts-foundation/nuts-node/vcr/pe"
- "github.com/nuts-foundation/nuts-node/vcr/signature/proof"
- "github.com/nuts-foundation/nuts-node/vcr/test"
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
-)
-
-func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) {
- const requestedScope = "example-scope"
- // Create issuer DID document and keys
- keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
- issuerDIDDocument := did.Document{
- ID: issuerDID,
- }
- keyID := did.DIDURL{DID: issuerDID}
- keyID.Fragment = "1"
- verificationMethod, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, issuerDID, keyPair.Public())
- require.NoError(t, err)
- issuerDIDDocument.AddAssertionMethod(verificationMethod)
-
- var presentationDefinition pe.PresentationDefinition
- require.NoError(t, json.Unmarshal([]byte(`
-{
- "format": {
- "ldp_vc": {
- "proof_type": [
- "JsonWebSignature2020"
- ]
- }
- },
- "input_descriptors": [
- {
- "id": "1",
- "constraints": {
- "fields": [
- {
- "path": [
- "$.type"
- ],
- "filter": {
- "type": "string",
- "const": "NutsOrganizationCredential"
- }
- }
- ]
- }
- }
- ]
-}`), &presentationDefinition))
-
- walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerOrganization: presentationDefinition}
- var submission pe.PresentationSubmission
- require.NoError(t, json.Unmarshal([]byte(`
-{
- "descriptor_map": [
- {
- "id": "1",
- "path": "$.verifiableCredential",
- "format": "ldp_vc"
- }
- ]
-}`), &submission))
- submissionJSONBytes, _ := json.Marshal(submission)
- submissionJSON := string(submissionJSONBytes)
- verifiableCredential := test.ValidNutsOrganizationCredential(t)
- subjectDID, _ := verifiableCredential.SubjectDID()
- proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
- proof.Domain = &issuerClientID
- })
- presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
- dpopHeader, _, _ := newSignedTestDPoP()
- httpRequest := &http.Request{
- Header: http.Header{
- "Dpop": []string{dpopHeader.String()},
- },
- }
- contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest)
- clientID := "https://example.com/oauth2/holder"
- t.Run("JSON-LD VP", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.NoError(t, err)
- require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
- tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
- assert.Equal(t, "DPoP", tokenResponse.TokenType)
- assert.Equal(t, requestedScope, *tokenResponse.Scope)
- assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
- assert.NotEmpty(t, tokenResponse.AccessToken)
- })
- t.Run("missing presentation expiry date", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- require.NoError(t, token.Remove(jwt.ExpirationKey))
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
- })
- t.Run("missing presentation not before date", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- require.NoError(t, token.Remove(jwt.NotBeforeKey))
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date")
- })
- t.Run("missing presentation valid for too long", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour)))
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation is valid for too long (max 5s)")
- })
- t.Run("JWT VP", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- require.NoError(t, token.Set(jwt.AudienceKey, issuerClientID))
- }, verifiableCredential)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.NoError(t, err)
- require.IsType(t, HandleTokenRequest200JSONResponse{}, resp)
- tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse))
- assert.Equal(t, "DPoP", tokenResponse.TokenType)
- assert.Equal(t, requestedScope, *tokenResponse.Scope)
- assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn)
- assert.NotEmpty(t, tokenResponse.AccessToken)
- })
- t.Run("VP is not valid JSON", func(t *testing.T) {
- ctx := newTestClient(t)
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, "[true, false]")
-
- assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: invalid JWT")
- assert.Nil(t, resp)
- })
- t.Run("not all VPs have the same credential subject ID", func(t *testing.T) {
- ctx := newTestClient(t)
-
- secondSubjectID := did.MustParseDID("did:web:example.com:other")
- secondPresentation := test.CreateJSONLDPresentation(t, secondSubjectID, proofVisitor, test.JWTNutsOrganizationCredential(t, secondSubjectID))
- assertionJSON, _ := json.Marshal([]VerifiablePresentation{presentation, secondPresentation})
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, string(assertionJSON))
- assert.EqualError(t, err, "invalid_request - not all presentations have the same credential subject ID")
- assert.Nil(t, resp)
- })
- t.Run("nonce", func(t *testing.T) {
- t.Run("replay attack (nonce is reused)", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil).Times(2)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
- require.NoError(t, err)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
- assert.EqualError(t, err, "invalid_request - presentation nonce has already been used")
- assert.Nil(t, resp)
- })
- t.Run("JSON-LD VP is missing nonce", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
- proof.Domain = &issuerClientID
- proof.Nonce = nil
- })
- presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
- assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
- assert.Nil(t, resp)
- })
- t.Run("JSON-LD VP has empty nonce", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) {
- proof.Domain = &issuerClientID
- proof.Nonce = new(string)
- })
- presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
- assert.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
- assert.Nil(t, resp)
- })
- t.Run("JWT VP is missing nonce", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- _ = token.Set(jwt.AudienceKey, issuerClientID)
- _ = token.Remove("nonce")
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
- })
- t.Run("JWT VP has empty nonce", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- _ = token.Set(jwt.AudienceKey, issuerClientID)
- _ = token.Set("nonce", "")
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
- })
- t.Run("JWT VP nonce is not a string", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- _ = token.Set(jwt.AudienceKey, issuerClientID)
- _ = token.Set("nonce", true)
- }, verifiableCredential)
-
- _, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce")
- })
- })
- t.Run("audience", func(t *testing.T) {
- t.Run("missing", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, nil, verifiableCredential)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- assert.EqualError(t, err, "invalid_request - expected: https://example.com/oauth2/issuer, got: [] - presentation audience/domain is missing or does not match")
- assert.Nil(t, resp)
- })
- t.Run("not matching", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation, _ := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) {
- require.NoError(t, token.Set(jwt.AudienceKey, "did:example:other"))
- }, verifiableCredential)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- assert.EqualError(t, err, "invalid_request - expected: https://example.com/oauth2/issuer, got: [did:example:other] - presentation audience/domain is missing or does not match")
- assert.Nil(t, resp)
- })
- })
- t.Run("VP verification fails", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.vcVerifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid"))
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or credential(s) verification failed")
- assert.Nil(t, resp)
- })
- t.Run("proof of ownership", func(t *testing.T) {
- t.Run("VC without credentialSubject.id", func(t *testing.T) {
- ctx := newTestClient(t)
- presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, vc.VerifiableCredential{
- CredentialSubject: []map[string]any{{}},
- })
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- assert.EqualError(t, err, `invalid_request - unable to get subject DID from VC: credential subjects have no ID`)
- assert.Nil(t, resp)
- })
- t.Run("signing key is not owned by credentialSubject.id", func(t *testing.T) {
- ctx := newTestClient(t)
- invalidProof := presentation.Proof[0].(map[string]interface{})
- invalidProof["verificationMethod"] = "did:example:other#1"
- verifiablePresentation := vc.VerifiablePresentation{
- VerifiableCredential: []vc.VerifiableCredential{verifiableCredential},
- Proof: []interface{}{invalidProof},
- }
- verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON()
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, string(verifiablePresentationJSON))
-
- assert.EqualError(t, err, `invalid_request - presentation signer is not credential subject`)
- assert.Nil(t, resp)
- })
- })
- t.Run("submission is not valid JSON", func(t *testing.T) {
- ctx := newTestClient(t)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, "not-a-valid-submission", presentation.Raw())
-
- assert.EqualError(t, err, `invalid_request - invalid presentation submission: invalid character 'o' in literal null (expecting 'u')`)
- assert.Nil(t, resp)
- })
- t.Run("unsupported scope", func(t *testing.T) {
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), "everything").Return(nil, policy.ErrNotFound)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, "everything", submissionJSON, presentation.Raw())
-
- assert.EqualError(t, err, `invalid_scope - not found - unsupported scope (everything) for presentation exchange: not found`)
- assert.Nil(t, resp)
- })
- t.Run("re-evaluation of presentation definition yields different credentials", func(t *testing.T) {
- // This indicates the client presented credentials that don't actually match the presentation definition,
- // which could indicate a malicious client.
- otherVerifiableCredential := vc.VerifiableCredential{
- CredentialSubject: []map[string]any{
- {
- "id": subjectDID.String(),
- // just for demonstration purposes, what matters is that the credential does not match the presentation definition.
- "IsAdministrator": true,
- },
- },
- }
- presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential)
-
- ctx := newTestClient(t)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(context.Background(), clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
- assert.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=)")
- assert.Nil(t, resp)
- })
- t.Run("invalid DPoP header", func(t *testing.T) {
- ctx := newTestClient(t)
- httpRequest := &http.Request{Header: http.Header{"Dpop": []string{"invalid"}}}
- httpRequest.Header.Set("DPoP", "invalid")
- contextWithValue := context.WithValue(context.Background(), httpRequestContextKey{}, httpRequest)
- ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
-
- resp, err := ctx.client.handleS2SAccessTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
-
- _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid")
- assert.Nil(t, resp)
- })
-}
-
-func TestWrapper_createAccessToken(t *testing.T) {
- credentialSubjectID := did.MustParseDID("did:nuts:B8PUHs2AUHbFF1xLLK4eZjgErEcMXHxs68FteY7NDtCY")
- verificationMethodID := ssi.MustParseURI(credentialSubjectID.String() + "#1")
- credential, err := vc.ParseVerifiableCredential(jsonld.TestOrganizationCredential)
- require.NoError(t, err)
- presentation := test.ParsePresentation(t, vc.VerifiablePresentation{
- VerifiableCredential: []vc.VerifiableCredential{*credential},
- Proof: []interface{}{
- proof.LDProof{
- VerificationMethod: verificationMethodID,
- },
- },
- })
- fieldId := "credential_type"
- definition := pe.PresentationDefinition{
- Id: "definitive",
- InputDescriptors: []*pe.InputDescriptor{
- {
- Id: "1",
- Constraints: &pe.Constraints{
- Fields: []pe.Field{
- {
- Path: []string{"$.type"},
- Id: &fieldId,
- Filter: &pe.Filter{
- Type: "string",
- },
- },
- },
- },
- },
- },
- }
- submission := pe.PresentationSubmission{
- Id: "submissive",
- DefinitionId: "definitive",
- DescriptorMap: []pe.InputDescriptorMappingObject{
- {
- Id: "1",
- Path: "$.verifiableCredential",
- Format: "ldp_vc",
- },
- },
- }
- dpopToken, _, _ := newSignedTestDPoP()
- verifiablePresentation := test.ParsePresentation(t, presentation)
- pexEnvelopeJSON, _ := json.Marshal(verifiablePresentation)
- pexEnvelope, err := pe.ParseEnvelope(pexEnvelopeJSON)
- pexConsumer := PEXConsumer{
- RequiredPresentationDefinitions: map[pe.WalletOwnerType]pe.PresentationDefinition{
- pe.WalletOwnerOrganization: definition,
- },
- Submissions: map[string]pe.PresentationSubmission{
- "definitive": submission,
- },
- SubmittedEnvelopes: map[string]pe.Envelope{
- "definitive": *pexEnvelope,
- },
- }
- t.Run("ok", func(t *testing.T) {
- ctx := newTestClient(t)
-
- require.NoError(t, err)
- accessToken, err := ctx.client.createAccessToken(issuerURL.String(), credentialSubjectID.String(), time.Now(), "everything", pexConsumer, dpopToken)
-
- require.NoError(t, err)
- assert.NotEmpty(t, accessToken.AccessToken)
- assert.Equal(t, "DPoP", accessToken.TokenType)
- assert.Equal(t, 900, *accessToken.ExpiresIn)
- assert.Equal(t, "everything", *accessToken.Scope)
-
- var storedToken AccessToken
- err = ctx.client.accessTokenServerStore().Get(accessToken.AccessToken, &storedToken)
- require.NoError(t, err)
- assert.Equal(t, accessToken.AccessToken, storedToken.Token)
- assert.Equal(t, submission, storedToken.PresentationSubmissions["definitive"])
- assert.Equal(t, definition, storedToken.PresentationDefinitions[pe.WalletOwnerOrganization])
- assert.Equal(t, []interface{}{"NutsOrganizationCredential", "VerifiableCredential"}, storedToken.InputDescriptorConstraintIdMap["credential_type"])
- expectedVPJSON, _ := presentation.MarshalJSON()
- actualVPJSON, _ := storedToken.VPToken[0].MarshalJSON()
- assert.JSONEq(t, string(expectedVPJSON), string(actualVPJSON))
- assert.Equal(t, issuerURL.String(), storedToken.Issuer)
- assert.NotEmpty(t, storedToken.Expiration)
- })
- t.Run("ok - bearer token", func(t *testing.T) {
- ctx := newTestClient(t)
- accessToken, err := ctx.client.createAccessToken(issuerURL.String(), credentialSubjectID.String(), time.Now(), "everything", pexConsumer, nil)
-
- require.NoError(t, err)
- assert.NotEmpty(t, accessToken.AccessToken)
- assert.Equal(t, "Bearer", accessToken.TokenType)
- })
-}
diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go
index 76ac6e2c9f..8e22a34871 100644
--- a/auth/api/iam/types.go
+++ b/auth/api/iam/types.go
@@ -75,8 +75,6 @@ var responseModesSupported = []string{responseModeQuery, responseModeDirectPost}
var responseTypesSupported = []string{oauth.CodeResponseType, oauth.VPTokenResponseType}
-var grantTypesSupported = []string{oauth.AuthorizationCodeGrantType, oauth.VpTokenGrantType}
-
var clientIdSchemesSupported = []string{entityClientIDScheme}
const entityClientIDScheme = "entity_id"
diff --git a/auth/api/iam/validation.go b/auth/api/iam/validation.go
index 7809a3ab4f..3cbf4a1101 100644
--- a/auth/api/iam/validation.go
+++ b/auth/api/iam/validation.go
@@ -22,6 +22,7 @@ import (
"context"
"errors"
"fmt"
+
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
@@ -30,6 +31,67 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/pe"
)
+// CredentialProfile validates a presentation against a presentation definition, and extracts the relevant information to be stored in the access token for policy decision and/or claims about the presentation.
+type CredentialProfile func(ctx context.Context, credentialProfile pe.WalletOwnerMapping, accessToken *AccessToken) error
+
+// SubmissionPresentationEvaluator returns a CredentialProfile that validates a presentation against the given presentation submission and presentation exchange envelope,
+// according to DIF Presentation Exchange.
+func SubmissionPresentationEvaluator(submission pe.PresentationSubmission, pexEnvelope pe.Envelope) CredentialProfile {
+ return func(ctx context.Context, presentationDefinitions pe.WalletOwnerMapping, accessToken *AccessToken) error {
+ pexConsumer := newPEXConsumer(presentationDefinitions)
+ if err := pexConsumer.fulfill(submission, pexEnvelope); err != nil {
+ return oauthError(oauth.InvalidRequest, err.Error())
+ }
+ credentialMap, err := pexConsumer.credentialMap()
+ if err != nil {
+ return err
+ }
+ fieldsMap, err := resolveInputDescriptorValues(pexConsumer.RequiredPresentationDefinitions, credentialMap)
+ if err != nil {
+ return err
+ }
+ accessToken.PresentationSubmissions = pexConsumer.Submissions
+ accessToken.PresentationDefinitions = pexConsumer.RequiredPresentationDefinitions
+ err = accessToken.AddInputDescriptorConstraintIdMap(fieldsMap)
+ if err != nil {
+ // Message returned to the client in ambiguous on purpose for security; it indicates misconfiguration on the server's side.
+ return oauthError(oauth.ServerError, "unable to fulfill presentation requirements", err)
+ }
+ accessToken.VPToken = append(accessToken.VPToken, pexEnvelope.Presentations...)
+ return nil
+ }
+}
+
+// BasicPresentationEvaluator returns a CredentialProfile that validates a presentation against the presentation definition(s).
+// It does not consume a Presentation Submission.
+func BasicPresentationEvaluator(presentation VerifiablePresentation) CredentialProfile {
+ return func(ctx context.Context, presentationDefinitions pe.WalletOwnerMapping, accessToken *AccessToken) error {
+ creds, inputDescriptors, err := presentationDefinitions[pe.WalletOwnerOrganization].Match(presentation.VerifiableCredential)
+ if err != nil {
+ return oauthError(oauth.InvalidRequest, "presentation does not match presentation definition", err)
+ }
+ // Collect input descriptor field ID -> value map
+ // Will be ultimately returned as claims in the access token.
+ credentialMap := make(map[string]vc.VerifiableCredential, len(inputDescriptors))
+ for i, cred := range creds {
+ credentialMap[inputDescriptors[i].Id] = cred
+ }
+ fieldMap, err := presentationDefinitions[pe.WalletOwnerOrganization].ResolveConstraintsFields(credentialMap)
+ if err != nil {
+ // This should be impossible, since the Match() function performs the same checks.
+ return oauthError(oauth.ServerError, "unable to fulfill presentation requirements", err)
+ }
+ err = accessToken.AddInputDescriptorConstraintIdMap(fieldMap)
+ if err != nil {
+ // Message returned to the client in ambiguous on purpose for security; it indicates misconfiguration on the server's side.
+ return oauthError(oauth.ServerError, "unable to fulfill presentation requirements", err)
+ }
+ accessToken.VPToken = append(accessToken.VPToken, presentation)
+ accessToken.PresentationDefinitions = presentationDefinitions
+ return nil
+ }
+}
+
// validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented.
// All returned errors can be used as description in an OAuth2 error.
func validatePresentationSigner(presentation vc.VerifiablePresentation, expectedCredentialSubjectDID did.DID) (*did.DID, error) {
diff --git a/auth/api/iam/validation_test.go b/auth/api/iam/validation_test.go
index cea68d4730..64cfea7d78 100644
--- a/auth/api/iam/validation_test.go
+++ b/auth/api/iam/validation_test.go
@@ -18,11 +18,16 @@
package iam
import (
+ "context"
+ "encoding/json"
"testing"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/vcr/pe"
+ "github.com/nuts-foundation/nuts-node/vcr/test"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
func Test_validatePresentationSigner(t *testing.T) {
@@ -35,3 +40,213 @@ func Test_validatePresentationSigner(t *testing.T) {
assert.NotNil(t, subjectDID)
})
}
+
+func testCredentialAndPresentation(t *testing.T) (vc.VerifiableCredential, vc.VerifiablePresentation, *did.DID) {
+ verifiableCredential := test.ValidNutsOrganizationCredential(t)
+ subjectDID, _ := verifiableCredential.SubjectDID()
+ presentation := test.CreateJSONLDPresentation(t, *subjectDID, nil, verifiableCredential)
+ return verifiableCredential, presentation, subjectDID
+}
+
+func testPresentationDefinition(t *testing.T) pe.PresentationDefinition {
+ var presentationDefinition pe.PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "id": "test-pd",
+ "format": {
+ "ldp_vc": {
+ "proof_type": ["JsonWebSignature2020"]
+ }
+ },
+ "input_descriptors": [{
+ "id": "1",
+ "constraints": {
+ "fields": [{
+ "path": ["$.type"],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ }]
+ }
+ }]
+ }`), &presentationDefinition))
+ return presentationDefinition
+}
+
+func TestSubmissionPresentationEvaluator(t *testing.T) {
+ ctx := context.Background()
+ verifiableCredential, presentation, subjectDID := testCredentialAndPresentation(t)
+ presentationDefinition := testPresentationDefinition(t)
+
+ walletOwnerMapping := pe.WalletOwnerMapping{
+ pe.WalletOwnerOrganization: presentationDefinition,
+ }
+
+ // Build submission using the presentation definition to ensure it matches
+ builder := presentationDefinition.PresentationSubmissionBuilder()
+ builder.AddWallet(*subjectDID, []vc.VerifiableCredential{verifiableCredential})
+ submission, _, err := builder.Build("ldp_vp")
+ require.NoError(t, err)
+
+ // Create envelope by parsing the presentation (needed for proper asInterface field)
+ presentationBytes, err := json.Marshal(presentation)
+ require.NoError(t, err)
+ envelope, err := pe.ParseEnvelope(presentationBytes)
+ require.NoError(t, err)
+
+ t.Run("ok", func(t *testing.T) {
+ accessToken := &AccessToken{}
+ validator := SubmissionPresentationEvaluator(submission, *envelope)
+
+ err := validator(ctx, walletOwnerMapping, accessToken)
+
+ require.NoError(t, err)
+ assert.NotNil(t, accessToken.PresentationSubmissions)
+ assert.NotNil(t, accessToken.PresentationDefinitions)
+ assert.NotNil(t, accessToken.VPToken)
+ assert.Len(t, accessToken.VPToken, 1)
+ assert.Equal(t, presentation, accessToken.VPToken[0])
+ })
+ t.Run("credentials don't match Presentation Definition", func(t *testing.T) {
+ accessToken := &AccessToken{}
+ invalidSubmission := submission
+ invalidSubmission.DescriptorMap[0].Path = "$.verifiableCredential[0]"
+ validator := SubmissionPresentationEvaluator(invalidSubmission, *envelope)
+
+ err := validator(ctx, walletOwnerMapping, accessToken)
+
+ require.EqualError(t, err, "invalid_request - presentation submission does not conform to presentation definition (id=test-pd)")
+ })
+}
+
+func TestBasicPresentationEvaluator(t *testing.T) {
+ ctx := context.Background()
+ _, presentation, _ := testCredentialAndPresentation(t)
+ presentationDefinition := testPresentationDefinition(t)
+
+ walletOwnerMapping := pe.WalletOwnerMapping{
+ pe.WalletOwnerOrganization: presentationDefinition,
+ }
+
+ t.Run("ok", func(t *testing.T) {
+ accessToken := &AccessToken{}
+ validator := BasicPresentationEvaluator(presentation)
+
+ err := validator(ctx, walletOwnerMapping, accessToken)
+
+ require.NoError(t, err)
+ require.Len(t, accessToken.VPToken, 1)
+ assert.Equal(t, presentation, accessToken.VPToken[0])
+
+ t.Run("second invocation for a second scope", func(t *testing.T) {
+ err := validator(ctx, walletOwnerMapping, accessToken)
+
+ require.NoError(t, err)
+ require.Len(t, accessToken.VPToken, 2)
+ assert.Equal(t, presentation, accessToken.VPToken[0])
+ })
+ })
+
+ t.Run("error - presentation doesn't match definition", func(t *testing.T) {
+ accessToken := &AccessToken{}
+ // Create a presentation with a credential that doesn't match
+ otherCredential := test.JWTNutsOrganizationCredential(t, did.MustParseDID("did:web:example.com"))
+ otherSubjectDID, _ := otherCredential.SubjectDID()
+ invalidPresentation := test.CreateJSONLDPresentation(t, *otherSubjectDID, nil, otherCredential)
+
+ // Create a presentation definition that requires a different credential type
+ var strictDefinition pe.PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "id": "strict-pd",
+ "format": {
+ "ldp_vc": {
+ "proof_type": ["JsonWebSignature2020"]
+ }
+ },
+ "input_descriptors": [{
+ "id": "1",
+ "constraints": {
+ "fields": [{
+ "path": ["$.credentialSubject.organization.city"],
+ "filter": {
+ "type": "string",
+ "const": "NonExistentCity"
+ }
+ }]
+ }
+ }]
+ }`), &strictDefinition))
+
+ strictMapping := pe.WalletOwnerMapping{
+ pe.WalletOwnerOrganization: strictDefinition,
+ }
+
+ validator := BasicPresentationEvaluator(invalidPresentation)
+ err := validator(ctx, strictMapping, accessToken)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid_request")
+ assert.Contains(t, err.Error(), "presentation does not match presentation definition")
+ })
+
+ t.Run("error - conflicting field values", func(t *testing.T) {
+ // Create a presentation definition with a field ID
+ var definitionWithFieldID pe.PresentationDefinition
+ require.NoError(t, json.Unmarshal([]byte(`{
+ "id": "test-pd-with-field",
+ "format": {
+ "ldp_vc": {
+ "proof_type": ["JsonWebSignature2020"]
+ }
+ },
+ "input_descriptors": [{
+ "id": "1",
+ "constraints": {
+ "fields": [{
+ "path": ["$.type"],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ },{
+ "id": "city",
+ "path": ["$.credentialSubject.organization.city"],
+ "filter": {
+ "type": "string"
+ }
+ }]
+ }
+ }]
+ }`), &definitionWithFieldID))
+
+ mappingWithField := pe.WalletOwnerMapping{
+ pe.WalletOwnerOrganization: definitionWithFieldID,
+ }
+
+ accessToken := &AccessToken{
+ InputDescriptorConstraintIdMap: map[string]any{
+ "city": "DifferentCity",
+ },
+ }
+ validator := BasicPresentationEvaluator(presentation)
+
+ err := validator(ctx, mappingWithField, accessToken)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "server_error")
+ assert.Contains(t, err.Error(), "unable to fulfill presentation requirements")
+ })
+
+ t.Run("error - empty presentation", func(t *testing.T) {
+ accessToken := &AccessToken{}
+ emptyPresentation := vc.VerifiablePresentation{
+ VerifiableCredential: []vc.VerifiableCredential{},
+ }
+ validator := BasicPresentationEvaluator(emptyPresentation)
+
+ err := validator(ctx, walletOwnerMapping, accessToken)
+
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid_request")
+ })
+}
diff --git a/auth/auth.go b/auth/auth.go
index f135335c01..ee8cb1cedc 100644
--- a/auth/auth.go
+++ b/auth/auth.go
@@ -21,7 +21,13 @@ package auth
import (
"crypto/tls"
"errors"
+ "net/url"
+ "path"
+ "slices"
+ "time"
+
"github.com/nuts-foundation/nuts-node/auth/client/iam"
+ "github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/didjwk"
"github.com/nuts-foundation/nuts-node/vdr/didkey"
@@ -30,10 +36,6 @@ import (
"github.com/nuts-foundation/nuts-node/vdr/didweb"
"github.com/nuts-foundation/nuts-node/vdr/didx509"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
- "net/url"
- "path"
- "slices"
- "time"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/auth/services/notary"
@@ -58,6 +60,7 @@ type Auth struct {
relyingParty oauth.RelyingParty
contractNotary services.ContractNotary
serviceResolver didman.CompoundServiceResolver
+ policyBackend policy.PDPBackend
keyStore crypto.KeyStore
vcr vcr.VCR
pkiProvider pki.Provider
@@ -100,12 +103,13 @@ func (auth *Auth) ContractNotary() services.ContractNotary {
// NewAuthInstance accepts a Config with several Nuts Engines and returns an instance of Auth
func NewAuthInstance(config Config, vdrInstance vdr.VDR, subjectManager didsubject.Manager, vcr vcr.VCR, keyStore crypto.KeyStore,
- serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider) *Auth {
+ serviceResolver didman.CompoundServiceResolver, jsonldManager jsonld.JSONLD, pkiProvider pki.Provider, policyBackend policy.PDPBackend) *Auth {
return &Auth{
config: config,
jsonldManager: jsonldManager,
vdrInstance: vdrInstance,
subjectManager: subjectManager,
+ policyBackend: policyBackend,
keyStore: keyStore,
vcr: vcr,
pkiProvider: pkiProvider,
@@ -126,7 +130,7 @@ func (auth *Auth) RelyingParty() oauth.RelyingParty {
func (auth *Auth) IAMClient() iam.Client {
keyResolver := resolver.DIDKeyResolver{Resolver: auth.vdrInstance.Resolver()}
- return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.strictMode, auth.httpClientTimeout)
+ return iam.NewClient(auth.vcr.Wallet(), keyResolver, auth.subjectManager, auth.keyStore, auth.jsonldManager.DocumentLoader(), auth.policyBackend, auth.strictMode, auth.httpClientTimeout)
}
// Configure the Auth struct by creating a validator and create an Irma server
@@ -187,6 +191,10 @@ func (auth *Auth) Configure(config core.ServerConfig) error {
return nil
}
+func (auth *Auth) GrantTypes() []string {
+ return auth.config.GrantTypes
+}
+
func (auth *Auth) SupportedDIDMethods() []string {
// DID methods that don't require additional resources/configuration in the Nuts node are always supported.
// Other DID methods (did:nuts), are only supported if explicitly enabled.
diff --git a/auth/auth_test.go b/auth/auth_test.go
index 968ea61ef8..baf0fe4840 100644
--- a/auth/auth_test.go
+++ b/auth/auth_test.go
@@ -19,6 +19,8 @@
package auth
import (
+ "testing"
+
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/jsonld"
@@ -28,7 +30,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "testing"
)
func TestAuth_Configure(t *testing.T) {
@@ -47,7 +48,7 @@ func TestAuth_Configure(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()
- i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock)
+ i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil)
require.NoError(t, i.Configure(tlsServerConfig))
})
@@ -61,7 +62,7 @@ func TestAuth_Configure(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()
- i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock)
+ i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, nil, pkiMock, nil)
require.NoError(t, i.Configure(tlsServerConfig))
})
@@ -119,7 +120,7 @@ func TestAuth_IAMClient(t *testing.T) {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()
- i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock)
+ i := NewAuthInstance(config, vdrInstance, nil, vcr.NewTestVCRInstance(t), crypto.NewMemoryCryptoInstance(t), nil, jsonld.NewTestJSONLDManager(t), pkiMock, nil)
assert.NotNil(t, i.IAMClient())
})
diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go
index 3b8e8c17bf..48adc9300c 100644
--- a/auth/client/iam/client.go
+++ b/auth/client/iam/client.go
@@ -24,16 +24,17 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/lestrrat-go/jwx/v2/jws"
- "github.com/lestrrat-go/jwx/v2/jwt"
- "github.com/nuts-foundation/nuts-node/crypto"
- "github.com/nuts-foundation/nuts-node/vdr/resolver"
"io"
"net/http"
"net/url"
"strings"
"time"
+ "github.com/lestrrat-go/jwx/v2/jws"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/nuts-foundation/nuts-node/crypto"
+ "github.com/nuts-foundation/nuts-node/vdr/resolver"
+
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/log"
"github.com/nuts-foundation/nuts-node/auth/oauth"
@@ -205,11 +206,22 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data
return token, oauth.RemoteOAuthError{Cause: oauthError}
}
+ // TODO: Remove this when Itzos fixed their Token Response
+ type LenientTokenResponse struct {
+ AccessToken string `json:"access_token"`
+ DPoPKid *string `json:"dpop_kid,omitempty"`
+ ExpiresAt *any `json:"expires_at,omitempty"`
+ ExpiresIn *any `json:"expires_in,omitempty"`
+ TokenType string `json:"token_type"`
+ Scope *string `json:"scope,omitempty"`
+ }
+
var responseData []byte
if responseData, err = io.ReadAll(response.Body); err != nil {
return token, fmt.Errorf("unable to read response: %w", err)
}
- if err = json.Unmarshal(responseData, &token); err != nil {
+ var lenientToken LenientTokenResponse
+ if err = json.Unmarshal(responseData, &lenientToken); err != nil {
// Cut off the response body to 100 characters max to prevent logging of large responses
responseBodyString := string(responseData)
if len(responseBodyString) > core.HttpResponseBodyLogClipAt {
@@ -217,9 +229,43 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, data
}
return token, fmt.Errorf("unable to unmarshal response: %w, %s", err, responseBodyString)
}
+ token.AccessToken = lenientToken.AccessToken
+ token.DPoPKid = lenientToken.DPoPKid
+ token.TokenType = lenientToken.TokenType
+ token.Scope = lenientToken.Scope
+ token.ExpiresAt, err = toInt(lenientToken.ExpiresAt)
+ if err != nil {
+ return token, fmt.Errorf("unable to parse expires_at: %w", err)
+ }
+ token.ExpiresIn, err = toInt(lenientToken.ExpiresIn)
+ if err != nil {
+ return token, fmt.Errorf("unable to parse expires_in: %w", err)
+ }
+
return token, nil
}
+func toInt(value *any) (*int, error) {
+ // handle expires_in which can be int or string
+ if value == nil {
+ return nil, nil
+ }
+ switch v := (*value).(type) {
+ case float64:
+ intValue := int(v)
+ return &intValue, nil
+ case string:
+ var intValue int
+ _, err := fmt.Sscanf(v, "%d", &intValue)
+ if err != nil {
+ return nil, fmt.Errorf("unable to parse string to int: %w", err)
+ }
+ return &intValue, nil
+ default:
+ return nil, fmt.Errorf("unable to parse value of type %T to int", v)
+ }
+}
+
// PostError posts an OAuth error to the redirect URL and returns the redirect URL with the error as query parameter.
func (hb HTTPClient) PostError(ctx context.Context, err oauth.OAuth2Error, verifierCallbackURL url.URL) (string, error) {
// initiate http client, create a POST request with x-www-form-urlencoded body and send it to the redirect URL
diff --git a/auth/client/iam/interface.go b/auth/client/iam/interface.go
index 5ccf9caaf5..612e27014d 100644
--- a/auth/client/iam/interface.go
+++ b/auth/client/iam/interface.go
@@ -20,6 +20,7 @@ package iam
import (
"context"
+
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/vcr/pe"
@@ -46,7 +47,7 @@ type Client interface {
// RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021.
// credentials are additional VCs to include alongside wallet-stored credentials.
// credentialSelection maps PD field IDs to expected values to disambiguate when multiple credentials match an input descriptor.
- RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, useDPoP bool,
+ RequestRFC021AccessToken(ctx context.Context, clientID string, subjectDID string, authServerURL string, scopes string, policyId string, useDPoP bool,
credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error)
// OpenIdCredentialIssuerMetadata returns the metadata of the remote credential issuer.
diff --git a/auth/client/iam/mock.go b/auth/client/iam/mock.go
index b6ad933a61..1af43b3894 100644
--- a/auth/client/iam/mock.go
+++ b/auth/client/iam/mock.go
@@ -194,18 +194,18 @@ func (mr *MockClientMockRecorder) RequestObjectByPost(ctx, requestURI, walletMet
}
// RequestRFC021AccessToken mocks base method.
-func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) {
+func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes, policyId string, useDPoP bool, credentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection)
+ ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials, credentialSelection)
ret0, _ := ret[0].(*oauth.TokenResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// RequestRFC021AccessToken indicates an expected call of RequestRFC021AccessToken.
-func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection any) *gomock.Call {
+func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials, credentialSelection any) *gomock.Call {
mr.mock.ctrl.T.Helper()
- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials, credentialSelection)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials, credentialSelection)
}
// VerifiableCredentials mocks base method.
diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go
index bf7f8fef68..714973ec46 100644
--- a/auth/client/iam/openid4vp.go
+++ b/auth/client/iam/openid4vp.go
@@ -24,16 +24,18 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/nuts-foundation/nuts-node/http/client"
- "github.com/nuts-foundation/nuts-node/vcr/credential"
- "github.com/nuts-foundation/nuts-node/vdr/didsubject"
- "github.com/piprate/json-gold/ld"
"maps"
"net/http"
"net/url"
"slices"
"time"
+ "github.com/nuts-foundation/nuts-node/http/client"
+ "github.com/nuts-foundation/nuts-node/policy"
+ "github.com/nuts-foundation/nuts-node/vcr/credential"
+ "github.com/nuts-foundation/nuts-node/vdr/didsubject"
+ "github.com/piprate/json-gold/ld"
+
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/log"
@@ -60,11 +62,12 @@ type OpenID4VPClient struct {
wallet holder.Wallet
ldDocumentLoader ld.DocumentLoader
subjectManager didsubject.Manager
+ policyBackend policy.PDPBackend
}
// NewClient returns an implementation of Holder
func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectManager didsubject.Manager, jwtSigner nutsCrypto.JWTSigner,
- ldDocumentLoader ld.DocumentLoader, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
+ ldDocumentLoader ld.DocumentLoader, policyBackend policy.PDPBackend, strictMode bool, httpClientTimeout time.Duration) *OpenID4VPClient {
return &OpenID4VPClient{
httpClient: HTTPClient{
strictMode: strictMode,
@@ -77,6 +80,7 @@ func NewClient(wallet holder.Wallet, keyResolver resolver.KeyResolver, subjectMa
subjectManager: subjectManager,
strictMode: strictMode,
wallet: wallet,
+ policyBackend: policyBackend,
}
}
@@ -235,24 +239,44 @@ func (c *OpenID4VPClient) AccessToken(ctx context.Context, code string, tokenEnd
}
func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID string, subjectID string, authServerURL string, scopes string,
- useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) {
+ policyId string, useDPoP bool, additionalCredentials []vc.VerifiableCredential, credentialSelection map[string]string) (*oauth.TokenResponse, error) {
iamClient := c.httpClient
metadata, err := c.AuthorizationServerMetadata(ctx, authServerURL)
if err != nil {
return nil, err
}
- // get the presentation definition from the verifier
- parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode)
- if err != nil {
- return nil, err
+ // if no policyId is provided, use the scopes as policyId
+ if policyId == "" {
+ policyId = scopes
}
- presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{
- "scope": scopes,
- })
- presentationDefinition, err := c.PresentationDefinition(ctx, presentationDefinitionURL.String())
- if err != nil {
+ // LSPxNuts: get the presentation definition from local definitions, if available
+ var presentationDefinition *pe.PresentationDefinition
+ presentationDefinitionMap, err := c.policyBackend.PresentationDefinitions(ctx, policyId)
+ if errors.Is(err, policy.ErrNotFound) {
+ // not found locally, get from verifier
+ // get the presentation definition from the verifier
+ parsedURL, err := core.ParsePublicURL(metadata.PresentationDefinitionEndpoint, c.strictMode)
+ if err != nil {
+ return nil, err
+ }
+ presentationDefinitionURL := nutsHttp.AddQueryParams(*parsedURL, map[string]string{
+ "scope": policyId,
+ })
+ presentationDefinition, err = c.PresentationDefinition(ctx, presentationDefinitionURL.String())
+ if err != nil {
+ return nil, err
+ }
+ } else if err != nil {
return nil, err
+ } else {
+ // found locally
+ if len(presentationDefinitionMap) != 1 {
+ return nil, fmt.Errorf("expected exactly one presentation definition for policy/scope '%s', found %d", policyId, len(presentationDefinitionMap))
+ }
+ for _, pd := range presentationDefinitionMap {
+ presentationDefinition = &pd
+ }
}
params := holder.BuildParams{
@@ -309,10 +333,21 @@ func (c *OpenID4VPClient) RequestRFC021AccessToken(ctx context.Context, clientID
presentationSubmission, _ := json.Marshal(submission)
data := url.Values{}
data.Set(oauth.ClientIDParam, clientID)
- data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType)
data.Set(oauth.AssertionParam, assertion)
- data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
data.Set(oauth.ScopeParam, scopes)
+ // Prefer VP token grant type (Nuts RFC021) when the server supports it, backwards compatibility
+ switch {
+ case slices.Contains(metadata.GrantTypesSupported, oauth.VpTokenGrantType):
+ data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType)
+ data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
+ case slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType):
+ // use JWT bearer grant type (e.g. authenticating at LSP GtK)
+ data.Set(oauth.GrantTypeParam, oauth.JWTBearerGrantType)
+ default:
+ // Fallback to vp_bearer-token for backwards compatibility
+ data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType)
+ data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission))
+ }
// create DPoP header
var dpopHeader string
diff --git a/auth/client/iam/openid4vp_test.go b/auth/client/iam/openid4vp_test.go
index f4a725a09c..c0b0c8c2cd 100644
--- a/auth/client/iam/openid4vp_test.go
+++ b/auth/client/iam/openid4vp_test.go
@@ -24,16 +24,18 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/nuts-foundation/nuts-node/http/client"
- test2 "github.com/nuts-foundation/nuts-node/test"
- "github.com/nuts-foundation/nuts-node/vcr/credential"
- "github.com/nuts-foundation/nuts-node/vdr/didsubject"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
+ "github.com/nuts-foundation/nuts-node/http/client"
+ "github.com/nuts-foundation/nuts-node/policy"
+ test2 "github.com/nuts-foundation/nuts-node/test"
+ "github.com/nuts-foundation/nuts-node/vcr/credential"
+ "github.com/nuts-foundation/nuts-node/vdr/didsubject"
+
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
@@ -252,20 +254,36 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
ctx := createClientServerTestContext(t)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
- response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
assert.NoError(t, err)
require.NotNil(t, response)
assert.Equal(t, "token", response.AccessToken)
assert.Equal(t, "bearer", response.TokenType)
})
+ t.Run("ok with policy ID that differs from scope", func(t *testing.T) {
+ ctx := createClientServerTestContext(t)
+ ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), "some-policy").Return(nil, policy.ErrNotFound)
+
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "some-policy", false, nil, nil)
+
+ assert.NoError(t, err)
+ require.NotNil(t, response)
+ assert.Equal(t, "token", response.AccessToken)
+ assert.Equal(t, "bearer", response.TokenType)
+ assert.Equal(t, "first second", *response.Scope)
+ })
t.Run("no DID fulfills the Presentation Definition", func(t *testing.T) {
ctx := createClientServerTestContext(t)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, pe.ErrNoCredentials)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
- response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
assert.ErrorIs(t, err, pe.ErrNoCredentials)
assert.Nil(t, response)
@@ -274,8 +292,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
ctx := createClientServerTestContext(t)
ctx.authzServerMetadata.DIDMethodsSupported = []string{"other"}
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
- response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrPreconditionFailed)
@@ -285,6 +304,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
t.Run("with additional credentials", func(t *testing.T) {
ctx := createClientServerTestContext(t)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
credentials := []vc.VerifiableCredential{
{
Context: []ssi.URI{
@@ -312,7 +332,55 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
return createdVP, &pe.PresentationSubmission{}, nil
})
- response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, credentials, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials, nil)
+
+ assert.NoError(t, err)
+ require.NotNil(t, response)
+ assert.Equal(t, "token", response.AccessToken)
+ assert.Equal(t, "bearer", response.TokenType)
+ })
+ t.Run("with Dezi credential", func(t *testing.T) {
+ ctx := createClientServerTestContext(t)
+ ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
+
+ // Create a Dezi credential from an id_token
+ idToken := "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ"
+ deziCredential, err := credential.CreateDeziUserCredential(idToken)
+ require.NoError(t, err)
+
+ credentials := []vc.VerifiableCredential{*deziCredential}
+
+ ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).
+ DoAndReturn(func(_ context.Context, _ []did.DID, additionalCredentials map[did.DID][]vc.VerifiableCredential, _ pe.PresentationDefinition, _ map[string]string, _ holder.BuildParams) (*vc.VerifiablePresentation, *pe.PresentationSubmission, error) {
+ // Assert Dezi credentials are NOT self-attested (they have an issuer)
+ require.Len(t, additionalCredentials, 2)
+ require.Len(t, additionalCredentials[primaryWalletDID], 1)
+ // Dezi credentials have their own issuer, not the wallet DID
+ assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[primaryWalletDID][0].Issuer.String())
+ assert.Contains(t, additionalCredentials[primaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential"))
+ require.Len(t, additionalCredentials[secondaryWalletDID], 1)
+ assert.Equal(t, "https://abonnee.dezi.nl", additionalCredentials[secondaryWalletDID][0].Issuer.String())
+ assert.Contains(t, additionalCredentials[secondaryWalletDID][0].Type, ssi.MustParseURI("DeziUserCredential"))
+ return createdVP, &pe.PresentationSubmission{}, nil
+ })
+
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials, nil)
+
+ assert.NoError(t, err)
+ require.NotNil(t, response)
+ assert.Equal(t, "token", response.AccessToken)
+ assert.Equal(t, "bearer", response.TokenType)
+ })
+ t.Run("grant_type urn:ietf:params:oauth:grant-type:jwt-bearer", func(t *testing.T) {
+ ctx := createClientServerTestContext(t)
+ // Set the authorization server to support JWT Bearer grant type
+ ctx.authzServerMetadata.GrantTypesSupported = []string{oauth.JWTBearerGrantType}
+ ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
+
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
assert.NoError(t, err)
require.NotNil(t, response)
@@ -325,14 +393,27 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
ctx.jwtSigner.EXPECT().SignDPoP(context.Background(), gomock.Any(), primaryKID).Return("dpop", nil)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
- response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, true, nil, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", true, nil, nil)
assert.NoError(t, err)
require.NotNil(t, response)
assert.Equal(t, "token", response.AccessToken)
assert.Equal(t, "bearer", response.TokenType)
})
+ t.Run("with Presentation Definition from local policy backend", func(t *testing.T) {
+ ctx := createClientServerTestContext(t)
+ pd := pe.PresentationDefinition{Name: "pd-id"}
+ ctx.clientTestContext.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(pe.WalletOwnerMapping{
+ pe.WalletOwnerOrganization: pd,
+ }, nil)
+ ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
+ ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), pd, gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
+ assert.NoError(t, err)
+ require.NotNil(t, response)
+ })
t.Run("error - access denied", func(t *testing.T) {
oauthError := oauth.OAuth2Error{
Code: "invalid_scope",
@@ -347,8 +428,9 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
}
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
require.Error(t, err)
var oauthErrResult oauth.OAuth2Error
@@ -358,6 +440,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
})
t.Run("error - failed to get presentation definition", func(t *testing.T) {
ctx := createClientServerTestContext(t)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
ctx.presentationDefinition = func(writer http.ResponseWriter) {
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusBadRequest)
@@ -366,7 +449,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
return
}
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
require.Error(t, err)
assert.True(t, errors.As(err, &oauth.OAuth2Error{}))
@@ -376,7 +459,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
ctx := createClientServerTestContext(t)
ctx.metadata = nil
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidClientCall)
@@ -384,13 +467,14 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
})
t.Run("error - faulty presentation definition", func(t *testing.T) {
ctx := createClientServerTestContext(t)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
ctx.presentationDefinition = func(writer http.ResponseWriter) {
writer.Header().Add("Content-Type", "application/json")
writer.WriteHeader(http.StatusOK)
_, _ = writer.Write([]byte("{"))
}
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrBadGateway)
@@ -398,10 +482,11 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
})
t.Run("error - failed to build vp", func(t *testing.T) {
ctx := createClientServerTestContext(t)
+ ctx.policyBackend.EXPECT().PresentationDefinitions(gomock.Any(), gomock.Any()).Return(nil, policy.ErrNotFound)
ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), subjectID).Return([]did.DID{primaryWalletDID, secondaryWalletDID}, nil)
ctx.wallet.EXPECT().BuildSubmission(gomock.Any(), []did.DID{primaryWalletDID, secondaryWalletDID}, gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, nil, assert.AnError)
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil, nil)
assert.Error(t, err)
})
@@ -478,6 +563,7 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon
tlsConfig = &tls.Config{}
}
tlsConfig.InsecureSkipVerify = true
+ policyBackend := policy.NewMockPDPBackend(ctrl)
return &clientTestContext{
audit: audit.TestContext(),
@@ -489,13 +575,15 @@ func createClientTestContext(t *testing.T, tlsConfig *tls.Config) *clientTestCon
strictMode: false,
httpClient: client.NewWithTLSConfig(10*time.Second, tlsConfig),
},
- jwtSigner: jwtSigner,
- keyResolver: keyResolver,
+ jwtSigner: jwtSigner,
+ keyResolver: keyResolver,
+ policyBackend: policyBackend,
},
jwtSigner: jwtSigner,
keyResolver: keyResolver,
wallet: wallet,
subjectManager: subjectManager,
+ policyBackend: policyBackend,
}
}
@@ -507,6 +595,7 @@ type clientTestContext struct {
keyResolver *resolver.MockKeyResolver
wallet *holder.MockWallet
subjectManager *didsubject.MockManager
+ policyBackend *policy.MockPDPBackend
}
type clientServerTestContext struct {
@@ -530,7 +619,7 @@ type clientServerTestContext struct {
func createClientServerTestContext(t *testing.T) *clientServerTestContext {
credentialIssuerMetadata := &oauth.OpenIDCredentialIssuerMetadata{}
- metadata := &oauth.AuthorizationServerMetadata{VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), DIDMethodsSupported: []string{"test"}}
+ metadata := &oauth.AuthorizationServerMetadata{VPFormatsSupported: oauth.DefaultOpenIDSupportedFormats(), DIDMethodsSupported: []string{"test"}, GrantTypesSupported: []string{oauth.VpTokenGrantType, oauth.JWTBearerGrantType}}
ctx := &clientServerTestContext{
clientTestContext: createClientTestContext(t, nil),
metadata: func(writer http.ResponseWriter) {
diff --git a/auth/cmd/cmd.go b/auth/cmd/cmd.go
index 5a2abf01e5..89c366d6bb 100644
--- a/auth/cmd/cmd.go
+++ b/auth/cmd/cmd.go
@@ -19,7 +19,11 @@
package cmd
import (
+ "fmt"
+ "strings"
+
"github.com/nuts-foundation/nuts-node/auth"
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/spf13/pflag"
)
@@ -47,6 +51,9 @@ const ConfAccessTokenLifeSpan = "auth.accesstokenlifespan"
// ConfAuthEndpointEnabled is the config key for enabling the Auth v2 API's Authorization Endpoint
const ConfAuthEndpointEnabled = "auth.authorizationendpoint.enabled"
+// ConfGrantTypes is the config key for the supported OAuth2 grant types
+const ConfGrantTypes = "auth.granttypes"
+
// FlagSet returns the configuration flags supported by this module.
func FlagSet() *pflag.FlagSet {
flags := pflag.NewFlagSet("auth", pflag.ContinueOnError)
@@ -61,6 +68,7 @@ func FlagSet() *pflag.FlagSet {
flags.StringSlice(ConfContractValidators, defs.ContractValidators, "sets the different contract validators to use")
flags.Bool(ConfAuthEndpointEnabled, defs.AuthorizationEndpoint.Enabled, "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.")
+ flags.StringSlice(ConfGrantTypes, defs.GrantTypes, fmt.Sprintf("enables OAuth2 grant types for the Authorization Server, options: %s", strings.Join(oauth.SupportedGrantTypes(), ", ")))
_ = flags.MarkDeprecated("auth.http.timeout", "use httpclient.timeout instead")
return flags
diff --git a/auth/cmd/cmd_test.go b/auth/cmd/cmd_test.go
index 5fe44f4c19..48f5cf396f 100644
--- a/auth/cmd/cmd_test.go
+++ b/auth/cmd/cmd_test.go
@@ -19,11 +19,12 @@
package cmd
import (
+ "sort"
+ "testing"
+
"github.com/nuts-foundation/nuts-node/auth"
"github.com/nuts-foundation/nuts-node/core"
"github.com/stretchr/testify/require"
- "sort"
- "testing"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
@@ -46,6 +47,7 @@ func TestFlagSet(t *testing.T) {
ConfAuthEndpointEnabled,
ConfClockSkew,
ConfContractValidators,
+ ConfGrantTypes,
ConfHTTPTimeout,
ConfAutoUpdateIrmaSchemas,
ConfIrmaCorsOrigin,
diff --git a/auth/config.go b/auth/config.go
index 0f30bd3c95..ad1388022a 100644
--- a/auth/config.go
+++ b/auth/config.go
@@ -19,6 +19,7 @@
package auth
import (
+ "github.com/nuts-foundation/nuts-node/auth/oauth"
"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/selfsigned"
@@ -32,6 +33,9 @@ type Config struct {
ContractValidators []string `koanf:"contractvalidators"`
AccessTokenLifeSpan int `koanf:"accesstokenlifespan"`
AuthorizationEndpoint AuthorizationEndpointConfig `koanf:"authorizationendpoint"`
+ // GrantTypes lists OAuth2 grant types the Authorization Server supports.
+ // They will be advertised on the Authorization Server Metadata and be checked when an access token request comes in.
+ GrantTypes []string `koanf:"granttypes"`
}
type AuthorizationEndpointConfig struct {
@@ -69,5 +73,10 @@ func DefaultConfig() Config {
selfsigned.ContractFormat,
},
AccessTokenLifeSpan: 60, // seconds, as specced in RFC003
+ GrantTypes: []string{
+ oauth.AuthorizationCodeGrantType,
+ oauth.VpTokenGrantType,
+ oauth.JWTBearerGrantType,
+ },
}
}
diff --git a/auth/interface.go b/auth/interface.go
index 6a0cd7eecb..e90a71d368 100644
--- a/auth/interface.go
+++ b/auth/interface.go
@@ -19,10 +19,11 @@
package auth
import (
+ "net/url"
+
"github.com/nuts-foundation/nuts-node/auth/client/iam"
"github.com/nuts-foundation/nuts-node/auth/services"
"github.com/nuts-foundation/nuts-node/auth/services/oauth"
- "net/url"
)
// ModuleName contains the name of this module
@@ -44,4 +45,6 @@ type AuthenticationServices interface {
AuthorizationEndpointEnabled() bool
// SupportedDIDMethods lists the DID methods the Nuts node can resolve.
SupportedDIDMethods() []string
+ // GrantTypes lists the OAuth2 grant types that the Authorization Server supports and has enabled.
+ GrantTypes() []string
}
diff --git a/auth/mock.go b/auth/mock.go
index e92db3f34a..30959b4a36 100644
--- a/auth/mock.go
+++ b/auth/mock.go
@@ -85,6 +85,20 @@ func (mr *MockAuthenticationServicesMockRecorder) ContractNotary() *gomock.Call
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContractNotary", reflect.TypeOf((*MockAuthenticationServices)(nil).ContractNotary))
}
+// GrantTypes mocks base method.
+func (m *MockAuthenticationServices) GrantTypes() []string {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GrantTypes")
+ ret0, _ := ret[0].([]string)
+ return ret0
+}
+
+// GrantTypes indicates an expected call of GrantTypes.
+func (mr *MockAuthenticationServicesMockRecorder) GrantTypes() *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GrantTypes", reflect.TypeOf((*MockAuthenticationServices)(nil).GrantTypes))
+}
+
// IAMClient mocks base method.
func (m *MockAuthenticationServices) IAMClient() iam.Client {
m.ctrl.T.Helper()
diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go
index 97572de798..ec6803a07b 100644
--- a/auth/oauth/openid.go
+++ b/auth/oauth/openid.go
@@ -24,7 +24,7 @@ import (
// proofTypeValuesSupported contains a list of supported cipher suites for ldp_vc & ldp_vp presentation formats
// Recommended list of options https://w3c-ccg.github.io/ld-cryptosuite-registry/
-var proofTypeValuesSupported = []string{"JsonWebSignature2020"}
+var proofTypeValuesSupported = []string{"JsonWebSignature2020", "DeziIDJWT"}
// DefaultOpenIDSupportedFormats returns the OpenID formats supported by the Nuts node and is used in the
// - Authorization Server's metadata field `vp_formats_supported`
diff --git a/auth/oauth/types.go b/auth/oauth/types.go
index c0a6d769d2..146b121704 100644
--- a/auth/oauth/types.go
+++ b/auth/oauth/types.go
@@ -21,9 +21,10 @@ package oauth
import (
"encoding/json"
+ "net/url"
+
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/nuts-foundation/nuts-node/core"
- "net/url"
)
// this file contains constants, variables and helper functions for OAuth related code
@@ -205,8 +206,14 @@ const (
PreAuthorizedCodeGrantType = "urn:ietf:params:oauth:grant-type:pre-authorized_code"
// VpTokenGrantType is the grant_type for the vp_token-bearer grant type. (RFC021)
VpTokenGrantType = "vp_token-bearer"
+ // JWTBearerGrantType is the grant_type for the jwt-bearer grant type. (RFC7523)
+ JWTBearerGrantType = "urn:ietf:params:oauth:grant-type:jwt-bearer"
)
+func SupportedGrantTypes() []string {
+ return []string{AuthorizationCodeGrantType, PreAuthorizedCodeGrantType, VpTokenGrantType, JWTBearerGrantType}
+}
+
// response types
const (
// CodeResponseType is the parameter name for the code parameter. (RFC6749)
diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go
index a9be7149d2..82052f6c04 100644
--- a/auth/services/oauth/authz_server_test.go
+++ b/auth/services/oauth/authz_server_test.go
@@ -27,11 +27,12 @@ import (
"encoding/json"
"errors"
"fmt"
- "github.com/nuts-foundation/nuts-node/audit"
- "github.com/nuts-foundation/nuts-node/vdr/resolver"
"testing"
"time"
+ "github.com/nuts-foundation/nuts-node/audit"
+ "github.com/nuts-foundation/nuts-node/vdr/resolver"
+
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
@@ -574,6 +575,7 @@ func TestService_parseAndValidateJwtBearerToken(t *testing.T) {
})
t.Run("wrong signing algorithm", func(t *testing.T) {
+ t.Skip("LSPxNuts: enabled RS256 support")
t.Setenv("GODEBUG", "rsa1024min=0") // minimum key-length has changed to 1024 -> https://pkg.go.dev/crypto/rsa#hdr-Minimum_key_size
privateKey, err := rsa.GenerateKey(rand.Reader, 512)
require.NoError(t, err)
diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go
index e78160a86c..50fede79b6 100644
--- a/auth/services/oauth/relying_party.go
+++ b/auth/services/oauth/relying_party.go
@@ -22,11 +22,12 @@ import (
"context"
"crypto/tls"
"fmt"
- "github.com/nuts-foundation/nuts-node/pki"
"net/url"
"strings"
"time"
+ "github.com/nuts-foundation/nuts-node/pki"
+
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/auth/api/auth/v1/client"
diff --git a/auth/test.go b/auth/test.go
index 4142046cdf..448668ad5c 100644
--- a/auth/test.go
+++ b/auth/test.go
@@ -19,9 +19,11 @@
package auth
import (
+ "testing"
+
+ "github.com/nuts-foundation/nuts-node/policy"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
- "testing"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/pki"
@@ -44,5 +46,5 @@ func testInstance(t *testing.T, cfg Config) *Auth {
vdrInstance := vdr.NewMockVDR(ctrl)
vdrInstance.EXPECT().Resolver().AnyTimes()
subjectManager := didsubject.NewMockManager(ctrl)
- return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock)
+ return NewAuthInstance(cfg, vdrInstance, subjectManager, vcrInstance, cryptoInstance, nil, nil, pkiMock, policy.NewMockPDPBackend(ctrl))
}
diff --git a/cmd/root.go b/cmd/root.go
index b2737c9b3a..75cdc10897 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -23,13 +23,14 @@ import (
"context"
"errors"
"fmt"
- "github.com/sirupsen/logrus"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
"io"
"os"
"runtime/pprof"
+ "github.com/sirupsen/logrus"
+ "github.com/spf13/cobra"
+ "github.com/spf13/pflag"
+
"github.com/nuts-foundation/nuts-node/auth"
authAPIv1 "github.com/nuts-foundation/nuts-node/auth/api/auth/v1"
authIAMAPI "github.com/nuts-foundation/nuts-node/auth/api/iam"
@@ -201,11 +202,11 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System {
credentialInstance := vcr.NewVCRInstance(cryptoInstance, vdrInstance, networkInstance, jsonld, eventManager, storageInstance, pkiInstance)
didmanInstance := didman.NewDidmanInstance(vdrInstance, credentialInstance, jsonld)
discoveryInstance := discovery.New(storageInstance, credentialInstance, vdrInstance, vdrInstance)
- authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance)
+ policyInstance := policy.New()
+ authInstance := auth.NewAuthInstance(auth.DefaultConfig(), vdrInstance, vdrInstance, credentialInstance, cryptoInstance, didmanInstance, jsonld, pkiInstance, policyInstance)
statusEngine := status.NewStatusEngine(system)
metricsEngine := core.NewMetricsEngine()
goldenHammer := golden_hammer.New(vdrInstance, didmanInstance)
- policyInstance := policy.New()
// Register HTTP routes
didKeyResolver := resolver.DIDKeyResolver{Resolver: vdrInstance.Resolver()}
diff --git a/core/tls.go b/core/tls.go
index 3ef10babaf..e36fe6a0d5 100644
--- a/core/tls.go
+++ b/core/tls.go
@@ -71,6 +71,14 @@ type TrustStore struct {
certificates []*x509.Certificate
}
+// PinCertificate adds the given certificate to the trust store as root certificate, without checking whether it forms a valid chain to some root.
+// This is useful for trusting pinned certificates.
+func (store *TrustStore) PinCertificate(certificate *x509.Certificate) {
+ store.certificates = append(store.certificates, certificate)
+ store.RootCAs = append(store.RootCAs, certificate)
+ store.CertPool.AddCert(certificate)
+}
+
// Certificates returns a copy of the certificates within the CertPool
func (store *TrustStore) Certificates() []*x509.Certificate {
return store.certificates[:]
diff --git a/crypto/jwx/algorithm.go b/crypto/jwx/algorithm.go
index 4f89d7684e..771d2829e2 100644
--- a/crypto/jwx/algorithm.go
+++ b/crypto/jwx/algorithm.go
@@ -27,7 +27,7 @@ import (
// ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported
var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported")
-var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.PS256, jwa.PS384, jwa.PS512}
+var SupportedAlgorithms = []jwa.SignatureAlgorithm{jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512, jwa.RS256, jwa.PS256, jwa.PS384, jwa.PS512}
const DefaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256
const DefaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW
diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go
index b42e45a5dc..6dac69e449 100644
--- a/crypto/jwx_test.go
+++ b/crypto/jwx_test.go
@@ -29,13 +29,14 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io"
+ "testing"
+ "time"
+
"github.com/nuts-foundation/nuts-node/crypto/jwx"
"github.com/nuts-foundation/nuts-node/crypto/storage/spi"
"github.com/nuts-foundation/nuts-node/storage/orm"
"go.uber.org/mock/gomock"
- "io"
- "testing"
- "time"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwe"
@@ -120,6 +121,7 @@ func TestSignJWT(t *testing.T) {
func TestParseJWT(t *testing.T) {
t.Run("unsupported algorithm", func(t *testing.T) {
+ t.Skip("LSPxNuts: enabled RS256 support")
rsaKey := test.GenerateRSAKey()
token := jwt.New()
signature, _ := jwt.Sign(token, jwt.WithKey(jwa.RS256, rsaKey))
@@ -581,7 +583,7 @@ func TestCrypto_convertHeaders(t *testing.T) {
func Test_isAlgorithmSupported(t *testing.T) {
assert.True(t, jwx.IsAlgorithmSupported(jwa.PS256))
- assert.False(t, jwx.IsAlgorithmSupported(jwa.RS256))
+ assert.True(t, jwx.IsAlgorithmSupported(jwa.RS256))
assert.False(t, jwx.IsAlgorithmSupported(""))
}
diff --git a/development/lspxnuts/certs/localhost-chain.pem b/development/lspxnuts/certs/localhost-chain.pem
new file mode 100644
index 0000000000..95ba04353b
--- /dev/null
+++ b/development/lspxnuts/certs/localhost-chain.pem
@@ -0,0 +1,40 @@
+-----BEGIN CERTIFICATE-----
+MIIC9jCCAd6gAwIBAgIURFCqPrL3QQdBNOqkwmXWNgx9pdQwDQYJKoZIhvcNAQEL
+BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNDExMTExNDE1MTha
+Fw0zNDExMDkxNDE1MThaMBsxGTAXBgNVBAMMEEZha2UgVVpJIFJvb3QgQ0EwggEi
+MA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDT5J8gKdyMJNi3cuAmJ+MILrMu
+wrKyTRYhjUUFHHn5rcVaHN0hzB6v5t74Nt40xUXRNaomDcclBIOlwt8f62JA2p/j
+83ENfdLrXvUu9NMThkqZwZ9dzRwK7l3UZBq8NTQUO74W4M2qx8nrXq31eWogxUUI
+Fc1XORh5ecebeL5mUb2E6UlmDmNgm2fGeSmmis8zieI+KKYOhi/hYtyeixrg7rxP
+4v0VRrEstcWAetRgXWQX0ElAxs0Vrsy6/vv3pEtXhx8wb2wi2xY14d9Ih8HdeNI+
++3wIbZz6WVM3fD5QFHV2EZBH+soo0pfKj2tHsaDz3FPMuMzILt6U6PT4ALIdAgMB
+AAGjMjAwMA8GA1UdEwQIMAYBAf8CAQAwHQYDVR0OBBYEFJuxz0XwN7PdeMhyJfcf
+m7py1BK9MA0GCSqGSIb3DQEBCwUAA4IBAQAhlpkz68x2dGpOLX3FzAb8Ee+Y2OV+
+RWFpsME9ZVDU06JETPfPCj02PH82lgUnc4jeR81rPSsIt2ssqm2S4zb02Nip595c
+AqCKvmBfEc9hPPW2ugpNxT8ZRU4LKrqpV4nJ6nBvDqmGuH5uq9Ng9l9SnM3eKmdZ
+tJKc+ZNAPKxVAiueLTdr6W2UbmKoZARQQ0JLkFnZOxnUkr8pQfxUzEIUkHg2dWaa
+I/4wo4Pni7xXggFoPDpVztu/iP33XBLqXJwxxHXhq9nc9JU/kEXDt7j8EgoyJo7J
+jSKcjpRfpGkE5gqqB4Sa8wAsAPUK3jRreuytllAtQUZRbCtHbxclc9yA
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL
+BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa
+Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH
+VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1
+ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO
+CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A
+PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m
+Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8
+8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd
+MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF
+BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G
+A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez
+3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP
+72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI
+bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d
+0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS
+05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP
+VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7
+fw==
+-----END CERTIFICATE-----
diff --git a/development/lspxnuts/certs/localhost.key b/development/lspxnuts/certs/localhost.key
new file mode 100644
index 0000000000..645c1a451e
--- /dev/null
+++ b/development/lspxnuts/certs/localhost.key
@@ -0,0 +1,28 @@
+-----BEGIN PRIVATE KEY-----
+MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQC2W0UgU+bgqOwk
+81YiKrniPXxM2Xo67LVjQ/W6BcDwEIBiM2MkCr+jSDdczZCynyXZRVRfAce0Wm0y
+mRCV5U4o84yS7DhSikLxUg4JDa7AoSzGFqmYWjFXX5I6clEnGcD4LqO7xu1bW9JN
+lLISivPBdvmjIwBXTYqy3YA9+xJCQ81TWYH5wdn0IQ4beBRdtDlDxQDDCv1NLZHu
+oWiaNgzvzVbUa+Yh07tr7iYaTgSLLQZmRXsSE9uk6Q6ZZF4XlNeKoOAKhZLIbcIT
+XGYVuduK8gcbxwEyWuPzYbzxQPgFYlxvZ523c8Xt/FJbi6UxoEvvMTz2z1WyYPKW
+Zij9lzcdAgMBAAECgf8UTRTlBHIvkJ65fl2YcClBhpbP92YkKTYIVwiELR/Nmgiw
+5geje47aHrALJNd3C0Crb4x1Bz20VlzRxTiTd3O8G2EK+kFK7xmExB3L5DoQN+FE
+LEG1NFVJ5Nnip9dhAvz4pDiWLw89nHtNJ8CrT8zTPOuNvdfL4FYQk5gzTkA6ICHA
+7NWx9bd/OXS9SWbxQiLi+BeWh4EHmC8U7ZD3HZMTDXnTerebL2Wdtjt3n6atX3/M
+gctFBx6L5spBcDpsrcyXPuJSzBrUL83XRCC9SpO/sWpqI5R7mIvOPIqiSXZcENeh
+h2zvRHFoo29olkagiTSAzJE8OAhQq8iZlmtotdECgYEA9KNaZw/0LWS3YMZZ5oJ9
+F/lVtXrEfKKoDfG4ftietgNRYmhSmyb/pzAKSll0t9MqCVUH18UTWVm6Unp0vPB0
+kaySBKKBLWysYxApVvn4Nmy6+A0KHDmDJdJsMxZHme/ApNOGjpA+30QlOMZk0aey
+5d7X9r5ETAVtDm1lGLItz8kCgYEAvtNqGdUxRaZlm7FA1nb3i8c+FN/i3nO9+6sQ
+ASu/Zy6144vwe51jxS1wctiZW/sp0xoq1rXmjxmXgFLHWoZGrzv4oGZjWz4wdTh2
+tTvrtrj7MgVDrfvBBt+LHzrUkqmW67x5QOIbDYCwFKtoicGDHdpqmz70bC1ey0Ub
+1fBr3rUCgYBETZ+eCuxICEjS8k6Dd4dpvCncA6z8h4WYbxbuA5k8hGyipzH5M8hJ
+a7ZTz+owsPqZpG4OJm4iklTdVmdloVVKnv4d4Slj/2WaOxbvu9c7itwhCbL68mvV
+kYy4Ls5LAo+s9YoqH8gOGj6yPWJEzye52qA9uh3jg9hRIOYLISR9UQKBgB+1baoB
+PQC/1555Y7a/af72CqDZWw9v2B/bmvs208VHg73d4QYJbyyykj7jMwiPwbFsZbXr
+3/XjYMNX/fxS16gCpRuyJ8xflxnDWiZfYJmqP0NekJJ2hOqpdqqn0e7U81kUpmlb
+qPcjbR7iJKrPVwQ86P4HBgJ7v4azYx63ppUJAoGBAIGkZj8Klu4p5wEtM+n5ZY0B
+Gm6mW77ukIF+M1U94CazxB1XXiQhPCnbLst6NkLLAYKb4Lkzu/ivlDYPXL18oNKZ
+e0xBSnzMgExEnuJvwYNHPOF08MDKuSuCu2A0LLGB/CZoFMpvW0sHJlO2h3AY9nZR
+TIh5VU9wuNH8zI0VvkRo
+-----END PRIVATE KEY-----
diff --git a/development/lspxnuts/certs/localhost.pem b/development/lspxnuts/certs/localhost.pem
new file mode 100644
index 0000000000..216f8dd292
--- /dev/null
+++ b/development/lspxnuts/certs/localhost.pem
@@ -0,0 +1,22 @@
+-----BEGIN CERTIFICATE-----
+MIIDjTCCAnWgAwIBAgIUFTPO+pUk32QWsYyLYdlLTmlRWWgwDQYJKoZIhvcNAQEL
+BQAwGzEZMBcGA1UEAwwQRmFrZSBVWkkgUm9vdCBDQTAeFw0yNTEyMDIwNzEwMDVa
+Fw0yNjEyMDIwNzEwMDVaMEMxEjAQBgNVBAMMCWxvY2FsaG9zdDEQMA4GA1UECgwH
+VGVzdE9yZzEPMA0GA1UEBwwGQXJuaGVtMQowCAYDVQQFEwExMIIBIjANBgkqhkiG
+9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtltFIFPm4KjsJPNWIiq54j18TNl6Ouy1Y0P1
+ugXA8BCAYjNjJAq/o0g3XM2Qsp8l2UVUXwHHtFptMpkQleVOKPOMkuw4UopC8VIO
+CQ2uwKEsxhapmFoxV1+SOnJRJxnA+C6ju8btW1vSTZSyEorzwXb5oyMAV02Kst2A
+PfsSQkPNU1mB+cHZ9CEOG3gUXbQ5Q8UAwwr9TS2R7qFomjYM781W1GvmIdO7a+4m
+Gk4Eiy0GZkV7EhPbpOkOmWReF5TXiqDgCoWSyG3CE1xmFbnbivIHG8cBMlrj82G8
+8UD4BWJcb2edt3PF7fxSW4ulMaBL7zE89s9VsmDylmYo/Zc3HQIDAQABo4GgMIGd
+MB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcDAjA8BgNVHREENTAzoDEGA1UF
+BaAqDCgyLjE2LjUyOC4xLjEwMDcuOTkuMjExMC0xLTEtUy0yLTAwLjAwMC0zMB0G
+A1UdDgQWBBRMg47vcZ1skdvIXwh8FC/rv7MfTjAfBgNVHSMEGDAWgBSbsc9F8Dez
+3XjIciX3H5u6ctQSvTANBgkqhkiG9w0BAQsFAAOCAQEACL8lz57c7UfGCV4btMuP
+72qrkxSj5Ii+nrreUGc4uxR3G8FpBE++0W4PMK/wNp8IfvKFweujHH1DigQLKhRI
+bHrFnsJdkZ7h/LtTEzxti/0OMLQ9J8DaZ9myPEdkO5Qn7zsoanyjzmNwCGXKJg2d
+0cOxsO5Gys6wAkqkS2YsLO/kKI6IUTNvxyoziSap8kvKwIrAP2vAgTWCECT9fKJS
+05kx7WFciN+STw6hkxEQNeStteAEgfXuLnrwQSeCPljrQSTNOYMy9B1uEs06C5QP
+VHWmT43b9/YWEogxRQKJdM4toIvpFTGM8PonOpWyS77z6Ltnglaq8b5+QNjykYv7
+fw==
+-----END CERTIFICATE-----
diff --git a/development/lspxnuts/docker-compose.yml b/development/lspxnuts/docker-compose.yml
new file mode 100644
index 0000000000..8b8a824e2b
--- /dev/null
+++ b/development/lspxnuts/docker-compose.yml
@@ -0,0 +1,25 @@
+services:
+ nutsnode:
+ image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:lspxnuts}"
+ #pull_policy: always
+ ports:
+ - "18081:8081"
+ environment:
+ NUTS_STRICTMODE: false
+ NUTS_URL: "https://nuts.nl"
+ NUTS_AUTH_CONTRACTVALIDATORS: dummy
+ NUTS_HTTP_INTERNAL_ADDRESS: ":8081"
+ NUTS_POLICY_DIRECTORY: /opt/nuts/policies
+ volumes:
+ - ./policies:/opt/nuts/policies:ro
+ healthcheck:
+ interval: 1s # Make test run quicker by checking health status more often
+
+ nutsadmin:
+ image: "nutsfoundation/nuts-admin:main"
+ environment:
+ - NUTS_NODE_ADDRESS=http://nutsnode:8081
+ ports:
+ - "1405:1305"
+ depends_on:
+ - nutsnode
diff --git a/development/lspxnuts/policies/lspxnuts.json b/development/lspxnuts/policies/lspxnuts.json
new file mode 100644
index 0000000000..6c2ab9e157
--- /dev/null
+++ b/development/lspxnuts/policies/lspxnuts.json
@@ -0,0 +1,225 @@
+{
+ "nuts-lsp": {
+ "organization": {
+ "format": {
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ },
+ "jwt_vc": {
+ "alg": [
+ "PS256",
+ "RS256"
+ ]
+ },
+ "jwt_vp": {
+ "alg": [
+ "ES256"
+ ]
+ }
+ },
+ "id": "pd_any_care_organization",
+ "name": "Care organization",
+ "purpose": "Finding a care organization",
+ "input_descriptors": [
+ {
+ "id": "id_uzicert_uracredential",
+ "name": "Care organization identity from fake UZI-server certificate",
+ "purpose": "Finding a care organization for authorizing access to medical metadata.",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "X509Credential"
+ }
+ },
+ {
+ "path": [
+ "$.issuer"
+ ],
+ "purpose": "We can only accept credentials from a trusted issuer",
+ "filter": {
+ "type": "string",
+ "pattern": "^did:x509:0:sha256:GwlhBZuEFlSHXSRUXQuTs3_YpQxAahColwJJj35US1A[\\w\\-.:%]*$"
+ }
+ },
+ {
+ "id": "organization_id",
+ "path": [
+ "$.credentialSubject[0].id",
+ "$.credentialSubject.id"
+ ],
+ "filter": {
+ "type": "string",
+ "pattern": "^did:web:"
+ }
+ },
+ {
+ "id": "organization_name",
+ "path": [
+ "$.credentialSubject[0].subject.O",
+ "$.credentialSubject.subject.O"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "id": "organization_ura",
+ "path": [
+ "$.credentialSubject[0].san.otherName",
+ "$.credentialSubject.san.otherName"
+ ],
+ "filter": {
+ "type": "string",
+ "pattern": "^[0-9.]+-\\d+-\\d+-S-(\\d+)-00\\.000-\\d+$"
+ }
+ },
+ {
+ "id": "organization_city",
+ "path": [
+ "$.credentialSubject[0].subject.L",
+ "$.credentialSubject.subject.L"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "id": "id_patient_enrollment",
+ "name": "The patient enrollment credential",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "PatientEnrollmentCredential"
+ }
+ },
+ {
+ "path": [
+ "$.issuer"
+ ],
+ "purpose": "We can only accept credentials from a trusted issuer",
+ "filter": {
+ "type": "string",
+ "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$"
+ }
+ },
+ {
+ "id": "organization_id",
+ "path": [
+ "$.credentialSubject[0].id",
+ "$.credentialSubject.id"
+ ],
+ "filter": {
+ "type": "string",
+ "pattern": "^did:web:"
+ }
+ },
+ {
+ "id": "patient_id",
+ "path": [
+ "$.credentialSubject[0].patientId",
+ "$.credentialSubject.patientId"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "id": "registered_by",
+ "path": [
+ "$.credentialSubject[0].registeredBy",
+ "$.credentialSubject.registeredBy"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ },
+ {
+ "id": "id_healthcare_professional_delegation",
+ "name": "The healthcare professional delegation credential",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "HealthCareProfessionalDelegationCredential"
+ }
+ },
+ {
+ "path": [
+ "$.issuer"
+ ],
+ "purpose": "We can only accept credentials from a trusted issuer",
+ "filter": {
+ "type": "string",
+ "pattern": "^did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4[\\w\\-.:%]*$"
+ }
+ },
+ {
+ "id": "organization_id",
+ "path": [
+ "$.credentialSubject[0].id",
+ "$.credentialSubject.id"
+ ],
+ "filter": {
+ "type": "string",
+ "pattern": "^did:web:"
+ }
+ },
+ {
+ "id": "registered_by",
+ "path": [
+ "$.credentialSubject[0].registeredBy",
+ "$.credentialSubject.registeredBy"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "id": "role_code",
+ "path": [
+ "$.credentialSubject[0].roleCode",
+ "$.credentialSubject.roleCode"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "id": "authorization_rule",
+ "path": [
+ "$.credentialSubject[0].authorizationRule",
+ "$.credentialSubject.authorizationRule"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/development/lspxnuts/setup.sh b/development/lspxnuts/setup.sh
new file mode 100755
index 0000000000..5b9c33c23b
--- /dev/null
+++ b/development/lspxnuts/setup.sh
@@ -0,0 +1,143 @@
+#!/usr/bin/env bash
+
+set -e
+
+# Configuration
+NUTS_NODE_URL="http://localhost:18081"
+SUBJECT_NAME="${SUBJECT_NAME:-testsubject}"
+CERT_CHAIN="./certs/localhost-chain.pem"
+CERT_KEY="./certs/localhost.key"
+ISSUER_CN="${ISSUER_CN:-CN=Fake UZI Root CA}"
+
+echo "======================================"
+echo "LSPxNuts Setup Script"
+echo "======================================"
+echo ""
+
+echo "------------------------------------"
+echo "Creating Nuts subject..."
+echo "------------------------------------"
+#REQUEST="{\"subject\":\"${SUBJECT_NAME}\"}"
+REQUEST="{}"
+RESPONSE=$(echo $REQUEST | curl -s -X POST --data-binary @- ${NUTS_NODE_URL}/internal/vdr/v2/subject --header "Content-Type: application/json")
+
+# Extract DID from response
+SUBJECT_NAME=$(echo $RESPONSE | jq -r '.subject')
+DID=$(echo $RESPONSE | jq -r '.documents[0].id')
+
+if [ -z "$DID" ] || [ "$DID" = "null" ]; then
+ echo "ERROR: Failed to create subject or extract DID"
+ echo "Response: $RESPONSE"
+ exit 1
+fi
+
+echo "✓ Subject created successfully"
+echo " Subject: ${SUBJECT_NAME}"
+echo " DID: ${DID}"
+echo ""
+
+echo "------------------------------------"
+echo "Issuing X509Credential..."
+echo "------------------------------------"
+
+# Check if certificate files exist
+if [ ! -f "$CERT_CHAIN" ]; then
+ echo "ERROR: Certificate chain not found at $CERT_CHAIN"
+ exit 1
+fi
+
+if [ ! -f "$CERT_KEY" ]; then
+ echo "ERROR: Certificate key not found at $CERT_KEY"
+ exit 1
+fi
+
+# Issue X509 credential using go-didx509-toolkit Docker image
+CREDENTIAL=$(docker run \
+ --rm \
+ -v "$(pwd)/${CERT_CHAIN}:/cert-chain.pem:ro" \
+ -v "$(pwd)/${CERT_KEY}:/cert-key.key:ro" \
+ nutsfoundation/go-didx509-toolkit:main \
+ vc "/cert-chain.pem" "/cert-key.key" "${ISSUER_CN}" "${DID}")
+
+if [ -z "$CREDENTIAL" ]; then
+ echo "ERROR: Failed to generate X509Credential"
+ exit 1
+fi
+
+echo "✓ X509Credential generated"
+echo ""
+
+echo "------------------------------------"
+echo "Loading credential into wallet..."
+echo "------------------------------------"
+
+# Store credential in wallet
+HTTP_CODE=$(echo "\"${CREDENTIAL}\"" | curl -s -o /dev/null -w "%{http_code}" \
+ -X POST --data-binary @- \
+ ${NUTS_NODE_URL}/internal/vcr/v2/holder/${SUBJECT_NAME}/vc \
+ -H "Content-Type:application/json")
+
+if [ "$HTTP_CODE" -eq 204 ]; then
+ echo "✓ X509Credential successfully stored in wallet"
+else
+ echo "ERROR: Failed to load X509Credential in wallet (HTTP $HTTP_CODE)"
+ exit 1
+fi
+
+echo ""
+echo "------------------------------------"
+echo "Issuing MandaatCredential..."
+echo "------------------------------------"
+
+# Issue a self-issued MandaatCredential
+MANDAAT_REQUEST=$(cat <&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+
+echo "---------------------------------------"
+echo "Perform OAuth 2.0 rfc021 flow..."
+echo "---------------------------------------"
+
+# Run generate-jwt.sh, and read the input into a var, clean newlines
+IDTOKEN=$(./generate-jwt.sh | tr -d '\n')
+
+REQUEST=$(
+cat << EOF
+{
+ "authorization_server": "https://nodeA/oauth2/vendorA",
+ "token_type": "bearer",
+ "scope": "test",
+ "id_token": "$IDTOKEN"
+}
+EOF
+)
+# Request access token
+RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:18081/internal/auth/v2/vendorA/request-service-access-token -H "Content-Type: application/json")
+if echo $RESPONSE | grep -q "access_token"; then
+ ACCESS_TOKEN=$(echo $RESPONSE | jq -r .access_token)
+else
+ echo "FAILED: Could not get access token from node-A" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+echo Access token: $ACCESS_TOKEN
+
+echo "------------------------------------"
+echo "Introspect access token..."
+echo "------------------------------------"
+RESPONSE=$(curl -X POST -s --data "token=$ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect)
+echo Introspection response: $RESPONSE
+
+# Check that it contains the following claims from the Dezi token:
+# Token contains:
+# - "abonnee_nummer":"90000380" -> organization_ura_dezi
+# - "dezi_nummer":"900022159" -> user_uzi
+# - "voorletters":"J." -> user_initials
+# - "achternaam":"90017362" -> user_surname
+# - "voorvoegsel":null -> user_surname_prefix (empty)
+# - "rol_code":"92.000" -> user_role
+if [ "$(echo $RESPONSE | jq -r .organization_ura_dezi)" != "90000380" ]; then
+ echo "FAILED: organization_ura_dezi invalid" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+if [ "$(echo $RESPONSE | jq -r .user_initials)" != "J." ]; then
+ echo "FAILED: user_initials invalid" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+if [ "$(echo $RESPONSE | jq -r .user_role)" != "92.000" ]; then
+ echo "FAILED: user_role invalid" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+if [ "$(echo $RESPONSE | jq -r .user_surname)" != "90017362" ]; then
+ echo "FAILED: user_surname invalid" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+# voorvoegsel is null in the token, so user_surname_prefix should be empty or not present
+USER_SURNAME_PREFIX=$(echo $RESPONSE | jq -r .user_surname_prefix)
+if [ "$USER_SURNAME_PREFIX" != "" ] && [ "$USER_SURNAME_PREFIX" != "null" ]; then
+ echo "FAILED: user_surname_prefix should be empty, got: $USER_SURNAME_PREFIX" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+if [ "$(echo $RESPONSE | jq -r .user_uzi)" != "900022159" ]; then
+ echo "FAILED: user_uzi invalid" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+
+echo "------------------------------------"
+echo "Stopping Docker containers..."
+echo "------------------------------------"
+docker compose down
\ No newline at end of file
diff --git a/e2e-tests/oauth-flow/jwt-bearer/docker-compose.yml b/e2e-tests/oauth-flow/jwt-bearer/docker-compose.yml
new file mode 100644
index 0000000000..23689a436b
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/docker-compose.yml
@@ -0,0 +1,48 @@
+services:
+ nodeA-backend:
+ image: "${IMAGE_NODE_A:-nutsfoundation/nuts-node:master}"
+ ports:
+ - "18081:8081"
+ environment:
+ NUTS_CONFIGFILE: /opt/nuts/nuts.yaml
+ volumes:
+ - "./node-A/nuts.yaml:/opt/nuts/nuts.yaml:ro"
+ - "../../tls-certs/nodeA-backend-certificate.pem:/opt/nuts/certificate-and-key.pem:ro"
+ - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro"
+ - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro"
+ - "./node-A/presentationexchangemapping.json:/opt/nuts/policies/presentationexchangemapping.json:ro"
+ healthcheck:
+ interval: 1s
+ nodeA:
+ image: nginx:1.25.1
+ ports:
+ - "10443:443"
+ volumes:
+ - "./node-A/nginx.conf:/etc/nginx/nginx.conf:ro"
+ - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/server.pem:ro"
+ - "../../tls-certs/nodeA-certificate.pem:/etc/nginx/ssl/key.pem:ro"
+ - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro"
+ - "./node-A/html:/etc/nginx/html:ro"
+ - "../../scripts/oauth2.js:/etc/nginx/oauth2.js:ro"
+ nodeB-backend:
+ image: "${IMAGE_NODE_B:-nutsfoundation/nuts-node:master}"
+ ports:
+ - "28081:8081"
+ environment:
+ NUTS_CONFIGFILE: /opt/nuts/nuts.yaml
+ volumes:
+ - "./node-B/nuts.yaml:/opt/nuts/nuts.yaml:ro"
+ - "../../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro"
+ - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro"
+ - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro"
+ healthcheck:
+ interval: 1s
+ nodeB:
+ image: nginx:1.25.1
+ ports:
+ - "20443:443"
+ volumes:
+ - "../../shared_config/nodeB-http-nginx.conf:/etc/nginx/conf.d/nuts-http.conf:ro"
+ - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/server.pem:ro"
+ - "../../tls-certs/nodeB-certificate.pem:/etc/nginx/ssl/key.pem:ro"
+ - "../../tls-certs/truststore.pem:/etc/nginx/ssl/truststore.pem:ro"
diff --git a/e2e-tests/oauth-flow/jwt-bearer/node-A/nginx.conf b/e2e-tests/oauth-flow/jwt-bearer/node-A/nginx.conf
new file mode 100644
index 0000000000..f3a5372982
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/node-A/nginx.conf
@@ -0,0 +1,71 @@
+load_module /usr/lib/nginx/modules/ngx_http_js_module.so;
+
+user nginx;
+worker_processes 1;
+
+error_log /var/log/nginx/error.log debug;
+pid /var/run/nginx.pid;
+
+events {
+ worker_connections 1024;
+}
+
+http {
+ js_import oauth2.js;
+ include /etc/nginx/mime.types;
+ default_type application/octet-stream;
+
+ log_format main '$remote_addr - $remote_user [$time_local] "$request" '
+ '$status $body_bytes_sent "$http_referer" '
+ '"$http_user_agent" "$http_x_forwarded_for"';
+
+ access_log /var/log/nginx/access.log main;
+
+ keepalive_timeout 65;
+
+ include /etc/nginx/conf.d/*.conf;
+
+ upstream nodeA-internal {
+ server nodeA-backend:8081;
+ }
+ upstream nodeA-external {
+ server nodeA-backend:8080;
+ }
+
+ server {
+ server_name nodeA;
+ listen 443 ssl;
+ http2 on;
+ ssl_certificate /etc/nginx/ssl/server.pem;
+ ssl_certificate_key /etc/nginx/ssl/key.pem;
+ ssl_client_certificate /etc/nginx/ssl/truststore.pem;
+ ssl_verify_client optional;
+ ssl_verify_depth 1;
+ ssl_protocols TLSv1.3;
+
+ location / {
+ proxy_set_header X-Ssl-Client-Cert $ssl_client_escaped_cert;
+ proxy_pass http://nodeA-external;
+ }
+
+ # check access via token introspection as described by https://www.nginx.com/blog/validating-oauth-2-0-access-tokens-nginx/
+ location /resource {
+ js_content oauth2.introspectAccessToken;
+ }
+
+ # Location in javascript subrequest.
+ # this is needed to set headers and method
+ location /_oauth2_send_request {
+ internal;
+ proxy_method POST;
+ proxy_set_header Content-Type "application/x-www-form-urlencoded";
+ proxy_pass http://nodeA-internal/internal/auth/v2/accesstoken/introspect;
+ }
+ location /_dpop_send_request {
+ internal;
+ proxy_method POST;
+ proxy_set_header Content-Type "application/json";
+ proxy_pass http://nodeA-internal/internal/auth/v2/dpop/validate;
+ }
+ }
+}
diff --git a/e2e-tests/oauth-flow/jwt-bearer/node-A/nuts.yaml b/e2e-tests/oauth-flow/jwt-bearer/node-A/nuts.yaml
new file mode 100644
index 0000000000..f5ce537620
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/node-A/nuts.yaml
@@ -0,0 +1,20 @@
+url: https://nodeA
+verbosity: debug
+strictmode: false
+internalratelimiter: false
+http:
+ log: metadata-and-body
+ internal:
+ address: :8081
+auth:
+ contractvalidators:
+ - dummy
+ irma:
+ autoupdateschemas: false
+ granttypes: urn:ietf:params:oauth:grant-type:jwt-bearer
+policy:
+ directory: /opt/nuts/policies
+tls:
+ truststorefile: /opt/nuts/truststore.pem
+ certfile: /opt/nuts/certificate-and-key.pem
+ certkeyfile: /opt/nuts/certificate-and-key.pem
diff --git a/e2e-tests/oauth-flow/jwt-bearer/node-A/presentationexchangemapping.json b/e2e-tests/oauth-flow/jwt-bearer/node-A/presentationexchangemapping.json
new file mode 100644
index 0000000000..2ea85e792e
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/node-A/presentationexchangemapping.json
@@ -0,0 +1,56 @@
+{
+ "test": {
+ "organization": {
+ "format": {
+ "ldp_vp": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ },
+ "ldp_vc": {
+ "proof_type": [
+ "JsonWebSignature2020"
+ ]
+ }
+ },
+ "id": "pd_nuts_care_organization",
+ "name": "Care organization",
+ "purpose": "Finding a care organization for authorizing access",
+ "input_descriptors": [
+ {
+ "id": "id_nuts_care_organization_cred",
+ "constraints": {
+ "fields": [
+ {
+ "path": [
+ "$.type"
+ ],
+ "filter": {
+ "type": "string",
+ "const": "NutsOrganizationCredential"
+ }
+ },
+ {
+ "id": "organization_name",
+ "path": [
+ "$.credentialSubject.organization.name"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ },
+ {
+ "path": [
+ "$.credentialSubject.organization.city"
+ ],
+ "filter": {
+ "type": "string"
+ }
+ }
+ ]
+ }
+ }
+ ]
+ }
+ }
+}
diff --git a/e2e-tests/oauth-flow/jwt-bearer/node-B/nuts.yaml b/e2e-tests/oauth-flow/jwt-bearer/node-B/nuts.yaml
new file mode 100644
index 0000000000..0b3fe63948
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/node-B/nuts.yaml
@@ -0,0 +1,19 @@
+url: https://nodeB
+verbosity: debug
+strictmode: false
+internalratelimiter: false
+http:
+ log: metadata-and-body
+ internal:
+ address: :8081
+auth:
+ tlsenabled: true
+ contractvalidators:
+ - dummy
+ irma:
+ autoupdateschemas: false
+ granttypes: urn:ietf:params:oauth:grant-type:jwt-bearer
+tls:
+ truststorefile: /opt/nuts/truststore.pem
+ certfile: /opt/nuts/certificate-and-key.pem
+ certkeyfile: /opt/nuts/certificate-and-key.pem
diff --git a/e2e-tests/oauth-flow/jwt-bearer/run-test.sh b/e2e-tests/oauth-flow/jwt-bearer/run-test.sh
new file mode 100755
index 0000000000..b2faed4aa1
--- /dev/null
+++ b/e2e-tests/oauth-flow/jwt-bearer/run-test.sh
@@ -0,0 +1,121 @@
+#!/usr/bin/env bash
+source ../../util.sh
+
+dc="docker compose -f docker-compose.yml"
+
+echo "------------------------------------"
+echo "Cleaning up running Docker containers and volumes, and key material..."
+echo "------------------------------------"
+$dc down --remove-orphans
+$dc rm -f -v
+
+echo "------------------------------------"
+echo "Starting Docker containers..."
+echo "------------------------------------"
+$dc up -d
+$dc up --wait nodeA nodeA-backend nodeB nodeB-backend
+
+echo "------------------------------------"
+echo "Registering vendors..."
+echo "------------------------------------"
+# Register Vendor A
+REQUEST="{\"subject\":\"vendorA\"}"
+VENDOR_A_DIDDOC=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:18081/internal/vdr/v2/subject --header "Content-Type: application/json")
+VENDOR_A_DID=$(echo $VENDOR_A_DIDDOC | jq -r .documents[0].id)
+echo Vendor A DID: $VENDOR_A_DID
+
+# Register Vendor B
+REQUEST="{\"subject\":\"vendorB\"}"
+VENDOR_B_DIDDOC=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vdr/v2/subject --header "Content-Type: application/json")
+VENDOR_B_DID=$(echo $VENDOR_B_DIDDOC | jq -r .documents[0].id)
+echo Vendor B DID: $VENDOR_B_DID
+
+# Issue NutsOrganizationCredential for Vendor B
+REQUEST="{\"type\":\"NutsOrganizationCredential\",\"issuer\":\"${VENDOR_B_DID}\", \"credentialSubject\": {\"id\":\"${VENDOR_B_DID}\", \"organization\":{\"name\":\"Caresoft B.V.\", \"city\":\"Caretown\"}},\"withStatusList2021Revocation\": true}"
+VENDOR_B_CREDENTIAL=$(echo $REQUEST | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/issuer/vc -H "Content-Type:application/json")
+if echo $VENDOR_B_CREDENTIAL | grep -q "VerifiableCredential"; then
+ echo "VC issued"
+else
+ echo "FAILED: Could not issue NutsOrganizationCredential to node-B" 1>&2
+ echo $VENDOR_B_CREDENTIAL
+ exitWithDockerLogs 1
+fi
+
+RESPONSE=$(echo $VENDOR_B_CREDENTIAL | curl -X POST --data-binary @- http://localhost:28081/internal/vcr/v2/holder/vendorB/vc -H "Content-Type:application/json")
+if echo $RESPONSE == ""; then
+ echo "VC stored in wallet"
+else
+ echo "FAILED: Could not load NutsOrganizationCredential in node-B wallet" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+
+echo "---------------------------------------"
+echo "Perform OAuth 2.0 JWT bearer flow..."
+echo "---------------------------------------"
+REQUEST=$(
+cat << EOF
+{
+ "authorization_server": "https://nodeA/oauth2/vendorA",
+ "scope": "test",
+ "token_type": "bearer"
+}
+EOF
+)
+# Request access token using JWT bearer grant type (no DPoP)
+RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:28081/internal/auth/v2/vendorB/request-service-access-token -H "Content-Type: application/json")
+if echo $RESPONSE | grep -q "access_token"; then
+ ACCESS_TOKEN=$(echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/')
+ echo "access token obtained"
+else
+ echo "FAILED: Could not get access token from node-A" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+
+echo "------------------------------------"
+echo "Introspect access token..."
+echo "------------------------------------"
+RESPONSE=$(curl -X POST -s --data "token=$ACCESS_TOKEN" http://localhost:18081/internal/auth/v2/accesstoken/introspect)
+echo $RESPONSE
+# Check that it contains "active": true
+if echo $RESPONSE | grep -q "active.*true"; then
+ echo "access token is active"
+else
+ echo "FAILED: Access token is not active" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+# Check that organization_name claim is present (from NutsOrganizationCredential)
+if echo $RESPONSE | grep -q "organization_name"; then
+ echo "organization_name claim is present"
+else
+ echo "FAILED: missing organization_name claim" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+# Verify JWT bearer was used: no presentation_submissions in introspect response
+if echo $RESPONSE | grep -q "presentation_submissions"; then
+ echo "FAILED: presentation_submissions should not be present for JWT bearer grant" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+else
+ echo "JWT bearer confirmed: no presentation_submissions in token"
+fi
+
+echo "------------------------------------"
+echo "Retrieving data..."
+echo "------------------------------------"
+RESPONSE=$($dc exec nodeB curl --http1.1 --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/resource -H "Authorization: Bearer $ACCESS_TOKEN")
+if echo $RESPONSE | grep -q "OK"; then
+ echo "success!"
+else
+ echo "FAILED: Could not get resource from node-A" 1>&2
+ echo $RESPONSE
+ exitWithDockerLogs 1
+fi
+
+echo "------------------------------------"
+echo "Stopping Docker containers..."
+echo "------------------------------------"
+$dc down
diff --git a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml
index 32ed855184..3f33aeb3bb 100644
--- a/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml
+++ b/e2e-tests/oauth-flow/rfc021/node-A/nuts.yaml
@@ -11,6 +11,7 @@ auth:
- dummy
irma:
autoupdateschemas: false
+ granttypes: vp_token-bearer
policy:
directory: /opt/nuts/policies
tls:
diff --git a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml
index fb78e0ee5f..0c86a46be3 100644
--- a/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml
+++ b/e2e-tests/oauth-flow/rfc021/node-B/nuts.yaml
@@ -12,6 +12,7 @@ auth:
- dummy
irma:
autoupdateschemas: false
+ granttypes: vp_token-bearer
discovery:
definitions:
directory: /nuts/discovery
diff --git a/e2e-tests/oauth-flow/run-tests.sh b/e2e-tests/oauth-flow/run-tests.sh
index 93f68d01a7..6219711db1 100755
--- a/e2e-tests/oauth-flow/run-tests.sh
+++ b/e2e-tests/oauth-flow/run-tests.sh
@@ -16,6 +16,13 @@ pushd rfc021
./run-test.sh
popd
+echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
+echo "!! Running test: OAuth flow (JWT bearer/RFC7523) !!"
+echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
+pushd jwt-bearer
+./run-test.sh
+popd
+
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
echo "!! Running test: OAuth flow (rfc021) using X509Credential !!"
echo "!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!"
diff --git a/e2e-tests/scripts/oauth2.js b/e2e-tests/scripts/oauth2.js
index 197b014d74..28d0e961fd 100644
--- a/e2e-tests/scripts/oauth2.js
+++ b/e2e-tests/scripts/oauth2.js
@@ -1,7 +1,14 @@
// # check access via token introspection as described by https://www.nginx.com/blog/validating-oauth-2-0-access-tokens-nginx/
function introspectAccessToken(r) {
- // strip the first 5 chars
- var token = "token=" + r.headersIn['Authorization'].substring(5);
+ var authHeader = r.headersIn['Authorization'];
+ var tokenValue;
+ if (authHeader.substring(0, 5) === 'DPoP ') {
+ tokenValue = authHeader.substring(5);
+ } else {
+ // Bearer token
+ tokenValue = authHeader.substring(7);
+ }
+ var token = "token=" + tokenValue;
// make a subrequest to the introspection endpoint
r.subrequest("/_oauth2_send_request",
{ method: "POST", body: token},
@@ -9,7 +16,11 @@ function introspectAccessToken(r) {
if (reply.status == 200) {
var introspection = JSON.parse(reply.responseBody);
if (introspection.active) {
- dpop(r, introspection.cnf)
+ if (introspection.cnf && introspection.cnf.jkt) {
+ dpop(r, introspection.cnf)
+ } else {
+ r.return(200, "OK");
+ }
} else {
r.return(403, "Unauthorized");
}
diff --git a/go.mod b/go.mod
index 3f030df067..8572e07b2e 100644
--- a/go.mod
+++ b/go.mod
@@ -191,7 +191,7 @@ require (
golang.org/x/text v0.36.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect
- gorm.io/gorm v1.31.1
+ gorm.io/gorm v1.30.2
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.48.1
diff --git a/go.sum b/go.sum
index dc02b39f8b..d78508b120 100644
--- a/go.sum
+++ b/go.sum
@@ -775,8 +775,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
-gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
-gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs=
+gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
diff --git a/pki/cacerts/QuoVadis PKIoverheid Private Services CA - G1.crt b/pki/cacerts/QuoVadis PKIoverheid Private Services CA - G1.crt
new file mode 100644
index 0000000000..276c6360d8
--- /dev/null
+++ b/pki/cacerts/QuoVadis PKIoverheid Private Services CA - G1.crt
@@ -0,0 +1,40 @@
+-----BEGIN CERTIFICATE-----
+MIIG/DCCBOSgAwIBAgIIU2w5U7TnvlwwDQYJKoZIhvcNAQELBQAwZjELMAkGA1UE
+BhMCTkwxHjAcBgNVBAoMFVN0YWF0IGRlciBOZWRlcmxhbmRlbjE3MDUGA1UEAwwu
+U3RhYXQgZGVyIE5lZGVybGFuZGVuIFByaXZhdGUgU2VydmljZXMgQ0EgLSBHMTAe
+Fw0xNjExMDMxMDM2MTFaFw0yODExMTIwMDAwMDBaMIGAMQswCQYDVQQGEwJOTDEg
+MB4GA1UECgwXUXVvVmFkaXMgVHJ1c3RsaW5rIEIuVi4xFzAVBgNVBGEMDk5UUk5M
+LTMwMjM3NDU5MTYwNAYDVQQDDC1RdW9WYWRpcyBQS0lvdmVyaGVpZCBQcml2YXRl
+IFNlcnZpY2VzIENBIC0gRzEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoIC
+AQCo9ep1FEPp6FQIgMT8wD5UoTDQoqkE43vWFx2ur6cxYQFpWXefd0dpdaugwOJ5
+igIilAoIrd+FdYxbJxQOkrbmkFX1mdW2mAf10+9BW5rn1HvwHG/nvZ8kyaeZuEyE
+UmVZ+EEFw+7hOru7YPkxA5U7N3IY409xAjiYS3Cti388bLPfE3Ci0Rt2WQlL3jbn
+SlAty2ZdJ5aCRkL3Cm8wERlwZ0lsLsTIQo1TCw6WfrDgEk+41MvP3eh+0wL3lWPn
+SPzwvI13Dd5PjT7Pte73oVpqRwgWl+2ZjGD7vwNf14rma3Khuwv74lWWJIr9EHu4
+miseqVlhk4mFpPC4zsKM6AeXfhZwKLmGAwM54yHw7hjvSPoBilpGiKdIdELMfFzW
+ToSOjMXPQZyHSF5F13sj/hD+YLRBx95QsDa+1xWJq4hv+/t/WPuw1E+s8JYQ/5HK
+ArYfGNMou20skdJyvYW5H+NcZ0guCaF6zdrUDkFv+uI+MkWQMxGw/mwqZX/cW4EC
+4PPG1j3NEK6/gfU7LT4W/M/GIBC6kJ+L5AJtDIvW8719oIp0UBmhF1nZyQSX6Wda
+plsM6CNYZ7SSq+kUC4k6Oalzsnv4kNy7ru6yzHoI33V6VXQcs2BevJjbf09YIHoh
+2cPKPB0nIvRNq9Lvpek0mi5lGzmDj+/DxRySLdPJbzzdCQIDAQABo4IBkTCCAY0w
+UgYIKwYBBQUHAQEERjBEMEIGCCsGAQUFBzAChjZodHRwOi8vY2VydC5wa2lvdmVy
+aGVpZC5ubC9Eb21Qcml2YXRlU2VydmljZXNDQS1HMS5jZXIwHQYDVR0OBBYEFLls
+phO6uy80Y4MxLvl+SR3fAPVjMBIGA1UdEwEB/wQIMAYBAf8CAQAwHwYDVR0jBBgw
+FoAUPq+oD4eiLEF7FGwb89to05KnRKgwJQYIKwYBBQUHAQMEGTAXMBUGCCsGAQUF
+BwsCMAkGBwQAi+xJAQIwXQYDVR0gBFYwVDAMBgpghBABh2sBAggEMAwGCmCEEAGH
+awECCAUwNgYKYIQQAYdrAQIIBjAoMCYGCCsGAQUFBwIBFhpodHRwczovL2Nwcy5w
+a2lvdmVyaGVpZC5ubDBNBgNVHR8ERjBEMEKgQKA+hjxodHRwOi8vY3JsLnBraW92
+ZXJoZWlkLm5sL0RvbVByaXZhdGVTZXJ2aWNlc0xhdGVzdENSTC1HMS5jcmwwDgYD
+VR0PAQH/BAQDAgEGMA0GCSqGSIb3DQEBCwUAA4ICAQCM9aEWB9EutS4/TKJ0hSrJ
+ljJSt0sAFxkoi6upCv7+C9Pjp+R5woGAwiBctbM5PyT+KpOKlDZKL3mrXSUc/71q
+NxsPlZR703c+HhlkvDCHbk9afrAXWtXz0sKVs8KaNS2W4k7O8xGNZVMjMwpanQdB
+csTnFPu12OTj8BCv4aOFxIYnPqPHkl8VTAi2pTArCtTQk9vi6QaXPzSmfi/rDINC
+JUAOnA3BEeZZI+BD8yCzE2x9M1N0AIn3UZRfVMfLJdI68a67lt3fLh2ZbLcjE0Pi
+4arBqxzFyKa1LyVsnA1Yg5UCZQh8U9l+5DS5dNS9lDVSBcd9iUio+lg8LvAQ7biz
++FFiLSqxVcWDuUg079d8JjPakm4JllmORlnSfWlcTHmgKmQOR0TgtXUL/7EDW2qb
+mRb5hUttT6ixBKnjtllnXmpOkx8hZn0F0hqjnIUsw8E0SdpYlrvIKszmowoKtZps
+zL/REVZybhfki5zj22GBMNBBP5MWTkltAZ8x2qu8iUw7MAUkBJy14cWmbqxue95J
+tT3a2/BnSMofYQNALQM4Ay9iZZyCUJIF/EYxg1OXmv65UthXpc4DdApICObyxY+/
+OABPJWHtxuG27SmMBx/MT3ZEs6vswVqGIsbPZydVSqerDskkP1AOl4iFEwmGOtfL
+B+VGn3werrg7IVfbCWEqdA==
+-----END CERTIFICATE-----
diff --git a/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt
new file mode 100644
index 0000000000..c53f9c8a70
--- /dev/null
+++ b/pki/cacerts/Sectigo Public Server Authentication CA EV R36.crt
@@ -0,0 +1,36 @@
+-----BEGIN CERTIFICATE-----
+MIIGSzCCBDOgAwIBAgIQbU98rTNTd8jG4AHd4uLIjjANBgkqhkiG9w0BAQwFADBf
+MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
+Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
+HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY
+MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp
+YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgRVYgUjM2MIIBojANBgkqhkiG9w0B
+AQEFAAOCAY8AMIIBigKCAYEApduwxkQH5noeb0k4yRXK47Fd+hVkH8twKWQF3FNm
+HHShQrOH7S0L3p8Aaf8P7OqojwyZgyqD5o/Sb95N45P/NfmL+lfAOkQ1Bpvz0OcZ
+VmwYhMWFYJe8parkz5w9Bk8mn/AtN65hIQF0NqBI6F+23/jzhlpl3E0zKkkUJSfT
+dgvwaJvTiPgLQ7CpYyLJvMpgdPHf+nnvA5YJjhae6olh6BRllvRhzKOq0gd60BrO
+Aos9QyOfYhUDG3eQovMA9fyiI2qNclCp6DY8hqt55lERoBS/Whza8Cx1bh6q8rTP
+nno2uI6FZb2UavIYblyGDamXAW/jv3qsMB42xz/p2mUHg0wQzn/8KAiyu2ZbDs7E
+OOwYx0V2ViNsCXGR/GCmJsUNPmhEEXBclOj6qF7rFf7nkST2bokSt9VF1NB1JgRl
+djZin1+v2Vm88UEgFxerQPPrPjAEMqDx44vKl22sGWnl+jOyxf/H59DHKbIezrIY
+rJpSnA3WEVaiXs1oIRy1ZziNAgMBAAGjggGAMIIBfDAfBgNVHSMEGDAWgBRWc1hk
+lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQUmC1eHo/rVPS5/1WVrUzHfqSYrnsw
+DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
+KwYBBQUHAwEGCCsGAQUFBwMCMBoGA1UdIAQTMBEwBgYEVR0gADAHBgVngQwBATBU
+BgNVHR8ETTBLMEmgR6BFhkNodHRwOi8vY3JsLnNlY3RpZ28uY29tL1NlY3RpZ29Q
+dWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYuY3JsMIGEBggrBgEFBQcB
+AQR4MHYwTwYIKwYBBQUHMAKGQ2h0dHA6Ly9jcnQuc2VjdGlnby5jb20vU2VjdGln
+b1B1YmxpY1NlcnZlckF1dGhlbnRpY2F0aW9uUm9vdFI0Ni5wN2MwIwYIKwYBBQUH
+MAGGF2h0dHA6Ly9vY3NwLnNlY3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4ICAQA2
+LRCehSXiyw56alKt2aqDwDLEDmyu74lUmDG4rI1Jw3+ivLz+OK3NpE466a8yXu7b
+f326wkrrOCF2SVu3ItNjXqsgwXdIM9tapVEKjsEwbjcPUcxNeqzSpLSgHZIVhjJJ
++QtdEL2n1NhDxqewlN+ZKWWBLGB0LA/S7SvCl9s7rsppo97uv6J3h//q4b2SgzHu
+KUu80Hofpjp2bILGHy4CqzKzyg3wn9sKFcX6Ufkcea/tuiWhIMPkiQCv5tXsRj+H
+n3CZql+wRPbjhNqncCkzrwOBrt7Gov46EHhqMUP/Euf8gSw7X+CzWEUJ0Q+Vn8VL
+ZDkiKcMSYMJI+6Trc5tVn9LQLI6tTzwagLhgcVtFpom3PVtEJRPN/d05L0ck7S1b
+xPaXcQhKUqdzFd09pt6fLx2vMmylBKXjKosSxjB12KRzLvI2CUUVdpl7OTFA32Fk
++zQC2gJPGmPPCzRMRYfVOEIZxbG0nI9lpYZidLkQ/EyTtJ3JVj7cb3KRe/e1s+Xj
+rnHqbUwoizicfogtcKm5giwj74f+Rpr0RRy4xum1bC662fNHRJaTt5ErVR6gHjHM
+Zhp+tOeOvohq5XxIMCKuetnwcPeBsOzUBzmUPoxQNRVXEYbwV2rS3t2jJ0Zh/Dnn
+O96biaAsEnxbQyu4fvMx6D4J4Zbkc7Zp75T3GE/UjQ==
+-----END CERTIFICATE-----
diff --git a/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt
new file mode 100644
index 0000000000..4440a7378d
--- /dev/null
+++ b/pki/cacerts/Sectigo Public Server Authentication CA OV R36.crt
@@ -0,0 +1,36 @@
+-----BEGIN CERTIFICATE-----
+MIIGTDCCBDSgAwIBAgIQLBo8dulD3d3/GRsxiQrtcTANBgkqhkiG9w0BAQwFADBf
+MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
+Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
+HhcNMjEwMzIyMDAwMDAwWhcNMzYwMzIxMjM1OTU5WjBgMQswCQYDVQQGEwJHQjEY
+MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTcwNQYDVQQDEy5TZWN0aWdvIFB1Ymxp
+YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gQ0EgT1YgUjM2MIIBojANBgkqhkiG9w0B
+AQEFAAOCAY8AMIIBigKCAYEApkMtJ3R06jo0fceI0M52B7K+TyMeGcv2BQ5AVc3j
+lYt76TvHIu/nNe22W/RJXX9rWUD/2GE6GF5x0V4bsY7K3IeJ8E7+KzG/TGboySfD
+u+F52jqQBbY62ofhYjMeiAbLI02+FqwHeM8uIrUtcX8b2RCxF358TB0NHVccAXZc
+FYgZndZCeXxjuca7pJJ20LLUnXtgXcjAE1vY4WvbReW0W6mkeZyNGdmpTcFs5Y+s
+yy6LtE5Zocji9J9NlNnReox2RWVyEXpA1ChZ4gqN+ZpVSIQ0HBorVFbBKyhdZyEX
+gZgNSNtBRwxqwIzJePJhYd4ZUhO1vk+/uP3nwDk0p95q/j7naXNCSvESnrHPypaB
+WRK066nKfPRPi9m9kIOhMdYfS8giFRTcdgL24Ycilj7ecAK9Trh0VbjwouJ4WH+x
+bt47u68ZFCD/ac55I0DNHkCpaPruj6e9Rmr7K46wZDAYXuEAqB7tGG/jd6JAA+H2
+O44CV98NRsU213f1kScIZntNAgMBAAGjggGBMIIBfTAfBgNVHSMEGDAWgBRWc1hk
+lfmSGrASKgRieaFAFYghSTAdBgNVHQ4EFgQU42Z0u3BojSxdTg6mSo+bNyKcgpIw
+DgYDVR0PAQH/BAQDAgGGMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYI
+KwYBBQUHAwEGCCsGAQUFBwMCMBsGA1UdIAQUMBIwBgYEVR0gADAIBgZngQwBAgIw
+VAYDVR0fBE0wSzBJoEegRYZDaHR0cDovL2NybC5zZWN0aWdvLmNvbS9TZWN0aWdv
+UHVibGljU2VydmVyQXV0aGVudGljYXRpb25Sb290UjQ2LmNybDCBhAYIKwYBBQUH
+AQEEeDB2ME8GCCsGAQUFBzAChkNodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3Rp
+Z29QdWJsaWNTZXJ2ZXJBdXRoZW50aWNhdGlvblJvb3RSNDYucDdjMCMGCCsGAQUF
+BzABhhdodHRwOi8vb2NzcC5zZWN0aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEA
+BZXWDHWC3cubb/e1I1kzi8lPFiK/ZUoH09ufmVOrc5ObYH/XKkWUexSPqRkwKFKr
+7r8OuG+p7VNB8rifX6uopqKAgsvZtZsq7iAFw04To6vNcxeBt1Eush3cQ4b8nbQR
+MQLChgEAqwhuXp9P48T4QEBSksYav7+aFjNySsLYlPzNqVM3RNwvBdvp6vgDtGwc
+xlKQZVuuNVIaoYyls8swhxDeSHKpRdxRauTLZ+pl+wGvy0pnrLEJGSz9mOEmfbod
+e/XopR2NGqaHJ6bIjyxPu6UtyQGI26En7UAEozACrHz06Nx2jTAY9E6NeB6XuobE
+wLK025ZRmvglcURG1BrV24tGHHTgxCe8M3oGlpUSMTKQ2dkgljZVYt+gKdFtWELZ
+MuRdi+X3XsrR8LFz+aLUiDRfQqhmw3RxjIyVKvvu9UPYY1nsvxYmFnUSeM+2q1z/
+iPUry+xDY9MC6+IhleKT094VKdFVp7LXH42+wvU+17lRolQ2mK2N/nBLVBwaIhib
+QXw4VYKwB86Bc6eS6iqsc94KEgD/U4VsjmgfhK+Xp4NM+VYzTTa3QeV3p8xOM0cw
+q1p8oZFA+OBcz3FYWpDIe5j0NWKlw9hXsTyPY/HeZUV59akskSOSRSmDfe8wJDPX
+58uB9/7lud0G3x0pxQAcffP0ayKavNwDTw4UfJ34cEw=
+-----END CERTIFICATE-----
diff --git a/pki/cacerts/Sectigo Public Server Authentication Root R46.crt b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt
new file mode 100644
index 0000000000..71afc161d9
--- /dev/null
+++ b/pki/cacerts/Sectigo Public Server Authentication Root R46.crt
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFijCCA3KgAwIBAgIQdY39i658BwD6qSWn4cetFDANBgkqhkiG9w0BAQwFADBf
+MQswCQYDVQQGEwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQD
+Ey1TZWN0aWdvIFB1YmxpYyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYw
+HhcNMjEwMzIyMDAwMDAwWhcNNDYwMzIxMjM1OTU5WjBfMQswCQYDVQQGEwJHQjEY
+MBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMTYwNAYDVQQDEy1TZWN0aWdvIFB1Ymxp
+YyBTZXJ2ZXIgQXV0aGVudGljYXRpb24gUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEB
+AQUAA4ICDwAwggIKAoICAQCTvtU2UnXYASOgHEdCSe5jtrch/cSV1UgrJnwUUxDa
+ef0rty2k1Cz66jLdScK5vQ9IPXtamFSvnl0xdE8H/FAh3aTPaE8bEmNtJZlMKpnz
+SDBh+oF8HqcIStw+KxwfGExxqjWMrfhu6DtK2eWUAtaJhBOqbchPM8xQljeSM9xf
+iOefVNlI8JhD1mb9nxc4Q8UBUQvX4yMPFF1bFOdLvt30yNoDN9HWOaEhUTCDsG3X
+ME6WW5HwcCSrv0WBZEMNvSE6Lzzpng3LILVCJ8zab5vuZDCQOc2TZYEhMbUjUDM3
+IuM47fgxMMxF/mL50V0yeUKH32rMVhlATc6qu/m1dkmU8Sf4kaWD5QazYw6A3OAS
+VYCmO2a0OYctyPDQ0RTp5A1NDvZdV3LFOxxHVp3i1fuBYYzMTYCQNFu31xR13NgE
+SJ/AwSiItOkcyqex8Va3e0lMWeUgFaiEAin6OJRpmkkGj80feRQXEgyDet4fsZfu
++Zd4KKTIRJLpfSYFplhym3kT2BFfrsU4YjRosoYwjviQYZ4ybPUHNs2iTG7sijbt
+8uaZFURww3y8nDnAtOFr94MlI1fZEoDlSfB1D++N6xybVCi0ITz8fAr/73trdf+L
+HaAZBav6+CuBQug4urv7qv094PPK306Xlynt8xhW6aWWrL3DkJiy4Pmi1KZHQ3xt
+zwIDAQABo0IwQDAdBgNVHQ4EFgQUVnNYZJX5khqwEioEYnmhQBWIIUkwDgYDVR0P
+AQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQEMBQADggIBAC9c
+mTz8Bl6MlC5w6tIyMY208FHVvArzZJ8HXtXBc2hkeqK5Duj5XYUtqDdFqij0lgVQ
+YKlJfp/imTYpE0RHap1VIDzYm/EDMrraQKFz6oOht0SmDpkBm+S8f74TlH7Kph52
+gDY9hAaLMyZlbcp+nv4fjFg4exqDsQ+8FxG75gbMY/qB8oFM2gsQa6H61SilzwZA
+Fv97fRheORKkU55+MkIQpiGRqRxOF3yEvJ+M0ejf5lG5Nkc/kLnHvALcWxxPDkjB
+JYOcCj+esQMzEhonrPcibCTRAUH4WAP+JWgiH5paPHxsnnVI84HxZmduTILA7rpX
+DhjvLpr3Etiga+kFpaHpaPi8TD8SHkXoUsCjvxInebnMMTzD9joiFgOgyY9mpFui
+TdaBJQbpdqQACj7LzTWb4OE4y2BThihCQRxEV+ioratF4yUQvNs+ZUH7G6aXD+u5
+dHn5HrwdVw1Hr8Mvn4dGp+smWg9WY7ViYG4A++MnESLn/pmPNPW56MORcr3Ywx65
+LvKRRFHQV80MNNVIIb/bE/FmJUNS0nAiNs2fxBx1IK1jcmMGDw4nztJqDby1ORrp
+0XZ60Vzk50lJLVU3aPAaOpg+VBeHVOmmJ1CJeyAvP/+/oYtKR5j/K3tJPsMpRmAY
+QqszKbrAKbkTidOIijlBO8n9pu0f9GBj39ItVQGL
+-----END CERTIFICATE-----
diff --git a/pki/cacerts/Staat der Nederlanden Private Root CA - G1.crt b/pki/cacerts/Staat der Nederlanden Private Root CA - G1.crt
new file mode 100644
index 0000000000..6e4740ae62
--- /dev/null
+++ b/pki/cacerts/Staat der Nederlanden Private Root CA - G1.crt
@@ -0,0 +1,32 @@
+-----BEGIN CERTIFICATE-----
+MIIFhDCCA2ygAwIBAgIEAJimITANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJO
+TDEeMBwGA1UECgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMTMwMQYDVQQDDCpTdGFh
+dCBkZXIgTmVkZXJsYW5kZW4gUHJpdmF0ZSBSb290IENBIC0gRzEwHhcNMTMxMTE0
+MTM0ODU1WhcNMjgxMTEzMjMwMDAwWjBiMQswCQYDVQQGEwJOTDEeMBwGA1UECgwV
+U3RhYXQgZGVyIE5lZGVybGFuZGVuMTMwMQYDVQQDDCpTdGFhdCBkZXIgTmVkZXJs
+YW5kZW4gUHJpdmF0ZSBSb290IENBIC0gRzEwggIiMA0GCSqGSIb3DQEBAQUAA4IC
+DwAwggIKAoICAQDaIMh56ynwnEhE7Ey54KpX5j1XDoxbHDCgXctute55RjmG2hy6
+fuq++q/dCSsj38Pi/KYn/PN13EF05k39IRvakb0AQNVyHifNKXfta6Tzi5QcM4BK
+09DB4Ckb6TdZTNUtWyEcAtRblYaVSQ4Xr5QODNqu2FGQucraVXqCIx81azlOE2Jb
+Zli9AZKn94pP57A11dUYhxMsh70YosJEKVB8Ue4ROksHhb/nnOISG+2y9FD5M8u8
+jYhp00TGZGVu5z0IFgtqX0i8GmrH0ub9AWjf/iU4MWjGVRSq0cwUHEeKRj/UD9a8
+xIEn9TxIfYj+6+s4tn9dW/4PV5jc6iGJx6ExTPfOR7VHpxS4XujrZb5Ba/+oj/ON
+dOfR0JSm2itCytbtjQBBL0oocIIqaqOna1cufHkcn9VleF7Zvz/8njQIpAU4J4nJ
+4pE5pQ3k4ORAGNnq5R9hAqqUQGDlo3Uj8PBou0nPzQ7JNgGkN+my/lGr4rceUNK/
+8CoGnYFUH+UyFtJkvlLlEkb688/IdNdGgY+vuXCAB6xfKlJjAGChFUBb6swbNeNc
+tVEdUj7Weg4Jt5gXu78C2mjs9x5lcHOgMO4ZmvYJ3Ejp4k3nNa45HOIVkYrfQrrB
+HzBhR0BuReAagurcbtUjJFd7BtufGVLfU3CUn1l6u3/9eG4DGH6pq+dSKQIDAQAB
+o0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU
+Kv25Kx76w4SHBtuB/4aXdQ3rAYswDQYJKoZIhvcNAQELBQADggIBAEvpmXMOOKdQ
+wUPysrsdIkGJUFF+dvmsJDiOuAqV0A1nNTooL3esvDLEZAWZwKTOwRomnHzeCfS/
+QxRKTkVX21pfrHf9ufDKykpzjl9uAILTS76FJ6//R0RTIPMrzknQpG2fCLR5DFEb
+HWU/jWAxGmncfx6HQYl/azHaWbv0dhZOUjPdkGAQ6EPvHcyNU9yMkETdw0X6ioxq
+zMwkGM893oBrMmtduiqIf3/H6HTXoRKAc+/DXZIq/pAc6eVMa6x43kokluaam9L7
+8yDrlHbGd2VYAr/HZ0TjDZTtI2t2/ySTb7JjC8wL8rSqxYmLpNrnhZzPW87sl2OC
+FC3re3ZhtJkIHNP85jj1gqewTC7DCW6llZdB3hBzfHWby0EX2RlcwgaMfNBEV5U0
+IogccdXV+S6zWK4F+yBr0sXUrdbdMFu+g3I9CbXxt0q4eVJtoaun4M2Z+bZMqZvy
+9FryBdSfhpgmJqwFz2luOhPOVCblCPhLrUeewrvuBXoZQWt1ZjuHfwJZ1dgjszVE
+qwY9S0SdqCg2ZlL9s3vDIrrd3wLWrcHLQMd9gwsppNv9c7JfIJdlcZLTmF9EuL6e
+CvVVrqBVqLHjva4erqYol6K/jbSfUtRCy8IlFU7LYu1KLehZKYvj3vekj3Cn08Aq
+ljr/Q8Pw+OfUZTzKg4PVDQVfFqKtyosv
+-----END CERTIFICATE-----
diff --git a/pki/cacerts/Staat der Nederlanden Private Services CA - G1.crt b/pki/cacerts/Staat der Nederlanden Private Services CA - G1.crt
new file mode 100644
index 0000000000..b029d6b0ac
--- /dev/null
+++ b/pki/cacerts/Staat der Nederlanden Private Services CA - G1.crt
@@ -0,0 +1,38 @@
+-----BEGIN CERTIFICATE-----
+MIIGoTCCBImgAwIBAgIEAJimiDANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJO
+TDEeMBwGA1UECgwVU3RhYXQgZGVyIE5lZGVybGFuZGVuMTMwMQYDVQQDDCpTdGFh
+dCBkZXIgTmVkZXJsYW5kZW4gUHJpdmF0ZSBSb290IENBIC0gRzEwHhcNMTMxMTE1
+MTAwNDEzWhcNMjgxMTEyMjMwMDAwWjBmMQswCQYDVQQGEwJOTDEeMBwGA1UECgwV
+U3RhYXQgZGVyIE5lZGVybGFuZGVuMTcwNQYDVQQDDC5TdGFhdCBkZXIgTmVkZXJs
+YW5kZW4gUHJpdmF0ZSBTZXJ2aWNlcyBDQSAtIEcxMIICIjANBgkqhkiG9w0BAQEF
+AAOCAg8AMIICCgKCAgEAvwOIqjkS4zOz5/8mp/xJ0N2031fQVS53AwimkLBrkqut
+ypo0dZKyn4JEoNs2KmwdjMNC4TWWn8aagdAzL0e8pIdJwuswDb2S5yKRCs1cvaTH
+op+ueVc8QoQbiQXx25ryJn0c+Se3DTo+0DGljnf+UXUqoDSnV0upptEt+yM7m17W
+ndBUzOUk/hh6dw63gkHSJn9fiN/Apd5iRH+fnAr6AE6uKcSYtjkrr4mt64WJCLFM
+9aISg3eplf3Gqv7Pzr7AVxr/VAMuSLz9EQdMveOYEqzjxDZJptvrGAmfFMYPkEDC
+yXPXF8Njbh5CVdCiaJye2dJXvYwnUtTCBs8Qbcf7Fzl5NFKEB7bvmY85kGZV5SJd
+3EPbSiNUtMZ2Dx5trC9NxobI4iOIdzIGx8t+lZ2hOSrBhidgXBa2AkbxqdVl+ibY
+wI2FOtdXg/EQzps2owvQST4uQAqthHsR2bGcAoKASXNSuBNrzUz0eu84k/Si/aPy
+Q1Yo3AkqC5qkX6QQP5JzjrTXTcoxsKYsqNdxyACA80/D0RXX5yNTMfMoAPl/n1Ax
+D8h1LB1tf3uiDImTkfU0Fjorqe7oTJdtWQLep+e+CVcDpuSzssk+6aKZnKESOzv6
+S+V85Z+iHfELSRku64dMZL6i4Yzr0MPidxwAvZGg7nuf48ruKeyE8C81V/fT2RcC
+AwEAAaOCAVkwggFVMA8GA1UdEwEB/wQFMAMBAf8wXQYDVR0gBFYwVDAMBgpghBAB
+h2sBAggEMAwGCmCEEAGHawECCAUwNgYKYIQQAYdrAQIIBjAoMCYGCCsGAQUFBwIB
+FhpodHRwczovL2Nwcy5wa2lvdmVyaGVpZC5ubDBLBggrBgEFBQcBAQQ/MD0wOwYI
+KwYBBQUHMAKGL2h0dHA6Ly9jZXJ0LnBraW92ZXJoZWlkLm5sL1ByaXZhdGVSb290
+Q0EtRzEuY2VyMA4GA1UdDwEB/wQEAwIBBjAfBgNVHSMEGDAWgBQq/bkrHvrDhIcG
+24H/hpd1DesBizBGBgNVHR8EPzA9MDugOaA3hjVodHRwOi8vY3JsLnBraW92ZXJo
+ZWlkLm5sL1ByaXZhdGVSb290TGF0ZXN0Q1JMLUcxLmNybDAdBgNVHQ4EFgQUPq+o
+D4eiLEF7FGwb89to05KnRKgwDQYJKoZIhvcNAQELBQADggIBAD679+kWzn1Qf9Nv
+GMlO+HFSTXmJ1M5/SMnbaXJCa1EHMzL7KErSc5BnQcwjV4HCrtQB9hRc6tzso2tE
+PAj9ytopNIvCelnW9ooYQ7wLuNErZU+LNYyOad7+pP+YSmwsIKF8wSBjz6IFgC+9
+NtWjNL00enDXPo0/gcVTAuwUuk2qXH4qhqU3IhnstHWgnlOH+dS1UsJR1Za+JTFa
+9mxy9vDC/D/MbFYcUd88M3QZ1aadUjZAbLR6dhZGsuFJSqX2Ck7F1ZNt48Hd2uB1
+DRYBedT7w+D/lKoJMz5XsYAVZx7yon3aohz+7SiuaQrvB6zo8HkMTRDGR1SKvguc
+yposE1o3SCkutP34xNvAG3Ykuop9J4vjdfmJ5G22eHwclbi037jGc2xv64KSanS9
+09VDFO4KCrKICwEUG4n36Dg8gEdYNLukZBvnH9LPdVao7cl8S6Clpd1JTTnMl1KD
+IVjICvggNklD0A+9Zl6o25ongji1F9SGLVy9LHkmymGybAvWvYTZJaOmzYtdxwih
+KxywNovJruAnD37+T63lt5S/FSpLSjOfKj0dvBxCPPeNs2B418/A3HTGz63e2dCV
+T+QOH14xfKJ+k9/ZFr5XuhkingMUG6vZeND4HWVJzc4mq6Ic27QKS9ulmcUdYos+
+0nOnmkmh5knv11C1n8YATbQjZh0i
+-----END CERTIFICATE-----
diff --git a/vcr/cmd/cmd.go b/vcr/cmd/cmd.go
index 01db27af37..5a0d8ffc5e 100644
--- a/vcr/cmd/cmd.go
+++ b/vcr/cmd/cmd.go
@@ -21,11 +21,12 @@ package cmd
import (
"encoding/json"
"fmt"
+ "strings"
+ "time"
+
"github.com/nuts-foundation/nuts-node/vcr"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/spf13/pflag"
- "strings"
- "time"
"github.com/nuts-foundation/nuts-node/core"
api "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2"
@@ -40,6 +41,8 @@ func FlagSet() *pflag.FlagSet {
flagSet.String("vcr.openid4vci.definitionsdir", defs.OpenID4VCI.DefinitionsDIR, "Directory with the additional credential definitions the node could issue (experimental, may change without notice).")
flagSet.Bool("vcr.openid4vci.enabled", defs.OpenID4VCI.Enabled, "Enable issuing and receiving credentials over OpenID4VCI.")
flagSet.Duration("vcr.openid4vci.timeout", time.Second*30, "Time-out for OpenID4VCI HTTP client operations.")
+ flagSet.Duration("vcr.verifier.revocation.maxage", time.Minute*15, "Max age of revocation information. If the revocation information is older than this, it will be refreshed from the issuer. If set to 0 or negative, revocation information will always be refreshed.")
+ flagSet.StringSlice("vcr.dezi.allowedjku", defs.Dezi.AllowedJKU, "List of allowed JKU URLs for fetching Dezi attestation keys. If not set, defaults to production (https://auth.dezi.nl/dezi/jwks.json), and in non-strict mode also acceptance (https://acceptatie.auth.dezi.nl/dezi/jwks.json).")
return flagSet
}
diff --git a/vcr/config.go b/vcr/config.go
index 614ddf7291..b87952e338 100644
--- a/vcr/config.go
+++ b/vcr/config.go
@@ -20,8 +20,9 @@
package vcr
import (
- "github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"time"
+
+ "github.com/nuts-foundation/nuts-node/vcr/openid4vci"
)
// ModuleName is the name of this module.
@@ -31,6 +32,23 @@ const ModuleName = "VCR"
type Config struct {
// OpenID4VCI holds the config for the OpenID4VCI credential issuer and wallet
OpenID4VCI openid4vci.Config `koanf:"openid4vci"`
+ Verifier VerifierConfig `koanf:"verifier"`
+ Dezi DeziConfig `koanf:"dezi"`
+}
+
+type VerifierConfig struct {
+ Revocation VerifierRevocationConfig `koanf:"revocation"`
+}
+
+type VerifierRevocationConfig struct {
+ MaxAge time.Duration `koanf:"maxage"`
+}
+
+type DeziConfig struct {
+ // AllowedJKU contains the list of JKU URLs from which Dezi attestation keys are allowed to be fetched.
+ // If not configured, defaults to production environment (https://auth.dezi.nl/dezi/jwks.json).
+ // In non-strict mode, acceptance environment is also allowed (https://acceptatie.auth.dezi.nl/dezi/jwks.json).
+ AllowedJKU []string `koanf:"allowedjku"`
}
// DefaultConfig returns a fresh Config filled with default values
diff --git a/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf
new file mode 100644
index 0000000000..aa7c8bb645
Binary files /dev/null and b/vcr/credential/Koppelvlakspecificatie_2024-DEZI-Online+koppelvlak+1_+platformleverancier.pdf differ
diff --git a/vcr/credential/cert.pem b/vcr/credential/cert.pem
new file mode 100644
index 0000000000..2d67b94830
--- /dev/null
+++ b/vcr/credential/cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV
+UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y
+NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt
+cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv
+0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN
+K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj
+L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc
+4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd
+6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa
+DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX
+FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts
+dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL
+zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk
+6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb
+VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT
+16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK
+F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an
+WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ
+4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW
+D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo=
+-----END CERTIFICATE-----
\ No newline at end of file
diff --git a/vcr/credential/dezi.go b/vcr/credential/dezi.go
new file mode 100644
index 0000000000..b887b1e996
--- /dev/null
+++ b/vcr/credential/dezi.go
@@ -0,0 +1,490 @@
+package credential
+
+import (
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "reflect"
+ "slices"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core"
+)
+
+const DeziIDJWT07ProofType = "DeziIDJWT07"
+const DeziIDJWT2024ProofType = "DeziIDJWT2024"
+
+func DeziIDJWTProofTypes() []string {
+ return []string{DeziIDJWT07ProofType, DeziIDJWT2024ProofType}
+}
+
+type DeziIDTokenSubject struct {
+ Identifier string `json:"identifier"`
+ Name string `json:"name,omitempty"`
+ Employee HealthcareWorker `json:"employee"`
+}
+
+func (d DeziIDTokenSubject) MarshalJSON() ([]byte, error) {
+ type Alias DeziIDTokenSubject
+ aux := struct {
+ Alias
+ Type string `json:"@type"`
+ }{
+ Alias: Alias(d),
+ Type: "DeziIDTokenSubject",
+ }
+ return json.Marshal(aux)
+}
+
+type HealthcareWorker struct {
+ Identifier string `json:"identifier"`
+ Initials string `json:"initials"`
+ SurnamePrefix string `json:"surnamePrefix"`
+ Surname string `json:"surname"`
+ Role string `json:"role,omitempty"`
+ RoleRegistry string `json:"role_registry,omitempty"`
+ RoleName string `json:"role_name,omitempty"`
+}
+
+func (d HealthcareWorker) MarshalJSON() ([]byte, error) {
+ type Alias HealthcareWorker
+ aux := struct {
+ Alias
+ Type string `json:"@type"`
+ }{
+ Alias: Alias(d),
+ Type: "HealthcareWorker",
+ }
+ return json.Marshal(aux)
+}
+
+// CreateDeziUserCredential creates a Verifiable Credential from a Dezi id_token JWT. It supports the following spec versions:
+// - april 2024
+// - 15 jan 2026/v0.7: https://www.dezi.nl/documenten/2024/04/24/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers
+func CreateDeziUserCredential(idTokenSerialized string) (*vc.VerifiableCredential, error) {
+ // Parse without signature or time validation - those are validated elsewhere
+ idToken, err := jwt.Parse([]byte(idTokenSerialized), jwt.WithVerify(false), jwt.WithValidate(false))
+ if err != nil {
+ return nil, fmt.Errorf("parsing id_token: %w", err)
+ }
+
+ subject, version, err := extractDeziIDTokenSubject(idToken)
+ if err != nil {
+ return nil, err
+ }
+
+ // Determine proof type based on version
+ var proofTypeName string
+ switch version {
+ case "2024":
+ proofTypeName = DeziIDJWT2024ProofType
+ case "0.7":
+ proofTypeName = DeziIDJWT07ProofType
+ default:
+ return nil, fmt.Errorf("unsupported Dezi id_token version: %s", version)
+ }
+
+ credentialMap := map[string]any{
+ "@context": []any{
+ "https://www.w3.org/2018/credentials/v1",
+ // TODO: Create JSON-LD context?
+ },
+ "type": []string{"VerifiableCredential", "DeziUserCredential"},
+ "id": idToken.JwtID(),
+ "issuer": idToken.Issuer(),
+ "issuanceDate": idToken.NotBefore().Format(time.RFC3339Nano),
+ "expirationDate": idToken.Expiration().Format(time.RFC3339Nano),
+ "credentialSubject": subject,
+ "proof": deziProofType{
+ Type: proofTypeName,
+ JWT: idTokenSerialized,
+ },
+ }
+ data, _ := json.Marshal(credentialMap)
+ return vc.ParseVerifiableCredential(string(data))
+}
+
+var _ Validator = DeziUserCredentialValidator{}
+
+type DeziUserCredentialValidator struct {
+ trustStore *core.TrustStore
+ // AllowedJKU is a list of allowed jku URLs for fetching JWK Sets (for v0.7 tokens), used to verify Dezi attestations.
+ AllowedJKU []string
+}
+
+func (d DeziUserCredentialValidator) Validate(credential vc.VerifiableCredential) error {
+ _, version, err := parseDeziProofType(credential)
+ if err != nil {
+ return err
+ }
+ switch version {
+ case "2024":
+ return deziIDToken2024CredentialValidator{
+ clock: time.Now,
+ trustStore: d.trustStore,
+ }.Validate(credential)
+ case "0.7":
+ return deziIDToken07CredentialValidator{
+ clock: time.Now,
+ allowedJKU: d.AllowedJKU,
+ }.Validate(credential)
+ default:
+ return fmt.Errorf("%w: unsupported Dezi id_token version: %s", errValidation, version)
+ }
+}
+
+var _ Validator = deziIDToken2024CredentialValidator{}
+
+// deziIDToken2024CredentialValidator validates DeziIDTokenCredential,
+// according to spec of april 2024 (uses x5c in JWT payload instead of jku header)
+type deziIDToken2024CredentialValidator struct {
+ clock func() time.Time
+ trustStore *core.TrustStore
+}
+
+func (d deziIDToken2024CredentialValidator) Validate(credential vc.VerifiableCredential) error {
+ proof, _, err := parseDeziProofType(credential)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errValidation, err)
+ }
+
+ idToken, err := d.validateIDToken(credential, proof.JWT)
+ if err != nil {
+ return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err)
+ }
+
+ // Validate that token timestamps match credential dates
+ if !idToken.NotBefore().Equal(credential.IssuanceDate) {
+ return errors.New("id_token 'nbf' does not match credential 'issuanceDate'")
+ }
+ if !idToken.Expiration().Equal(*credential.ExpirationDate) {
+ return errors.New("id_token 'exp' does not match credential 'expirationDate'")
+ }
+
+ // Validate that the
+
+ return (defaultCredentialValidator{}).Validate(credential)
+}
+
+func (d deziIDToken2024CredentialValidator) validateIDToken(credential vc.VerifiableCredential, serialized string) (jwt.Token, error) {
+ // Parse without verification first to extract x5c from payload
+ token, err := jwt.Parse([]byte(serialized), jwt.WithVerify(false), jwt.WithValidate(false))
+ if err != nil {
+ return nil, fmt.Errorf("parse JWT: %w", err)
+ }
+
+ // After signature has been validated, token can be considered a valid JWT
+ err = d.validateSignature(token, err, serialized)
+ if err != nil {
+ return nil, fmt.Errorf("signature: %w", err)
+ }
+ return token, nil
+}
+
+func (d deziIDToken2024CredentialValidator) validateSignature(token jwt.Token, err error, serialized string) error {
+ // Extract x5c claim from payload (not header - this is non-standard but per 2024 spec)
+ x5cRaw, ok := token.Get("x5c")
+ if !ok {
+ return errors.New("missing 'x5c' claim in JWT payload")
+ }
+
+ var x5c []any
+ switch v := x5cRaw.(type) {
+ case []any:
+ x5c = v
+ case string:
+ x5c = []any{v}
+ default:
+ return errors.New("'x5c' claim must be either a string or an array of strings")
+ }
+
+ // Parse the certificate chain
+ var certChain [][]byte
+ for i, certData := range x5c {
+ certStr, ok := certData.(string)
+ if !ok {
+ return fmt.Errorf("'x5c[%d]' must be a string", i)
+ }
+ // x5c contains base64-encoded DER certificates
+ certBytes, err := base64.StdEncoding.DecodeString(certStr)
+ if err != nil {
+ return fmt.Errorf("decode 'x5c[%d]': %w", i, err)
+ }
+ certChain = append(certChain, certBytes)
+ }
+
+ if len(certChain) == 0 {
+ return errors.New("'x5c' certificate chain is empty")
+ }
+
+ // Parse the leaf certificate (first in chain)
+ leafCert, err := x509.ParseCertificate(certChain[0])
+ if err != nil {
+ return fmt.Errorf("parse signing certificate: %w", err)
+ }
+
+ _, err = leafCert.Verify(x509.VerifyOptions{
+ Roots: core.NewCertPool(d.trustStore.RootCAs),
+ CurrentTime: d.clock(),
+ Intermediates: core.NewCertPool(d.trustStore.IntermediateCAs),
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny}, // TODO: use more specific key usage if possible
+ })
+ if err != nil {
+ return fmt.Errorf("verify Dezi certificate chain: %w", err)
+ }
+
+ // Verify the JWT signature using the leaf certificate's public key
+ _, err = jwt.Parse([]byte(serialized), jwt.WithKey(jwa.RS256, leafCert.PublicKey), jwt.WithValidate(true), jwt.WithClock(jwt.ClockFunc(d.clock)))
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+// deziIDToken07CredentialValidator validates DeziUserCredential,
+// according to v0.7 spec of 15-01-2026 (https://www.dezi.nl/documenten/2025/12/15/koppelvlakspecificatie-dezi-voor-platform--en-softwareleveranciers)
+type deziIDToken07CredentialValidator struct {
+ clock func() time.Time
+ httpClient *http.Client // Optional HTTP client for fetching JWK Set (for testing)
+ allowedJKU []string // List of allowed jku URLs
+}
+
+func (d deziIDToken07CredentialValidator) Validate(credential vc.VerifiableCredential) error {
+ proof, _, err := parseDeziProofType(credential)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errValidation, err)
+ }
+ if err := d.validateDeziToken(credential, proof.JWT); err != nil {
+ return fmt.Errorf("%w: invalid Dezi id_token: %w", errValidation, err)
+ }
+ return (defaultCredentialValidator{}).Validate(credential)
+}
+
+func (d deziIDToken07CredentialValidator) validateDeziToken(credential vc.VerifiableCredential, serialized string) error {
+ // Parse and verify the JWT
+ // - WithVerifyAuto(nil, ...) uses default jwk.Fetch and automatically fetches the JWK Set from the jku header URL
+ // - WithFetchWhitelist allows fetching from any https:// URL (Dezi endpoints)
+ // - WithHTTPClient allows using a custom HTTP client (for testing with self-signed certs)
+ fetchOptions := []jwk.FetchOption{jwk.WithFetchWhitelist(jwk.WhitelistFunc(func(requestedURL string) bool {
+ return slices.Contains(d.allowedJKU, requestedURL)
+ }))}
+ if d.httpClient != nil {
+ fetchOptions = append(fetchOptions, jwk.WithHTTPClient(d.httpClient))
+ }
+
+ // TODO: Only allow specific domains for the jku
+ // TODO: make sure it's signed with a jku
+ token, err := jwt.Parse(
+ []byte(serialized),
+ jwt.WithVerifyAuto(nil, fetchOptions...),
+ jwt.WithClock(jwt.ClockFunc(d.clock)),
+ )
+ if err != nil {
+ return fmt.Errorf("failed to verify JWT signature: %w", err)
+ }
+
+ // Validate that token timestamps match credential dates
+ if !token.NotBefore().Equal(credential.IssuanceDate) {
+ return errors.New("'nbf' does not match credential 'issuanceDate'")
+ }
+ if !token.Expiration().Equal(*credential.ExpirationDate) {
+ return errors.New("'exp' does not match credential 'expirationDate'")
+ }
+
+ var credentialSubject []DeziIDTokenSubject
+ if err = credential.UnmarshalCredentialSubject(&credentialSubject); err != nil {
+ return fmt.Errorf("invalid credential subject format: %w", err)
+ }
+ if len(credentialSubject) != 1 {
+ return fmt.Errorf("expected exactly one credential subject, got %d", len(credentialSubject))
+ }
+
+ subjectFromToken, _, err := extractDeziIDTokenSubject(token)
+ if err != nil {
+ return fmt.Errorf("invalid id_token claims: %w", err)
+ }
+ if !reflect.DeepEqual(credentialSubject[0], subjectFromToken) {
+ return errors.New("credential subject does not match id_token claims")
+ }
+
+ // TODO: check id_token revocation
+ return nil
+}
+
+type deziProofType struct {
+ Type string `json:"type"`
+ JWT string `json:"jwt"`
+}
+
+func parseDeziProofType(credential vc.VerifiableCredential) (*deziProofType, string, error) {
+ var proofs []deziProofType
+ if err := credential.UnmarshalProofValue(&proofs); err != nil {
+ return nil, "", fmt.Errorf("invalid proof format: %w", err)
+ }
+ if len(proofs) != 1 {
+ return nil, "", fmt.Errorf("expected exactly one proof, got %d", len(proofs))
+ }
+ proof := &proofs[0]
+
+ // Derive version from proof type
+ var version string
+ switch proof.Type {
+ case "DeziIDJWT2024":
+ version = "2024"
+ case "DeziIDJWT07":
+ version = "0.7"
+ default:
+ return nil, "", fmt.Errorf("invalid proof type: expected 'DeziIDJWT2024' or 'DeziIDJWT07', got '%s'", proof.Type)
+ }
+
+ return proof, version, nil
+}
+
+// extractDeziIDTokenSubject extracts and validates the subject information from a Dezi id_token JWT.
+// It returns the DeziIDTokenSubject, the detected version ("2024" or "0.7"), and any error encountered.
+func extractDeziIDTokenSubject(idToken jwt.Token) (DeziIDTokenSubject, string, error) {
+ // Check if this is v0.7 format (has abonnee_nummer) or 2024 format (has relations)
+ var version string
+ {
+ _, hasRelations := idToken.Get("relations")
+ if hasRelations {
+ version = "2024"
+ } else {
+ version = "0.7"
+ }
+ }
+
+ switch version {
+ case "0.7":
+ return extractDezi07Subject(idToken)
+ case "2024":
+ return extractDezi2024Subject(idToken)
+ default:
+ return DeziIDTokenSubject{}, "", fmt.Errorf("unsupported Dezi id_token version: %s", version)
+ }
+}
+
+// extractDezi07Subject extracts the subject from a v0.7 Dezi id_token
+func extractDezi07Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) {
+ getString := func(claim string) string {
+ value, ok := idToken.Get(claim)
+ if !ok {
+ return ""
+ }
+ result, _ := value.(string)
+ return result
+ }
+
+ orgURA := getString("abonnee_nummer")
+ if orgURA == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_nummer' claim")
+ }
+ orgName := getString("abonnee_naam")
+ if orgName == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'abonnee_naam' claim")
+ }
+
+ userID := getString("dezi_nummer")
+ if userID == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim")
+ }
+ initials := getString("voorletters")
+ if initials == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'voorletters' claim")
+ }
+ surname := getString("achternaam")
+ if surname == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'achternaam' claim")
+ }
+ surnamePrefix := getString("voorvoegsel") // Can be null/empty in v0.7
+
+ role := getString("rol_code")
+ roleRegistry := getString("rol_code_bron")
+ roleName := getString("rol_naam")
+
+ return DeziIDTokenSubject{
+ Identifier: orgURA,
+ Name: orgName,
+ Employee: HealthcareWorker{
+ Identifier: userID,
+ Initials: initials,
+ SurnamePrefix: surnamePrefix,
+ Surname: surname,
+ Role: role,
+ RoleRegistry: roleRegistry,
+ RoleName: roleName,
+ },
+ }, "0.7", nil
+}
+
+// extractDezi2024Subject extracts the subject from a 2024 Dezi id_token
+func extractDezi2024Subject(idToken jwt.Token) (DeziIDTokenSubject, string, error) {
+ getString := func(claim string) string {
+ value, ok := idToken.Get(claim)
+ if !ok {
+ return ""
+ }
+ result, _ := value.(string)
+ return result
+ }
+
+ relationsRaw, _ := idToken.Get("relations")
+ relations, ok := relationsRaw.([]any)
+ if !ok || len(relations) != 1 {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)")
+ }
+ relation, ok := relations[0].(map[string]any)
+ if !ok {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations' claim invalid or missing (expected array of objects with single item)")
+ }
+
+ orgURA, ok := relation["ura"].(string)
+ if !ok || orgURA == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token 'relations[0].ura' claim invalid or missing (expected non-empty string)")
+ }
+ orgName, _ := relation["entity_name"].(string)
+
+ userID := getString("dezi_nummer")
+ if userID == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'dezi_nummer' claim")
+ }
+ initials := getString("initials")
+ if initials == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'initials' claim")
+ }
+ surname := getString("surname")
+ if surname == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname' claim")
+ }
+ surnamePrefix := getString("surname_prefix")
+ if surnamePrefix == "" {
+ return DeziIDTokenSubject{}, "", fmt.Errorf("id_token missing 'surname_prefix' claim")
+ }
+
+ // In 2024 format, roles is an array - we'll take the first role for now
+ // TODO: Clarify how to handle multiple roles
+ var role string
+ rolesAny, ok := relation["roles"].([]any)
+ if ok && len(rolesAny) > 0 {
+ role, _ = rolesAny[0].(string)
+ }
+
+ return DeziIDTokenSubject{
+ Identifier: orgURA,
+ Name: orgName,
+ Employee: HealthcareWorker{
+ Identifier: userID,
+ Initials: initials,
+ SurnamePrefix: surnamePrefix,
+ Surname: surname,
+ Role: role,
+ },
+ }, "2024", nil
+}
diff --git a/vcr/credential/dezi_test.go b/vcr/credential/dezi_test.go
new file mode 100644
index 0000000000..d320d66f16
--- /dev/null
+++ b/vcr/credential/dezi_test.go
@@ -0,0 +1,409 @@
+package credential
+
+import (
+ "bytes"
+ "crypto/tls"
+ "crypto/x509"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jwk"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+ "github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core"
+ "github.com/nuts-foundation/nuts-node/core/to"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// stubbedRoundTripper is a test helper that returns a mock JWK Set for any HTTP request
+type stubbedRoundTripper struct {
+ keySets map[string]jwk.Set
+}
+
+func (s *stubbedRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
+ keySet, ok := s.keySets[req.URL.String()]
+ if !ok {
+ return &http.Response{
+ StatusCode: http.StatusNotFound,
+ Body: io.NopCloser(bytes.NewReader([]byte(`{"error": "not found"}`))),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+ }
+
+ // Marshal the key set to JSON
+ jwksJSON, err := json.Marshal(keySet)
+ if err != nil {
+ return nil, err
+ }
+
+ // Return a mock HTTP response with the JWK Set
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Body: io.NopCloser(bytes.NewReader(jwksJSON)),
+ Header: http.Header{"Content-Type": []string{"application/json"}},
+ }, nil
+}
+
+func TestCreateDeziIDToken(t *testing.T) {
+ t.Run("version 0.7", func(t *testing.T) {
+ const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6IjMyNWRlOWFiLTQzMzAtNGMwMS04MjRlLWQ5YmQwYzM3Y2NhMCIsImprdSI6Imh0dHBzOi8vW2V4dGVybiBlbmRwb2ludF0vandrcy5qc29uIiwidHlwIjoiSldUIn0.eyJqdGkiOiI2MWIxZmFmYy00ZWM3LTQ0ODktYTI4MC04ZDBhNTBhM2Q1YTkiLCJpc3MiOiJhYm9ubmVlLmRlemkubmwiLCJleHAiOjE3NDAxMzExNzYsIm5iZiI6MTczMjE4MjM3NiwianNvbl9zY2hlbWEiOiJodHRwczovL3d3dy5kZXppLm5sL2pzb25fc2NoZW1hcy92ZXJrbGFyaW5nX3YxLmpzb24iLCJsb2FfZGV6aSI6Imh0dHA6Ly9laWRhcy5ldXJvcGUuZXUvTG9BL2hpZ2giLCJ2ZXJrbGFyaW5nX2lkIjoiODUzOWY3NWQtNjM0Yy00N2RiLWJiNDEtMjg3OTFkZmQxZjhkIiwiZGV6aV9udW1tZXIiOiIxMjM0NTY3ODkiLCJ2b29ybGV0dGVycyI6IkEuQi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IlpvcmdtZWRld2Vya2VyIiwiYWJvbm5lZV9udW1tZXIiOiI4NzY1NDMyMSIsImFib25uZWVfbmFhbSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xfY29kZSI6IjAxLjAwMCIsInJvbF9uYWFtIjoiQXJ0cyIsInJvbF9jb2RlX2Jyb24iOiJodHRwOi8vd3d3LmRlemkubmwvcm9sX2NvZGVfYnJvbi9iaWciLCJyZXZvY2F0aWVfY29udHJvbGVfdXJpIjoiaHR0cHM6Ly9hdXRoLmRlemkubmwvcmV2b2NhdGllLXN0YXR1cy92MS92ZXJrbGFyaW5nLzg1MzlmNzVkLTYzNGMtNDdkYi1iYjQxLTI4NzkxZGZkMWY4ZCJ9.vegszRMWJjE-SBpfPO9lxN_fEY814ezsXRYhLXorPq3j_B_wlv4A92saasdEWrTALbl9Shux0i6JvkbouqvZ_oJpOUfJxWFGFfGGCuiMhiz4k1zm665i98e2xTqFzqjQySu_gup3wYm24FmnzbHxy02RzM3pXvQCsk_jIfQ1YcUZmNmXa5hR4DEn4Z9STLHd2HwyL6IKafEGl-R_kgbAnArSHQvuLw0Fpx62QD0tr5d3PbzPirBdkuy4G1l0umb69EjZMZ5MyIl8Y_irhQ9IFomAeSlU_zZp6UojVIOnCY2gL5EMc_8B1PDC6R_C--quGoh14jiSOJAeYSf_9ETjgQ"
+
+ actual, err := CreateDeziUserCredential(input)
+ require.NoError(t, err)
+
+ require.Len(t, actual.CredentialSubject, 1)
+ subject := actual.CredentialSubject[0]
+ employee := subject["employee"].(map[string]interface{})
+ assert.Equal(t, "87654321", subject["identifier"])
+ assert.Equal(t, "Zorgaanbieder", subject["name"])
+ assert.Equal(t, "123456789", employee["identifier"])
+ assert.Equal(t, "A.B.", employee["initials"])
+ assert.Equal(t, "Zorgmedewerker", employee["surname"])
+ assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token
+ assert.Equal(t, "01.000", employee["role"])
+ assert.Equal(t, "http://www.dezi.nl/rol_code_bron/big", employee["role_registry"])
+ assert.Equal(t, "Arts", employee["role_name"])
+
+ t.Run("from online test environment", func(t *testing.T) {
+ // Payload:
+ // {
+ // "json_schema": "https://www.dezi.nl/json_schemas/v1/verklaring.json",
+ // "loa_dezi": "http://eidas.europa.eu/LoA/high",
+ // "jti": "f410b255-6b07-4182-ac5c-c41f02bd3995",
+ // "verklaring_id": "0e970fcb-530c-482e-ba28-47b461d4dcb5",
+ // "dezi_nummer": "900022159",
+ // "voorletters": "J.",
+ // "voorvoegsel": null,
+ // "achternaam": "90017362",
+ // "abonnee_nummer": "90000380",
+ // "abonnee_naam": "Tést Zorginstelling 01",
+ // "rol_code": "92.000",
+ // "rol_naam": "Mondhygiënist",
+ // "rol_code_bron": "http://www.dezi.nl/rol_bron/big",
+ // "status_uri": "https://acceptatie.auth.dezi.nl/status/v1/verklaring/0e970fcb-530c-482e-ba28-47b461d4dcb5",
+ // "nbf": 1772665200,
+ // "exp": 1780610400,
+ // "iss": "https://abonnee.dezi.nl"
+ //}
+ const input = "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ"
+
+ actual, err := CreateDeziUserCredential(input)
+ require.NoError(t, err)
+
+ require.Len(t, actual.CredentialSubject, 1)
+ subject := actual.CredentialSubject[0]
+ employee := subject["employee"].(map[string]interface{})
+ assert.Equal(t, "90000380", subject["identifier"])
+ assert.Equal(t, "Tést Zorginstelling 01", subject["name"])
+ assert.Equal(t, "900022159", employee["identifier"])
+ assert.Equal(t, "J.", employee["initials"])
+ assert.Equal(t, "90017362", employee["surname"])
+ assert.Equal(t, "", employee["surnamePrefix"]) // voorvoegsel is null in this token
+ assert.Equal(t, "92.000", employee["role"])
+ assert.Equal(t, "http://www.dezi.nl/rol_bron/big", employee["role_registry"])
+ assert.Equal(t, "Mondhygiënist", employee["role_name"])
+ })
+ })
+}
+
+func TestDeziIDToken07CredentialValidator(t *testing.T) {
+ iat := time.Unix(1732182376, 0)
+ exp := time.Unix(1740131176, 0)
+ validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC)
+
+ signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem")
+ require.NoError(t, err)
+
+ publicKeyJWK, err := jwk.FromRaw(signingKeyCert.Leaf.PublicKey)
+ require.NoError(t, err)
+ require.NoError(t, publicKeyJWK.Set(jwk.KeyIDKey, "1"))
+ correctKeySet := jwk.NewSet()
+ require.NoError(t, correctKeySet.AddKey(publicKeyJWK))
+ // KeySet taken from https://acceptatie.auth.dezi.nl/dezi/jwks.json, copied to make the test deterministic
+ accKeySet := jwk.NewSet()
+ err = json.Unmarshal([]byte(`{
+ "keys" : [
+ {
+ "kty": "RSA",
+ "kid": "ae46829d-c8e8-48a0-bd6a-21b8a07b8cb2",
+ "x5c": [
+ "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"
+ ],
+ "x5t": "mlPsZptNN2Bo8A8A6keBROJ6Q_U",
+ "x5t#S256": "UHZTsA9YMQnGRd24MZLxZabWczwuZn1PE9iV7j-oDm8",
+ "n": "4ObWRH19nPSyFsuKIQ_HG3FrlAqoBiij4mAYsAl7EWduHCGj92jkkGE4z6CNPgcdVK3J2WllhyKj7kDf1aZoCvfkVrQHpS_GnEBHME-5Vo3a8Z-1AfVxxSbVLlXFu793tx83U_mB8PVxHhzf6pW449fjZrSNc0cnluXoYRFgNGxD0hlL5JahMuOoWGpKJ5XVZp6bZjbIuHc2rC589THQl1N1V11QcpoCnQsFkX92JTtgtDl-jehrqr_P2-EXRhAZl59MAk6BAZXBJWDFY_gbjYW3j4q-ITBG5iGc8tYK3JxOCdK4K3Ql3QoNEptU32ET1zrRux5D5MRiC09MKoJ4bQ",
+ "e": "AQAB"
+ }
+]}`), &accKeySet)
+ require.NoError(t, err)
+
+ wrongKeySet := jwk.NewSet()
+ wrongKey, _ := jwk.FromRaw([]byte("wrong-secret-key-data"))
+ wrongKey.Set(jwk.KeyIDKey, "wrong-kid")
+ wrongKeySet.AddKey(wrongKey)
+
+ tests := []struct {
+ name string
+ deziAttestation string
+ keySet jwk.Set
+ clock *time.Time
+ modifyCred func(*vc.VerifiableCredential)
+ allowedJKU []string
+ wantErr string
+ }{
+ {
+ name: "ok",
+ keySet: correctKeySet,
+ },
+ {
+ name: "from test environment",
+ deziAttestation: "eyJhbGciOiJSUzI1NiIsImtpZCI6ImFlNDY4MjlkLWM4ZTgtNDhhMC1iZDZhLTIxYjhhMDdiOGNiMiIsInR5cCI6IkpXVCIsImprdSI6Imh0dHBzOi8vYWNjZXB0YXRpZS5hdXRoLmRlemkubmwvZGV6aS9qd2tzLmpzb24ifQ.eyJqc29uX3NjaGVtYSI6Imh0dHBzOi8vd3d3LmRlemkubmwvanNvbl9zY2hlbWFzL3YxL3ZlcmtsYXJpbmcuanNvbiIsImxvYV9kZXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsImp0aSI6ImY0MTBiMjU1LTZiMDctNDE4Mi1hYzVjLWM0MWYwMmJkMzk5NSIsInZlcmtsYXJpbmdfaWQiOiIwZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJkZXppX251bW1lciI6IjkwMDAyMjE1OSIsInZvb3JsZXR0ZXJzIjoiSi4iLCJ2b29ydm9lZ3NlbCI6bnVsbCwiYWNodGVybmFhbSI6IjkwMDE3MzYyIiwiYWJvbm5lZV9udW1tZXIiOiI5MDAwMDM4MCIsImFib25uZWVfbmFhbSI6IlTDqXN0IFpvcmdpbnN0ZWxsaW5nIDAxIiwicm9sX2NvZGUiOiI5Mi4wMDAiLCJyb2xfbmFhbSI6Ik1vbmRoeWdpw6tuaXN0Iiwicm9sX2NvZGVfYnJvbiI6Imh0dHA6Ly93d3cuZGV6aS5ubC9yb2xfYnJvbi9iaWciLCJzdGF0dXNfdXJpIjoiaHR0cHM6Ly9hY2NlcHRhdGllLmF1dGguZGV6aS5ubC9zdGF0dXMvdjEvdmVya2xhcmluZy8wZTk3MGZjYi01MzBjLTQ4MmUtYmEyOC00N2I0NjFkNGRjYjUiLCJuYmYiOjE3NzI2NjUyMDAsImV4cCI6MTc4MDYxMDQwMCwiaXNzIjoiaHR0cHM6Ly9hYm9ubmVlLmRlemkubmwifQ.ipR4stqmO8MOmmapukeQxIOVpwO_Ipjgy5BHjUsdCvuFObhVrj48AQCndtV48D_Ol1hXO4s9p4b-1epjEiobjEmEO0JQNU0BAOGG0eWl8MujfhzlDnmwo5AEtvdgTjlnBaLReVu1BJ8KYgc1DT7JhCukq9z5wZLqU1aqtETleX2-s-dNdTdwrUjJa1DvIgO-DQ_rCp-1tcfkr2rtyW16ztyI88Q2YdBkNGcG0if5aYZHpcQ4-121WBObUa0FhswS7EHni5Ru8KwZNq0HC8OLWw3YqLrYHTFe2K0GQjMtEO6zNxApbMXWKlgeWdf7Ry2rPpe2l9Z5NuMrFiB8JChZsQ",
+ clock: to.Ptr(time.Date(2026, 3, 11, 8, 0, 0, 0, time.UTC)),
+ },
+ {
+ name: "wrong exp",
+ keySet: correctKeySet,
+ modifyCred: func(c *vc.VerifiableCredential) {
+ wrongExp := exp.Add(time.Hour)
+ c.ExpirationDate = &wrongExp
+ },
+ wantErr: "'exp' does not match credential 'expirationDate'",
+ },
+ {
+ name: "wrong nbf",
+ keySet: correctKeySet,
+ modifyCred: func(c *vc.VerifiableCredential) {
+ c.IssuanceDate = iat.Add(-time.Hour)
+ },
+ wantErr: "'nbf' does not match credential 'issuanceDate'",
+ },
+ {
+ name: "invalid signature",
+ keySet: wrongKeySet,
+ wantErr: "failed to verify JWT signature",
+ },
+ {
+ name: "JWK set endpoint unreachable",
+ keySet: nil,
+ wantErr: "failed to verify JWT signature",
+ },
+ {
+ name: "token claims differ from credential subject",
+ keySet: correctKeySet,
+ modifyCred: func(c *vc.VerifiableCredential) {
+ c.CredentialSubject[0]["identifier"] = "different-identifier"
+ },
+ wantErr: "credential subject does not match id_token claims",
+ },
+ {
+ name: "JKU not allowed",
+ allowedJKU: []string{"https://example.com/other"},
+ wantErr: "rejected by whitelist",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ deziAttestation := tt.deziAttestation
+ if tt.deziAttestation == "" {
+ tokenBytes, err := CreateTestDezi07IDToken(iat, exp, signingKeyCert.PrivateKey)
+ require.NoError(t, err)
+ deziAttestation = string(tokenBytes)
+ }
+
+ cred, err := CreateDeziUserCredential(deziAttestation)
+ require.NoError(t, err)
+
+ if tt.modifyCred != nil {
+ tt.modifyCred(cred)
+ }
+
+ validator := deziIDToken07CredentialValidator{
+ clock: func() time.Time { return validAt },
+ httpClient: &http.Client{Transport: &stubbedRoundTripper{keySets: map[string]jwk.Set{
+ "https://acceptatie.auth.dezi.nl/dezi/jwks.json": accKeySet,
+ "https://example.com/jwks.json": tt.keySet,
+ }}},
+ allowedJKU: []string{
+ "https://acceptatie.auth.dezi.nl/dezi/jwks.json",
+ "https://example.com/jwks.json",
+ },
+ }
+ if tt.clock != nil {
+ validator.clock = func() time.Time { return *tt.clock }
+ }
+ if tt.allowedJKU != nil {
+ validator.allowedJKU = tt.allowedJKU
+ }
+
+ err = validator.Validate(*cred)
+ if tt.wantErr != "" {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestDeziIDToken2024CredentialValidator(t *testing.T) {
+ t.Skip("TODO: implement or remove")
+ const exampleToken = `eyJhbGciOiJSUzI1NiIsImtpZCI6IjFNY2p3cjgxMGpOVUZHVHR6T21MeTRTNnN5cVJ1aVZ1YVM0UmZyWmZwOEk9IiwieDV0Ijoibk4xTVdBeFRZTUgxOE45cFBWMlVIYlVZVDVOWTByT19TaHQyLWZVWF9nOCJ9.eyJhdWQiOiIwMDZmYmYzNC1hODBiLTRjODEtYjZlOS01OTM2MDA2NzVmYjIiLCJleHAiOjE3MDE5MzM2OTcsImluaXRpYWxzIjoiQi5CLiIsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYiLCJqc29uX3NjaGVtYSI6Imh0dHBzOi8vbG9jYWxob3N0OjgwMDYvanNvbl9zY2hlbWEuanNvbiIsImxvYV9hdXRobiI6Imh0dHA6Ly9laWRhcy5ldXJvcGEuZXUvTG9BL2hpZ2giLCJsb2FfdXppIjoiaHR0cDovL2VpZGFzLmV1cm9wYS5ldS9Mb0EvaGlnaCIsIm5iZiI6MTcwMTkzMzYyNywicmVsYXRpb25zIjpbeyJlbnRpdHlfbmFtZSI6IlpvcmdhYW5iaWVkZXIiLCJyb2xlcyI6WyIwMS4wNDEiLCIzMC4wMDAiLCIwMS4wMTAiLCIwMS4wMTEiXSwidXJhIjoiODc2NTQzMjEifV0sInN1cm5hbWUiOiJKYW5zZW4iLCJzdXJuYW1lX3ByZWZpeCI6InZhbiBkZXIiLCJ1emlfaWQiOiI5MDAwMDAwMDkiLCJ4NWMiOiJNSUlEMWpDQ0FiNENDUUQwRmx2SnNiY3J2VEFOQmdrcWhraUc5dzBCQVFzRkFEQW9NUXN3Q1FZRFZRUUREQUpWXG5VekVaTUJjR0ExVUVBd3dRYVc1blpTMDJMWFY2YVhCdll5MWpZVEFlRncweU16RXlNREV4TWpNek16QmFGdzB5XG5OVEEwTVRReE1qTXpNekJhTURJeEN6QUpCZ05WQkFZVEFsVlRNU013SVFZRFZRUUREQnB1YkMxMWVtbHdiMk10XG5jR2h3TFd4aGNtRjJaV3d0WkdWdGJ6Q0NBU0l3RFFZSktvWklodmNOQVFFQkJRQURnZ0VQQURDQ0FRb0NnZ0VCXG5BS2JDQ0dZYTcwL0VlS2dwc0N0QnRHUmlIWVJyb2RWQWN0bHJPTUhFZDI2aGpSdmN0TGZOaXl0MFF5QkZsekN2XG4wVkRYUjc3OWVvU1E2bWdPYURjNzFrYi9zR1dxbjhMZFE3NEp0WTVnSTVxRzduM1JYM0VRWkxFdGIxNmp6WWROXG5LMU5mMm9GK0tNV2t2eWMvVjlSNWUyNjdyTjJpUklHQlNKUTFmZmN4RHFUZnJNVmxjaFYyZmdWVDdZTzQ3U25qXG5MMXdDK0Z4cXhTRzc1N056OHlleVBncjJaazFvaWF6dHhQY1hXRlVpTklGWm9KUzlpVzdITTZyQ204WjcvbVJjXG40Qm5kbC9wbkZlMjVrZmhPZzlKSVVNbzFvcjltbDZDSXN6Um9aL2hTOHZCOUduNldUS05CYUgxMTB6Sno4eXNkXG42cXM4WkpCYURia0pnSTZMNlZtL3d0OENBd0VBQVRBTkJna3Foa2lHOXcwQkFRc0ZBQU9DQWdFQWl6eEJmYUFhXG5ETlFOOWhYS0ZKZVB6N2RUUEx3SFkvWmpDNDlkUXJ0eVd6ZmdyMUxLaTRvMWl4amtkZzJGWFU5dCt4TXVXZ3hYXG5GZGJqT0pMZGNRWVdFWWIrVzdLbWtnbEljUDhiT2tSRFdmcGxnc2twYVRvZ1JLMDk1UzFDdU1NOXYwYktDL3RzXG5kSGIzVWZxVzRVNFprbzM4L1VlOWZSRjhyYTJwNzFRRnM4K250L0JBd0NrenJOTHpheE1ZOC8vVGlGVStaRVlMXG56UElRQmpLYVlCOHlWaDBXaDNxYWllQjJCektVYW4rRXlzaDJiVWM5VHBsUXlrSWRrNHo2VCtGTzVLVGo1ZVZrXG42enBIZmxXV0NUNjF5MTVtdTN4QUViODNyT2YrekZwb05HaURzc2lrbzBPZUxLN0ZscWg3SHVDUDI2Tk5ud3NiXG5WR3drZzYwcER1K0FTRzJhbTNUUGlmM0pwSTdza3pBQkZ3NHZidlBVcElrNkltM3ljQzk4R3lYb3dRdWpJMFpUXG4xNmRYZmgxRTM4cHNSVWVPNW8rdXhZNk1VUFhOU2lvWVowbWYzQkFSTGFoTjQxcnF4S1h6NU1MMURTWm5JT1pLXG5GM3BlU2dnYVpvUmkxaDByNlcxNFdFY1l2eGRIRGtWUjZNMXFXMGk3WWVJQms2a2FYRWt3Q21GejNoazV3OWFuXG5XSkRqbk1xU1JnUnNGVmNJTC9FemkvRWx1YmsyMWY0TEhURVFtc2p6emQxRytkMDlmamRJNkpyaFlNZnRHdVlaXG40ak9aWldwem9NSDFUaVpaK0prQmR5UndFZGJxelcrdisvMEJaUXk2SFJhWmxvbWJjT21TOU1TakZSRFR5VUdXXG5EOUYxZVVJcUtjdDB5eUpQUFhIM2xEa3pxcXRYNERMY29wbz0ifQ.VvzIXZ8FCIwxvP3Wc4kLvIgQChJZAhS-DcKKvkiZg677w-ZRciIFCWUH5oXLqG-emyV4f87tIoWnp4TY3gGFNljNrtlTVCv3zXaTCxHwzL6q2QCs1liBus2uPv0kjBtzeve2G5_Owst3ndeUcwLJPnTIoYRLvbjjaPkFTg49K5ZTpN8E9dl7Gimwgv_rZ1fOH7XrAwlTY-jF34wsR_K17wHI5Zp237_HcAPqnMI8P3U7u74Vu-3mqCePubVBDnT4bGcd4flZCFH-LTDhew9BO4cBkBxafAev7OB5A9qGOKEtRynTDAOkazyb8_qwJAGnyCAVxBQ4VFRB1-cE576TLQ`
+ const exampleCertificateDERBase64 = `MIID1jCCAb4CCQD0FlvJsbcrvTANBgkqhkiG9w0BAQsFADAoMQswCQYDVQQDDAJV
+UzEZMBcGA1UEAwwQaW5nZS02LXV6aXBvYy1jYTAeFw0yMzEyMDExMjMzMzBaFw0y
+NTA0MTQxMjMzMzBaMDIxCzAJBgNVBAYTAlVTMSMwIQYDVQQDDBpubC11emlwb2Mt
+cGhwLWxhcmF2ZWwtZGVtbzCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB
+AKbCCGYa70/EeKgpsCtBtGRiHYRrodVActlrOMHEd26hjRvctLfNiyt0QyBFlzCv
+0VDXR779eoSQ6mgOaDc71kb/sGWqn8LdQ74JtY5gI5qG7n3RX3EQZLEtb16jzYdN
+K1Nf2oF+KMWkvyc/V9R5e267rN2iRIGBSJQ1ffcxDqTfrMVlchV2fgVT7YO47Snj
+L1wC+FxqxSG757Nz8yeyPgr2Zk1oiaztxPcXWFUiNIFZoJS9iW7HM6rCm8Z7/mRc
+4Bndl/pnFe25kfhOg9JIUMo1or9ml6CIszRoZ/hS8vB9Gn6WTKNBaH110zJz8ysd
+6qs8ZJBaDbkJgI6L6Vm/wt8CAwEAATANBgkqhkiG9w0BAQsFAAOCAgEAizxBfaAa
+DNQN9hXKFJePz7dTPLwHY/ZjC49dQrtyWzfgr1LKi4o1ixjkdg2FXU9t+xMuWgxX
+FdbjOJLdcQYWEYb+W7KmkglIcP8bOkRDWfplgskpaTogRK095S1CuMM9v0bKC/ts
+dHb3UfqW4U4Zko38/Ue9fRF8ra2p71QFs8+nt/BAwCkzrNLzaxMY8//TiFU+ZEYL
+zPIQBjKaYB8yVh0Wh3qaieB2BzKUan+Eysh2bUc9TplQykIdk4z6T+FO5KTj5eVk
+6zpHflWWCT61y15mu3xAEb83rOf+zFpoNGiDssiko0OeLK7Flqh7HuCP26NNnwsb
+VGwkg60pDu+ASG2am3TPif3JpI7skzABFw4vbvPUpIk6Im3ycC98GyXowQujI0ZT
+16dXfh1E38psRUeO5o+uxY6MUPXNSioYZ0mf3BARLahN41rqxKXz5ML1DSZnIOZK
+F3peSggaZoRi1h0r6W14WEcYvxdHDkVR6M1qW0i7YeIBk6kaXEkwCmFz3hk5w9an
+WJDjnMqSRgRsFVcIL/Ezi/Elubk21f4LHTEQmsjzzd1G+d09fjdI6JrhYMftGuYZ
+4jOZZWpzoMH1TiZZ+JkBdyRwEdbqzW+v+/0BZQy6HRaZlombcOmS9MSjFRDTyUGW
+D9F1eUIqKct0yyJPPXH3lDkzqqtX4DLcopo=`
+ certificateDER, err := base64.StdEncoding.DecodeString(exampleCertificateDERBase64)
+ require.NoError(t, err)
+ exampleCertificate, err := x509.ParseCertificate(certificateDER)
+ require.NoError(t, err)
+ // Setup trust store with the Dezi certificate as root CA
+ exampleTrustStore := core.BuildTrustStore([]*x509.Certificate{})
+ exampleTrustStore.RootCAs = append(exampleTrustStore.RootCAs, exampleCertificate)
+
+ // Load a signing key pair for creating test tokens
+ // Note: In real scenarios, the signing key would match the cert in x5c
+ signingKeyCert, err := tls.LoadX509KeyPair("../../test/pki/certificate-and-key.pem", "../../test/pki/certificate-and-key.pem")
+ require.NoError(t, err)
+ signingKeyCert.Leaf, err = x509.ParseCertificate(signingKeyCert.Certificate[0])
+ require.NoError(t, err)
+ trustStore := core.BuildTrustStore([]*x509.Certificate{})
+ trustStore.RootCAs = append(trustStore.RootCAs, signingKeyCert.Leaf)
+
+ // Use a validation time within the Dezi certificate validity period
+ validAt := time.Date(2024, 12, 1, 0, 0, 0, 0, time.UTC)
+
+ // createToken returns a factory function that creates a JWT with the given x5c value
+ // If x5cValue is a *x509.Certificate, it encodes that certificate in x5c
+ // If x5cValue is any other type (string, []string, nil), it's used directly as the x5c claim
+ createToken := func(x5cValue any, nbf *time.Time, exp *time.Time) func(t *testing.T) []byte {
+ if nbf == nil {
+ nbf = new(time.Time)
+ *nbf = time.Unix(1732182376, 0) // Nov 21, 2024
+ }
+ if exp == nil {
+ exp = new(time.Time)
+ *exp = time.Unix(1740131176, 0) // Feb 21, 2025
+ }
+ return func(t *testing.T) []byte {
+ token := jwt.New()
+ claims := map[string]any{
+ jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2",
+ jwt.ExpirationKey: exp.Unix(),
+ jwt.NotBeforeKey: nbf.Unix(),
+ jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl",
+ jwt.JwtIDKey: "test-jwt-id",
+ "dezi_nummer": "900000009",
+ "initials": "B.B.",
+ "surname": "Jansen",
+ "surname_prefix": "van der",
+ "relations": []map[string]interface{}{
+ {"entity_name": "Zorgaanbieder", "roles": []string{"01.041"}, "ura": "87654321"},
+ },
+ }
+
+ // Handle x5c based on type
+ if cert, ok := x5cValue.(*x509.Certificate); ok {
+ // Encode the provided certificate in x5c
+ x5cArray := []string{base64.StdEncoding.EncodeToString(cert.Raw)}
+ claims["x5c"] = x5cArray
+ } else if x5cValue != nil {
+ // Use x5cValue directly (for testing invalid formats)
+ claims["x5c"] = x5cValue
+ }
+ // If x5cValue is nil, don't add x5c claim (for testing missing x5c)
+
+ for k, v := range claims {
+ require.NoError(t, token.Set(k, v))
+ }
+ signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, signingKeyCert.PrivateKey))
+ require.NoError(t, err)
+ return signed
+ }
+ }
+
+ tests := []struct {
+ name string
+ createToken func(t *testing.T) []byte
+ modifyCred func(*vc.VerifiableCredential)
+ trustStore *core.TrustStore
+ wantErr string
+ }{
+ {
+ name: "ok",
+ createToken: func(t *testing.T) []byte {
+ return []byte(exampleToken)
+ },
+ trustStore: exampleTrustStore,
+ },
+ {
+ name: "missing x5c",
+ createToken: createToken(nil, nil, nil),
+ wantErr: "missing 'x5c' claim",
+ },
+ {
+ name: "invalid certificate",
+ createToken: createToken([]string{"invalid-base64!!!"}, nil, nil),
+ wantErr: "decode 'x5c",
+ },
+ {
+ name: "credential's nbf does not match token's nbf",
+ createToken: createToken([]string{base64.StdEncoding.EncodeToString(signingKeyCert.Leaf.Raw)}, nil, nil),
+ modifyCred: func(c *vc.VerifiableCredential) {
+ c.IssuanceDate = time.Date(2024, 11, 1, 0, 0, 0, 0, time.UTC)
+ },
+ wantErr: "'nbf' does not match credential 'issuanceDate'",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ tokenBytes := tt.createToken(t)
+ cred, err := CreateDeziUserCredential(string(tokenBytes))
+ require.NoError(t, err)
+
+ if tt.modifyCred != nil {
+ tt.modifyCred(cred)
+ }
+ validator := deziIDToken2024CredentialValidator{
+ clock: func() time.Time { return validAt },
+ trustStore: trustStore,
+ }
+ if tt.trustStore != nil {
+ validator.trustStore = tt.trustStore
+ }
+
+ err = validator.Validate(*cred)
+ if tt.wantErr != "" {
+ assert.Error(t, err)
+ assert.Contains(t, err.Error(), tt.wantErr)
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
diff --git a/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf
new file mode 100644
index 0000000000..6cc845d2f7
Binary files /dev/null and b/vcr/credential/koppelvlakspecificatie_07-dezi-voor-platform-en-softwareleveranciers-v0-7.pdf differ
diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go
index 182eda33e7..45b9a8b7d2 100644
--- a/vcr/credential/resolver.go
+++ b/vcr/credential/resolver.go
@@ -22,6 +22,7 @@ package credential
import (
"errors"
"fmt"
+
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/crypto"
@@ -29,6 +30,8 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
)
+var DefaultDeziUserCredentialValidator = DeziUserCredentialValidator{}
+
// FindValidator finds the Validator the provided credential based on its Type
// When no additional type is provided, it returns the default validator
func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validator) Validator {
@@ -41,6 +44,11 @@ func FindValidator(credential vc.VerifiableCredential, pkiValidator pki.Validato
return nutsAuthorizationCredentialValidator{}
case X509CredentialType:
return x509CredentialValidator{pkiValidator: pkiValidator}
+ case DeziUserCredentialTypeURI.String():
+ // TODO: This is an ugly pattern, and FindValidator() should probably be moved to the Verifier, but that's a big refactor.
+ // As long as it's non-production/PoC code, this is fine.
+ // Make nice when merging to master.
+ return DefaultDeziUserCredentialValidator
}
}
}
diff --git a/vcr/credential/test.go b/vcr/credential/test.go
new file mode 100644
index 0000000000..bfd11a341d
--- /dev/null
+++ b/vcr/credential/test.go
@@ -0,0 +1,91 @@
+package credential
+
+import (
+ "crypto"
+ "time"
+
+ "github.com/lestrrat-go/jwx/v2/jwa"
+ "github.com/lestrrat-go/jwx/v2/jws"
+ "github.com/lestrrat-go/jwx/v2/jwt"
+)
+
+func CreateTestDezi07IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) {
+ claims := map[string]any{
+ jwt.JwtIDKey: "test-jwt-id-07",
+ jwt.ExpirationKey: validUntil.Unix(),
+ jwt.NotBeforeKey: issuedAt.Unix(),
+ jwt.IssuerKey: "abonnee.dezi.nl",
+ "json_schema": "https://www.dezi.nl/json_schemas/verklaring_v1.json",
+ "loa_dezi": "http://eidas.europa.eu/LoA/high",
+ "verklaring_id": "test-verklaring-id",
+ // v0.7 format claims
+ "dezi_nummer": "123456789",
+ "voorletters": "A.B.",
+ "voorvoegsel": "van der",
+ "achternaam": "Zorgmedewerker",
+ "abonnee_nummer": "87654321",
+ "abonnee_naam": "Zorgaanbieder",
+ "rol_code": "01.000",
+ "rol_naam": "Arts",
+ "rol_code_bron": "http://www.dezi.nl/rol_code_bron/big",
+ }
+ token := jwt.New()
+ for name, value := range claims {
+ if err := token.Set(name, value); err != nil {
+ return nil, err
+ }
+ }
+
+ headers := jws.NewHeaders()
+ for k, v := range map[string]any{
+ "alg": "RS256",
+ "kid": "1",
+ "jku": "https://example.com/jwks.json",
+ } {
+ if err := headers.Set(k, v); err != nil {
+ return nil, err
+ }
+ }
+ return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers)))
+}
+
+func CreateTestDezi2024IDToken(issuedAt time.Time, validUntil time.Time, key crypto.PrivateKey) ([]byte, error) {
+ claims := map[string]any{
+ jwt.AudienceKey: "006fbf34-a80b-4c81-b6e9-593600675fb2",
+ jwt.ExpirationKey: validUntil.Unix(),
+ jwt.NotBeforeKey: issuedAt.Unix(),
+ jwt.IssuerKey: "https://max.proeftuin.Dezi-online.rdobeheer.nl",
+ "initials": "B.B.",
+ "surname": "Jansen",
+ "surname_prefix": "van der",
+ "Dezi_id": "900000009",
+ "json_schema": "https://max.proeftuin.Dezi-online.rdobeheer.nl/json_schema.json",
+ "loa_authn": "http://eidas.europa.eu/LoA/high",
+ "loa_Dezi": "http://eidas.europa.eu/LoA/high",
+ "relations": []map[string]interface{}{
+ {
+ "entity_name": "Zorgaanbieder",
+ "roles": []string{"01.041", "30.000", "01.010", "01.011"},
+ "ura": "87654321",
+ },
+ },
+ }
+ token := jwt.New()
+ for name, value := range claims {
+ if err := token.Set(name, value); err != nil {
+ return nil, err
+ }
+ }
+
+ headers := jws.NewHeaders()
+ for k, v := range map[string]any{
+ "alg": "RS256",
+ "kid": "1",
+ "jku": "https://example.com/jwks.json",
+ } {
+ if err := headers.Set(k, v); err != nil {
+ return nil, err
+ }
+ }
+ return jwt.Sign(token, jwt.WithKey(jwa.RS256, key, jws.WithProtectedHeaders(headers)))
+}
diff --git a/vcr/credential/types.go b/vcr/credential/types.go
index 87da9fefeb..b7bb5b9283 100644
--- a/vcr/credential/types.go
+++ b/vcr/credential/types.go
@@ -39,6 +39,8 @@ var (
NutsOrganizationCredentialTypeURI, _ = ssi.ParseURI(NutsOrganizationCredentialType)
// NutsAuthorizationCredentialTypeURI is the VC type for a NutsAuthorizationCredentialType as URI
NutsAuthorizationCredentialTypeURI, _ = ssi.ParseURI(NutsAuthorizationCredentialType)
+ // DeziUserCredentialTypeURI is the VC type for a DeziUserCredential
+ DeziUserCredentialTypeURI = ssi.MustParseURI("DeziUserCredential")
// NutsV1ContextURI is the nuts V1 json-ld context as URI
NutsV1ContextURI = ssi.MustParseURI(NutsV1Context)
)
diff --git a/vcr/credential/util.go b/vcr/credential/util.go
index 41643548f7..0895718898 100644
--- a/vcr/credential/util.go
+++ b/vcr/credential/util.go
@@ -20,18 +20,20 @@ package credential
import (
"errors"
+ "slices"
+ "time"
+
"github.com/google/uuid"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
- "slices"
- "time"
)
// ResolveSubjectDID resolves the subject DID from the given credentials.
+// It skips credentials that don't have a credentialSubject.id (e.g., DeziUserCredential).
// It returns an error if:
// - the credentials do not have the same subject DID.
-// - the credentials do not have a subject DID.
+// - none of the credentials have a subject DID.
func ResolveSubjectDID(credentials ...vc.VerifiableCredential) (*did.DID, error) {
var subjectID did.DID
for _, credential := range credentials {
@@ -114,10 +116,20 @@ func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Ti
// AutoCorrectSelfAttestedCredential sets the required fields for a self-attested credential.
// These are provided through the API, and for convenience we set the required fields, if not already set.
-// It only does this for unsigned JSON-LD credentials. DO NOT USE THIS WITH JWT_VC CREDENTIALS.
+// It only does this for unsigned JSON-LD credentials and DeziUserCredentials (derived proof). DO NOT USE THIS WITH JWT_VC CREDENTIALS.
func AutoCorrectSelfAttestedCredential(credential vc.VerifiableCredential, requester did.DID) vc.VerifiableCredential {
if len(credential.Proof) > 0 {
- return credential
+ proofs, _ := credential.Proofs()
+ requiresCorrection := false
+ for _, p := range proofs {
+ if slices.Contains(DeziIDJWTProofTypes(), string(p.Type)) {
+ requiresCorrection = true
+ break
+ }
+ }
+ if !requiresCorrection {
+ return credential
+ }
}
if credential.ID == nil {
credential.ID, _ = ssi.ParseURI(uuid.NewString())
diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go
index ac2b481dae..56be3476b3 100644
--- a/vcr/credential/validator.go
+++ b/vcr/credential/validator.go
@@ -25,6 +25,9 @@ import (
"encoding/json"
"errors"
"fmt"
+ "net/url"
+ "strings"
+
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
@@ -33,8 +36,6 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/revocation"
"github.com/nuts-foundation/nuts-node/vdr/didx509"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
- "net/url"
- "strings"
)
// Validator is the interface specific VC verification.
diff --git a/vcr/holder/presenter.go b/vcr/holder/presenter.go
index 4c7fda522e..2f2ebb326b 100644
--- a/vcr/holder/presenter.go
+++ b/vcr/holder/presenter.go
@@ -23,22 +23,23 @@ import (
"encoding/json"
"errors"
"fmt"
+ "os"
+ "strings"
+ "time"
+
"github.com/google/uuid"
- "github.com/lestrrat-go/jwx/v2/jws"
- "github.com/lestrrat-go/jwx/v2/jwt"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/oauth"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/vcr/credential"
+ "github.com/nuts-foundation/nuts-node/vcr/log"
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/signature"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/piprate/json-gold/ld"
- "strings"
- "time"
)
type presenter struct {
@@ -118,57 +119,51 @@ func (p presenter) buildPresentation(ctx context.Context, signerDID *did.DID, cr
return nil, fmt.Errorf("unable to resolve assertion key for signing VP (did=%s): %w", *signerDID, err)
}
+ var vp *vc.VerifiablePresentation
switch options.Format {
case JWTPresentationFormat:
- return p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid)
+ vp, err = p.buildJWTPresentation(ctx, *signerDID, credentials, options, kid)
+ if err != nil {
+ return nil, err
+ }
case "":
fallthrough
case JSONLDPresentationFormat:
- return p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid)
+ vp, err = p.buildJSONLDPresentation(ctx, *signerDID, credentials, options, kid)
+ if err != nil {
+ return nil, err
+ }
default:
return nil, fmt.Errorf("unsupported presentation proof format: %s", options.Format)
}
-}
-// buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token
-func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) {
- headers := map[string]interface{}{
- jws.TypeKey: "JWT",
- }
- id := did.DIDURL{DID: subjectDID}
- id.Fragment = strings.ToLower(uuid.NewString())
- claims := map[string]interface{}{
- jwt.SubjectKey: subjectDID.String(),
- jwt.JwtIDKey: id.String(),
- "vp": vc.VerifiablePresentation{
- Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...),
- Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...),
- Holder: options.Holder,
- VerifiableCredential: credentials,
- },
- }
- if options.ProofOptions.Nonce != nil {
- claims["nonce"] = *options.ProofOptions.Nonce
- }
- if options.ProofOptions.Domain != nil {
- claims[jwt.AudienceKey] = *options.ProofOptions.Domain
- }
- if options.ProofOptions.Created.IsZero() {
- claims[jwt.NotBeforeKey] = time.Now().Unix()
- } else {
- claims[jwt.NotBeforeKey] = int(options.ProofOptions.Created.Unix())
- }
- if options.ProofOptions.Expires != nil {
- claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix())
- }
- for claimName, value := range options.ProofOptions.AdditionalProperties {
- claims[claimName] = value
+ tmpFile, err := os.CreateTemp(os.TempDir(), "vp-*.txt")
+ if err != nil {
+ return nil, fmt.Errorf("unable to create temp file for VP debug output: %w", err)
}
- token, err := p.signer.SignJWT(ctx, claims, headers, keyID)
+ defer tmpFile.Close()
+ _, err = tmpFile.WriteString(vp.Raw())
if err != nil {
- return nil, fmt.Errorf("unable to sign JWT presentation: %w", err)
+ return nil, fmt.Errorf("unable to write VP debug output to temp file: %w", err)
}
- return vc.ParseVerifiablePresentation(token)
+ log.Logger().Infof("Created VP stored in temp file: %s", tmpFile.Name())
+ return vp, nil
+}
+
+// buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token
+func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) {
+ return vc.CreateJWTVerifiablePresentation(ctx, subjectDID.URI(), credentials, vc.PresentationOptions{
+ AdditionalContexts: options.AdditionalContexts,
+ AdditionalTypes: options.AdditionalTypes,
+ AdditionalProofProperties: options.ProofOptions.AdditionalProperties,
+ Holder: options.Holder,
+ Nonce: options.ProofOptions.Nonce,
+ Audience: options.ProofOptions.Domain,
+ IssuedAt: &options.ProofOptions.Created,
+ ExpiresAt: options.ProofOptions.Expires,
+ }, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) {
+ return p.signer.SignJWT(ctx, claims, headers, keyID)
+ })
}
func (p presenter) buildJSONLDPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) {
diff --git a/vcr/holder/presenter_test.go b/vcr/holder/presenter_test.go
index 9671b719b1..4f2a85622d 100644
--- a/vcr/holder/presenter_test.go
+++ b/vcr/holder/presenter_test.go
@@ -20,6 +20,9 @@ package holder
import (
"context"
+ "testing"
+ "time"
+
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
@@ -39,8 +42,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
- "testing"
- "time"
)
func TestPresenter_buildPresentation(t *testing.T) {
@@ -142,7 +143,7 @@ func TestPresenter_buildPresentation(t *testing.T) {
})
})
t.Run("JWT", func(t *testing.T) {
- options := PresentationOptions{Format: JWTPresentationFormat}
+ options := PresentationOptions{Format: JWTPresentationFormat, ProofOptions: proof.ProofOptions{Created: time.Now()}}
t.Run("ok - one VC", func(t *testing.T) {
ctrl := gomock.NewController(t)
@@ -163,6 +164,7 @@ func TestPresenter_buildPresentation(t *testing.T) {
nonce, _ := result.JWT().Get("nonce")
assert.Empty(t, nonce)
})
+
t.Run("ok - multiple VCs", func(t *testing.T) {
ctrl := gomock.NewController(t)
diff --git a/vcr/holder/sql_wallet.go b/vcr/holder/sql_wallet.go
index 7323332558..470fd6cc92 100644
--- a/vcr/holder/sql_wallet.go
+++ b/vcr/holder/sql_wallet.go
@@ -20,7 +20,6 @@ package holder
import (
"context"
- "errors"
"fmt"
"time"
@@ -129,13 +128,15 @@ func (h sqlWallet) List(_ context.Context, holderDID did.DID) ([]vc.VerifiableCr
// now validate credentials and remove invalid ones
validCredentials := make([]vc.VerifiableCredential, 0, len(credentials))
for _, credential := range credentials {
+ validCredentials = append(validCredentials, credential)
+ // TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.
// we only want to check expiration and revocation status
- if err = h.verifier.Verify(credential, true, false, nil); err == nil {
- validCredentials = append(validCredentials, credential)
- } else if !errors.Is(err, types.ErrCredentialNotValidAtTime) && !errors.Is(err, types.ErrRevoked) {
- // a possible technical error has occurred that should be logged.
- log.Logger().WithError(err).WithField(core.LogFieldCredentialID, credential.ID).Warn("unable to verify credential")
- }
+ //if err = h.verifier.Verify(credential, true, false, nil); err == nil {
+ // validCredentials = append(validCredentials, credential)
+ //} else if !errors.Is(err, types.ErrCredentialNotValidAtTime) && !errors.Is(err, types.ErrRevoked) {
+ // // a possible technical error has occurred that should be logged.
+ // log.Logger().WithError(err).WithField(core.LogFieldCredentialID, credential.ID).Warn("unable to verify credential")
+ //}
}
return validCredentials, nil
diff --git a/vcr/holder/sql_wallet_test.go b/vcr/holder/sql_wallet_test.go
index 75d4767538..6ba791771f 100644
--- a/vcr/holder/sql_wallet_test.go
+++ b/vcr/holder/sql_wallet_test.go
@@ -202,6 +202,7 @@ func Test_sqlWallet_List(t *testing.T) {
assert.Equal(t, expected.ID.String(), list[0].ID.String())
})
t.Run("expired credential", func(t *testing.T) {
+ t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.")
resetStore(t, storageEngine.GetSQLDatabase())
sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine)
expected := createCredential(vdr.TestMethodDIDA.String())
@@ -213,6 +214,7 @@ func Test_sqlWallet_List(t *testing.T) {
require.Len(t, list, 0)
})
t.Run("other error", func(t *testing.T) {
+ t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.")
captureLogs := audit.CaptureLogs(t, logrus.StandardLogger())
resetStore(t, storageEngine.GetSQLDatabase())
sut := NewSQLWallet(nil, nil, testVerifier{err: assert.AnError}, nil, storageEngine)
@@ -241,6 +243,7 @@ func Test_sqlWallet_SearchCredential(t *testing.T) {
assert.Empty(t, list)
})
t.Run("returns all credentials including expired/revoked", func(t *testing.T) {
+ t.Skip("TODO: Disabled for now in project GF, because we want to actively demo with expired credentials.")
resetStore(t, storageEngine.GetSQLDatabase())
// SearchCredential should not filter by validity, so we pass a testVerifier that would filter
sut := NewSQLWallet(nil, nil, testVerifier{err: types.ErrCredentialNotValidAtTime}, nil, storageEngine)
diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go
index 2cfc4d2272..c69fbd1f41 100644
--- a/vcr/issuer/issuer_test.go
+++ b/vcr/issuer/issuer_test.go
@@ -1106,7 +1106,7 @@ func TestIssuer_StatusList(t *testing.T) {
func newTestStatusList2021(t testing.TB, db *gorm.DB, signingKey crypto.PublicKey, dids ...did.DID) *revocation.StatusList2021 {
storage.AddDIDtoSQLDB(t, db, dids...)
- cs := revocation.NewStatusList2021(db, nil, "https://example.com")
+ cs := revocation.NewStatusList2021(db, nil, "https://example.com", 15*time.Minute)
cs.Sign = func(_ context.Context, unsignedCredential vc.VerifiableCredential, kid string) (*vc.VerifiableCredential, error) {
unsignedCredential.ID, _ = ssi.ParseURI("test-credential")
bs, err := json.Marshal(unsignedCredential)
diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go
index 0d8e19068d..750e8aa995 100644
--- a/vcr/pe/presentation_definition_test.go
+++ b/vcr/pe/presentation_definition_test.go
@@ -24,11 +24,12 @@ import (
"crypto/rand"
"embed"
"encoding/json"
+ "strings"
+ "testing"
+
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/core/to"
vcrTest "github.com/nuts-foundation/nuts-node/vcr/test"
- "strings"
- "testing"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jwt"
diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go
index fcd011152f..52788ea573 100644
--- a/vcr/pe/presentation_submission.go
+++ b/vcr/pe/presentation_submission.go
@@ -22,13 +22,14 @@ import (
"encoding/json"
"errors"
"fmt"
+ "strings"
+
"github.com/PaesslerAG/jsonpath"
"github.com/google/uuid"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/vcr/credential"
v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2"
- "strings"
)
// ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission.
@@ -146,11 +147,15 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis
}
// the verifiableCredential property in Verifiable Presentations can be a single VC or an array of VCs when represented in JSON.
- // go-did always marshals a single VC as a single VC for JSON-LD VPs. So we might need to fix the mapping paths.
-
- // todo the check below actually depends on the format of the credential and not the format of the VP
+ // go-did always marshals a single VC as a single VC for JSON-LD VPs. So we need to fix the mapping paths.
if len(signInstruction.Mappings) == 1 {
- signInstruction.Mappings[0].Path = "$.verifiableCredential"
+ if format == vc.JWTPresentationProofFormat {
+ // JWT VP always has an array of VCs
+ signInstruction.Mappings[0].Path = "$.verifiableCredential[0]"
+ } else {
+ // JSON-LD VP with single VC has single VC in verifiableCredential
+ signInstruction.Mappings[0].Path = "$.verifiableCredential"
+ }
}
// Just 1 VP, no nesting needed
diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go
index 614777ff0c..605ac264c9 100644
--- a/vcr/pe/presentation_submission_test.go
+++ b/vcr/pe/presentation_submission_test.go
@@ -20,7 +20,11 @@ package pe
import (
"encoding/json"
+ "testing"
+ "time"
+
"errors"
+
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
@@ -28,8 +32,6 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "testing"
- "time"
)
func TestParsePresentationSubmission(t *testing.T) {
@@ -190,7 +192,7 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) {
assert.Len(t, signInstruction.VerifiableCredentials, 1)
assert.Equal(t, holder1, signInstruction.Holder)
require.Len(t, submission.DescriptorMap, 1)
- assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path)
+ assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].Path)
})
})
}
diff --git a/vcr/revocation/statuslist2021_verifier.go b/vcr/revocation/statuslist2021_verifier.go
index 47f514229f..f7841c34a1 100644
--- a/vcr/revocation/statuslist2021_verifier.go
+++ b/vcr/revocation/statuslist2021_verifier.go
@@ -25,7 +25,6 @@ import (
"io"
"net/http"
"strconv"
- "time"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/core"
@@ -34,9 +33,6 @@ import (
"gorm.io/gorm/clause"
)
-// maxAgeExternal is the maximum age of external StatusList2021Credentials. If older than this we try to refresh.
-const maxAgeExternal = 15 * time.Minute
-
// Verify StatusList2021 returns a types.ErrRevoked when the credentialStatus contains a 'StatusList2021Entry' that can be resolved and lists the credential as 'revoked'
// Other credentialStatus type/statusPurpose are ignored. Verification may fail with other non-standardized errors.
func (cs *StatusList2021) Verify(credentialToVerify vc.VerifiableCredential) error {
@@ -106,6 +102,7 @@ func (cs *StatusList2021) Verify(credentialToVerify vc.VerifiableCredential) err
func (cs *StatusList2021) statusList(statusListCredential string) (*credentialRecord, error) {
cr, err := cs.loadCredential(statusListCredential)
if err != nil {
+ log.Logger().WithError(err).Warnf("Failed to load StatusList2021Credential from database, fetching from issuer (url=%s)", statusListCredential)
// assume any error means we don't have the credential, so try fetching remote
return cs.update(statusListCredential)
}
@@ -115,23 +112,11 @@ func (cs *StatusList2021) statusList(statusListCredential string) (*credentialRe
return cr, nil
}
- // TODO: renewal criteria need to be reconsidered if we add other purposes. A 'suspension' may have been canceled
- // renew expired certificates
- if (cr.Expires != nil && time.Unix(*cr.Expires, 0).Before(time.Now())) || // expired
- time.Unix(cr.CreatedAt, 0).Add(maxAgeExternal).Before(time.Now()) { // older than 15 min
- crUpdated, err := cs.update(statusListCredential)
- if err == nil {
- return crUpdated, nil
- }
- // use known StatusList2021Credential if we can't fetch a new one, even if it is older/expired
- if cr.Expires != nil && time.Unix(*cr.Expires, 0).Before(time.Now()) {
- // log warning if using expired StatusList2021Credential
- log.Logger().WithError(err).WithField(core.LogFieldCredentialSubject, statusListCredential).
- Info("Validating credentialStatus using expired StatusList2021Credential")
- }
+ // PROJECT-GF: for demo purposes, we always update the statuslist credentials, so we can demo revocation.
+ crUpdated, err := cs.update(statusListCredential)
+ if err == nil {
+ return crUpdated, nil
}
-
- // return credentialRecord, which could be outdated but is the best information available.
return cr, nil
}
diff --git a/vcr/revocation/statuslist2021_verifier_test.go b/vcr/revocation/statuslist2021_verifier_test.go
index dfe9eb6f3f..3d11c9592b 100644
--- a/vcr/revocation/statuslist2021_verifier_test.go
+++ b/vcr/revocation/statuslist2021_verifier_test.go
@@ -132,6 +132,7 @@ func TestStatusList2021_statusList(t *testing.T) {
return *cr, cir
}
t.Run("ok - known credential", func(t *testing.T) {
+ t.Skip("PROJECT-GF: for demo purposes, we always update the statuslist credentials, so we can demo revocation.")
cs, entry, _ := testSetup(t, false)
cs.client = nil // panics if attempts to update
expectedCR, _ := makeRecords(entry.StatusListCredential)
@@ -180,7 +181,7 @@ func TestStatusList2021_statusList(t *testing.T) {
t.Run("ok - exceeded max age", func(t *testing.T) {
cs, _, ts := testSetup(t, false)
cr, cir := makeRecords(ts.URL)
- cr.CreatedAt = time.Now().Add(-2 * maxAgeExternal).Unix()
+ cr.CreatedAt = time.Now().Add(-2 * cs.maxAge).Unix()
require.NoError(t, cs.db.Create(&cr).Error)
actualCR, err := cs.statusList(cir.SubjectID)
diff --git a/vcr/revocation/types.go b/vcr/revocation/types.go
index 4bde9dd649..a7d05f977f 100644
--- a/vcr/revocation/types.go
+++ b/vcr/revocation/types.go
@@ -119,12 +119,14 @@ type StatusList2021 struct {
VerifySignature VerifySignFn // injected by verifier
Sign SignFn // injected by issuer, context must contain an audit log
ResolveKey ResolveKeyFn // injected by issuer
+ // maxAge is the maximum age of external StatusList2021Credentials. If older than this we try to refresh.
+ maxAge time.Duration
}
// NewStatusList2021 returns a StatusList2021 without a Sign or VerifySignature method.
// The URL in the credential will be constructed as follows using the given base URL: /statuslist//
-func NewStatusList2021(db *gorm.DB, client core.HTTPRequestDoer, baseURL string) *StatusList2021 {
- return &StatusList2021{client: client, db: db, baseURL: baseURL}
+func NewStatusList2021(db *gorm.DB, client core.HTTPRequestDoer, baseURL string, maxAgeForRefresh time.Duration) *StatusList2021 {
+ return &StatusList2021{client: client, db: db, baseURL: baseURL, maxAge: maxAgeForRefresh}
}
// StatusList2021Entry is the "credentialStatus" property used by issuers to enable VerifiableCredential status information.
diff --git a/vcr/revocation/types_test.go b/vcr/revocation/types_test.go
index e0a642b5ea..a12ec51672 100644
--- a/vcr/revocation/types_test.go
+++ b/vcr/revocation/types_test.go
@@ -35,7 +35,7 @@ import (
// newTestStatusList2021 returns a StatusList2021 that does not Sign or VerifySignature, with a SQLite db containing the dids, and no http-client.
func newTestStatusList2021(t testing.TB, dids ...did.DID) *StatusList2021 {
- cs := NewStatusList2021(storage.NewTestStorageEngine(t).GetSQLDatabase(), nil, "https://example.com")
+ cs := NewStatusList2021(storage.NewTestStorageEngine(t).GetSQLDatabase(), nil, "https://example.com", 15*time.Minute)
cs.Sign = noopSign
cs.ResolveKey = noopResolveKey
cs.VerifySignature = noopSignVerify
diff --git a/vcr/vcr.go b/vcr/vcr.go
index 7804c5f1e1..120ce58ebe 100644
--- a/vcr/vcr.go
+++ b/vcr/vcr.go
@@ -24,6 +24,12 @@ import (
"encoding/json"
"errors"
"fmt"
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
+ "time"
+
"github.com/nuts-foundation/go-leia/v4"
"github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/pki"
@@ -32,11 +38,6 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/revocation"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
- "io/fs"
- "net/http"
- "path"
- "strings"
- "time"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
@@ -165,6 +166,23 @@ func (c *vcr) Configure(config core.ServerConfig) error {
// copy strictmode for openid4vci usage
c.strictmode = config.Strictmode
+ // Configure Dezi JKU allowlist
+ {
+ var allowedJKU []string
+ if len(c.config.Dezi.AllowedJKU) > 0 {
+ // Use configured values
+ allowedJKU = c.config.Dezi.AllowedJKU
+ } else {
+ // Default behavior: production URL always allowed
+ allowedJKU = []string{"https://auth.dezi.nl/dezi/jwks.json"}
+ if !c.strictmode {
+ // In non-strict mode, also allow acceptance environment
+ allowedJKU = append(allowedJKU, "https://acceptatie.auth.dezi.nl/dezi/jwks.json")
+ }
+ }
+ credential.DefaultDeziUserCredentialValidator = credential.DeziUserCredentialValidator{AllowedJKU: allowedJKU}
+ }
+
// create issuer store (to revoke)
issuerStorePath := path.Join(c.datadir, "vcr", "issued-credentials.db")
issuerBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-issued-credentials", storage.PersistentStorageClass)
@@ -229,7 +247,7 @@ func (c *vcr) Configure(config core.ServerConfig) error {
networkPublisher = issuer.NewNetworkPublisher(c.network, didResolver, c.keyStore)
}
- status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout), config.URL)
+ status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout), config.URL, c.config.Verifier.Revocation.MaxAge)
c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig, status)
c.verifier = verifier.NewVerifier(c.verifierStore, didResolver, c.keyResolver, c.jsonldManager, c.trustConfig, status, c.pkiProvider)
diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go
index d4833943a9..bbc42b7467 100644
--- a/vcr/vcr_test.go
+++ b/vcr/vcr_test.go
@@ -43,6 +43,7 @@ import (
"github.com/nuts-foundation/nuts-node/events"
"github.com/nuts-foundation/nuts-node/jsonld"
+ "github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/verifier"
"go.etcd.io/bbolt"
@@ -108,6 +109,73 @@ func TestVCR_Configure(t *testing.T) {
assert.ErrorContains(t, err, "http request error: strictmode is enabled, but request is not over HTTPS")
})
+ t.Run("Dezi AllowedJKU configuration", func(t *testing.T) {
+ t.Run("uses configured AllowedJKU", func(t *testing.T) {
+ testDirectory := io.TestDirectory(t)
+ ctrl := gomock.NewController(t)
+ vdrInstance := vdr.NewMockVDR(ctrl)
+ vdrInstance.EXPECT().Resolver().AnyTimes()
+ pkiProvider := pki.NewMockProvider(ctrl)
+ pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes()
+ networkInstance := network.NewMockTransactions(ctrl)
+ networkInstance.EXPECT().Disabled().AnyTimes()
+ instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr)
+
+ // Configure custom AllowedJKU
+ instance.config.Dezi.AllowedJKU = []string{"https://custom.dezi.nl/jwks.json"}
+
+ err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) {
+ config.Datadir = testDirectory
+ }))
+
+ require.NoError(t, err)
+ // Verify that the configured value is used
+ assert.Equal(t, []string{"https://custom.dezi.nl/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU)
+ })
+ t.Run("default: production only in strict mode", func(t *testing.T) {
+ testDirectory := io.TestDirectory(t)
+ ctrl := gomock.NewController(t)
+ vdrInstance := vdr.NewMockVDR(ctrl)
+ vdrInstance.EXPECT().Resolver().AnyTimes()
+ pkiProvider := pki.NewMockProvider(ctrl)
+ pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes()
+ networkInstance := network.NewMockTransactions(ctrl)
+ networkInstance.EXPECT().Disabled().AnyTimes()
+ instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr)
+
+ err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) {
+ config.Datadir = testDirectory
+ config.Strictmode = true
+ }))
+
+ require.NoError(t, err)
+ // Verify that only production is allowed
+ assert.Equal(t, []string{"https://auth.dezi.nl/dezi/jwks.json"}, credential.DefaultDeziUserCredentialValidator.AllowedJKU)
+ })
+ t.Run("default: production and acceptance in non-strict mode", func(t *testing.T) {
+ testDirectory := io.TestDirectory(t)
+ ctrl := gomock.NewController(t)
+ vdrInstance := vdr.NewMockVDR(ctrl)
+ vdrInstance.EXPECT().Resolver().AnyTimes()
+ pkiProvider := pki.NewMockProvider(ctrl)
+ pkiProvider.EXPECT().CreateTLSConfig(gomock.Any()).Return(nil, nil).AnyTimes()
+ networkInstance := network.NewMockTransactions(ctrl)
+ networkInstance.EXPECT().Disabled().AnyTimes()
+ instance := NewVCRInstance(nil, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), nil, storage.NewTestStorageEngine(t), pkiProvider).(*vcr)
+
+ err := instance.Configure(core.TestServerConfig(func(config *core.ServerConfig) {
+ config.Datadir = testDirectory
+ config.Strictmode = false
+ }))
+
+ require.NoError(t, err)
+ // Verify that both production and acceptance are allowed
+ assert.Equal(t, []string{
+ "https://auth.dezi.nl/dezi/jwks.json",
+ "https://acceptatie.auth.dezi.nl/dezi/jwks.json",
+ }, credential.DefaultDeziUserCredentialValidator.AllowedJKU)
+ })
+ })
}
func TestVCR_Start(t *testing.T) {
diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go
index 59f833327e..3ff19e471f 100644
--- a/vcr/verifier/verifier.go
+++ b/vcr/verifier/verifier.go
@@ -152,6 +152,10 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus
}
// Check signature
+ // DeziUserCredential: signature is verified by Dezi attestation ("verklaring") inside the credential. Signature verification is skipped here.
+ if credentialToVerify.IsType(credential.DeziUserCredentialTypeURI) {
+ checkSignature = false
+ }
if checkSignature {
issuerDID, _ := did.ParseDID(credentialToVerify.Issuer.String())
metadata := resolver.ResolveMetadata{ResolveTime: validAt, AllowDeactivated: false}
diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go
index 3466d519e4..5fe6754dd9 100644
--- a/vcr/verifier/verifier_test.go
+++ b/vcr/verifier/verifier_test.go
@@ -31,7 +31,6 @@ import (
"testing"
"time"
- "github.com/nuts-foundation/nuts-node/core/to"
"github.com/nuts-foundation/nuts-node/storage/orm"
"github.com/nuts-foundation/nuts-node/test/pki"
@@ -40,6 +39,7 @@ import (
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
+ "github.com/nuts-foundation/nuts-node/core/to"
"github.com/nuts-foundation/nuts-node/crypto/storage/spi"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/storage"
@@ -156,7 +156,7 @@ func TestVerifier_Verify(t *testing.T) {
ctx := newMockContext(t)
ctx.store.EXPECT().GetRevocations(gomock.Any()).Return([]*credential.Revocation{}, ErrNotFound).AnyTimes()
db := storage.NewTestStorageEngine(t).GetSQLDatabase()
- ctx.verifier.credentialStatus = revocation.NewStatusList2021(db, ts.Client(), "https://example.com")
+ ctx.verifier.credentialStatus = revocation.NewStatusList2021(db, ts.Client(), "https://example.com", 15*time.Minute)
ctx.verifier.credentialStatus.(*revocation.StatusList2021).VerifySignature = func(_ vc.VerifiableCredential, _ *time.Time) error { return nil } // don't check signatures on 'downloaded' StatusList2021Credentials
ctx.verifier.credentialStatus.(*revocation.StatusList2021).Sign = func(_ context.Context, unsignedCredential vc.VerifiableCredential, _ string) (*vc.VerifiableCredential, error) {
bs, err := json.Marshal(unsignedCredential)
@@ -307,7 +307,7 @@ func TestVerifier_Verify(t *testing.T) {
assert.EqualError(t, err, "verifiable credential must list at most 2 types")
})
- t.Run("verify x509", func(t *testing.T) {
+ t.Run("X509Credential", func(t *testing.T) {
ura := "312312312"
certs, keys, err := pki.BuildCertChain(nil, ura, nil)
chain := pki.CertsToChain(certs)
@@ -855,7 +855,7 @@ func newMockContext(t *testing.T) mockContext {
trustConfig := trust.NewConfig(path.Join(io.TestDirectory(t), "trust.yaml"))
db := orm.NewTestDatabase(t)
- verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig, revocation.NewStatusList2021(db, nil, ""), nil).(*verifier)
+ verifier := NewVerifier(verifierStore, didResolver, keyResolver, jsonldManager, trustConfig, revocation.NewStatusList2021(db, nil, "", time.Minute*15), nil).(*verifier)
return mockContext{
ctrl: ctrl,
diff --git a/vdr/didx509/resolver.go b/vdr/didx509/resolver.go
index 38af9a321b..02b44126ce 100644
--- a/vdr/didx509/resolver.go
+++ b/vdr/didx509/resolver.go
@@ -23,11 +23,12 @@ import (
"crypto/x509"
"errors"
"fmt"
+ "strings"
+
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/core"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
- "strings"
)
const (
@@ -108,6 +109,7 @@ func (r Resolver) Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did.
validatedChains, err := validationCert.Verify(x509.VerifyOptions{
Intermediates: core.NewCertPool(trustStore.IntermediateCAs),
Roots: core.NewCertPool(trustStore.RootCAs),
+ KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageAny},
})
if err != nil {
return nil, nil, fmt.Errorf("did:509 certificate chain validation failed: %w", err)
diff --git a/vdr/didx509/resolver_test.go b/vdr/didx509/resolver_test.go
index a9c56b3c50..f16beff4a8 100644
--- a/vdr/didx509/resolver_test.go
+++ b/vdr/didx509/resolver_test.go
@@ -23,6 +23,9 @@ import (
"crypto/x509"
"encoding/base64"
"fmt"
+ "strings"
+ "testing"
+
"github.com/lestrrat-go/jwx/v2/cert"
"github.com/minio/sha256-simd"
"github.com/nuts-foundation/go-did/did"
@@ -30,8 +33,6 @@ import (
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "strings"
- "testing"
)
func TestManager_Resolve(t *testing.T) {
@@ -190,6 +191,26 @@ func TestManager_Resolve(t *testing.T) {
_, _, err = didResolver.Resolve(rootDID, &metadata)
require.ErrorContains(t, err, "did:509 certificate chain validation failed: x509: certificate signed by unknown authority")
})
+ t.Run("did:x509 from UZI card", func(t *testing.T) {
+ certsBase64 := []string{
+ "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",
+ }
+ chain := new(cert.Chain)
+ for _, certB64 := range certsBase64 {
+ err := chain.Add([]byte(certB64))
+ require.NoError(t, err)
+ }
+ uziDID := did.MustParseDID("did:x509:0:sha256:KY3NR_y2OphPtJev5NxWhxJ7A-4bNta8OTRnalCbIv4::subject:O:T%C3%A9st%20Zorginstelling%2001::san:otherName:2.16.528.1.1007.99.217-1-900030757-Z-90000380-01.015-00000000")
+ metadata := resolver.ResolveMetadata{}
+ metadata.JwtProtectedHeaders = make(map[string]interface{})
+ metadata.JwtProtectedHeaders[X509CertChainHeader] = chain
+ _, _, err = didResolver.Resolve(uziDID, &metadata)
+ require.NoError(t, err)
+
+ })
t.Run("x5c contains extra certs", func(t *testing.T) {
metadata := resolver.ResolveMetadata{
JwtProtectedHeaders: map[string]interface{}{