diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index e5e3566031..df9995620f 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -62,6 +62,17 @@ const accessTokenValidity = 15 * time.Minute const oid4vciSessionValidity = 15 * time.Minute +// userSessionCookieName is the name of the cookie used to store the user session. +// It uses the __Host prefix, that instructs the user agent to treat it as a secure cookie: +// - Must be set with the Secure attribute +// - Must be set from an HTTPS uri +// - Must not contain a Domain attribute +// - Must contain a Path attribute +// Also see: +// - https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/06-Session_Management_Testing/02-Testing_for_Cookies_Attributes +// - https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies +const userSessionCookieName = "__Host-SID" + // Wrapper handles OAuth2 flows. type Wrapper struct { vcr vcr.VCR @@ -223,26 +234,14 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce iat := int(token.IssuedAt.Unix()) exp := int(token.Expiration.Unix()) response := IntrospectAccessToken200JSONResponse{ - Active: true, - Iat: &iat, - Exp: &exp, - Iss: &token.Issuer, - Sub: &token.Issuer, - ClientId: &token.ClientId, - Scope: &token.Scope, - InputDescriptorConstraintIdMap: &token.InputDescriptorConstraintIdMap, - PresentationDefinition: nil, - PresentationSubmission: nil, - Vps: &token.VPToken, - - // TODO: user authentication, used in OpenID4VP flow - FamilyName: nil, - Prefix: nil, - Initials: nil, - AssuranceLevel: nil, - Email: nil, - UserRole: nil, - Username: nil, + Active: true, + Iat: &iat, + Exp: &exp, + Iss: &token.Issuer, + Sub: &token.Issuer, + ClientId: &token.ClientId, + Scope: &token.Scope, + Vps: &token.VPToken, } // set presentation definition if in token @@ -259,6 +258,16 @@ func (r Wrapper) IntrospectAccessToken(_ context.Context, request IntrospectAcce log.Logger().WithError(err).Error("IntrospectAccessToken: failed to marshal presentation submission") return IntrospectAccessToken200JSONResponse{}, err } + + if token.InputDescriptorConstraintIdMap != nil { + for _, reserved := range []string{"iss", "sub", "exp", "iat", "active", "client_id", "scope"} { + if _, exists := token.InputDescriptorConstraintIdMap[reserved]; exists { + return nil, errors.New(fmt.Sprintf("IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name '%s'", reserved)) + } + } + response.AdditionalProperties = token.InputDescriptorConstraintIdMap + } + return response, nil } @@ -469,11 +478,16 @@ func (r Wrapper) PresentationDefinition(ctx context.Context, request Presentatio } } - if _, ok := mapping[pe.WalletOwnerOrganization]; !ok { - return nil, oauthError(oauth.ServerError, "no presentation definition found for organization wallet") + walletOwnerType := pe.WalletOwnerOrganization + if request.Params.WalletOwnerType != nil { + walletOwnerType = *request.Params.WalletOwnerType + } + result, exists := mapping[walletOwnerType] + if !exists { + return nil, oauthError(oauth.InvalidRequest, fmt.Sprintf("no presentation definition found for '%s' wallet", walletOwnerType)) } - return PresentationDefinition200JSONResponse(mapping[pe.WalletOwnerOrganization]), nil + return PresentationDefinition200JSONResponse(result), nil } // toOwnedDIDForOAuth2 is like toOwnedDID but wraps the errors in oauth.OAuth2Error to make sure they're returned as specified by the OAuth2 RFC. diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 6cb8dda7ce..42c92b521e 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -226,6 +226,7 @@ func TestWrapper_PresentationDefinition(t *testing.T) { webDID := did.MustParseDID("did:web:example.com:iam:123") ctx := audit.TestContext() walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerOrganization: pe.PresentationDefinition{Id: "test"}} + userWalletType := pe.WalletOwnerUser t.Run("ok", func(t *testing.T) { test := newTestClient(t) @@ -251,6 +252,33 @@ func TestWrapper_PresentationDefinition(t *testing.T) { assert.True(t, ok) }) + t.Run("ok - user wallet", func(t *testing.T) { + walletOwnerMapping := pe.WalletOwnerMapping{pe.WalletOwnerUser: pe.PresentationDefinition{Id: "test"}} + + test := newTestClient(t) + test.policy.EXPECT().PresentationDefinitions(gomock.Any(), webDID, "example-scope").Return(walletOwnerMapping, nil) + test.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.String(), Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) + + require.NoError(t, err) + require.NotNil(t, response) + _, ok := response.(PresentationDefinition200JSONResponse) + assert.True(t, ok) + }) + + t.Run("err - unknown wallet type", func(t *testing.T) { + test := newTestClient(t) + test.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) + test.policy.EXPECT().PresentationDefinitions(gomock.Any(), webDID, "example-scope").Return(walletOwnerMapping, nil) + + response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.String(), Params: PresentationDefinitionParams{Scope: "example-scope", WalletOwnerType: &userWalletType}}) + + require.Error(t, err) + assert.Nil(t, response) + assert.Equal(t, "invalid_request - no presentation definition found for 'user' wallet", err.Error()) + }) + t.Run("error - unknown scope", func(t *testing.T) { test := newTestClient(t) test.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) @@ -639,6 +667,36 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.True(t, ok) assert.True(t, tokenResponse.Active) }) + t.Run("with claims from InputDescriptorConstraintIdMap", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Second), + InputDescriptorConstraintIdMap: map[string]any{ + "family_name": "Doe", + }, + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) + + res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + + require.NoError(t, err) + tokenResponse, ok := res.(IntrospectAccessToken200JSONResponse) + require.True(t, ok) + assert.Equal(t, "Doe", tokenResponse.AdditionalProperties["family_name"]) + }) + t.Run("InputDescriptorConstraintIdMap contains reserved claim", func(t *testing.T) { + token := AccessToken{ + Expiration: time.Now().Add(time.Second), + InputDescriptorConstraintIdMap: map[string]any{ + "iss": "value", + }, + } + require.NoError(t, ctx.client.accessTokenServerStore().Put("token", token)) + + res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) + + require.EqualError(t, err, "IntrospectAccessToken: InputDescriptorConstraintIdMap contains reserved claim name 'iss'") + require.Nil(t, res) + }) t.Run(" ok - s2s flow", func(t *testing.T) { // TODO: this should be an integration test to make sure all fields are set @@ -663,17 +721,17 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { require.NoError(t, ctx.client.accessTokenServerStore().Put(token.Token, token)) expectedResponse, err := json.Marshal(IntrospectAccessToken200JSONResponse{ - Active: true, - ClientId: ptrTo("client"), - Exp: ptrTo(int(tNow.Add(time.Minute).Unix())), - Iat: ptrTo(int(tNow.Unix())), - Iss: ptrTo("resource-owner"), - Scope: ptrTo("test"), - Sub: ptrTo("resource-owner"), - Vps: &[]VerifiablePresentation{presentation}, - InputDescriptorConstraintIdMap: ptrTo(map[string]any{"key": "value"}), - PresentationSubmission: ptrTo(map[string]interface{}{"definition_id": "", "descriptor_map": nil, "id": ""}), - PresentationDefinition: ptrTo(map[string]interface{}{"id": "", "input_descriptors": nil}), + Active: true, + ClientId: ptrTo("client"), + Exp: ptrTo(int(tNow.Add(time.Minute).Unix())), + Iat: ptrTo(int(tNow.Unix())), + Iss: ptrTo("resource-owner"), + Scope: ptrTo("test"), + Sub: ptrTo("resource-owner"), + Vps: &[]VerifiablePresentation{presentation}, + PresentationSubmission: ptrTo(map[string]interface{}{"definition_id": "", "descriptor_map": nil, "id": ""}), + PresentationDefinition: ptrTo(map[string]interface{}{"id": "", "input_descriptors": nil}), + AdditionalProperties: map[string]interface{}{"key": "value"}, }) require.NoError(t, err) diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 8b528fadcb..330d311ff3 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -19,13 +19,6 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) -// Defines values for TokenIntrospectionResponseAssuranceLevel. -const ( - High TokenIntrospectionResponseAssuranceLevel = "high" - Low TokenIntrospectionResponseAssuranceLevel = "low" - Substantial TokenIntrospectionResponseAssuranceLevel = "substantial" -) - // RedirectResponseWithID defines model for RedirectResponseWithID. type RedirectResponseWithID struct { // RedirectUri The URL to which the user-agent will be redirected after the authorization request. @@ -36,6 +29,7 @@ type RedirectResponseWithID struct { } // TokenIntrospectionRequest Token introspection request as described in RFC7662 section 2.1 +// Alongside the defined properties, it can return values (additionalProperties) from the Verifiable Credentials that resulted from the Presentation Exchange. type TokenIntrospectionRequest struct { Token string `json:"token"` } @@ -45,40 +39,21 @@ type TokenIntrospectionResponse struct { // Active True if the token is active, false if the token is expired, malformed etc. Required per RFC7662 Active bool `json:"active"` - // AssuranceLevel Assurance level of the identity of the End-User. - AssuranceLevel *TokenIntrospectionResponseAssuranceLevel `json:"assurance_level,omitempty"` - // Aud RFC7662 - Service-specific string identifier or list of string identifiers representing the intended audience for this token, as defined in JWT [RFC7519]. Aud *string `json:"aud,omitempty"` // ClientId The client (DID) the access token was issued to ClientId *string `json:"client_id,omitempty"` - // Email End-User's preferred e-mail address. Should be a personal email and can be used to uniquely identify a user. Just like the email used for an account. - Email *string `json:"email,omitempty"` - // Exp Expiration date in seconds since UNIX epoch Exp *int `json:"exp,omitempty"` - // FamilyName Surname(s) or last name(s) of the End-User. - FamilyName *string `json:"family_name,omitempty"` - // Iat Issuance time in seconds since UNIX epoch Iat *int `json:"iat,omitempty"` - // Initials Initials of the End-User. - Initials *string `json:"initials,omitempty"` - - // InputDescriptorConstraintIdMap Mapping from the ID field of a 'presentation_definition' input descriptor constraints to the value provided in the 'vps' for the constraints. - // The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation - InputDescriptorConstraintIdMap *map[string]interface{} `json:"input_descriptor_constraint_id_map,omitempty"` - // Iss Contains the DID of the authorizer. Should be equal to 'sub' Iss *string `json:"iss,omitempty"` - // Prefix Surname prefix - Prefix *string `json:"prefix,omitempty"` - // PresentationDefinition presentation definition, as described in presentation exchange specification, fulfilled to obtain the access token PresentationDefinition *map[string]interface{} `json:"presentation_definition,omitempty"` @@ -91,19 +66,11 @@ type TokenIntrospectionResponse struct { // Sub Contains the DID of the resource owner Sub *string `json:"sub,omitempty"` - // UserRole Role of the End-User. - UserRole *string `json:"user_role,omitempty"` - - // Username Identifier uniquely identifying the End-User's account in the issuing system. - Username *string `json:"username,omitempty"` - // Vps The Verifiable Presentations that were used to request the access token using the same encoding as used in the access token request. - Vps *[]VerifiablePresentation `json:"vps,omitempty"` + Vps *[]VerifiablePresentation `json:"vps,omitempty"` + AdditionalProperties map[string]interface{} `json:"-"` } -// TokenIntrospectionResponseAssuranceLevel Assurance level of the identity of the End-User. -type TokenIntrospectionResponseAssuranceLevel string - // UserDetails Claims about the authorized user. type UserDetails struct { // Id Machine-readable identifier, uniquely identifying the user in the issuing system. @@ -190,7 +157,8 @@ type CallbackParams struct { // PresentationDefinitionParams defines parameters for PresentationDefinition. type PresentationDefinitionParams struct { - Scope string `form:"scope" json:"scope"` + Scope string `form:"scope" json:"scope"` + WalletOwnerType *WalletOwnerType `form:"wallet_owner_type,omitempty" json:"wallet_owner_type,omitempty"` } // HandleAuthorizeResponseFormdataBody defines parameters for HandleAuthorizeResponse. @@ -238,6 +206,222 @@ type HandleAuthorizeResponseFormdataRequestBody HandleAuthorizeResponseFormdataB // HandleTokenRequestFormdataRequestBody defines body for HandleTokenRequest for application/x-www-form-urlencoded ContentType. type HandleTokenRequestFormdataRequestBody HandleTokenRequestFormdataBody +// Getter for additional properties for TokenIntrospectionResponse. Returns the specified +// element and whether it was found +func (a TokenIntrospectionResponse) Get(fieldName string) (value interface{}, found bool) { + if a.AdditionalProperties != nil { + value, found = a.AdditionalProperties[fieldName] + } + return +} + +// Setter for additional properties for TokenIntrospectionResponse +func (a *TokenIntrospectionResponse) Set(fieldName string, value interface{}) { + if a.AdditionalProperties == nil { + a.AdditionalProperties = make(map[string]interface{}) + } + a.AdditionalProperties[fieldName] = value +} + +// Override default JSON handling for TokenIntrospectionResponse to handle AdditionalProperties +func (a *TokenIntrospectionResponse) UnmarshalJSON(b []byte) error { + object := make(map[string]json.RawMessage) + err := json.Unmarshal(b, &object) + if err != nil { + return err + } + + if raw, found := object["active"]; found { + err = json.Unmarshal(raw, &a.Active) + if err != nil { + return fmt.Errorf("error reading 'active': %w", err) + } + delete(object, "active") + } + + if raw, found := object["aud"]; found { + err = json.Unmarshal(raw, &a.Aud) + if err != nil { + return fmt.Errorf("error reading 'aud': %w", err) + } + delete(object, "aud") + } + + if raw, found := object["client_id"]; found { + err = json.Unmarshal(raw, &a.ClientId) + if err != nil { + return fmt.Errorf("error reading 'client_id': %w", err) + } + delete(object, "client_id") + } + + if raw, found := object["exp"]; found { + err = json.Unmarshal(raw, &a.Exp) + if err != nil { + return fmt.Errorf("error reading 'exp': %w", err) + } + delete(object, "exp") + } + + if raw, found := object["iat"]; found { + err = json.Unmarshal(raw, &a.Iat) + if err != nil { + return fmt.Errorf("error reading 'iat': %w", err) + } + delete(object, "iat") + } + + if raw, found := object["iss"]; found { + err = json.Unmarshal(raw, &a.Iss) + if err != nil { + return fmt.Errorf("error reading 'iss': %w", err) + } + delete(object, "iss") + } + + if raw, found := object["presentation_definition"]; found { + err = json.Unmarshal(raw, &a.PresentationDefinition) + if err != nil { + return fmt.Errorf("error reading 'presentation_definition': %w", err) + } + delete(object, "presentation_definition") + } + + if raw, found := object["presentation_submission"]; found { + err = json.Unmarshal(raw, &a.PresentationSubmission) + if err != nil { + return fmt.Errorf("error reading 'presentation_submission': %w", err) + } + delete(object, "presentation_submission") + } + + if raw, found := object["scope"]; found { + err = json.Unmarshal(raw, &a.Scope) + if err != nil { + return fmt.Errorf("error reading 'scope': %w", err) + } + delete(object, "scope") + } + + if raw, found := object["sub"]; found { + err = json.Unmarshal(raw, &a.Sub) + if err != nil { + return fmt.Errorf("error reading 'sub': %w", err) + } + delete(object, "sub") + } + + if raw, found := object["vps"]; found { + err = json.Unmarshal(raw, &a.Vps) + if err != nil { + return fmt.Errorf("error reading 'vps': %w", err) + } + delete(object, "vps") + } + + if len(object) != 0 { + a.AdditionalProperties = make(map[string]interface{}) + for fieldName, fieldBuf := range object { + var fieldVal interface{} + err := json.Unmarshal(fieldBuf, &fieldVal) + if err != nil { + return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) + } + a.AdditionalProperties[fieldName] = fieldVal + } + } + return nil +} + +// Override default JSON handling for TokenIntrospectionResponse to handle AdditionalProperties +func (a TokenIntrospectionResponse) MarshalJSON() ([]byte, error) { + var err error + object := make(map[string]json.RawMessage) + + object["active"], err = json.Marshal(a.Active) + if err != nil { + return nil, fmt.Errorf("error marshaling 'active': %w", err) + } + + if a.Aud != nil { + object["aud"], err = json.Marshal(a.Aud) + if err != nil { + return nil, fmt.Errorf("error marshaling 'aud': %w", err) + } + } + + if a.ClientId != nil { + object["client_id"], err = json.Marshal(a.ClientId) + if err != nil { + return nil, fmt.Errorf("error marshaling 'client_id': %w", err) + } + } + + if a.Exp != nil { + object["exp"], err = json.Marshal(a.Exp) + if err != nil { + return nil, fmt.Errorf("error marshaling 'exp': %w", err) + } + } + + if a.Iat != nil { + object["iat"], err = json.Marshal(a.Iat) + if err != nil { + return nil, fmt.Errorf("error marshaling 'iat': %w", err) + } + } + + if a.Iss != nil { + object["iss"], err = json.Marshal(a.Iss) + if err != nil { + return nil, fmt.Errorf("error marshaling 'iss': %w", err) + } + } + + if a.PresentationDefinition != nil { + object["presentation_definition"], err = json.Marshal(a.PresentationDefinition) + if err != nil { + return nil, fmt.Errorf("error marshaling 'presentation_definition': %w", err) + } + } + + if a.PresentationSubmission != nil { + object["presentation_submission"], err = json.Marshal(a.PresentationSubmission) + if err != nil { + return nil, fmt.Errorf("error marshaling 'presentation_submission': %w", err) + } + } + + if a.Scope != nil { + object["scope"], err = json.Marshal(a.Scope) + if err != nil { + return nil, fmt.Errorf("error marshaling 'scope': %w", err) + } + } + + if a.Sub != nil { + object["sub"], err = json.Marshal(a.Sub) + if err != nil { + return nil, fmt.Errorf("error marshaling 'sub': %w", err) + } + } + + if a.Vps != nil { + object["vps"], err = json.Marshal(a.Vps) + if err != nil { + return nil, fmt.Errorf("error marshaling 'vps': %w", err) + } + } + + for fieldName, field := range a.AdditionalProperties { + object[fieldName], err = json.Marshal(field) + if err != nil { + return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) + } + } + return json.Marshal(object) +} + // ServerInterface represents all server handlers. type ServerInterface interface { // Returns the root did:web DID of this domain. @@ -574,6 +758,13 @@ func (w *ServerInterfaceWrapper) PresentationDefinition(ctx echo.Context) error return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter scope: %s", err)) } + // ------------- Optional query parameter "wallet_owner_type" ------------- + + err = runtime.BindQueryParameter("form", true, false, "wallet_owner_type", ctx.QueryParams(), ¶ms.WalletOwnerType) + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, fmt.Sprintf("Invalid format for parameter wallet_owner_type: %s", err)) + } + // Invoke the callback with all the unmarshaled arguments err = w.Handler.PresentationDefinition(ctx, did, params) return err diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index b798df8b06..72b85f84be 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -537,7 +537,7 @@ func (r Wrapper) handleAccessTokenRequest(ctx context.Context, verifier did.DID, return nil, withCallbackURI(oauthError(oauth.InvalidRequest, fmt.Sprintf("client_id does not match: %s vs %s", oauthSession.ClientID, *clientId)), callbackURI) } - state := oauthSession.ServerState + state := oauthSession.ServerState mapping, err := r.policyBackend.PresentationDefinitions(ctx, verifier, oauthSession.Scope) if err != nil { return nil, withCallbackURI(oauthError(oauth.ServerError, fmt.Sprintf("failed to fetch presentation definition: %s", err.Error())), callbackURI) diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index b8cdbf8f83..d865b40664 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -19,6 +19,9 @@ package iam import ( + "errors" + "fmt" + "github.com/lestrrat-go/jwx/v2/jwk" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/http" @@ -39,7 +42,6 @@ type OAuthSession struct { ServerState ServerState ResponseType string PresentationDefinition PresentationDefinition - UserDetails UserDetails VerifierDID *did.DID // TODO use these 2 fields to track if all OpenID4VP flows have been concluded @@ -49,6 +51,44 @@ type OAuthSession struct { Presentations map[string]vc.VerifiablePresentation } +// UserSession is a session-bound Verifiable Credential wallet. +type UserSession struct { + // TenantDID is the requesting DID when the user session was created, typically the employer's (of the user) DID. + // A session needs to be scoped to the tenant DID, since the session gives access to the tenant's wallet, + // and the user session might contain session-bound credentials (e.g. EmployeeCredential) that were issued by the tenant. + TenantDID did.DID `json:"tenantDID"` + // PreAuthorizedUser is the user that is pre-authorized by the client application. + // It is stored to later assert that subsequent RequestUserAccessToken() calls that (accidentally or intentionally) + // re-use the browser session, are indeed for the same client application user. + PreAuthorizedUser *UserDetails `json:"preauthorized_user"` + Wallet UserWallet `json:"wallet"` +} + +// UserWallet is a session-bound Verifiable Credential wallet. +// It's an in-memory wallet which contains the user's private key in plain text. +// This is OK, since the associated credentials are intended for protocol compatibility (OpenID4VP with a low-assurance EmployeeCredential), +// when an actual user wallet is involved, this wallet isn't used. +type UserWallet struct { + Credentials []vc.VerifiableCredential + // JWK is an in-memory key pair associated with the user's wallet in JWK form. + JWK []byte + // DID is the did:jwk DID of the user's wallet. + DID did.DID +} + +// Key returns the JWK as jwk.Key +func (w UserWallet) Key() (jwk.Key, error) { + set, err := jwk.Parse(w.JWK) + if err != nil { + return nil, fmt.Errorf("failed to parse JWK: %w", err) + } + result, available := set.Key(0) + if !available { + return nil, errors.New("expected exactly 1 key in the JWK set") + } + return result, nil +} + // ServerState is a convenience type for extracting different types of data from the session. type ServerState struct { CredentialMap map[string]vc.VerifiableCredential diff --git a/auth/api/iam/session_test.go b/auth/api/iam/session_test.go new file mode 100644 index 0000000000..21fe7ee644 --- /dev/null +++ b/auth/api/iam/session_test.go @@ -0,0 +1,27 @@ +package iam + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestUserWallet_Key(t *testing.T) { + t.Run("ok", func(t *testing.T) { + pk, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + keyAsJWK, err := jwk.FromRaw(pk) + require.NoError(t, err) + jwkAsJSON, _ := json.Marshal(keyAsJWK) + wallet := UserWallet{ + JWK: jwkAsJSON, + } + key, err := wallet.Key() + require.NoError(t, err) + assert.Equal(t, keyAsJWK, key) + }) +} diff --git a/auth/api/iam/types.go b/auth/api/iam/types.go index 5b0c4d6552..a20cf5bb17 100644 --- a/auth/api/iam/types.go +++ b/auth/api/iam/types.go @@ -24,6 +24,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "net/http" "time" ) @@ -59,10 +60,20 @@ type OAuthAuthorizationServerMetadata = oauth.AuthorizationServerMetadata // OAuthClientMetadata is an alias type OAuthClientMetadata = oauth.OAuthClientMetadata +// WalletOwnerType is an alias +type WalletOwnerType = pe.WalletOwnerType + const ( sessionExpiry = 5 * time.Minute ) +// CookieReader is an interface for reading cookies from an HTTP request. +// It is implemented by echo.Context and http.Request. +type CookieReader interface { + // Cookie returns the named cookie provided in the request. + Cookie(name string) (*http.Cookie, error) +} + const ( // oauth.ResponseTypeParam is the name of the response_type parameter. // Specified by https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.1 diff --git a/auth/api/iam/user.go b/auth/api/iam/user.go index 141d6121ef..50a7306168 100644 --- a/auth/api/iam/user.go +++ b/auth/api/iam/user.go @@ -19,9 +19,18 @@ package iam import ( + "context" + "encoding/base64" + "encoding/json" + "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwk" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/credential" + issuer "github.com/nuts-foundation/nuts-node/vcr/issuer" "net/http" "time" @@ -79,6 +88,25 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { return err } + session, err := r.loadUserSession(echoCtx, redirectSession.OwnDID, accessTokenRequest.Body.PreauthorizedUser) + if err != nil { + // Should only really occur in exceptional circumstances (e.g. cookie survived after intended max age). + log.Logger().WithError(err).Info("Invalid user session, a new session will be created") + } + if session == nil { + wallet, err := r.createUserWallet(echoCtx.Request().Context(), redirectSession.OwnDID, *accessTokenRequest.Body.PreauthorizedUser) + if err != nil { + return fmt.Errorf("create user wallet: %w", err) + } + // this causes the session cookie to be set + if err = r.createUserSession(echoCtx, UserSession{ + TenantDID: redirectSession.OwnDID, + Wallet: *wallet, + }); err != nil { + return fmt.Errorf("create user session: %w", err) + } + } + // burn token err = store.Delete(token) if err != nil { @@ -93,7 +121,6 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { VerifierDID: verifier, SessionID: redirectSession.SessionID, RedirectURI: accessTokenRequest.Body.RedirectUri, - UserDetails: *accessTokenRequest.Body.PreauthorizedUser, } // store user session in session store under sessionID and clientState err = r.oauthClientStateStore().Put(oauthSession.ClientState, oauthSession) @@ -113,6 +140,7 @@ func (r Wrapper) handleUserLanding(echoCtx echo.Context) error { values[oauth.StateParam] = oauthSession.ClientState values[oauth.ScopeParam] = accessTokenRequest.Body.Scope } + // TODO: First create user session, or AuthorizationRequest first? (which one is more expensive? both sign stuff) redirectURL, err := r.auth.IAMClient().CreateAuthorizationRequest(echoCtx.Request().Context(), redirectSession.OwnDID, *verifier, modifier) if err != nil { return err @@ -124,6 +152,10 @@ func (r Wrapper) userRedirectStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(userRedirectTimeout, userRedirectSessionKey...) } +func (r Wrapper) userSessionStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(userSessionTimeout, userSessionKey...) +} + func (r Wrapper) oauthClientStateStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthClientStateKey...) } @@ -131,3 +163,135 @@ func (r Wrapper) oauthClientStateStore() storage.SessionStore { func (r Wrapper) oauthCodeStore() storage.SessionStore { return r.storageEngine.GetSessionDatabase().GetStore(oAuthFlowTimeout, oauthCodeKey...) } + +// loadUserSession loads the user session given the session ID in the cookie. +// If there is no session cookie (not yet authenticated, or the session expired), nil is returned. +// If another, technical error occurs when retrieving the session. +func (r Wrapper) loadUserSession(cookies CookieReader, tenantDID did.DID, preAuthorizedUser *UserDetails) (*UserSession, error) { + cookie, err := cookies.Cookie(userSessionCookieName) + if err != nil { + // sadly, no cookie for you + // Cookie only returns http.ErrNoCookie + return nil, nil + } + session := new(UserSession) + if err = r.userSessionStore().Get(cookie.Value, session); errors.Is(err, storage.ErrNotFound) { + return nil, errors.New("unknown or expired session") + } else if err != nil { + // other error occurred + return nil, err + } + // Note that the session itself does not have an expiration field: + // it depends on the session store to clean up when it expires. + if !session.TenantDID.Equals(tenantDID) { + return nil, fmt.Errorf("session belongs to another tenant (%s)", session.TenantDID) + } + // If the existing session was created for a pre-authorized user, the call to RequestUserAccessToken() must be + // for the same user. + // TODO: When we support external Identity Providers, make sure the existing session was not for a preauthorized user. + if *preAuthorizedUser != *session.PreAuthorizedUser { + return nil, errors.New("session belongs to another pre-authorized user") + } + return session, nil +} + +func (r Wrapper) createUserSession(ctx echo.Context, session UserSession) error { + sessionID := crypto.GenerateNonce() + if err := r.userSessionStore().Put(sessionID, session); err != nil { + return err + } + // Do not set Expires: then it isn't a session cookie anymore. + // TODO: we could make this more secure by narrowing the Path, but we currently have the following user-facing paths: + // - /iam/:did/(openid4vp_authz_accept) + // - /oauth2/:did/user + // If we move these under a common base path (/oauth2 or /iam), we could use that as Path property + // The issue with the current approach is that we have a single cookie for the whole domain, + // thus a new user session for a different DID will overwrite the current one (since a new cookie is created). + // By scoping the cookies to a tenant (DID)-specific path, they can co-exist. + var path string + if r.auth.PublicURL().Path != "" { + path = r.auth.PublicURL().Path + } else { + path = "/" + } + ctx.SetCookie(&http.Cookie{ + Name: userSessionCookieName, + Value: sessionID, + Path: path, + MaxAge: int(userSessionTimeout.Seconds()), + Secure: true, + HttpOnly: true, // do not let JavaScript + SameSite: http.SameSiteStrictMode, // do not allow the cookie to be sent with cross-site requests + }) + return nil +} + +func (r Wrapper) createUserWallet(ctx context.Context, issuerDID did.DID, userDetails UserDetails) (*UserWallet, error) { + userJWK, userDID, err := generateUserSessionJWK() + if err != nil { + return nil, err + } + userJWKBytes, err := json.Marshal(userJWK) + if err != nil { + return nil, err + } + // create user session wallet + wallet := UserWallet{ + JWK: userJWKBytes, + DID: *userDID, + } + issuanceDate := time.Now() + expirationDate := issuanceDate.Add(userSessionTimeout) + template := vc.VerifiableCredential{ + Context: []ssi.URI{credential.NutsV1ContextURI}, + Type: []ssi.URI{ssi.MustParseURI("EmployeeCredential")}, + Issuer: issuerDID.URI(), + IssuanceDate: issuanceDate, + ExpirationDate: &expirationDate, + CredentialSubject: []interface{}{ + map[string]string{ + "id": userDID.String(), + "identifier": userDetails.Id, + "name": userDetails.Name, + "roleName": userDetails.Role, + }, + }, + } + employeeCredential, err := r.vcr.Issuer().Issue(ctx, template, issuer.CredentialOptions{ + Format: vc.JWTCredentialProofFormat, + Publish: false, + Public: false, + WithStatusListRevocation: false, + }) + if err != nil { + return nil, fmt.Errorf("issue EmployeeCredential: %w", err) + } + wallet.Credentials = append(wallet.Credentials, *employeeCredential) + return &wallet, nil +} + +func generateUserSessionJWK() (jwk.Key, *did.DID, error) { + // Generate a key pair and JWK for storage + userJWK, err := crypto.GenerateJWK() + if err != nil { + return nil, nil, err + } + // Now derive the did:jwk DID + publicKey, err := userJWK.PublicKey() + if err != nil { + return nil, nil, err + } + publicUserJSON, err := json.Marshal(publicKey) + if err != nil { + return nil, nil, err + } + userDID, err := did.ParseDID("did:jwk:" + base64.RawStdEncoding.EncodeToString(publicUserJSON)) + if err != nil { + return nil, nil, err + } + if err := userJWK.Set(jwk.KeyIDKey, userDID.String()+"#0"); err != nil { + return nil, nil, err + } + + return userJWK, userDID, nil +} diff --git a/auth/api/iam/user_test.go b/auth/api/iam/user_test.go index 25cc095af2..e56b6135bc 100644 --- a/auth/api/iam/user_test.go +++ b/auth/api/iam/user_test.go @@ -19,11 +19,17 @@ package iam import ( + "context" + "github.com/lestrrat-go/jwx/v2/jwa" + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth/client/iam" "github.com/nuts-foundation/nuts-node/mock" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "go.uber.org/mock/gomock" "net/http" "net/url" + "strings" "testing" "time" @@ -32,11 +38,23 @@ import ( "github.com/stretchr/testify/require" ) +var walletDID = did.MustParseDID("did:web:example.com:iam:123") +var userDID = did.MustParseDID("did:jwk:really-a-jwk") + +var sessionCookie = http.Cookie{ + Name: "__Host-SID", + Value: "sessionID", + Path: "/", + Secure: true, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, +} + func TestWrapper_handleUserLanding(t *testing.T) { - walletDID := did.MustParseDID("did:web:test.test:iam:123") - verifierDID := did.MustParseDID("did:web:test.test:iam:456") userDetails := UserDetails{ - Id: "test", + Id: "test", + Name: "John Doe", + Role: "Caregiver", } redirectSession := RedirectSession{ OwnDID: walletDID, @@ -50,13 +68,25 @@ func TestWrapper_handleUserLanding(t *testing.T) { }, } - t.Run("OK", func(t *testing.T) { + t.Run("new session", func(t *testing.T) { ctx := newTestClient(t) - expectedURL, _ := url.Parse("https://test.test/iam/123/user?token=token") + expectedURL, _ := url.Parse("https://example.com/iam/123/user?token=token") echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().Return(&http.Request{Host: "test.test"}) + echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) echoCtx.EXPECT().Redirect(http.StatusFound, expectedURL.String()) + var capturedCookie *http.Cookie + echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) + echoCtx.EXPECT().SetCookie(gomock.Any()).DoAndReturn(func(cookie *http.Cookie) { + capturedCookie = cookie + }) + var employeeCredentialTemplate vc.VerifiableCredential + var employeeCredentialOptions issuer.CredentialOptions + ctx.vcIssuer.EXPECT().Issue(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, t vc.VerifiableCredential, o issuer.CredentialOptions) (*vc.VerifiableCredential, error) { + employeeCredentialTemplate = t + employeeCredentialOptions = o + return &t, nil + }) ctx.iamClient.EXPECT().CreateAuthorizationRequest(gomock.Any(), walletDID, verifierDID, gomock.Any()).DoAndReturn(func(_ interface{}, did, verifier did.DID, modifier iam.RequestModifier) (*url.URL, error) { // check the parameters params := map[string]interface{}{} @@ -72,10 +102,65 @@ func TestWrapper_handleUserLanding(t *testing.T) { err = ctx.client.handleUserLanding(echoCtx) require.NoError(t, err) + // check security settings of session cookie + assert.Equal(t, "/", capturedCookie.Path) + assert.Equal(t, "__Host-SID", capturedCookie.Name) + assert.Empty(t, capturedCookie.Domain) + assert.Empty(t, capturedCookie.Expires) + assert.NotEmpty(t, capturedCookie.MaxAge) + assert.Equal(t, http.SameSiteStrictMode, capturedCookie.SameSite) + assert.True(t, capturedCookie.Secure) + assert.True(t, capturedCookie.HttpOnly) + // check for issued EmployeeCredential in session wallet + userSession := new(UserSession) + require.NoError(t, ctx.client.userSessionStore().Get(capturedCookie.Value, userSession)) + assert.Equal(t, walletDID, userSession.TenantDID) + require.Len(t, userSession.Wallet.Credentials, 1) + // check the JWK can be parsed and contains a private key + sessionKey, err := jwk.ParseKey(userSession.Wallet.JWK) + require.NoError(t, err) + assert.NotEmpty(t, sessionKey.KeyID) + assert.Equal(t, jwa.EC, sessionKey.KeyType()) + // check for details of issued EmployeeCredential + assert.Equal(t, "EmployeeCredential", employeeCredentialTemplate.Type[0].String()) + employeeCredentialSubject := employeeCredentialTemplate.CredentialSubject[0].(map[string]string) + assert.True(t, strings.HasPrefix(employeeCredentialSubject["id"], "did:jwk:")) + assert.Equal(t, userDetails.Id, employeeCredentialSubject["identifier"]) + assert.Equal(t, userDetails.Name, employeeCredentialSubject["name"]) + assert.Equal(t, userDetails.Role, employeeCredentialSubject["roleName"]) + // check issuance options + assert.False(t, employeeCredentialOptions.Public) + assert.False(t, employeeCredentialOptions.Publish) + assert.False(t, employeeCredentialOptions.WithStatusListRevocation) + assert.Equal(t, vc.JWTCredentialProofFormat, employeeCredentialOptions.Format) + // check for deleted token err = store.Get("token", &RedirectSession{}) assert.Error(t, err) }) + t.Run("existing session", func(t *testing.T) { + ctx := newTestClient(t) + expectedURL, _ := url.Parse("https://example.com/iam/123/user?token=token") + echoCtx := mock.NewMockContext(ctx.ctrl) + echoCtx.EXPECT().QueryParam("token").Return("token") + echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) + echoCtx.EXPECT().Redirect(http.StatusFound, expectedURL.String()) + echoCtx.EXPECT().Cookie(gomock.Any()).Return(&sessionCookie, nil) + ctx.iamClient.EXPECT().CreateAuthorizationRequest(gomock.Any(), walletDID, verifierDID, gomock.Any()).Return(expectedURL, nil) + require.NoError(t, ctx.client.userRedirectStore().Put("token", redirectSession)) + session := UserSession{ + TenantDID: walletDID, + PreAuthorizedUser: &userDetails, + Wallet: UserWallet{ + DID: userDID, + }, + } + require.NoError(t, ctx.client.userSessionStore().Put(sessionCookie.Value, session)) + + err := ctx.client.handleUserLanding(echoCtx) + + require.NoError(t, err) + }) t.Run("error - no token", func(t *testing.T) { ctx := newTestClient(t) echoCtx := mock.NewMockContext(ctx.ctrl) @@ -120,9 +205,15 @@ func TestWrapper_handleUserLanding(t *testing.T) { }) t.Run("error - authorization request error", func(t *testing.T) { ctx := newTestClient(t) + ctx.vcIssuer.EXPECT().Issue(gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(_ context.Context, t vc.VerifiableCredential, _ issuer.CredentialOptions) (*vc.VerifiableCredential, error) { + // just return whatever template was given to avoid nil deref + return &t, nil + }) echoCtx := mock.NewMockContext(ctx.ctrl) echoCtx.EXPECT().QueryParam("token").Return("token") - echoCtx.EXPECT().Request().Return(&http.Request{Host: "test.test"}) + echoCtx.EXPECT().Request().MinTimes(1).Return(&http.Request{Host: "example.com"}) + echoCtx.EXPECT().Cookie(gomock.Any()).Return(nil, http.ErrNoCookie) + echoCtx.EXPECT().SetCookie(gomock.Any()) store := ctx.client.storageEngine.GetSessionDatabase().GetStore(time.Second*5, "user", "redirect") err := store.Put("token", redirectSession) require.NoError(t, err) @@ -133,3 +224,90 @@ func TestWrapper_handleUserLanding(t *testing.T) { assert.Error(t, err) }) } + +func TestWrapper_loadUserSession(t *testing.T) { + user := &UserDetails{ + Id: "test", + Name: "John Doe", + Role: "Caregiver", + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + expected := UserSession{ + TenantDID: walletDID, + PreAuthorizedUser: user, + Wallet: UserWallet{ + DID: userDID, + }, + } + _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) + ctrl := gomock.NewController(t) + echoCtx := mock.NewMockContext(ctrl) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) + + actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + + assert.NoError(t, err) + assert.Equal(t, expected, *actual) + }) + t.Run("error - no session cookie", func(t *testing.T) { + ctx := newTestClient(t) + ctrl := gomock.NewController(t) + echoCtx := mock.NewMockContext(ctrl) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(nil, http.ErrNoCookie) + + actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + + assert.NoError(t, err) + assert.Nil(t, actual) + }) + t.Run("error - session not found", func(t *testing.T) { + ctx := newTestClient(t) + ctrl := gomock.NewController(t) + echoCtx := mock.NewMockContext(ctrl) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) + + actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + + assert.EqualError(t, err, "unknown or expired session") + assert.Nil(t, actual) + }) + t.Run("error - session belongs to a different tenant", func(t *testing.T) { + ctx := newTestClient(t) + expected := UserSession{ + TenantDID: did.MustParseDID("did:web:someone-else"), + Wallet: UserWallet{ + DID: userDID, + }, + } + _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) + ctrl := gomock.NewController(t) + echoCtx := mock.NewMockContext(ctrl) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) + + actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + + assert.EqualError(t, err, "session belongs to another tenant (did:web:someone-else)") + assert.Nil(t, actual) + }) + t.Run("error - session belongs to a different pre-authorized user", func(t *testing.T) { + ctx := newTestClient(t) + expected := UserSession{ + TenantDID: walletDID, + PreAuthorizedUser: &UserDetails{Id: "someone-else"}, + Wallet: UserWallet{ + DID: userDID, + }, + } + + _ = ctx.client.userSessionStore().Put(sessionCookie.Value, expected) + ctrl := gomock.NewController(t) + echoCtx := mock.NewMockContext(ctrl) + echoCtx.EXPECT().Cookie(sessionCookie.Name).Return(&sessionCookie, nil) + + actual, err := ctx.client.loadUserSession(echoCtx, walletDID, user) + + assert.EqualError(t, err, "session belongs to another pre-authorized user") + assert.Nil(t, actual) + }) +} diff --git a/codegen/configs/auth_iam.yaml b/codegen/configs/auth_iam.yaml index 2e3b4e176b..401c5754d0 100644 --- a/codegen/configs/auth_iam.yaml +++ b/codegen/configs/auth_iam.yaml @@ -15,3 +15,4 @@ output-options: - TokenResponse - VerifiablePresentation - VerifiableCredential + - WalletOwnerType diff --git a/crypto/crypto.go b/crypto/crypto.go index 743453fc29..b5c700373e 100644 --- a/crypto/crypto.go +++ b/crypto/crypto.go @@ -26,6 +26,7 @@ import ( "crypto/rand" "errors" "fmt" + "github.com/lestrrat-go/jwx/v2/jwk" "path" "time" @@ -196,6 +197,20 @@ func generateECKeyPair() (*ecdsa.PrivateKey, error) { return ecdsa.GenerateKey(elliptic.P256(), rand.Reader) } +// GenerateJWK a new in-memory key pair and returns it as JWK. +// It sets the alg field of the JWK. +func GenerateJWK() (jwk.Key, error) { + keyPair, err := generateECKeyPair() + if err != nil { + return nil, nil + } + result, err := jwkKey(keyPair) + if err != nil { + return nil, err + } + return result, nil +} + // Exists checks storage for an entry for the given legal entity and returns true if it exists func (client *Crypto) Exists(ctx context.Context, kid string) bool { return client.storage.PrivateKeyExists(ctx, kid) diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index 84c2d34764..d37e03b900 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -220,6 +220,10 @@ paths: description: | The scope for which a presentation definition is requested. Multiple scopes can be specified by separating them with a space. example: usecase patient:x:read + - name: wallet_owner_type + in: query + schema: + $ref: '#/components/schemas/WalletOwnerType' responses: "200": description: PresentationDefinition that matches scope is found. @@ -867,7 +871,10 @@ components: description: Code identifying the error that occurred. example: "invalid_request" TokenIntrospectionRequest: - description: Token introspection request as described in RFC7662 section 2.1 + description: > + Token introspection request as described in RFC7662 section 2.1 + + Alongside the defined properties, it can return values (additionalProperties) from the Verifiable Credentials that resulted from the Presentation Exchange. required: - token properties: @@ -879,6 +886,7 @@ components: description: Token introspection response as described in RFC7662 section 2.2 required: - active + additionalProperties: {} properties: active: type: boolean @@ -908,11 +916,6 @@ components: scope: type: string description: granted scopes - input_descriptor_constraint_id_map: - type: object - description: | - Mapping from the ID field of a 'presentation_definition' input descriptor constraints to the value provided in the 'vps' for the constraints. - The Policy Decision Point can use this map to make decisions without having to deal with PEX/VCs/VPs/SignatureValidation presentation_definition: type: object description: presentation definition, as described in presentation exchange specification, fulfilled to obtain the access token @@ -926,36 +929,14 @@ components: items: $ref: '#/components/schemas/VerifiablePresentation' description: The Verifiable Presentations that were used to request the access token using the same encoding as used in the access token request. -# TODO: below is copied from introspection/v1. Remove anything unused when flows are finalized. -# TODO: existing contract validator responses - family_name: - type: string - description: Surname(s) or last name(s) of the End-User. - example: Bruijn - prefix: - type: string - description: Surname prefix - example: de - initials: - type: string - description: Initials of the End-User. - example: I. - username: - type: string - description: Identifier uniquely identifying the End-User's account in the issuing system. - assurance_level: - type: string - description: Assurance level of the identity of the End-User. - format: enum - enum: [low, substantial, high] -# TODO: ??? - email: - type: string - description: End-User's preferred e-mail address. Should be a personal email and can be used to uniquely identify a user. Just like the email used for an account. - example: w.debruijn@example.org - user_role: - type: string - description: Role of the End-User. + WalletOwnerType: + type: string + description: | + Wallet owner type that should fulfill the presentation definition. + Can either be an organization wallet or a user (personal) wallet. + enum: + - organization + - user securitySchemes: jwtBearerAuth: type: http diff --git a/go.mod b/go.mod index 4512a8cc5f..ce6123c130 100644 --- a/go.mod +++ b/go.mod @@ -47,7 +47,7 @@ require ( go.uber.org/mock v0.4.0 golang.org/x/crypto v0.22.0 golang.org/x/time v0.5.0 - google.golang.org/grpc v1.63.0 + google.golang.org/grpc v1.63.2 google.golang.org/protobuf v1.33.0 gopkg.in/Regis24GmbH/go-phonetics.v2 v2.0.3 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index 0be7f11f2f..06000124d7 100644 --- a/go.sum +++ b/go.sum @@ -863,8 +863,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.63.0 h1:WjKe+dnvABXyPJMD7KDNLxtoGk5tgk+YFWN6cBWjZE8= -google.golang.org/grpc v1.63.0/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= +google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/storage/engine.go b/storage/engine.go index 4a5d84785d..12cc5368d9 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -24,6 +24,7 @@ import ( "embed" "errors" "fmt" + "io" "net/url" "path" "strings" @@ -57,20 +58,22 @@ var sqlMigrationsFS embed.FS // New creates a new instance of the storage engine. func New() Engine { return &engine{ - storesMux: &sync.Mutex{}, - stores: map[string]stoabs.Store{}, - sessionDatabase: NewInMemorySessionDatabase(), + storesMux: &sync.Mutex{}, + stores: map[string]stoabs.Store{}, + sessionDatabase: NewInMemorySessionDatabase(), + sqlMigrationLogger: logrusInfoLogWriter{}, } } type engine struct { - datadir string - storesMux *sync.Mutex - stores map[string]stoabs.Store - databases []database - sessionDatabase SessionDatabase - sqlDB *gorm.DB - config Config + datadir string + storesMux *sync.Mutex + stores map[string]stoabs.Store + databases []database + sessionDatabase SessionDatabase + sqlDB *gorm.DB + config Config + sqlMigrationLogger io.Writer } func (e *engine) Config() interface{} { @@ -239,7 +242,7 @@ func (e *engine) initSQLDatabase() error { dbMigrator.FS = sqlMigrationsFS dbMigrator.MigrationsDir = []string{"sql_migrations"} dbMigrator.AutoDumpSchema = false - dbMigrator.Log = sqlMigrationLogger{} + dbMigrator.Log = e.sqlMigrationLogger if err = dbMigrator.CreateAndMigrate(); err != nil { return fmt.Errorf("failed to migrate database: %w", err) } @@ -316,10 +319,10 @@ func (p *provider) getStore(moduleName string, name string, adapter database) (s return store, err } -type sqlMigrationLogger struct { +type logrusInfoLogWriter struct { } -func (m sqlMigrationLogger) Write(p []byte) (n int, err error) { +func (m logrusInfoLogWriter) Write(p []byte) (n int, err error) { log.Logger().Info(string(p)) return len(p), nil } diff --git a/storage/test.go b/storage/test.go index 50a3df9bc8..1e41a51aa6 100644 --- a/storage/test.go +++ b/storage/test.go @@ -23,6 +23,7 @@ import ( "errors" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/test/io" + stdIO "io" "testing" "github.com/nuts-foundation/go-did/did" @@ -34,6 +35,9 @@ import ( func NewTestStorageEngineInDir(t testing.TB, dir string) Engine { result := New().(*engine) + // Prevent dbmate and gorm from logging database creation and applied schema migrations. + // These are logged on INFO, which is good for production but annoying in unit tests. + result.sqlMigrationLogger = stdIO.Discard result.config.SQL = SQLConfig{ConnectionString: sqliteConnectionString(dir)} err := result.Configure(core.TestServerConfig(func(config *core.ServerConfig) { diff --git a/vcr/revocation/statuslist2021_issuer.go b/vcr/revocation/statuslist2021_issuer.go index 45a5a37ec2..f06892e07c 100644 --- a/vcr/revocation/statuslist2021_issuer.go +++ b/vcr/revocation/statuslist2021_issuer.go @@ -22,9 +22,11 @@ import ( "context" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/audit" "net/url" "path" "strconv" + "strings" "time" "github.com/google/uuid" @@ -146,6 +148,15 @@ func (cs *StatusList2021) Credential(ctx context.Context, issuerDID did.DID, pag log.Logger().WithError(err).WithField("StatusList2021Credential", statusListCredentialURL).Error("Failed to parse managed StatusList2021Credential in database") } + // Rewrite audit context. This is a system action and should not be logged against an external party. + info := audit.InfoFromContext(ctx) + if info != nil { + module, operation, ok := strings.Cut(info.Operation, ".") + if ok { + ctx = audit.Context(ctx, "_system_signing_expired_statuslist2021credential", module, operation) + } + } + // resolve signing key outside of transaction key, err := cs.ResolveKey(ctx, issuerDID) if err != nil { diff --git a/vcr/revocation/statuslist2021_issuer_test.go b/vcr/revocation/statuslist2021_issuer_test.go index 3c2bb4d614..1d1ea06cac 100644 --- a/vcr/revocation/statuslist2021_issuer_test.go +++ b/vcr/revocation/statuslist2021_issuer_test.go @@ -327,6 +327,15 @@ func TestStatusList2021_Credential(t *testing.T) { assert.NotEmpty(t, cred) }) t.Run("ok - refresh expired credential", func(t *testing.T) { + old := s.Sign + defer func() { s.Sign = old }() + s.Sign = func(ctx context.Context, unsignedCredential vc.VerifiableCredential, key crypto.Key) (*vc.VerifiableCredential, error) { + info := audit.InfoFromContext(ctx) + require.NotNil(t, info) + assert.Equal(t, info.Actor, "_system_signing_expired_statuslist2021credential") + assert.Equal(t, info.Operation, "TestModule.TestOperation") // confirm unchanged + return noopSign(ctx, unsignedCredential, key) + } // change expires so that time.Now is between refresh and expired err = s.db.Model(new(credentialRecord)).Where("subject_id = ?", entry.StatusListCredential). UpdateColumn("expires", time.Now().Add(minTimeUntilExpired-time.Second).Unix()).Error diff --git a/vcr/revocation/statuslist2021_verifier.go b/vcr/revocation/statuslist2021_verifier.go index 64bcd7061f..72aacde9ec 100644 --- a/vcr/revocation/statuslist2021_verifier.go +++ b/vcr/revocation/statuslist2021_verifier.go @@ -109,7 +109,7 @@ func (cs *StatusList2021) statusList(statusListCredential string) (*credentialRe return cs.update(statusListCredential) } - // managed StatusList2021Credentials are always up-to-date, does not matter that it is expired + // managed StatusList2021Credentials are always up-to-date, does not matter if it is expired if cs.isManaged(statusListCredential) { return cr, nil }