@learncard/vc-plugin 1.5.12 → 1.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.cts +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/refreshCredential.d.ts +13 -0
- package/dist/refreshCredential.d.ts.map +1 -0
- package/dist/refreshCredential.fetch.d.ts +13 -0
- package/dist/refreshCredential.fetch.d.ts.map +1 -0
- package/dist/refreshCredential.fetch.test.d.ts +2 -0
- package/dist/refreshCredential.fetch.test.d.ts.map +1 -0
- package/dist/refreshCredential.test.d.ts +2 -0
- package/dist/refreshCredential.test.d.ts.map +1 -0
- package/dist/types.d.ts +32 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/vc-plugin.cjs.development.cjs +626 -1
- package/dist/vc-plugin.cjs.development.cjs.map +4 -4
- package/dist/vc-plugin.cjs.production.min.cjs +1 -1
- package/dist/vc-plugin.cjs.production.min.cjs.map +4 -4
- package/dist/vc-plugin.esm.js +635 -1
- package/dist/vc-plugin.esm.js.map +4 -4
- package/dist/vc.d.ts.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +1 -0
- package/src/refreshCredential.fetch.test.ts +110 -0
- package/src/refreshCredential.fetch.ts +159 -0
- package/src/refreshCredential.test.ts +1144 -0
- package/src/refreshCredential.ts +931 -0
- package/src/types.ts +36 -0
- package/src/vc.ts +2 -0
|
@@ -0,0 +1,1144 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-explicit-any -- refresh protocol tests use intentionally partial wallet and credential doubles */
|
|
2
|
+
import { vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import type { CredentialRefreshResult, VC } from '@learncard/types';
|
|
5
|
+
|
|
6
|
+
import { getVCPlugin } from './vc';
|
|
7
|
+
import { refreshCredential } from './refreshCredential';
|
|
8
|
+
import type { PinnedAddress } from './refreshCredential.fetch';
|
|
9
|
+
import type { RefreshCredentialOptions } from './types';
|
|
10
|
+
|
|
11
|
+
const guardedTransportMock = vi.hoisted(() =>
|
|
12
|
+
vi.fn((url: URL, init: RequestInit, _pinnedAddress?: PinnedAddress) =>
|
|
13
|
+
globalThis.fetch(url.href, init)
|
|
14
|
+
)
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
vi.mock('./refreshCredential.fetch', () => ({ fetchWithPinnedAddress: guardedTransportMock }));
|
|
18
|
+
|
|
19
|
+
const PUBLIC_IP = '93.184.216.34';
|
|
20
|
+
const SECOND_PUBLIC_IP = '142.250.72.14';
|
|
21
|
+
const REFRESH_SERVICE_ID = 'https://refresh.example.com/refresh/refresh-1';
|
|
22
|
+
|
|
23
|
+
const okCheck: { checks: string[]; warnings: string[]; errors: string[] } = {
|
|
24
|
+
checks: ['proof'],
|
|
25
|
+
warnings: [],
|
|
26
|
+
errors: [],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const makeCredential = (overrides: Record<string, any> = {}): Record<string, any> => ({
|
|
30
|
+
'@context': ['https://www.w3.org/ns/credentials/v2'],
|
|
31
|
+
id: 'urn:uuid:credential-1',
|
|
32
|
+
type: ['VerifiableCredential'],
|
|
33
|
+
issuer: 'did:example:issuer',
|
|
34
|
+
validFrom: '2026-01-01T00:00:00.000Z',
|
|
35
|
+
credentialSubject: { id: 'did:example:holder', achievement: { name: 'Provisional' } },
|
|
36
|
+
refreshService: { id: REFRESH_SERVICE_ID, type: 'LearnCardCredentialRefresh2026' },
|
|
37
|
+
proof: {
|
|
38
|
+
type: 'DataIntegrityProof',
|
|
39
|
+
created: '2026-01-01T00:00:00Z',
|
|
40
|
+
proofPurpose: 'assertionMethod',
|
|
41
|
+
verificationMethod: 'did:example:issuer#key-1',
|
|
42
|
+
proofValue: 'z111',
|
|
43
|
+
},
|
|
44
|
+
...overrides,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const currentCredential = makeCredential();
|
|
48
|
+
|
|
49
|
+
const updatedCredential = makeCredential({
|
|
50
|
+
validFrom: '2026-02-01T00:00:00.000Z',
|
|
51
|
+
credentialSubject: { id: 'did:example:holder', achievement: { name: 'Final' } },
|
|
52
|
+
proof: {
|
|
53
|
+
type: 'DataIntegrityProof',
|
|
54
|
+
created: '2026-02-01T00:00:00Z',
|
|
55
|
+
proofPurpose: 'assertionMethod',
|
|
56
|
+
verificationMethod: 'did:example:issuer#key-1',
|
|
57
|
+
proofValue: 'z222',
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const validJwe = { protected: 'a', iv: 'b', ciphertext: 'c', tag: 'd' };
|
|
62
|
+
|
|
63
|
+
const getLearnCard = (overrides: Record<string, any> = {}) => ({
|
|
64
|
+
id: { did: () => 'did:example:holder' },
|
|
65
|
+
invoke: {
|
|
66
|
+
verifyCredential: vi.fn(async (credential: any) =>
|
|
67
|
+
credential?.proof?.type === 'DataIntegrityProof'
|
|
68
|
+
? okCheck
|
|
69
|
+
: { checks: [], warnings: [], errors: ['proof'] }
|
|
70
|
+
),
|
|
71
|
+
getDidAuthVp: vi.fn(
|
|
72
|
+
async (options: any) => `signed-vp:${options?.challenge ?? ''}:${options?.domain ?? ''}`
|
|
73
|
+
),
|
|
74
|
+
decryptDagJwe: vi.fn(async () => updatedCredential),
|
|
75
|
+
...overrides,
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const jsonResponse = (
|
|
80
|
+
body: unknown,
|
|
81
|
+
init: { status?: number; headers?: Record<string, string> } = {}
|
|
82
|
+
) =>
|
|
83
|
+
new Response(typeof body === 'string' ? body : JSON.stringify(body), {
|
|
84
|
+
status: init.status ?? 200,
|
|
85
|
+
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe('refreshCredential', () => {
|
|
89
|
+
let fetchMock: ReturnType<typeof vi.fn>;
|
|
90
|
+
let learnCard: ReturnType<typeof getLearnCard>;
|
|
91
|
+
|
|
92
|
+
const runRefresh = (
|
|
93
|
+
credential: any = currentCredential,
|
|
94
|
+
options: RefreshCredentialOptions = {}
|
|
95
|
+
): Promise<CredentialRefreshResult> =>
|
|
96
|
+
refreshCredential({} as never)(learnCard as never, credential as VC, {
|
|
97
|
+
resolveHost: async () => [PUBLIC_IP],
|
|
98
|
+
...options,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('refreshes a standard JSON credential without a credential ID', async () => {
|
|
102
|
+
const service = { id: REFRESH_SERVICE_ID, type: '1EdTechCredentialRefresh' };
|
|
103
|
+
const held = makeCredential({ id: undefined, refreshService: service });
|
|
104
|
+
const next = makeCredential({
|
|
105
|
+
id: undefined,
|
|
106
|
+
refreshService: service,
|
|
107
|
+
validFrom: '2026-03-01T00:00:00Z',
|
|
108
|
+
});
|
|
109
|
+
fetchMock.mockResolvedValue(jsonResponse(next));
|
|
110
|
+
expect(await runRefresh(held)).toMatchObject({ status: 'updated' });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('does not sign a LearnCard challenge from a standard service', async () => {
|
|
114
|
+
fetchMock.mockResolvedValue(jsonResponse({}, { status: 401 }));
|
|
115
|
+
const held = makeCredential({
|
|
116
|
+
refreshService: { id: REFRESH_SERVICE_ID, type: '1EdTechCredentialRefresh' },
|
|
117
|
+
});
|
|
118
|
+
expect(await runRefresh(held)).toMatchObject({ status: 'failed', code: 'UNAUTHORIZED' });
|
|
119
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('rejects a managed envelope advertised as a standard service', async () => {
|
|
123
|
+
fetchMock.mockResolvedValue(jsonResponse({ format: 'jwe', jwe: validJwe }));
|
|
124
|
+
const held = makeCredential({
|
|
125
|
+
refreshService: { id: REFRESH_SERVICE_ID, type: '1EdTechCredentialRefresh' },
|
|
126
|
+
});
|
|
127
|
+
expect(await runRefresh(held)).toMatchObject({ status: 'failed' });
|
|
128
|
+
expect(learnCard.invoke.decryptDagJwe).not.toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
beforeEach(() => {
|
|
132
|
+
fetchMock = vi.fn();
|
|
133
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
134
|
+
guardedTransportMock.mockClear();
|
|
135
|
+
learnCard = getLearnCard();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
vi.useRealTimers();
|
|
140
|
+
vi.unstubAllGlobals();
|
|
141
|
+
vi.restoreAllMocks();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('is registered on the VC plugin', () => {
|
|
145
|
+
const plugin = getVCPlugin(learnCard as never);
|
|
146
|
+
|
|
147
|
+
expect(typeof plugin.methods.refreshCredential).toBe('function');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('public services', () => {
|
|
151
|
+
it('returns the verified updated credential on a public GET success', async () => {
|
|
152
|
+
fetchMock.mockResolvedValue(
|
|
153
|
+
jsonResponse(updatedCredential, { headers: { etag: 'W/"v2"' } })
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const result = await runRefresh();
|
|
157
|
+
|
|
158
|
+
expect(result).toEqual({
|
|
159
|
+
status: 'updated',
|
|
160
|
+
credential: updatedCredential,
|
|
161
|
+
etag: 'W/"v2"',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
165
|
+
|
|
166
|
+
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
167
|
+
|
|
168
|
+
expect(url).toBe(REFRESH_SERVICE_ID);
|
|
169
|
+
expect(init.method ?? 'GET').toBe('GET');
|
|
170
|
+
expect((init.headers as Record<string, string>).accept).toContain('application/json');
|
|
171
|
+
expect((init.headers as Record<string, string>).accept).not.toContain(
|
|
172
|
+
'application/jwt'
|
|
173
|
+
);
|
|
174
|
+
expect((init.headers as Record<string, string>).accept).not.toContain(
|
|
175
|
+
'application/vc+jwt'
|
|
176
|
+
);
|
|
177
|
+
expect((init.headers as Record<string, string>).authorization).toBeUndefined();
|
|
178
|
+
expect(init.redirect).toBe('manual');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('sends If-None-Match when an etag is provided', async () => {
|
|
182
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 304 }));
|
|
183
|
+
|
|
184
|
+
await runRefresh(currentCredential, { etag: 'W/"v1"' });
|
|
185
|
+
|
|
186
|
+
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
187
|
+
|
|
188
|
+
expect((init.headers as Record<string, string>)['if-none-match']).toBe('W/"v1"');
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('returns unchanged on a 304 response', async () => {
|
|
192
|
+
fetchMock.mockResolvedValue(new Response(null, { status: 304 }));
|
|
193
|
+
|
|
194
|
+
const result = await runRefresh(currentCredential, { etag: 'W/"v1"' });
|
|
195
|
+
|
|
196
|
+
expect(result.status).toBe('unchanged');
|
|
197
|
+
|
|
198
|
+
if (result.status === 'unchanged') {
|
|
199
|
+
expect(typeof result.checkedAt).toBe('string');
|
|
200
|
+
expect(result.etag).toBe('W/"v1"');
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('returns unchanged when a 200 body has identical proof-insensitive content', async () => {
|
|
205
|
+
const sameContent = makeCredential({
|
|
206
|
+
proof: { ...makeCredential().proof, proofValue: 'z999' },
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
fetchMock.mockResolvedValue(jsonResponse(sameContent));
|
|
210
|
+
|
|
211
|
+
const result = await runRefresh();
|
|
212
|
+
|
|
213
|
+
expect(result.status).toBe('unchanged');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('returns unsupported when the service type is not recognized', async () => {
|
|
217
|
+
const credential = makeCredential({
|
|
218
|
+
refreshService: { id: REFRESH_SERVICE_ID, type: 'SomeOtherRefreshService' },
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = await runRefresh(credential);
|
|
222
|
+
|
|
223
|
+
expect(result).toEqual({ status: 'unsupported' });
|
|
224
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('returns unsupported when no refresh service exists', async () => {
|
|
228
|
+
const { refreshService: _omit, ...credential } = makeCredential();
|
|
229
|
+
|
|
230
|
+
const result = await runRefresh(credential);
|
|
231
|
+
|
|
232
|
+
expect(result).toEqual({ status: 'unsupported' });
|
|
233
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it('returns a retryable UNAVAILABLE failure on a 5xx endpoint failure', async () => {
|
|
237
|
+
fetchMock.mockResolvedValue(jsonResponse({ error: 'boom' }, { status: 500 }));
|
|
238
|
+
|
|
239
|
+
const result = await runRefresh();
|
|
240
|
+
|
|
241
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAVAILABLE', retryable: true });
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('returns a non-retryable UNAVAILABLE failure on other error statuses', async () => {
|
|
245
|
+
fetchMock.mockResolvedValue(jsonResponse({ error: 'nope' }, { status: 404 }));
|
|
246
|
+
|
|
247
|
+
const result = await runRefresh();
|
|
248
|
+
|
|
249
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAVAILABLE', retryable: false });
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('returns a retryable TIMEOUT failure when the request is aborted', async () => {
|
|
253
|
+
fetchMock.mockImplementation(
|
|
254
|
+
(_url: string, init: RequestInit) =>
|
|
255
|
+
new Promise((_resolve, reject) => {
|
|
256
|
+
init.signal?.addEventListener('abort', () =>
|
|
257
|
+
reject(new DOMException('The operation was aborted', 'AbortError'))
|
|
258
|
+
);
|
|
259
|
+
})
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const result = await runRefresh(currentCredential, { timeoutMs: 25 });
|
|
263
|
+
|
|
264
|
+
expect(result).toEqual({ status: 'failed', code: 'TIMEOUT', retryable: true });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('keeps the timeout active until the response body has been consumed', async () => {
|
|
268
|
+
fetchMock.mockImplementation((_url: string, init: RequestInit) => {
|
|
269
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
270
|
+
start(controller) {
|
|
271
|
+
init.signal?.addEventListener('abort', () => {
|
|
272
|
+
controller.error(
|
|
273
|
+
new DOMException('The operation was aborted', 'AbortError')
|
|
274
|
+
);
|
|
275
|
+
});
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
return Promise.resolve(
|
|
280
|
+
new Response(stream, {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: { 'content-type': 'application/json' },
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
const result = await Promise.race([
|
|
288
|
+
runRefresh(currentCredential, { timeoutMs: 25 }),
|
|
289
|
+
new Promise<'body-still-pending'>(resolve =>
|
|
290
|
+
setTimeout(() => resolve('body-still-pending'), 100)
|
|
291
|
+
),
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
expect(result).toEqual({ status: 'failed', code: 'TIMEOUT', retryable: true });
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it('returns MALFORMED_RESPONSE for invalid JSON', async () => {
|
|
298
|
+
fetchMock.mockResolvedValue(jsonResponse('this is not json'));
|
|
299
|
+
|
|
300
|
+
const result = await runRefresh();
|
|
301
|
+
|
|
302
|
+
expect(result).toEqual({
|
|
303
|
+
status: 'failed',
|
|
304
|
+
code: 'MALFORMED_RESPONSE',
|
|
305
|
+
retryable: false,
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('returns MALFORMED_RESPONSE for a wrong content type', async () => {
|
|
310
|
+
fetchMock.mockResolvedValue(
|
|
311
|
+
jsonResponse(updatedCredential, { headers: { 'content-type': 'text/html' } })
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
const result = await runRefresh();
|
|
315
|
+
|
|
316
|
+
expect(result).toEqual({
|
|
317
|
+
status: 'failed',
|
|
318
|
+
code: 'MALFORMED_RESPONSE',
|
|
319
|
+
retryable: false,
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it.each(['application/jwt', 'application/vc+jwt'])(
|
|
324
|
+
'does not accept advertised compact JWT media type %s as JSON',
|
|
325
|
+
async contentType => {
|
|
326
|
+
fetchMock.mockResolvedValue(
|
|
327
|
+
jsonResponse(updatedCredential, {
|
|
328
|
+
headers: { 'content-type': contentType },
|
|
329
|
+
})
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
const result = await runRefresh();
|
|
333
|
+
|
|
334
|
+
expect(result).toEqual({
|
|
335
|
+
status: 'failed',
|
|
336
|
+
code: 'MALFORMED_RESPONSE',
|
|
337
|
+
retryable: false,
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
it('returns MALFORMED_RESPONSE for an oversized streaming response', async () => {
|
|
343
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
344
|
+
start(controller) {
|
|
345
|
+
controller.enqueue(new TextEncoder().encode('x'.repeat(64)));
|
|
346
|
+
controller.enqueue(new TextEncoder().encode('y'.repeat(64)));
|
|
347
|
+
controller.close();
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
fetchMock.mockResolvedValue(
|
|
352
|
+
new Response(stream, {
|
|
353
|
+
status: 200,
|
|
354
|
+
headers: { 'content-type': 'application/json' },
|
|
355
|
+
})
|
|
356
|
+
);
|
|
357
|
+
|
|
358
|
+
const result = await runRefresh(currentCredential, { maxResponseBytes: 100 });
|
|
359
|
+
|
|
360
|
+
expect(result).toEqual({
|
|
361
|
+
status: 'failed',
|
|
362
|
+
code: 'MALFORMED_RESPONSE',
|
|
363
|
+
retryable: false,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it('returns INVALID_PROOF when the replacement proof fails verification', async () => {
|
|
368
|
+
fetchMock.mockResolvedValue(
|
|
369
|
+
jsonResponse({
|
|
370
|
+
...updatedCredential,
|
|
371
|
+
proof: {
|
|
372
|
+
type: 'Ed25519Signature2020',
|
|
373
|
+
created: '2026-02-01T00:00:00Z',
|
|
374
|
+
proofPurpose: 'assertionMethod',
|
|
375
|
+
verificationMethod: 'did:example:issuer#key-1',
|
|
376
|
+
proofValue: 'zforged',
|
|
377
|
+
},
|
|
378
|
+
})
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
const result = await runRefresh();
|
|
382
|
+
|
|
383
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it('verifies the current credential first and never fetches when it is invalid', async () => {
|
|
387
|
+
const credential = { ...currentCredential, proof: { type: 'ForgedProof' } };
|
|
388
|
+
|
|
389
|
+
const result = await runRefresh(credential);
|
|
390
|
+
|
|
391
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
392
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('requires the current credential verification to report a proof check', async () => {
|
|
396
|
+
learnCard.invoke.verifyCredential.mockResolvedValue({
|
|
397
|
+
checks: [],
|
|
398
|
+
warnings: [],
|
|
399
|
+
errors: [],
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const result = await runRefresh();
|
|
403
|
+
|
|
404
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
405
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it('rejects a current credential whose proof verification reports a warning', async () => {
|
|
409
|
+
learnCard.invoke.verifyCredential.mockResolvedValue({
|
|
410
|
+
checks: ['proof'],
|
|
411
|
+
warnings: ['The issuer did not authorize this signing key'],
|
|
412
|
+
errors: [],
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
const result = await runRefresh();
|
|
416
|
+
|
|
417
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
418
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
it('requires the replacement verification to report a proof check', async () => {
|
|
422
|
+
learnCard.invoke.verifyCredential
|
|
423
|
+
.mockResolvedValueOnce(okCheck)
|
|
424
|
+
.mockResolvedValueOnce({ checks: [], warnings: [], errors: [] });
|
|
425
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
426
|
+
|
|
427
|
+
const result = await runRefresh();
|
|
428
|
+
|
|
429
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
it('rejects a replacement whose proof verification reports a warning', async () => {
|
|
433
|
+
learnCard.invoke.verifyCredential.mockResolvedValueOnce(okCheck).mockResolvedValueOnce({
|
|
434
|
+
checks: ['proof'],
|
|
435
|
+
warnings: ['The issuer did not authorize this signing key'],
|
|
436
|
+
errors: [],
|
|
437
|
+
});
|
|
438
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
439
|
+
|
|
440
|
+
const result = await runRefresh();
|
|
441
|
+
|
|
442
|
+
expect(result).toEqual({ status: 'failed', code: 'INVALID_PROOF', retryable: false });
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('returns ID_MISMATCH when the candidate changes the credential ID', async () => {
|
|
446
|
+
fetchMock.mockResolvedValue(
|
|
447
|
+
jsonResponse({ ...updatedCredential, id: 'urn:uuid:credential-2' })
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
const result = await runRefresh();
|
|
451
|
+
|
|
452
|
+
expect(result).toEqual({ status: 'failed', code: 'ID_MISMATCH', retryable: false });
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it('returns ID_MISMATCH without fetching when the current credential has no ID', async () => {
|
|
456
|
+
const { id: _omit, ...credential } = makeCredential();
|
|
457
|
+
|
|
458
|
+
const result = await runRefresh(credential);
|
|
459
|
+
|
|
460
|
+
expect(result).toEqual({ status: 'failed', code: 'ID_MISMATCH', retryable: false });
|
|
461
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it('returns ISSUER_MISMATCH when the candidate changes the issuer', async () => {
|
|
465
|
+
fetchMock.mockResolvedValue(
|
|
466
|
+
jsonResponse({ ...updatedCredential, issuer: 'did:example:mallory' })
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const result = await runRefresh();
|
|
470
|
+
|
|
471
|
+
expect(result).toEqual({ status: 'failed', code: 'ISSUER_MISMATCH', retryable: false });
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it('normalizes object-form issuers when comparing issuer identity', async () => {
|
|
475
|
+
fetchMock.mockResolvedValue(
|
|
476
|
+
jsonResponse({ ...updatedCredential, issuer: { id: 'did:example:issuer' } })
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
const result = await runRefresh();
|
|
480
|
+
|
|
481
|
+
expect(result.status).toBe('updated');
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it('returns ID_MISMATCH when the candidate changes the holder', async () => {
|
|
485
|
+
fetchMock.mockResolvedValue(
|
|
486
|
+
jsonResponse({
|
|
487
|
+
...updatedCredential,
|
|
488
|
+
credentialSubject: { id: 'did:example:mallory' },
|
|
489
|
+
})
|
|
490
|
+
);
|
|
491
|
+
|
|
492
|
+
const result = await runRefresh();
|
|
493
|
+
|
|
494
|
+
expect(result).toEqual({ status: 'failed', code: 'ID_MISMATCH', retryable: false });
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
it('returns ROLLBACK for a strictly older effective timestamp', async () => {
|
|
498
|
+
fetchMock.mockResolvedValue(
|
|
499
|
+
jsonResponse({ ...updatedCredential, validFrom: '2025-06-01T00:00:00.000Z' })
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
const result = await runRefresh();
|
|
503
|
+
|
|
504
|
+
expect(result).toEqual({ status: 'failed', code: 'ROLLBACK', retryable: false });
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
it('accepts an equal timestamp with changed content as an update', async () => {
|
|
508
|
+
fetchMock.mockResolvedValue(
|
|
509
|
+
jsonResponse({ ...updatedCredential, validFrom: currentCredential.validFrom })
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
const result = await runRefresh();
|
|
513
|
+
|
|
514
|
+
expect(result.status).toBe('updated');
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
it('accepts changed content when neither credential carries a timestamp', async () => {
|
|
518
|
+
const { validFrom: _a, ...current } = makeCredential();
|
|
519
|
+
const { validFrom: _b, ...candidate } = { ...updatedCredential };
|
|
520
|
+
|
|
521
|
+
fetchMock.mockResolvedValue(jsonResponse(candidate));
|
|
522
|
+
|
|
523
|
+
const result = await runRefresh(current);
|
|
524
|
+
|
|
525
|
+
expect(result.status).toBe('updated');
|
|
526
|
+
});
|
|
527
|
+
|
|
528
|
+
it('rejects plain-HTTP endpoints as UNSAFE_ENDPOINT', async () => {
|
|
529
|
+
const credential = makeCredential({
|
|
530
|
+
refreshService: {
|
|
531
|
+
id: 'http://refresh.example.com/refresh/refresh-1',
|
|
532
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
533
|
+
},
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
const result = await runRefresh(credential);
|
|
537
|
+
|
|
538
|
+
expect(result).toEqual({ status: 'failed', code: 'UNSAFE_ENDPOINT', retryable: false });
|
|
539
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it.each([
|
|
543
|
+
['loopback', 'https://127.0.0.1/refresh/x'],
|
|
544
|
+
['link-local metadata', 'https://169.254.169.254/latest/meta-data'],
|
|
545
|
+
['private range', 'https://10.1.2.3/refresh/x'],
|
|
546
|
+
['ipv6 loopback', 'https://[::1]/refresh/x'],
|
|
547
|
+
['IPv4-mapped IPv6 loopback', 'https://[::ffff:127.0.0.1]/refresh/x'],
|
|
548
|
+
[
|
|
549
|
+
'expanded IPv4-mapped IPv6 metadata address',
|
|
550
|
+
'https://[0:0:0:0:0:ffff:a9fe:a9fe]/latest/meta-data',
|
|
551
|
+
],
|
|
552
|
+
['NAT64 well-known prefix', 'https://[64:ff9b::a9fe:a9fe]/latest/meta-data'],
|
|
553
|
+
['NAT64 local-use prefix', 'https://[64:ff9b:1::a9fe:a9fe]/latest/meta-data'],
|
|
554
|
+
['6to4 transition prefix', 'https://[2002:a9fe:a9fe::]/latest/meta-data'],
|
|
555
|
+
])('rejects %s host literals as UNSAFE_ENDPOINT', async (_label, id) => {
|
|
556
|
+
const credential = makeCredential({
|
|
557
|
+
refreshService: { id, type: 'LearnCardCredentialRefresh2026' },
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
const result = await runRefresh(credential);
|
|
561
|
+
|
|
562
|
+
expect(result).toEqual({ status: 'failed', code: 'UNSAFE_ENDPOINT', retryable: false });
|
|
563
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it('rejects endpoints whose DNS answers include private addresses', async () => {
|
|
567
|
+
const result = await runRefresh(currentCredential, {
|
|
568
|
+
resolveHost: async () => [PUBLIC_IP, '192.168.1.10'],
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
expect(result).toEqual({ status: 'failed', code: 'UNSAFE_ENDPOINT', retryable: false });
|
|
572
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('allows a private address only with the explicit local-development opt-in', async () => {
|
|
576
|
+
const credential = makeCredential({
|
|
577
|
+
refreshService: {
|
|
578
|
+
id: 'http://localhost:4000/refresh/refresh-1',
|
|
579
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
584
|
+
|
|
585
|
+
const result = await runRefresh(credential, {
|
|
586
|
+
allowInsecureHttp: true,
|
|
587
|
+
allowPrivateAddresses: true,
|
|
588
|
+
resolveHost: async () => ['127.0.0.1'],
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
expect(result.status).toBe('updated');
|
|
592
|
+
expect(guardedTransportMock).toHaveBeenCalledWith(
|
|
593
|
+
new URL('http://localhost:4000/refresh/refresh-1'),
|
|
594
|
+
expect.objectContaining({ method: 'GET' }),
|
|
595
|
+
{ address: '127.0.0.1', family: 4 }
|
|
596
|
+
);
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
it('passes the validated DNS address to the connection transport', async () => {
|
|
600
|
+
const resolveHost = vi.fn(async () => [PUBLIC_IP]);
|
|
601
|
+
|
|
602
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
603
|
+
|
|
604
|
+
const result = await runRefresh(currentCredential, { resolveHost });
|
|
605
|
+
|
|
606
|
+
expect(result.status).toBe('updated');
|
|
607
|
+
expect(resolveHost).toHaveBeenCalledTimes(1);
|
|
608
|
+
expect(guardedTransportMock).toHaveBeenCalledWith(
|
|
609
|
+
new URL(REFRESH_SERVICE_ID),
|
|
610
|
+
expect.objectContaining({ method: 'GET' }),
|
|
611
|
+
{ address: PUBLIC_IP, family: 4 }
|
|
612
|
+
);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
it.each(['::ffff:7f00:1', '0:0:0:0:0:ffff:a9fe:a9fe'])(
|
|
616
|
+
'rejects a private IPv4-mapped DNS answer in canonical form (%s)',
|
|
617
|
+
async mappedAddress => {
|
|
618
|
+
const result = await runRefresh(currentCredential, {
|
|
619
|
+
resolveHost: async () => [mappedAddress],
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
expect(result).toEqual({
|
|
623
|
+
status: 'failed',
|
|
624
|
+
code: 'UNSAFE_ENDPOINT',
|
|
625
|
+
retryable: false,
|
|
626
|
+
});
|
|
627
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
628
|
+
}
|
|
629
|
+
);
|
|
630
|
+
|
|
631
|
+
it.each(['64:ff9b::a9fe:a9fe', '64:ff9b:1::a9fe:a9fe', '2002:a9fe:a9fe::'])(
|
|
632
|
+
'rejects an IPv4 transition/translation DNS answer (%s)',
|
|
633
|
+
async translatedAddress => {
|
|
634
|
+
const result = await runRefresh(currentCredential, {
|
|
635
|
+
resolveHost: async () => [translatedAddress],
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
expect(result).toEqual({
|
|
639
|
+
status: 'failed',
|
|
640
|
+
code: 'UNSAFE_ENDPOINT',
|
|
641
|
+
retryable: false,
|
|
642
|
+
});
|
|
643
|
+
expect(fetchMock).not.toHaveBeenCalled();
|
|
644
|
+
}
|
|
645
|
+
);
|
|
646
|
+
|
|
647
|
+
it.each([
|
|
648
|
+
[
|
|
649
|
+
'literal',
|
|
650
|
+
makeCredential({
|
|
651
|
+
refreshService: {
|
|
652
|
+
id: 'https://[2606:4700:4700::1111]/refresh/x',
|
|
653
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
654
|
+
},
|
|
655
|
+
}),
|
|
656
|
+
{},
|
|
657
|
+
],
|
|
658
|
+
[
|
|
659
|
+
'DNS answer',
|
|
660
|
+
currentCredential,
|
|
661
|
+
{ resolveHost: async () => ['2606:4700:4700::1111'] },
|
|
662
|
+
],
|
|
663
|
+
])('allows an ordinary public IPv6 %s', async (_label, credential, options) => {
|
|
664
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
665
|
+
|
|
666
|
+
const result = await runRefresh(credential, options);
|
|
667
|
+
|
|
668
|
+
expect(result.status).toBe('updated');
|
|
669
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
it('allows plain HTTP only with the explicit local-development opt-in', async () => {
|
|
673
|
+
const credential = makeCredential({
|
|
674
|
+
refreshService: {
|
|
675
|
+
id: 'http://refresh.example.com/refresh/refresh-1',
|
|
676
|
+
type: 'LearnCardCredentialRefresh2026',
|
|
677
|
+
},
|
|
678
|
+
});
|
|
679
|
+
|
|
680
|
+
fetchMock.mockResolvedValue(jsonResponse(updatedCredential));
|
|
681
|
+
|
|
682
|
+
const result = await runRefresh(credential, { allowInsecureHttp: true });
|
|
683
|
+
|
|
684
|
+
expect(result.status).toBe('updated');
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('follows a same-origin redirect and revalidates the target', async () => {
|
|
688
|
+
fetchMock
|
|
689
|
+
.mockResolvedValueOnce(
|
|
690
|
+
new Response(null, {
|
|
691
|
+
status: 302,
|
|
692
|
+
headers: { location: '/refresh/refresh-2' },
|
|
693
|
+
})
|
|
694
|
+
)
|
|
695
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
696
|
+
|
|
697
|
+
const result = await runRefresh();
|
|
698
|
+
|
|
699
|
+
expect(result.status).toBe('updated');
|
|
700
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
701
|
+
expect(fetchMock.mock.calls[1][0]).toBe(
|
|
702
|
+
'https://refresh.example.com/refresh/refresh-2'
|
|
703
|
+
);
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it('pins the separately validated address for every redirect target', async () => {
|
|
707
|
+
const resolveHost = vi.fn(async (hostname: string) =>
|
|
708
|
+
hostname === 'refresh.example.com' ? [PUBLIC_IP] : [SECOND_PUBLIC_IP]
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
fetchMock
|
|
712
|
+
.mockResolvedValueOnce(
|
|
713
|
+
new Response(null, {
|
|
714
|
+
status: 302,
|
|
715
|
+
headers: { location: 'https://other.example.com/refresh/refresh-2' },
|
|
716
|
+
})
|
|
717
|
+
)
|
|
718
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
719
|
+
|
|
720
|
+
const result = await runRefresh(currentCredential, { resolveHost });
|
|
721
|
+
|
|
722
|
+
expect(result.status).toBe('updated');
|
|
723
|
+
expect(resolveHost).toHaveBeenCalledTimes(2);
|
|
724
|
+
expect(guardedTransportMock.mock.calls.map(call => call[2])).toEqual([
|
|
725
|
+
{ address: PUBLIC_IP, family: 4 },
|
|
726
|
+
{ address: SECOND_PUBLIC_IP, family: 4 },
|
|
727
|
+
]);
|
|
728
|
+
});
|
|
729
|
+
|
|
730
|
+
it('rejects redirects to unsafe targets', async () => {
|
|
731
|
+
fetchMock.mockResolvedValue(
|
|
732
|
+
new Response(null, {
|
|
733
|
+
status: 302,
|
|
734
|
+
headers: { location: 'https://127.0.0.1/refresh/x' },
|
|
735
|
+
})
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
const result = await runRefresh();
|
|
739
|
+
|
|
740
|
+
expect(result).toEqual({ status: 'failed', code: 'UNSAFE_ENDPOINT', retryable: false });
|
|
741
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('caps the number of followed redirects', async () => {
|
|
745
|
+
fetchMock.mockResolvedValue(
|
|
746
|
+
new Response(null, {
|
|
747
|
+
status: 302,
|
|
748
|
+
headers: { location: '/refresh/loop' },
|
|
749
|
+
})
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
const result = await runRefresh(currentCredential, { maxRedirects: 2 });
|
|
753
|
+
|
|
754
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAVAILABLE', retryable: false });
|
|
755
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
it('shares one timeout budget across redirect hops', async () => {
|
|
760
|
+
vi.useFakeTimers();
|
|
761
|
+
fetchMock
|
|
762
|
+
.mockImplementationOnce(
|
|
763
|
+
() =>
|
|
764
|
+
new Promise(resolve => {
|
|
765
|
+
setTimeout(
|
|
766
|
+
() =>
|
|
767
|
+
resolve(
|
|
768
|
+
new Response(null, {
|
|
769
|
+
status: 302,
|
|
770
|
+
headers: { location: '/next' },
|
|
771
|
+
})
|
|
772
|
+
),
|
|
773
|
+
15
|
|
774
|
+
);
|
|
775
|
+
})
|
|
776
|
+
)
|
|
777
|
+
.mockImplementationOnce(
|
|
778
|
+
(_url, init) =>
|
|
779
|
+
new Promise((_resolve, reject) => {
|
|
780
|
+
init.signal.addEventListener('abort', () =>
|
|
781
|
+
reject(new DOMException('aborted', 'AbortError'))
|
|
782
|
+
);
|
|
783
|
+
})
|
|
784
|
+
);
|
|
785
|
+
const pending = runRefresh(currentCredential, { timeoutMs: 25 });
|
|
786
|
+
await vi.advanceTimersByTimeAsync(25);
|
|
787
|
+
const secondSignal = fetchMock.mock.calls[1]?.[1].signal;
|
|
788
|
+
expect(secondSignal?.aborted).toBe(true);
|
|
789
|
+
expect(await pending).toEqual({ status: 'failed', code: 'TIMEOUT', retryable: true });
|
|
790
|
+
});
|
|
791
|
+
|
|
792
|
+
describe('managed services', () => {
|
|
793
|
+
const challengeHeaders = {
|
|
794
|
+
'www-authenticate':
|
|
795
|
+
'LearnCardDIDAuth challenge="srv-challenge-1", domain="refresh.example.com"',
|
|
796
|
+
'content-type': 'application/json',
|
|
797
|
+
};
|
|
798
|
+
const challengeBody = {
|
|
799
|
+
challenge: 'srv-challenge-1',
|
|
800
|
+
expiresAt: '2099-01-01T00:00:00.000Z',
|
|
801
|
+
domain: 'refresh.example.com',
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
const challengeResponse = (headers: Record<string, string>, body?: unknown) =>
|
|
805
|
+
new Response(body === undefined ? null : JSON.stringify(body), {
|
|
806
|
+
status: 401,
|
|
807
|
+
headers,
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
it('does not sign for a cross-origin redirect target', async () => {
|
|
811
|
+
fetchMock
|
|
812
|
+
.mockResolvedValueOnce(
|
|
813
|
+
new Response(null, {
|
|
814
|
+
status: 302,
|
|
815
|
+
headers: { location: 'https://other.example.com/refresh/1' },
|
|
816
|
+
})
|
|
817
|
+
)
|
|
818
|
+
.mockResolvedValueOnce(
|
|
819
|
+
challengeResponse({
|
|
820
|
+
'www-authenticate':
|
|
821
|
+
'LearnCardDIDAuth challenge="other", domain="other.example.com"',
|
|
822
|
+
})
|
|
823
|
+
);
|
|
824
|
+
expect(await runRefresh()).toEqual({
|
|
825
|
+
status: 'failed',
|
|
826
|
+
code: 'UNAUTHORIZED',
|
|
827
|
+
retryable: false,
|
|
828
|
+
});
|
|
829
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
830
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
831
|
+
});
|
|
832
|
+
|
|
833
|
+
it('bounds live challenge retention and frees expired capacity', async () => {
|
|
834
|
+
let now = Date.now();
|
|
835
|
+
vi.spyOn(Date, 'now').mockImplementation(() => now);
|
|
836
|
+
const method = refreshCredential({} as never);
|
|
837
|
+
const options = { resolveHost: async () => [PUBLIC_IP] };
|
|
838
|
+
const queueChallenge = (index: number) =>
|
|
839
|
+
fetchMock.mockResolvedValueOnce(
|
|
840
|
+
challengeResponse(
|
|
841
|
+
{ 'www-authenticate': 'LearnCardDIDAuth' },
|
|
842
|
+
{
|
|
843
|
+
...challengeBody,
|
|
844
|
+
challenge: `bounded-${index}`,
|
|
845
|
+
expiresAt: new Date(now + 1000).toISOString(),
|
|
846
|
+
}
|
|
847
|
+
)
|
|
848
|
+
);
|
|
849
|
+
for (let index = 0; index < 1024; index += 1) {
|
|
850
|
+
queueChallenge(index).mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
851
|
+
expect(
|
|
852
|
+
(await method(learnCard as never, currentCredential as VC, options)).status
|
|
853
|
+
).toBe('updated');
|
|
854
|
+
}
|
|
855
|
+
queueChallenge(1024);
|
|
856
|
+
expect(await method(learnCard as never, currentCredential as VC, options)).toEqual({
|
|
857
|
+
status: 'failed',
|
|
858
|
+
code: 'UNAUTHORIZED',
|
|
859
|
+
retryable: false,
|
|
860
|
+
});
|
|
861
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledTimes(1024);
|
|
862
|
+
now += 1001;
|
|
863
|
+
queueChallenge(1025).mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
864
|
+
expect(
|
|
865
|
+
(await method(learnCard as never, currentCredential as VC, options)).status
|
|
866
|
+
).toBe('updated');
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
it('answers a LearnCardDIDAuth challenge once and decrypts the returned JWE', async () => {
|
|
870
|
+
fetchMock
|
|
871
|
+
.mockResolvedValueOnce(challengeResponse(challengeHeaders, challengeBody))
|
|
872
|
+
.mockResolvedValueOnce(
|
|
873
|
+
jsonResponse({ format: 'jwe', jwe: validJwe, etag: 'W/"enc-2"', version: 3 })
|
|
874
|
+
);
|
|
875
|
+
|
|
876
|
+
const result = await runRefresh();
|
|
877
|
+
|
|
878
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
879
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledWith({
|
|
880
|
+
proofFormat: 'jwt',
|
|
881
|
+
challenge: 'srv-challenge-1',
|
|
882
|
+
domain: 'refresh.example.com',
|
|
883
|
+
});
|
|
884
|
+
|
|
885
|
+
const [retryUrl, retryInit] = fetchMock.mock.calls[1] as [string, RequestInit];
|
|
886
|
+
const retryHeaders = retryInit.headers as Record<string, string>;
|
|
887
|
+
|
|
888
|
+
expect(retryUrl).toBe(REFRESH_SERVICE_ID);
|
|
889
|
+
expect(retryHeaders.authorization).toBe(
|
|
890
|
+
'Bearer signed-vp:srv-challenge-1:refresh.example.com'
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
expect(learnCard.invoke.decryptDagJwe).toHaveBeenCalledWith(validJwe);
|
|
894
|
+
expect(result).toEqual({
|
|
895
|
+
status: 'updated',
|
|
896
|
+
credential: updatedCredential,
|
|
897
|
+
etag: 'W/"enc-2"',
|
|
898
|
+
managedVersion: 3,
|
|
899
|
+
});
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
it('accepts a challenge carried only in the JSON body', async () => {
|
|
903
|
+
fetchMock
|
|
904
|
+
.mockResolvedValueOnce(
|
|
905
|
+
challengeResponse(
|
|
906
|
+
{
|
|
907
|
+
'www-authenticate': 'LearnCardDIDAuth',
|
|
908
|
+
'content-type': 'application/json',
|
|
909
|
+
},
|
|
910
|
+
challengeBody
|
|
911
|
+
)
|
|
912
|
+
)
|
|
913
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
914
|
+
|
|
915
|
+
const result = await runRefresh();
|
|
916
|
+
|
|
917
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledWith({
|
|
918
|
+
proofFormat: 'jwt',
|
|
919
|
+
challenge: 'srv-challenge-1',
|
|
920
|
+
domain: 'refresh.example.com',
|
|
921
|
+
});
|
|
922
|
+
expect(result.status).toBe('updated');
|
|
923
|
+
});
|
|
924
|
+
|
|
925
|
+
it('rejects a challenge whose audience does not match the validated endpoint', async () => {
|
|
926
|
+
fetchMock.mockResolvedValue(
|
|
927
|
+
challengeResponse(
|
|
928
|
+
{
|
|
929
|
+
'www-authenticate':
|
|
930
|
+
'LearnCardDIDAuth challenge="srv-challenge-1", domain="attacker.example"',
|
|
931
|
+
'content-type': 'application/json',
|
|
932
|
+
},
|
|
933
|
+
{ ...challengeBody, domain: 'attacker.example' }
|
|
934
|
+
)
|
|
935
|
+
);
|
|
936
|
+
|
|
937
|
+
const result = await runRefresh();
|
|
938
|
+
|
|
939
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
940
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
941
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
942
|
+
});
|
|
943
|
+
|
|
944
|
+
it('rejects conflicting header and body challenge audiences without signing', async () => {
|
|
945
|
+
fetchMock.mockResolvedValue(
|
|
946
|
+
challengeResponse(
|
|
947
|
+
{
|
|
948
|
+
'www-authenticate':
|
|
949
|
+
'LearnCardDIDAuth challenge="srv-challenge-1", domain="refresh.example.com"',
|
|
950
|
+
'content-type': 'application/json',
|
|
951
|
+
},
|
|
952
|
+
{ ...challengeBody, domain: 'attacker.example' }
|
|
953
|
+
)
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
const result = await runRefresh();
|
|
957
|
+
|
|
958
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
959
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
960
|
+
});
|
|
961
|
+
|
|
962
|
+
it('requires an exact LearnCardDIDAuth authentication scheme', async () => {
|
|
963
|
+
fetchMock.mockResolvedValue(
|
|
964
|
+
challengeResponse(
|
|
965
|
+
{
|
|
966
|
+
'www-authenticate':
|
|
967
|
+
'LearnCardDIDAuthRelay challenge="srv-challenge-1", domain="refresh.example.com"',
|
|
968
|
+
'content-type': 'application/json',
|
|
969
|
+
},
|
|
970
|
+
challengeBody
|
|
971
|
+
)
|
|
972
|
+
);
|
|
973
|
+
|
|
974
|
+
const result = await runRefresh();
|
|
975
|
+
|
|
976
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
977
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
978
|
+
});
|
|
979
|
+
|
|
980
|
+
it('rejects a malformed challenge without signing', async () => {
|
|
981
|
+
fetchMock.mockResolvedValue(
|
|
982
|
+
challengeResponse(
|
|
983
|
+
{
|
|
984
|
+
'www-authenticate': 'LearnCardDIDAuth',
|
|
985
|
+
'content-type': 'application/json',
|
|
986
|
+
},
|
|
987
|
+
'not json at all'
|
|
988
|
+
)
|
|
989
|
+
);
|
|
990
|
+
|
|
991
|
+
const result = await runRefresh();
|
|
992
|
+
|
|
993
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
994
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
995
|
+
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
996
|
+
});
|
|
997
|
+
|
|
998
|
+
it('rejects an expired challenge without signing', async () => {
|
|
999
|
+
fetchMock.mockResolvedValue(
|
|
1000
|
+
challengeResponse(challengeHeaders, {
|
|
1001
|
+
...challengeBody,
|
|
1002
|
+
expiresAt: '2020-01-01T00:00:00.000Z',
|
|
1003
|
+
})
|
|
1004
|
+
);
|
|
1005
|
+
|
|
1006
|
+
const result = await runRefresh();
|
|
1007
|
+
|
|
1008
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
1009
|
+
expect(learnCard.invoke.getDidAuthVp).not.toHaveBeenCalled();
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
it('rejects a replayed challenge value without signing again', async () => {
|
|
1013
|
+
const method = refreshCredential({} as never);
|
|
1014
|
+
const options: RefreshCredentialOptions = { resolveHost: async () => [PUBLIC_IP] };
|
|
1015
|
+
|
|
1016
|
+
fetchMock
|
|
1017
|
+
.mockResolvedValueOnce(challengeResponse(challengeHeaders, challengeBody))
|
|
1018
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
1019
|
+
|
|
1020
|
+
const first = await method(learnCard as never, currentCredential as VC, options);
|
|
1021
|
+
|
|
1022
|
+
expect(first.status).toBe('updated');
|
|
1023
|
+
|
|
1024
|
+
fetchMock.mockResolvedValueOnce(challengeResponse(challengeHeaders, challengeBody));
|
|
1025
|
+
|
|
1026
|
+
const second = await method(learnCard as never, currentCredential as VC, options);
|
|
1027
|
+
|
|
1028
|
+
expect(second).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
1029
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledTimes(1);
|
|
1030
|
+
});
|
|
1031
|
+
|
|
1032
|
+
it('allows exactly one authenticated retry', async () => {
|
|
1033
|
+
fetchMock
|
|
1034
|
+
.mockResolvedValueOnce(challengeResponse(challengeHeaders, challengeBody))
|
|
1035
|
+
.mockResolvedValueOnce(
|
|
1036
|
+
challengeResponse(
|
|
1037
|
+
{
|
|
1038
|
+
'www-authenticate':
|
|
1039
|
+
'LearnCardDIDAuth challenge="srv-challenge-2", domain="refresh.example.com"',
|
|
1040
|
+
'content-type': 'application/json',
|
|
1041
|
+
},
|
|
1042
|
+
{ ...challengeBody, challenge: 'srv-challenge-2' }
|
|
1043
|
+
)
|
|
1044
|
+
);
|
|
1045
|
+
|
|
1046
|
+
const result = await runRefresh();
|
|
1047
|
+
|
|
1048
|
+
expect(result).toEqual({ status: 'failed', code: 'UNAUTHORIZED', retryable: false });
|
|
1049
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
1050
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledTimes(1);
|
|
1051
|
+
});
|
|
1052
|
+
|
|
1053
|
+
it('fails a managed JWE response when no decrypt capability exists', async () => {
|
|
1054
|
+
learnCard = getLearnCard({ decryptDagJwe: undefined });
|
|
1055
|
+
|
|
1056
|
+
fetchMock.mockResolvedValue(jsonResponse({ format: 'jwe', jwe: validJwe }));
|
|
1057
|
+
|
|
1058
|
+
const result = await runRefresh();
|
|
1059
|
+
|
|
1060
|
+
expect(result).toEqual({
|
|
1061
|
+
status: 'failed',
|
|
1062
|
+
code: 'UNSUPPORTED_SERVICE',
|
|
1063
|
+
retryable: false,
|
|
1064
|
+
});
|
|
1065
|
+
});
|
|
1066
|
+
|
|
1067
|
+
it('returns REVOKED on a 410 response', async () => {
|
|
1068
|
+
fetchMock.mockResolvedValue(
|
|
1069
|
+
jsonResponse({ code: 'CREDENTIAL_REVOKED' }, { status: 410 })
|
|
1070
|
+
);
|
|
1071
|
+
|
|
1072
|
+
const result = await runRefresh();
|
|
1073
|
+
|
|
1074
|
+
expect(result).toEqual({ status: 'failed', code: 'REVOKED', retryable: false });
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
it('never forwards DID authorization to a redirected origin', async () => {
|
|
1078
|
+
fetchMock
|
|
1079
|
+
.mockResolvedValueOnce(challengeResponse(challengeHeaders, challengeBody))
|
|
1080
|
+
.mockResolvedValueOnce(
|
|
1081
|
+
new Response(null, {
|
|
1082
|
+
status: 302,
|
|
1083
|
+
headers: { location: 'https://other.example.com/refresh/refresh-1' },
|
|
1084
|
+
})
|
|
1085
|
+
)
|
|
1086
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
1087
|
+
|
|
1088
|
+
const result = await runRefresh();
|
|
1089
|
+
|
|
1090
|
+
expect(fetchMock).toHaveBeenCalledTimes(3);
|
|
1091
|
+
|
|
1092
|
+
const [, crossOriginInit] = fetchMock.mock.calls[2] as [string, RequestInit];
|
|
1093
|
+
|
|
1094
|
+
expect(
|
|
1095
|
+
(crossOriginInit.headers as Record<string, string>).authorization
|
|
1096
|
+
).toBeUndefined();
|
|
1097
|
+
expect(result.status).toBe('updated');
|
|
1098
|
+
});
|
|
1099
|
+
|
|
1100
|
+
it('allows an authenticated redirect within the original service origin', async () => {
|
|
1101
|
+
const redirectedChallengeBody = {
|
|
1102
|
+
...challengeBody,
|
|
1103
|
+
domain: 'refresh.example.com',
|
|
1104
|
+
};
|
|
1105
|
+
const redirectedChallengeHeaders = {
|
|
1106
|
+
'www-authenticate':
|
|
1107
|
+
'LearnCardDIDAuth challenge="srv-challenge-1", domain="refresh.example.com"',
|
|
1108
|
+
'content-type': 'application/json',
|
|
1109
|
+
};
|
|
1110
|
+
|
|
1111
|
+
fetchMock
|
|
1112
|
+
.mockResolvedValueOnce(
|
|
1113
|
+
new Response(null, {
|
|
1114
|
+
status: 302,
|
|
1115
|
+
headers: { location: 'https://refresh.example.com/refresh/redirected' },
|
|
1116
|
+
})
|
|
1117
|
+
)
|
|
1118
|
+
.mockResolvedValueOnce(
|
|
1119
|
+
challengeResponse(redirectedChallengeHeaders, redirectedChallengeBody)
|
|
1120
|
+
)
|
|
1121
|
+
.mockResolvedValueOnce(jsonResponse(updatedCredential));
|
|
1122
|
+
|
|
1123
|
+
const result = await runRefresh(currentCredential, {
|
|
1124
|
+
resolveHost: async hostname => [
|
|
1125
|
+
hostname === 'refresh.example.com' ? PUBLIC_IP : SECOND_PUBLIC_IP,
|
|
1126
|
+
],
|
|
1127
|
+
});
|
|
1128
|
+
|
|
1129
|
+
expect(learnCard.invoke.getDidAuthVp).toHaveBeenCalledWith({
|
|
1130
|
+
proofFormat: 'jwt',
|
|
1131
|
+
challenge: 'srv-challenge-1',
|
|
1132
|
+
domain: 'refresh.example.com',
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
const [retryUrl, retryInit] = fetchMock.mock.calls[2] as [string, RequestInit];
|
|
1136
|
+
|
|
1137
|
+
expect(retryUrl).toBe('https://refresh.example.com/refresh/redirected');
|
|
1138
|
+
expect((retryInit.headers as Record<string, string>).authorization).toBe(
|
|
1139
|
+
'Bearer signed-vp:srv-challenge-1:refresh.example.com'
|
|
1140
|
+
);
|
|
1141
|
+
expect(result.status).toBe('updated');
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
});
|