@tidecloak/js 0.13.1 → 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.
Files changed (48) hide show
  1. package/README.md +170 -5
  2. package/dist/cjs/lib/tidecloak.js +1759 -1629
  3. package/dist/cjs/lib/tidecloak.js.map +1 -1
  4. package/dist/cjs/src/IAMService.js +526 -32
  5. package/dist/cjs/src/IAMService.js.map +1 -1
  6. package/dist/cjs/src/index.js +4 -21
  7. package/dist/cjs/src/index.js.map +1 -1
  8. package/dist/cjs/src/policy-react.js +3 -0
  9. package/dist/cjs/src/policy-react.js.map +1 -0
  10. package/dist/cjs/src/policy.css +1 -0
  11. package/dist/cjs/src/utils/fetch.js +36 -0
  12. package/dist/cjs/src/utils/fetch.js.map +1 -0
  13. package/dist/cjs/src/utils/index.js +3 -0
  14. package/dist/cjs/src/utils/index.js.map +1 -0
  15. package/dist/cjs/src/utils/pkce.js +43 -0
  16. package/dist/cjs/src/utils/pkce.js.map +1 -0
  17. package/dist/esm/lib/tidecloak.js +1760 -1619
  18. package/dist/esm/lib/tidecloak.js.map +1 -1
  19. package/dist/esm/src/IAMService.js +523 -23
  20. package/dist/esm/src/IAMService.js.map +1 -1
  21. package/dist/esm/src/index.js +2 -6
  22. package/dist/esm/src/index.js.map +1 -1
  23. package/dist/esm/src/policy-react.js +3 -0
  24. package/dist/esm/src/policy-react.js.map +1 -0
  25. package/dist/esm/src/policy.css +1 -0
  26. package/dist/esm/src/utils/fetch.js +36 -0
  27. package/dist/esm/src/utils/fetch.js.map +1 -0
  28. package/dist/esm/src/utils/index.js +3 -0
  29. package/dist/esm/src/utils/index.js.map +1 -0
  30. package/dist/esm/src/utils/pkce.js +43 -0
  31. package/dist/esm/src/utils/pkce.js.map +1 -0
  32. package/dist/types/IAMService.d.ts +328 -0
  33. package/dist/types/index.d.ts +3 -0
  34. package/dist/types/lib/tidecloak.d.ts +325 -31
  35. package/dist/types/policy-react.d.ts +1 -0
  36. package/dist/types/src/IAMService.d.ts +245 -23
  37. package/dist/types/src/index.d.ts +2 -2
  38. package/dist/types/src/policy-react.d.ts +1 -0
  39. package/dist/types/src/utils/fetch.d.ts +11 -0
  40. package/dist/types/src/utils/index.d.ts +2 -0
  41. package/dist/types/src/utils/pkce.d.ts +24 -0
  42. package/dist/types/utils/fetch.d.ts +11 -0
  43. package/dist/types/utils/index.d.ts +2 -0
  44. package/dist/types/utils/pkce.d.ts +24 -0
  45. package/package.json +18 -29
  46. package/scripts/postinstall.cjs +36 -0
  47. package/silent-check-sso.html +1 -0
  48. package/scripts/postinstall.js +0 -43
@@ -1,16 +1,18 @@
1
- "use strict";
2
- var __importDefault = (this && this.__importDefault) || function (mod) {
3
- return (mod && mod.__esModule) ? mod : { "default": mod };
4
- };
5
- Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.IAMService = void 0;
7
- const tidecloak_1 = __importDefault(require("../lib/tidecloak"));
1
+ import { makePkce, fetchJson } from "./utils/index.js";
2
+ import TideCloak from "../lib/tidecloak.js";
8
3
  /**
9
4
  * Singleton IAMService wrapping the TideCloak client.
10
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
+ *
11
13
  * Usage A: pass an onReady callback directly
12
14
  * ```js
13
- * import { IAMService } from 'tidecloak-js';
15
+ * import { IAMService } from '@tidecloak/js';
14
16
  * import tidecloakConfig from './tidecloakAdapter.json';
15
17
  *
16
18
  * IAMService.initIAM(tidecloakConfig, authenticated => {
@@ -26,12 +28,83 @@ const tidecloak_1 = __importDefault(require("../lib/tidecloak"));
26
28
  *
27
29
  * await IAMService.initIAM(tidecloakConfig);
28
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)
29
96
  */
30
97
  class IAMService {
31
98
  constructor() {
32
99
  this._tc = null;
33
100
  this._config = null;
34
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
35
108
  }
36
109
  /**
37
110
  * Register an event listener.
@@ -68,21 +141,33 @@ class IAMService {
68
141
  }
69
142
  });
70
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
+ }
71
152
  /**
72
153
  * Load TideCloak configuration and instantiate the client once.
73
154
  * @param {Object} config - TideCloak configuration object.
74
155
  * @returns {Promise<Object|null>} The loaded config, or null on failure.
75
156
  */
76
157
  async loadConfig(config) {
77
- if (this._tc)
158
+ if (this._config)
78
159
  return this._config;
79
160
  if (!config || Object.keys(config).length === 0) {
80
161
  console.warn("[loadConfig] empty config");
81
162
  return null;
82
163
  }
83
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
+ }
84
169
  try {
85
- this._tc = new tidecloak_1.default({
170
+ this._tc = new TideCloak({
86
171
  url: config["auth-server-url"],
87
172
  realm: config.realm,
88
173
  clientId: config.resource,
@@ -107,6 +192,7 @@ class IAMService {
107
192
  }
108
193
  /**
109
194
  * Initialize the TideCloak SSO client with silent SSO check.
195
+ * In hybrid mode, handles the redirect callback if present.
110
196
  * @param {Object} config - TideCloak configuration object.
111
197
  * @param {Function} [onReady] - Optional callback for the 'ready' event.
112
198
  * @returns {Promise<boolean>} true if authenticated, else false.
@@ -128,6 +214,41 @@ class IAMService {
128
214
  this._emit("initError", new Error("Failed to load config"));
129
215
  return false;
130
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 ---
131
252
  if (!this._tc) {
132
253
  const err = new Error("TideCloak client not available");
133
254
  this._emit("initError", err);
@@ -135,7 +256,7 @@ class IAMService {
135
256
  }
136
257
  if (this._tc.didInitialize) {
137
258
  console.debug("[IAMService] IAM Already initialized once.");
138
- return this._tc.isLoggedIn();
259
+ return !!this._tc.tokenParsed;
139
260
  }
140
261
  let authenticated = false;
141
262
  try {
@@ -170,60 +291,128 @@ class IAMService {
170
291
  }
171
292
  return this._config;
172
293
  }
173
- /** @returns {boolean} Whether there's a valid token */
294
+ /** @returns {boolean} Whether there's a valid token (or session in hybrid mode) */
174
295
  isLoggedIn() {
296
+ if (this.isHybridMode())
297
+ return this._hybridAuthenticated;
175
298
  return !!this.getTideCloakClient().token;
176
299
  }
177
- /** @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
+ */
178
305
  async getToken() {
306
+ if (this.isHybridMode()) {
307
+ throw new Error("getToken() not available in hybrid mode - tokens are server-side");
308
+ }
179
309
  const exp = this.getTokenExp();
180
310
  if (exp < 3)
181
311
  await this.updateIAMToken();
182
312
  return this.getTideCloakClient().token;
183
313
  }
184
- /** Seconds until token expiry */
314
+ /**
315
+ * Seconds until token expiry.
316
+ * @returns {number}
317
+ * @throws {Error} In hybrid mode (tokens are server-side)
318
+ */
185
319
  getTokenExp() {
320
+ if (this.isHybridMode()) {
321
+ throw new Error("getTokenExp() not available in hybrid mode - tokens are server-side");
322
+ }
186
323
  const kc = this.getTideCloakClient();
187
324
  return Math.round(kc.tokenParsed.exp + kc.timeSkew - Date.now() / 1000);
188
325
  }
189
- /** @returns {string} ID token */
326
+ /**
327
+ * Get ID token.
328
+ * @returns {string}
329
+ * @throws {Error} In hybrid mode (tokens are server-side)
330
+ */
190
331
  getIDToken() {
332
+ if (this.isHybridMode()) {
333
+ throw new Error("getIDToken() not available in hybrid mode - tokens are server-side");
334
+ }
191
335
  return this.getTideCloakClient().idToken;
192
336
  }
193
- /** @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
+ */
194
342
  getName() {
343
+ if (this.isHybridMode()) {
344
+ throw new Error("getName() not available in hybrid mode - tokens are server-side");
345
+ }
195
346
  return this.getTideCloakClient().tokenParsed.preferred_username;
196
347
  }
197
348
  /**
198
- * @param {string} role - the name of the role to check
199
- * @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
+ */
200
362
  hasRealmRole(role) {
363
+ if (this.isHybridMode()) {
364
+ throw new Error("hasRealmRole() not available in hybrid mode - tokens are server-side");
365
+ }
201
366
  return this.getTideCloakClient().hasRealmRole(role);
202
367
  }
203
368
  /**
369
+ * Check if user has a client role.
204
370
  * @param {string} role - the name of the role to check
205
371
  * @param {string} [client] - optional client-ID (defaults to the configured adapter resource)
206
372
  * @returns {boolean} - whether the user has that role
373
+ * @throws {Error} In hybrid mode (role checks not available client-side)
207
374
  */
208
375
  hasClientRole(role, client) {
376
+ if (this.isHybridMode()) {
377
+ throw new Error("hasClientRole() not available in hybrid mode - tokens are server-side");
378
+ }
209
379
  return this.getTideCloakClient().hasResourceRole(role, client);
210
380
  }
211
381
  /**
382
+ * Get custom claim from access token.
212
383
  * @param {string} key - The name of the claim to retrieve from the Access token's payload.
213
- * @returns {*} Custom claim from access token */
384
+ * @returns {*} Custom claim from access token
385
+ * @throws {Error} In hybrid mode (tokens are server-side)
386
+ */
214
387
  getValueFromToken(key) {
215
388
  var _a;
389
+ if (this.isHybridMode()) {
390
+ throw new Error("getValueFromToken() not available in hybrid mode - tokens are server-side");
391
+ }
216
392
  return (_a = this.getTideCloakClient().tokenParsed[key]) !== null && _a !== void 0 ? _a : null;
217
393
  }
218
394
  /**
395
+ * Get custom claim from ID token.
219
396
  * @param {string} key - The name of the claim to retrieve from the ID token's payload.
220
- * @returns {*} Custom claim from access token */
397
+ * @returns {*} Custom claim from ID token
398
+ * @throws {Error} In hybrid mode (tokens are server-side)
399
+ */
221
400
  getValueFromIDToken(key) {
222
401
  var _a;
402
+ if (this.isHybridMode()) {
403
+ throw new Error("getValueFromIDToken() not available in hybrid mode - tokens are server-side");
404
+ }
223
405
  return (_a = this.getTideCloakClient().idTokenParsed[key]) !== null && _a !== void 0 ? _a : null;
224
406
  }
225
- /** 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
+ */
226
412
  async updateIAMToken() {
413
+ if (this.isHybridMode()) {
414
+ throw new Error("updateIAMToken() not available in hybrid mode - tokens are server-side");
415
+ }
227
416
  const kc = this.getTideCloakClient();
228
417
  const refreshed = await kc.updateToken();
229
418
  const expiresIn = this.getTokenExp();
@@ -233,8 +422,15 @@ class IAMService {
233
422
  document.cookie = `kcToken=${kc.token}; path=/;`;
234
423
  return refreshed;
235
424
  }
236
- /** 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
+ */
237
430
  async forceUpdateToken() {
431
+ if (this.isHybridMode()) {
432
+ throw new Error("forceUpdateToken() not available in hybrid mode - tokens are server-side");
433
+ }
238
434
  document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
239
435
  const kc = this.getTideCloakClient();
240
436
  const refreshed = await kc.updateToken(-1);
@@ -245,36 +441,334 @@ class IAMService {
245
441
  document.cookie = `kcToken=${kc.token}; path=/;`;
246
442
  return refreshed;
247
443
  }
248
- /** Start login redirect */
249
- doLogin() {
250
- 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
+ }
251
461
  this.getTideCloakClient().login({
252
- 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`
253
463
  });
254
464
  }
255
- /** Encrypt data via adapter */
465
+ /**
466
+ * Encrypt data via adapter.
467
+ * Not available in hybrid mode (encryption requires client-side doken).
468
+ */
256
469
  async doEncrypt(data) {
470
+ if (this.isHybridMode()) {
471
+ throw new Error("Encrypt not supported in hybrid mode (tokens are server-side)");
472
+ }
257
473
  return this.getTideCloakClient().encrypt(data);
258
474
  }
259
- /** Decrypt data via adapter */
475
+ /**
476
+ * Decrypt data via adapter.
477
+ * Not available in hybrid mode (decryption requires client-side doken).
478
+ */
260
479
  async doDecrypt(data) {
480
+ if (this.isHybridMode()) {
481
+ throw new Error("Decrypt not supported in hybrid mode (tokens are server-side)");
482
+ }
261
483
  return this.getTideCloakClient().decrypt(data);
262
484
  }
263
- /** 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
+ */
264
489
  doLogout() {
265
490
  var _a;
491
+ if (this.isHybridMode()) {
492
+ this._hybridAuthenticated = false;
493
+ this._hybridReturnUrl = null;
494
+ this._emit("logout");
495
+ return;
496
+ }
266
497
  document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
267
498
  this.getTideCloakClient().logout({
268
499
  redirectUri: (_a = this._config["redirectUri"]) !== null && _a !== void 0 ? _a : `${window.location.origin}/auth/redirect`
269
500
  });
270
501
  }
271
- /** 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
+ */
272
506
  getBaseUrl() {
273
507
  var _a, _b;
508
+ if (this.isHybridMode())
509
+ return "";
274
510
  return ((_b = (_a = this._config) === null || _a === void 0 ? void 0 : _a["auth-server-url"]) === null || _b === void 0 ? void 0 : _b.replace(/\/$/, "")) || "";
275
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
+ }
276
770
  }
277
771
  const IAMServiceInstance = new IAMService();
278
- exports.IAMService = IAMServiceInstance;
279
- exports.default = IAMServiceInstance;
772
+ export { IAMServiceInstance as IAMService };
773
+ export default IAMServiceInstance;
280
774
  //# sourceMappingURL=IAMService.js.map