@tidecloak/js 0.12.47 → 0.13.2

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.
@@ -1,10 +1,18 @@
1
- import TideCloak from "../lib/tidecloak";
1
+ import { makePkce, fetchJson } from "./utils/index.js";
2
+ import TideCloak from "../lib/tidecloak.js";
2
3
  /**
3
4
  * Singleton IAMService wrapping the TideCloak client.
4
5
  *
6
+ * Supports two modes:
7
+ * - **Front-channel mode**: Browser handles all token operations (standard OIDC)
8
+ * - **Hybrid/BFF mode**: Browser handles PKCE, backend exchanges code for tokens (more secure)
9
+ *
10
+ * ---
11
+ * ## Front-channel Mode
12
+ *
5
13
  * Usage A: pass an onReady callback directly
6
14
  * ```js
7
- * import { IAMService } from 'tidecloak-js';
15
+ * import { IAMService } from '@tidecloak/js';
8
16
  * import tidecloakConfig from './tidecloakAdapter.json';
9
17
  *
10
18
  * IAMService.initIAM(tidecloakConfig, authenticated => {
@@ -20,12 +28,83 @@ import TideCloak from "../lib/tidecloak";
20
28
  *
21
29
  * await IAMService.initIAM(tidecloakConfig);
22
30
  * ```
31
+ *
32
+ * ---
33
+ * ## Hybrid/BFF Mode (Backend-For-Frontend)
34
+ *
35
+ * In hybrid mode, the browser generates PKCE and redirects to the IdP, but the
36
+ * backend exchanges the authorization code for tokens. This keeps tokens server-side
37
+ * for improved security.
38
+ *
39
+ * ### Config shape:
40
+ * ```js
41
+ * const hybridConfig = {
42
+ * authMode: "hybrid",
43
+ * oidc: {
44
+ * authorizationEndpoint: "https://auth.example.com/realms/myrealm/protocol/openid-connect/auth",
45
+ * clientId: "my-client",
46
+ * redirectUri: "https://app.example.com/auth/callback",
47
+ * scope: "openid profile email", // optional, defaults to "openid profile email"
48
+ * prompt: "login" // optional
49
+ * },
50
+ * tokenExchange: {
51
+ * endpoint: "/api/authenticate", // Backend endpoint that exchanges code for tokens
52
+ * provider: "tidecloak-auth", // optional, defaults to "tidecloak-auth"
53
+ * headers: () => ({ // optional, custom headers (e.g., CSRF token)
54
+ * "anti-csrf-token": getCSRFToken()
55
+ * })
56
+ * }
57
+ * };
58
+ * ```
59
+ *
60
+ * ### Login Page:
61
+ * ```js
62
+ * // Load config and trigger login
63
+ * await IAMService.loadConfig(hybridConfig);
64
+ * IAMService.doLogin("/dashboard"); // returnUrl after successful auth
65
+ * ```
66
+ *
67
+ * ### Redirect/Callback Page:
68
+ * ```js
69
+ * // initIAM handles the callback automatically - exchanges code for tokens via backend
70
+ * const authenticated = await IAMService.initIAM(hybridConfig);
71
+ * if (authenticated) {
72
+ * const returnUrl = IAMService.getReturnUrl() || "/";
73
+ * window.location.assign(returnUrl);
74
+ * }
75
+ * ```
76
+ *
77
+ * ### Token Exchange Request Format:
78
+ * The backend endpoint receives a POST request with:
79
+ * ```json
80
+ * {
81
+ * "accessToken": "{\"code\":\"...\",\"code_verifier\":\"...\",\"redirect_uri\":\"...\"}",
82
+ * "provider": "tidecloak-auth"
83
+ * }
84
+ * ```
85
+ *
86
+ * ---
87
+ * ## Events
88
+ * - `ready` - Emitted when initialization completes (with authenticated boolean)
89
+ * - `initError` - Emitted when initialization fails
90
+ * - `authSuccess` - Emitted on successful authentication
91
+ * - `authError` - Emitted on authentication failure
92
+ * - `authRefreshSuccess` - Emitted when token refresh succeeds (front-channel only)
93
+ * - `authRefreshError` - Emitted when token refresh fails (front-channel only)
94
+ * - `logout` - Emitted on logout
95
+ * - `tokenExpired` - Emitted when token expires (front-channel only)
23
96
  */
24
97
  class IAMService {
25
98
  constructor() {
26
99
  this._tc = null;
27
100
  this._config = null;
28
101
  this._listeners = {};
102
+ // --- Hybrid mode state ---
103
+ this._hybridAuthenticated = false;
104
+ this._hybridReturnUrl = null;
105
+ this._hybridCallbackHandled = false; // Guard against React StrictMode double-execution
106
+ this._hybridCallbackPromise = null; // Promise for pending token exchange (for StrictMode)
107
+ this._cachedCallbackData = null; // Cache for getHybridCallbackData to prevent data loss
29
108
  }
30
109
  /**
31
110
  * Register an event listener.
@@ -62,19 +141,31 @@ class IAMService {
62
141
  }
63
142
  });
64
143
  }
144
+ /**
145
+ * Check if running in hybrid mode.
146
+ * @returns {boolean}
147
+ */
148
+ isHybridMode() {
149
+ var _a;
150
+ return (((_a = this._config) === null || _a === void 0 ? void 0 : _a.authMode) || "frontchannel").toLowerCase() === "hybrid";
151
+ }
65
152
  /**
66
153
  * Load TideCloak configuration and instantiate the client once.
67
154
  * @param {Object} config - TideCloak configuration object.
68
155
  * @returns {Promise<Object|null>} The loaded config, or null on failure.
69
156
  */
70
157
  async loadConfig(config) {
71
- if (this._tc)
158
+ if (this._config)
72
159
  return this._config;
73
160
  if (!config || Object.keys(config).length === 0) {
74
161
  console.warn("[loadConfig] empty config");
75
162
  return null;
76
163
  }
77
164
  this._config = config;
165
+ // Hybrid mode: do not construct TideCloak client (tokens are server-side)
166
+ if (this.isHybridMode()) {
167
+ return this._config;
168
+ }
78
169
  try {
79
170
  this._tc = new TideCloak({
80
171
  url: config["auth-server-url"],
@@ -101,6 +192,7 @@ class IAMService {
101
192
  }
102
193
  /**
103
194
  * Initialize the TideCloak SSO client with silent SSO check.
195
+ * In hybrid mode, handles the redirect callback if present.
104
196
  * @param {Object} config - TideCloak configuration object.
105
197
  * @param {Function} [onReady] - Optional callback for the 'ready' event.
106
198
  * @returns {Promise<boolean>} true if authenticated, else false.
@@ -122,6 +214,41 @@ class IAMService {
122
214
  this._emit("initError", new Error("Failed to load config"));
123
215
  return false;
124
216
  }
217
+ // --- Hybrid mode init: handle redirect callback if present ---
218
+ if (this.isHybridMode()) {
219
+ // Guard against React StrictMode double-execution
220
+ if (this._hybridCallbackHandled) {
221
+ console.debug("[IAMService] Hybrid callback already handled");
222
+ // If there's a pending token exchange, wait for it instead of returning stale state
223
+ if (this._hybridCallbackPromise) {
224
+ console.debug("[IAMService] Waiting for pending token exchange...");
225
+ return this._hybridCallbackPromise;
226
+ }
227
+ this._emit("ready", this._hybridAuthenticated);
228
+ return this._hybridAuthenticated;
229
+ }
230
+ // Check if this is a callback page (has code) - mark as handled BEFORE processing
231
+ const qs = new URLSearchParams(window.location.search);
232
+ if (qs.get("code")) {
233
+ this._hybridCallbackHandled = true;
234
+ }
235
+ // Store the promise so subsequent calls can wait for it (React StrictMode)
236
+ this._hybridCallbackPromise = (async () => {
237
+ const { handled, authenticated, returnUrl } = await this._handleHybridRedirectCallback({
238
+ onMissingVerifierRedirectTo: "/login",
239
+ });
240
+ this._hybridReturnUrl = returnUrl || null;
241
+ // If we weren't on a callback page, emit ready with current state (defaults false)
242
+ if (!handled) {
243
+ this._emit("ready", this._hybridAuthenticated);
244
+ }
245
+ // Clear the promise once complete
246
+ this._hybridCallbackPromise = null;
247
+ return authenticated || this._hybridAuthenticated;
248
+ })();
249
+ return this._hybridCallbackPromise;
250
+ }
251
+ // --- Front-channel mode ---
125
252
  if (!this._tc) {
126
253
  const err = new Error("TideCloak client not available");
127
254
  this._emit("initError", err);
@@ -164,60 +291,128 @@ class IAMService {
164
291
  }
165
292
  return this._config;
166
293
  }
167
- /** @returns {boolean} Whether there's a valid token */
294
+ /** @returns {boolean} Whether there's a valid token (or session in hybrid mode) */
168
295
  isLoggedIn() {
296
+ if (this.isHybridMode())
297
+ return this._hybridAuthenticated;
169
298
  return !!this.getTideCloakClient().token;
170
299
  }
171
- /** @returns {Promise<string>} Valid token (refreshing if needed) */
300
+ /**
301
+ * Get valid token (refreshing if needed).
302
+ * @returns {Promise<string>}
303
+ * @throws {Error} In hybrid mode (tokens are server-side)
304
+ */
172
305
  async getToken() {
306
+ if (this.isHybridMode()) {
307
+ throw new Error("getToken() not available in hybrid mode - tokens are server-side");
308
+ }
173
309
  const exp = this.getTokenExp();
174
310
  if (exp < 3)
175
311
  await this.updateIAMToken();
176
312
  return this.getTideCloakClient().token;
177
313
  }
178
- /** Seconds until token expiry */
314
+ /**
315
+ * Seconds until token expiry.
316
+ * @returns {number}
317
+ * @throws {Error} In hybrid mode (tokens are server-side)
318
+ */
179
319
  getTokenExp() {
320
+ if (this.isHybridMode()) {
321
+ throw new Error("getTokenExp() not available in hybrid mode - tokens are server-side");
322
+ }
180
323
  const kc = this.getTideCloakClient();
181
324
  return Math.round(kc.tokenParsed.exp + kc.timeSkew - Date.now() / 1000);
182
325
  }
183
- /** @returns {string} ID token */
326
+ /**
327
+ * Get ID token.
328
+ * @returns {string}
329
+ * @throws {Error} In hybrid mode (tokens are server-side)
330
+ */
184
331
  getIDToken() {
332
+ if (this.isHybridMode()) {
333
+ throw new Error("getIDToken() not available in hybrid mode - tokens are server-side");
334
+ }
185
335
  return this.getTideCloakClient().idToken;
186
336
  }
187
- /** @returns {string} Username (preferred_username claim) */
337
+ /**
338
+ * Get username (preferred_username claim).
339
+ * @returns {string}
340
+ * @throws {Error} In hybrid mode (tokens are server-side)
341
+ */
188
342
  getName() {
343
+ if (this.isHybridMode()) {
344
+ throw new Error("getName() not available in hybrid mode - tokens are server-side");
345
+ }
189
346
  return this.getTideCloakClient().tokenParsed.preferred_username;
190
347
  }
191
348
  /**
192
- * @param {string} role - the name of the role to check
193
- * @returns {boolean} Whether the user has a given realm role */
349
+ * Get the return URL after successful hybrid authentication.
350
+ * Only available in hybrid mode after successful auth.
351
+ * @returns {string|null}
352
+ */
353
+ getReturnUrl() {
354
+ return this._hybridReturnUrl;
355
+ }
356
+ /**
357
+ * Check if user has a realm role.
358
+ * @param {string} role - the name of the role to check
359
+ * @returns {boolean} Whether the user has a given realm role
360
+ * @throws {Error} In hybrid mode (role checks not available client-side)
361
+ */
194
362
  hasRealmRole(role) {
363
+ if (this.isHybridMode()) {
364
+ throw new Error("hasRealmRole() not available in hybrid mode - tokens are server-side");
365
+ }
195
366
  return this.getTideCloakClient().hasRealmRole(role);
196
367
  }
197
368
  /**
369
+ * Check if user has a client role.
198
370
  * @param {string} role - the name of the role to check
199
371
  * @param {string} [client] - optional client-ID (defaults to the configured adapter resource)
200
372
  * @returns {boolean} - whether the user has that role
373
+ * @throws {Error} In hybrid mode (role checks not available client-side)
201
374
  */
202
375
  hasClientRole(role, client) {
376
+ if (this.isHybridMode()) {
377
+ throw new Error("hasClientRole() not available in hybrid mode - tokens are server-side");
378
+ }
203
379
  return this.getTideCloakClient().hasResourceRole(role, client);
204
380
  }
205
381
  /**
382
+ * Get custom claim from access token.
206
383
  * @param {string} key - The name of the claim to retrieve from the Access token's payload.
207
- * @returns {*} Custom claim from access token */
384
+ * @returns {*} Custom claim from access token
385
+ * @throws {Error} In hybrid mode (tokens are server-side)
386
+ */
208
387
  getValueFromToken(key) {
209
388
  var _a;
389
+ if (this.isHybridMode()) {
390
+ throw new Error("getValueFromToken() not available in hybrid mode - tokens are server-side");
391
+ }
210
392
  return (_a = this.getTideCloakClient().tokenParsed[key]) !== null && _a !== void 0 ? _a : null;
211
393
  }
212
394
  /**
395
+ * Get custom claim from ID token.
213
396
  * @param {string} key - The name of the claim to retrieve from the ID token's payload.
214
- * @returns {*} Custom claim from access token */
397
+ * @returns {*} Custom claim from ID token
398
+ * @throws {Error} In hybrid mode (tokens are server-side)
399
+ */
215
400
  getValueFromIDToken(key) {
216
401
  var _a;
402
+ if (this.isHybridMode()) {
403
+ throw new Error("getValueFromIDToken() not available in hybrid mode - tokens are server-side");
404
+ }
217
405
  return (_a = this.getTideCloakClient().idTokenParsed[key]) !== null && _a !== void 0 ? _a : null;
218
406
  }
219
- /** Refreshes token if expired or about to expire */
407
+ /**
408
+ * Refreshes token if expired or about to expire.
409
+ * @returns {Promise<boolean>}
410
+ * @throws {Error} In hybrid mode (token refresh handled server-side)
411
+ */
220
412
  async updateIAMToken() {
413
+ if (this.isHybridMode()) {
414
+ throw new Error("updateIAMToken() not available in hybrid mode - tokens are server-side");
415
+ }
221
416
  const kc = this.getTideCloakClient();
222
417
  const refreshed = await kc.updateToken();
223
418
  const expiresIn = this.getTokenExp();
@@ -227,8 +422,15 @@ class IAMService {
227
422
  document.cookie = `kcToken=${kc.token}; path=/;`;
228
423
  return refreshed;
229
424
  }
230
- /** Force immediate refresh (min validity = -1) */
425
+ /**
426
+ * Force immediate refresh (min validity = -1).
427
+ * @returns {Promise<boolean>}
428
+ * @throws {Error} In hybrid mode (token refresh handled server-side)
429
+ */
231
430
  async forceUpdateToken() {
431
+ if (this.isHybridMode()) {
432
+ throw new Error("forceUpdateToken() not available in hybrid mode - tokens are server-side");
433
+ }
232
434
  document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
233
435
  const kc = this.getTideCloakClient();
234
436
  const refreshed = await kc.updateToken(-1);
@@ -239,34 +441,332 @@ class IAMService {
239
441
  document.cookie = `kcToken=${kc.token}; path=/;`;
240
442
  return refreshed;
241
443
  }
242
- /** Start login redirect */
243
- doLogin() {
244
- var _a;
444
+ /**
445
+ * Start login redirect.
446
+ * In hybrid mode, initiates PKCE flow and redirects to IdP.
447
+ * @param {string} [returnUrl] - URL to redirect to after successful auth (hybrid mode only)
448
+ */
449
+ doLogin(returnUrl = "") {
450
+ var _a, _b;
451
+ console.debug("[IAMService.doLogin] Called with returnUrl:", returnUrl);
452
+ console.debug("[IAMService.doLogin] isHybridMode:", this.isHybridMode());
453
+ console.debug("[IAMService.doLogin] authMode config:", (_a = this._config) === null || _a === void 0 ? void 0 : _a.authMode);
454
+ if (this.isHybridMode()) {
455
+ // Catch and log any errors from the async function
456
+ return this._startHybridLogin(returnUrl).catch(err => {
457
+ console.error("[IAMService.doLogin] Error in hybrid login:", err);
458
+ throw err;
459
+ });
460
+ }
245
461
  this.getTideCloakClient().login({
246
- redirectUri: (_a = this._config["redirectUri"]) !== null && _a !== void 0 ? _a : `${window.location.origin}/auth/redirect`
462
+ redirectUri: (_b = this._config["redirectUri"]) !== null && _b !== void 0 ? _b : `${window.location.origin}/auth/redirect`
247
463
  });
248
464
  }
249
- /** Encrypt data via adapter */
465
+ /**
466
+ * Encrypt data via adapter.
467
+ * Not available in hybrid mode (encryption requires client-side doken).
468
+ */
250
469
  async doEncrypt(data) {
470
+ if (this.isHybridMode()) {
471
+ throw new Error("Encrypt not supported in hybrid mode (tokens are server-side)");
472
+ }
251
473
  return this.getTideCloakClient().encrypt(data);
252
474
  }
253
- /** Decrypt data via adapter */
475
+ /**
476
+ * Decrypt data via adapter.
477
+ * Not available in hybrid mode (decryption requires client-side doken).
478
+ */
254
479
  async doDecrypt(data) {
480
+ if (this.isHybridMode()) {
481
+ throw new Error("Decrypt not supported in hybrid mode (tokens are server-side)");
482
+ }
255
483
  return this.getTideCloakClient().decrypt(data);
256
484
  }
257
- /** Logout, clear cookie, then redirect */
485
+ /**
486
+ * Logout, clear cookie/session, then redirect.
487
+ * In hybrid mode, clears local state and emits logout event.
488
+ */
258
489
  doLogout() {
259
490
  var _a;
491
+ if (this.isHybridMode()) {
492
+ this._hybridAuthenticated = false;
493
+ this._hybridReturnUrl = null;
494
+ this._emit("logout");
495
+ return;
496
+ }
260
497
  document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
261
498
  this.getTideCloakClient().logout({
262
499
  redirectUri: (_a = this._config["redirectUri"]) !== null && _a !== void 0 ? _a : `${window.location.origin}/auth/redirect`
263
500
  });
264
501
  }
265
- /** Base URL for Tidecloak realm (no trailing slash) */
502
+ /**
503
+ * Base URL for Tidecloak realm (no trailing slash).
504
+ * In hybrid mode returns empty string.
505
+ */
266
506
  getBaseUrl() {
267
507
  var _a, _b;
508
+ if (this.isHybridMode())
509
+ return "";
268
510
  return ((_b = (_a = this._config) === null || _a === void 0 ? void 0 : _a["auth-server-url"]) === null || _b === void 0 ? void 0 : _b.replace(/\/$/, "")) || "";
269
511
  }
512
+ // ---------------------------------------------------------------------------
513
+ // HYBRID MODE SUPPORT (private helpers)
514
+ // ---------------------------------------------------------------------------
515
+ /**
516
+ * Start hybrid login flow: generate PKCE, store verifier, redirect to IdP.
517
+ * @private
518
+ * @param {string} returnUrl - URL to redirect to after successful auth
519
+ */
520
+ async _startHybridLogin(returnUrl = "") {
521
+ var _a, _b;
522
+ if (typeof window === "undefined") {
523
+ throw new Error("Cannot login in SSR context");
524
+ }
525
+ const oidc = (_a = this._config) === null || _a === void 0 ? void 0 : _a.oidc;
526
+ const tokenExchange = (_b = this._config) === null || _b === void 0 ? void 0 : _b.tokenExchange;
527
+ console.debug("[IAMService._startHybridLogin] Config:", {
528
+ authorizationEndpoint: oidc === null || oidc === void 0 ? void 0 : oidc.authorizationEndpoint,
529
+ clientId: oidc === null || oidc === void 0 ? void 0 : oidc.clientId,
530
+ redirectUri: oidc === null || oidc === void 0 ? void 0 : oidc.redirectUri,
531
+ tokenExchangeEndpoint: tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.endpoint,
532
+ });
533
+ if (!(oidc === null || oidc === void 0 ? void 0 : oidc.authorizationEndpoint) || !(oidc === null || oidc === void 0 ? void 0 : oidc.clientId) || !(oidc === null || oidc === void 0 ? void 0 : oidc.redirectUri)) {
534
+ throw new Error("Hybrid mode requires config.oidc.authorizationEndpoint, clientId, and redirectUri");
535
+ }
536
+ if (!(tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.endpoint)) {
537
+ throw new Error("Hybrid mode requires config.tokenExchange.endpoint");
538
+ }
539
+ console.debug("[IAMService] Generating PKCE...");
540
+ const { verifier, challenge, method } = await makePkce();
541
+ console.debug("[IAMService] PKCE generated, verifier length:", verifier.length);
542
+ // Store PKCE verifier and return URL in sessionStorage
543
+ sessionStorage.setItem("kc_pkce_verifier", verifier);
544
+ sessionStorage.setItem("kc_return_url", returnUrl || "");
545
+ // Verify storage worked
546
+ const storedVerifier = sessionStorage.getItem("kc_pkce_verifier");
547
+ console.debug("[IAMService] Stored verifier in sessionStorage, retrieved length:", storedVerifier === null || storedVerifier === void 0 ? void 0 : storedVerifier.length);
548
+ // Encode return URL in state parameter
549
+ const state = returnUrl ? `__url_${returnUrl}` : "";
550
+ const scope = oidc.scope || "openid profile email";
551
+ const authUrl = `${oidc.authorizationEndpoint}` +
552
+ `?client_id=${encodeURIComponent(oidc.clientId)}` +
553
+ `&redirect_uri=${encodeURIComponent(oidc.redirectUri)}` +
554
+ `&response_type=code` +
555
+ `&scope=${encodeURIComponent(scope)}` +
556
+ `&state=${encodeURIComponent(state)}` +
557
+ `&code_challenge=${encodeURIComponent(challenge)}` +
558
+ `&code_challenge_method=${encodeURIComponent(method)}` +
559
+ (oidc.prompt ? `&prompt=${encodeURIComponent(oidc.prompt)}` : "");
560
+ console.debug("[IAMService] Redirecting to:", authUrl.substring(0, 100) + "...");
561
+ window.location.assign(authUrl);
562
+ }
563
+ /**
564
+ * Get hybrid callback data for custom token exchange.
565
+ * Use this when you need to handle token exchange with your own auth system
566
+ * (e.g., using a custom useLogin hook) instead of IAMService's built-in fetchJson.
567
+ *
568
+ * @param {Object} [opts] - Options
569
+ * @param {boolean} [opts.clearStorage=true] - Whether to clear sessionStorage after reading
570
+ * @param {string} [opts.redirectUri] - Override redirect URI (use when config isn't loaded)
571
+ * @param {string} [opts.provider] - Override provider (defaults to "tidecloak-auth")
572
+ * @returns {{
573
+ * isCallback: boolean,
574
+ * code: string,
575
+ * verifier: string,
576
+ * redirectUri: string,
577
+ * returnUrl: string,
578
+ * provider: string,
579
+ * error: string|null,
580
+ * errorDescription: string|null
581
+ * }}
582
+ *
583
+ * @example
584
+ * // With redirectUri override (recommended for callback pages after full navigation)
585
+ * const data = IAMService.getHybridCallbackData({
586
+ * redirectUri: process.env.KEYCLOAK_REDIRECTURI,
587
+ * });
588
+ * if (data.isCallback && data.code && data.verifier) {
589
+ * login.execute({
590
+ * accessToken: JSON.stringify({
591
+ * code: data.code,
592
+ * code_verifier: data.verifier,
593
+ * redirect_uri: data.redirectUri,
594
+ * }),
595
+ * provider: data.provider,
596
+ * });
597
+ * }
598
+ */
599
+ getHybridCallbackData(opts = {}) {
600
+ var _a, _b, _c, _d;
601
+ const { clearStorage = true, redirectUri: optsRedirectUri, provider: optsProvider } = opts;
602
+ if (typeof window === "undefined") {
603
+ return {
604
+ isCallback: false,
605
+ code: "",
606
+ verifier: "",
607
+ redirectUri: "",
608
+ returnUrl: "",
609
+ provider: "",
610
+ error: null,
611
+ errorDescription: null,
612
+ };
613
+ }
614
+ // Return cached data if available (prevents data loss from multiple calls or initIAM clearing storage)
615
+ if (this._cachedCallbackData) {
616
+ console.debug("[IAMService.getHybridCallbackData] Returning cached data");
617
+ // Allow overriding redirectUri and provider even when returning cached data
618
+ return {
619
+ ...this._cachedCallbackData,
620
+ redirectUri: optsRedirectUri || this._cachedCallbackData.redirectUri,
621
+ provider: optsProvider || this._cachedCallbackData.provider,
622
+ };
623
+ }
624
+ const qs = new URLSearchParams(window.location.search);
625
+ const error = qs.get("error");
626
+ const errorDescription = qs.get("error_description");
627
+ const code = qs.get("code") || "";
628
+ const state = qs.get("state") || "";
629
+ // Decode return URL from state, fallback to sessionStorage
630
+ const stateReturnUrl = state.startsWith("__url_") ? state.substring(6) : "";
631
+ const returnUrl = stateReturnUrl || sessionStorage.getItem("kc_return_url") || "";
632
+ const verifier = sessionStorage.getItem("kc_pkce_verifier") || "";
633
+ // Use opts override, then config, then empty string
634
+ const redirectUri = optsRedirectUri || ((_b = (_a = this._config) === null || _a === void 0 ? void 0 : _a.oidc) === null || _b === void 0 ? void 0 : _b.redirectUri) || "";
635
+ const provider = optsProvider || ((_d = (_c = this._config) === null || _c === void 0 ? void 0 : _c.tokenExchange) === null || _d === void 0 ? void 0 : _d.provider) || "tidecloak-auth";
636
+ const isCallback = !!(code || error);
637
+ console.debug("[IAMService.getHybridCallbackData] code:", code ? code.substring(0, 20) + "..." : "(empty)");
638
+ console.debug("[IAMService.getHybridCallbackData] verifier:", verifier ? `(length: ${verifier.length})` : "(empty)");
639
+ console.debug("[IAMService.getHybridCallbackData] redirectUri:", redirectUri);
640
+ console.debug("[IAMService.getHybridCallbackData] returnUrl:", returnUrl);
641
+ console.debug("[IAMService.getHybridCallbackData] clearStorage:", clearStorage, "isCallback:", isCallback);
642
+ const data = {
643
+ isCallback,
644
+ code,
645
+ verifier,
646
+ redirectUri,
647
+ returnUrl,
648
+ provider,
649
+ error,
650
+ errorDescription,
651
+ };
652
+ // Cache the data before clearing storage
653
+ if (isCallback && verifier) {
654
+ this._cachedCallbackData = data;
655
+ console.debug("[IAMService.getHybridCallbackData] Cached callback data");
656
+ }
657
+ if (clearStorage && isCallback) {
658
+ sessionStorage.removeItem("kc_pkce_verifier");
659
+ sessionStorage.removeItem("kc_return_url");
660
+ console.debug("[IAMService.getHybridCallbackData] Cleared sessionStorage");
661
+ }
662
+ return data;
663
+ }
664
+ /**
665
+ * Handle hybrid redirect callback: exchange code for tokens via backend endpoint.
666
+ * @private
667
+ * @param {Object} opts - Options
668
+ * @param {string} [opts.onMissingVerifierRedirectTo] - URL to redirect if verifier is missing
669
+ * @returns {Promise<{handled: boolean, authenticated: boolean, returnUrl: string}>}
670
+ */
671
+ async _handleHybridRedirectCallback(opts = {}) {
672
+ var _a, _b, _c, _d, _e;
673
+ if (typeof window === "undefined") {
674
+ return { handled: false, authenticated: false, returnUrl: "" };
675
+ }
676
+ const qs = new URLSearchParams(window.location.search);
677
+ const error = qs.get("error");
678
+ const errorDescription = qs.get("error_description") || "An error occurred";
679
+ const code = qs.get("code") || "";
680
+ const state = qs.get("state") || "";
681
+ // Decode return URL from state, fallback to sessionStorage (Keycloak broker modifies state)
682
+ const stateReturnUrl = state.startsWith("__url_") ? state.substring(6) : "";
683
+ const returnUrl = stateReturnUrl || sessionStorage.getItem("kc_return_url") || "";
684
+ // Handle error response from IdP
685
+ if (error) {
686
+ this._emit("authError", new Error(`${error}: ${errorDescription}`));
687
+ this._emit("ready", false);
688
+ return { handled: true, authenticated: false, returnUrl };
689
+ }
690
+ // No code = not a callback page
691
+ if (!code) {
692
+ console.debug("[IAMService] No code in URL, not a callback page");
693
+ return { handled: false, authenticated: false, returnUrl: "" };
694
+ }
695
+ console.debug("[IAMService] Code found in URL, checking for PKCE verifier...");
696
+ const verifier = sessionStorage.getItem("kc_pkce_verifier") || "";
697
+ const redirectUri = ((_b = (_a = this._config) === null || _a === void 0 ? void 0 : _a.oidc) === null || _b === void 0 ? void 0 : _b.redirectUri) || "";
698
+ console.debug("[IAMService] Retrieved verifier from sessionStorage, length:", verifier.length);
699
+ console.debug("[IAMService] Current origin:", window.location.origin);
700
+ // Cache the callback data so getHybridCallbackData() can access it even after we clear storage
701
+ if (code && verifier) {
702
+ const provider = ((_d = (_c = this._config) === null || _c === void 0 ? void 0 : _c.tokenExchange) === null || _d === void 0 ? void 0 : _d.provider) || "tidecloak-auth";
703
+ this._cachedCallbackData = {
704
+ isCallback: true,
705
+ code,
706
+ verifier,
707
+ redirectUri,
708
+ returnUrl,
709
+ provider,
710
+ error: null,
711
+ errorDescription: null,
712
+ };
713
+ console.debug("[IAMService] Cached callback data from _handleHybridRedirectCallback");
714
+ }
715
+ // Code present but verifier missing (e.g., page refresh after verifier consumed)
716
+ if (code.length > 0 && verifier.length === 0) {
717
+ console.error("[IAMService] PKCE verifier missing! Code present but no verifier in sessionStorage.");
718
+ console.debug("[IAMService] All sessionStorage keys:", Object.keys(sessionStorage));
719
+ if (opts.onMissingVerifierRedirectTo) {
720
+ window.location.assign(opts.onMissingVerifierRedirectTo);
721
+ }
722
+ this._emit("authError", new Error("Missing PKCE verifier (likely page refresh after it was consumed)"));
723
+ this._emit("ready", false);
724
+ return { handled: true, authenticated: false, returnUrl };
725
+ }
726
+ // Clear session storage
727
+ sessionStorage.removeItem("kc_pkce_verifier");
728
+ sessionStorage.removeItem("kc_return_url");
729
+ const tokenExchange = (_e = this._config) === null || _e === void 0 ? void 0 : _e.tokenExchange;
730
+ const exchangeEndpoint = tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.endpoint;
731
+ const provider = (tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.provider) || "tidecloak-auth";
732
+ // Support custom headers (static object or dynamic function)
733
+ const customHeaders = typeof (tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.headers) === "function"
734
+ ? tokenExchange.headers()
735
+ : ((tokenExchange === null || tokenExchange === void 0 ? void 0 : tokenExchange.headers) || {});
736
+ console.debug("[IAMService] Token exchange endpoint:", exchangeEndpoint);
737
+ console.debug("[IAMService] Custom headers:", customHeaders);
738
+ try {
739
+ // Exchange code for tokens via backend endpoint
740
+ // Payload format matches existing backend expectation
741
+ await fetchJson(exchangeEndpoint, {
742
+ method: "POST",
743
+ headers: customHeaders,
744
+ body: JSON.stringify({
745
+ accessToken: JSON.stringify({
746
+ code: code,
747
+ code_verifier: verifier,
748
+ redirect_uri: redirectUri,
749
+ }),
750
+ provider,
751
+ }),
752
+ });
753
+ this._hybridAuthenticated = true;
754
+ this._hybridReturnUrl = returnUrl || null; // Set BEFORE emitting so getReturnUrl() works in handler
755
+ this._emit("authSuccess");
756
+ // Clean URL (remove code, state, etc.)
757
+ const url = new URL(window.location.href);
758
+ ["code", "state", "session_state", "iss", "error", "error_description"].forEach(k => url.searchParams.delete(k));
759
+ window.history.replaceState({}, document.title, url.toString());
760
+ this._emit("ready", true);
761
+ return { handled: true, authenticated: true, returnUrl };
762
+ }
763
+ catch (err) {
764
+ this._hybridAuthenticated = false;
765
+ this._emit("authError", err);
766
+ this._emit("ready", false);
767
+ return { handled: true, authenticated: false, returnUrl };
768
+ }
769
+ }
270
770
  }
271
771
  const IAMServiceInstance = new IAMService();
272
772
  export { IAMServiceInstance as IAMService };