diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 60ea89cb5..c8135c523 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -84,7 +84,7 @@ type ExtendedTokenIntrospectionResponse struct { // 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 The client identity the access token was issued to. Since the Verifiable Presentation is used to grant access, the client_id reflects the client_id in the access token request. ClientId *string `json:"client_id,omitempty"` // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. @@ -96,7 +96,7 @@ type ExtendedTokenIntrospectionResponse struct { // Iat Issuance time in seconds since UNIX epoch Iat *int `json:"iat,omitempty"` - // Iss Contains the DID of the authorizer. Should be equal to 'sub' + // Iss Issuer URL of the authorizer. Iss *string `json:"iss,omitempty"` // PresentationDefinitions Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token diff --git a/discovery/api/server/client/http.go b/discovery/api/server/client/http.go index 6623b642a..b819be4c9 100644 --- a/discovery/api/server/client/http.go +++ b/discovery/api/server/client/http.go @@ -21,7 +21,6 @@ package client import ( "bytes" "context" - "crypto/tls" "encoding/json" "fmt" "github.com/nuts-foundation/go-did/vc" @@ -35,9 +34,9 @@ import ( ) // New creates a new DefaultHTTPClient. -func New(strictMode bool, timeout time.Duration, tlsConfig *tls.Config) *DefaultHTTPClient { +func New(timeout time.Duration) *DefaultHTTPClient { return &DefaultHTTPClient{ - client: client.NewWithTLSConfig(timeout, tlsConfig), + client: client.New(timeout), } } diff --git a/discovery/api/server/client/http_test.go b/discovery/api/server/client/http_test.go index bfe0717ce..ac40dd855 100644 --- a/discovery/api/server/client/http_test.go +++ b/discovery/api/server/client/http_test.go @@ -40,7 +40,7 @@ func TestHTTPInvoker_Register(t *testing.T) { t.Run("ok", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusCreated} server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) err := client.Register(context.Background(), server.URL, vp) @@ -51,7 +51,7 @@ func TestHTTPInvoker_Register(t *testing.T) { }) t.Run("non-ok with problem details", func(t *testing.T) { server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusBadRequest, ResponseData: `{"title":"missing credentials", "status":400, "detail":"could not resolve DID"}`}) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) err := client.Register(context.Background(), server.URL, vp) @@ -61,7 +61,7 @@ func TestHTTPInvoker_Register(t *testing.T) { }) t.Run("non-ok other", func(t *testing.T) { server := httptest.NewServer(&testHTTP.Handler{StatusCode: http.StatusNotFound, ResponseData: `not found`}) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) err := client.Register(context.Background(), server.URL, vp) @@ -84,7 +84,7 @@ func TestHTTPInvoker_Get(t *testing.T) { "timestamp": 1, } server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) presentations, seed, timestamp, err := client.Get(context.Background(), server.URL, 0) @@ -102,7 +102,7 @@ func TestHTTPInvoker_Get(t *testing.T) { "timestamp": 1, } server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) presentations, _, timestamp, err := client.Get(context.Background(), server.URL, 1) @@ -120,7 +120,7 @@ func TestHTTPInvoker_Get(t *testing.T) { writer.Write([]byte("{}")) } server := httptest.NewServer(http.HandlerFunc(handler)) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) _, _, _, err := client.Get(context.Background(), server.URL, 0) @@ -130,7 +130,7 @@ func TestHTTPInvoker_Get(t *testing.T) { t.Run("server returns invalid status code", func(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusInternalServerError, ResponseData: `{"title":"internal server error", "status":500, "detail":"db not found"}`} server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) _, _, _, err := client.Get(context.Background(), server.URL, 0) @@ -142,7 +142,7 @@ func TestHTTPInvoker_Get(t *testing.T) { handler := &testHTTP.Handler{StatusCode: http.StatusOK} handler.ResponseData = "not json" server := httptest.NewServer(handler) - client := New(false, time.Minute, server.TLS) + client := New(time.Minute) _, _, _, err := client.Get(context.Background(), server.URL, 0) diff --git a/discovery/api/v1/api.go b/discovery/api/v1/api.go index 6632c4ca5..b05a56ee5 100644 --- a/discovery/api/v1/api.go +++ b/discovery/api/v1/api.go @@ -121,6 +121,9 @@ func (w *Wrapper) ActivateServiceForSubject(ctx context.Context, request Activat func (w *Wrapper) DeactivateServiceForSubject(ctx context.Context, request DeactivateServiceForSubjectRequestObject) (DeactivateServiceForSubjectResponseObject, error) { err := w.Client.DeactivateServiceForSubject(ctx, request.ServiceID, request.SubjectID) if err != nil { + if errors.Is(err, discovery.ErrPresentationRegistrationFailed) { + return DeactivateServiceForSubject202JSONResponse{Reason: err.Error()}, nil + } return nil, err } return DeactivateServiceForSubject200Response{}, nil @@ -132,19 +135,24 @@ func (w *Wrapper) GetServices(_ context.Context, _ GetServicesRequestObject) (Ge } func (w *Wrapper) GetServiceActivation(ctx context.Context, request GetServiceActivationRequestObject) (GetServiceActivationResponseObject, error) { - response := GetServiceActivation200JSONResponse{ - Status: ServiceStatusActive, - } + response := GetServiceActivation200JSONResponse{} activated, presentations, err := w.Client.GetServiceActivation(ctx, request.ServiceID, request.SubjectID) if err != nil { if !errors.As(err, &discovery.RegistrationRefreshError{}) { return nil, err } - response.Status = ServiceStatusError + response.Status = to.Ptr(ServiceStatusError) response.Error = to.Ptr(err.Error()) } response.Activated = activated - response.Vp = &presentations + if activated && response.Status == nil { + // only set if not already set to ServiceStatusError + response.Status = to.Ptr(ServiceStatusActive) + } + if presentations != nil { + // if presentations is nil this would add `"vp":null` to the response + response.Vp = &presentations + } return response, nil } diff --git a/discovery/api/v1/api_test.go b/discovery/api/v1/api_test.go index 578a47ddf..3a831ac36 100644 --- a/discovery/api/v1/api_test.go +++ b/discovery/api/v1/api_test.go @@ -108,6 +108,19 @@ func TestWrapper_DeactivateServiceForSubject(t *testing.T) { assert.NoError(t, err) assert.IsType(t, DeactivateServiceForSubject200Response{}, response) }) + t.Run("server error", func(t *testing.T) { + test := newMockContext(t) + expectedErr := errors.Join(discovery.ErrPresentationRegistrationFailed, errors.New("custom error")) + test.client.EXPECT().DeactivateServiceForSubject(gomock.Any(), serviceID, subjectID).Return(expectedErr) + + response, err := test.wrapper.DeactivateServiceForSubject(nil, DeactivateServiceForSubjectRequestObject{ + ServiceID: serviceID, + SubjectID: subjectID, + }) + + assert.NoError(t, err) + assert.IsType(t, DeactivateServiceForSubject202JSONResponse{Reason: expectedErr.Error()}, response) + }) t.Run("error", func(t *testing.T) { test := newMockContext(t) test.client.EXPECT().DeactivateServiceForSubject(gomock.Any(), serviceID, subjectID).Return(errors.New("foo")) @@ -199,7 +212,7 @@ func TestWrapper_GetServiceActivation(t *testing.T) { assert.NoError(t, err) require.IsType(t, GetServiceActivation200JSONResponse{}, response) assert.True(t, response.(GetServiceActivation200JSONResponse).Activated) - assert.Equal(t, ServiceStatusActive, string(response.(GetServiceActivation200JSONResponse).Status)) + assert.Equal(t, ServiceStatusActive, *response.(GetServiceActivation200JSONResponse).Status) assert.Nil(t, response.(GetServiceActivation200JSONResponse).Error) assert.Empty(t, response.(GetServiceActivation200JSONResponse).Vp) }) @@ -215,7 +228,7 @@ func TestWrapper_GetServiceActivation(t *testing.T) { assert.NoError(t, err) require.IsType(t, GetServiceActivation200JSONResponse{}, response) assert.True(t, response.(GetServiceActivation200JSONResponse).Activated) - assert.Equal(t, ServiceStatusError, string(response.(GetServiceActivation200JSONResponse).Status)) + assert.Equal(t, ServiceStatusError, *response.(GetServiceActivation200JSONResponse).Status) assert.NotNil(t, response.(GetServiceActivation200JSONResponse).Error) assert.Empty(t, response.(GetServiceActivation200JSONResponse).Vp) }) diff --git a/discovery/api/v1/generated.go b/discovery/api/v1/generated.go index 11e76317d..69cde00bd 100644 --- a/discovery/api/v1/generated.go +++ b/discovery/api/v1/generated.go @@ -61,13 +61,13 @@ type ServerInterface interface { // Searches for presentations registered on the Discovery Service. // (GET /internal/discovery/v1/{serviceID}) SearchPresentations(ctx echo.Context, serviceID string, params SearchPresentationsParams) error - // Client API to deactivate the given subject from the Discovery Service. + // Remove a subject from the Discovery Service. // (DELETE /internal/discovery/v1/{serviceID}/{subjectID}) DeactivateServiceForSubject(ctx echo.Context, serviceID string, subjectID string) error // Retrieves the activation status of a subject on a Discovery Service. // (GET /internal/discovery/v1/{serviceID}/{subjectID}) GetServiceActivation(ctx echo.Context, serviceID string, subjectID string) error - // Client API to activate a subject on the specified Discovery Service. + // Activate a Discovery Service for a subject. // (POST /internal/discovery/v1/{serviceID}/{subjectID}) ActivateServiceForSubject(ctx echo.Context, serviceID string, subjectID string) error } @@ -325,24 +325,6 @@ func (response DeactivateServiceForSubject202JSONResponse) VisitDeactivateServic return json.NewEncoder(w).Encode(response) } -type DeactivateServiceForSubject400ApplicationProblemPlusJSONResponse struct { - // Detail A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail"` - - // Status HTTP statuscode - Status float32 `json:"status"` - - // Title A short, human-readable summary of the problem type. - Title string `json:"title"` -} - -func (response DeactivateServiceForSubject400ApplicationProblemPlusJSONResponse) VisitDeactivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - type DeactivateServiceForSubjectdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. @@ -381,10 +363,10 @@ type GetServiceActivation200JSONResponse struct { Error *string `json:"error,omitempty"` // Status Status of the activation. "active" or "error". - Status GetServiceActivation200JSONResponseStatus `json:"status"` + Status *GetServiceActivation200JSONResponseStatus `json:"status,omitempty"` // Vp List of VPs on the Discovery Service for the subject. One per DID method registered on the Service. - // The list can be empty even if activated==true if none of the DIDs of a subject is actually registered on the Discovery Service. + // The list is empty when status is "error". Vp *[]VerifiablePresentation `json:"vp,omitempty"` } @@ -434,42 +416,6 @@ func (response ActivateServiceForSubject200Response) VisitActivateServiceForSubj return nil } -type ActivateServiceForSubject400ApplicationProblemPlusJSONResponse struct { - // Detail A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail"` - - // Status HTTP statuscode - Status float32 `json:"status"` - - // Title A short, human-readable summary of the problem type. - Title string `json:"title"` -} - -func (response ActivateServiceForSubject400ApplicationProblemPlusJSONResponse) VisitActivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(400) - - return json.NewEncoder(w).Encode(response) -} - -type ActivateServiceForSubject412ApplicationProblemPlusJSONResponse struct { - // Detail A human-readable explanation specific to this occurrence of the problem. - Detail string `json:"detail"` - - // Status HTTP statuscode - Status float32 `json:"status"` - - // Title A short, human-readable summary of the problem type. - Title string `json:"title"` -} - -func (response ActivateServiceForSubject412ApplicationProblemPlusJSONResponse) VisitActivateServiceForSubjectResponse(w http.ResponseWriter) error { - w.Header().Set("Content-Type", "application/problem+json") - w.WriteHeader(412) - - return json.NewEncoder(w).Encode(response) -} - type ActivateServiceForSubjectdefaultApplicationProblemPlusJSONResponse struct { Body struct { // Detail A human-readable explanation specific to this occurrence of the problem. @@ -499,13 +445,13 @@ type StrictServerInterface interface { // Searches for presentations registered on the Discovery Service. // (GET /internal/discovery/v1/{serviceID}) SearchPresentations(ctx context.Context, request SearchPresentationsRequestObject) (SearchPresentationsResponseObject, error) - // Client API to deactivate the given subject from the Discovery Service. + // Remove a subject from the Discovery Service. // (DELETE /internal/discovery/v1/{serviceID}/{subjectID}) DeactivateServiceForSubject(ctx context.Context, request DeactivateServiceForSubjectRequestObject) (DeactivateServiceForSubjectResponseObject, error) // Retrieves the activation status of a subject on a Discovery Service. // (GET /internal/discovery/v1/{serviceID}/{subjectID}) GetServiceActivation(ctx context.Context, request GetServiceActivationRequestObject) (GetServiceActivationResponseObject, error) - // Client API to activate a subject on the specified Discovery Service. + // Activate a Discovery Service for a subject. // (POST /internal/discovery/v1/{serviceID}/{subjectID}) ActivateServiceForSubject(ctx context.Context, request ActivateServiceForSubjectRequestObject) (ActivateServiceForSubjectResponseObject, error) } diff --git a/discovery/api/v1/types.go b/discovery/api/v1/types.go index e5db1857e..2c3c738ee 100644 --- a/discovery/api/v1/types.go +++ b/discovery/api/v1/types.go @@ -37,7 +37,7 @@ type GetServiceActivation200JSONResponseStatus string const ( // ServiceStatusActive is the status for an active service. - ServiceStatusActive = "active" + ServiceStatusActive GetServiceActivation200JSONResponseStatus = "active" // ServiceStatusError is the status for an inactive service. - ServiceStatusError = "error" + ServiceStatusError GetServiceActivation200JSONResponseStatus = "error" ) diff --git a/discovery/client.go b/discovery/client.go index 059181c44..84ec5c300 100644 --- a/discovery/client.go +++ b/discovery/client.go @@ -43,20 +43,7 @@ import ( // clientRegistrationManager is a client component, responsible for managing registrations on a Discovery Service. // It can refresh registered Verifiable Presentations when they are about to expire. -type clientRegistrationManager interface { - activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error - deactivate(ctx context.Context, serviceID, subjectID string) error - // refresh checks which Verifiable Presentations that are about to expire, and should be refreshed on the Discovery Service. - refresh(ctx context.Context, now time.Time) error - // validate validates all presentations that are not yet validated - validate() error - // removeRevoked removes all revoked presentations from the store - removeRevoked() error -} - -var _ clientRegistrationManager = &defaultClientRegistrationManager{} - -type defaultClientRegistrationManager struct { +type clientRegistrationManager struct { services map[string]ServiceDefinition store *sqlStore client client.HTTPClient @@ -66,8 +53,8 @@ type defaultClientRegistrationManager struct { verifier presentationVerifier } -func newRegistrationManager(services map[string]ServiceDefinition, store *sqlStore, client client.HTTPClient, vcr vcr.VCR, subjectManager didsubject.Manager, didResolver resolver.DIDResolver, verifier presentationVerifier) *defaultClientRegistrationManager { - return &defaultClientRegistrationManager{ +func newRegistrationManager(services map[string]ServiceDefinition, store *sqlStore, client client.HTTPClient, vcr vcr.VCR, subjectManager didsubject.Manager, didResolver resolver.DIDResolver, verifier presentationVerifier) *clientRegistrationManager { + return &clientRegistrationManager{ services: services, store: store, client: client, @@ -78,16 +65,12 @@ func newRegistrationManager(services map[string]ServiceDefinition, store *sqlSto } } -func (r *defaultClientRegistrationManager) activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error { - service, serviceExists := r.services[serviceID] - if !serviceExists { - return ErrServiceNotFound - } - subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) +func (r *clientRegistrationManager) activate(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error { + service, subjectDIDs, err := r.getServiceAndSubject(ctx, serviceID, subjectID) if err != nil { return err } - // filter DIDs on DID methods supported by the service + // filter DIDs on DID methods supported by the service; len == 0 means all DID Methods are accepted if len(service.DIDMethods) > 0 { j := 0 for i, did := range subjectDIDs { @@ -97,10 +80,6 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service } } subjectDIDs = subjectDIDs[:j] - - if len(subjectDIDs) == 0 { - return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) - } } // and filter by deactivated status @@ -116,7 +95,7 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service subjectDIDs = subjectDIDs[:j] if len(subjectDIDs) == 0 { - return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, didsubject.ErrSubjectNotFound, subjectID) + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrNoSupportedDIDMethods, subjectID) } log.Logger().Debugf("Registering Verifiable Presentation on Discovery Service (service=%s, subject=%s)", service.ID, subjectID) @@ -162,23 +141,18 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service return nil } -func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, serviceID, subjectID string) error { - service, serviceExists := r.services[serviceID] - if !serviceExists { - return ErrServiceNotFound - } - // delete DID/service combination from DB, so it won't be registered again - err := r.store.updatePresentationRefreshTime(serviceID, subjectID, nil, nil) +func (r *clientRegistrationManager) deactivate(ctx context.Context, serviceID, subjectID string) error { + service, subjectDIDs, err := r.getServiceAndSubject(ctx, serviceID, subjectID) if err != nil { return err } - // subject is now successfully deactivated for the service, anything after this point is best effort - subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) + // delete DID/service combination from DB, so it won't be registered again + err = r.store.updatePresentationRefreshTime(serviceID, subjectID, nil, nil) if err != nil { - // this could be a didsubject.ErrSubjectNotFound after the subject has been deactivated - // still fail in this case since we no longer have the keys to sign a retraction return err } + // subject is now successfully deactivated for the service, + // anything after this point is best-effort and should include ErrPresentationRegistrationFailed to trigger a 202 status code // filter DIDs on DID methods supported by the service if len(service.DIDMethods) > 0 { @@ -193,7 +167,7 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi } if len(subjectDIDs) == 0 { // if this means we can't deactivate a previously registered subject because the DID methods have changed, then we rely on the refresh interval to clean up. - return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID) + return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrNoSupportedDIDMethods, subjectID) } // find all active presentations @@ -224,7 +198,20 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi return nil } -func (r *defaultClientRegistrationManager) deregisterPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, vp vc.VerifiablePresentation) error { +// getServiceAndSubject returns the service and subject, or ErrServiceNotFound / didsubject.ErrSubjectNotFound if either does not exist +func (r *clientRegistrationManager) getServiceAndSubject(ctx context.Context, serviceID, subjectID string) (ServiceDefinition, []did.DID, error) { + service, serviceExists := r.services[serviceID] + if !serviceExists { + return ServiceDefinition{}, nil, ErrServiceNotFound + } + subjectDIDs, err := r.subjectManager.ListDIDs(ctx, subjectID) + if err != nil { + return ServiceDefinition{}, nil, err + } + return service, subjectDIDs, nil +} + +func (r *clientRegistrationManager) deregisterPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, vp vc.VerifiablePresentation) error { presentation, err := r.buildPresentation(ctx, subjectDID, service, nil, map[string]interface{}{ "retract_jti": vp.ID.String(), }, &retractionPresentationType) @@ -234,7 +221,7 @@ func (r *defaultClientRegistrationManager) deregisterPresentation(ctx context.Co return r.client.Register(ctx, service.Endpoint, *presentation) } -func (r *defaultClientRegistrationManager) registerPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) error { +func (r *clientRegistrationManager) registerPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) error { presentation, err := r.findCredentialsAndBuildPresentation(ctx, subjectDID, service, parameters) if err != nil { return err @@ -242,7 +229,7 @@ func (r *defaultClientRegistrationManager) registerPresentation(ctx context.Cont return r.client.Register(ctx, service.Endpoint, *presentation) } -func (r *defaultClientRegistrationManager) findCredentialsAndBuildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) (*vc.VerifiablePresentation, error) { +func (r *clientRegistrationManager) findCredentialsAndBuildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, parameters map[string]interface{}) (*vc.VerifiablePresentation, error) { credentials, err := r.vcr.Wallet().List(ctx, subjectDID) if err != nil { return nil, err @@ -266,7 +253,7 @@ func (r *defaultClientRegistrationManager) findCredentialsAndBuildPresentation(c return r.buildPresentation(ctx, subjectDID, service, matchingCredentials, nil, nil) } -func (r *defaultClientRegistrationManager) buildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, credentials []vc.VerifiableCredential, additionalProperties map[string]interface{}, additionalVPType *ssi.URI) (*vc.VerifiablePresentation, error) { +func (r *clientRegistrationManager) buildPresentation(ctx context.Context, subjectDID did.DID, service ServiceDefinition, credentials []vc.VerifiableCredential, additionalProperties map[string]interface{}, additionalVPType *ssi.URI) (*vc.VerifiablePresentation, error) { nonce := nutsCrypto.GenerateNonce() // Make sure the presentation is not valid for longer than the max validity as defined by the Service Definitio. expires := time.Now().Add(time.Duration(service.PresentationMaxValidity-1) * time.Second).Truncate(time.Second) @@ -289,7 +276,8 @@ func (r *defaultClientRegistrationManager) buildPresentation(ctx context.Context }, &subjectDID, false) } -func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time.Time) error { +// refresh checks which Verifiable Presentations that are about to expire, and should be refreshed on the Discovery Service. +func (r *clientRegistrationManager) refresh(ctx context.Context, now time.Time) error { log.Logger().Debug("Refreshing own registered Verifiable Presentations on Discovery Services") refreshCandidates, err := r.store.getSubjectsToBeRefreshed(now) if err != nil { @@ -299,11 +287,13 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time for _, candidate := range refreshCandidates { var loopErr error if err = r.activate(ctx, candidate.ServiceID, candidate.SubjectID, candidate.Parameters); err != nil { - if errors.Is(err, ErrDIDMethodsNotSupported) { + if errors.Is(err, ErrNoSupportedDIDMethods) { // DID method no longer supported, remove err = r.store.updatePresentationRefreshTime(candidate.ServiceID, candidate.SubjectID, nil, nil) if err != nil { loopErr = fmt.Errorf("failed to remove subject with unsupported DID method (service=%s, subject=%s): %w", candidate.ServiceID, candidate.SubjectID, err) + } else { + loopErr = fmt.Errorf("removed subject that has no supported DID method (service=%s, subject=%s)", candidate.ServiceID, candidate.SubjectID) } } else if errors.Is(err, didsubject.ErrSubjectNotFound) { // Subject has probably been deactivated. Remove from service or registration will be retried every refresh interval. @@ -329,7 +319,8 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time return nil } -func (r *defaultClientRegistrationManager) validate() error { +// validate validates all presentations that are not yet validated +func (r *clientRegistrationManager) validate() error { errMsg := "background verification of presentation failed (service: %s, id: %s)" // find all unvalidated entries in store presentations, err := r.store.allPresentations(false) @@ -362,7 +353,8 @@ func (r *defaultClientRegistrationManager) validate() error { return nil } -func (r *defaultClientRegistrationManager) removeRevoked() error { +// removeRevoked removes all revoked presentations from the store +func (r *clientRegistrationManager) removeRevoked() error { errMsg := "background revocation check of presentation failed (id: %s)" // find all validated entries in store presentations, err := r.store.allPresentations(true) diff --git a/discovery/client_test.go b/discovery/client_test.go index 928722e41..cbebab6ad 100644 --- a/discovery/client_test.go +++ b/discovery/client_test.go @@ -52,7 +52,7 @@ type testContext struct { wallet *holder.MockWallet subjectManager *didsubject.MockManager store *sqlStore - manager *defaultClientRegistrationManager + manager *clientRegistrationManager } func newTestContext(t *testing.T) testContext { @@ -131,7 +131,7 @@ func Test_defaultClientRegistrationManager_activate(t *testing.T) { err := ctx.manager.activate(audit.TestContext(), unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject)) - assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) }) t.Run("no matching credentials", func(t *testing.T) { ctx := newTestContext(t) @@ -255,7 +255,7 @@ func Test_defaultClientRegistrationManager_deactivate(t *testing.T) { err := ctx.manager.deactivate(audit.TestContext(), unsupportedServiceID, aliceSubject) - assert.ErrorIs(t, err, ErrDIDMethodsNotSupported) + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) }) t.Run("deregistering from Discovery Service fails", func(t *testing.T) { ctx := newTestContext(t) @@ -289,6 +289,13 @@ func Test_defaultClientRegistrationManager_deactivate(t *testing.T) { assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) }) + t.Run("unknown service", func(t *testing.T) { + ctx := newTestContext(t) + + err := ctx.manager.deactivate(audit.TestContext(), "unknown", aliceSubject) + + assert.ErrorIs(t, err, ErrServiceNotFound) + }) } func Test_defaultClientRegistrationManager_refresh(t *testing.T) { @@ -342,7 +349,7 @@ func Test_defaultClientRegistrationManager_refresh(t *testing.T) { assert.EqualError(t, err, "removed unknown subject (service=usecase_v1, subject=alice)") }) - t.Run("deactivate deactivated DID", func(t *testing.T) { + t.Run("deactivate unsupported DID method", func(t *testing.T) { ctx := newTestContext(t) ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) ctx.didResolver.EXPECT().Resolve(aliceDID, gomock.Any()).Return(nil, nil, resolver.ErrDeactivated) @@ -350,18 +357,9 @@ func Test_defaultClientRegistrationManager_refresh(t *testing.T) { err := ctx.manager.refresh(audit.TestContext(), time.Now()) - assert.EqualError(t, err, "removed unknown subject (service=usecase_v1, subject=alice)") - }) - t.Run("deactivate unsupported DID method", func(t *testing.T) { - ctx := newTestContext(t) - ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) - _ = ctx.store.updatePresentationRefreshTime(unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &nextRefresh) - - err := ctx.manager.refresh(audit.TestContext(), time.Now()) - // refresh clears the registration - require.NoError(t, err) - record, err := ctx.store.getPresentationRefreshRecord(unsupportedServiceID, aliceSubject) + assert.EqualError(t, err, "removed subject that has no supported DID method (service=usecase_v1, subject=alice)") + record, err := ctx.store.getPresentationRefreshRecord(testServiceID, aliceSubject) assert.NoError(t, err) assert.Nil(t, record) }) @@ -395,19 +393,19 @@ func Test_defaultClientRegistrationManager_validate(t *testing.T) { tests := []struct { name string - setupManager func(ctx testContext) *defaultClientRegistrationManager + setupManager func(ctx testContext) *clientRegistrationManager expectedLen int }{ { name: "ok", - setupManager: func(ctx testContext) *defaultClientRegistrationManager { + setupManager: func(ctx testContext) *clientRegistrationManager { return ctx.manager }, expectedLen: 1, }, { name: "verification failed", - setupManager: func(ctx testContext) *defaultClientRegistrationManager { + setupManager: func(ctx testContext) *clientRegistrationManager { return newRegistrationManager(testDefinitions(), ctx.store, ctx.invoker, ctx.vcr, ctx.subjectManager, ctx.didResolver, func(service ServiceDefinition, vp vc.VerifiablePresentation) error { return errors.New("verification failed") }) @@ -416,7 +414,7 @@ func Test_defaultClientRegistrationManager_validate(t *testing.T) { }, { name: "registration for unknown service", - setupManager: func(ctx testContext) *defaultClientRegistrationManager { + setupManager: func(ctx testContext) *clientRegistrationManager { return newRegistrationManager(map[string]ServiceDefinition{}, ctx.store, ctx.invoker, ctx.vcr, ctx.subjectManager, ctx.didResolver, alwaysOkVerifier) }, expectedLen: 0, diff --git a/discovery/definition.go b/discovery/definition.go index fe84531ba..e032ae696 100644 --- a/discovery/definition.go +++ b/discovery/definition.go @@ -50,7 +50,7 @@ type ServiceDefinition struct { ID string `json:"id"` // DIDMethods is a list of DID methods that are supported by the use case. // If empty, all methods are supported. - DIDMethods []string `json:"did_methods"` + DIDMethods []string `json:"did_methods,omitempty"` // Endpoint is the endpoint where the use case list is served. Endpoint string `json:"endpoint"` // PresentationDefinition specifies the Presentation ServiceDefinition submissions to the list must conform to, diff --git a/discovery/interface.go b/discovery/interface.go index 7a9cf5e92..7e1d00e7b 100644 --- a/discovery/interface.go +++ b/discovery/interface.go @@ -34,8 +34,12 @@ var ErrPresentationAlreadyExists = errors.New("presentation already exists") // ErrPresentationRegistrationFailed indicates registration of a presentation on a remote Discovery Service failed. var ErrPresentationRegistrationFailed = errors.New("registration of Verifiable Presentation on remote Discovery Service failed") +// ErrDIDMethodsNotSupported indicates that a received VP does not match the supported DID Methods of the service. var ErrDIDMethodsNotSupported = errors.New("DID methods not supported") +// ErrNoSupportedDIDMethods indicates that the client cannot create a VP for a subject because it has no (active) DID matching the supported DID Methods of the service. +var ErrNoSupportedDIDMethods = errors.New("subject has no (active) DIDs matching the service") + // authServerURLField is the field name for the authServerURL in the DiscoveryRegistrationCredential. // it is used to resolve authorization server metadata and thus the endpoints for a service entry. const authServerURLField = "authServerURL" @@ -55,19 +59,20 @@ type Server interface { type Client interface { // Search searches for presentations which credential(s) match the given query. // Query parameters are formatted as simple JSON paths, e.g. "issuer" or "credentialSubject.name". + // It returns an ErrServiceNotFound if the service invalid/unknown. Search(serviceID string, query map[string]string) ([]SearchResult, error) // ActivateServiceForSubject causes a subject to be registered for a Discovery Service. // Registration of all DIDs of the subject will be attempted immediately, and automatically refreshed. // If the function is called again for the same service/DID combination, it will try to refresh the registration. // parameters are added as credentialSubject to a DiscoveryRegistrationCredential holder credential. - // It returns an error if the service or subject is invalid/unknown. + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. ActivateServiceForSubject(ctx context.Context, serviceID, subjectID string, parameters map[string]interface{}) error // DeactivateServiceForSubject stops registration of a subject on a Discovery Service. // It also tries to remove all active registrations of the subject from the Discovery Service. // If removal of one or more active registration fails a ErrPresentationRegistrationFailed may be returned. The failed registrations will be removed when they expire. - // It returns an error if the service or subject is invalid/unknown. + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. DeactivateServiceForSubject(ctx context.Context, serviceID, subjectID string) error // Services returns the list of services that are registered on this client. @@ -76,7 +81,8 @@ type Client interface { // GetServiceActivation returns the activation status of a subject on a Discovery Service. // The boolean indicates whether the subject is activated on the Discovery Service (ActivateServiceForSubject() has been called). // It also returns the Verifiable Presentations for all DIDs of the subject that are registered on the Discovery Service, if any. - // It returns a refreshRecordError if the last refresh of the service failed (activation status and VPs are still returned). + // It returns an ErrServiceNotFound or didsubject.ErrSubjectNotFound if the service or subject is invalid/unknown. + // It returns a RegistrationRefreshError with additional information if the last refresh of the service failed (activation status and VPs are still returned). // The time of the last error is added in the error message. GetServiceActivation(ctx context.Context, serviceID, subjectID string) (bool, []vc.VerifiablePresentation, error) } diff --git a/discovery/module.go b/discovery/module.go index 86d5ef678..c979fb9e7 100644 --- a/discovery/module.go +++ b/discovery/module.go @@ -86,7 +86,7 @@ type Module struct { httpClient client.HTTPClient storageInstance storage.Engine store *sqlStore - registrationManager clientRegistrationManager + registrationManager *clientRegistrationManager serverDefinitions map[string]ServiceDefinition allDefinitions map[string]ServiceDefinition vcrInstance vcr.VCR @@ -106,7 +106,7 @@ func (m *Module) Configure(serverConfig core.ServerConfig) error { return err } - m.httpClient = client.New(serverConfig.Strictmode, serverConfig.HTTPClient.Timeout, nil) + m.httpClient = client.New(serverConfig.HTTPClient.Timeout) return m.loadDefinitions() @@ -390,7 +390,10 @@ func (m *Module) ActivateServiceForSubject(ctx context.Context, serviceID, subje } log.Logger().Infof("Successfully activated service for subject (subject=%s,service=%s)", subjectID, serviceID) - _ = m.clientUpdater.updateService(ctx, m.allDefinitions[serviceID]) + err = m.clientUpdater.updateService(ctx, m.allDefinitions[serviceID]) + if err != nil { + log.Logger().Infof("Failed to update local copy of Discovery Service (service=%s): %s", serviceID, err) + } return nil } @@ -412,6 +415,11 @@ func (m *Module) Services() []ServiceDefinition { // GetServiceActivation is a Discovery Client function that retrieves the activation status of a service for a subject. // See interface.go for more information. func (m *Module) GetServiceActivation(ctx context.Context, serviceID, subjectID string) (bool, []vc.VerifiablePresentation, error) { + // first check if the combination getServiceAndSubject to generate correct api returns + _, subjectDIDs, err := m.registrationManager.getServiceAndSubject(ctx, serviceID, subjectID) + if err != nil { + return false, nil, err + } refreshRecord, err := m.store.getPresentationRefreshRecord(serviceID, subjectID) if err != nil { return false, nil, err @@ -421,12 +429,6 @@ func (m *Module) GetServiceActivation(ctx context.Context, serviceID, subjectID } // subject is activated for service - subjectDIDs, err := m.subjectManager.ListDIDs(ctx, subjectID) - if err != nil { - // can only happen if DB is offline/corrupt, or between deactivating a subject and its next refresh on the service (didsubject.ErrSubjectNotFound) - return true, nil, err - } - vps2D, err := m.store.getSubjectVPsOnService(serviceID, subjectDIDs) if err != nil { return true, nil, err // DB err diff --git a/discovery/module_test.go b/discovery/module_test.go index e04ce0ff5..52dc2c2ba 100644 --- a/discovery/module_test.go +++ b/discovery/module_test.go @@ -40,7 +40,6 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "gorm.io/gorm" - "os" "sync" "sync/atomic" "testing" @@ -464,8 +463,8 @@ func TestModule_Search(t *testing.T) { { Presentation: vpAlice, Fields: map[string]interface{}{ - "auth_server_url":"https://example.com/oauth2/alice", - "issuer_field": authorityDID, + "auth_server_url": "https://example.com/oauth2/alice", + "issuer_field": authorityDID, }, Parameters: defaultRegistrationParams(aliceSubject), }, @@ -625,7 +624,7 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { require.EqualError(t, err, "subject not found") }) - t.Run("deactivated", func(t *testing.T) { + t.Run("deactivated DID", func(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) m, testContext := setupModule(t, storageEngine) @@ -634,7 +633,7 @@ func TestModule_ActivateServiceForSubject(t *testing.T) { err := m.ActivateServiceForSubject(context.Background(), testServiceID, aliceSubject, nil) - assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + assert.ErrorIs(t, err, ErrNoSupportedDIDMethods) }) t.Run("ok, but couldn't register presentation -> maps to ErrRegistrationFailed", func(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) @@ -667,7 +666,8 @@ func TestModule_GetServiceActivation(t *testing.T) { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) t.Run("not activated", func(t *testing.T) { - m, _ := setupModule(t, storageEngine) + m, ctx := setupModule(t, storageEngine) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil) activated, presentation, err := m.GetServiceActivation(context.Background(), testServiceID, aliceSubject) @@ -713,14 +713,23 @@ func TestModule_GetServiceActivation(t *testing.T) { assert.ErrorAs(t, err, &RegistrationRefreshError{}) }) }) -} + t.Run("service does not exist - 404", func(t *testing.T) { + m, _ := setupModule(t, storageEngine) -func checkWriteAccess(dir string) bool { - info, err := os.Stat(dir) - if err != nil { - return false - } + activated, presentation, err := m.GetServiceActivation(context.Background(), "unknown", "unknown") + + assert.ErrorIs(t, err, ErrServiceNotFound) + assert.False(t, activated) + assert.Nil(t, presentation) + }) + t.Run("subject does not exist - 404", func(t *testing.T) { + m, ctx := setupModule(t, storageEngine) + ctx.subjectManager.EXPECT().ListDIDs(gomock.Any(), "unknown").Return(nil, didsubject.ErrSubjectNotFound) + + activated, presentation, err := m.GetServiceActivation(context.Background(), testServiceID, "unknown") - // Check if the directory is writable by the current user - return info.Mode().Perm()&(1<<(uint(7))) != 0 + assert.ErrorIs(t, err, didsubject.ErrSubjectNotFound) + assert.False(t, activated) + assert.Nil(t, presentation) + }) } diff --git a/docs/_static/discovery/v1.yaml b/docs/_static/discovery/v1.yaml index d56076d6f..e8b597d4c 100644 --- a/docs/_static/discovery/v1.yaml +++ b/docs/_static/discovery/v1.yaml @@ -70,6 +70,9 @@ paths: - `credentialSubject.organization.name=Hospital*` - `credentialSubject.organization.name=*clinic` - `issuer=did:web:example.com` + + error returns: + * 404 - unknown service. operationId: searchPresentations tags: - discovery @@ -111,6 +114,9 @@ paths: and the status of the activation. A refresh could have failed. It will return true after successfully calling the activateServiceForSubject API, and false after calling the deactivateServiceForSubject API. It also returns the active Verifiable Presentations, if any. + + error returns: + * 404 - unknown service or subject. operationId: getServiceActivation tags: - discovery @@ -123,7 +129,6 @@ paths: type: object required: - activated - - status properties: activated: type: boolean @@ -140,31 +145,30 @@ paths: vp: description: | List of VPs on the Discovery Service for the subject. One per DID method registered on the Service. - The list can be empty even if activated==true if none of the DIDs of a subject is actually registered on the Discovery Service. + The list is empty when status is "error". type: array items: $ref: "#/components/schemas/VerifiablePresentation" default: $ref: "../common/error_response.yaml" post: - summary: Client API to activate a subject on the specified Discovery Service. + summary: Activate a Discovery Service for a subject. description: | An API provided by the discovery client that will cause all qualifying DIDs of a subject to be registered on the specified Discovery Service. A DID qualifies for registration if it meets the requirements defined the Presentation Definition of the Discovery Service. - Registration of all DIDs of a subject will be attempted immediately, and they will be automatically refreshed. + Registration of all DIDs of a subject will be attempted immediately. + If at least one DID is registered on the Discovery Server, the operation is considered a success and will be periodically refreshed for the entire subject. Applications only need to call this API once for every service/subject combination, until the registration is explicitly deleted through this API. - If initial registration fails, this API returns the error indicating what failed and periodically retry registration. Applications can force a retry by calling this API again. error returns: - * 400 - incorrect input: invalid/unknown service or subject. - * 412 - precondition failed: subject doesn't have the required credentials. + * 404 - unknown service or subject + * 412 - precondition failed: subject doesn't have the required credentials operationId: activateServiceForSubject tags: - discovery requestBody: - required: true content: application/json: schema: @@ -172,21 +176,16 @@ paths: responses: "200": description: Activation was successful. - - "400": - $ref: "../common/error_response.yaml" - "412": - $ref: "../common/error_response.yaml" default: $ref: "../common/error_response.yaml" delete: - summary: Client API to deactivate the given subject from the Discovery Service. + summary: Remove a subject from the Discovery Service. description: | An API provided by the discovery client that will cancel the periodic registration of a subject on the specified Discovery Service. It will also try to delete all the existing registrations on the Discovery Service, if any. error returns: - * 400 - incorrect input: invalid/unknown service or subject. + * 404 - unknown service or subject operationId: deactivateServiceForSubject tags: - discovery @@ -209,8 +208,6 @@ paths: reason: type: string description: Description of why removal of the registration failed. - "400": - $ref: "../common/error_response.yaml" default: $ref: "../common/error_response.yaml" components: @@ -269,6 +266,11 @@ components: id: type: string description: The ID of the Discovery Service. + did_methods: + type: array + items: + type: string + description: List of DID Methods supported by the Discovery Service. Empty/missing means no restrictions. endpoint: type: string description: The endpoint of the Discovery Service. diff --git a/e2e-tests/browser/client/iam/generated.go b/e2e-tests/browser/client/iam/generated.go index a4fffed9f..d264b86e7 100644 --- a/e2e-tests/browser/client/iam/generated.go +++ b/e2e-tests/browser/client/iam/generated.go @@ -85,7 +85,7 @@ type ExtendedTokenIntrospectionResponse struct { // 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 The client identity the access token was issued to. Since the Verifiable Presentation is used to grant access, the client_id reflects the client_id in the access token request. ClientId *string `json:"client_id,omitempty"` // Cnf The 'confirmation' claim is used in JWTs to proof the possession of a key. @@ -97,7 +97,7 @@ type ExtendedTokenIntrospectionResponse struct { // Iat Issuance time in seconds since UNIX epoch Iat *int `json:"iat,omitempty"` - // Iss Contains the DID of the authorizer. Should be equal to 'sub' + // Iss Issuer URL of the authorizer. Iss *string `json:"iss,omitempty"` // PresentationDefinitions Presentation Definitions, as described in Presentation Exchange specification, fulfilled to obtain the access token diff --git a/vdr/api/v2/generated.go b/vdr/api/v2/generated.go index 8e05597ab..11a3fd99f 100644 --- a/vdr/api/v2/generated.go +++ b/vdr/api/v2/generated.go @@ -35,6 +35,7 @@ type CreateSubjectOptions struct { Keys *KeyCreationOptions `json:"keys,omitempty"` // Subject controls the DID subject to which all created DIDs are bound. If not given, a uuid is generated and returned. + // The subject must follow the pattern [a-zA-Z0-9._-]+ Subject *string `json:"subject,omitempty"` }