@telicent-oss/fe-auth-lib 1.0.1 → 1.0.2-TELFE-1477.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 (36) hide show
  1. package/package.json +3 -3
  2. package/src/AuthServerOAuth2Client.d.ts +12 -15
  3. package/src/AuthServerOAuth2Client.js +22 -5
  4. package/src/__tests__/callback.failures.test.ts +285 -0
  5. package/src/__tests__/callback.success.test.ts +410 -0
  6. package/src/__tests__/core.failures.test.ts +122 -0
  7. package/src/__tests__/core.success.test.ts +196 -0
  8. package/src/__tests__/env.success.test.ts +17 -0
  9. package/src/__tests__/logout.success.test.ts +151 -0
  10. package/src/__tests__/methods/base64URLEncode.success.test.ts +39 -0
  11. package/src/__tests__/methods/generateCodeChallenge.success.test.ts +43 -0
  12. package/src/__tests__/methods/generateCodeVerifier.success.test.ts +43 -0
  13. package/src/__tests__/methods/generateNonce.success.test.ts +43 -0
  14. package/src/__tests__/methods/generateState.success.test.ts +43 -0
  15. package/src/__tests__/methods/getCsrfToken.failures.test.ts +54 -0
  16. package/src/__tests__/methods/getCsrfToken.success.test.ts +45 -0
  17. package/src/__tests__/methods/getRawIdToken.success.test.ts +39 -0
  18. package/src/__tests__/methods/getUserInfo.failures.test.ts +153 -0
  19. package/src/__tests__/methods/getUserInfo.success.test.ts +84 -0
  20. package/src/__tests__/methods/isAuthenticated.failures.test.ts +62 -0
  21. package/src/__tests__/methods/isAuthenticated.success.test.ts +84 -0
  22. package/src/__tests__/methods/isIdTokenExpired.failures.test.ts +77 -0
  23. package/src/__tests__/methods/isIdTokenExpired.success.test.ts +49 -0
  24. package/src/__tests__/methods/validateIdToken.failures.test.ts +177 -0
  25. package/src/__tests__/methods/validateIdToken.success.test.ts +55 -0
  26. package/src/__tests__/methods/validateIdTokenForRecovery.failures.test.ts +121 -0
  27. package/src/__tests__/methods/validateIdTokenForRecovery.success.test.ts +49 -0
  28. package/src/__tests__/popup.success.test.ts +277 -0
  29. package/src/__tests__/request-helpers.failures.test.ts +143 -0
  30. package/src/__tests__/request-helpers.success.test.ts +137 -0
  31. package/src/__tests__/schema-loading.failures.test.ts +44 -0
  32. package/src/__tests__/schema-loading.success.test.ts +106 -0
  33. package/src/__tests__/state.success.test.ts +217 -0
  34. package/src/__tests__/test-utils.node.success.test.ts +16 -0
  35. package/src/__tests__/test-utils.success.test.ts +188 -0
  36. package/src/__tests__/test-utils.ts +203 -0
@@ -0,0 +1,410 @@
1
+ import AuthServerOAuth2Client, {
2
+ AuthServerOAuth2ClientConfig,
3
+ } from "../AuthServerOAuth2Client";
4
+ import {
5
+ buildJwt,
6
+ installTestEnv,
7
+ mockPkceValues,
8
+ resetTestEnv,
9
+ setWindowLocation,
10
+ } from "./test-utils";
11
+
12
+ const createConfig = (
13
+ overrides: Partial<AuthServerOAuth2ClientConfig> = {}
14
+ ): AuthServerOAuth2ClientConfig => ({
15
+ clientId: "client-1",
16
+ authServerUrl: "http://auth.telicent.localhost",
17
+ redirectUri: "http://app.telicent.localhost/callback",
18
+ popupRedirectUri: "http://app.telicent.localhost/popup",
19
+ scope: "openid profile",
20
+ onLogout: jest.fn(),
21
+ ...overrides,
22
+ });
23
+
24
+ const createFetchResponse = (options: {
25
+ ok?: boolean;
26
+ status?: number;
27
+ jsonData?: unknown;
28
+ textData?: string;
29
+ }): Response =>
30
+ ({
31
+ ok: options.ok ?? true,
32
+ status: options.status ?? 200,
33
+ json: jest.fn().mockResolvedValue(options.jsonData ?? {}),
34
+ text: jest.fn().mockResolvedValue(options.textData ?? ""),
35
+ } as unknown as Response);
36
+
37
+ const matchedNonce = "✅ MATCHED nonce";
38
+ const mismatchedNonces = {
39
+ stored: "❌ 🍎 MIS-matched nonce",
40
+ token: "❌ 🍌 MIS-matched nonce",
41
+ };
42
+
43
+ describe("happy path - handleCallback stores session and id token", () => {
44
+ beforeEach(() => {
45
+ installTestEnv();
46
+ setWindowLocation("http://app.telicent.localhost/home");
47
+ });
48
+
49
+ afterEach(() => {
50
+ resetTestEnv();
51
+ jest.useRealTimers();
52
+ });
53
+
54
+ it("stores session and id token for cross-domain session", async () => {
55
+ jest.useFakeTimers();
56
+ const now = 1_700_000_000_000;
57
+ jest.spyOn(Date, "now").mockReturnValue(now);
58
+
59
+ const client = new AuthServerOAuth2Client(
60
+ createConfig({ authServerUrl: "https://auth.telicent.io" })
61
+ );
62
+ mockPkceValues(client, {
63
+ state: "ABC_state_ABC",
64
+ nonce: "ABC_nonce_ABC",
65
+ codeVerifier: "ABC_codeVerifier_ABC",
66
+ });
67
+
68
+ sessionStorage.setItem("oauth_state", "ABC_state_ABC");
69
+ sessionStorage.setItem("oauth_nonce", "ABC_nonce_ABC");
70
+ sessionStorage.setItem("oauth_code_verifier", "ABC_codeVerifier_ABC");
71
+ sessionStorage.setItem(
72
+ "oauth_redirect_uri",
73
+ "http://app.telicent.localhost/callback"
74
+ );
75
+
76
+ const idToken = buildJwt({
77
+ sub: "user-1",
78
+ aud: "client-1",
79
+ exp: Math.floor(now / 1000) + 300,
80
+ iat: Math.floor(now / 1000),
81
+ nonce: "ABC_nonce_ABC",
82
+ email: "user@example.com",
83
+ preferred_name: "User One",
84
+ iss: "https://auth.telicent.io",
85
+ jti: "id-1",
86
+ });
87
+
88
+ const fetchMock = jest.fn();
89
+ fetchMock
90
+ .mockResolvedValueOnce(
91
+ createFetchResponse({
92
+ jsonData: {
93
+ isCrossDomain: true,
94
+ sessionToken: "SESSION_123",
95
+ user: "user-1",
96
+ },
97
+ })
98
+ )
99
+ .mockResolvedValueOnce(
100
+ createFetchResponse({
101
+ jsonData: { id_token: idToken },
102
+ })
103
+ );
104
+
105
+ globalThis.fetch = fetchMock;
106
+
107
+ const promise = client.handleCallback({
108
+ code: "CODE_123",
109
+ state: "ABC_state_ABC",
110
+ });
111
+ await jest.advanceTimersByTimeAsync(100);
112
+ const sessionData = await promise;
113
+
114
+ expect({
115
+ sessionData,
116
+ storage: {
117
+ authSessionId: sessionStorage.getItem("auth_session_id"),
118
+ authIdToken: sessionStorage.getItem("auth_id_token"),
119
+ oauthState: sessionStorage.getItem("oauth_state"),
120
+ oauthCodeVerifier: sessionStorage.getItem("oauth_code_verifier"),
121
+ oauthNonce: sessionStorage.getItem("oauth_nonce"),
122
+ },
123
+ fetchCalls: fetchMock.mock.calls.map((call) => call[0]),
124
+ }).toMatchInlineSnapshot(`
125
+ {
126
+ "fetchCalls": [
127
+ "https://auth.telicent.io/oauth2/token",
128
+ "https://auth.telicent.io/session/idtoken",
129
+ ],
130
+ "sessionData": {
131
+ "isCrossDomain": true,
132
+ "sessionToken": "SESSION_123",
133
+ "user": "user-1",
134
+ },
135
+ "storage": {
136
+ "authIdToken": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTEiLCJhdWQiOiJjbGllbnQtMSIsImV4cCI6MTcwMDAwMDMwMCwiaWF0IjoxNzAwMDAwMDAwLCJub25jZSI6IkFCQ19ub25jZV9BQkMiLCJlbWFpbCI6InVzZXJAZXhhbXBsZS5jb20iLCJwcmVmZXJyZWRfbmFtZSI6IlVzZXIgT25lIiwiaXNzIjoiaHR0cHM6Ly9hdXRoLnRlbGljZW50LmlvIiwianRpIjoiaWQtMSJ9.",
137
+ "authSessionId": "SESSION_123",
138
+ "oauthCodeVerifier": null,
139
+ "oauthNonce": null,
140
+ "oauthState": null,
141
+ },
142
+ }
143
+ `);
144
+
145
+ (Date.now as jest.Mock).mockRestore();
146
+ });
147
+
148
+ it("completes callback when id token response is not ok (500 status)", async () => {
149
+ jest.useFakeTimers();
150
+
151
+ const client = new AuthServerOAuth2Client(createConfig());
152
+
153
+ mockPkceValues(client, {
154
+ state: "ABC_state_ABC",
155
+ nonce: matchedNonce,
156
+ codeVerifier: "ABC_codeVerifier_ABC",
157
+ });
158
+
159
+ sessionStorage.setItem("oauth_state", "ABC_state_ABC");
160
+ sessionStorage.setItem("oauth_nonce", matchedNonce);
161
+ sessionStorage.setItem("oauth_code_verifier", "ABC_codeVerifier_ABC");
162
+ sessionStorage.setItem(
163
+ "oauth_redirect_uri",
164
+ "http://app.telicent.localhost/callback"
165
+ );
166
+
167
+ const fetchMock = jest.fn();
168
+ fetchMock
169
+ .mockResolvedValueOnce(
170
+ createFetchResponse({
171
+ jsonData: { isCrossDomain: false, user: "user-1" },
172
+ })
173
+ )
174
+ .mockResolvedValueOnce(
175
+ createFetchResponse({
176
+ ok: false,
177
+ status: 500,
178
+ jsonData: { error: "server_error" },
179
+ })
180
+ );
181
+ globalThis.fetch = fetchMock;
182
+
183
+ const promise = client.handleCallback({
184
+ code: "CODE_123",
185
+ state: "ABC_state_ABC",
186
+ });
187
+ await jest.advanceTimersByTimeAsync(100);
188
+ const sessionData = await promise;
189
+
190
+ expect({
191
+ sessionData,
192
+ storage: {
193
+ authSessionId: sessionStorage.getItem("auth_session_id"),
194
+ authIdToken: sessionStorage.getItem("auth_id_token"),
195
+ oauthState: sessionStorage.getItem("oauth_state"),
196
+ oauthCodeVerifier: sessionStorage.getItem("oauth_code_verifier"),
197
+ oauthNonce: sessionStorage.getItem("oauth_nonce"),
198
+ },
199
+ }).toMatchInlineSnapshot(`
200
+ {
201
+ "sessionData": {
202
+ "isCrossDomain": false,
203
+ "user": "user-1",
204
+ },
205
+ "storage": {
206
+ "authIdToken": null,
207
+ "authSessionId": null,
208
+ "oauthCodeVerifier": null,
209
+ "oauthNonce": "✅ MATCHED nonce",
210
+ "oauthState": null,
211
+ },
212
+ }
213
+ `);
214
+ });
215
+
216
+ it("stores id token without cross-domain session id", async () => {
217
+ jest.useFakeTimers();
218
+ const now = 1_700_000_100_000;
219
+ jest.spyOn(Date, "now").mockReturnValue(now);
220
+
221
+ const client = new AuthServerOAuth2Client(createConfig());
222
+ mockPkceValues(client, {
223
+ state: "ABC_state_ABC",
224
+ nonce: "ABC_nonce_ABC",
225
+ codeVerifier: "ABC_codeVerifier_ABC",
226
+ });
227
+
228
+ sessionStorage.setItem("oauth_state", "ABC_state_ABC");
229
+ sessionStorage.setItem("oauth_nonce", "ABC_nonce_ABC");
230
+ sessionStorage.setItem("oauth_code_verifier", "ABC_codeVerifier_ABC");
231
+ sessionStorage.setItem(
232
+ "oauth_redirect_uri",
233
+ "http://app.telicent.localhost/callback"
234
+ );
235
+
236
+ const idToken = buildJwt({
237
+ sub: "user-2",
238
+ aud: "client-1",
239
+ exp: Math.floor(now / 1000) + 300,
240
+ iat: Math.floor(now / 1000),
241
+ nonce: "ABC_nonce_ABC",
242
+ email: "user2@example.com",
243
+ preferred_name: "User Two",
244
+ iss: "http://auth.telicent.localhost",
245
+ jti: "id-2",
246
+ });
247
+
248
+ const fetchMock = jest.fn();
249
+ fetchMock
250
+ .mockResolvedValueOnce(
251
+ createFetchResponse({
252
+ jsonData: {
253
+ isCrossDomain: true,
254
+ sessionToken: "SESSION_456",
255
+ user: "user-2",
256
+ },
257
+ })
258
+ )
259
+ .mockResolvedValueOnce(
260
+ createFetchResponse({
261
+ jsonData: { id_token: idToken },
262
+ })
263
+ );
264
+ globalThis.fetch = fetchMock;
265
+
266
+ const promise = client.handleCallback({
267
+ code: "CODE_456",
268
+ state: "ABC_state_ABC",
269
+ });
270
+ await jest.advanceTimersByTimeAsync(100);
271
+ const sessionData = await promise;
272
+
273
+ expect({
274
+ sessionData,
275
+ storage: {
276
+ authSessionId: sessionStorage.getItem("auth_session_id"),
277
+ authIdToken: sessionStorage.getItem("auth_id_token"),
278
+ },
279
+ }).toMatchInlineSnapshot(`
280
+ {
281
+ "sessionData": {
282
+ "isCrossDomain": true,
283
+ "sessionToken": "SESSION_456",
284
+ "user": "user-2",
285
+ },
286
+ "storage": {
287
+ "authIdToken": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiJ1c2VyLTIiLCJhdWQiOiJjbGllbnQtMSIsImV4cCI6MTcwMDAwMDQwMCwiaWF0IjoxNzAwMDAwMTAwLCJub25jZSI6IkFCQ19ub25jZV9BQkMiLCJlbWFpbCI6InVzZXIyQGV4YW1wbGUuY29tIiwicHJlZmVycmVkX25hbWUiOiJVc2VyIFR3byIsImlzcyI6Imh0dHA6Ly9hdXRoLnRlbGljZW50LmxvY2FsaG9zdCIsImp0aSI6ImlkLTIifQ.",
288
+ "authSessionId": null,
289
+ },
290
+ }
291
+ `);
292
+
293
+ (Date.now as jest.Mock).mockRestore();
294
+ });
295
+
296
+ it("warns when id token validation fails (nonce mismatch)", async () => {
297
+ jest.useFakeTimers();
298
+ const now = 1_700_000_200_000;
299
+ jest.spyOn(Date, "now").mockReturnValue(now);
300
+
301
+ const client = new AuthServerOAuth2Client(createConfig());
302
+ mockPkceValues(client, {
303
+ state: "ABC_state_ABC",
304
+ nonce: mismatchedNonces.stored,
305
+ codeVerifier: "ABC_codeVerifier_ABC",
306
+ });
307
+
308
+ sessionStorage.setItem("oauth_state", "ABC_state_ABC");
309
+ sessionStorage.setItem("oauth_nonce", mismatchedNonces.stored);
310
+ sessionStorage.setItem("oauth_code_verifier", "ABC_codeVerifier_ABC");
311
+ sessionStorage.setItem(
312
+ "oauth_redirect_uri",
313
+ "http://app.telicent.localhost/callback"
314
+ );
315
+
316
+ const nonceMismatchIdToken = buildJwt({
317
+ sub: "user-3",
318
+ aud: "client-1",
319
+ exp: Math.floor(now / 1000) + 300,
320
+ iat: Math.floor(now / 1000),
321
+ nonce: mismatchedNonces.token,
322
+ email: "user3@example.com",
323
+ preferred_name: "User Three",
324
+ iss: "http://auth.telicent.localhost",
325
+ jti: "id-3",
326
+ });
327
+
328
+ const fetchMock = jest.fn().mockResolvedValue(
329
+ createFetchResponse({
330
+ jsonData: { isCrossDomain: false, user: "user-3" },
331
+ })
332
+ );
333
+ globalThis.fetch = fetchMock;
334
+ jest
335
+ .spyOn(client, "makeAuthenticatedRequest")
336
+ .mockResolvedValue(
337
+ createFetchResponse({ jsonData: { id_token: nonceMismatchIdToken } })
338
+ );
339
+
340
+ const promise = client.handleCallback({
341
+ code: "CODE_789",
342
+ state: "ABC_state_ABC",
343
+ });
344
+ await jest.advanceTimersByTimeAsync(100);
345
+ await promise;
346
+
347
+ expect({
348
+ reason: mismatchedNonces,
349
+ warnings: (console.warn as jest.Mock).mock.calls.map((call) => call[0]),
350
+ }).toMatchInlineSnapshot(`
351
+ {
352
+ "reason": {
353
+ "stored": "❌ 🍎 MIS-matched nonce",
354
+ "token": "❌ 🍌 MIS-matched nonce",
355
+ },
356
+ "warnings": [
357
+ "ID token validation failed, but continuing with callback",
358
+ ],
359
+ }
360
+ `);
361
+
362
+ (Date.now as jest.Mock).mockRestore();
363
+ });
364
+
365
+ it("warns when id token retrieval throws", async () => {
366
+ jest.useFakeTimers();
367
+
368
+ const client = new AuthServerOAuth2Client(createConfig());
369
+ mockPkceValues(client, {
370
+ state: "ABC_state_ABC",
371
+ nonce: "ABC_nonce_ABC",
372
+ codeVerifier: "ABC_codeVerifier_ABC",
373
+ });
374
+
375
+ sessionStorage.setItem("oauth_state", "ABC_state_ABC");
376
+ sessionStorage.setItem("oauth_nonce", "ABC_nonce_ABC");
377
+ sessionStorage.setItem("oauth_code_verifier", "ABC_codeVerifier_ABC");
378
+ sessionStorage.setItem(
379
+ "oauth_redirect_uri",
380
+ "http://app.telicent.localhost/callback"
381
+ );
382
+
383
+ const fetchMock = jest.fn().mockResolvedValue(
384
+ createFetchResponse({
385
+ jsonData: { isCrossDomain: false, user: "user-4" },
386
+ })
387
+ );
388
+ globalThis.fetch = fetchMock;
389
+ jest
390
+ .spyOn(client, "makeAuthenticatedRequest")
391
+ .mockRejectedValue(new Error("id token fetch failed"));
392
+
393
+ const promise = client.handleCallback({
394
+ code: "CODE_456",
395
+ state: "ABC_state_ABC",
396
+ });
397
+ await jest.advanceTimersByTimeAsync(100);
398
+ await promise;
399
+
400
+ expect({
401
+ warnings: (console.warn as jest.Mock).mock.calls.map((call) => call[0]),
402
+ }).toMatchInlineSnapshot(`
403
+ {
404
+ "warnings": [
405
+ "Error retrieving ID token during callback, but continuing:",
406
+ ],
407
+ }
408
+ `);
409
+ });
410
+ });
@@ -0,0 +1,122 @@
1
+ import AuthServerOAuth2Client, {
2
+ AuthServerOAuth2ClientConfig,
3
+ } from "../AuthServerOAuth2Client";
4
+ import { installTestEnv, resetTestEnv, setWindowLocation } from "./test-utils";
5
+
6
+ const createConfig = (
7
+ overrides: Partial<AuthServerOAuth2ClientConfig> = {}
8
+ ): AuthServerOAuth2ClientConfig => ({
9
+ clientId: "client-1",
10
+ authServerUrl: "http://auth.telicent.localhost",
11
+ redirectUri: "http://app.telicent.localhost/callback",
12
+ popupRedirectUri: "http://app.telicent.localhost/popup",
13
+ scope: "openid profile",
14
+ onLogout: jest.fn(),
15
+ ...overrides,
16
+ });
17
+
18
+ describe("failure path - validation failures and edge handling", () => {
19
+ beforeEach(() => {
20
+ installTestEnv();
21
+ setWindowLocation("http://app.telicent.localhost/home");
22
+ });
23
+
24
+ afterEach(() => {
25
+ resetTestEnv();
26
+ });
27
+
28
+ it("throws on invalid config and logs details", () => {
29
+ let error: Error | null = null;
30
+ try {
31
+ new AuthServerOAuth2Client(createConfig({ authServerUrl: "not-a-url" }));
32
+ } catch (caught) {
33
+ error = caught as Error;
34
+ }
35
+
36
+ const errorCalls = (console.error as jest.Mock).mock.calls;
37
+
38
+ expect({
39
+ error: error?.message,
40
+ consoleError: errorCalls.map((call) => call[0]),
41
+ }).toMatchInlineSnapshot(`
42
+ {
43
+ "consoleError": [
44
+ "❌ Invalid AuthServerOAuth2Client configuration:",
45
+ ],
46
+ "error": "Invalid AuthServerOAuth2Client configuration: [
47
+ {
48
+ "validation": "url",
49
+ "code": "invalid_string",
50
+ "message": "Invalid url",
51
+ "path": [
52
+ "authServerUrl"
53
+ ]
54
+ }
55
+ ]",
56
+ }
57
+ `);
58
+ });
59
+
60
+ it("returns early when config is undefined", () => {
61
+ const client = new (AuthServerOAuth2Client as unknown as new (
62
+ config?: AuthServerOAuth2ClientConfig
63
+ ) => AuthServerOAuth2Client)(undefined);
64
+
65
+ const warnCalls = (console.warn as jest.Mock).mock.calls;
66
+
67
+ expect({
68
+ config: (client as AuthServerOAuth2Client).config,
69
+ isCrossDomain: (client as AuthServerOAuth2Client).isCrossDomain,
70
+ consoleWarn: warnCalls.map((call) => call[0]),
71
+ }).toMatchInlineSnapshot(`
72
+ {
73
+ "config": undefined,
74
+ "consoleWarn": [
75
+ "⚠️ AuthServerOAuth2Client instantiated with undefined config",
76
+ ],
77
+ "isCrossDomain": undefined,
78
+ }
79
+ `);
80
+ });
81
+
82
+ it("defaults to cross-domain when window origin is invalid", () => {
83
+ Object.defineProperty(window, "location", {
84
+ value: { origin: "not-a-url" },
85
+ writable: true,
86
+ });
87
+
88
+ const client = new AuthServerOAuth2Client(createConfig());
89
+ const warnCalls = (console.warn as jest.Mock).mock.calls;
90
+
91
+ expect({
92
+ isCrossDomain: client.isCrossDomain,
93
+ consoleWarn: warnCalls.map((call) => call[0]),
94
+ }).toMatchInlineSnapshot(`
95
+ {
96
+ "consoleWarn": [
97
+ "Error detecting domain context, defaulting to cross-domain:",
98
+ ],
99
+ "isCrossDomain": true,
100
+ }
101
+ `);
102
+ });
103
+
104
+ it("requires popup redirect uri for popup login", async () => {
105
+ const client = new AuthServerOAuth2Client(createConfig());
106
+ (client.config as { popupRedirectUri?: string }).popupRedirectUri =
107
+ undefined;
108
+
109
+ let error: Error | null = null;
110
+ try {
111
+ await client.loginWithPopup();
112
+ } catch (caught) {
113
+ error = caught as Error;
114
+ }
115
+
116
+ expect({ error: error?.message }).toMatchInlineSnapshot(`
117
+ {
118
+ "error": "redirectUri is required for popup login. Either provide it as a parameter or configure popupRedirectUri in the client config.",
119
+ }
120
+ `);
121
+ });
122
+ });