@bytescale/sdk 3.60.0 → 3.62.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 +91 -0
- package/dist/browser/cjs/main.js +508 -161
- package/dist/browser/esm/main.mjs +508 -161
- package/dist/node/cjs/main.js +351 -97
- package/dist/node/esm/main.mjs +351 -97
- package/dist/types/private/AuthSessionState.d.ts +2 -0
- package/dist/types/private/Scheduler.d.ts +12 -3
- package/dist/types/private/dtos/AuthSwConfigEntryDto.d.ts +3 -1
- package/dist/types/private/model/AuthSession.d.ts +4 -0
- package/dist/types/private/model/AuthSessionConfigBase.d.ts +8 -0
- package/dist/types/public/browser/AuthManagerBrowser.d.ts +3 -0
- package/dist/types/public/shared/generated/runtime.d.ts +6 -0
- package/dist/worker/cjs/main.js +351 -97
- package/dist/worker/esm/main.mjs +351 -97
- package/package.json +1 -1
- package/tests/ApiClientAuth.test.ts +115 -2
- package/tests/AuthManagerBrowser.test.ts +145 -2
- package/tests/AuthServiceWorkerRequestScope.test.ts +259 -0
- package/tests/AuthServiceWorkerRewrite.test.ts +17 -152
- package/tests/Scheduler.test.ts +135 -0
- package/tests/UploadManagerAuth.test.ts +30 -0
- package/tests/fixtures/auth-sw-3.61.0.js +272 -0
- package/tests/utils/AuthServiceWorkerHarness.ts +183 -0
|
@@ -60,6 +60,94 @@ describe("API-client AuthManager configuration", () => {
|
|
|
60
60
|
expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-refreshed");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test.each(
|
|
64
|
+
[[], ["https://app.example.com/media-auth/"]].map((prefixes): { prefixes: typeof prefixes } => ({ prefixes }))
|
|
65
|
+
)("ignores service-worker request restrictions in API clients: $prefixes", async ({ prefixes }) => {
|
|
66
|
+
const configState = state(undefined, accountA, "jwt-a", "access-a");
|
|
67
|
+
configState.config.requestUrlPrefixes = prefixes;
|
|
68
|
+
setModernSession([configState]);
|
|
69
|
+
const withKey = createApi({ apiKey: apiKeyA });
|
|
70
|
+
const withoutKey = createApi({});
|
|
71
|
+
await withKey.api.get();
|
|
72
|
+
await withoutKey.api.get();
|
|
73
|
+
expect(requestHeaders(withKey.fetchApi).get("Authorization-Token")).toBe("access-a");
|
|
74
|
+
expect(requestHeaders(withoutKey.fetchApi).get("Authorization")).toBe("Bearer jwt-a");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("awaits manager-owned authentication at the expiry boundary", async () => {
|
|
78
|
+
const configState = state("customer", accountA, "jwt-old", "access-old");
|
|
79
|
+
configState.expiresAt = Date.now();
|
|
80
|
+
let completeRefresh = (): void => {
|
|
81
|
+
throw new Error("Refresh completion callback was not initialized.");
|
|
82
|
+
};
|
|
83
|
+
const authenticationPromise = new Promise<void>(resolve => {
|
|
84
|
+
completeRefresh = () => {
|
|
85
|
+
configState.accessToken = "access-new";
|
|
86
|
+
configState.expiresAt = Date.now() + 60_000;
|
|
87
|
+
configState.jwt = "jwt-new";
|
|
88
|
+
configState.refreshPromise = undefined;
|
|
89
|
+
resolve();
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
configState.authenticationPromise = authenticationPromise;
|
|
93
|
+
configState.refreshPromise = authenticationPromise;
|
|
94
|
+
setModernSession([configState]);
|
|
95
|
+
const { api, fetchApi } = createApi({ apiKey: apiKeyA, authConfigId: "customer" });
|
|
96
|
+
|
|
97
|
+
const requests = [api.get(), api.get()];
|
|
98
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
99
|
+
completeRefresh();
|
|
100
|
+
await Promise.all(requests);
|
|
101
|
+
|
|
102
|
+
expect(requestHeaders(fetchApi, 0).get("Authorization-Token")).toBe("access-new");
|
|
103
|
+
expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-new");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("observes authentication that starts while resolving custom headers", async () => {
|
|
107
|
+
const configState = state("customer", accountB, "jwt-old", "access-old");
|
|
108
|
+
let completeRefresh = (): void => {
|
|
109
|
+
throw new Error("Refresh completion callback was not initialized.");
|
|
110
|
+
};
|
|
111
|
+
const authenticationPromise = new Promise<void>(resolve => {
|
|
112
|
+
completeRefresh = () => {
|
|
113
|
+
configState.accessToken = "access-new";
|
|
114
|
+
configState.expiresAt = Date.now() + 60_000;
|
|
115
|
+
configState.jwt = "jwt-new";
|
|
116
|
+
configState.refreshPromise = undefined;
|
|
117
|
+
resolve();
|
|
118
|
+
};
|
|
119
|
+
});
|
|
120
|
+
setModernSession([configState]);
|
|
121
|
+
const { api, fetchApi } = createApi({
|
|
122
|
+
authConfigId: "customer",
|
|
123
|
+
headers: async (): Promise<Record<string, string>> => {
|
|
124
|
+
configState.authenticationPromise = authenticationPromise;
|
|
125
|
+
configState.refreshPromise = authenticationPromise;
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const request = api.get();
|
|
131
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
132
|
+
completeRefresh();
|
|
133
|
+
await request;
|
|
134
|
+
|
|
135
|
+
expect(requestHeaders(fetchApi).get("Authorization")).toBe("Bearer jwt-new");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("never invokes the token provider when authentication is not in progress", async () => {
|
|
139
|
+
const getAuthorizationToken = jest.fn(async () => "jwt-new");
|
|
140
|
+
const configState = state("customer", accountB, "jwt-old", "access-old", getAuthorizationToken);
|
|
141
|
+
configState.expiresAt = Date.now();
|
|
142
|
+
setModernSession([configState]);
|
|
143
|
+
const { api, fetchApi } = createApi({ authConfigId: "customer" });
|
|
144
|
+
|
|
145
|
+
await expect(api.get()).rejects.toThrow("not ready");
|
|
146
|
+
|
|
147
|
+
expect(getAuthorizationToken).not.toHaveBeenCalled();
|
|
148
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
149
|
+
});
|
|
150
|
+
|
|
63
151
|
test("uses the raw JWT as sole authentication for an API-key-less named client", async () => {
|
|
64
152
|
setModernSession([state("customer", accountB, "jwt-b", "access-b")]);
|
|
65
153
|
const { api, fetchApi } = createApi({ authConfigId: "customer" });
|
|
@@ -126,6 +214,29 @@ describe("API-client AuthManager configuration", () => {
|
|
|
126
214
|
expect(requestHeaders(optedOut.fetchApi).get("Authorization")).toBe(`Bearer ${apiKeyA}`);
|
|
127
215
|
});
|
|
128
216
|
|
|
217
|
+
test("fails closed for an expired V1 default instead of falling back to its API key", async () => {
|
|
218
|
+
const expired = state(undefined, accountA, "jwt-a", "access-a");
|
|
219
|
+
expired.expiresAt = Date.now() - 1;
|
|
220
|
+
AuthSessionState.setSession({
|
|
221
|
+
accessToken: undefined,
|
|
222
|
+
accessTokenRefreshHandle: undefined,
|
|
223
|
+
authConfigs: [expired],
|
|
224
|
+
authServiceWorker: undefined,
|
|
225
|
+
isActive: true,
|
|
226
|
+
isReady: false,
|
|
227
|
+
params: {
|
|
228
|
+
accountId: accountA,
|
|
229
|
+
authHeaders: async (): Promise<Record<string, string>> => ({}),
|
|
230
|
+
authUrl: "https://app.example.com/auth"
|
|
231
|
+
},
|
|
232
|
+
serviceWorkerConfigured: false
|
|
233
|
+
});
|
|
234
|
+
const { api, fetchApi } = createApi({ apiKey: apiKeyA });
|
|
235
|
+
|
|
236
|
+
await expect(api.get()).rejects.toThrow("not ready");
|
|
237
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
|
|
129
240
|
test("recognizes a 3.54 session as the default supplemental token", async () => {
|
|
130
241
|
AuthSessionState.setSession({
|
|
131
242
|
accessToken: "legacy-access",
|
|
@@ -184,16 +295,18 @@ function state(
|
|
|
184
295
|
authConfigId: string | undefined,
|
|
185
296
|
accountId: string,
|
|
186
297
|
jwt: string,
|
|
187
|
-
accessToken: string
|
|
298
|
+
accessToken: string,
|
|
299
|
+
getAuthorizationToken: () => Promise<string> = async () => jwt
|
|
188
300
|
): AuthSessionConfigState {
|
|
189
301
|
const config: AuthSessionConfig = {
|
|
190
302
|
accountId,
|
|
191
303
|
authConfigId,
|
|
192
304
|
enableServiceWorkerAuth: false,
|
|
193
|
-
getAuthorizationToken
|
|
305
|
+
getAuthorizationToken
|
|
194
306
|
};
|
|
195
307
|
return {
|
|
196
308
|
accessToken,
|
|
309
|
+
authenticationPromise: Promise.resolve(),
|
|
197
310
|
config,
|
|
198
311
|
expiresAt: Date.now() + 60_000,
|
|
199
312
|
jwt,
|
|
@@ -4,11 +4,14 @@ import { AuthSessionState } from "../src/private/AuthSessionState";
|
|
|
4
4
|
import { AuthSessionConfigState } from "../src/private/model/AuthSession";
|
|
5
5
|
import type {
|
|
6
6
|
AuthSessionConfig,
|
|
7
|
+
AuthSwConfigEntryDto,
|
|
7
8
|
BeginAuthSessionParams,
|
|
8
9
|
BeginAuthSessionParamsV2,
|
|
9
10
|
UrlRewriteRule
|
|
10
11
|
} from "../src/index.browser";
|
|
11
12
|
|
|
13
|
+
import { AuthServiceWorkerHarness } from "./utils/AuthServiceWorkerHarness";
|
|
14
|
+
|
|
12
15
|
type FetchApi = NonNullable<NonNullable<BeginAuthSessionParams["options"]>["fetchApi"]>;
|
|
13
16
|
|
|
14
17
|
interface AuthManagerApi {
|
|
@@ -143,11 +146,13 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
143
146
|
authConfigId: undefined,
|
|
144
147
|
authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Auth": "app-token" }),
|
|
145
148
|
authUrl: "https://app.example.com/auth-a",
|
|
149
|
+
requestUrlPrefixes: ["https://app.example.com/media-auth/"],
|
|
146
150
|
sourceUrlPrefixes: ["https://app.example.com/"]
|
|
147
151
|
},
|
|
148
152
|
{
|
|
149
153
|
accountId: accountB,
|
|
150
154
|
authConfigId: "customer-b",
|
|
155
|
+
requestUrlPrefixes: ["https://app.example.com/download/"],
|
|
151
156
|
getAuthorizationToken: manualB
|
|
152
157
|
},
|
|
153
158
|
{
|
|
@@ -188,14 +193,16 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
188
193
|
{
|
|
189
194
|
expires: expect.any(Number),
|
|
190
195
|
headers: [{ key: "Authorization", value: `Bearer ${jwtA}` }],
|
|
196
|
+
requestUrlPrefixes: ["https://app.example.com/media-auth/"],
|
|
191
197
|
sourceUrlPrefixes: ["https://app.example.com/"],
|
|
192
|
-
urlPrefix: `!bytescale-source-scoped!https://upcdn.io/${accountA}/`
|
|
198
|
+
urlPrefix: `!bytescale-request-scoped!!bytescale-source-scoped!https://upcdn.io/${accountA}/`
|
|
193
199
|
},
|
|
194
200
|
{
|
|
195
201
|
expires: expect.any(Number),
|
|
196
202
|
headers: [{ key: "Authorization", value: `Bearer ${jwtB}` }],
|
|
203
|
+
requestUrlPrefixes: ["https://app.example.com/download/"],
|
|
197
204
|
sourceUrlPrefixes: undefined,
|
|
198
|
-
urlPrefix:
|
|
205
|
+
urlPrefix: `!bytescale-request-scoped!https://upcdn.io/${accountB}/`
|
|
199
206
|
}
|
|
200
207
|
],
|
|
201
208
|
type: "SET_BYTESCALE_AUTH_CONFIG",
|
|
@@ -203,6 +210,94 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
203
210
|
});
|
|
204
211
|
});
|
|
205
212
|
|
|
213
|
+
test.each(
|
|
214
|
+
[undefined, [], ["https://app.example.com/media-auth/", "https://app.example.com/download/"]].map(
|
|
215
|
+
(prefixes): { prefixes: typeof prefixes } => ({
|
|
216
|
+
prefixes
|
|
217
|
+
})
|
|
218
|
+
)
|
|
219
|
+
)("sends request prefixes through messages and restores them in the worker: $prefixes", async ({ prefixes }) => {
|
|
220
|
+
const fetchApi = createFetchApi();
|
|
221
|
+
await AuthManager.beginAuthSession({
|
|
222
|
+
authConfigs: async () => [
|
|
223
|
+
{
|
|
224
|
+
accountId: accountA,
|
|
225
|
+
authConfigId: undefined,
|
|
226
|
+
getAuthorizationToken: async () => jwtA,
|
|
227
|
+
requestUrlPrefixes: prefixes
|
|
228
|
+
}
|
|
229
|
+
],
|
|
230
|
+
options: { fetchApi },
|
|
231
|
+
serviceWorkerScript: "/auth-sw.js",
|
|
232
|
+
urlRewriteRules: [{ fromUrlPrefix: "https://app.example.com/media-auth/", toUrlPrefix: "https://upcdn.io/" }]
|
|
233
|
+
});
|
|
234
|
+
const message = postMessage.mock.calls.at(-1)?.[0] as {
|
|
235
|
+
config: AuthSwConfigEntryDto[];
|
|
236
|
+
type: "SET_BYTESCALE_AUTH_CONFIG";
|
|
237
|
+
urlRewriteRules: UrlRewriteRule[];
|
|
238
|
+
};
|
|
239
|
+
expect(message.config[0].requestUrlPrefixes).toEqual(prefixes);
|
|
240
|
+
expect(message.config[0].urlPrefix).toBe(
|
|
241
|
+
`${prefixes === undefined ? "" : "!bytescale-request-scoped!"}https://upcdn.io/${accountA}/`
|
|
242
|
+
);
|
|
243
|
+
const worker = new AuthServiceWorkerHarness();
|
|
244
|
+
await worker.dispatchMessage(message);
|
|
245
|
+
const restarted = worker.restart();
|
|
246
|
+
const result = await restarted.dispatchFetch(`https://app.example.com/media-auth/${accountA}/image/example.jpg`);
|
|
247
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe(prefixes?.length === 0 ? null : `Bearer ${jwtA}`);
|
|
248
|
+
const direct = await restarted.dispatchFetch(`https://upcdn.io/${accountA}/image/example.jpg`);
|
|
249
|
+
expect(direct.responded).toBe(prefixes === undefined);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
test("snapshots request prefixes and keeps them through token refresh", async () => {
|
|
253
|
+
const requestUrlPrefixes = ["https://app.example.com/media-auth/"];
|
|
254
|
+
const provider = jest.fn<() => Promise<string>>().mockResolvedValueOnce(jwtA).mockResolvedValueOnce(jwtB);
|
|
255
|
+
await AuthManager.beginAuthSession({
|
|
256
|
+
authConfigs: async () => [
|
|
257
|
+
{ accountId: accountA, authConfigId: undefined, getAuthorizationToken: provider, requestUrlPrefixes }
|
|
258
|
+
],
|
|
259
|
+
options: { fetchApi: createFetchApi() },
|
|
260
|
+
serviceWorkerScript: "/auth-sw.js"
|
|
261
|
+
});
|
|
262
|
+
requestUrlPrefixes.length = 0;
|
|
263
|
+
const session = AuthSessionState.getSession();
|
|
264
|
+
const state = session?.authConfigs?.[0];
|
|
265
|
+
if (session === undefined || state === undefined) {
|
|
266
|
+
throw new Error("Expected initialized auth state.");
|
|
267
|
+
}
|
|
268
|
+
await (AuthManager as AuthManagerInternals).refreshAuthConfig(session, state);
|
|
269
|
+
expect(provider).toHaveBeenCalledTimes(2);
|
|
270
|
+
expect(state.config.requestUrlPrefixes).toEqual(["https://app.example.com/media-auth/"]);
|
|
271
|
+
expect(state.refreshHandle).toBeDefined();
|
|
272
|
+
expect((postMessage.mock.calls.at(-1)?.[0] as { config: AuthSwConfigEntryDto[] }).config[0]).toMatchObject({
|
|
273
|
+
headers: [{ key: "Authorization", value: `Bearer ${jwtB}` }],
|
|
274
|
+
requestUrlPrefixes: ["https://app.example.com/media-auth/"]
|
|
275
|
+
});
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test.each(
|
|
279
|
+
[null, "https://app.example.com/", [42], ["https://app.example.com/", null]].map(
|
|
280
|
+
(prefixes): { prefixes: typeof prefixes } => ({ prefixes })
|
|
281
|
+
)
|
|
282
|
+
)("rejects malformed request prefixes before acquiring tokens: $prefixes", async ({ prefixes }) => {
|
|
283
|
+
const provider = jest.fn(async () => jwtA);
|
|
284
|
+
const fetchApi = createFetchApi();
|
|
285
|
+
await expect(
|
|
286
|
+
AuthManager.beginAuthSession(
|
|
287
|
+
v2Params(fetchApi, [
|
|
288
|
+
{
|
|
289
|
+
...apiOnlyConfig(undefined, accountA, provider),
|
|
290
|
+
requestUrlPrefixes: prefixes as unknown as string[]
|
|
291
|
+
}
|
|
292
|
+
])
|
|
293
|
+
)
|
|
294
|
+
).rejects.toThrow("The 'requestUrlPrefixes' field must be an array of strings.");
|
|
295
|
+
expect(provider).not.toHaveBeenCalled();
|
|
296
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
297
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
298
|
+
expect(AuthManager.isAuthSessionActive()).toBe(false);
|
|
299
|
+
});
|
|
300
|
+
|
|
206
301
|
test("uses each config's effective CDN URL for registration, worker prefixes, cleanup, and collisions", async () => {
|
|
207
302
|
const fetchApi = createFetchApi();
|
|
208
303
|
const defaultCdnUrl = "https://downloads-default.example.com";
|
|
@@ -269,6 +364,7 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
269
364
|
authConfigId: undefined,
|
|
270
365
|
enableCookieAuth: true,
|
|
271
366
|
enableServiceWorkerAuth: false,
|
|
367
|
+
requestUrlPrefixes: [],
|
|
272
368
|
getAuthorizationToken: provider
|
|
273
369
|
}
|
|
274
370
|
],
|
|
@@ -308,6 +404,53 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
308
404
|
expect(AuthManager.isAuthSessionReady()).toBe(true);
|
|
309
405
|
});
|
|
310
406
|
|
|
407
|
+
test("deduplicates concurrent refreshes for the same auth config", async () => {
|
|
408
|
+
const fetchApi = createFetchApi();
|
|
409
|
+
let resolveRefresh = (_jwt: string): void => {
|
|
410
|
+
throw new Error("Refresh resolver was not initialized.");
|
|
411
|
+
};
|
|
412
|
+
const refreshResult = new Promise<string>(resolve => {
|
|
413
|
+
resolveRefresh = resolve;
|
|
414
|
+
});
|
|
415
|
+
let markRefreshStarted = (): void => {
|
|
416
|
+
throw new Error("Refresh-start resolver was not initialized.");
|
|
417
|
+
};
|
|
418
|
+
const refreshStarted = new Promise<void>(resolve => {
|
|
419
|
+
markRefreshStarted = resolve;
|
|
420
|
+
});
|
|
421
|
+
const provider = jest
|
|
422
|
+
.fn<() => Promise<string>>()
|
|
423
|
+
.mockResolvedValueOnce(jwtA)
|
|
424
|
+
.mockImplementationOnce(async () => {
|
|
425
|
+
markRefreshStarted();
|
|
426
|
+
return await refreshResult;
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
await AuthManager.beginAuthSession({
|
|
430
|
+
authConfigs: async () => [apiOnlyConfig("customer", accountA, provider)],
|
|
431
|
+
options: { fetchApi }
|
|
432
|
+
});
|
|
433
|
+
const session = AuthSessionState.getSession();
|
|
434
|
+
const configState = session?.authConfigs?.[0];
|
|
435
|
+
if (session === undefined || configState === undefined) {
|
|
436
|
+
throw new Error("Expected initialized auth state.");
|
|
437
|
+
}
|
|
438
|
+
const internals = AuthManager as AuthManagerInternals;
|
|
439
|
+
|
|
440
|
+
const firstRefresh = internals.refreshAuthConfig(session, configState);
|
|
441
|
+
const secondRefresh = internals.refreshAuthConfig(session, configState);
|
|
442
|
+
await refreshStarted;
|
|
443
|
+
expect(provider).toHaveBeenCalledTimes(2);
|
|
444
|
+
expect(configState.authenticationPromise).toBe(configState.refreshPromise);
|
|
445
|
+
|
|
446
|
+
resolveRefresh(jwtB);
|
|
447
|
+
await Promise.all([firstRefresh, secondRefresh]);
|
|
448
|
+
|
|
449
|
+
expect(configState.jwt).toBe(jwtB);
|
|
450
|
+
expect(configState.authenticationPromise).toBeDefined();
|
|
451
|
+
expect(configState.refreshPromise).toBeUndefined();
|
|
452
|
+
});
|
|
453
|
+
|
|
311
454
|
test("clears the cookie-enabled config and the complete worker config on end", async () => {
|
|
312
455
|
const fetchApi = createFetchApi();
|
|
313
456
|
await AuthManager.beginAuthSession({
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import type { AuthSwConfigEntryDto } from "../src/index.browser";
|
|
4
|
+
import { AuthServiceWorkerHarness, authConfig, rewriteRule } from "./utils/AuthServiceWorkerHarness";
|
|
5
|
+
|
|
6
|
+
const mediaPrefix = "https://dashboard.example.com/media-auth/";
|
|
7
|
+
const downloadPrefix = "https://dashboard.example.com/download/";
|
|
8
|
+
const sourcePrefix = "https://app.example.com/private/";
|
|
9
|
+
const rules = [rewriteRule("media-auth/", ""), rewriteRule("download/", "")];
|
|
10
|
+
const oldWorkerSource = readFileSync(resolve(process.cwd(), "tests/fixtures/auth-sw-3.61.0.js"), "utf8");
|
|
11
|
+
|
|
12
|
+
describe("Auth service-worker original request URL restrictions", () => {
|
|
13
|
+
test.each([
|
|
14
|
+
{ prefixes: undefined, directAuth: true, mediaAuth: true, downloadAuth: true },
|
|
15
|
+
{ prefixes: [], directAuth: false, mediaAuth: false, downloadAuth: false },
|
|
16
|
+
{ prefixes: [""], directAuth: true, mediaAuth: true, downloadAuth: true },
|
|
17
|
+
{ prefixes: [mediaPrefix], directAuth: false, mediaAuth: true, downloadAuth: false },
|
|
18
|
+
{ prefixes: [mediaPrefix, downloadPrefix], directAuth: false, mediaAuth: true, downloadAuth: true },
|
|
19
|
+
{ prefixes: ["https://upcdn.io/account-a/"], directAuth: true, mediaAuth: false, downloadAuth: false }
|
|
20
|
+
])("matches the original URL for $prefixes", async ({ prefixes, directAuth, mediaAuth, downloadAuth }) => {
|
|
21
|
+
const harness = new AuthServiceWorkerHarness();
|
|
22
|
+
await harness.setConfig([scopedConfig(prefixes)], rules);
|
|
23
|
+
|
|
24
|
+
const direct = await harness.dispatchFetch("https://upcdn.io/account-a/image/example.jpg");
|
|
25
|
+
expect(direct.responded).toBe(directAuth);
|
|
26
|
+
expect(direct.outboundRequest?.headers.get("Authorization")).toBe(directAuth ? "Bearer token-a" : undefined);
|
|
27
|
+
|
|
28
|
+
for (const [prefix, expectedAuth] of [
|
|
29
|
+
[mediaPrefix, mediaAuth],
|
|
30
|
+
[downloadPrefix, downloadAuth]
|
|
31
|
+
] as const) {
|
|
32
|
+
const result = await harness.dispatchFetch(`${prefix}account-a/raw/example.jpg?download=true`, {
|
|
33
|
+
navigation: true
|
|
34
|
+
});
|
|
35
|
+
expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/raw/example.jpg?download=true");
|
|
36
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe(expectedAuth ? "Bearer token-a" : null);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("still checks the final account and CDN for an allowed alias", async () => {
|
|
41
|
+
const harness = new AuthServiceWorkerHarness();
|
|
42
|
+
await harness.setConfig([scopedConfig([mediaPrefix])], rules);
|
|
43
|
+
|
|
44
|
+
const otherAccount = await harness.dispatchFetch(`${mediaPrefix}account-b/raw/example.jpg`);
|
|
45
|
+
const similarAccount = await harness.dispatchFetch(`${mediaPrefix}account-a-extra/raw/example.jpg`);
|
|
46
|
+
expect(otherAccount.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
47
|
+
expect(similarAccount.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
48
|
+
|
|
49
|
+
await harness.setConfig(
|
|
50
|
+
[scopedConfig([mediaPrefix])],
|
|
51
|
+
[{ fromUrlPrefix: mediaPrefix, toUrlPrefix: "https://other-cdn.example.com/" }]
|
|
52
|
+
);
|
|
53
|
+
const otherCdn = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
54
|
+
expect(otherCdn.outboundRequest?.url).toBe("https://other-cdn.example.com/account-a/raw/example.jpg");
|
|
55
|
+
expect(otherCdn.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test.each([[], [downloadPrefix]].map((prefixes): { prefixes: typeof prefixes } => ({ prefixes })))(
|
|
59
|
+
"continues to another eligible config after rejecting $prefixes",
|
|
60
|
+
async ({ prefixes }) => {
|
|
61
|
+
const harness = new AuthServiceWorkerHarness();
|
|
62
|
+
await harness.setConfig(
|
|
63
|
+
[
|
|
64
|
+
{ ...scopedConfig(prefixes), headers: [{ key: "X-Rejected", value: "must-not-leak" }] },
|
|
65
|
+
scopedConfig([mediaPrefix]),
|
|
66
|
+
{ ...scopedConfig([mediaPrefix]), headers: [{ key: "Authorization", value: "Bearer later" }] }
|
|
67
|
+
],
|
|
68
|
+
rules
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
72
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
|
|
73
|
+
expect(result.outboundRequest?.headers.has("X-Rejected")).toBe(false);
|
|
74
|
+
}
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
test("does not block an eligible destination config with a rejected original-prefix config", async () => {
|
|
78
|
+
const harness = new AuthServiceWorkerHarness();
|
|
79
|
+
await harness.setConfig(
|
|
80
|
+
[
|
|
81
|
+
scopedConfig([downloadPrefix]),
|
|
82
|
+
{
|
|
83
|
+
...scopedConfig([mediaPrefix]),
|
|
84
|
+
headers: [{ key: "Authorization", value: "Bearer token-b" }],
|
|
85
|
+
urlPrefix: "!bytescale-request-scoped!https://upcdn.io/account-b/"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
rules
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-b/raw/example.jpg`);
|
|
92
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-b");
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test.each([
|
|
96
|
+
{ requestPrefix: mediaPrefix, clientUrl: `${sourcePrefix}page`, authorized: true },
|
|
97
|
+
{ requestPrefix: mediaPrefix, clientUrl: "https://app.example.com/public/", authorized: false },
|
|
98
|
+
{ requestPrefix: downloadPrefix, clientUrl: `${sourcePrefix}page`, authorized: false },
|
|
99
|
+
{ requestPrefix: mediaPrefix, clientUrl: undefined, authorized: false }
|
|
100
|
+
])("combines resource and initiating-page restrictions: %j", async ({ requestPrefix, clientUrl, authorized }) => {
|
|
101
|
+
const harness = new AuthServiceWorkerHarness();
|
|
102
|
+
if (clientUrl !== undefined) {
|
|
103
|
+
harness.setWindowClient("client", clientUrl);
|
|
104
|
+
}
|
|
105
|
+
await harness.setConfig([scopedConfig([mediaPrefix], [sourcePrefix])], rules);
|
|
106
|
+
|
|
107
|
+
const result = await harness.dispatchFetch(`${requestPrefix}account-a/raw/example.jpg`, { clientId: "client" });
|
|
108
|
+
expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/raw/example.jpg");
|
|
109
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe(authorized ? "Bearer token-a" : null);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("a rejected request restriction skips source lookup and permits a later config", async () => {
|
|
113
|
+
const harness = new AuthServiceWorkerHarness();
|
|
114
|
+
await harness.setConfig([scopedConfig([], [sourcePrefix]), scopedConfig([mediaPrefix])], rules);
|
|
115
|
+
|
|
116
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
117
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test.each(["expired", "empty source", "HEAD"])("keeps rewriting when auth is excluded by %s", async reason => {
|
|
121
|
+
const harness = new AuthServiceWorkerHarness();
|
|
122
|
+
const config = scopedConfig([mediaPrefix], reason === "empty source" ? [] : undefined);
|
|
123
|
+
if (reason === "expired") {
|
|
124
|
+
config.expires = Date.now() - 1;
|
|
125
|
+
}
|
|
126
|
+
await harness.setConfig([config], rules);
|
|
127
|
+
|
|
128
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg?download=true`, {
|
|
129
|
+
method: reason === "HEAD" ? "HEAD" : "GET"
|
|
130
|
+
});
|
|
131
|
+
expect(result.outboundRequest?.url).toBe("https://upcdn.io/account-a/raw/example.jpg?download=true");
|
|
132
|
+
expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("does not strip caller headers from requests excluded by the restriction", async () => {
|
|
136
|
+
const harness = new AuthServiceWorkerHarness();
|
|
137
|
+
await harness.setConfig([scopedConfig([])], rules);
|
|
138
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`, {
|
|
139
|
+
headers: { "Authorization": "Caller token", "Authorization-Token": "Caller access token", "Range": "bytes=0-9" }
|
|
140
|
+
});
|
|
141
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe("Caller token");
|
|
142
|
+
expect(result.outboundRequest?.headers.get("Authorization-Token")).toBe("Caller access token");
|
|
143
|
+
expect(result.outboundRequest?.headers.get("Range")).toBe("bytes=0-9");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test.each(
|
|
147
|
+
[undefined, [], [mediaPrefix, downloadPrefix]].map((prefixes): { prefixes: typeof prefixes } => ({ prefixes }))
|
|
148
|
+
)("recovers restrictions and rewrites after restart: $prefixes", async ({ prefixes }) => {
|
|
149
|
+
const harness = new AuthServiceWorkerHarness();
|
|
150
|
+
await harness.dispatchMessage({
|
|
151
|
+
type: "SET_BYTESCALE_AUTH_CONFIG",
|
|
152
|
+
config: [scopedConfig(prefixes)],
|
|
153
|
+
urlRewriteRules: rules
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const direct = await harness.restart().dispatchFetch("https://upcdn.io/account-a/raw/example.jpg");
|
|
157
|
+
expect(direct.outboundRequest?.headers.get("Authorization")).toBe(prefixes === undefined ? "Bearer token-a" : null);
|
|
158
|
+
for (const prefix of [mediaPrefix, downloadPrefix]) {
|
|
159
|
+
const alias = await harness
|
|
160
|
+
.restart()
|
|
161
|
+
.dispatchFetch(`${prefix}account-a/raw/example.jpg?download=true`, { navigation: true });
|
|
162
|
+
expect(alias.outboundRequest?.url).toBe("https://upcdn.io/account-a/raw/example.jpg?download=true");
|
|
163
|
+
expect(alias.outboundRequest?.headers.get("Authorization")).toBe(
|
|
164
|
+
prefixes?.length === 0 ? null : "Bearer token-a"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("recovers both restrictions after restart", async () => {
|
|
170
|
+
const harness = new AuthServiceWorkerHarness();
|
|
171
|
+
await harness.dispatchMessage({
|
|
172
|
+
type: "SET_BYTESCALE_AUTH_CONFIG",
|
|
173
|
+
config: [scopedConfig([mediaPrefix], [sourcePrefix])],
|
|
174
|
+
urlRewriteRules: rules
|
|
175
|
+
});
|
|
176
|
+
const restarted = harness.restart();
|
|
177
|
+
restarted.setWindowClient("client", `${sourcePrefix}page`);
|
|
178
|
+
const allowed = await restarted.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`, { clientId: "client" });
|
|
179
|
+
const rejected = await restarted.dispatchFetch(`${downloadPrefix}account-a/raw/example.jpg`, {
|
|
180
|
+
clientId: "client"
|
|
181
|
+
});
|
|
182
|
+
expect(allowed.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
|
|
183
|
+
expect(rejected.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test.each([null, "https://", [42]].map((prefixes): { prefixes: typeof prefixes } => ({ prefixes })))(
|
|
187
|
+
"rejects malformed persisted request prefixes: $prefixes",
|
|
188
|
+
async ({ prefixes }) => {
|
|
189
|
+
const harness = new AuthServiceWorkerHarness();
|
|
190
|
+
await harness.setConfig([scopedConfig(prefixes as unknown as string[])], rules);
|
|
191
|
+
const result = await harness.restart().dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
192
|
+
expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
test("rejects restricted configs without the compatibility marker", async () => {
|
|
197
|
+
const harness = new AuthServiceWorkerHarness();
|
|
198
|
+
await harness.setConfig([{ ...authConfig("account-a/", "token-a"), requestUrlPrefixes: [mediaPrefix] }], rules);
|
|
199
|
+
const result = await harness.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
200
|
+
expect(result.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("Auth service-worker version compatibility", () => {
|
|
205
|
+
test.each(
|
|
206
|
+
[undefined, [], [sourcePrefix]].map((sourcePrefixes): { sourcePrefixes: typeof sourcePrefixes } => ({
|
|
207
|
+
sourcePrefixes
|
|
208
|
+
}))
|
|
209
|
+
)("3.61.0 skips request-scoped auth with source prefixes $sourcePrefixes", async ({ sourcePrefixes }) => {
|
|
210
|
+
const oldWorker = new AuthServiceWorkerHarness(undefined, oldWorkerSource);
|
|
211
|
+
oldWorker.setWindowClient("client", `${sourcePrefix}page`);
|
|
212
|
+
await oldWorker.setConfig([scopedConfig([mediaPrefix, downloadPrefix], sourcePrefixes)], rules);
|
|
213
|
+
const direct = await oldWorker.dispatchFetch("https://upcdn.io/account-a/raw/example.jpg", {
|
|
214
|
+
clientId: "client"
|
|
215
|
+
});
|
|
216
|
+
expect(direct.responded).toBe(false);
|
|
217
|
+
const alias = await oldWorker.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`, { clientId: "client" });
|
|
218
|
+
expect(alias.outboundRequest?.url).toBe("https://upcdn.io/account-a/raw/example.jpg");
|
|
219
|
+
expect(alias.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
220
|
+
|
|
221
|
+
const recoveredOld = await oldWorker.restart().dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
222
|
+
expect(recoveredOld.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
223
|
+
const upgraded = oldWorker.restart(readFileSync(resolve(process.cwd(), "src/index.auth-sw.js"), "utf8"));
|
|
224
|
+
upgraded.setWindowClient("client", `${sourcePrefix}page`);
|
|
225
|
+
const recoveredNew = await upgraded.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`, {
|
|
226
|
+
clientId: "client"
|
|
227
|
+
});
|
|
228
|
+
expect(recoveredNew.outboundRequest?.headers.get("Authorization")).toBe(
|
|
229
|
+
sourcePrefixes?.length === 0 ? null : "Bearer token-a"
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("3.61.0 skips empty request prefixes before and after restart", async () => {
|
|
234
|
+
const oldWorker = new AuthServiceWorkerHarness(undefined, oldWorkerSource);
|
|
235
|
+
await oldWorker.setConfig([scopedConfig([])], rules);
|
|
236
|
+
const alias = await oldWorker.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
237
|
+
const direct = await oldWorker.restart().dispatchFetch("https://upcdn.io/account-a/raw/example.jpg");
|
|
238
|
+
expect(alias.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
239
|
+
expect(direct.outboundRequest?.headers.has("Authorization")).toBe(false);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("3.61.0 still authenticates unrestricted configs from the new SDK", async () => {
|
|
243
|
+
const oldWorker = new AuthServiceWorkerHarness(undefined, oldWorkerSource);
|
|
244
|
+
await oldWorker.setConfig([scopedConfig(undefined)], rules);
|
|
245
|
+
const result = await oldWorker.dispatchFetch(`${mediaPrefix}account-a/raw/example.jpg`);
|
|
246
|
+
expect(result.outboundRequest?.headers.get("Authorization")).toBe("Bearer token-a");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
function scopedConfig(requestUrlPrefixes: string[] | undefined, sourceUrlPrefixes?: string[]): AuthSwConfigEntryDto {
|
|
251
|
+
return {
|
|
252
|
+
...authConfig("account-a/", "token-a"),
|
|
253
|
+
requestUrlPrefixes,
|
|
254
|
+
sourceUrlPrefixes,
|
|
255
|
+
urlPrefix: `${requestUrlPrefixes === undefined ? "" : "!bytescale-request-scoped!"}${
|
|
256
|
+
sourceUrlPrefixes === undefined ? "" : "!bytescale-source-scoped!"
|
|
257
|
+
}https://upcdn.io/account-a/`
|
|
258
|
+
};
|
|
259
|
+
}
|