Skip to content
Open
89 changes: 89 additions & 0 deletions docs/_static/vcr/vcr_v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,57 @@ paths:
$ref: '#/components/schemas/SearchVCResults'
default:
$ref: '../common/error_response.yaml'
/internal/vcr/v2/holder/expiring:
get:
summary: List credentials across all wallets on this node that are expired or about to expire.
description: |
Returns all credentials held by any subject on this node whose `expirationDate` is at or before
`now + within`. This includes credentials that are already expired. Credentials without an

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since eOverdracht (and others) uses NutsAuthVCs, this list will quickly become longer and longer. I think you might want to add some additional filtering here to include or exclude certain types. Also a param to ignore already expired VCs might be a good idea since you probably want to signal for upcoming expiration?

@reinkrul reinkrul May 18, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since eOverdracht (and others) uses NutsAuthVCs, this list will quickly become longer and longer. I think you might want to add some additional filtering here to include or exclude certain types.

That's right, we need something like that. I'm leaning towards an exclude model, because an include model quickly becomes out of date if a new credential is introduced. So you'd have something like:

/expiring?within=30d&exclude=NutsAuthorizationCredential

The downside is that you have to explicitly (in many cases, always) exclude certain types, but at least it'll be visible for operators if the configuration is off.

Also a param to ignore already expired VCs might be a good idea since you probably want to signal for upcoming expiration?

Maybe... You don't want to keep being bothered if you don't clean up expired VCs, on the other hand you could've missed/ignored (and forgot) about renewing it. We could make it more flexible (at the cost of a more complex API), by adding a parameter which specifies for how long we'll keep returning it, after it expired. E.g., return VCs that expired less than a week ago.

Now I think of it, you also don't want to send e-mails (if your monitoring system does that) every day for the same VC, 30 days straight (if you're checking for VCs that expire within 30 days, every day). Not sure if we should solve that here, but it complicates things.

Proposal: keep this feature simple at first;

  • Add excludeTypes parameter
  • Let the monitoring system deal with not sending too many notifications for the same VC every hour/day (we're not building a monitoring system here, just feeding it with data)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking about it, lets include expired credentials, since those should be visible so vendors can clean them up.

`expirationDate` are never returned, since they do not expire.

Operators can use this endpoint to monitor all wallets on the node and refresh credentials before
they expire.

error returns:
* 400 - Invalid value for the `within` parameter
* 500 - An error occurred while processing the request
operationId: getExpiringCredentialsInWallet
tags:
- credential
parameters:
- name: within
in: query
description: |
Time window relative to now in which a credential's `expirationDate` falls for it to be considered
expiring. Accepts a Go duration string (e.g. `24h`, `720h`, `30m`). Must be non-negative.
Comment thread
reinkrul marked this conversation as resolved.
Outdated
Defaults to 720h (30 days). Use `0s` to return only already-expired credentials.
required: false
schema:
type: string
default: "720h"
example: "720h"
responses:
"200":
description: |
Map of subject ID to the list of expired or about-to-expire credentials held by that subject.
Only subjects with at least one such credential are included.
content:
application/json:
schema:
type: object
additionalProperties:
type: array
items:
$ref: "#/components/schemas/ExpiringCredential"
example:
90BC1AE9-752B-432F-ADC3-DD9F9C61843C:
- id: "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5"
holder: "did:web:example.com:iam:123"
issuer: "did:web:issuer.example.com"
type: ["NutsOrganizationCredential"]
expirationDate: "2026-05-15T12:00:00Z"
default:
$ref: '../common/error_response.yaml'
components:
schemas:
VerifiableCredential:
Expand All @@ -616,6 +667,44 @@ components:
Revocation:
$ref: '../common/ssi_types.yaml#/components/schemas/Revocation'

ExpiringCredential:
type: object
description: |
Summary of a Verifiable Credential in a wallet on this node that is expired or about to expire.
Contains only the fields needed for monitoring; the full credential can be retrieved via the
wallet search endpoints using the `id`.
required:
- id
- holder
- issuer
- type
- expirationDate
properties:
id:
description: ID of the credential (the `id` property of the Verifiable Credential).
type: string
example: "did:web:issuer.example.com#c4199b74-0c0a-4e09-a463-6927553e65f5"
holder:
description: DID of the wallet holding the credential.
type: string
example: "did:web:example.com:iam:123"
issuer:
description: DID of the credential's issuer.
type: string
example: "did:web:issuer.example.com"
type:
description: |
Credential type(s), excluding the generic `VerifiableCredential` type.
type: array
items:
type: string
example: ["NutsOrganizationCredential"]
expirationDate:
description: RFC3339 time at which the credential expires.
type: string
format: date-time
example: "2026-05-15T12:00:00Z"

IssueVCRequest:
type: object
description: A request for issuing a new Verifiable Credential.
Expand Down
75 changes: 75 additions & 0 deletions vcr/api/vcr/v2/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ var clockFn = func() time.Time {
return time.Now()
}

// defaultExpiringWithin is the default time window used by GetExpiringCredentialsInWallet
// when the `within` query parameter is not supplied.
const defaultExpiringWithin = 30 * 24 * time.Hour

var _ StrictServerInterface = (*Wrapper)(nil)

// Wrapper implements the generated interface from oapi-codegen
Expand Down Expand Up @@ -489,6 +493,77 @@ func (w *Wrapper) SearchCredentialsInWallet(ctx context.Context, request SearchC
return SearchCredentialsInWallet200JSONResponse(SearchVCResults{VerifiableCredentials: searchResults}), nil
}

// GetExpiringCredentialsInWallet returns credentials across all wallets on this node that have an
// expirationDate at or before now + within, grouped by subject ID. Already-expired credentials are
// included. Credentials without an expirationDate are never returned because they don't expire.
// Subjects without any expiring credentials are omitted from the result.
func (w *Wrapper) GetExpiringCredentialsInWallet(ctx context.Context, request GetExpiringCredentialsInWalletRequestObject) (GetExpiringCredentialsInWalletResponseObject, error) {
within := defaultExpiringWithin
if request.Params.Within != nil {
parsed, err := time.ParseDuration(*request.Params.Within)
if err != nil {
return nil, core.InvalidInputError("invalid value for within: %w", err)
}
if parsed < 0 {
return nil, core.InvalidInputError("within must not be negative")
}
within = parsed
}

subjects, err := w.SubjectManager.List(ctx)
if err != nil {
return nil, err
}

threshold := clockFn().Add(within)
result := make(map[string][]ExpiringCredential)
for subjectID, dids := range subjects {
var expiring []ExpiringCredential
for _, holderDID := range dids {
creds, err := w.VCR.Wallet().SearchCredential(ctx, holderDID)
if err != nil {
return nil, err
}
for _, cred := range creds {
if cred.ExpirationDate == nil || cred.ExpirationDate.IsZero() {
continue
}
if cred.ExpirationDate.After(threshold) {
continue
}
expiring = append(expiring, toExpiringCredential(cred, holderDID))
}
}
if len(expiring) > 0 {
result[subjectID] = expiring
}
}

return GetExpiringCredentialsInWallet200JSONResponse(result), nil
}

// toExpiringCredential builds a monitoring-friendly summary of a credential. The Wallet stores
// credentials per holder DID, so the holder is supplied by the caller rather than re-derived.
func toExpiringCredential(cred vc.VerifiableCredential, holder did.DID) ExpiringCredential {
types := make([]string, 0, len(cred.Type))
for _, t := range cred.Type {
if s := t.String(); s != "VerifiableCredential" {
types = append(types, s)
}
}
var id string
if cred.ID != nil {
id = cred.ID.String()
}
return ExpiringCredential{
Id: id,
Holder: holder.String(),
Issuer: cred.Issuer.String(),
Type: types,
ExpirationDate: *cred.ExpirationDate,
}
}

func (w *Wrapper) RemoveCredentialFromWallet(ctx context.Context, request RemoveCredentialFromWalletRequestObject) (RemoveCredentialFromWalletResponseObject, error) {
// get DIDs for holder
dids, err := w.SubjectManager.ListDIDs(ctx, request.SubjectID)
Expand Down
160 changes: 160 additions & 0 deletions vcr/api/vcr/v2/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -891,6 +891,166 @@ func TestWrapper_SearchCredentialsInWallet(t *testing.T) {
})
}

func TestWrapper_GetExpiringCredentialsInWallet(t *testing.T) {
now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC)
originalClock := clockFn
clockFn = func() time.Time { return now }
t.Cleanup(func() { clockFn = originalClock })

issuerURI := ssi.MustParseURI("did:web:issuer.example.com")
otherHolderDID := did.MustParseDID("did:web:example.com:iam:other")
makeVC := func(idSuffix string, holder did.DID, exp *time.Time) vc.VerifiableCredential {
id := ssi.MustParseURI("did:web:issuer.example.com#" + idSuffix)
return vc.VerifiableCredential{
Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI("NutsOrganizationCredential")},
ID: &id,
Issuer: issuerURI,
CredentialSubject: []map[string]any{{"id": holder.String()}},
ExpirationDate: exp,
}
}
expired := now.Add(-24 * time.Hour)
soon := now.Add(10 * 24 * time.Hour)
farFuture := now.Add(365 * 24 * time.Hour)
expiredVC := makeVC("expired", holderDID, &expired)
soonVC := makeVC("soon", holderDID, &soon)
farVC := makeVC("far", holderDID, &farFuture)
noExpVC := makeVC("noexp", holderDID, nil)
otherSubjectExpiredVC := makeVC("other-expired", otherHolderDID, &expired)

expectedExpiredEntry := ExpiringCredential{
Id: "did:web:issuer.example.com#expired",
Holder: holderDID.String(),
Issuer: issuerURI.String(),
Type: []string{"NutsOrganizationCredential"},
ExpirationDate: expired,
}
expectedSoonEntry := ExpiringCredential{
Id: "did:web:issuer.example.com#soon",
Holder: holderDID.String(),
Issuer: issuerURI.String(),
Type: []string{"NutsOrganizationCredential"},
ExpirationDate: soon,
}
expectedFarEntry := ExpiringCredential{
Id: "did:web:issuer.example.com#far",
Holder: holderDID.String(),
Issuer: issuerURI.String(),
Type: []string{"NutsOrganizationCredential"},
ExpirationDate: farFuture,
}
expectedOtherEntry := ExpiringCredential{
Id: "did:web:issuer.example.com#other-expired",
Holder: otherHolderDID.String(),
Issuer: issuerURI.String(),
Type: []string{"NutsOrganizationCredential"},
ExpirationDate: expired,
}

t.Run("ok - groups expiring credentials by subject; subjects with none are omitted", func(t *testing.T) {
testContext := newMockContext(t)
emptySubjectDID := did.MustParseDID("did:web:example.com:iam:empty")
testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{
"holder-a": {holderDID},
"holder-b": {otherHolderDID},
"holder-c": {emptySubjectDID},
}, nil)
testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).
Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil)
testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, otherHolderDID).
Return([]vc.VerifiableCredential{otherSubjectExpiredVC}, nil)
testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, emptySubjectDID).
Return([]vc.VerifiableCredential{farVC, noExpVC}, nil)

response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{})

assert.NoError(t, err)
assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{
"holder-a": {expectedExpiredEntry, expectedSoonEntry},
"holder-b": {expectedOtherEntry},
}, response)
})

t.Run("ok - custom within=8760h (1y) also returns far", func(t *testing.T) {
testContext := newMockContext(t)
testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{
"holder-a": {holderDID},
}, nil)
testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).
Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil)
within := "8760h"

response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{
Params: GetExpiringCredentialsInWalletParams{Within: &within},
})

assert.NoError(t, err)
assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{
"holder-a": {expectedExpiredEntry, expectedSoonEntry, expectedFarEntry},
}, response)
})

t.Run("ok - within=0 returns only already-expired", func(t *testing.T) {
testContext := newMockContext(t)
testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{
"holder-a": {holderDID},
}, nil)
testContext.mockWallet.EXPECT().SearchCredential(testContext.requestCtx, holderDID).
Return([]vc.VerifiableCredential{expiredVC, soonVC, farVC, noExpVC}, nil)
within := "0s"

response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{
Params: GetExpiringCredentialsInWalletParams{Within: &within},
})

assert.NoError(t, err)
assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{
"holder-a": {expectedExpiredEntry},
}, response)
})

t.Run("ok - no subjects returns empty map", func(t *testing.T) {
testContext := newMockContext(t)
testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(map[string][]did.DID{}, nil)

response, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{})

assert.NoError(t, err)
assert.Equal(t, GetExpiringCredentialsInWallet200JSONResponse{}, response)
})

t.Run("error - invalid within", func(t *testing.T) {
testContext := newMockContext(t)
within := "not-a-duration"

_, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{
Params: GetExpiringCredentialsInWalletParams{Within: &within},
})

assert.ErrorContains(t, err, "invalid value for within")
})

t.Run("error - negative within", func(t *testing.T) {
testContext := newMockContext(t)
within := "-1h"

_, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{
Params: GetExpiringCredentialsInWalletParams{Within: &within},
})

assert.ErrorContains(t, err, "within must not be negative")
})

t.Run("error - subject manager fails", func(t *testing.T) {
testContext := newMockContext(t)
testContext.mockSubjectManager.EXPECT().List(gomock.Any()).Return(nil, assert.AnError)

_, err := testContext.client.GetExpiringCredentialsInWallet(testContext.requestCtx, GetExpiringCredentialsInWalletRequestObject{})

assert.ErrorIs(t, err, assert.AnError)
})
}

func TestWrapper_RemoveCredentialFromSubjectWallet(t *testing.T) {
didNuts := did.MustParseDID("did:nuts:123")
didWeb := did.MustParseDID("did:web:example.com")
Expand Down
Loading
Loading