@axa-fr/oidc-client-service-worker 7.29.8 → 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.
- package/README.md +64 -0
- package/dist/OidcServiceWorker.d.ts +2 -1
- package/dist/OidcServiceWorker.d.ts.map +1 -0
- package/dist/OidcServiceWorker.js +995 -765
- package/dist/OidcServiceWorker.js.map +1 -1
- package/dist/constants.d.ts.map +1 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/dpop.d.ts.map +1 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/oidcConfig.d.ts.map +1 -0
- package/dist/protocol.d.ts +140 -2
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +50 -2
- package/dist/protocol.js.map +1 -1
- package/dist/resource.d.ts +22 -0
- package/dist/resource.d.ts.map +1 -0
- package/dist/tokenContexts.d.ts +5 -0
- package/dist/tokenContexts.d.ts.map +1 -0
- package/dist/{src/types.d.ts → types.d.ts} +26 -1
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/codeVerifier.d.ts.map +1 -0
- package/dist/{src/utils → utils}/domains.d.ts +2 -2
- package/dist/utils/domains.d.ts.map +1 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/normalizeUrl.d.ts.map +1 -0
- package/dist/utils/serializeHeaders.d.ts.map +1 -0
- package/dist/utils/shouldBypassDestination.d.ts.map +1 -0
- package/dist/utils/sleep.d.ts.map +1 -0
- package/dist/utils/strings.d.ts.map +1 -0
- package/dist/{src/utils → utils}/tokens.d.ts +12 -3
- package/dist/utils/tokens.d.ts.map +1 -0
- package/dist/utils/waitForValidTokens.d.ts.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/package.json +3 -2
- package/src/OidcServiceWorker.ts +290 -34
- package/src/__tests__/jwt.spec.ts +17 -0
- package/src/__tests__/resource.spec.ts +98 -0
- package/src/__tests__/workerResource.spec.ts +919 -0
- package/src/jwt.ts +4 -1
- package/src/oidcConfig.ts +2 -1
- package/src/protocol.ts +16 -1
- package/src/resource.ts +104 -0
- package/src/tokenContexts.ts +39 -0
- package/src/types.ts +27 -1
- package/src/utils/domains.ts +20 -2
- package/src/utils/tokens.ts +155 -8
- package/src/version.ts +1 -1
- package/dist/src/OidcServiceWorker.d.ts +0 -2
- package/dist/src/OidcServiceWorker.d.ts.map +0 -1
- package/dist/src/__tests__/jwt.spec.d.ts +0 -2
- package/dist/src/__tests__/jwt.spec.d.ts.map +0 -1
- package/dist/src/__tests__/oidcConfig.spec.d.ts +0 -2
- package/dist/src/__tests__/oidcConfig.spec.d.ts.map +0 -1
- package/dist/src/__tests__/protocol.spec.d.ts +0 -2
- package/dist/src/__tests__/protocol.spec.d.ts.map +0 -1
- package/dist/src/constants.d.ts.map +0 -1
- package/dist/src/crypto.d.ts.map +0 -1
- package/dist/src/dpop.d.ts.map +0 -1
- package/dist/src/jwt.d.ts.map +0 -1
- package/dist/src/oidcConfig.d.ts.map +0 -1
- package/dist/src/protocol.d.ts +0 -134
- package/dist/src/protocol.d.ts.map +0 -1
- package/dist/src/types.d.ts.map +0 -1
- package/dist/src/utils/__tests__/codeVerifier.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/codeVerifier.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/domains.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/domains.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/normalizeUrl.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/normalizeUrl.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/serializeHeaders.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/serializeHeaders.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/shouldBypassDestination.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/shouldBypassDestination.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/strings.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/strings.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/testHelper.d.ts +0 -58
- package/dist/src/utils/__tests__/testHelper.d.ts.map +0 -1
- package/dist/src/utils/__tests__/tokens.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/tokens.spec.d.ts.map +0 -1
- package/dist/src/utils/__tests__/waitForValidTokens.spec.d.ts +0 -2
- package/dist/src/utils/__tests__/waitForValidTokens.spec.d.ts.map +0 -1
- package/dist/src/utils/codeVerifier.d.ts.map +0 -1
- package/dist/src/utils/domains.d.ts.map +0 -1
- package/dist/src/utils/index.d.ts.map +0 -1
- package/dist/src/utils/normalizeUrl.d.ts.map +0 -1
- package/dist/src/utils/serializeHeaders.d.ts.map +0 -1
- package/dist/src/utils/shouldBypassDestination.d.ts.map +0 -1
- package/dist/src/utils/sleep.d.ts.map +0 -1
- package/dist/src/utils/strings.d.ts.map +0 -1
- package/dist/src/utils/tokens.d.ts.map +0 -1
- package/dist/src/utils/waitForValidTokens.d.ts.map +0 -1
- package/dist/src/version.d.ts +0 -3
- package/dist/src/version.d.ts.map +0 -1
- /package/dist/{src/constants.d.ts → constants.d.ts} +0 -0
- /package/dist/{src/crypto.d.ts → crypto.d.ts} +0 -0
- /package/dist/{src/dpop.d.ts → dpop.d.ts} +0 -0
- /package/dist/{src/jwt.d.ts → jwt.d.ts} +0 -0
- /package/dist/{src/oidcConfig.d.ts → oidcConfig.d.ts} +0 -0
- /package/dist/{src/utils → utils}/codeVerifier.d.ts +0 -0
- /package/dist/{src/utils → utils}/index.d.ts +0 -0
- /package/dist/{src/utils → utils}/normalizeUrl.d.ts +0 -0
- /package/dist/{src/utils → utils}/serializeHeaders.d.ts +0 -0
- /package/dist/{src/utils → utils}/shouldBypassDestination.d.ts +0 -0
- /package/dist/{src/utils → utils}/sleep.d.ts +0 -0
- /package/dist/{src/utils → utils}/strings.d.ts +0 -0
- /package/dist/{src/utils → utils}/waitForValidTokens.d.ts +0 -0
|
@@ -0,0 +1,919 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { runInNewContext } from 'node:vm';
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
|
|
8
|
+
import { getResourceContextName, RESOURCE_TOKEN_REQUEST_HEADER } from '../resource';
|
|
9
|
+
import type { DomainDetails } from '../types';
|
|
10
|
+
|
|
11
|
+
interface WorkerEvent {
|
|
12
|
+
request?: Request;
|
|
13
|
+
respondWith?: (response: Promise<Response>) => void;
|
|
14
|
+
data?: { type: string; configurationName: string; tabId: string; data: unknown };
|
|
15
|
+
ports?: Array<{ postMessage: (response: Record<string, unknown>) => void }>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const resourceA = 'urn:example:api:a';
|
|
19
|
+
const resourceB = 'urn:example:api:b';
|
|
20
|
+
const issuer = 'https://issuer.example.com';
|
|
21
|
+
const jwt = (payload: Record<string, unknown>): string =>
|
|
22
|
+
`e30.${btoa(JSON.stringify(payload))}.signature`;
|
|
23
|
+
const source = readFileSync(new URL('../../dist/OidcServiceWorker.js', import.meta.url), 'utf8');
|
|
24
|
+
let handlers: Map<string, (event: WorkerEvent) => void | Promise<void>>;
|
|
25
|
+
let network: ReturnType<typeof vi.fn<typeof fetch>>;
|
|
26
|
+
let trustedDomain: DomainDetails;
|
|
27
|
+
let configuration: {
|
|
28
|
+
token_renew_mode: string;
|
|
29
|
+
demonstrating_proof_of_possession: boolean;
|
|
30
|
+
client_id: string;
|
|
31
|
+
scope?: string;
|
|
32
|
+
resource: string[];
|
|
33
|
+
resources: Array<{
|
|
34
|
+
resource: string;
|
|
35
|
+
allowedUrlPrefixes: string[];
|
|
36
|
+
audiences?: string[];
|
|
37
|
+
scope?: string;
|
|
38
|
+
}>;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const message = async (
|
|
42
|
+
type: string,
|
|
43
|
+
context = 'default',
|
|
44
|
+
data: unknown = null,
|
|
45
|
+
tabId = 'tab-one',
|
|
46
|
+
): Promise<Record<string, unknown>> => {
|
|
47
|
+
let response: Record<string, unknown> | undefined;
|
|
48
|
+
const handler = handlers.get('message');
|
|
49
|
+
if (!handler) throw new Error('The worker message handler was not registered.');
|
|
50
|
+
await handler({
|
|
51
|
+
data: { type, configurationName: context, data, tabId },
|
|
52
|
+
ports: [
|
|
53
|
+
{
|
|
54
|
+
postMessage: value => {
|
|
55
|
+
response = value;
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
});
|
|
60
|
+
if (!response) throw new Error('The worker did not answer the message.');
|
|
61
|
+
return response;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const dispatch = async (request: Request): Promise<Response> => {
|
|
65
|
+
let response: Promise<Response> | undefined;
|
|
66
|
+
const handler = handlers.get('fetch');
|
|
67
|
+
if (!handler) throw new Error('The worker fetch handler was not registered.');
|
|
68
|
+
await handler({
|
|
69
|
+
request,
|
|
70
|
+
respondWith: value => {
|
|
71
|
+
response = value;
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
return response ?? network(request);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const tokenRequest = (
|
|
78
|
+
parameters: Record<string, string>,
|
|
79
|
+
headers: Record<string, string> = {},
|
|
80
|
+
isResourceRequest = true,
|
|
81
|
+
): Promise<Response> =>
|
|
82
|
+
dispatch(
|
|
83
|
+
new Request(`${issuer}/token`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
body: new URLSearchParams(parameters),
|
|
86
|
+
headers: {
|
|
87
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
88
|
+
...(isResourceRequest && parameters.grant_type === 'refresh_token' && parameters.resource
|
|
89
|
+
? { [RESOURCE_TOKEN_REQUEST_HEADER]: encodeURIComponent(parameters.resource) }
|
|
90
|
+
: {}),
|
|
91
|
+
...headers,
|
|
92
|
+
},
|
|
93
|
+
}),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
const lastRequestParameters = (): URLSearchParams => {
|
|
97
|
+
const call = network.mock.calls.at(-1);
|
|
98
|
+
if (!call?.[1]?.body) throw new Error('The worker did not dispatch the expected form request.');
|
|
99
|
+
return new URLSearchParams(String(call[1].body));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const init = (context = 'default', tabId = 'tab-one'): Promise<Record<string, unknown>> =>
|
|
103
|
+
message(
|
|
104
|
+
'init',
|
|
105
|
+
context,
|
|
106
|
+
{
|
|
107
|
+
oidcServerConfiguration: {
|
|
108
|
+
issuer,
|
|
109
|
+
authorizationEndpoint: `${issuer}/authorize`,
|
|
110
|
+
tokenEndpoint: `${issuer}/token`,
|
|
111
|
+
revocationEndpoint: `${issuer}/revoke`,
|
|
112
|
+
userInfoEndpoint: `${issuer}/userinfo`,
|
|
113
|
+
},
|
|
114
|
+
oidcConfiguration: configuration,
|
|
115
|
+
where: 'test',
|
|
116
|
+
},
|
|
117
|
+
tabId,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const login = async (tabId = 'tab-one'): Promise<Record<string, unknown>> => {
|
|
121
|
+
await init('default', tabId);
|
|
122
|
+
await message('setNonce', 'default', { nonce: { nonce: 'nonce' } }, tabId);
|
|
123
|
+
await message('setCodeVerifier', 'default', { codeVerifier: 'actual-verifier' }, tabId);
|
|
124
|
+
const verifier = await message('getCodeVerifier', 'default', null, tabId);
|
|
125
|
+
return (
|
|
126
|
+
await tokenRequest(
|
|
127
|
+
{
|
|
128
|
+
code: `code-${tabId}`,
|
|
129
|
+
grant_type: 'authorization_code',
|
|
130
|
+
code_verifier: String(verifier.codeVerifier),
|
|
131
|
+
client_id: 'client',
|
|
132
|
+
},
|
|
133
|
+
trustedDomain.demonstratingProofOfPossession
|
|
134
|
+
? { DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default' }
|
|
135
|
+
: {},
|
|
136
|
+
)
|
|
137
|
+
).json();
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
beforeEach(() => {
|
|
141
|
+
handlers = new Map();
|
|
142
|
+
configuration = {
|
|
143
|
+
token_renew_mode: 'access_token_invalid',
|
|
144
|
+
demonstrating_proof_of_possession: false,
|
|
145
|
+
client_id: 'client',
|
|
146
|
+
scope: 'openid read write',
|
|
147
|
+
resource: [resourceA, resourceB],
|
|
148
|
+
resources: [
|
|
149
|
+
{ resource: resourceA, allowedUrlPrefixes: ['https://api.example.com/a'] },
|
|
150
|
+
{ resource: resourceB, allowedUrlPrefixes: ['https://api.example.com/b'] },
|
|
151
|
+
],
|
|
152
|
+
};
|
|
153
|
+
let refreshNumber = 0;
|
|
154
|
+
trustedDomain = {
|
|
155
|
+
showAccessToken: false,
|
|
156
|
+
oidcDomains: [issuer],
|
|
157
|
+
accessTokenDomains: ['https://api.example.com/'],
|
|
158
|
+
};
|
|
159
|
+
network = vi.fn<typeof fetch>().mockImplementation(async (input, init) => {
|
|
160
|
+
const url = input instanceof Request ? input.url : String(input);
|
|
161
|
+
if (url !== `${issuer}/token`) return new Response('api');
|
|
162
|
+
const body = init?.body ?? (input instanceof Request ? await input.clone().text() : '');
|
|
163
|
+
const parameters = new URLSearchParams(String(body));
|
|
164
|
+
const resource = parameters.get('resource');
|
|
165
|
+
if (parameters.has('refresh_token')) {
|
|
166
|
+
refreshNumber++;
|
|
167
|
+
return new Response(
|
|
168
|
+
JSON.stringify({
|
|
169
|
+
access_token: `${resource}-secret`,
|
|
170
|
+
refresh_token: `refresh-${refreshNumber}`,
|
|
171
|
+
expires_in: 120,
|
|
172
|
+
token_type: trustedDomain.demonstratingProofOfPossession ? 'DPoP' : 'Bearer',
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
return new Response(
|
|
177
|
+
JSON.stringify({
|
|
178
|
+
access_token: 'default-secret',
|
|
179
|
+
refresh_token:
|
|
180
|
+
parameters.get('code') === 'code-tab-two' ? 'second-tab-refresh' : 'refresh-0',
|
|
181
|
+
expires_in: 120,
|
|
182
|
+
token_type: trustedDomain.demonstratingProofOfPossession ? 'DPoP' : 'Bearer',
|
|
183
|
+
id_token: jwt({
|
|
184
|
+
iss: issuer,
|
|
185
|
+
sub: 'user-one',
|
|
186
|
+
nonce: 'nonce',
|
|
187
|
+
iat: Date.now() / 1000,
|
|
188
|
+
exp: Date.now() / 1000 + 3600,
|
|
189
|
+
}),
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
192
|
+
});
|
|
193
|
+
const self = {
|
|
194
|
+
importScripts: vi.fn(),
|
|
195
|
+
crypto: globalThis.crypto,
|
|
196
|
+
addEventListener: (
|
|
197
|
+
type: string,
|
|
198
|
+
handler: (event: WorkerEvent) => void | Promise<void>,
|
|
199
|
+
): void => {
|
|
200
|
+
handlers.set(type, handler);
|
|
201
|
+
},
|
|
202
|
+
clients: { claim: vi.fn().mockResolvedValue(undefined) },
|
|
203
|
+
skipWaiting: vi.fn().mockResolvedValue(undefined),
|
|
204
|
+
};
|
|
205
|
+
runInNewContext(source, {
|
|
206
|
+
self,
|
|
207
|
+
fetch: network,
|
|
208
|
+
Headers,
|
|
209
|
+
Request,
|
|
210
|
+
Response,
|
|
211
|
+
URL,
|
|
212
|
+
URLSearchParams,
|
|
213
|
+
Date,
|
|
214
|
+
crypto: globalThis.crypto,
|
|
215
|
+
TextEncoder,
|
|
216
|
+
Uint8Array,
|
|
217
|
+
atob,
|
|
218
|
+
btoa,
|
|
219
|
+
setTimeout,
|
|
220
|
+
clearTimeout,
|
|
221
|
+
console: { log: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
222
|
+
trustedDomains: { default: trustedDomain },
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
afterEach(() => vi.restoreAllMocks());
|
|
227
|
+
|
|
228
|
+
describe('standalone Service Worker resource integration', () => {
|
|
229
|
+
it('preserves legacy init messages, default renewal, API injection, revocation and clearing', async () => {
|
|
230
|
+
configuration = {
|
|
231
|
+
token_renew_mode: 'access_token_invalid',
|
|
232
|
+
demonstrating_proof_of_possession: false,
|
|
233
|
+
} as typeof configuration;
|
|
234
|
+
const original = await login();
|
|
235
|
+
const renewed = await (
|
|
236
|
+
await tokenRequest(
|
|
237
|
+
{
|
|
238
|
+
grant_type: 'refresh_token',
|
|
239
|
+
refresh_token: String(original.refresh_token),
|
|
240
|
+
},
|
|
241
|
+
{},
|
|
242
|
+
false,
|
|
243
|
+
)
|
|
244
|
+
).json();
|
|
245
|
+
expect(renewed.access_token).toBe(original.access_token);
|
|
246
|
+
expect(renewed.refresh_token).toBe(original.refresh_token);
|
|
247
|
+
expect(lastRequestParameters().get('refresh_token')).toBe('refresh-0');
|
|
248
|
+
expect(lastRequestParameters().has('resource')).toBe(false);
|
|
249
|
+
expect((await init()).resourceContexts).toEqual([]);
|
|
250
|
+
|
|
251
|
+
await dispatch(
|
|
252
|
+
new Request('https://api.example.com/legacy', {
|
|
253
|
+
headers: { Authorization: `Bearer ${renewed.access_token}` },
|
|
254
|
+
}),
|
|
255
|
+
);
|
|
256
|
+
const apiRequest = network.mock.calls.at(-1)?.[0];
|
|
257
|
+
expect(apiRequest instanceof Request && apiRequest.headers.get('Authorization')).toBe(
|
|
258
|
+
'Bearer null-secret',
|
|
259
|
+
);
|
|
260
|
+
|
|
261
|
+
await dispatch(
|
|
262
|
+
new Request(`${issuer}/revoke`, {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
body: new URLSearchParams({
|
|
265
|
+
token: String(renewed.refresh_token),
|
|
266
|
+
token_type_hint: 'refresh_token',
|
|
267
|
+
}),
|
|
268
|
+
}),
|
|
269
|
+
);
|
|
270
|
+
expect(lastRequestParameters().get('token')).toBe('refresh-1');
|
|
271
|
+
await message('clear', 'default', { status: 'LOGGED_OUT' });
|
|
272
|
+
expect((await init()).tokens).toBeNull();
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('never falls back to the default token outside mapped paths on a managed API origin', async () => {
|
|
276
|
+
await login();
|
|
277
|
+
network.mockClear();
|
|
278
|
+
await dispatch(new Request('https://api.example.com/v2/items'));
|
|
279
|
+
const request = network.mock.calls[0][0];
|
|
280
|
+
if (!(request instanceof Request))
|
|
281
|
+
throw new Error('The worker did not dispatch the anonymous request.');
|
|
282
|
+
expect(request.headers.has('Authorization')).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
it('ships one classic worker file without import statements or external chunks', () => {
|
|
285
|
+
expect(source).not.toMatch(/^\s*(?:import|export)\s/m);
|
|
286
|
+
expect(source).not.toMatch(/from\s+["']\.\//);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('negotiates resource support without changing the default token entry', async () => {
|
|
290
|
+
const tokens = await login();
|
|
291
|
+
expect(tokens.access_token).toBe(
|
|
292
|
+
'ACCESS_TOKEN_SECURED_BY_OIDC_SERVICE_WORKER_default#tabId=default',
|
|
293
|
+
);
|
|
294
|
+
expect((await init()).resourceTokenManagement).toBe(true);
|
|
295
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
296
|
+
expect((await init()).tokens).toMatchObject({ access_token: tokens.access_token });
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('isolates two resource responses, rotates their shared grant, and keeps the default access token', async () => {
|
|
300
|
+
const base = await login();
|
|
301
|
+
const a = await (
|
|
302
|
+
await tokenRequest({
|
|
303
|
+
grant_type: 'refresh_token',
|
|
304
|
+
refresh_token: String(base.refresh_token),
|
|
305
|
+
resource: resourceA,
|
|
306
|
+
})
|
|
307
|
+
).json();
|
|
308
|
+
const b = await (
|
|
309
|
+
await tokenRequest({
|
|
310
|
+
grant_type: 'refresh_token',
|
|
311
|
+
refresh_token: String(base.refresh_token),
|
|
312
|
+
resource: resourceB,
|
|
313
|
+
})
|
|
314
|
+
).json();
|
|
315
|
+
expect(JSON.stringify(a)).not.toContain(`${resourceA}-secret`);
|
|
316
|
+
expect(JSON.stringify(a)).not.toContain('refresh-1');
|
|
317
|
+
expect(a.access_token).not.toBe(b.access_token);
|
|
318
|
+
expect(a.resourceContext).toMatchObject({
|
|
319
|
+
resource: resourceA,
|
|
320
|
+
issuer,
|
|
321
|
+
clientId: 'client',
|
|
322
|
+
configurationName: 'default',
|
|
323
|
+
subject: 'user-one',
|
|
324
|
+
grantResource: null,
|
|
325
|
+
});
|
|
326
|
+
const refreshBodies = network.mock.calls
|
|
327
|
+
.slice(1)
|
|
328
|
+
.map(([, init]) => new URLSearchParams(String(init?.body)).get('refresh_token'));
|
|
329
|
+
expect(refreshBodies).toEqual(['refresh-0', 'refresh-1']);
|
|
330
|
+
for (const [, init] of network.mock.calls)
|
|
331
|
+
expect(new Headers(init?.headers).has(RESOURCE_TOKEN_REQUEST_HEADER)).toBe(false);
|
|
332
|
+
network.mockClear();
|
|
333
|
+
await dispatch(
|
|
334
|
+
new Request('https://api.example.com/a/items', {
|
|
335
|
+
headers: { Authorization: `Bearer ${a.access_token}` },
|
|
336
|
+
}),
|
|
337
|
+
);
|
|
338
|
+
await dispatch(
|
|
339
|
+
new Request('https://api.example.com/b/items', {
|
|
340
|
+
headers: { Authorization: `Bearer ${b.access_token}` },
|
|
341
|
+
}),
|
|
342
|
+
);
|
|
343
|
+
expect(new Headers((network.mock.calls[0][0] as Request).headers).get('Authorization')).toBe(
|
|
344
|
+
`Bearer ${resourceA}-secret`,
|
|
345
|
+
);
|
|
346
|
+
expect(new Headers((network.mock.calls[1][0] as Request).headers).get('Authorization')).toBe(
|
|
347
|
+
`Bearer ${resourceB}-secret`,
|
|
348
|
+
);
|
|
349
|
+
expect(network.mock.calls[0][1]).toEqual({ redirect: 'error' });
|
|
350
|
+
expect((await init()).tokens).toMatchObject({ access_token: base.access_token });
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('keeps single-resource default renewal in the default token entry', async () => {
|
|
354
|
+
configuration.resource = [resourceA];
|
|
355
|
+
const base = await login();
|
|
356
|
+
const response = await tokenRequest(
|
|
357
|
+
{
|
|
358
|
+
grant_type: 'refresh_token',
|
|
359
|
+
refresh_token: String(base.refresh_token),
|
|
360
|
+
client_id: 'client',
|
|
361
|
+
redirect_uri: 'https://app.example.com/callback',
|
|
362
|
+
resource: resourceA,
|
|
363
|
+
},
|
|
364
|
+
{},
|
|
365
|
+
false,
|
|
366
|
+
);
|
|
367
|
+
expect(response.status).toBe(200);
|
|
368
|
+
const renewed = await response.json();
|
|
369
|
+
expect(renewed.access_token).toBe(base.access_token);
|
|
370
|
+
expect(renewed.resourceContext).toBeUndefined();
|
|
371
|
+
expect((await init()).resourceContexts).toEqual([]);
|
|
372
|
+
await dispatch(
|
|
373
|
+
new Request(`${issuer}/userinfo`, {
|
|
374
|
+
headers: { Authorization: `Bearer ${base.access_token}` },
|
|
375
|
+
}),
|
|
376
|
+
);
|
|
377
|
+
expect((network.mock.calls.at(-1)?.[0] as Request).headers.get('Authorization')).toBe(
|
|
378
|
+
`Bearer ${resourceA}-secret`,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it('rejects a resource routing hint that disagrees with the OAuth resource', async () => {
|
|
383
|
+
const base = await login();
|
|
384
|
+
network.mockClear();
|
|
385
|
+
const response = await tokenRequest(
|
|
386
|
+
{
|
|
387
|
+
grant_type: 'refresh_token',
|
|
388
|
+
refresh_token: String(base.refresh_token),
|
|
389
|
+
resource: resourceA,
|
|
390
|
+
},
|
|
391
|
+
{ [RESOURCE_TOKEN_REQUEST_HEADER]: encodeURIComponent(resourceB) },
|
|
392
|
+
);
|
|
393
|
+
expect(response.status).toBe(500);
|
|
394
|
+
expect(network).not.toHaveBeenCalled();
|
|
395
|
+
expect((await init()).resourceContexts).toEqual([]);
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('never attaches a token for API B to API A', async () => {
|
|
399
|
+
const base = await login();
|
|
400
|
+
const b = await (
|
|
401
|
+
await tokenRequest({
|
|
402
|
+
grant_type: 'refresh_token',
|
|
403
|
+
refresh_token: String(base.refresh_token),
|
|
404
|
+
resource: resourceB,
|
|
405
|
+
})
|
|
406
|
+
).json();
|
|
407
|
+
network.mockClear();
|
|
408
|
+
const response = await dispatch(
|
|
409
|
+
new Request('https://api.example.com/a/items', {
|
|
410
|
+
headers: { Authorization: `Bearer ${b.access_token}` },
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
expect(response.status).toBe(403);
|
|
414
|
+
expect(network).not.toHaveBeenCalled();
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('does not widen trusted domains using a resource URL mapping', async () => {
|
|
418
|
+
configuration.resources[0].allowedUrlPrefixes.push('https://untrusted.example.com/');
|
|
419
|
+
const base = await login();
|
|
420
|
+
const a = await (
|
|
421
|
+
await tokenRequest({
|
|
422
|
+
grant_type: 'refresh_token',
|
|
423
|
+
refresh_token: String(base.refresh_token),
|
|
424
|
+
resource: resourceA,
|
|
425
|
+
})
|
|
426
|
+
).json();
|
|
427
|
+
network.mockClear();
|
|
428
|
+
expect(
|
|
429
|
+
(
|
|
430
|
+
await dispatch(
|
|
431
|
+
new Request('https://untrusted.example.com/', {
|
|
432
|
+
headers: { Authorization: `Bearer ${a.access_token}` },
|
|
433
|
+
}),
|
|
434
|
+
)
|
|
435
|
+
).status,
|
|
436
|
+
).toBe(403);
|
|
437
|
+
expect(network).not.toHaveBeenCalled();
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('requires explicit markers rather than default or arbitrary resource-token attachment', async () => {
|
|
441
|
+
const base = await login();
|
|
442
|
+
await tokenRequest({
|
|
443
|
+
grant_type: 'refresh_token',
|
|
444
|
+
refresh_token: String(base.refresh_token),
|
|
445
|
+
resource: resourceA,
|
|
446
|
+
});
|
|
447
|
+
network.mockClear();
|
|
448
|
+
await dispatch(new Request('https://api.example.com/a/items'));
|
|
449
|
+
expect((network.mock.calls[0][0] as Request).headers.get('Authorization')).toBeNull();
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
it('clears one entry without clearing its sibling or the default grant', async () => {
|
|
453
|
+
const base = await login();
|
|
454
|
+
const a = await (
|
|
455
|
+
await tokenRequest({
|
|
456
|
+
grant_type: 'refresh_token',
|
|
457
|
+
refresh_token: String(base.refresh_token),
|
|
458
|
+
resource: resourceA,
|
|
459
|
+
})
|
|
460
|
+
).json();
|
|
461
|
+
const b = await (
|
|
462
|
+
await tokenRequest({
|
|
463
|
+
grant_type: 'refresh_token',
|
|
464
|
+
refresh_token: String(base.refresh_token),
|
|
465
|
+
resource: resourceB,
|
|
466
|
+
})
|
|
467
|
+
).json();
|
|
468
|
+
await message('clear', getResourceContextName('default', resourceA), { status: 'LOGGED_OUT' });
|
|
469
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
470
|
+
expect((await init(getResourceContextName('default', resourceB))).tokens).toMatchObject({
|
|
471
|
+
access_token: b.access_token,
|
|
472
|
+
});
|
|
473
|
+
expect((await init()).tokens).toMatchObject({ access_token: base.access_token });
|
|
474
|
+
network.mockClear();
|
|
475
|
+
expect(
|
|
476
|
+
(
|
|
477
|
+
await dispatch(
|
|
478
|
+
new Request('https://api.example.com/a', {
|
|
479
|
+
headers: { Authorization: `Bearer ${a.access_token}` },
|
|
480
|
+
}),
|
|
481
|
+
)
|
|
482
|
+
).status,
|
|
483
|
+
).toBe(403);
|
|
484
|
+
expect(network).not.toHaveBeenCalled();
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
it('clears all resource tokens on session logout', async () => {
|
|
488
|
+
const base = await login();
|
|
489
|
+
await tokenRequest({
|
|
490
|
+
grant_type: 'refresh_token',
|
|
491
|
+
refresh_token: String(base.refresh_token),
|
|
492
|
+
resource: resourceA,
|
|
493
|
+
});
|
|
494
|
+
await message('clear', 'default', { status: 'LOGGED_OUT' });
|
|
495
|
+
expect((await init()).tokens).toBeNull();
|
|
496
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
it('rejects unknown resource contexts and refresh resources outside their grant', async () => {
|
|
500
|
+
const base = await login();
|
|
501
|
+
expect(await init(getResourceContextName('default', 'urn:unknown'))).toHaveProperty('error');
|
|
502
|
+
network.mockClear();
|
|
503
|
+
expect(
|
|
504
|
+
(
|
|
505
|
+
await tokenRequest({
|
|
506
|
+
grant_type: 'refresh_token',
|
|
507
|
+
refresh_token: String(base.refresh_token),
|
|
508
|
+
resource: 'urn:unknown',
|
|
509
|
+
})
|
|
510
|
+
).status,
|
|
511
|
+
).toBe(500);
|
|
512
|
+
expect(network).not.toHaveBeenCalled();
|
|
513
|
+
});
|
|
514
|
+
|
|
515
|
+
it('does not resurrect a cleared resource from a late token response', async () => {
|
|
516
|
+
const base = await login();
|
|
517
|
+
let resolve: ((response: Response) => void) | undefined;
|
|
518
|
+
network.mockImplementation(
|
|
519
|
+
() =>
|
|
520
|
+
new Promise<Response>(complete => {
|
|
521
|
+
resolve = complete;
|
|
522
|
+
}),
|
|
523
|
+
);
|
|
524
|
+
const pending = tokenRequest({
|
|
525
|
+
grant_type: 'refresh_token',
|
|
526
|
+
refresh_token: String(base.refresh_token),
|
|
527
|
+
resource: resourceA,
|
|
528
|
+
});
|
|
529
|
+
await vi.waitFor(() => expect(resolve).toBeTypeOf('function'));
|
|
530
|
+
await message('clear', getResourceContextName('default', resourceA), { status: 'LOGGED_OUT' });
|
|
531
|
+
if (!resolve) throw new Error('The token request did not start.');
|
|
532
|
+
resolve(new Response(JSON.stringify({ access_token: 'late-secret', expires_in: 120 })));
|
|
533
|
+
expect((await pending).status).toBe(500);
|
|
534
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
it('persists requested resource scopes when the provider omits them', async () => {
|
|
538
|
+
configuration.scope = 'openid read write';
|
|
539
|
+
configuration.resources[0].scope = 'read';
|
|
540
|
+
const base = await login();
|
|
541
|
+
const response = await (
|
|
542
|
+
await tokenRequest({
|
|
543
|
+
grant_type: 'refresh_token',
|
|
544
|
+
refresh_token: String(base.refresh_token),
|
|
545
|
+
resource: resourceA,
|
|
546
|
+
scope: 'read',
|
|
547
|
+
})
|
|
548
|
+
).json();
|
|
549
|
+
expect(response.scope).toBe('read');
|
|
550
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toMatchObject({
|
|
551
|
+
scope: 'read',
|
|
552
|
+
});
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('does not refresh scopes outside the known grant', async () => {
|
|
556
|
+
configuration.scope = 'openid read';
|
|
557
|
+
configuration.resources[1].scope = 'write';
|
|
558
|
+
const base = await login();
|
|
559
|
+
network.mockClear();
|
|
560
|
+
expect(
|
|
561
|
+
(
|
|
562
|
+
await tokenRequest({
|
|
563
|
+
grant_type: 'refresh_token',
|
|
564
|
+
refresh_token: String(base.refresh_token),
|
|
565
|
+
resource: resourceB,
|
|
566
|
+
scope: 'write',
|
|
567
|
+
})
|
|
568
|
+
).status,
|
|
569
|
+
).toBe(500);
|
|
570
|
+
expect(network).not.toHaveBeenCalled();
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
it('rejects insufficient scopes and wrong audiences before storing a resource token', async () => {
|
|
574
|
+
configuration.resources[0].scope = 'read';
|
|
575
|
+
configuration.resources[0].audiences = ['api-a'];
|
|
576
|
+
const base = await login();
|
|
577
|
+
network.mockResolvedValueOnce(
|
|
578
|
+
new Response(
|
|
579
|
+
JSON.stringify({
|
|
580
|
+
access_token: jwt({ aud: 'api-a', iss: issuer, exp: Date.now() / 1000 + 120 }),
|
|
581
|
+
scope: 'write',
|
|
582
|
+
refresh_token: 'rotated-scope',
|
|
583
|
+
expires_in: 120,
|
|
584
|
+
}),
|
|
585
|
+
),
|
|
586
|
+
);
|
|
587
|
+
expect(
|
|
588
|
+
(
|
|
589
|
+
await tokenRequest({
|
|
590
|
+
grant_type: 'refresh_token',
|
|
591
|
+
refresh_token: String(base.refresh_token),
|
|
592
|
+
resource: resourceA,
|
|
593
|
+
})
|
|
594
|
+
).status,
|
|
595
|
+
).toBe(500);
|
|
596
|
+
network.mockResolvedValueOnce(
|
|
597
|
+
new Response(
|
|
598
|
+
JSON.stringify({
|
|
599
|
+
access_token: jwt({ aud: 'api-b', iss: issuer, exp: Date.now() / 1000 + 120 }),
|
|
600
|
+
scope: 'read',
|
|
601
|
+
refresh_token: 'rotated-audience',
|
|
602
|
+
expires_in: 120,
|
|
603
|
+
}),
|
|
604
|
+
),
|
|
605
|
+
);
|
|
606
|
+
expect(
|
|
607
|
+
(
|
|
608
|
+
await tokenRequest({
|
|
609
|
+
grant_type: 'refresh_token',
|
|
610
|
+
refresh_token: String(base.refresh_token),
|
|
611
|
+
resource: resourceA,
|
|
612
|
+
})
|
|
613
|
+
).status,
|
|
614
|
+
).toBe(500);
|
|
615
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
616
|
+
await tokenRequest({
|
|
617
|
+
grant_type: 'refresh_token',
|
|
618
|
+
refresh_token: String(base.refresh_token),
|
|
619
|
+
resource: resourceB,
|
|
620
|
+
});
|
|
621
|
+
expect(lastRequestParameters().get('refresh_token')).toBe('rotated-audience');
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
it('uses a separate authorization transaction and independent resource grant without overwriting default tokens', async () => {
|
|
625
|
+
configuration.resource = [resourceA];
|
|
626
|
+
const base = await login();
|
|
627
|
+
const context = getResourceContextName('default', resourceB);
|
|
628
|
+
await init(context);
|
|
629
|
+
await message('setNonce', context, { nonce: { nonce: 'resource-nonce' } });
|
|
630
|
+
await message('setCodeVerifier', context, { codeVerifier: 'resource-verifier' });
|
|
631
|
+
const verifier = await message('getCodeVerifier', context);
|
|
632
|
+
network.mockResolvedValueOnce(
|
|
633
|
+
new Response(
|
|
634
|
+
JSON.stringify({
|
|
635
|
+
access_token: 'resource-code-secret',
|
|
636
|
+
refresh_token: 'independent-refresh',
|
|
637
|
+
expires_in: 120,
|
|
638
|
+
id_token: jwt({
|
|
639
|
+
iss: issuer,
|
|
640
|
+
sub: 'user-one',
|
|
641
|
+
nonce: 'resource-nonce',
|
|
642
|
+
exp: Date.now() / 1000 + 10,
|
|
643
|
+
}),
|
|
644
|
+
}),
|
|
645
|
+
),
|
|
646
|
+
);
|
|
647
|
+
const resource = await (
|
|
648
|
+
await tokenRequest({
|
|
649
|
+
code: 'resource-code',
|
|
650
|
+
grant_type: 'authorization_code',
|
|
651
|
+
code_verifier: String(verifier.codeVerifier),
|
|
652
|
+
client_id: 'client',
|
|
653
|
+
resource: resourceB,
|
|
654
|
+
})
|
|
655
|
+
).json();
|
|
656
|
+
expect(resource.resourceContext).toMatchObject({
|
|
657
|
+
resource: resourceB,
|
|
658
|
+
grantResource: resourceB,
|
|
659
|
+
subject: 'user-one',
|
|
660
|
+
});
|
|
661
|
+
expect(lastRequestParameters().get('code_verifier')).toBe('resource-verifier');
|
|
662
|
+
expect((await init()).tokens).toMatchObject({
|
|
663
|
+
access_token: base.access_token,
|
|
664
|
+
refresh_token: base.refresh_token,
|
|
665
|
+
});
|
|
666
|
+
vi.spyOn(Date.prototype, 'getTime').mockReturnValue(Date.now() + 20000);
|
|
667
|
+
const renewed = await tokenRequest({
|
|
668
|
+
grant_type: 'refresh_token',
|
|
669
|
+
refresh_token: String(resource.refresh_token),
|
|
670
|
+
resource: resourceB,
|
|
671
|
+
});
|
|
672
|
+
expect(renewed.ok).toBe(true);
|
|
673
|
+
expect((await renewed.json()).idTokenPayload).toBeNull();
|
|
674
|
+
expect(lastRequestParameters().get('refresh_token')).toBe('independent-refresh');
|
|
675
|
+
network.mockClear();
|
|
676
|
+
expect(
|
|
677
|
+
(
|
|
678
|
+
await dispatch(
|
|
679
|
+
new Request(`${issuer}/revoke`, {
|
|
680
|
+
method: 'POST',
|
|
681
|
+
body: new URLSearchParams({
|
|
682
|
+
token: String(resource.refresh_token),
|
|
683
|
+
token_type_hint: 'refresh_token',
|
|
684
|
+
}),
|
|
685
|
+
}),
|
|
686
|
+
)
|
|
687
|
+
).ok,
|
|
688
|
+
).toBe(true);
|
|
689
|
+
expect(lastRequestParameters().get('token')).toMatch(/^refresh-/);
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
it.each(['different subject', 'missing ID token', 'missing nonce', 'malformed ID token'])(
|
|
693
|
+
'rejects resource authorization with %s',
|
|
694
|
+
async failure => {
|
|
695
|
+
await login();
|
|
696
|
+
const context = getResourceContextName('default', resourceB);
|
|
697
|
+
await init(context);
|
|
698
|
+
await message('setNonce', context, { nonce: { nonce: 'resource-nonce' } });
|
|
699
|
+
await message('setCodeVerifier', context, { codeVerifier: 'resource-verifier' });
|
|
700
|
+
const verifier = await message('getCodeVerifier', context);
|
|
701
|
+
network.mockResolvedValueOnce(
|
|
702
|
+
new Response(
|
|
703
|
+
JSON.stringify({
|
|
704
|
+
access_token: 'other-subject-secret',
|
|
705
|
+
expires_in: 120,
|
|
706
|
+
id_token:
|
|
707
|
+
failure === 'missing ID token'
|
|
708
|
+
? undefined
|
|
709
|
+
: failure === 'malformed ID token'
|
|
710
|
+
? 'invalid.jwt'
|
|
711
|
+
: jwt({
|
|
712
|
+
iss: issuer,
|
|
713
|
+
sub: failure === 'different subject' ? 'another-user' : 'user-one',
|
|
714
|
+
nonce: failure === 'missing nonce' ? undefined : 'resource-nonce',
|
|
715
|
+
exp: Date.now() / 1000 + 3600,
|
|
716
|
+
}),
|
|
717
|
+
}),
|
|
718
|
+
),
|
|
719
|
+
);
|
|
720
|
+
expect(
|
|
721
|
+
(
|
|
722
|
+
await tokenRequest({
|
|
723
|
+
code: 'resource-code',
|
|
724
|
+
code_verifier: String(verifier.codeVerifier),
|
|
725
|
+
resource: resourceB,
|
|
726
|
+
})
|
|
727
|
+
).status,
|
|
728
|
+
).toBe(500);
|
|
729
|
+
expect((await init(context)).tokens).toBeNull();
|
|
730
|
+
},
|
|
731
|
+
);
|
|
732
|
+
|
|
733
|
+
it('preserves shared rotation after resource-only clear but not after a new identity transaction', async () => {
|
|
734
|
+
const base = await login();
|
|
735
|
+
let complete: ((response: Response) => void) | undefined;
|
|
736
|
+
network.mockImplementationOnce(
|
|
737
|
+
() =>
|
|
738
|
+
new Promise<Response>(resolve => {
|
|
739
|
+
complete = resolve;
|
|
740
|
+
}),
|
|
741
|
+
);
|
|
742
|
+
const pending = tokenRequest({
|
|
743
|
+
grant_type: 'refresh_token',
|
|
744
|
+
refresh_token: String(base.refresh_token),
|
|
745
|
+
resource: resourceA,
|
|
746
|
+
});
|
|
747
|
+
await vi.waitFor(() => expect(complete).toBeTypeOf('function'));
|
|
748
|
+
await message('clear', getResourceContextName('default', resourceA), { status: 'LOGGED_OUT' });
|
|
749
|
+
if (!complete) throw new Error('The token request did not start.');
|
|
750
|
+
complete(
|
|
751
|
+
new Response(
|
|
752
|
+
JSON.stringify({
|
|
753
|
+
access_token: 'late-secret',
|
|
754
|
+
refresh_token: 'late-rotation',
|
|
755
|
+
expires_in: 120,
|
|
756
|
+
}),
|
|
757
|
+
),
|
|
758
|
+
);
|
|
759
|
+
expect((await pending).status).toBe(500);
|
|
760
|
+
await tokenRequest({
|
|
761
|
+
grant_type: 'refresh_token',
|
|
762
|
+
refresh_token: String(base.refresh_token),
|
|
763
|
+
resource: resourceB,
|
|
764
|
+
});
|
|
765
|
+
expect(lastRequestParameters().get('refresh_token')).toBe('late-rotation');
|
|
766
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
767
|
+
await message('setNonce', 'default', { nonce: { nonce: 'new-login' } });
|
|
768
|
+
expect((await init(getResourceContextName('default', resourceB))).tokens).toBeNull();
|
|
769
|
+
});
|
|
770
|
+
|
|
771
|
+
it('isolates resource contexts and refresh grants across independently authenticated tabs', async () => {
|
|
772
|
+
trustedDomain.allowMultiTabLogin = true;
|
|
773
|
+
const first = await login();
|
|
774
|
+
const second = await login('tab-two');
|
|
775
|
+
const firstResource = await (
|
|
776
|
+
await tokenRequest({
|
|
777
|
+
grant_type: 'refresh_token',
|
|
778
|
+
refresh_token: String(first.refresh_token),
|
|
779
|
+
resource: resourceA,
|
|
780
|
+
})
|
|
781
|
+
).json();
|
|
782
|
+
expect((await init(getResourceContextName('default', resourceA), 'tab-two')).tokens).toBeNull();
|
|
783
|
+
const secondResource = await (
|
|
784
|
+
await tokenRequest({
|
|
785
|
+
grant_type: 'refresh_token',
|
|
786
|
+
refresh_token: String(second.refresh_token),
|
|
787
|
+
resource: resourceA,
|
|
788
|
+
})
|
|
789
|
+
).json();
|
|
790
|
+
expect(firstResource.access_token).not.toBe(secondResource.access_token);
|
|
791
|
+
expect(lastRequestParameters().get('refresh_token')).toBe('second-tab-refresh');
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
it('binds resource DPoP proofs to the selected token, original key and nonce', async () => {
|
|
795
|
+
trustedDomain.demonstratingProofOfPossession = true;
|
|
796
|
+
const base = await login();
|
|
797
|
+
const originalProof = new Headers(network.mock.calls[0]?.[1]?.headers).get('DPoP');
|
|
798
|
+
if (!originalProof) throw new Error('The authorization grant did not include a DPoP proof.');
|
|
799
|
+
network.mockResolvedValueOnce(
|
|
800
|
+
new Response(
|
|
801
|
+
JSON.stringify({
|
|
802
|
+
access_token: 'resource-dpop-secret',
|
|
803
|
+
refresh_token: 'dpop-rotation',
|
|
804
|
+
token_type: 'DPoP',
|
|
805
|
+
expires_in: 120,
|
|
806
|
+
}),
|
|
807
|
+
{ headers: { 'DPoP-Nonce': 'resource-nonce' } },
|
|
808
|
+
),
|
|
809
|
+
);
|
|
810
|
+
const resource = await (
|
|
811
|
+
await tokenRequest(
|
|
812
|
+
{
|
|
813
|
+
grant_type: 'refresh_token',
|
|
814
|
+
refresh_token: String(base.refresh_token),
|
|
815
|
+
resource: resourceA,
|
|
816
|
+
},
|
|
817
|
+
{ DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default' },
|
|
818
|
+
)
|
|
819
|
+
).json();
|
|
820
|
+
network.mockClear();
|
|
821
|
+
network.mockResolvedValueOnce(new Response('api', { headers: { 'DPoP-Nonce': 'api-nonce' } }));
|
|
822
|
+
await dispatch(
|
|
823
|
+
new Request('https://api.example.com/a/items', {
|
|
824
|
+
method: 'POST',
|
|
825
|
+
body: 'payload',
|
|
826
|
+
headers: {
|
|
827
|
+
Authorization: `DPoP ${resource.access_token}`,
|
|
828
|
+
DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default',
|
|
829
|
+
},
|
|
830
|
+
}),
|
|
831
|
+
);
|
|
832
|
+
const request = network.mock.calls[0][0];
|
|
833
|
+
if (!(request instanceof Request)) throw new Error('The worker did not dispatch a Request.');
|
|
834
|
+
const proof = request.headers.get('DPoP');
|
|
835
|
+
if (!proof) throw new Error('The resource request did not include a DPoP proof.');
|
|
836
|
+
const header = JSON.parse(atob(proof.split('.')[0]));
|
|
837
|
+
const payload = JSON.parse(atob(proof.split('.')[1]));
|
|
838
|
+
expect(payload).toMatchObject({
|
|
839
|
+
htm: 'POST',
|
|
840
|
+
htu: 'https://api.example.com/a/items',
|
|
841
|
+
ath: createHash('sha256').update('resource-dpop-secret').digest('base64url'),
|
|
842
|
+
});
|
|
843
|
+
expect(header.jwk).toEqual(JSON.parse(atob(originalProof.split('.')[0])).jwk);
|
|
844
|
+
expect(request.headers.get('Authorization')).toBe('DPoP resource-dpop-secret');
|
|
845
|
+
expect(payload).not.toHaveProperty('nonce');
|
|
846
|
+
await dispatch(
|
|
847
|
+
new Request('https://api.example.com/a/items', {
|
|
848
|
+
headers: {
|
|
849
|
+
Authorization: `DPoP ${resource.access_token}`,
|
|
850
|
+
DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default',
|
|
851
|
+
},
|
|
852
|
+
}),
|
|
853
|
+
);
|
|
854
|
+
const next = network.mock.calls[1][0];
|
|
855
|
+
if (!(next instanceof Request))
|
|
856
|
+
throw new Error('The worker did not dispatch the next resource request.');
|
|
857
|
+
const nextProof = next.headers.get('DPoP');
|
|
858
|
+
if (!nextProof) throw new Error('The next resource request did not include a DPoP proof.');
|
|
859
|
+
expect(JSON.parse(atob(nextProof.split('.')[1])).nonce).toBe('api-nonce');
|
|
860
|
+
const sibling = await (
|
|
861
|
+
await tokenRequest(
|
|
862
|
+
{
|
|
863
|
+
grant_type: 'refresh_token',
|
|
864
|
+
refresh_token: String(base.refresh_token),
|
|
865
|
+
resource: resourceB,
|
|
866
|
+
},
|
|
867
|
+
{ DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default' },
|
|
868
|
+
)
|
|
869
|
+
).json();
|
|
870
|
+
const tokenProof = new Headers(network.mock.calls[2][1]?.headers).get('DPoP');
|
|
871
|
+
if (!tokenProof) throw new Error('The sibling token request did not include a DPoP proof.');
|
|
872
|
+
expect(JSON.parse(atob(tokenProof.split('.')[1])).nonce).toBe('resource-nonce');
|
|
873
|
+
await dispatch(
|
|
874
|
+
new Request('https://api.example.com/b/items', {
|
|
875
|
+
headers: {
|
|
876
|
+
Authorization: `DPoP ${sibling.access_token}`,
|
|
877
|
+
DPoP: 'DPOP_SECURED_BY_OIDC_SERVICE_WORKER_default',
|
|
878
|
+
},
|
|
879
|
+
}),
|
|
880
|
+
);
|
|
881
|
+
const siblingRequest = network.mock.calls[3][0];
|
|
882
|
+
if (!(siblingRequest instanceof Request))
|
|
883
|
+
throw new Error('The worker did not dispatch the sibling request.');
|
|
884
|
+
const siblingProof = siblingRequest.headers.get('DPoP');
|
|
885
|
+
if (!siblingProof) throw new Error('The sibling request did not include a DPoP proof.');
|
|
886
|
+
expect(JSON.parse(atob(siblingProof.split('.')[1]))).not.toHaveProperty('nonce');
|
|
887
|
+
});
|
|
888
|
+
|
|
889
|
+
it('returns explicit errors for malformed resource contexts and invalid initialization', async () => {
|
|
890
|
+
await login();
|
|
891
|
+
expect(await init('default#resource=%')).toHaveProperty('error');
|
|
892
|
+
expect(
|
|
893
|
+
await message('init', 'default', {
|
|
894
|
+
oidcServerConfiguration: {
|
|
895
|
+
issuer: 'https://untrusted.test',
|
|
896
|
+
tokenEndpoint: 'https://untrusted.test/token',
|
|
897
|
+
},
|
|
898
|
+
oidcConfiguration: configuration,
|
|
899
|
+
}),
|
|
900
|
+
).toHaveProperty('error');
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
it('reports lost parent worker state without restoring stale resource tokens', async () => {
|
|
904
|
+
const base = await login();
|
|
905
|
+
await message('clear', 'default', { status: 'SESSION_LOST' });
|
|
906
|
+
network.mockClear();
|
|
907
|
+
expect(
|
|
908
|
+
(
|
|
909
|
+
await tokenRequest({
|
|
910
|
+
grant_type: 'refresh_token',
|
|
911
|
+
refresh_token: String(base.refresh_token),
|
|
912
|
+
resource: resourceA,
|
|
913
|
+
})
|
|
914
|
+
).status,
|
|
915
|
+
).toBe(500);
|
|
916
|
+
expect(network).not.toHaveBeenCalled();
|
|
917
|
+
expect((await init(getResourceContextName('default', resourceA))).tokens).toBeNull();
|
|
918
|
+
});
|
|
919
|
+
});
|