@bytescale/sdk 3.58.0 → 3.59.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 (29) hide show
  1. package/dist/browser/cjs/main.js +719 -316
  2. package/dist/browser/esm/main.mjs +719 -316
  3. package/dist/node/cjs/main.js +123 -29
  4. package/dist/node/esm/main.mjs +123 -29
  5. package/dist/types/private/AuthSessionState.d.ts +7 -0
  6. package/dist/types/private/UploadManagerBase.d.ts +0 -1
  7. package/dist/types/private/dtos/AuthSwSetConfigDto.d.ts +1 -1
  8. package/dist/types/private/model/AuthManagerInterface.d.ts +1 -105
  9. package/dist/types/private/model/AuthSession.d.ts +12 -5
  10. package/dist/types/private/model/AuthSessionConfig.d.ts +3 -0
  11. package/dist/types/private/model/AuthSessionConfigAuto.d.ts +6 -0
  12. package/dist/types/private/model/AuthSessionConfigBase.d.ts +12 -0
  13. package/dist/types/private/model/AuthSessionConfigManual.d.ts +7 -0
  14. package/dist/types/private/model/BeginAuthSessionParams.d.ts +3 -0
  15. package/dist/types/private/model/BeginAuthSessionParamsOptions.d.ts +2 -0
  16. package/dist/types/private/model/BeginAuthSessionParamsV1.d.ts +11 -0
  17. package/dist/types/private/model/BeginAuthSessionParamsV2.d.ts +16 -0
  18. package/dist/types/private/model/NonEmptyArray.d.ts +1 -0
  19. package/dist/types/private/model/UrlRewriteRule.d.ts +6 -0
  20. package/dist/types/public/browser/AuthManagerBrowser.d.ts +27 -11
  21. package/dist/types/public/node/AuthManagerNode.d.ts +12 -2
  22. package/dist/types/public/shared/generated/runtime.d.ts +13 -6
  23. package/dist/worker/cjs/main.js +123 -29
  24. package/dist/worker/esm/main.mjs +123 -29
  25. package/package.json +1 -1
  26. package/tests/ApiClientAuth.test.ts +222 -0
  27. package/tests/AuthManagerBrowser.test.ts +301 -237
  28. package/tests/AuthServiceWorkerRewrite.test.ts +63 -2
  29. package/tests/UploadManagerAuth.test.ts +156 -0
@@ -1,10 +1,11 @@
1
1
  import { jest } from "@jest/globals";
2
2
  import { Response as NodeFetchResponse } from "node-fetch";
3
3
  import { AuthSessionState } from "../src/private/AuthSessionState";
4
+ import { AuthSessionConfigState } from "../src/private/model/AuthSession";
4
5
  import type {
5
- AuthManagerServiceWorkerConfig,
6
- AuthSwConfigEntryDto,
6
+ AuthSessionConfig,
7
7
  BeginAuthSessionParams,
8
+ BeginAuthSessionParamsV2,
8
9
  UrlRewriteRule
9
10
  } from "../src/index.browser";
10
11
 
@@ -18,14 +19,21 @@ interface AuthManagerApi {
18
19
  }
19
20
 
20
21
  interface AuthManagerInternals extends AuthManagerApi {
21
- refreshAccessToken: (
22
+ refreshAuthConfig: (
22
23
  session: NonNullable<ReturnType<typeof AuthSessionState.getSession>>,
23
- params: BeginAuthSessionParams
24
+ state: AuthSessionConfigState
24
25
  ) => Promise<void>;
25
26
  scheduler: { unschedule: (handle: number) => void };
26
27
  }
27
28
 
28
- describe("AuthManager browser service-worker config", () => {
29
+ const accountA = "A123abc";
30
+ const accountB = "B123abc";
31
+ const accountC = "C123abc";
32
+ const jwtA = "e30.e30.jwt-a";
33
+ const jwtB = "e30.e30.jwt-b";
34
+ const jwtC = "e30.e30.jwt-c";
35
+
36
+ describe("AuthManager browser multi-configuration sessions", () => {
29
37
  const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, "navigator");
30
38
  const originalFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
31
39
  const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -63,6 +71,12 @@ describe("AuthManager browser service-worker config", () => {
63
71
  serviceWorkerApi.register.mockClear();
64
72
  });
65
73
 
74
+ afterEach(async () => {
75
+ navigatorValue.serviceWorker = serviceWorkerApi;
76
+ await AuthManager.endAuthSession();
77
+ jest.restoreAllMocks();
78
+ });
79
+
66
80
  afterAll(() => {
67
81
  for (const [key, descriptor] of [
68
82
  ["fetch", originalFetch],
@@ -77,322 +91,372 @@ describe("AuthManager browser service-worker config", () => {
77
91
  }
78
92
  });
79
93
 
80
- afterEach(async () => {
81
- navigatorValue.serviceWorker = serviceWorkerApi;
82
- await AuthManager.endAuthSession();
83
- jest.restoreAllMocks();
84
- });
85
-
86
- test("retains the existing cookie fallback when no additional config is requested", async () => {
94
+ test("preserves the complete V1 cookie flow and legacy default token", async () => {
87
95
  delete navigatorValue.serviceWorker;
88
- const fetchApi = createPrimaryFetchApi();
96
+ const fetchApi = createFetchApi();
89
97
 
90
- await AuthManager.beginAuthSession(createParams(fetchApi));
98
+ await AuthManager.beginAuthSession(v1Params(fetchApi));
91
99
 
92
- expect((fetchApi.mock.calls[1][0] as string).endsWith("?set-cookie=true")).toBe(true);
93
- expect(postMessage).not.toHaveBeenCalled();
94
100
  expect(AuthManager.isAuthSessionReady()).toBe(true);
101
+ expect(AuthSessionState.getSession()?.accessToken).toBe("access-a");
102
+ expect(fetchUrls(fetchApi)).toEqual([
103
+ "https://app.example.com/auth-a",
104
+ `https://upcdn.io/api/v1/access_tokens/${accountA}?set-cookie=true`
105
+ ]);
106
+ expect(postMessage).not.toHaveBeenCalled();
107
+
108
+ await AuthManager.endAuthSession();
109
+ expect(fetchUrls(fetchApi).at(-1)).toBe(`https://upcdn.io/api/v1/access_tokens/${accountA}?set-cookie=true`);
95
110
  });
96
111
 
97
- test("retains the existing primary-only service-worker flow", async () => {
98
- const fetchApi = createPrimaryFetchApi();
112
+ test("preserves V1 service-worker auth and unsupported-browser cookie fallback", async () => {
113
+ const supportedFetch = createFetchApi();
114
+ await AuthManager.beginAuthSession({ ...v1Params(supportedFetch), serviceWorkerScript: "/auth-sw.js" });
99
115
 
100
- await AuthManager.beginAuthSession({
101
- ...createParams(fetchApi),
102
- serviceWorkerScript: "/bytescale-auth-sw.js"
103
- });
116
+ expect((postMessage.mock.calls.at(-1)?.[0] as any).config).toEqual([
117
+ {
118
+ expires: expect.any(Number),
119
+ headers: [{ key: "Authorization", value: `Bearer ${jwtA}` }],
120
+ sourceUrlPrefixes: undefined,
121
+ urlPrefix: `https://upcdn.io/${accountA}/`
122
+ }
123
+ ]);
124
+ expect(fetchUrls(supportedFetch)[1]).toContain("set-cookie=false");
125
+ await AuthManager.endAuthSession();
104
126
 
105
- expect(AuthManager.isAuthSessionReady()).toBe(true);
106
- expect(postMessage.mock.calls[0][0]).toEqual({
107
- config: [
108
- {
109
- expires: expect.any(Number),
110
- headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
111
- urlPrefix: "https://upcdn.io/account-a/"
112
- }
113
- ],
114
- type: "SET_BYTESCALE_AUTH_CONFIG"
115
- });
116
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
127
+ postMessage.mockClear();
128
+ delete navigatorValue.serviceWorker;
129
+ const fallbackFetch = createFetchApi();
130
+ await AuthManager.beginAuthSession({ ...v1Params(fallbackFetch), serviceWorkerScript: "/auth-sw.js" });
131
+
132
+ expect(fetchUrls(fallbackFetch)[1]).toContain("set-cookie=true");
133
+ expect(postMessage).not.toHaveBeenCalled();
117
134
  });
118
135
 
119
- test("merges the primary API/download context with additional download-only contexts", async () => {
120
- const fetchApi = createPrimaryFetchApi();
121
- const additionalConfig: AuthSwConfigEntryDto[] = [
136
+ test("initializes automatic and manual V2 configs and sends one aggregate worker model", async () => {
137
+ const fetchApi = createFetchApi();
138
+ const manualB = jest.fn(async () => jwtB);
139
+ const manualC = jest.fn(async () => jwtC);
140
+ const configs: [AuthSessionConfig, ...AuthSessionConfig[]] = [
141
+ {
142
+ accountId: accountA,
143
+ authConfigId: undefined,
144
+ authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Auth": "app-token" }),
145
+ authUrl: "https://app.example.com/auth-a",
146
+ sourceUrlPrefixes: ["https://app.example.com/"]
147
+ },
122
148
  {
123
- expires: Date.now() + 60_000,
124
- headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
125
- sourceUrlPrefixes: ["https://app.example.com/account-b/"],
126
- urlPrefix: "https://upcdn.io/account-b/"
149
+ accountId: accountB,
150
+ authConfigId: "customer-b",
151
+ getAuthorizationToken: manualB
127
152
  },
128
153
  {
129
- expires: undefined,
130
- headers: [{ key: "Authorization", value: "Bearer jwt-c" }],
131
- urlPrefix: "https://upcdn.io/account-c/"
154
+ accountId: accountC,
155
+ authConfigId: "cookie-c",
156
+ enableCookieAuth: true,
157
+ enableServiceWorkerAuth: false,
158
+ getAuthorizationToken: manualC
132
159
  }
133
160
  ];
161
+ const authConfigs = jest.fn(async () => configs);
134
162
  const urlRewriteRules: UrlRewriteRule[] = [
135
163
  {
136
- fromUrlPrefix: "https://app.example.com/__authenticated-download/",
137
- toUrlPrefix: "https://upcdn.io/account-b/"
164
+ fromUrlPrefix: "https://app.example.com/download/",
165
+ toUrlPrefix: `https://upcdn.io/${accountB}/`
138
166
  }
139
167
  ];
140
- const serviceWorkerConfig = jest.fn(
141
- async (): Promise<AuthManagerServiceWorkerConfig> => ({
142
- additionalConfig,
143
- sourceUrlPrefixes: ["https://app.example.com/"],
144
- urlRewriteRules
145
- })
146
- );
147
168
 
148
169
  await AuthManager.beginAuthSession({
149
- ...createParams(fetchApi),
150
- serviceWorkerConfig,
151
- serviceWorkerScript: "/bytescale-auth-sw.js"
170
+ authConfigs,
171
+ options: { fetchApi },
172
+ serviceWorkerScript: "/auth-sw.js",
173
+ urlRewriteRules
152
174
  });
153
175
 
154
176
  expect(AuthManager.isAuthSessionReady()).toBe(true);
155
- expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
156
- expect(postMessage.mock.calls[0][0]).toEqual({
177
+ expect(authConfigs).toHaveBeenCalledTimes(1);
178
+ expect(manualB).toHaveBeenCalledTimes(1);
179
+ expect(manualC).toHaveBeenCalledTimes(1);
180
+ const session = AuthSessionState.getSession();
181
+ expect(session?.authConfigs?.map(state => state.accessToken)).toEqual(["access-a", "access-b", "access-c"]);
182
+ expect(session?.accessToken).toBeUndefined();
183
+ expect(fetchUrls(fetchApi).filter(url => url.includes("set-cookie=true"))).toEqual([
184
+ `https://upcdn.io/api/v1/access_tokens/${accountC}?set-cookie=true`
185
+ ]);
186
+ expect(postMessage.mock.calls.at(-1)?.[0]).toEqual({
157
187
  config: [
158
188
  {
159
189
  expires: expect.any(Number),
160
- headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
190
+ headers: [{ key: "Authorization", value: `Bearer ${jwtA}` }],
161
191
  sourceUrlPrefixes: ["https://app.example.com/"],
162
- urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
192
+ urlPrefix: `!bytescale-source-scoped!https://upcdn.io/${accountA}/`
163
193
  },
164
194
  {
165
- ...additionalConfig[0],
166
- urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-b/"
167
- },
168
- additionalConfig[1]
195
+ expires: expect.any(Number),
196
+ headers: [{ key: "Authorization", value: `Bearer ${jwtB}` }],
197
+ sourceUrlPrefixes: undefined,
198
+ urlPrefix: `https://upcdn.io/${accountB}/`
199
+ }
169
200
  ],
170
201
  type: "SET_BYTESCALE_AUTH_CONFIG",
171
202
  urlRewriteRules
172
203
  });
173
- expect(additionalConfig[0].urlPrefix).toBe("https://upcdn.io/account-b/");
174
- expect(AuthSessionState.getSession()?.accessToken).toBe("access-a");
175
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
176
-
177
- await AuthManager.endAuthSession();
178
-
179
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "DELETE"]);
180
- expect(postMessage.mock.calls[1][0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
181
204
  });
182
205
 
183
- test("refreshes additional rules independently while retaining the primary context", async () => {
184
- const fetchApi = createPrimaryFetchApi();
185
- const initialAdditionalConfig: AuthSwConfigEntryDto[] = [
186
- {
187
- expires: Date.now() + 21_000,
188
- headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
189
- urlPrefix: "https://upcdn.io/account-b/"
190
- }
191
- ];
192
- const serviceWorkerConfig = jest
193
- .fn<() => Promise<AuthManagerServiceWorkerConfig>>()
194
- .mockResolvedValueOnce({
195
- additionalConfig: initialAdditionalConfig,
196
- sourceUrlPrefixes: ["https://app.example.com/initial/"],
197
- urlRewriteRules: [
198
- {
199
- fromUrlPrefix: "https://app.example.com/download/",
200
- toUrlPrefix: "https://upcdn.io/account-b/"
201
- }
202
- ]
203
- })
204
- .mockResolvedValueOnce({
205
- additionalConfig: [],
206
- sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
207
- urlRewriteRules: [
208
- {
209
- fromUrlPrefix: "https://app.example.com/download/",
210
- toUrlPrefix: "https://upcdn.io/account-c/"
211
- }
212
- ]
213
- });
206
+ test("supports a manual cookie-only V2 config without a service worker", async () => {
207
+ delete navigatorValue.serviceWorker;
208
+ const fetchApi = createFetchApi();
209
+ const provider = jest.fn(async () => jwtA);
214
210
 
215
211
  await AuthManager.beginAuthSession({
216
- ...createParams(fetchApi),
217
- serviceWorkerConfig,
218
- serviceWorkerScript: "/bytescale-auth-sw.js"
219
- });
220
- await new Promise(resolve => setTimeout(resolve, 1_500));
221
-
222
- expect(serviceWorkerConfig).toHaveBeenCalledTimes(2);
223
- expect(postMessage.mock.calls[1][0]).toEqual({
224
- config: [
212
+ authConfigs: async () => [
225
213
  {
226
- expires: expect.any(Number),
227
- headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
228
- sourceUrlPrefixes: ["https://app.example.com/refreshed/"],
229
- urlPrefix: "!bytescale-source-scoped!https://upcdn.io/account-a/"
214
+ accountId: accountA,
215
+ authConfigId: undefined,
216
+ enableCookieAuth: true,
217
+ enableServiceWorkerAuth: false,
218
+ getAuthorizationToken: provider
230
219
  }
231
220
  ],
232
- type: "SET_BYTESCALE_AUTH_CONFIG",
233
- urlRewriteRules: [
234
- {
235
- fromUrlPrefix: "https://app.example.com/download/",
236
- toUrlPrefix: "https://upcdn.io/account-c/"
237
- }
238
- ]
221
+ options: { fetchApi }
239
222
  });
240
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
223
+
241
224
  expect(AuthManager.isAuthSessionReady()).toBe(true);
225
+ expect(provider).toHaveBeenCalledTimes(1);
226
+ expect(fetchUrls(fetchApi)).toEqual([`https://upcdn.io/api/v1/access_tokens/${accountA}?set-cookie=true`]);
227
+ expect(postMessage).not.toHaveBeenCalled();
242
228
  });
243
229
 
244
- test("retains additional rules when the primary JWT refreshes", async () => {
245
- const fetchApi = createPrimaryFetchApi();
246
- const additionalConfig: AuthSwConfigEntryDto[] = [
247
- {
248
- expires: undefined,
249
- headers: [{ key: "Authorization", value: "Bearer jwt-b" }],
250
- urlPrefix: "https://upcdn.io/account-b/"
251
- }
252
- ];
253
- const serviceWorkerConfig = jest.fn(
254
- async (): Promise<AuthManagerServiceWorkerConfig> => ({
255
- additionalConfig,
256
- urlRewriteRules: [
257
- {
258
- fromUrlPrefix: "https://app.example.com/download/",
259
- toUrlPrefix: "https://upcdn.io/account-b/"
260
- }
261
- ]
262
- })
263
- );
230
+ test("refreshes configs independently and preserves a still-valid token after failure", async () => {
231
+ jest.spyOn(console, "warn").mockImplementation(() => {});
232
+ const fetchApi = createFetchApi();
233
+ const providerA = jest.fn(async () => jwtA);
234
+ const providerB = jest.fn<() => Promise<string>>().mockResolvedValueOnce(jwtB).mockRejectedValueOnce("offline");
264
235
 
265
236
  await AuthManager.beginAuthSession({
266
- ...createParams(fetchApi),
267
- serviceWorkerConfig,
268
- serviceWorkerScript: "/bytescale-auth-sw.js"
237
+ authConfigs: async () => [apiOnlyConfig(undefined, accountA, providerA), apiOnlyConfig("b", accountB, providerB)],
238
+ options: { fetchApi }
269
239
  });
270
-
271
240
  const session = AuthSessionState.getSession();
272
- if (session?.accessTokenRefreshHandle === undefined) {
273
- throw new Error("Expected the primary access-token refresh to be scheduled.");
241
+ const stateB = session?.authConfigs?.[1];
242
+ if (session === undefined || stateB === undefined) {
243
+ throw new Error("Expected initialized auth state.");
274
244
  }
275
- const authManagerInternals = AuthManager as AuthManagerInternals;
276
- authManagerInternals.scheduler.unschedule(session.accessTokenRefreshHandle);
277
- await authManagerInternals.refreshAccessToken(session, session.params);
245
+ const previousExpiry = stateB.expiresAt;
246
+ const internals = AuthManager as AuthManagerInternals;
247
+ await internals.refreshAuthConfig(session, stateB);
248
+
249
+ expect(providerA).toHaveBeenCalledTimes(1);
250
+ expect(providerB).toHaveBeenCalledTimes(2);
251
+ expect(stateB.accessToken).toBe("access-b");
252
+ expect(stateB.jwt).toBe(jwtB);
253
+ expect(stateB.expiresAt).toBe(previousExpiry);
254
+ expect(AuthManager.isAuthSessionReady()).toBe(true);
255
+ });
278
256
 
279
- expect(serviceWorkerConfig).toHaveBeenCalledTimes(1);
280
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT", "GET", "PUT"]);
281
- expect(postMessage.mock.calls[1][0]).toEqual({
282
- config: [
257
+ test("clears the cookie-enabled config and the complete worker config on end", async () => {
258
+ const fetchApi = createFetchApi();
259
+ await AuthManager.beginAuthSession({
260
+ authConfigs: async () => [
283
261
  {
284
- expires: expect.any(Number),
285
- headers: [{ key: "Authorization", value: "Bearer jwt-a" }],
286
- urlPrefix: "https://upcdn.io/account-a/"
262
+ accountId: accountA,
263
+ authConfigId: "worker",
264
+ getAuthorizationToken: async () => jwtA
287
265
  },
288
- additionalConfig[0]
289
- ],
290
- type: "SET_BYTESCALE_AUTH_CONFIG",
291
- urlRewriteRules: [
292
266
  {
293
- fromUrlPrefix: "https://app.example.com/download/",
294
- toUrlPrefix: "https://upcdn.io/account-b/"
267
+ accountId: accountB,
268
+ authConfigId: "cookie",
269
+ enableCookieAuth: true,
270
+ enableServiceWorkerAuth: false,
271
+ getAuthorizationToken: async () => jwtB
295
272
  }
296
- ]
273
+ ],
274
+ options: { fetchApi },
275
+ serviceWorkerScript: "/auth-sw.js"
297
276
  });
298
- });
299
-
300
- test("rejects malformed URL rewrite rules", async () => {
301
- jest.spyOn(console, "warn").mockImplementation(() => {});
302
- const fetchApi = createPrimaryFetchApi();
303
277
 
304
- await AuthManager.beginAuthSession({
305
- ...createParams(fetchApi),
306
- serviceWorkerConfig: async (): Promise<AuthManagerServiceWorkerConfig> => ({
307
- additionalConfig: [],
308
- urlRewriteRules: [{ fromUrlPrefix: "https://app.example.com/download/" }] as UrlRewriteRule[]
309
- }),
310
- serviceWorkerScript: "/bytescale-auth-sw.js"
311
- });
278
+ await AuthManager.endAuthSession();
312
279
 
313
- expect(postMessage).not.toHaveBeenCalled();
314
- expect(AuthManager.isAuthSessionReady()).toBe(false);
280
+ expect(fetchApi.mock.calls.filter(([, init]) => init?.method === "DELETE")).toHaveLength(1);
281
+ expect(fetchUrls(fetchApi).at(-1)).toBe(`https://upcdn.io/api/v1/access_tokens/${accountB}?set-cookie=true`);
282
+ expect(postMessage.mock.calls.at(-1)?.[0]).toEqual({ config: [], type: "SET_BYTESCALE_AUTH_CONFIG" });
283
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
284
+ await expect(AuthManager.endAuthSession()).resolves.toBeUndefined();
315
285
  });
316
286
 
317
- test("fails closed until the initial service-worker config callback succeeds", async () => {
318
- jest.spyOn(console, "warn").mockImplementation(() => {});
319
- const fetchApi = createPrimaryFetchApi();
287
+ test.each([
288
+ {
289
+ name: "empty auth config array",
290
+ params: (fetchApi: FetchApi) =>
291
+ ({ authConfigs: async () => [], options: { fetchApi } } as unknown as BeginAuthSessionParamsV2),
292
+ error: "non-empty array"
293
+ },
294
+ {
295
+ name: "duplicate named IDs",
296
+ params: (fetchApi: FetchApi) =>
297
+ v2Params(fetchApi, [
298
+ apiOnlyConfig("same", accountA, async () => jwtA),
299
+ apiOnlyConfig("same", accountB, async () => jwtB)
300
+ ]),
301
+ error: "Duplicate auth configuration ID"
302
+ },
303
+ {
304
+ name: "multiple defaults",
305
+ params: (fetchApi: FetchApi) =>
306
+ v2Params(fetchApi, [
307
+ apiOnlyConfig(undefined, accountA, async () => jwtA),
308
+ apiOnlyConfig(undefined, accountB, async () => jwtB)
309
+ ]),
310
+ error: "Only one default"
311
+ },
312
+ {
313
+ name: "multiple cookie configs",
314
+ params: (fetchApi: FetchApi) =>
315
+ v2Params(fetchApi, [
316
+ { ...apiOnlyConfig("a", accountA, async () => jwtA), enableCookieAuth: true },
317
+ { ...apiOnlyConfig("b", accountB, async () => jwtB), enableCookieAuth: true }
318
+ ]),
319
+ error: "Only one auth configuration may enable cookie"
320
+ },
321
+ {
322
+ name: "missing service-worker script",
323
+ params: (fetchApi: FetchApi) =>
324
+ v2Params(fetchApi, [{ ...apiOnlyConfig("a", accountA, async () => jwtA), enableServiceWorkerAuth: true }]),
325
+ error: "serviceWorkerScript"
326
+ },
327
+ {
328
+ name: "duplicate worker destination",
329
+ params: (fetchApi: FetchApi): BeginAuthSessionParamsV2 => ({
330
+ ...v2Params(fetchApi, [
331
+ { ...apiOnlyConfig("a", accountA, async () => jwtA), enableServiceWorkerAuth: true },
332
+ { ...apiOnlyConfig("b", accountA, async () => jwtB), enableServiceWorkerAuth: true }
333
+ ]),
334
+ serviceWorkerScript: "/auth-sw.js"
335
+ }),
336
+ error: "same URL prefix"
337
+ },
338
+ {
339
+ name: "invalid account ID",
340
+ params: (fetchApi: FetchApi) => v2Params(fetchApi, [apiOnlyConfig("a", "A12/abc", async () => jwtA)]),
341
+ error: "Invalid Bytescale account ID"
342
+ },
343
+ {
344
+ name: "overlapping cookie and worker accounts",
345
+ params: (fetchApi: FetchApi): BeginAuthSessionParamsV2 => ({
346
+ ...v2Params(fetchApi, [
347
+ { ...apiOnlyConfig("cookie", accountA, async () => jwtA), enableCookieAuth: true },
348
+ { ...apiOnlyConfig("worker", accountA, async () => jwtB), enableServiceWorkerAuth: true }
349
+ ]),
350
+ serviceWorkerScript: "/auth-sw.js"
351
+ }),
352
+ error: "Cookie and service-worker authentication"
353
+ }
354
+ ])("rejects $name before invoking a provider", async ({ params, error }) => {
355
+ const fetchApi = createFetchApi();
320
356
 
321
- await AuthManager.beginAuthSession({
322
- ...createParams(fetchApi),
323
- serviceWorkerConfig: async () => null as unknown as AuthManagerServiceWorkerConfig,
324
- serviceWorkerScript: "/bytescale-auth-sw.js"
325
- });
357
+ await expect(AuthManager.beginAuthSession(params(fetchApi))).rejects.toThrow(error);
326
358
 
327
- expect(fetchApi.mock.calls.map(([, init]) => init?.method)).toEqual(["GET", "PUT"]);
359
+ expect(fetchApi).not.toHaveBeenCalled();
328
360
  expect(postMessage).not.toHaveBeenCalled();
329
- expect(AuthManager.isAuthSessionReady()).toBe(false);
361
+ expect(AuthManager.isAuthSessionActive()).toBe(false);
330
362
  });
331
363
 
332
- test("requires a service-worker script for additional configuration", async () => {
333
- const fetchApi = createPrimaryFetchApi();
334
- const serviceWorkerConfig = jest.fn(
335
- async (): Promise<AuthManagerServiceWorkerConfig> => ({
336
- additionalConfig: []
337
- })
338
- );
364
+ test("rejects V2 service-worker features when the browser cannot enforce them", async () => {
365
+ delete navigatorValue.serviceWorker;
366
+ const fetchApi = createFetchApi();
367
+ const provider = jest.fn(async () => jwtA);
339
368
 
340
- await expect(AuthManager.beginAuthSession({ ...createParams(fetchApi), serviceWorkerConfig })).rejects.toThrow(
341
- "'serviceWorkerScript' field is required"
342
- );
369
+ await expect(
370
+ AuthManager.beginAuthSession({
371
+ authConfigs: async () => [
372
+ {
373
+ accountId: accountA,
374
+ authConfigId: undefined,
375
+ getAuthorizationToken: provider
376
+ }
377
+ ],
378
+ options: { fetchApi },
379
+ serviceWorkerScript: "/auth-sw.js"
380
+ })
381
+ ).rejects.toThrow("does not support");
343
382
 
344
- expect(serviceWorkerConfig).not.toHaveBeenCalled();
383
+ expect(provider).not.toHaveBeenCalled();
345
384
  expect(fetchApi).not.toHaveBeenCalled();
346
- expect(AuthManager.isAuthSessionActive()).toBe(false);
347
385
  });
348
386
 
349
- test("rejects additional configuration when service workers are unavailable", async () => {
350
- delete navigatorValue.serviceWorker;
351
- const fetchApi = createPrimaryFetchApi();
352
- const serviceWorkerConfig = jest.fn(
353
- async (): Promise<AuthManagerServiceWorkerConfig> => ({
354
- additionalConfig: []
355
- })
356
- );
387
+ test.each(["", "not-a-jwt"])("rejects a malformed manual token and disposes the partial V2 session", async token => {
388
+ jest.spyOn(console, "warn").mockImplementation(() => {});
389
+ const fetchApi = createFetchApi();
357
390
 
358
391
  await expect(
359
392
  AuthManager.beginAuthSession({
360
- ...createParams(fetchApi),
361
- serviceWorkerConfig,
362
- serviceWorkerScript: "/bytescale-auth-sw.js"
393
+ authConfigs: async () => [apiOnlyConfig(undefined, accountA, async () => token)],
394
+ options: { fetchApi }
363
395
  })
364
- ).rejects.toThrow("requires service workers");
396
+ ).rejects.toThrow("malformed");
365
397
 
366
- expect(serviceWorkerConfig).not.toHaveBeenCalled();
367
398
  expect(fetchApi).not.toHaveBeenCalled();
368
399
  expect(AuthManager.isAuthSessionActive()).toBe(false);
369
400
  });
370
401
  });
371
402
 
372
- function createParams(fetchApi: FetchApi): BeginAuthSessionParams {
403
+ function apiOnlyConfig(
404
+ authConfigId: string | undefined,
405
+ accountId: string,
406
+ provider: () => Promise<string>
407
+ ): AuthSessionConfig {
373
408
  return {
374
- accountId: "account-a",
375
- authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Authorization": "app-token" }),
376
- authUrl: "https://app.example.com/auth",
377
- options: { fetchApi }
409
+ accountId,
410
+ authConfigId,
411
+ enableServiceWorkerAuth: false,
412
+ getAuthorizationToken: provider
378
413
  };
379
414
  }
380
415
 
381
- function createPrimaryFetchApi(): jest.MockedFunction<FetchApi> {
382
- return jest.fn<FetchApi>(async (_input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
383
- switch (init?.method) {
384
- case "GET":
385
- return new NodeFetchResponse("jwt-a", {
386
- headers: { "Content-Type": "text/plain" }
387
- }) as unknown as Response;
388
- case "PUT":
389
- return new NodeFetchResponse(
390
- JSON.stringify({ accessToken: "access-a", ttlSeconds: 3600 })
391
- ) as unknown as Response;
392
- case "DELETE":
393
- return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
394
- default:
395
- throw new Error(`Unexpected method: ${init?.method ?? "undefined"}`);
416
+ function createFetchApi(): jest.MockedFunction<FetchApi> {
417
+ return jest.fn<FetchApi>(async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
418
+ const url = inputUrl(input);
419
+ if (init?.method === "GET") {
420
+ const suffix = url.split("auth-")[1] ?? "a";
421
+ const jwt = suffix === "a" ? jwtA : suffix === "b" ? jwtB : jwtC;
422
+ return new NodeFetchResponse(jwt, {
423
+ headers: { "Content-Type": "text/plain" }
424
+ }) as unknown as Response;
425
+ }
426
+ if (init?.method === "PUT") {
427
+ const accountId = url.split("/access_tokens/")[1]?.split("?")[0];
428
+ const suffix = accountId === accountA ? "a" : accountId === accountB ? "b" : "c";
429
+ return new NodeFetchResponse(
430
+ JSON.stringify({ accessToken: `access-${suffix}`, ttlSeconds: 3600 })
431
+ ) as unknown as Response;
432
+ }
433
+ if (init?.method === "DELETE") {
434
+ return new NodeFetchResponse(null, { status: 204 }) as unknown as Response;
396
435
  }
436
+ throw new Error(`Unexpected request: ${init?.method ?? "undefined"} ${url}`);
397
437
  });
398
438
  }
439
+
440
+ function fetchUrls(fetchApi: jest.MockedFunction<FetchApi>): string[] {
441
+ return fetchApi.mock.calls.map(([input]) => inputUrl(input));
442
+ }
443
+
444
+ function v1Params(fetchApi: FetchApi): BeginAuthSessionParams {
445
+ return {
446
+ accountId: accountA,
447
+ authHeaders: async (): Promise<Record<string, string>> => ({ "X-App-Authorization": "app-token" }),
448
+ authUrl: "https://app.example.com/auth-a",
449
+ options: { fetchApi }
450
+ };
451
+ }
452
+
453
+ function inputUrl(input: RequestInfo | URL): string {
454
+ return typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
455
+ }
456
+
457
+ function v2Params(fetchApi: FetchApi, configs: AuthSessionConfig[]): BeginAuthSessionParamsV2 {
458
+ return {
459
+ authConfigs: async () => configs as [AuthSessionConfig, ...AuthSessionConfig[]],
460
+ options: { fetchApi }
461
+ };
462
+ }