@bytescale/sdk 3.60.0 → 3.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser/cjs/main.js +494 -156
- package/dist/browser/esm/main.mjs +494 -156
- 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/model/AuthSession.d.ts +4 -0
- package/dist/types/public/browser/AuthManagerBrowser.d.ts +1 -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 +101 -2
- package/tests/AuthManagerBrowser.test.ts +47 -0
- package/tests/Scheduler.test.ts +135 -0
- package/tests/UploadManagerAuth.test.ts +30 -0
|
@@ -60,6 +60,80 @@ describe("API-client AuthManager configuration", () => {
|
|
|
60
60
|
expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-refreshed");
|
|
61
61
|
});
|
|
62
62
|
|
|
63
|
+
test("awaits manager-owned authentication at the expiry boundary", async () => {
|
|
64
|
+
const configState = state("customer", accountA, "jwt-old", "access-old");
|
|
65
|
+
configState.expiresAt = Date.now();
|
|
66
|
+
let completeRefresh = (): void => {
|
|
67
|
+
throw new Error("Refresh completion callback was not initialized.");
|
|
68
|
+
};
|
|
69
|
+
const authenticationPromise = new Promise<void>(resolve => {
|
|
70
|
+
completeRefresh = () => {
|
|
71
|
+
configState.accessToken = "access-new";
|
|
72
|
+
configState.expiresAt = Date.now() + 60_000;
|
|
73
|
+
configState.jwt = "jwt-new";
|
|
74
|
+
configState.refreshPromise = undefined;
|
|
75
|
+
resolve();
|
|
76
|
+
};
|
|
77
|
+
});
|
|
78
|
+
configState.authenticationPromise = authenticationPromise;
|
|
79
|
+
configState.refreshPromise = authenticationPromise;
|
|
80
|
+
setModernSession([configState]);
|
|
81
|
+
const { api, fetchApi } = createApi({ apiKey: apiKeyA, authConfigId: "customer" });
|
|
82
|
+
|
|
83
|
+
const requests = [api.get(), api.get()];
|
|
84
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
85
|
+
completeRefresh();
|
|
86
|
+
await Promise.all(requests);
|
|
87
|
+
|
|
88
|
+
expect(requestHeaders(fetchApi, 0).get("Authorization-Token")).toBe("access-new");
|
|
89
|
+
expect(requestHeaders(fetchApi, 1).get("Authorization-Token")).toBe("access-new");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("observes authentication that starts while resolving custom headers", async () => {
|
|
93
|
+
const configState = state("customer", accountB, "jwt-old", "access-old");
|
|
94
|
+
let completeRefresh = (): void => {
|
|
95
|
+
throw new Error("Refresh completion callback was not initialized.");
|
|
96
|
+
};
|
|
97
|
+
const authenticationPromise = new Promise<void>(resolve => {
|
|
98
|
+
completeRefresh = () => {
|
|
99
|
+
configState.accessToken = "access-new";
|
|
100
|
+
configState.expiresAt = Date.now() + 60_000;
|
|
101
|
+
configState.jwt = "jwt-new";
|
|
102
|
+
configState.refreshPromise = undefined;
|
|
103
|
+
resolve();
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
setModernSession([configState]);
|
|
107
|
+
const { api, fetchApi } = createApi({
|
|
108
|
+
authConfigId: "customer",
|
|
109
|
+
headers: async (): Promise<Record<string, string>> => {
|
|
110
|
+
configState.authenticationPromise = authenticationPromise;
|
|
111
|
+
configState.refreshPromise = authenticationPromise;
|
|
112
|
+
return {};
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const request = api.get();
|
|
117
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
118
|
+
completeRefresh();
|
|
119
|
+
await request;
|
|
120
|
+
|
|
121
|
+
expect(requestHeaders(fetchApi).get("Authorization")).toBe("Bearer jwt-new");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("never invokes the token provider when authentication is not in progress", async () => {
|
|
125
|
+
const getAuthorizationToken = jest.fn(async () => "jwt-new");
|
|
126
|
+
const configState = state("customer", accountB, "jwt-old", "access-old", getAuthorizationToken);
|
|
127
|
+
configState.expiresAt = Date.now();
|
|
128
|
+
setModernSession([configState]);
|
|
129
|
+
const { api, fetchApi } = createApi({ authConfigId: "customer" });
|
|
130
|
+
|
|
131
|
+
await expect(api.get()).rejects.toThrow("not ready");
|
|
132
|
+
|
|
133
|
+
expect(getAuthorizationToken).not.toHaveBeenCalled();
|
|
134
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
63
137
|
test("uses the raw JWT as sole authentication for an API-key-less named client", async () => {
|
|
64
138
|
setModernSession([state("customer", accountB, "jwt-b", "access-b")]);
|
|
65
139
|
const { api, fetchApi } = createApi({ authConfigId: "customer" });
|
|
@@ -126,6 +200,29 @@ describe("API-client AuthManager configuration", () => {
|
|
|
126
200
|
expect(requestHeaders(optedOut.fetchApi).get("Authorization")).toBe(`Bearer ${apiKeyA}`);
|
|
127
201
|
});
|
|
128
202
|
|
|
203
|
+
test("fails closed for an expired V1 default instead of falling back to its API key", async () => {
|
|
204
|
+
const expired = state(undefined, accountA, "jwt-a", "access-a");
|
|
205
|
+
expired.expiresAt = Date.now() - 1;
|
|
206
|
+
AuthSessionState.setSession({
|
|
207
|
+
accessToken: undefined,
|
|
208
|
+
accessTokenRefreshHandle: undefined,
|
|
209
|
+
authConfigs: [expired],
|
|
210
|
+
authServiceWorker: undefined,
|
|
211
|
+
isActive: true,
|
|
212
|
+
isReady: false,
|
|
213
|
+
params: {
|
|
214
|
+
accountId: accountA,
|
|
215
|
+
authHeaders: async (): Promise<Record<string, string>> => ({}),
|
|
216
|
+
authUrl: "https://app.example.com/auth"
|
|
217
|
+
},
|
|
218
|
+
serviceWorkerConfigured: false
|
|
219
|
+
});
|
|
220
|
+
const { api, fetchApi } = createApi({ apiKey: apiKeyA });
|
|
221
|
+
|
|
222
|
+
await expect(api.get()).rejects.toThrow("not ready");
|
|
223
|
+
expect(fetchApi).not.toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
129
226
|
test("recognizes a 3.54 session as the default supplemental token", async () => {
|
|
130
227
|
AuthSessionState.setSession({
|
|
131
228
|
accessToken: "legacy-access",
|
|
@@ -184,16 +281,18 @@ function state(
|
|
|
184
281
|
authConfigId: string | undefined,
|
|
185
282
|
accountId: string,
|
|
186
283
|
jwt: string,
|
|
187
|
-
accessToken: string
|
|
284
|
+
accessToken: string,
|
|
285
|
+
getAuthorizationToken: () => Promise<string> = async () => jwt
|
|
188
286
|
): AuthSessionConfigState {
|
|
189
287
|
const config: AuthSessionConfig = {
|
|
190
288
|
accountId,
|
|
191
289
|
authConfigId,
|
|
192
290
|
enableServiceWorkerAuth: false,
|
|
193
|
-
getAuthorizationToken
|
|
291
|
+
getAuthorizationToken
|
|
194
292
|
};
|
|
195
293
|
return {
|
|
196
294
|
accessToken,
|
|
295
|
+
authenticationPromise: Promise.resolve(),
|
|
197
296
|
config,
|
|
198
297
|
expiresAt: Date.now() + 60_000,
|
|
199
298
|
jwt,
|
|
@@ -308,6 +308,53 @@ describe("AuthManager browser multi-configuration sessions", () => {
|
|
|
308
308
|
expect(AuthManager.isAuthSessionReady()).toBe(true);
|
|
309
309
|
});
|
|
310
310
|
|
|
311
|
+
test("deduplicates concurrent refreshes for the same auth config", async () => {
|
|
312
|
+
const fetchApi = createFetchApi();
|
|
313
|
+
let resolveRefresh = (_jwt: string): void => {
|
|
314
|
+
throw new Error("Refresh resolver was not initialized.");
|
|
315
|
+
};
|
|
316
|
+
const refreshResult = new Promise<string>(resolve => {
|
|
317
|
+
resolveRefresh = resolve;
|
|
318
|
+
});
|
|
319
|
+
let markRefreshStarted = (): void => {
|
|
320
|
+
throw new Error("Refresh-start resolver was not initialized.");
|
|
321
|
+
};
|
|
322
|
+
const refreshStarted = new Promise<void>(resolve => {
|
|
323
|
+
markRefreshStarted = resolve;
|
|
324
|
+
});
|
|
325
|
+
const provider = jest
|
|
326
|
+
.fn<() => Promise<string>>()
|
|
327
|
+
.mockResolvedValueOnce(jwtA)
|
|
328
|
+
.mockImplementationOnce(async () => {
|
|
329
|
+
markRefreshStarted();
|
|
330
|
+
return await refreshResult;
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
await AuthManager.beginAuthSession({
|
|
334
|
+
authConfigs: async () => [apiOnlyConfig("customer", accountA, provider)],
|
|
335
|
+
options: { fetchApi }
|
|
336
|
+
});
|
|
337
|
+
const session = AuthSessionState.getSession();
|
|
338
|
+
const configState = session?.authConfigs?.[0];
|
|
339
|
+
if (session === undefined || configState === undefined) {
|
|
340
|
+
throw new Error("Expected initialized auth state.");
|
|
341
|
+
}
|
|
342
|
+
const internals = AuthManager as AuthManagerInternals;
|
|
343
|
+
|
|
344
|
+
const firstRefresh = internals.refreshAuthConfig(session, configState);
|
|
345
|
+
const secondRefresh = internals.refreshAuthConfig(session, configState);
|
|
346
|
+
await refreshStarted;
|
|
347
|
+
expect(provider).toHaveBeenCalledTimes(2);
|
|
348
|
+
expect(configState.authenticationPromise).toBe(configState.refreshPromise);
|
|
349
|
+
|
|
350
|
+
resolveRefresh(jwtB);
|
|
351
|
+
await Promise.all([firstRefresh, secondRefresh]);
|
|
352
|
+
|
|
353
|
+
expect(configState.jwt).toBe(jwtB);
|
|
354
|
+
expect(configState.authenticationPromise).toBeDefined();
|
|
355
|
+
expect(configState.refreshPromise).toBeUndefined();
|
|
356
|
+
});
|
|
357
|
+
|
|
311
358
|
test("clears the cookie-enabled config and the complete worker config on end", async () => {
|
|
312
359
|
const fetchApi = createFetchApi();
|
|
313
360
|
await AuthManager.beginAuthSession({
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { jest } from "@jest/globals";
|
|
2
|
+
import { Scheduler } from "../src/private/Scheduler";
|
|
3
|
+
|
|
4
|
+
class TestEventTarget {
|
|
5
|
+
private readonly listeners = new Map<string, Set<EventListener>>();
|
|
6
|
+
visibilityState: DocumentVisibilityState = "visible";
|
|
7
|
+
|
|
8
|
+
addEventListener(type: string, listener: EventListener): void {
|
|
9
|
+
const listeners = this.listeners.get(type) ?? new Set<EventListener>();
|
|
10
|
+
listeners.add(listener);
|
|
11
|
+
this.listeners.set(type, listeners);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
removeEventListener(type: string, listener: EventListener): void {
|
|
15
|
+
this.listeners.get(type)?.delete(listener);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
dispatch(type: string): void {
|
|
19
|
+
for (const listener of this.listeners.get(type) ?? []) {
|
|
20
|
+
listener(new Event(type));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
listenerCount(type: string): number {
|
|
25
|
+
return this.listeners.get(type)?.size ?? 0;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("Scheduler", () => {
|
|
30
|
+
const originalDocument = Object.getOwnPropertyDescriptor(globalThis, "document");
|
|
31
|
+
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
|
|
32
|
+
let documentEvents: TestEventTarget;
|
|
33
|
+
let windowEvents: TestEventTarget;
|
|
34
|
+
let now: number;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
jest.useFakeTimers();
|
|
38
|
+
now = 1000;
|
|
39
|
+
jest.spyOn(Date, "now").mockImplementation(() => now);
|
|
40
|
+
documentEvents = new TestEventTarget();
|
|
41
|
+
windowEvents = new TestEventTarget();
|
|
42
|
+
Object.defineProperty(globalThis, "document", { configurable: true, value: documentEvents });
|
|
43
|
+
Object.defineProperty(globalThis, "window", { configurable: true, value: windowEvents });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
jest.useRealTimers();
|
|
48
|
+
jest.restoreAllMocks();
|
|
49
|
+
restoreGlobal("document", originalDocument);
|
|
50
|
+
restoreGlobal("window", originalWindow);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test.each(["focus", "online", "pageshow"])("runs overdue callbacks immediately on %s", eventName => {
|
|
54
|
+
const scheduler = new Scheduler();
|
|
55
|
+
const callback = jest.fn();
|
|
56
|
+
scheduler.schedule(2000, callback);
|
|
57
|
+
|
|
58
|
+
now = 3000;
|
|
59
|
+
windowEvents.dispatch(eventName);
|
|
60
|
+
|
|
61
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("runs overdue callbacks when a visible document resumes", () => {
|
|
65
|
+
const scheduler = new Scheduler();
|
|
66
|
+
const callback = jest.fn();
|
|
67
|
+
scheduler.schedule(2000, callback);
|
|
68
|
+
|
|
69
|
+
now = 3000;
|
|
70
|
+
documentEvents.visibilityState = "hidden";
|
|
71
|
+
documentEvents.dispatch("visibilitychange");
|
|
72
|
+
expect(callback).not.toHaveBeenCalled();
|
|
73
|
+
|
|
74
|
+
documentEvents.visibilityState = "visible";
|
|
75
|
+
documentEvents.dispatch("visibilitychange");
|
|
76
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("runs scheduled callbacks immediately after a material backwards clock change", () => {
|
|
80
|
+
const scheduler = new Scheduler();
|
|
81
|
+
const callback = jest.fn();
|
|
82
|
+
now = 10_000;
|
|
83
|
+
scheduler.schedule(20_000, callback);
|
|
84
|
+
|
|
85
|
+
now = 8000;
|
|
86
|
+
documentEvents.dispatch("resume");
|
|
87
|
+
|
|
88
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("retains normal polling behavior", () => {
|
|
92
|
+
const scheduler = new Scheduler();
|
|
93
|
+
const callback = jest.fn();
|
|
94
|
+
scheduler.schedule(2000, callback);
|
|
95
|
+
|
|
96
|
+
now = 1999;
|
|
97
|
+
jest.advanceTimersByTime(1000);
|
|
98
|
+
expect(callback).not.toHaveBeenCalled();
|
|
99
|
+
|
|
100
|
+
now = 2000;
|
|
101
|
+
jest.advanceTimersByTime(1000);
|
|
102
|
+
expect(callback).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("does not refresh early for a small clock correction", () => {
|
|
106
|
+
const scheduler = new Scheduler();
|
|
107
|
+
const callback = jest.fn();
|
|
108
|
+
const handle = scheduler.schedule(20_000, callback);
|
|
109
|
+
|
|
110
|
+
now = 500;
|
|
111
|
+
windowEvents.dispatch("focus");
|
|
112
|
+
|
|
113
|
+
expect(callback).not.toHaveBeenCalled();
|
|
114
|
+
scheduler.unschedule(handle);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("removes lifecycle listeners after the final callback is removed", () => {
|
|
118
|
+
const scheduler = new Scheduler();
|
|
119
|
+
const handle = scheduler.schedule(20_000, jest.fn());
|
|
120
|
+
|
|
121
|
+
expect(windowEvents.listenerCount("focus")).toBe(1);
|
|
122
|
+
expect(documentEvents.listenerCount("resume")).toBe(1);
|
|
123
|
+
scheduler.unschedule(handle);
|
|
124
|
+
expect(windowEvents.listenerCount("focus")).toBe(0);
|
|
125
|
+
expect(documentEvents.listenerCount("resume")).toBe(0);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
function restoreGlobal(name: "document" | "window", descriptor: PropertyDescriptor | undefined): void {
|
|
130
|
+
if (descriptor === undefined) {
|
|
131
|
+
Reflect.deleteProperty(globalThis, name);
|
|
132
|
+
} else {
|
|
133
|
+
Object.defineProperty(globalThis, name, descriptor);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -80,6 +80,35 @@ describe("UploadManager AuthManager configuration", () => {
|
|
|
80
80
|
await expect(manager.upload({ data: "x" })).rejects.toThrow("provide an API key");
|
|
81
81
|
expect(uploadApi.beginMultipartUpload).not.toHaveBeenCalled();
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
test("awaits manager-owned authentication before starting an upload", async () => {
|
|
85
|
+
const configState = state("customer", accountA);
|
|
86
|
+
configState.expiresAt = Date.now();
|
|
87
|
+
let completeRefresh = (): void => {
|
|
88
|
+
throw new Error("Refresh completion callback was not initialized.");
|
|
89
|
+
};
|
|
90
|
+
const refreshPromise = new Promise<void>(resolve => {
|
|
91
|
+
completeRefresh = () => {
|
|
92
|
+
configState.accessToken = "access-token-new";
|
|
93
|
+
configState.expiresAt = Date.now() + 60_000;
|
|
94
|
+
configState.jwt = "jwt-new";
|
|
95
|
+
configState.refreshPromise = undefined;
|
|
96
|
+
resolve();
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
configState.authenticationPromise = refreshPromise;
|
|
100
|
+
configState.refreshPromise = refreshPromise;
|
|
101
|
+
setSession(configState);
|
|
102
|
+
const manager = new TestUploadManager({ authConfigId: "customer" });
|
|
103
|
+
const uploadApi = installUploadApiMock(manager);
|
|
104
|
+
|
|
105
|
+
const upload = manager.upload({ data: "x" });
|
|
106
|
+
expect(uploadApi.beginMultipartUpload).not.toHaveBeenCalled();
|
|
107
|
+
completeRefresh();
|
|
108
|
+
await upload;
|
|
109
|
+
|
|
110
|
+
expect(uploadApi.beginMultipartUpload).toHaveBeenCalled();
|
|
111
|
+
});
|
|
83
112
|
});
|
|
84
113
|
|
|
85
114
|
interface UploadApiMock {
|
|
@@ -127,6 +156,7 @@ function installUploadApiMock(manager: TestUploadManager): UploadApiMock {
|
|
|
127
156
|
function state(authConfigId: string, accountId: string): AuthSessionConfigState {
|
|
128
157
|
return {
|
|
129
158
|
accessToken: "access-token",
|
|
159
|
+
authenticationPromise: Promise.resolve(),
|
|
130
160
|
config: {
|
|
131
161
|
accountId,
|
|
132
162
|
authConfigId,
|