Skip to content

Commit ced9f31

Browse files
committed
Allow issued credentials to be revoked
1 parent c5fa748 commit ced9f31

File tree

10 files changed

+231
-34
lines changed

10 files changed

+231
-34
lines changed

api/api.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
package api
22

33
import (
4+
"net/http"
5+
"strings"
6+
47
"github.com/nuts-foundation/nuts-admin/discovery"
58
"github.com/nuts-foundation/nuts-admin/identity"
69
"github.com/nuts-foundation/nuts-admin/issuer"
710
"github.com/nuts-foundation/nuts-admin/model"
8-
"net/http"
9-
"strings"
1011

1112
"github.com/labstack/echo/v4"
1213
)
@@ -60,7 +61,7 @@ func (w Wrapper) GetIssuedCredentials(ctx echo.Context, params GetIssuedCredenti
6061
if err != nil {
6162
return err
6263
}
63-
result := make([]model.VerifiableCredential, 0)
64+
result := make([]model.IssuedCredential, 0)
6465
for _, currID := range identities {
6566
for _, issuerDID := range currID.DIDs {
6667
credentials, err := w.IssuerService.GetIssuedCredentials(ctx.Request().Context(), issuerDID, strings.Split(params.CredentialTypes, ","))

api/api.yaml

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -67,15 +67,15 @@ paths:
6767
'404':
6868
description: The identity could not be found
6969
/api/issuer/vc:
70-
parameters:
71-
- name: credentialTypes
72-
description: A comma-separated list of credential types which are returned.
73-
in: query
74-
required: true
75-
schema:
76-
type: string
7770
get:
7871
operationId: getIssuedCredentials
72+
parameters:
73+
- name: credentialTypes
74+
description: A comma-separated list of credential types which are returned.
75+
in: query
76+
required: true
77+
schema:
78+
type: string
7979
responses:
8080
'200':
8181
description: List of issued VCs

api/proxy.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ var allowedProxyRoutes = []proxyRoute{
7171
method: http.MethodPost,
7272
path: "/internal/auth/v2/([a-z-A-Z0-9_\\-\\:\\.%]+)/request-credential",
7373
},
74+
// Revoke Verifiable Credential
75+
{
76+
method: http.MethodDelete,
77+
path: "/internal/vcr/v2/issuer/vc/(.*)",
78+
},
7479
}
7580

7681
// ConfigureProxy configures the proxy middleware for the given Nuts node address.

identity/service.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@ package identity
33
import (
44
"context"
55
"errors"
6+
"slices"
7+
"strings"
8+
69
"github.com/nuts-foundation/go-did/vc"
710
"github.com/nuts-foundation/go-nuts-client/nuts"
811
"github.com/nuts-foundation/go-nuts-client/nuts/vcr"
912
"github.com/nuts-foundation/go-nuts-client/nuts/vdr"
1013
"github.com/nuts-foundation/nuts-admin/discovery"
1114
"github.com/nuts-foundation/nuts-admin/model"
12-
"slices"
13-
"strings"
1415
)
1516

1617
type Service struct {
@@ -101,7 +102,7 @@ func (i Service) Get(ctx context.Context, subjectID string) (*IdentityDetails, e
101102
if err != nil {
102103
return nil, err
103104
}
104-
creds := model.ToModel(vcs)
105+
creds := model.ListToModel(vcs)
105106
result.WalletCredentials = creds
106107

107108
return &result, nil

issuer/service.go

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,37 @@ package issuer
22

33
import (
44
"context"
5-
"github.com/nuts-foundation/go-did/vc"
5+
"encoding/json"
6+
"strings"
7+
"time"
8+
69
"github.com/nuts-foundation/go-nuts-client/nuts"
710
"github.com/nuts-foundation/go-nuts-client/nuts/vcr"
811
"github.com/nuts-foundation/nuts-admin/identity"
912
"github.com/nuts-foundation/nuts-admin/model"
10-
"strings"
1113
)
1214

1315
type Service struct {
1416
IdentityService identity.Service
1517
VCRClient *vcr.Client
1618
}
1719

18-
func (s Service) GetIssuedCredentials(ctx context.Context, issuer string, credentialTypes []string) ([]model.VerifiableCredential, error) {
19-
var result []vc.VerifiableCredential
20+
func (s Service) GetIssuedCredentials(ctx context.Context, issuer string, credentialTypes []string) ([]model.IssuedCredential, error) {
21+
// Get all identities for lookup
22+
identities, err := s.IdentityService.List(ctx)
23+
if err != nil {
24+
return nil, err
25+
}
26+
27+
// Create a map of DID to subject name
28+
didToSubject := make(map[string]string)
29+
for _, ident := range identities {
30+
for _, did := range ident.DIDs {
31+
didToSubject[did] = ident.Subject
32+
}
33+
}
34+
35+
var result []model.IssuedCredential
2036
for _, credentialType := range credentialTypes {
2137
credentialType = strings.TrimSpace(credentialType)
2238
if credentialType == "" {
@@ -31,8 +47,21 @@ func (s Service) GetIssuedCredentials(ctx context.Context, issuer string, creden
3147
return nil, err
3248
}
3349
for _, searchResult := range response.JSON200.VerifiableCredentials {
34-
result = append(result, searchResult.VerifiableCredential)
50+
data, _ := json.Marshal(searchResult)
51+
println(string(data))
52+
currentResult := model.IssuedCredential{
53+
VerifiableCredential: model.ToModel(searchResult.VerifiableCredential),
54+
IssuerSubject: didToSubject[searchResult.VerifiableCredential.Issuer.String()],
55+
}
56+
if searchResult.Revocation != nil {
57+
currentResult.Status = "revoked"
58+
} else if searchResult.VerifiableCredential.ExpirationDate != nil && searchResult.VerifiableCredential.ExpirationDate.Before(time.Now()) {
59+
currentResult.Status = "expired"
60+
} else {
61+
currentResult.Status = "active"
62+
}
63+
result = append(result, currentResult)
3564
}
3665
}
37-
return model.ToModel(result), nil
66+
return result, nil
3867
}

model/model.go

Lines changed: 34 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package model
22

3-
import "github.com/nuts-foundation/go-did/vc"
3+
import (
4+
"time"
5+
6+
"github.com/nuts-foundation/go-did/vc"
7+
)
48

59
type VerifiableCredential vc.VerifiableCredential
610

@@ -9,16 +13,38 @@ type CredentialProfile struct {
913
Issuer string `json:"issuer" koanf:"issuer"`
1014
}
1115

12-
func ToModel(vcs []vc.VerifiableCredential) []VerifiableCredential {
16+
type IssuedCredential struct {
17+
VerifiableCredential
18+
IssuerSubject string `json:"issuer_subject,omitempty"`
19+
Status string `json:"status"`
20+
}
21+
22+
func ListToModel(vcs []vc.VerifiableCredential) []VerifiableCredential {
1323
result := make([]VerifiableCredential, 0)
1424
for _, credential := range vcs {
15-
currentCredential := VerifiableCredential(credential)
16-
if credential.Format() == vc.JWTCredentialProofFormat {
17-
currentCredential.Proof = []interface{}{
18-
"jwt",
19-
}
20-
}
25+
currentCredential := ToModel(credential)
2126
result = append(result, currentCredential)
2227
}
2328
return result
2429
}
30+
31+
func ToModel(credential vc.VerifiableCredential) VerifiableCredential {
32+
currentCredential := VerifiableCredential(credential)
33+
if credential.Format() == vc.JWTCredentialProofFormat {
34+
currentCredential.Proof = []interface{}{
35+
"jwt",
36+
}
37+
}
38+
return currentCredential
39+
}
40+
41+
// GetCredentialStatus determines if a credential is active, expired, or revoked
42+
func GetCredentialStatus(credential vc.VerifiableCredential, isRevoked bool) string {
43+
if isRevoked {
44+
return "revoked"
45+
}
46+
if credential.ExpirationDate != nil && credential.ExpirationDate.Before(time.Now()) {
47+
return "expired"
48+
}
49+
return "active"
50+
}

web/src/admin/credentials/CredentialDetails.vue

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
<label>Expiration date</label>
2323
<div>{{credential.expirationDate}}</div>
2424
</div>
25+
<div v-if="credential.status">
26+
<label>Status</label>
27+
<div>
28+
<span :class="statusClass">{{credential.status}}</span>
29+
</div>
30+
</div>
2531
</section>
2632
<section>
2733
<header>Credential Subject</header>
@@ -40,12 +46,36 @@
4046
</tbody>
4147
</table>
4248
</section>
49+
50+
<!-- Button bar for actions -->
51+
<div v-if="showRevokeButton && credential.status === 'active'" class="mt-4 pt-4 border-t border-gray-200">
52+
<button @click="revokeCredential"
53+
class="bg-red-600 hover:bg-red-700 text-white font-semibold py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed"
54+
:disabled="revoking">
55+
{{ revoking ? 'Revoking...' : 'Revoke Credential' }}
56+
</button>
57+
<p v-if="revokeError" class="text-red-600 mt-2">{{ revokeError }}</p>
58+
<p v-if="revokeSuccess" class="text-green-600 mt-2 font-semibold">Credential revoked successfully</p>
59+
</div>
4360
</div>
4461
</template>
4562
<script>
63+
import { parseApiError } from '../../lib/errors.js'
64+
4665
export default {
4766
props: {
48-
credential: Object
67+
credential: Object,
68+
showRevokeButton: {
69+
type: Boolean,
70+
default: false
71+
}
72+
},
73+
data() {
74+
return {
75+
revoking: false,
76+
revokeError: '',
77+
revokeSuccess: false
78+
}
4979
},
5080
computed: {
5181
credentialSubject() {
@@ -73,6 +103,50 @@ export default {
73103
return flatten(this.credential.credentialSubject[0])
74104
}
75105
return flatten(this.credential.credentialSubject)
106+
},
107+
statusClass() {
108+
switch(this.credential.status) {
109+
case 'active':
110+
return 'text-green-600 font-semibold'
111+
case 'expired':
112+
return 'text-gray-500 font-semibold'
113+
case 'revoked':
114+
return 'text-red-600 font-semibold'
115+
default:
116+
return ''
117+
}
118+
}
119+
},
120+
methods: {
121+
async revokeCredential() {
122+
if (!confirm('Are you sure you want to revoke this credential? This action cannot be undone.')) {
123+
return
124+
}
125+
126+
this.revoking = true
127+
this.revokeError = ''
128+
this.revokeSuccess = false
129+
130+
try {
131+
const credentialId = this.credential.id
132+
if (!credentialId) {
133+
throw new Error('Credential ID is missing')
134+
}
135+
136+
// Call the proxy endpoint to revoke the credential
137+
await this.$api.delete('api/proxy/internal/vcr/v2/issuer/vc/' + encodeURIComponent(credentialId))
138+
139+
this.revokeSuccess = true
140+
// Update the credential status locally
141+
this.credential.status = 'revoked'
142+
143+
// Emit event so parent component can refresh if needed
144+
this.$emit('revoked')
145+
} catch (error) {
146+
this.revokeError = parseApiError(error)
147+
} finally {
148+
this.revoking = false
149+
}
76150
}
77151
}
78152
}

web/src/admin/credentials/IssueCredential.vue

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,13 @@
6464
<input id="daysValid" v-model="daysValid" type="number">
6565
<p>This will be used to set the credentials <code>expirationDate</code> property.</p>
6666
</div>
67+
<div>
68+
<label for="enableRevocation" class="flex items-center cursor-pointer">
69+
<input id="enableRevocation" v-model="enableRevocation" type="checkbox" class="mr-2">
70+
<span>Enable revocation (StatusList2021)</span>
71+
</label>
72+
<p>Allows the credential to be revoked later using the StatusList2021 feature.</p>
73+
</div>
6774
<div v-for="(field, idx) in template.fields" :key="field.name">
6875
<label :for="field.name">
6976
{{ field.name }}
@@ -112,6 +119,7 @@ export default {
112119
template: undefined,
113120
credentialFields: [],
114121
daysValid: 365,
122+
enableRevocation: true,
115123
credentialPreview: undefined,
116124
issuedCredential: undefined,
117125
}
@@ -160,8 +168,9 @@ export default {
160168
credentialToIssue['type'] = credentialToIssue['type'].find(t => t !== "VerifiableCredential")
161169
credentialToIssue['expirationDate'] = new Date(new Date().getTime() + 1000 * 60 * 60 * 24 * this.daysValid).toISOString()
162170
credentialToIssue['format'] = this.credentialProofFormat
163-
// disable statusListRevocation2021 for now, causes issues on MS SQL Server
164-
//credentialToIssue.withStatusList2021Revocation = true
171+
if (this.enableRevocation) {
172+
credentialToIssue.withStatusList2021Revocation = true
173+
}
165174
this.fetchError = undefined
166175
this.$api.post('api/proxy/internal/vcr/v2/issuer/vc', credentialToIssue)
167176
.then(issuedCredential => {

0 commit comments

Comments
 (0)