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"`