@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.
- package/README.md +170 -5
- package/dist/cjs/src/IAMService.js +522 -22
- package/dist/cjs/src/IAMService.js.map +1 -1
- package/dist/cjs/src/utils/fetch.js +36 -0
- package/dist/cjs/src/utils/fetch.js.map +1 -0
- package/dist/cjs/src/utils/index.js +3 -0
- package/dist/cjs/src/utils/index.js.map +1 -0
- package/dist/cjs/src/utils/pkce.js +43 -0
- package/dist/cjs/src/utils/pkce.js.map +1 -0
- package/dist/esm/src/IAMService.js +522 -22
- package/dist/esm/src/IAMService.js.map +1 -1
- package/dist/esm/src/utils/fetch.js +36 -0
- package/dist/esm/src/utils/fetch.js.map +1 -0
- package/dist/esm/src/utils/index.js +3 -0
- package/dist/esm/src/utils/index.js.map +1 -0
- package/dist/esm/src/utils/pkce.js +43 -0
- package/dist/esm/src/utils/pkce.js.map +1 -0
- package/dist/types/IAMService.d.ts +241 -19
- package/dist/types/src/IAMService.d.ts +246 -23
- package/dist/types/src/utils/fetch.d.ts +11 -0
- package/dist/types/src/utils/index.d.ts +2 -0
- package/dist/types/src/utils/pkce.d.ts +24 -0
- package/dist/types/utils/fetch.d.ts +11 -0
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/utils/pkce.d.ts +24 -0
- package/package.json +2 -2
|
@@ -1,10 +1,18 @@
|
|
|
1
|
-
import
|
|
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
|
|
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.
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
193
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
243
|
-
|
|
244
|
-
|
|
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: (
|
|
462
|
+
redirectUri: (_b = this._config["redirectUri"]) !== null && _b !== void 0 ? _b : `${window.location.origin}/auth/redirect`
|
|
247
463
|
});
|
|
248
464
|
}
|
|
249
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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 };
|