@axa-fr/oidc-client-service-worker 7.29.7 → 7.30.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.
Files changed (107) hide show
  1. package/README.md +64 -0
  2. package/dist/OidcServiceWorker.d.ts +2 -1
  3. package/dist/OidcServiceWorker.d.ts.map +1 -0
  4. package/dist/OidcServiceWorker.js +995 -765
  5. package/dist/OidcServiceWorker.js.map +1 -1
  6. package/dist/constants.d.ts.map +1 -0
  7. package/dist/crypto.d.ts.map +1 -0
  8. package/dist/dpop.d.ts.map +1 -0
  9. package/dist/jwt.d.ts.map +1 -0
  10. package/dist/oidcConfig.d.ts.map +1 -0
  11. package/dist/protocol.d.ts +140 -2
  12. package/dist/protocol.d.ts.map +1 -0
  13. package/dist/protocol.js +50 -2
  14. package/dist/protocol.js.map +1 -1
  15. package/dist/resource.d.ts +22 -0
  16. package/dist/resource.d.ts.map +1 -0
  17. package/dist/tokenContexts.d.ts +5 -0
  18. package/dist/tokenContexts.d.ts.map +1 -0
  19. package/dist/{src/types.d.ts → types.d.ts} +26 -1
  20. package/dist/types.d.ts.map +1 -0
  21. package/dist/utils/codeVerifier.d.ts.map +1 -0
  22. package/dist/{src/utils → utils}/domains.d.ts +2 -2
  23. package/dist/utils/domains.d.ts.map +1 -0
  24. package/dist/utils/index.d.ts.map +1 -0
  25. package/dist/utils/normalizeUrl.d.ts.map +1 -0
  26. package/dist/utils/serializeHeaders.d.ts.map +1 -0
  27. package/dist/utils/shouldBypassDestination.d.ts.map +1 -0
  28. package/dist/utils/sleep.d.ts.map +1 -0
  29. package/dist/utils/strings.d.ts.map +1 -0
  30. package/dist/{src/utils → utils}/tokens.d.ts +12 -3
  31. package/dist/utils/tokens.d.ts.map +1 -0
  32. package/dist/utils/waitForValidTokens.d.ts.map +1 -0
  33. package/dist/version.d.ts +3 -0
  34. package/dist/version.d.ts.map +1 -0
  35. package/package.json +3 -2
  36. package/src/OidcServiceWorker.ts +290 -34
  37. package/src/__tests__/jwt.spec.ts +17 -0
  38. package/src/__tests__/resource.spec.ts +98 -0
  39. package/src/__tests__/workerResource.spec.ts +919 -0
  40. package/src/jwt.ts +4 -1
  41. package/src/oidcConfig.ts +2 -1
  42. package/src/protocol.ts +16 -1
  43. package/src/resource.ts +104 -0
  44. package/src/tokenContexts.ts +39 -0
  45. package/src/types.ts +27 -1
  46. package/src/utils/domains.ts +20 -2
  47. package/src/utils/tokens.ts +155 -8
  48. package/src/version.ts +1 -1
  49. package/dist/src/OidcServiceWorker.d.ts +0 -2
  50. package/dist/src/OidcServiceWorker.d.ts.map +0 -1
  51. package/dist/src/__tests__/jwt.spec.d.ts +0 -2
  52. package/dist/src/__tests__/jwt.spec.d.ts.map +0 -1
  53. package/dist/src/__tests__/oidcConfig.spec.d.ts +0 -2
  54. package/dist/src/__tests__/oidcConfig.spec.d.ts.map +0 -1
  55. package/dist/src/__tests__/protocol.spec.d.ts +0 -2
  56. package/dist/src/__tests__/protocol.spec.d.ts.map +0 -1
  57. package/dist/src/constants.d.ts.map +0 -1
  58. package/dist/src/crypto.d.ts.map +0 -1
  59. package/dist/src/dpop.d.ts.map +0 -1
  60. package/dist/src/jwt.d.ts.map +0 -1
  61. package/dist/src/oidcConfig.d.ts.map +0 -1
  62. package/dist/src/protocol.d.ts +0 -134
  63. package/dist/src/protocol.d.ts.map +0 -1
  64. package/dist/src/types.d.ts.map +0 -1
  65. package/dist/src/utils/__tests__/codeVerifier.spec.d.ts +0 -2
  66. package/dist/src/utils/__tests__/codeVerifier.spec.d.ts.map +0 -1
  67. package/dist/src/utils/__tests__/domains.spec.d.ts +0 -2
  68. package/dist/src/utils/__tests__/domains.spec.d.ts.map +0 -1
  69. package/dist/src/utils/__tests__/normalizeUrl.spec.d.ts +0 -2
  70. package/dist/src/utils/__tests__/normalizeUrl.spec.d.ts.map +0 -1
  71. package/dist/src/utils/__tests__/serializeHeaders.spec.d.ts +0 -2
  72. package/dist/src/utils/__tests__/serializeHeaders.spec.d.ts.map +0 -1
  73. package/dist/src/utils/__tests__/shouldBypassDestination.spec.d.ts +0 -2
  74. package/dist/src/utils/__tests__/shouldBypassDestination.spec.d.ts.map +0 -1
  75. package/dist/src/utils/__tests__/strings.spec.d.ts +0 -2
  76. package/dist/src/utils/__tests__/strings.spec.d.ts.map +0 -1
  77. package/dist/src/utils/__tests__/testHelper.d.ts +0 -58
  78. package/dist/src/utils/__tests__/testHelper.d.ts.map +0 -1
  79. package/dist/src/utils/__tests__/tokens.spec.d.ts +0 -2
  80. package/dist/src/utils/__tests__/tokens.spec.d.ts.map +0 -1
  81. package/dist/src/utils/__tests__/waitForValidTokens.spec.d.ts +0 -2
  82. package/dist/src/utils/__tests__/waitForValidTokens.spec.d.ts.map +0 -1
  83. package/dist/src/utils/codeVerifier.d.ts.map +0 -1
  84. package/dist/src/utils/domains.d.ts.map +0 -1
  85. package/dist/src/utils/index.d.ts.map +0 -1
  86. package/dist/src/utils/normalizeUrl.d.ts.map +0 -1
  87. package/dist/src/utils/serializeHeaders.d.ts.map +0 -1
  88. package/dist/src/utils/shouldBypassDestination.d.ts.map +0 -1
  89. package/dist/src/utils/sleep.d.ts.map +0 -1
  90. package/dist/src/utils/strings.d.ts.map +0 -1
  91. package/dist/src/utils/tokens.d.ts.map +0 -1
  92. package/dist/src/utils/waitForValidTokens.d.ts.map +0 -1
  93. package/dist/src/version.d.ts +0 -3
  94. package/dist/src/version.d.ts.map +0 -1
  95. /package/dist/{src/constants.d.ts → constants.d.ts} +0 -0
  96. /package/dist/{src/crypto.d.ts → crypto.d.ts} +0 -0
  97. /package/dist/{src/dpop.d.ts → dpop.d.ts} +0 -0
  98. /package/dist/{src/jwt.d.ts → jwt.d.ts} +0 -0
  99. /package/dist/{src/oidcConfig.d.ts → oidcConfig.d.ts} +0 -0
  100. /package/dist/{src/utils → utils}/codeVerifier.d.ts +0 -0
  101. /package/dist/{src/utils → utils}/index.d.ts +0 -0
  102. /package/dist/{src/utils → utils}/normalizeUrl.d.ts +0 -0
  103. /package/dist/{src/utils → utils}/serializeHeaders.d.ts +0 -0
  104. /package/dist/{src/utils → utils}/shouldBypassDestination.d.ts +0 -0
  105. /package/dist/{src/utils → utils}/sleep.d.ts +0 -0
  106. /package/dist/{src/utils → utils}/strings.d.ts +0 -0
  107. /package/dist/{src/utils → utils}/waitForValidTokens.d.ts +0 -0
@@ -3,6 +3,12 @@ import { base64urlOfHashOfASCIIEncodingAsync } from './crypto';
3
3
  import { getDpopConfiguration, getDpopOnlyWhenDpopHeaderPresent } from './dpop';
4
4
  import { generateJwkAsync, generateJwtDemonstratingProofOfPossessionAsync } from './jwt';
5
5
  import { getCurrentDatabasesTokenEndpoint, shouldBypassNonOidcRequest } from './oidcConfig';
6
+ import {
7
+ getContextResource,
8
+ getResourceConfiguration,
9
+ RESOURCE_TOKEN_REQUEST_HEADER,
10
+ } from './resource';
11
+ import { getOrCreateResourceContext, getTokenContexts } from './tokenContexts';
6
12
  import { Database, MessageEventData, OidcConfig, TrustedDomains } from './types';
7
13
  import {
8
14
  checkDomain,
@@ -88,17 +94,26 @@ async function generateDpopAsync(
88
94
  const dpopConfiguration = currentDatabase.demonstratingProofOfPossessionConfiguration;
89
95
  const jwk = currentDatabase.demonstratingProofOfPossessionJwkJson;
90
96
  const method = originalRequest.method;
97
+ const resourceNonce = currentDatabase.resource
98
+ ? url === normalizeUrl(currentDatabase.oidcServerConfiguration!.tokenEndpoint)
99
+ ? currentDatabase.demonstratingProofOfPossessionNonce
100
+ : currentDatabase.demonstratingProofOfPossessionApiNonce
101
+ : undefined;
91
102
  const dpop = await generateJwtDemonstratingProofOfPossessionAsync(self)(dpopConfiguration)(
92
103
  jwk,
93
104
  method,
94
105
  url,
95
- extrasClaims,
106
+ {
107
+ ...extrasClaims,
108
+ ...(resourceNonce ? { nonce: resourceNonce } : {}),
109
+ },
96
110
  );
97
111
 
98
112
  headersExtras.dpop = dpop;
99
- if (currentDatabase.demonstratingProofOfPossessionNonce != null) {
100
- headersExtras.nonce = currentDatabase.demonstratingProofOfPossessionNonce;
101
- }
113
+ const nonceHeader =
114
+ resourceNonce ??
115
+ (!currentDatabase.resource ? currentDatabase.demonstratingProofOfPossessionNonce : undefined);
116
+ if (nonceHeader) headersExtras.nonce = nonceHeader;
102
117
  }
103
118
  return headersExtras;
104
119
  }
@@ -153,8 +168,19 @@ const handleFetch = (event: FetchEvent): void => {
153
168
  }
154
169
 
155
170
  const currentDatabaseForRequestAccessToken = currentDatabasesForRequestAccessToken?.find(
156
- c => c.configurationName.endsWith(key),
171
+ c =>
172
+ key.includes('#resource=')
173
+ ? c.configurationName === key
174
+ : !c.resource && c.configurationName.endsWith(key),
157
175
  );
176
+ if (key.includes('#resource=') && !currentDatabaseForRequestAccessToken) {
177
+ console.error(
178
+ '[OidcServiceWorker] Resource token destination or context is not permitted.',
179
+ );
180
+ return new Response('Resource token destination or context is not permitted.', {
181
+ status: 403,
182
+ });
183
+ }
158
184
 
159
185
  // Inject the access token into the request if one is available
160
186
  if (currentDatabaseForRequestAccessToken?.tokens?.access_token) {
@@ -163,6 +189,10 @@ const handleFetch = (event: FetchEvent): void => {
163
189
  if (tokenError) {
164
190
  return tokenError;
165
191
  }
192
+ const accessToken = currentDatabaseForRequestAccessToken.tokens!.access_token;
193
+ const generation = currentDatabaseForRequestAccessToken.generation;
194
+ const parentGeneration =
195
+ currentDatabaseForRequestAccessToken.parentConfiguration?.generation;
166
196
 
167
197
  // Adjust request mode for CORS if configured
168
198
  let requestMode = originalRequest.mode;
@@ -192,9 +222,7 @@ const handleFetch = (event: FetchEvent): void => {
192
222
  ) {
193
223
  // DPoP mode
194
224
  const claimsExtras = {
195
- ath: await base64urlOfHashOfASCIIEncodingAsync(
196
- currentDatabaseForRequestAccessToken.tokens.access_token,
197
- ),
225
+ ath: await base64urlOfHashOfASCIIEncodingAsync(accessToken),
198
226
  };
199
227
  const dpopHeaders = await generateDpopAsync(
200
228
  originalRequest,
@@ -204,13 +232,13 @@ const handleFetch = (event: FetchEvent): void => {
204
232
  );
205
233
  headers = {
206
234
  ...dpopHeaders,
207
- authorization: `DPoP ${currentDatabaseForRequestAccessToken.tokens.access_token}`,
235
+ authorization: `DPoP ${accessToken}`,
208
236
  };
209
237
  } else {
210
238
  // Bearer mode
211
239
  headers = {
212
240
  ...serializeHeaders(originalRequest.headers),
213
- authorization: `${authenticationMode} ${currentDatabaseForRequestAccessToken.tokens.access_token}`,
241
+ authorization: `${authenticationMode} ${accessToken}`,
214
242
  };
215
243
  }
216
244
  }
@@ -228,7 +256,31 @@ const handleFetch = (event: FetchEvent): void => {
228
256
  }
229
257
 
230
258
  const newRequest = new Request(originalRequest, init);
231
- return fetch(newRequest);
259
+ if (
260
+ currentDatabaseForRequestAccessToken.resource &&
261
+ (currentDatabaseForRequestAccessToken.generation !== generation ||
262
+ currentDatabaseForRequestAccessToken.parentConfiguration?.generation !==
263
+ parentGeneration ||
264
+ currentDatabaseForRequestAccessToken.tokens?.access_token !== accessToken)
265
+ ) {
266
+ console.error('[OidcServiceWorker] The resource token changed before dispatch.');
267
+ return new Response('The resource token changed before dispatch.', { status: 403 });
268
+ }
269
+ const response = await fetch(
270
+ newRequest,
271
+ currentDatabaseForRequestAccessToken.resource ? { redirect: 'error' } : undefined,
272
+ );
273
+ if (
274
+ currentDatabaseForRequestAccessToken.resource &&
275
+ currentDatabaseForRequestAccessToken.generation === generation &&
276
+ currentDatabaseForRequestAccessToken.parentConfiguration?.generation ===
277
+ parentGeneration &&
278
+ response.headers.has('DPoP-Nonce')
279
+ ) {
280
+ currentDatabaseForRequestAccessToken.demonstratingProofOfPossessionApiNonce =
281
+ response.headers.get('DPoP-Nonce');
282
+ }
283
+ return response;
232
284
  }
233
285
 
234
286
  // Pass through non-POST requests without modification
@@ -264,29 +316,112 @@ const handleFetch = (event: FetchEvent): void => {
264
316
  currentDb.tokens.access_token,
265
317
  ),
266
318
  };
267
- headers = await generateDpopAsync(
268
- originalRequest,
269
- currentDb,
270
- url,
271
- claimsExtras,
272
- );
273
-
274
319
  const keyRefreshToken = encodeURIComponent(
275
320
  `${TOKEN.REFRESH_TOKEN}_${currentDb.configurationName}`,
276
321
  );
277
- if (actualBody.includes(keyRefreshToken)) {
322
+ if (
323
+ [
324
+ new URLSearchParams(actualBody).get('refresh_token'),
325
+ new URLSearchParams(actualBody).get('token'),
326
+ ].includes(decodeURIComponent(keyRefreshToken))
327
+ ) {
328
+ const form = new URLSearchParams(actualBody);
329
+ const selected = form.getAll('resource');
330
+ const resourceHeader = originalRequest.headers.get(
331
+ RESOURCE_TOKEN_REQUEST_HEADER,
332
+ );
333
+ const isRevocation =
334
+ currentDb.oidcServerConfiguration?.revocationEndpoint &&
335
+ url ===
336
+ normalizeUrl(currentDb.oidcServerConfiguration.revocationEndpoint) &&
337
+ form.has('token');
338
+ if (isRevocation) {
339
+ currentDatabase = currentDb;
340
+ } else if (resourceHeader !== null) {
341
+ const resource = decodeURIComponent(resourceHeader);
342
+ if (selected.length !== 1 || selected[0] !== resource)
343
+ throw new Error('The resource routing hint must match the request.');
344
+ if (!currentDb.oidcConfiguration)
345
+ throw new Error('The resource grant configuration is unavailable.');
346
+ const resourceConfiguration = getResourceConfiguration(
347
+ currentDb.oidcConfiguration.resources,
348
+ resource,
349
+ );
350
+ const grantedScope =
351
+ currentDb.tokens.scope ??
352
+ currentDb.authorizationScope ??
353
+ currentDb.oidcConfiguration.scope;
354
+ const requestedScopes = [
355
+ form.get('scope'),
356
+ resourceConfiguration.scope,
357
+ ].filter((scope): scope is string => typeof scope === 'string');
358
+ if (
359
+ requestedScopes.some(
360
+ scope =>
361
+ !scope
362
+ .split(/\s+/)
363
+ .filter(Boolean)
364
+ .every(value => grantedScope?.split(/\s+/).includes(value)),
365
+ )
366
+ ) {
367
+ throw new Error('The resource scope is outside this refresh grant.');
368
+ }
369
+ if (
370
+ currentDb.resource !== undefined
371
+ ? currentDb.resource !== resource
372
+ : !(
373
+ typeof currentDb.oidcConfiguration.resource === 'string'
374
+ ? [currentDb.oidcConfiguration.resource]
375
+ : (currentDb.oidcConfiguration.resource ?? [])
376
+ ).includes(resource)
377
+ )
378
+ throw new Error('The resource is outside this refresh grant.');
379
+ currentDatabase = currentDb.resource
380
+ ? currentDb
381
+ : getOrCreateResourceContext(currentDb, resource);
382
+ currentDatabase.refreshGrant = currentDb;
383
+ currentDatabase.requestedScope = form.get('scope') ?? grantedScope;
384
+ currentDatabase.nonce = currentDb.nonce;
385
+ currentDatabase.demonstratingProofOfPossessionJwkJson =
386
+ currentDb.demonstratingProofOfPossessionJwkJson;
387
+ currentDatabase.demonstratingProofOfPossessionConfiguration =
388
+ currentDb.demonstratingProofOfPossessionConfiguration;
389
+ currentDatabase.demonstratingProofOfPossessionNonce =
390
+ currentDb.demonstratingProofOfPossessionNonce;
391
+ } else if (currentDb.resource) {
392
+ throw new Error(
393
+ 'A resource refresh must select exactly its own resource.',
394
+ );
395
+ } else {
396
+ currentDatabase = currentDb;
397
+ currentDatabase.requestedScope =
398
+ form.get('scope') ?? currentDb.tokens.scope;
399
+ }
400
+ headers = await generateDpopAsync(
401
+ originalRequest,
402
+ currentDatabase,
403
+ url,
404
+ claimsExtras,
405
+ );
278
406
  newBody = newBody.replace(
279
407
  keyRefreshToken,
280
408
  encodeURIComponent(currentDb.tokens.refresh_token as string),
281
409
  );
282
- currentDatabase = currentDb;
283
410
  break;
284
411
  }
285
-
286
412
  const keyAccessToken = encodeURIComponent(
287
413
  `${TOKEN.ACCESS_TOKEN}_${currentDb.configurationName}`,
288
414
  );
289
- if (actualBody.includes(keyAccessToken)) {
415
+ if (
416
+ new URLSearchParams(actualBody).get('token') ===
417
+ decodeURIComponent(keyAccessToken)
418
+ ) {
419
+ headers = await generateDpopAsync(
420
+ originalRequest,
421
+ currentDb,
422
+ url,
423
+ claimsExtras,
424
+ );
290
425
  newBody = newBody.replace(
291
426
  keyAccessToken,
292
427
  encodeURIComponent(currentDb.tokens.access_token),
@@ -296,6 +431,25 @@ const handleFetch = (event: FetchEvent): void => {
296
431
  }
297
432
  }
298
433
  }
434
+ if (!currentDatabase)
435
+ throw new Error(
436
+ 'The token placeholder does not identify an available grant.',
437
+ );
438
+ delete headers[RESOURCE_TOKEN_REQUEST_HEADER];
439
+ const generation = currentDatabase.generation;
440
+ const grant = currentDatabase.refreshGrant ?? currentDatabase;
441
+ const grantGeneration = grant.generation;
442
+ if (
443
+ currentDatabase.resource &&
444
+ url !==
445
+ normalizeUrl(currentDatabase.oidcServerConfiguration!.tokenEndpoint) &&
446
+ url !==
447
+ normalizeUrl(currentDatabase.oidcServerConfiguration!.revocationEndpoint)
448
+ ) {
449
+ throw new Error(
450
+ 'Resource tokens may only be sent to their exact token endpoint.',
451
+ );
452
+ }
299
453
 
300
454
  const fetchPromise = fetch(originalRequest, {
301
455
  body: newBody,
@@ -324,7 +478,13 @@ const handleFetch = (event: FetchEvent): void => {
324
478
 
325
479
  // Hide real token values in the response
326
480
  const hidden = await fetchPromise.then(
327
- hideTokens(currentDatabase as OidcConfig),
481
+ hideTokens(
482
+ currentDatabase,
483
+ () =>
484
+ currentDatabase!.generation === generation &&
485
+ grant.generation === grantGeneration,
486
+ () => grant.generation === grantGeneration,
487
+ ),
328
488
  );
329
489
  resolve(hidden);
330
490
  return;
@@ -341,7 +501,34 @@ const handleFetch = (event: FetchEvent): void => {
341
501
  ) {
342
502
  throw new Error('No configuration name found in code_verifier');
343
503
  }
344
- currentDatabase = database[currentLoginCallbackConfigurationName];
504
+ currentDatabase =
505
+ getTokenContexts(database).find(
506
+ context =>
507
+ context.configurationName === currentLoginCallbackConfigurationName,
508
+ ) ?? null;
509
+ if (!currentDatabase)
510
+ throw new Error(
511
+ 'No available authorization transaction matches the code verifier.',
512
+ );
513
+ if (currentDatabase.resource) {
514
+ const selected = new URLSearchParams(actualBody).getAll('resource');
515
+ if (selected.length !== 1 || selected[0] !== currentDatabase.resource)
516
+ throw new Error('Authorization resource mismatch.');
517
+ if (
518
+ url !== normalizeUrl(currentDatabase.oidcServerConfiguration!.tokenEndpoint)
519
+ )
520
+ throw new Error('Invalid resource token endpoint.');
521
+ currentDatabase.refreshGrant = currentDatabase;
522
+ }
523
+ currentDatabase.requestedScope =
524
+ currentDatabase.authorizationScope ??
525
+ (currentDatabase.resource
526
+ ? getResourceConfiguration(
527
+ currentDatabase.oidcConfiguration?.resources,
528
+ currentDatabase.resource,
529
+ ).scope
530
+ : currentDatabase.oidcConfiguration?.scope);
531
+ const generation = currentDatabase.generation;
345
532
  let newBody = actualBody;
346
533
  const codeVerifier = currentDatabase.codeVerifier;
347
534
  if (codeVerifier != null) {
@@ -364,7 +551,12 @@ const handleFetch = (event: FetchEvent): void => {
364
551
  credentials: clonedRequest.credentials,
365
552
  integrity: clonedRequest.integrity,
366
553
  });
367
- const hidden = await hideTokens(currentDatabase)(resp);
554
+ const hidden = await hideTokens(
555
+ currentDatabase,
556
+ () => currentDatabase!.generation === generation,
557
+ undefined,
558
+ true,
559
+ )(resp);
368
560
  resolve(hidden);
369
561
  return;
370
562
  }
@@ -389,7 +581,7 @@ const handleFetch = (event: FetchEvent): void => {
389
581
  .catch(reject);
390
582
  });
391
583
 
392
- return responsePromise;
584
+ return await responsePromise;
393
585
  }
394
586
 
395
587
  // Default: pass through the request unchanged
@@ -403,7 +595,7 @@ const handleFetch = (event: FetchEvent): void => {
403
595
  );
404
596
  };
405
597
 
406
- const handleMessage = async (event: ExtendableMessageEvent) => {
598
+ const handleMessageAsync = async (event: ExtendableMessageEvent): Promise<void> => {
407
599
  const port = event.ports[0];
408
600
  const data = event.data as MessageEventData;
409
601
 
@@ -417,6 +609,7 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
417
609
  }
418
610
 
419
611
  const configurationName = data.configurationName.split('#')[0];
612
+ const resource = getContextResource(data.configurationName);
420
613
 
421
614
  if (trustedDomains == null) {
422
615
  trustedDomains = {};
@@ -468,9 +661,23 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
468
661
  trustedDomains[configurationName] = [];
469
662
  }
470
663
  }
664
+ if (resource !== undefined) {
665
+ try {
666
+ currentDatabase = getOrCreateResourceContext(currentDatabase, resource);
667
+ } catch (error) {
668
+ console.error('[OidcServiceWorker] Invalid resource context:', error);
669
+ port.postMessage({ configurationName, error: 'The resource is not registered.' });
670
+ return;
671
+ }
672
+ }
471
673
 
472
674
  switch (data.type) {
473
675
  case 'clear':
676
+ currentDatabase.generation = (currentDatabase.generation ?? 0) + 1;
677
+ for (const entry of Object.values(currentDatabase.resourceEntries ?? {})) {
678
+ entry.generation = (entry.generation ?? 0) + 1;
679
+ }
680
+ currentDatabase.resourceEntries = undefined;
474
681
  currentDatabase.tokens = null;
475
682
  currentDatabase.state = null;
476
683
  currentDatabase.codeVerifier = null;
@@ -479,6 +686,9 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
479
686
  currentDatabase.demonstratingProofOfPossessionJwkJson = null;
480
687
  currentDatabase.demonstratingProofOfPossessionConfiguration = null;
481
688
  currentDatabase.demonstratingProofOfPossessionOnlyWhenDpopHeaderPresent = false;
689
+ currentDatabase.authorizationScope = undefined;
690
+ currentDatabase.requestedScope = undefined;
691
+ currentDatabase.demonstratingProofOfPossessionApiNonce = null;
482
692
  currentDatabase.status = data.data.status;
483
693
  port.postMessage({ configurationName });
484
694
  return;
@@ -500,10 +710,24 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
500
710
 
501
711
  currentDatabase.oidcServerConfiguration = oidcServerConfiguration;
502
712
  currentDatabase.oidcConfiguration = data.data.oidcConfiguration;
713
+ if (data.data.where === 'loginAsync')
714
+ currentDatabase.authorizationScope = currentDatabase.oidcConfiguration.scope;
715
+ if (resource !== undefined)
716
+ getResourceConfiguration(currentDatabase.oidcConfiguration.resources, resource);
503
717
 
504
718
  if (currentDatabase.demonstratingProofOfPossessionConfiguration == null) {
719
+ const parent = currentDatabase.parentConfiguration;
720
+ if (parent?.demonstratingProofOfPossessionJwkJson) {
721
+ currentDatabase.demonstratingProofOfPossessionJwkJson =
722
+ parent.demonstratingProofOfPossessionJwkJson;
723
+ currentDatabase.demonstratingProofOfPossessionConfiguration =
724
+ parent.demonstratingProofOfPossessionConfiguration;
725
+ }
505
726
  const demonstratingProofOfPossessionConfiguration = getDpopConfiguration(trustedDomain);
506
- if (demonstratingProofOfPossessionConfiguration != null) {
727
+ if (
728
+ demonstratingProofOfPossessionConfiguration != null &&
729
+ currentDatabase.demonstratingProofOfPossessionConfiguration == null
730
+ ) {
507
731
  if (currentDatabase.oidcConfiguration.demonstrating_proof_of_possession) {
508
732
  console.warn(
509
733
  'In service worker, demonstrating_proof_of_possession must be configured from trustedDomains file',
@@ -525,23 +749,32 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
525
749
  status: currentDatabase.status,
526
750
  configurationName,
527
751
  version,
752
+ resourceTokenManagement: true,
753
+ resourceContexts: Object.keys(currentDatabase.resourceEntries ?? {}),
528
754
  });
529
755
  } else {
530
- const tokens = { ...currentDatabase.tokens };
756
+ const tokens = {
757
+ ...currentDatabase.tokens,
758
+ idTokenPayload: currentDatabase.tokens.idTokenPayload
759
+ ? { ...currentDatabase.tokens.idTokenPayload }
760
+ : undefined,
761
+ };
531
762
  if (currentDatabase.hideAccessToken) {
532
- tokens.access_token = `${TOKEN.ACCESS_TOKEN}_${configurationName}#tabId=${tabId}`;
763
+ tokens.access_token = `${TOKEN.ACCESS_TOKEN}_${currentDatabase.configurationName}`;
533
764
  }
534
765
  if (tokens.refresh_token) {
535
- tokens.refresh_token = `${TOKEN.REFRESH_TOKEN}_${configurationName}#tabId=${tabId}`;
766
+ tokens.refresh_token = `${TOKEN.REFRESH_TOKEN}_${(currentDatabase.refreshGrant ?? currentDatabase).configurationName}`;
536
767
  }
537
768
  if (tokens?.idTokenPayload?.nonce && currentDatabase.nonce != null) {
538
- tokens.idTokenPayload.nonce = `${TOKEN.NONCE_TOKEN}_${configurationName}#tabId=${tabId}`;
769
+ tokens.idTokenPayload.nonce = `${TOKEN.NONCE_TOKEN}_${currentDatabase.configurationName}`;
539
770
  }
540
771
  port.postMessage({
541
772
  tokens,
542
773
  status: currentDatabase.status,
543
774
  configurationName,
544
775
  version,
776
+ resourceTokenManagement: true,
777
+ resourceContexts: Object.keys(currentDatabase.resourceEntries ?? {}),
545
778
  });
546
779
  }
547
780
  return;
@@ -585,7 +818,7 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
585
818
  case 'getCodeVerifier': {
586
819
  const codeVerifier =
587
820
  currentDatabase.codeVerifier != null
588
- ? `${TOKEN.CODE_VERIFIER}_${configurationName}#tabId=${tabId}`
821
+ ? `${TOKEN.CODE_VERIFIER}_${currentDatabase.configurationName}`
589
822
  : null;
590
823
  port.postMessage({
591
824
  configurationName,
@@ -609,6 +842,17 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
609
842
  case 'setNonce': {
610
843
  const nonce = data.data.nonce;
611
844
  if (nonce) {
845
+ if (
846
+ !currentDatabase.resource &&
847
+ currentDatabase.nonce &&
848
+ nonce.nonce !== currentDatabase.nonce.nonce
849
+ ) {
850
+ currentDatabase.generation = (currentDatabase.generation ?? 0) + 1;
851
+ for (const entry of Object.values(currentDatabase.resourceEntries ?? {})) {
852
+ entry.generation = (entry.generation ?? 0) + 1;
853
+ }
854
+ currentDatabase.resourceEntries = undefined;
855
+ }
612
856
  currentDatabase.nonce = nonce;
613
857
  }
614
858
  port.postMessage({ configurationName });
@@ -616,7 +860,7 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
616
860
  }
617
861
 
618
862
  case 'getNonce': {
619
- const keyNonce = `${TOKEN.NONCE_TOKEN}_${configurationName}#tabId=${tabId}`;
863
+ const keyNonce = `${TOKEN.NONCE_TOKEN}_${currentDatabase.configurationName}`;
620
864
  const nonce = currentDatabase.nonce ? keyNonce : null;
621
865
  port.postMessage({ configurationName, nonce });
622
866
  return;
@@ -627,6 +871,18 @@ const handleMessage = async (event: ExtendableMessageEvent) => {
627
871
  }
628
872
  };
629
873
 
874
+ const handleMessage = async (event: ExtendableMessageEvent): Promise<void> => {
875
+ try {
876
+ await handleMessageAsync(event);
877
+ } catch (error) {
878
+ console.error('[OidcServiceWorker] Message failed:', error);
879
+ event.ports?.[0]?.postMessage({
880
+ configurationName: event.data?.configurationName,
881
+ error: error instanceof Error ? error.message : 'The worker could not process this message.',
882
+ });
883
+ }
884
+ };
885
+
630
886
  // Event listeners
631
887
  _self.addEventListener('fetch', handleFetch);
632
888
  _self.addEventListener('message', handleMessage);
@@ -12,6 +12,23 @@ const decodePayload = (jwt: string) => {
12
12
  };
13
13
 
14
14
  describe('generateJwtDemonstratingProofOfPossessionAsync', () => {
15
+ it.each([
16
+ ['https://api.example.com/resource?limit=2#section', 'https://api.example.com/resource'],
17
+ ['/resource?limit=2#section', 'https://api.example.com/resource'],
18
+ ])('excludes query and fragment from the worker DPoP target %s', async (url, expected) => {
19
+ const environment = {
20
+ crypto: globalThis.crypto,
21
+ location: { href: 'https://api.example.com/' },
22
+ };
23
+ const jwk = await generateJwkAsync(environment)(
24
+ defaultDemonstratingProofOfPossessionConfiguration.generateKeyAlgorithm,
25
+ );
26
+ const jwt = await generateJwtDemonstratingProofOfPossessionAsync(environment)(
27
+ defaultDemonstratingProofOfPossessionConfiguration,
28
+ )(jwk, 'GET', url);
29
+ expect(decodePayload(jwt).htu).toBe(expected);
30
+ });
31
+
15
32
  it('uses a cryptographically secure UUID for the DPoP jti in the service worker', async () => {
16
33
  const uuid = '123e4567-e89b-42d3-a456-426614174000';
17
34
  const randomUUID = vi.fn(() => uuid);
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ getContextResource,
5
+ getResourceConfiguration,
6
+ getResourceContextName,
7
+ isResourceOriginManaged,
8
+ isResourceUrlAllowed,
9
+ validateResourceIdentifier,
10
+ } from '../resource';
11
+
12
+ describe('RFC 8707 resource configuration', () => {
13
+ it('recognizes managed origins outside mapped paths without permitting those paths', () => {
14
+ const configuration = {
15
+ resource: 'urn:orders',
16
+ allowedUrlPrefixes: ['https://api.example.com/orders'],
17
+ };
18
+ expect(isResourceOriginManaged([configuration], 'https://api.example.com/v2/items')).toBe(true);
19
+ expect(isResourceUrlAllowed(configuration, 'https://api.example.com/v2/items')).toBe(false);
20
+ expect(
21
+ isResourceOriginManaged([configuration], 'https://api.example.com.evil.test/orders'),
22
+ ).toBe(false);
23
+ expect(() =>
24
+ isResourceOriginManaged(
25
+ [{ ...configuration, allowedUrlPrefixes: ['https://api.example.com/orders?query'] }],
26
+ 'https://api.example.com/orders',
27
+ ),
28
+ ).toThrow('query');
29
+ });
30
+ const configuration = {
31
+ resource: 'urn:example:api',
32
+ allowedUrlPrefixes: ['https://api.example.com/orders'],
33
+ };
34
+
35
+ it.each(['urn:example:api', 'https://api.example.com/?tenant=one'])(
36
+ 'accepts the absolute identifier %s',
37
+ resource => {
38
+ expect(() => validateResourceIdentifier(resource)).not.toThrow();
39
+ },
40
+ );
41
+
42
+ it.each([
43
+ '',
44
+ '/api',
45
+ 'https://api.example.com/#fragment',
46
+ 'urn:example:api#',
47
+ ' https://api.example.com',
48
+ ])('rejects the invalid identifier %s', resource => {
49
+ expect(() => validateResourceIdentifier(resource)).toThrow();
50
+ });
51
+
52
+ it('requires an explicit, unique registration and API mappings', () => {
53
+ expect(getResourceConfiguration([configuration], configuration.resource)).toBe(configuration);
54
+ expect(() => getResourceConfiguration([], configuration.resource)).toThrow();
55
+ expect(() =>
56
+ getResourceConfiguration([configuration, configuration], configuration.resource),
57
+ ).toThrow();
58
+ expect(() =>
59
+ getResourceConfiguration(
60
+ [{ ...configuration, allowedUrlPrefixes: [] }],
61
+ configuration.resource,
62
+ ),
63
+ ).toThrow();
64
+ });
65
+
66
+ it.each(['https://api.example.com/orders', 'https://api.example.com/orders/42?view=full'])(
67
+ 'permits the mapped destination %s',
68
+ url => {
69
+ expect(isResourceUrlAllowed(configuration, url)).toBe(true);
70
+ },
71
+ );
72
+
73
+ it.each([
74
+ 'https://api.example.com.evil.test/orders',
75
+ 'https://api.example.com/orders-other',
76
+ 'https://other.example.com/orders',
77
+ 'https://api.example.com:444/orders',
78
+ ])('rejects the unmapped destination %s', url => {
79
+ expect(isResourceUrlAllowed(configuration, url)).toBe(false);
80
+ });
81
+
82
+ it.each([
83
+ 'https://user:password@api.example.com/orders',
84
+ 'https://api.example.com/orders%2fother',
85
+ 'file:///orders',
86
+ 'https://api.example.com/orders#fragment',
87
+ ])('rejects the unsafe destination %s', url => {
88
+ expect(() => isResourceUrlAllowed(configuration, url)).toThrow();
89
+ });
90
+
91
+ it('round-trips resource context markers without collisions', () => {
92
+ const resource = 'urn:example:a&b=%25';
93
+ const name = getResourceContextName('default#tabId=one', resource);
94
+ expect(getContextResource(name)).toBe(resource);
95
+ expect(getContextResource('default')).toBeUndefined();
96
+ expect(name).not.toBe(getResourceContextName('default#tabId=one', 'urn:example:a'));
97
+ });
98
+ });