diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 544b8bff73..b9e7355708 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -62,12 +62,15 @@ func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, subm } } + var credentialSubjectID did.DID for _, presentation := range pexEnvelope.Presentations { if err := validateS2SPresentationMaxValidity(presentation); err != nil { return nil, err } - if err := validatePresentationSigner(presentation); err != nil { + if subjectDID, err := validatePresentationSigner(presentation, credentialSubjectID); err != nil { return nil, err + } else { + credentialSubjectID = *subjectDID } if err := r.validatePresentationAudience(presentation, issuer); err != nil { return nil, err @@ -96,7 +99,7 @@ func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, subm } // All OK, allow access - response, err := r.createS2SAccessToken(issuer, time.Now(), pexEnvelope.Presentations, *submission, *definition, scope) + response, err := r.createS2SAccessToken(issuer, time.Now(), pexEnvelope.Presentations, *submission, *definition, scope, credentialSubjectID) if err != nil { return nil, err } @@ -143,17 +146,11 @@ func (r *Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessT } func (r *Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, - submission pe.PresentationSubmission, definition PresentationDefinition, scope string) (*oauth.TokenResponse, error) { - // TODO: RFC021 isn't clear on this, so take credential subject from first VP for now. - // See https://github.com/nuts-foundation/nuts-specification/issues/269 - clientDID, err := credential.PresentationSigner(presentations[0]) - if err != nil { - return nil, fmt.Errorf("unable to extract client DID from presentation: %w", err) - } + submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID) (*oauth.TokenResponse, error) { accessToken := AccessToken{ Token: crypto.GenerateNonce(), Issuer: issuer.String(), - ClientId: clientDID.String(), + ClientId: credentialSubjectDID.String(), IssuedAt: issueTime, Expiration: issueTime.Add(accessTokenValidity), Scope: scope, @@ -161,7 +158,7 @@ func (r *Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, pres PresentationDefinition: &definition, PresentationSubmission: &submission, } - err = r.accessTokenStore().Put(accessToken.Token, accessToken) + err := r.accessTokenStore().Put(accessToken.Token, accessToken) if err != nil { return nil, fmt.Errorf("unable to store access token: %w", err) } @@ -217,21 +214,27 @@ func validateS2SPresentationMaxValidity(presentation vc.VerifiablePresentation) } // validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. -func validatePresentationSigner(presentation vc.VerifiablePresentation) error { - ok, err := credential.PresenterIsCredentialSubject(presentation) +func validatePresentationSigner(presentation vc.VerifiablePresentation, expectedCredentialSubjectDID did.DID) (*did.DID, error) { + subjectDID, err := credential.PresenterIsCredentialSubject(presentation) if err != nil { - return oauth.OAuth2Error{ + return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: err.Error(), } } - if !ok { - return oauth.OAuth2Error{ + if subjectDID == nil { + return nil, oauth.OAuth2Error{ Code: oauth.InvalidRequest, Description: "presentation signer is not credential subject", } } - return nil + if !expectedCredentialSubjectDID.Empty() && !subjectDID.Equals(expectedCredentialSubjectDID) { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "not all presentations have the same credential subject ID", + } + } + return subjectDID, nil } // validateS2SPresentationNonce checks if the nonce has been used before; 'nonce' claim for JWTs or LDProof's 'nonce' for JSON-LD. diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index 774c8d8afe..3d088690c9 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -220,6 +220,17 @@ func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { 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, credential.JWTNutsOrganizationCredential(t, secondSubjectID)) + assertionJSON, _ := json.Marshal([]VerifiablePresentation{presentation, secondPresentation}) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, 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) @@ -390,13 +401,15 @@ func TestWrapper_handleS2SAccessTokenRequest(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: ssi.MustParseURI("did:nuts:B8PUHs2AUHbFF1xLLK4eZjgErEcMXHxs68FteY7NDtCY#1"), + VerificationMethod: verificationMethodID, }, }, }) @@ -409,7 +422,8 @@ func TestWrapper_createAccessToken(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - accessToken, err := ctx.client.createS2SAccessToken(issuerDID, time.Now(), []VerifiablePresentation{test.ParsePresentation(t, presentation)}, submission, definition, "everything") + vps := []VerifiablePresentation{test.ParsePresentation(t, presentation)} + accessToken, err := ctx.client.createS2SAccessToken(issuerDID, time.Now(), vps, submission, definition, "everything", credentialSubjectID) require.NoError(t, err) assert.NotEmpty(t, accessToken.AccessToken) diff --git a/vcr/credential/test.go b/vcr/credential/test.go index 4e1f7d87d5..33de4bcd0e 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -26,6 +26,7 @@ import ( "encoding/json" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/assets" "github.com/stretchr/testify/require" "testing" @@ -75,12 +76,13 @@ func ValidNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { return inputVC } -func JWTNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { +func JWTNutsOrganizationCredential(t *testing.T, subjectID did.DID) vc.VerifiableCredential { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) token := jwt.New() require.NoError(t, token.Set("vc", map[string]interface{}{ "credentialSubject": map[string]interface{}{ + "id": subjectID, "organization": map[string]interface{}{ "city": "IJbergen", "name": "care", diff --git a/vcr/credential/util.go b/vcr/credential/util.go index faec84a9dd..afbf2d1884 100644 --- a/vcr/credential/util.go +++ b/vcr/credential/util.go @@ -46,20 +46,20 @@ func ResolveSubjectDID(credentials ...vc.VerifiableCredential) (*did.DID, error) // PresenterIsCredentialSubject checks if the presenter of the VP is the same as the subject of the VCs being presented. // If the presentation signer or credential subject can't be resolved, it returns an error. -// If parsing succeeds and the signer DID is the same as the credential subject DID, it returns true. -func PresenterIsCredentialSubject(vp vc.VerifiablePresentation) (bool, error) { +// If parsing succeeds and the signer DID is the same as the credential subject DID, it returns the DID. +func PresenterIsCredentialSubject(vp vc.VerifiablePresentation) (*did.DID, error) { signerDID, err := PresentationSigner(vp) if err != nil { - return false, err + return nil, err } credentialSubjectID, err := ResolveSubjectDID(vp.VerifiableCredential...) if err != nil { - return false, err + return nil, err } if !credentialSubjectID.Equals(*signerDID) { - return false, nil + return nil, nil } - return true, nil + return signerDID, nil } // PresentationIssuanceDate returns the date at which the presentation was issued. diff --git a/vcr/credential/util_test.go b/vcr/credential/util_test.go index f761445fb6..e7eba1c518 100644 --- a/vcr/credential/util_test.go +++ b/vcr/credential/util_test.go @@ -67,7 +67,7 @@ func TestResolveSubjectDID(t *testing.T) { } func TestPresenterIsCredentialSubject(t *testing.T) { - subjectDID := ssi.MustParseURI("did:test:123") + subjectDID := did.MustParseDID("did:test:123") keyID := ssi.MustParseURI("did:test:123#1") t.Run("ok", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -85,13 +85,13 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.NoError(t, err) - assert.True(t, is) + assert.Equal(t, subjectDID, *is) }) t.Run("no proof", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{}) - is, err := PresenterIsCredentialSubject(vp) + actual, err := PresenterIsCredentialSubject(vp) assert.EqualError(t, err, "presentation should have exactly 1 proof, got 0") - assert.False(t, is) + assert.Nil(t, actual) }) t.Run("no VC subject", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -107,7 +107,7 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.EqualError(t, err, "unable to get subject DID from VC: there must be at least 1 credentialSubject") - assert.False(t, is) + assert.Nil(t, is) }) t.Run("no VC subject ID", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -125,7 +125,7 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.EqualError(t, err, "unable to get subject DID from VC: credential subjects have no ID") - assert.False(t, is) + assert.Nil(t, is) }) t.Run("proof verification method does not equal VC subject ID", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -143,7 +143,7 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.NoError(t, err) - assert.False(t, is) + assert.Nil(t, is) }) t.Run("proof type is unsupported", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -158,7 +158,7 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.EqualError(t, err, "invalid LD-proof for presentation: json: cannot unmarshal bool into Go value of type proof.LDProof") - assert.False(t, is) + assert.Nil(t, is) }) t.Run("too many proofs", func(t *testing.T) { vp := test.ParsePresentation(t, vc.VerifiablePresentation{ @@ -174,7 +174,7 @@ func TestPresenterIsCredentialSubject(t *testing.T) { }) is, err := PresenterIsCredentialSubject(vp) assert.EqualError(t, err, "presentation should have exactly 1 proof, got 2") - assert.False(t, is) + assert.Nil(t, is) }) } diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index eef3cb75db..875675a9af 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,6 +24,7 @@ import ( "crypto/rand" "embed" "encoding/json" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/credential" "testing" @@ -89,7 +90,7 @@ func definitions() testDefinitions { func TestMatch(t *testing.T) { jsonldVC := credential.ValidNutsOrganizationCredential(t) - jwtVC := credential.JWTNutsOrganizationCredential(t) + jwtVC := credential.JWTNutsOrganizationCredential(t, did.MustParseDID("did:web:example.com")) t.Run("Basic", func(t *testing.T) { t.Run("JSON-LD", func(t *testing.T) {