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 339d4b0086..c77f8e32d1 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"
@@ -37,6 +36,8 @@ import (
"strings"
"time"
+ "github.com/nuts-foundation/nuts-node/core/to"
+
"github.com/labstack/echo/v4"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
@@ -225,7 +226,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
// - OpenID4VCI
// - OpenID4VP
// verifier DID is taken from code->oauthSession storage
- return r.handleAccessTokenRequest(ctx, *request.Body)
+ return r.handleAuthzCodeTokenRequest(ctx, *request.Body)
case oauth.PreAuthorizedCodeGrantType:
// Options:
// - OpenID4VCI
@@ -234,6 +235,16 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
Code: oauth.UnsupportedGrantType,
Description: "not implemented yet",
}
+ case oauth.JWTBearerGrantType:
+ // NL Generic Functions Authentication flow
+ if request.Body.Assertion == nil || request.Body.Scope == nil ||
+ request.Body.ClientId == nil || request.Body.ClientAssertion == 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.ClientAssertion, *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 {
@@ -242,7 +253,7 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ
Description: "missing required parameters",
}
}
- return r.handleS2SAccessTokenRequest(ctx, *request.Body.ClientId, request.SubjectID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion)
+ return r.handleRFC021VPTokenRequest(ctx, *request.Body.ClientId, request.SubjectID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion)
default:
return nil, oauth.OAuth2Error{
Code: oauth.UnsupportedGrantType,
@@ -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 {
@@ -774,7 +789,11 @@ func (r Wrapper) RequestServiceAccessToken(ctx context.Context, request RequestS
useDPoP = false
}
clientID := r.subjectToBaseURL(request.SubjectID)
- tokenResult, err := r.auth.IAMClient().RequestRFC021AccessToken(ctx, clientID.String(), request.SubjectID, request.Body.AuthorizationServer, request.Body.Scope, useDPoP, credentials)
+ 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)
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
diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go
index 35efca16da..696ace04af 100644
--- a/auth/api/iam/api_test.go
+++ b/auth/api/iam/api_test.go
@@ -886,7 +886,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
request.Params.CacheControl = to.Ptr("no-cache")
// Initial call to populate cache
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil).Times(2)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil).Times(2)
token, err := ctx.client.RequestServiceAccessToken(nil, request)
// Test call to check cache is bypassed
@@ -907,7 +907,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
TokenType: "Bearer",
ExpiresIn: to.Ptr(900),
}
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(response, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(response, nil)
token, err := ctx.client.RequestServiceAccessToken(nil, request)
@@ -946,7 +946,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
t.Run("cache expired", func(t *testing.T) {
cacheKey := accessTokenRequestCacheKey(request)
_ = ctx.client.accessTokenCache().Delete(cacheKey)
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "other"}, nil)
otherToken, err := ctx.client.RequestServiceAccessToken(nil, request)
@@ -963,7 +963,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
Scope: "first second",
TokenType: &tokenTypeBearer,
}
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", false, nil).Return(&oauth.TokenResponse{}, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", false, nil).Return(&oauth.TokenResponse{}, nil)
_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body})
@@ -972,7 +972,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
t.Run("ok with expired cache by ttl", func(t *testing.T) {
ctx := newTestClient(t)
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{ExpiresIn: to.Ptr(5)}, nil)
_, err := ctx.client.RequestServiceAccessToken(nil, request)
@@ -981,7 +981,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
})
t.Run("error - no matching credentials", func(t *testing.T) {
ctx := newTestClient(t)
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(nil, pe.ErrNoCredentials)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(nil, pe.ErrNoCredentials)
_, err := ctx.client.RequestServiceAccessToken(nil, RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body})
@@ -997,8 +997,8 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
ctx.client.storageEngine = mockStorage
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil)
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "first"}, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, nil).Return(&oauth.TokenResponse{AccessToken: "second"}, nil)
token1, err := ctx.client.RequestServiceAccessToken(nil, request)
require.NoError(t, err)
@@ -1023,7 +1023,7 @@ func TestWrapper_RequestServiceAccessToken(t *testing.T) {
{ID: to.Ptr(ssi.MustParseURI("not empty"))},
}
request := RequestServiceAccessTokenRequestObject{SubjectID: holderSubjectID, Body: body}
- ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", true, *body.Credentials).Return(response, nil)
+ ctx.iamClient.EXPECT().RequestRFC021AccessToken(nil, holderClientID, holderSubjectID, verifierURL.String(), "first second", "", true, *body.Credentials).Return(response, nil)
_, err := ctx.client.RequestServiceAccessToken(nil, request)
diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/bearer_token.go
similarity index 69%
rename from auth/api/iam/s2s_vptoken.go
rename to auth/api/iam/bearer_token.go
index c215ea4269..6964d0b696 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"
@@ -33,17 +34,17 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/pe"
)
-// s2sMaxPresentationValidity defines the maximum validity of a presentation.
+// bearerTokenMaxPresentationValidity defines the maximum validity of a presentation.
// This is to prevent replay attacks. The value is specified by Nuts RFC021, and excludes max. clock skew.
-const s2sMaxPresentationValidity = 5 * time.Second
+const bearerTokenMaxPresentationValidity = 5 * time.Second
-// s2sMaxClockSkew defines the maximum clock skew between nodes.
+// bearerTokenMaxClockSkew defines the maximum clock skew between nodes.
// The value is specified by Nuts RFC021.
-const s2sMaxClockSkew = 5 * time.Second
+const bearerTokenMaxClockSkew = 5 * time.Second
-// handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges.
+// handleRFC021VPTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges.
// It performs cheap checks first (parameter presence and validity, matching VCs to the presentation definition), then the more expensive ones (checking signatures).
-func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID string, subject string, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) {
+func (r Wrapper) handleRFC021VPTokenRequest(ctx context.Context, clientID string, subject string, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) {
pexEnvelope, err := pe.ParseEnvelope([]byte(assertionJSON))
if err != nil {
return nil, oauth.OAuth2Error{
@@ -59,8 +60,33 @@ func (r Wrapper) handleS2SAccessTokenRequest(ctx context.Context, clientID strin
}
}
+ response, err := r.handleBearerTokenRequest(ctx, clientID, subject, scope, pexEnvelope.Presentations, SubmissionProfileValidator(*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, clientAssertion string, assertion string) (HandleTokenRequestResponseObject, error) {
+ 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}, BasicProfileValidator(*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, profileValidator CredentialProfileValidatorFunc) (*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 +99,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 = profileValidator(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 +133,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 +146,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) {
@@ -152,10 +185,10 @@ func validateS2SPresentationMaxValidity(presentation vc.VerifiablePresentation)
Description: "presentation is missing creation or expiration date",
}
}
- if expires.Sub(*created) > s2sMaxPresentationValidity {
+ if expires.Sub(*created) > bearerTokenMaxPresentationValidity {
return oauth.OAuth2Error{
Code: oauth.InvalidRequest,
- Description: fmt.Sprintf("presentation is valid for too long (max %s)", s2sMaxPresentationValidity),
+ Description: fmt.Sprintf("presentation is valid for too long (max %s)", bearerTokenMaxPresentationValidity),
}
}
return nil
@@ -218,5 +251,5 @@ var s2sNonceKey = []string{"s2s", "nonce"}
// s2sNonceStore is used by the authorization server for replay prevention by keeping track of used nonces in the s2s flow
func (r Wrapper) s2sNonceStore() storage.SessionStore {
- return r.storageEngine.GetSessionDatabase().GetStore(s2sMaxPresentationValidity+s2sMaxClockSkew, s2sNonceKey...)
+ return r.storageEngine.GetSessionDatabase().GetStore(bearerTokenMaxPresentationValidity+bearerTokenMaxClockSkew, s2sNonceKey...)
}
diff --git a/auth/api/iam/bearer_token_test.go b/auth/api/iam/bearer_token_test.go
new file mode 100644
index 0000000000..f735b68108
--- /dev/null
+++ b/auth/api/iam/bearer_token_test.go
@@ -0,0 +1,521 @@
+/*
+ * 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("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(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil)
+ ctx.policy.EXPECT().PresentationDefinitions(gomock.Any(), requestedScope).Return(walletOwnerMapping, nil)
+
+ resp, err := ctx.client.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
+ require.NoError(t, err)
+
+ resp, err := ctx.client.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(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.handleRFC021VPTokenRequest(contextWithValue, clientID, issuerSubjectID, requestedScope, submissionJSON, presentation.Raw())
+
+ _ = assertOAuthErrorWithCode(t, err, oauth.InvalidDPopProof, "DPoP header is invalid")
+ 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, "NOT USED YET", 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 USED YET", "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 5dbe21544d..8428f6e6e1 100644
--- a/auth/api/iam/generated.go
+++ b/auth/api/iam/generated.go
@@ -146,6 +146,12 @@ type ServiceAccessTokenRequest struct {
// - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature)
Credentials *[]VerifiableCredential `json:"credentials,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"`
@@ -289,6 +295,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/openid4vp.go b/auth/api/iam/openid4vp.go
index 53868ed600..714c5e3260 100644
--- a/auth/api/iam/openid4vp.go
+++ b/auth/api/iam/openid4vp.go
@@ -675,7 +675,7 @@ func (r Wrapper) validatePresentationNonce(presentations []vc.VerifiablePresenta
return nil
}
-func (r Wrapper) handleAccessTokenRequest(ctx context.Context, request HandleTokenRequestFormdataRequestBody) (HandleTokenRequestResponseObject, error) {
+func (r Wrapper) handleAuthzCodeTokenRequest(ctx context.Context, request HandleTokenRequestFormdataRequestBody) (HandleTokenRequestResponseObject, error) {
// check if code is present
if request.Code == nil {
return nil, oauthError(oauth.InvalidRequest, "missing code parameter")
@@ -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/openid4vp_test.go b/auth/api/iam/openid4vp_test.go
index bcc4137bdc..26912d2f7e 100644
--- a/auth/api/iam/openid4vp_test.go
+++ b/auth/api/iam/openid4vp_test.go
@@ -661,7 +661,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID, CodeVerifier: &validSession.PKCEParams.Verifier}
putCodeSession(ctx, code, validSession)
- response, err := ctx.client.handleAccessTokenRequest(contextWithValue, requestBody)
+ response, err := ctx.client.handleAuthzCodeTokenRequest(contextWithValue, requestBody)
require.NoError(t, err)
token, ok := response.(HandleTokenRequest200JSONResponse)
@@ -679,7 +679,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
verifier := "verifier"
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID, CodeVerifier: &verifier}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
require.Error(t, err)
oauthErr, ok := err.(oauth.OAuth2Error)
@@ -693,7 +693,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
clientID := "other"
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID, CodeVerifier: &validSession.PKCEParams.Verifier}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
_ = assertOAuthError(t, err, "client_id does not match: did:web:example.com:iam:holder vs other")
// authz code is burned in failed requests
@@ -703,7 +703,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
ctx := newTestClient(t)
requestBody := HandleTokenRequestFormdataRequestBody{ClientId: &clientID, CodeVerifier: &validSession.PKCEParams.Verifier}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
_ = assertOAuthError(t, err, "missing code parameter")
})
@@ -711,7 +711,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
ctx := newTestClient(t)
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
_ = assertOAuthError(t, err, "missing code_verifier parameter")
})
@@ -719,7 +719,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
ctx := newTestClient(t)
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, ClientId: &clientID, CodeVerifier: &validSession.PKCEParams.Verifier}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
require.Error(t, err)
oauthErr, ok := err.(oauth.OAuth2Error)
@@ -731,7 +731,7 @@ func Test_handleAccessTokenRequest(t *testing.T) {
ctx := newTestClient(t)
requestBody := HandleTokenRequestFormdataRequestBody{Code: &code, CodeVerifier: &validSession.PKCEParams.Verifier}
- _, err := ctx.client.handleAccessTokenRequest(context.Background(), requestBody)
+ _, err := ctx.client.handleAuthzCodeTokenRequest(context.Background(), requestBody)
_ = assertOAuthError(t, err, "missing client_id parameter")
})
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/validation.go b/auth/api/iam/validation.go
index 7809a3ab4f..c9634b6320 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,66 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/pe"
)
+// CredentialProfileValidatorFunc is used to validate a presentation against a presentation definition, and extract the relevant information to be stored in the access token for policy decision and/or claims about the presentation.
+type CredentialProfileValidatorFunc func(ctx context.Context, credentialProfile pe.WalletOwnerMapping, accessToken *AccessToken) error
+
+// SubmissionProfileValidator returns a CredentialProfileValidatorFunc that validates a presentation against the given presentation submission and presentation exchange envelope,
+// according to DIF Presentation Exchange.
+func SubmissionProfileValidator(submission pe.PresentationSubmission, pexEnvelope pe.Envelope) CredentialProfileValidatorFunc {
+ return func(ctx context.Context, credentialProfile pe.WalletOwnerMapping, accessToken *AccessToken) error {
+ pexConsumer := newPEXConsumer(credentialProfile)
+ 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
+ }
+}
+
+// BasicProfileValidator returns a CredentialProfileValidatorFunc that validates a presentation against the presentation definition(s).
+// It does not consume a Presentation Submission.
+func BasicProfileValidator(presentation VerifiablePresentation) CredentialProfileValidatorFunc {
+ return func(ctx context.Context, credentialProfile pe.WalletOwnerMapping, accessToken *AccessToken) error {
+ creds, inputDescriptors, err := credentialProfile[pe.WalletOwnerOrganization].Match(presentation.VerifiableCredential)
+ if err != nil {
+ return oauthError(oauth.InvalidRequest, fmt.Sprintf("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 := credentialProfile[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)
+ 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..ecc891a87f 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 TestSubmissionProfileValidator(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 := SubmissionProfileValidator(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 := SubmissionProfileValidator(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 TestBasicProfileValidator(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 := BasicProfileValidator(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 := BasicProfileValidator(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 := BasicProfileValidator(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 := BasicProfileValidator(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..6244e478f6 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
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/interface.go b/auth/client/iam/interface.go
index 5016708d9d..19fa3082c6 100644
--- a/auth/client/iam/interface.go
+++ b/auth/client/iam/interface.go
@@ -44,7 +44,7 @@ type Client interface {
// PresentationDefinition returns the presentation definition from the given endpoint.
PresentationDefinition(ctx context.Context, endpoint string) (*pe.PresentationDefinition, error)
// RequestRFC021AccessToken is called by the local EHR node to request an access token from a remote OAuth2 Authorization Server using Nuts RFC021.
- 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) (*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 add1a059cd..1d49452ebb 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) (*oauth.TokenResponse, error) {
+func (m *MockClient) RequestRFC021AccessToken(ctx context.Context, clientID, subjectDID, authServerURL, scopes, policyId string, useDPoP bool, credentials []vc.VerifiableCredential) (*oauth.TokenResponse, error) {
m.ctrl.T.Helper()
- ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, useDPoP, credentials)
+ ret := m.ctrl.Call(m, "RequestRFC021AccessToken", ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials)
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 any) *gomock.Call {
+func (mr *MockClientMockRecorder) RequestRFC021AccessToken(ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials 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)
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RequestRFC021AccessToken", reflect.TypeOf((*MockClient)(nil).RequestRFC021AccessToken), ctx, clientID, subjectDID, authServerURL, scopes, policyId, useDPoP, credentials)
}
// VerifiableCredentials mocks base method.
diff --git a/auth/client/iam/openid4vp.go b/auth/client/iam/openid4vp.go
index 0f9e370601..d3f54e62b2 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) (*oauth.TokenResponse, error) {
+ policyId string, useDPoP bool, additionalCredentials []vc.VerifiableCredential) (*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,16 @@ 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)
+ if slices.Contains(metadata.GrantTypesSupported, oauth.JWTBearerGrantType) {
+ // use JWT bearer grant type (e.g. authenticating at LSP GtK)
+ data.Set(oauth.GrantTypeParam, oauth.JWTBearerGrantType)
+ } else {
+ // use VP token grant type (as per Nuts RFC021) as default and fallback
+ 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 e5c4ef6840..27bed126f3 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()).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)
+
+ 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()).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, false, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "some-policy", false, 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()).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)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, 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)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, 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,22 @@ 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)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, credentials)
+
+ 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()).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)
assert.NoError(t, err)
require.NotNil(t, response)
@@ -325,14 +360,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()).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)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", true, 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()).Return(createdVP, &pe.PresentationSubmission{}, nil)
+ response, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, 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 +395,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()).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)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil)
require.Error(t, err)
oauthError, ok := err.(oauth.OAuth2Error)
@@ -357,6 +406,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)
@@ -365,7 +415,7 @@ func TestRelyingParty_RequestRFC021AccessToken(t *testing.T) {
return
}
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil)
require.Error(t, err)
assert.True(t, errors.As(err, &oauth.OAuth2Error{}))
@@ -375,7 +425,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)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrInvalidClientCall)
@@ -383,13 +433,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)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil)
require.Error(t, err)
assert.ErrorIs(t, err, ErrBadGateway)
@@ -397,10 +448,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()).Return(nil, nil, assert.AnError)
- _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, false, nil)
+ _, err := ctx.client.RequestRFC021AccessToken(context.Background(), subjectClientID, subjectID, ctx.verifierURL.String(), scopes, "", false, nil)
assert.Error(t, err)
})
@@ -477,6 +529,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(),
@@ -488,13 +541,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,
}
}
@@ -506,6 +561,7 @@ type clientTestContext struct {
keyResolver *resolver.MockKeyResolver
wallet *holder.MockWallet
subjectManager *didsubject.MockManager
+ policyBackend *policy.MockPDPBackend
}
type clientServerTestContext struct {
diff --git a/auth/oauth/types.go b/auth/oauth/types.go
index c0a6d769d2..f7f09c4703 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,6 +206,8 @@ 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"
)
// response types
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..4cce7e287a 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -201,11 +201,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/docs/_static/auth/iam.partial.yaml b/docs/_static/auth/iam.partial.yaml
index e8520fab41..282f959e23 100644
--- a/docs/_static/auth/iam.partial.yaml
+++ b/docs/_static/auth/iam.partial.yaml
@@ -32,6 +32,8 @@ paths:
type: string
client_id:
type: string
+ client_assertion:
+ type: string
assertion:
type: string
presentation_submission:
diff --git a/docs/_static/auth/v2.yaml b/docs/_static/auth/v2.yaml
index c032a1ff64..7e2c653248 100644
--- a/docs/_static/auth/v2.yaml
+++ b/docs/_static/auth/v2.yaml
@@ -410,6 +410,13 @@ components:
used to locate the OAuth2 Authorization Server metadata.
type: string
example: https://example.com/oauth2
+ policy_id:
+ type: string
+ description: |
+ (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.
scope:
type: string
description: The scope that will be the service for which this access token can be used.
diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go
index 9ed0f8cbac..259b8a8b99 100644
--- a/e2e-tests/browser/client/iam/generated.go
+++ b/e2e-tests/browser/client/iam/generated.go
@@ -140,6 +140,12 @@ type ServiceAccessTokenRequest struct {
// - proof/signature (MUST be omitted; integrity protection is covered by the VP's proof/signature)
Credentials *[]VerifiableCredential `json:"credentials,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"`