Skip to content

Commit

Permalink
add DIDMethods config param to discovery service (#3384)
Browse files Browse the repository at this point in the history
  • Loading branch information
woutslakhorst authored Sep 18, 2024
1 parent 5568694 commit 2d077d4
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 8 deletions.
2 changes: 2 additions & 0 deletions discovery/api/server/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ func (w *Wrapper) ResolveStatusCode(err error) int {
switch {
case errors.Is(err, discovery.ErrInvalidPresentation):
return http.StatusBadRequest
case errors.Is(err, discovery.ErrDIDMethodsNotSupported):
return http.StatusBadRequest
case errors.Is(err, discovery.ErrServiceNotFound):
return http.StatusNotFound
default:
Expand Down
45 changes: 43 additions & 2 deletions discovery/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/pe"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
"slices"
"strings"
"time"
)
Expand Down Expand Up @@ -76,6 +77,21 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service
if err != nil {
return err
}
// filter DIDs on DID methods supported by the service
if len(service.DIDMethods) > 0 {
j := 0
for i, did := range subjectDIDs {
if slices.Contains(service.DIDMethods, did.Method) {
subjectDIDs[j] = subjectDIDs[i]
j++
}
}
subjectDIDs = subjectDIDs[:j]
}
if len(subjectDIDs) == 0 {
return fmt.Errorf("%w: %w for %s", ErrPresentationRegistrationFailed, ErrDIDMethodsNotSupported, subjectID)
}

var asSoonAsPossible time.Time
if err := r.store.updatePresentationRefreshTime(serviceID, subjectID, parameters, &asSoonAsPossible); err != nil {
return err
Expand Down Expand Up @@ -120,6 +136,10 @@ func (r *defaultClientRegistrationManager) activate(ctx context.Context, service
}

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)
if err != nil {
Expand All @@ -133,6 +153,22 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi
return err
}

// filter DIDs on DID methods supported by the service
if len(service.DIDMethods) > 0 {
j := 0
for i, did := range subjectDIDs {
if slices.Contains(service.DIDMethods, did.Method) {
subjectDIDs[j] = subjectDIDs[i]
j++
}
}
subjectDIDs = subjectDIDs[:j]
}
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)
}

// find all active presentations
vps2D, err := r.store.getSubjectVPsOnService(serviceID, subjectDIDs)
if err != nil {
Expand All @@ -141,7 +177,6 @@ func (r *defaultClientRegistrationManager) deactivate(ctx context.Context, servi

// retract active registrations for all DIDs
// failures are collected and merged into a single error
service := r.services[serviceID]
var loopErrs []error
for did, vps := range vps2D {
for _, vp := range vps {
Expand Down Expand Up @@ -232,7 +267,13 @@ func (r *defaultClientRegistrationManager) refresh(ctx context.Context, now time
for _, candidate := range refreshCandidates {
if err = r.activate(ctx, candidate.ServiceID, candidate.SubjectID, candidate.Parameters); err != nil {
var loopErr error
if errors.Is(err, didsubject.ErrSubjectNotFound) {
if errors.Is(err, ErrDIDMethodsNotSupported) {
// 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 if errors.Is(err, didsubject.ErrSubjectNotFound) {
// Subject has probably been deactivated. Remove from service or registration will be retried every refresh interval.
err = r.store.updatePresentationRefreshTime(candidate.ServiceID, candidate.SubjectID, candidate.Parameters, nil)
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions discovery/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,16 @@ func Test_defaultClientRegistrationManager_activate(t *testing.T) {
require.ErrorIs(t, err, ErrPresentationRegistrationFailed)
assert.ErrorContains(t, err, "invoker error")
})
t.Run("DID method not supported", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockSubjectManager := didsubject.NewMockSubjectManager(ctrl)
mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil)
manager := newRegistrationManager(testDefinitions(), nil, nil, nil, mockSubjectManager)

err := manager.activate(audit.TestContext(), unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject))

assert.ErrorIs(t, err, ErrDIDMethodsNotSupported)
})
t.Run("no matching credentials", func(t *testing.T) {
ctrl := gomock.NewController(t)
invoker := client.NewMockHTTPClient(ctrl)
Expand Down Expand Up @@ -239,6 +249,17 @@ func Test_defaultClientRegistrationManager_deactivate(t *testing.T) {

assert.NoError(t, err)
})
t.Run("DID method not supported", func(t *testing.T) {
ctrl := gomock.NewController(t)
mockSubjectManager := didsubject.NewMockSubjectManager(ctrl)
mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil)
store := setupStore(t, storageEngine.GetSQLDatabase())
manager := newRegistrationManager(testDefinitions(), store, nil, nil, mockSubjectManager)

err := manager.deactivate(audit.TestContext(), unsupportedServiceID, aliceSubject)

assert.ErrorIs(t, err, ErrDIDMethodsNotSupported)
})
t.Run("deregistering from Discovery Service fails", func(t *testing.T) {
ctrl := gomock.NewController(t)
invoker := client.NewMockHTTPClient(ctrl)
Expand Down Expand Up @@ -349,6 +370,25 @@ func Test_defaultClientRegistrationManager_refresh(t *testing.T) {

assert.EqualError(t, err, "removed unknown subject (service=usecase_v1, subject=alice)")
})

t.Run("deactivate unsupported DID method", func(t *testing.T) {
store := setupStore(t, storageEngine.GetSQLDatabase())
ctrl := gomock.NewController(t)
invoker := client.NewMockHTTPClient(ctrl)
mockVCR := vcr.NewMockVCR(ctrl)
mockSubjectManager := didsubject.NewMockSubjectManager(ctrl)
mockSubjectManager.EXPECT().ListDIDs(gomock.Any(), aliceSubject).Return([]did.DID{aliceDID}, nil)
manager := newRegistrationManager(testDefinitions(), store, invoker, mockVCR, mockSubjectManager)
_ = store.updatePresentationRefreshTime(unsupportedServiceID, aliceSubject, defaultRegistrationParams(aliceSubject), &time.Time{})

err := manager.refresh(audit.TestContext(), time.Now())

// refresh clears the registration
require.NoError(t, err)
refreshTime, err := store.getPresentationRefreshTime(unsupportedServiceID, aliceSubject)
assert.NoError(t, err)
assert.Nil(t, refreshTime)
})
}

func Test_clientUpdater_updateService(t *testing.T) {
Expand Down Expand Up @@ -432,6 +472,7 @@ func Test_clientUpdater_update(t *testing.T) {
httpClient := client.NewMockHTTPClient(ctrl)
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil)
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, errors.New("test"))
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(map[string]vc.VerifiablePresentation{}, 0, nil)
updater := newClientUpdater(testDefinitions(), store, alwaysOkVerifier, httpClient)

err := updater.update(context.Background())
Expand Down
3 changes: 3 additions & 0 deletions discovery/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ func init() {
type ServiceDefinition struct {
// ID is the unique identifier of the use case.
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"`
// 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,
Expand Down
2 changes: 2 additions & 0 deletions discovery/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ 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")

var ErrDIDMethodsNotSupported = errors.New("DID methods not supported")

// 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"
Expand Down
7 changes: 6 additions & 1 deletion discovery/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,16 @@ func (m *Module) verifyRegistration(definition ServiceDefinition, presentation v
if time.Until(expiration) > time.Duration(definition.PresentationMaxValidity)*time.Second {
return errors.Join(ErrInvalidPresentation, fmt.Errorf("presentation is valid for too long (max %s)", time.Duration(definition.PresentationMaxValidity)*time.Second))
}
// Check if the presentation already exists
credentialSubjectID, err := credential.PresentationSigner(presentation)
if err != nil {
return err
}
// Check if the issuer uses a supported DID method
if len(definition.DIDMethods) > 0 && !slices.Contains(definition.DIDMethods, credentialSubjectID.Method) {
return errors.Join(ErrInvalidPresentation, ErrDIDMethodsNotSupported)
}

// Check if the presentation already exists
exists, err := m.store.exists(definition.ID, credentialSubjectID.String(), presentation.ID.String())
if err != nil {
return err
Expand Down
20 changes: 17 additions & 3 deletions discovery/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,8 +153,7 @@ func Test_Module_Register(t *testing.T) {
t.Run("not conform to Presentation Definition", func(t *testing.T) {
m, _ := setupModule(t, storageEngine)

// Presentation Definition only allows did:example DIDs
otherVP := createPresentationCustom(unsupportedDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) {
otherVP := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) {
claims[jwt.AudienceKey] = []string{testServiceID}
}, createCredential(unsupportedDID, unsupportedDID, nil, nil))
err := m.Register(ctx, testServiceID, otherVP)
Expand All @@ -163,6 +162,20 @@ func Test_Module_Register(t *testing.T) {
_, timestamp, _ := m.Get(ctx, testServiceID, 0)
assert.Equal(t, 0, timestamp)
})
t.Run("unsupported DID method", func(t *testing.T) {
m, _ := setupModule(t, storageEngine, func(module *Module) {
module.serverDefinitions[unsupportedServiceID] = ServiceDefinition{
ID: unsupportedServiceID,
DIDMethods: []string{"unsupported"},
}
})
otherVP := createPresentationCustom(aliceDID, func(claims map[string]interface{}, vp *vc.VerifiablePresentation) {
claims[jwt.AudienceKey] = []string{unsupportedServiceID}
}, vcAlice, aliceDiscoveryCredential)

err := m.Register(ctx, unsupportedServiceID, otherVP)
assert.ErrorIs(t, err, ErrDIDMethodsNotSupported)
})
t.Run("cycle detected", func(t *testing.T) {
m, _ := setupModule(t, storageEngine, func(module *Module) {
module.allDefinitions["someother"] = ServiceDefinition{
Expand Down Expand Up @@ -312,6 +325,7 @@ func setupModule(t *testing.T, storageInstance storage.Engine, visitors ...func(
httpClient := client.NewMockHTTPClient(ctrl)
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/other", gomock.Any()).Return(nil, 0, nil).AnyTimes()
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/usecase", gomock.Any()).Return(nil, 0, nil).AnyTimes()
httpClient.EXPECT().Get(gomock.Any(), "http://example.com/unsupported", gomock.Any()).Return(nil, 0, nil).AnyTimes()
m.httpClient = httpClient
m.allDefinitions = testDefinitions()
m.serverDefinitions = map[string]ServiceDefinition{
Expand Down Expand Up @@ -542,7 +556,7 @@ func TestModule_Services(t *testing.T) {
services := (&Module{
allDefinitions: testDefinitions(),
}).Services()
assert.Len(t, services, 2)
assert.Len(t, services, 3)
})
}

Expand Down
12 changes: 10 additions & 2 deletions discovery/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ var vpBob vc.VerifiablePresentation
var unsupportedDID did.DID

var testServiceID = "usecase_v1"
var unsupportedServiceID = "unsupported"

func testDefinitions() map[string]ServiceDefinition {
return map[string]ServiceDefinition{
testServiceID: {
ID: testServiceID,
Endpoint: "http://example.com/usecase",
ID: testServiceID,
DIDMethods: []string{"example"},
Endpoint: "http://example.com/usecase",
PresentationDefinition: pe.PresentationDefinition{
Format: &pe.PresentationDefinitionClaimFormatDesignations{
"ldp_vc": {
Expand Down Expand Up @@ -146,6 +148,12 @@ func testDefinitions() map[string]ServiceDefinition {
},
PresentationMaxValidity: int((24 * time.Hour).Seconds()),
},
unsupportedServiceID: {
ID: "unsupported",
DIDMethods: []string{"unsupported"},
Endpoint: "http://example.com/unsupported",
PresentationMaxValidity: int((24 * time.Hour).Seconds()),
},
}
}

Expand Down
1 change: 1 addition & 0 deletions docs/pages/deployment/discovery.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ The parties implementing that use case then configure their Nuts nodes with the
The service definition is a JSON document agreed upon (and loaded) by all parties that specifies:

- which Verifiable Credentials are required for the service,
- which DID methods are allowed (blank for all),
- where the Discovery Service is hosted, and
- how often the Verifiable Presentations must be updated.

Expand Down

0 comments on commit 2d077d4

Please sign in to comment.