@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telicent-oss/fe-auth-lib",
3
- "version": "1.0.1",
3
+ "version": "1.0.2-TELFE-1477.0",
4
4
  "private": false,
5
5
  "license": "Apache-2.0",
6
6
  "description": "OAuth2 client library for Telicent Authentication Server",
@@ -32,7 +32,7 @@
32
32
  "INSTRUCTIONS.md"
33
33
  ],
34
34
  "scripts": {
35
- "test": "jest",
35
+ "test": "jest --config ./jest.config.js",
36
36
  "build": "echo \"No-op: Nothing to build\"",
37
37
  "local-publish": "npm publish --registry http://localhost:4873"
38
38
  },
@@ -50,5 +50,5 @@
50
50
  "engines": {
51
51
  "node": ">=20.19.0"
52
52
  },
53
- "gitHead": "4868d793fefb4b0a564b028a85189e608d9ca927"
53
+ "gitHead": "4222468eba6c8be751a0de016110d35cd53322cd"
54
54
  }
@@ -10,19 +10,17 @@
10
10
  */
11
11
  export interface AuthServerOAuth2ClientConfig {
12
12
  /** OAuth2 client identifier registered with the auth server */
13
- clientId?: string;
13
+ clientId: string;
14
14
  /** Base URL of the authentication server (e.g. "http://auth.telicent.localhost") */
15
- authServerUrl?: string;
15
+ authServerUrl: string;
16
16
  /** URI to redirect to after OAuth authorization (must be registered with auth server) */
17
- redirectUri?: string;
17
+ redirectUri: string;
18
18
  /** Alternative redirect URI for popup-based authentication flows */
19
- popupRedirectUri?: string;
19
+ popupRedirectUri: string;
20
20
  /** OAuth2 scopes to request (e.g. "openid profile message.read") */
21
- scope?: string;
22
- /** Base URL for API requests (used for cross-domain detection) */
23
- apiUrl?: string;
21
+ scope: string;
24
22
  /** Callback function executed after successful logout */
25
- onLogout?: () => void;
23
+ onLogout: () => void;
26
24
  }
27
25
 
28
26
  /**
@@ -144,7 +142,7 @@ declare class AuthServerOAuth2Client {
144
142
  static readonly OAUTH_ERROR: string;
145
143
 
146
144
  /** Client configuration with defaults applied */
147
- config: Required<AuthServerOAuth2ClientConfig>;
145
+ config: AuthServerOAuth2ClientConfig;
148
146
  /** Whether this client operates in cross-domain mode */
149
147
  isCrossDomain: boolean;
150
148
 
@@ -184,6 +182,8 @@ declare class AuthServerOAuth2Client {
184
182
  */
185
183
  base64URLEncode(array: Uint8Array): string;
186
184
 
185
+ base64URLEncodeString(str: string): string;
186
+
187
187
  /**
188
188
  * Generate random state
189
189
  */
@@ -255,7 +255,9 @@ declare class AuthServerOAuth2Client {
255
255
  * });
256
256
  * ```
257
257
  */
258
- handleCallback(callbackParams?: string | null): Promise<SessionData>;
258
+ handleCallback(
259
+ callbackParams?: string | Record<string, string> | URLSearchParams | null
260
+ ): Promise<SessionData>;
259
261
 
260
262
  /**
261
263
  * Check if user has valid session
@@ -425,11 +427,6 @@ declare class AuthServerOAuth2Client {
425
427
  */
426
428
  logout(): Promise<void>;
427
429
 
428
- /**
429
- * Track popup window and listen for OAuth callback messages
430
- */
431
- trackPopup(popup: Window): void;
432
-
433
430
  /**
434
431
  * Complete popup OAuth flow
435
432
  *
@@ -44,6 +44,9 @@ class AuthServerOAuth2Client {
44
44
  this.config = {
45
45
  ...config,
46
46
  };
47
+ this.derived = {
48
+ accountInactive: `${this.config.authServerUrl}/account-inactive`,
49
+ }
47
50
 
48
51
  // Auto-detect if this is a cross-domain client
49
52
  this.isCrossDomain = this.detectCrossDomain();
@@ -278,6 +281,8 @@ class AuthServerOAuth2Client {
278
281
  );
279
282
 
280
283
  if (!tokenResponse.ok) {
284
+ let errorMessage = "Unknown error";
285
+ const authUrl = `${this.config.authServerUrl}/oauth2/authorize`;
281
286
  let errorDetails;
282
287
  try {
283
288
  // Try to parse JSON error response first
@@ -288,6 +293,9 @@ class AuthServerOAuth2Client {
288
293
  errorDetails = { error: "unknown", error_description: errorText };
289
294
  }
290
295
 
296
+ errorMessage =
297
+ errorDetails.error_description || errorDetails.error || "Unknown error";
298
+
291
299
  // Handle specific consent_required error
292
300
  if (errorDetails.error === "consent_required") {
293
301
  console.log("Consent required - redirecting to proper OAuth2 flow");
@@ -295,15 +303,24 @@ class AuthServerOAuth2Client {
295
303
  // The SPA should not directly call /oauth2/session without consent
296
304
  // Instead, redirect to start the proper OAuth2 authorization flow
297
305
  // which will handle consent properly through OAuth2AuthenticationSuccessHandler
298
- const authUrl = this.buildAuthorizationUrl();
299
306
  console.log("Redirecting to OAuth2 authorization flow:", authUrl);
300
- window.location.href = authUrl;
307
+ this.clearLocalStorage();
308
+ window.location.href = `${authUrl}/access-denied`;
309
+
301
310
  return; // Don't throw error, we're redirecting
302
311
  }
303
312
 
313
+ if (errorDetails.error === "access_denied" && tokenResponse.status === 400) {
314
+ console.log(`${errorMessage}`);
315
+ this.clearLocalStorage();
316
+
317
+ window.location.href = this.derived.accountInactive;
318
+
319
+ throw new Error(`${errorMessage} redirecting to ${this.derived.accountInactive}`);
320
+ }
321
+
304
322
  // For other errors, throw as before
305
- const errorMessage =
306
- errorDetails.error_description || errorDetails.error || "Unknown error";
323
+ this.clearLocalStorage();
307
324
  throw new Error(
308
325
  `Token exchange and session creation failed: ${errorMessage}`
309
326
  );
@@ -963,4 +980,4 @@ if (typeof module !== "undefined" && module.exports) {
963
980
  // ES modules
964
981
  exports.default = AuthServerOAuth2Client;
965
982
  exports.AuthServerOAuth2Client = AuthServerOAuth2Client;
966
- }
983
+ }
@@ -0,0 +1,285 @@
1
+ import AuthServerOAuth2Client, {
2
+ AuthServerOAuth2ClientConfig,
3
+ } from "../AuthServerOAuth2Client";
4
+ import { installTestEnv, resetTestEnv } 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
+ const createFetchResponse = (options: {
19
+ ok?: boolean;
20
+ status?: number;
21
+ jsonData?: unknown;
22
+ textData?: string;
23
+ jsonThrows?: boolean;
24
+ }): Response =>
25
+ ({
26
+ ok: options.ok ?? false,
27
+ status: options.status ?? 400,
28
+ json: options.jsonThrows
29
+ ? jest.fn().mockRejectedValue(new Error("json error"))
30
+ : jest.fn().mockResolvedValue(options.jsonData ?? {}),
31
+ text: jest.fn().mockResolvedValue(options.textData ?? ""),
32
+ } as unknown as Response);
33
+
34
+ describe("failure path - handleCallback failure modes", () => {
35
+ beforeEach(() => {
36
+ installTestEnv();
37
+ Object.defineProperty(window, "location", {
38
+ value: {
39
+ href: "http://app.telicent.localhost/callback",
40
+ origin: "http://app.telicent.localhost",
41
+ search: "",
42
+ },
43
+ writable: true,
44
+ });
45
+ });
46
+
47
+ afterEach(() => {
48
+ resetTestEnv();
49
+ jest.useRealTimers();
50
+ });
51
+
52
+ it("throws on missing code or state", async () => {
53
+ const client = new AuthServerOAuth2Client(createConfig());
54
+
55
+ const cases = [
56
+ { params: {}, error: "Missing code or state parameter" },
57
+ { params: { code: "CODE_1" }, error: "Missing code or state parameter" },
58
+ {
59
+ params: { state: "STATE_1" },
60
+ error: "Missing code or state parameter",
61
+ },
62
+ ];
63
+
64
+ const results = [];
65
+ for (const item of cases) {
66
+ try {
67
+ await client.handleCallback(item.params as Record<string, string>);
68
+ results.push({ error: null });
69
+ } catch (error) {
70
+ results.push({ error: (error as Error).message });
71
+ }
72
+ }
73
+
74
+ expect(results).toMatchInlineSnapshot(`
75
+ [
76
+ {
77
+ "error": "Missing code or state parameter",
78
+ },
79
+ {
80
+ "error": "Missing code or state parameter",
81
+ },
82
+ {
83
+ "error": "Missing code or state parameter",
84
+ },
85
+ ]
86
+ `);
87
+ });
88
+
89
+ it("throws on state mismatch or missing verifier/redirect uri", async () => {
90
+ const client = new AuthServerOAuth2Client(createConfig());
91
+ sessionStorage.setItem("oauth_state", "STATE_1");
92
+ sessionStorage.setItem("oauth_code_verifier", "VERIFIER_1");
93
+ sessionStorage.setItem("oauth_redirect_uri", "http://app/callback");
94
+
95
+ const cases = [
96
+ {
97
+ params: { code: "CODE_1", state: "STATE_2" },
98
+ error: "Invalid state parameter",
99
+ },
100
+ {
101
+ params: { code: "CODE_1", state: "STATE_1" },
102
+ clear: "oauth_code_verifier",
103
+ error: "Missing code verifier",
104
+ },
105
+ {
106
+ params: { code: "CODE_1", state: "STATE_1" },
107
+ clear: "oauth_redirect_uri",
108
+ error: "Missing redirect URI",
109
+ },
110
+ ];
111
+
112
+ const results = [];
113
+ for (const item of cases) {
114
+ sessionStorage.setItem("oauth_state", "STATE_1");
115
+ sessionStorage.setItem("oauth_code_verifier", "VERIFIER_1");
116
+ sessionStorage.setItem("oauth_redirect_uri", "http://app/callback");
117
+ if (item.clear) sessionStorage.removeItem(item.clear);
118
+ try {
119
+ await client.handleCallback(item.params as Record<string, string>);
120
+ results.push({ error: null });
121
+ } catch (error) {
122
+ results.push({ error: (error as Error).message });
123
+ }
124
+ }
125
+
126
+ expect(results).toMatchInlineSnapshot(`
127
+ [
128
+ {
129
+ "error": "Invalid state parameter",
130
+ },
131
+ {
132
+ "error": "Missing code verifier",
133
+ },
134
+ {
135
+ "error": "Missing redirect URI",
136
+ },
137
+ ]
138
+ `);
139
+ });
140
+
141
+ it("throws on error param from callback params", async () => {
142
+ const client = new AuthServerOAuth2Client(createConfig());
143
+
144
+ let error: Error | null = null;
145
+ try {
146
+ await client.handleCallback({ error: "access_denied" });
147
+ } catch (caught) {
148
+ error = caught as Error;
149
+ }
150
+
151
+ expect({ error: error?.message }).toMatchInlineSnapshot(`
152
+ {
153
+ "error": "OAuth error: access_denied",
154
+ }
155
+ `);
156
+ });
157
+
158
+ it("reads callback params from window.location.search", async () => {
159
+ const client = new AuthServerOAuth2Client(createConfig());
160
+ Object.defineProperty(window, "location", {
161
+ value: {
162
+ href: "http://app.telicent.localhost/callback?error=access_denied",
163
+ origin: "http://app.telicent.localhost",
164
+ search: "?error=access_denied",
165
+ },
166
+ writable: true,
167
+ });
168
+
169
+ let error: Error | null = null;
170
+ try {
171
+ await client.handleCallback();
172
+ } catch (caught) {
173
+ error = caught as Error;
174
+ }
175
+
176
+ expect({ error: error?.message }).toMatchInlineSnapshot(`
177
+ {
178
+ "error": "OAuth error: access_denied",
179
+ }
180
+ `);
181
+ });
182
+
183
+ it("redirects on consent_required without throwing", async () => {
184
+ jest.useFakeTimers();
185
+ const client = new AuthServerOAuth2Client(createConfig());
186
+ sessionStorage.setItem("oauth_state", "STATE_1");
187
+ sessionStorage.setItem("oauth_code_verifier", "VERIFIER_1");
188
+ sessionStorage.setItem("oauth_redirect_uri", "http://app/callback");
189
+
190
+ const fetchMock = jest.fn().mockResolvedValue(
191
+ createFetchResponse({
192
+ ok: false,
193
+ status: 400,
194
+ jsonData: { error: "consent_required" },
195
+ })
196
+ );
197
+ globalThis.fetch = fetchMock;
198
+
199
+ await client.handleCallback({ code: "CODE_1", state: "STATE_1" });
200
+
201
+ expect({
202
+ href: window.location.href,
203
+ oauthState: sessionStorage.getItem("oauth_state"),
204
+ oauthVerifier: sessionStorage.getItem("oauth_code_verifier"),
205
+ }).toMatchInlineSnapshot(`
206
+ {
207
+ "href": "http://auth.telicent.localhost/oauth2/authorize/access-denied",
208
+ "oauthState": null,
209
+ "oauthVerifier": null,
210
+ }
211
+ `);
212
+ });
213
+
214
+ it("redirects on access_denied", async () => {
215
+ jest.useFakeTimers();
216
+ const client = new AuthServerOAuth2Client(createConfig());
217
+ sessionStorage.setItem("oauth_state", "STATE_1");
218
+ sessionStorage.setItem("oauth_code_verifier", "VERIFIER_1");
219
+ sessionStorage.setItem("oauth_redirect_uri", "http://app/callback");
220
+
221
+ const fetchMock = jest.fn().mockResolvedValue(
222
+ createFetchResponse({
223
+ ok: false,
224
+ status: 400,
225
+ jsonData: { error: "access_denied" },
226
+ })
227
+ );
228
+ globalThis.fetch = fetchMock;
229
+
230
+ await expect(
231
+ client.handleCallback({ code: "CODE_1", state: "STATE_1" })
232
+ ).rejects.toThrow(
233
+ "access_denied redirecting to http://auth.telicent.localhost/account-inactive"
234
+ );
235
+
236
+ expect({
237
+ href: window.location.href,
238
+ oauthState: sessionStorage.getItem("oauth_state"),
239
+ oauthVerifier: sessionStorage.getItem("oauth_code_verifier"),
240
+ }).toMatchInlineSnapshot(`
241
+ {
242
+ "href": "http://auth.telicent.localhost/account-inactive",
243
+ "oauthState": null,
244
+ "oauthVerifier": null,
245
+ }
246
+ `);
247
+ });
248
+
249
+ it("throws when token exchange fails with text response", async () => {
250
+ jest.useFakeTimers();
251
+ const client = new AuthServerOAuth2Client(createConfig());
252
+ sessionStorage.setItem("oauth_state", "STATE_1");
253
+ sessionStorage.setItem("oauth_code_verifier", "VERIFIER_1");
254
+ sessionStorage.setItem("oauth_redirect_uri", "http://app/callback");
255
+
256
+ const fetchMock = jest.fn().mockResolvedValue(
257
+ createFetchResponse({
258
+ ok: false,
259
+ status: 500,
260
+ jsonThrows: true,
261
+ textData: "server down",
262
+ })
263
+ );
264
+ globalThis.fetch = fetchMock;
265
+
266
+ let error: Error | null = null;
267
+ try {
268
+ await client.handleCallback({ code: "CODE_1", state: "STATE_1" });
269
+ } catch (caught) {
270
+ error = caught as Error;
271
+ }
272
+
273
+ expect({
274
+ error: error?.message,
275
+ oauthState: sessionStorage.getItem("oauth_state"),
276
+ oauthVerifier: sessionStorage.getItem("oauth_code_verifier"),
277
+ }).toMatchInlineSnapshot(`
278
+ {
279
+ "error": "Token exchange and session creation failed: server down",
280
+ "oauthState": null,
281
+ "oauthVerifier": null,
282
+ }
283
+ `);
284
+ });
285
+ });