@tidecloak/js 0.13.11 → 0.13.14
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 +18 -503
- package/dist/cjs/src/AdminAPI.js +419 -0
- package/dist/cjs/src/AdminAPI.js.map +1 -0
- package/dist/cjs/src/IAMService.js +924 -9
- package/dist/cjs/src/IAMService.js.map +1 -1
- package/dist/cjs/src/index.js +8 -0
- package/dist/cjs/src/index.js.map +1 -1
- package/dist/cjs/src/types.js +5 -0
- package/dist/cjs/src/types.js.map +1 -0
- package/dist/esm/src/AdminAPI.js +419 -0
- package/dist/esm/src/AdminAPI.js.map +1 -0
- package/dist/esm/src/IAMService.js +924 -9
- package/dist/esm/src/IAMService.js.map +1 -1
- package/dist/esm/src/index.js +8 -0
- package/dist/esm/src/index.js.map +1 -1
- package/dist/esm/src/types.js +5 -0
- package/dist/esm/src/types.js.map +1 -0
- package/dist/types/AdminAPI.d.ts +102 -0
- package/dist/types/IAMService.d.ts +160 -6
- package/dist/types/index.d.ts +6 -0
- package/dist/types/src/AdminAPI.d.ts +102 -0
- package/dist/types/src/IAMService.d.ts +160 -6
- package/dist/types/src/index.d.ts +6 -0
- package/dist/types/src/types.d.ts +112 -0
- package/dist/types/types.d.ts +112 -0
- package/package.json +2 -2
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { makePkce, fetchJson } from "./utils/index.js";
|
|
2
|
-
import TideCloak from "../lib/tidecloak.js";
|
|
2
|
+
import TideCloak, { RequestEnclave } from "../lib/tidecloak.js";
|
|
3
3
|
/**
|
|
4
4
|
* Singleton IAMService wrapping the TideCloak client.
|
|
5
5
|
*
|
|
6
|
-
* Supports
|
|
6
|
+
* Supports three modes:
|
|
7
7
|
* - **Front-channel mode**: Browser handles all token operations (standard OIDC)
|
|
8
8
|
* - **Hybrid/BFF mode**: Browser handles PKCE, backend exchanges code for tokens (more secure)
|
|
9
|
+
* - **Native mode**: External browser for login, app handles tokens via adapter (Electron, Tauri, React Native)
|
|
9
10
|
*
|
|
10
11
|
* ---
|
|
11
12
|
* ## Front-channel Mode
|
|
@@ -105,6 +106,21 @@ class IAMService {
|
|
|
105
106
|
this._hybridCallbackHandled = false; // Guard against React StrictMode double-execution
|
|
106
107
|
this._hybridCallbackPromise = null; // Promise for pending token exchange (for StrictMode)
|
|
107
108
|
this._cachedCallbackData = null; // Cache for getHybridCallbackData to prevent data loss
|
|
109
|
+
// --- Native mode state ---
|
|
110
|
+
this._nativeAdapter = null;
|
|
111
|
+
this._nativeAuthenticated = false;
|
|
112
|
+
this._nativeTokens = null;
|
|
113
|
+
this._nativeCallbackUnsubscribe = null;
|
|
114
|
+
this._nativeCallbackHandled = false;
|
|
115
|
+
this._nativeCallbackPromise = null;
|
|
116
|
+
this._nativeCallbackProcessing = false; // Guard against concurrent callback processing
|
|
117
|
+
// --- Native mode encryption state ---
|
|
118
|
+
this._nativeDoken = null;
|
|
119
|
+
this._nativeDokenParsed = null;
|
|
120
|
+
this._nativeVoucher = null; // Voucher fetched during login for encryption
|
|
121
|
+
this._nativeRequestEnclave = null;
|
|
122
|
+
this._nativeEncryptionCallbackUnsubscribe = null;
|
|
123
|
+
this._pendingEncryptionRequests = new Map(); // requestId -> { resolve, reject }
|
|
108
124
|
}
|
|
109
125
|
/**
|
|
110
126
|
* Register an event listener.
|
|
@@ -149,6 +165,14 @@ class IAMService {
|
|
|
149
165
|
var _a;
|
|
150
166
|
return (((_a = this._config) === null || _a === void 0 ? void 0 : _a.authMode) || "frontchannel").toLowerCase() === "hybrid";
|
|
151
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* Check if running in native mode.
|
|
170
|
+
* @returns {boolean}
|
|
171
|
+
*/
|
|
172
|
+
isNativeMode() {
|
|
173
|
+
var _a;
|
|
174
|
+
return (((_a = this._config) === null || _a === void 0 ? void 0 : _a.authMode) || "frontchannel").toLowerCase() === "native";
|
|
175
|
+
}
|
|
152
176
|
/**
|
|
153
177
|
* Load TideCloak configuration and instantiate the client once.
|
|
154
178
|
* @param {Object} config - TideCloak configuration object.
|
|
@@ -166,6 +190,15 @@ class IAMService {
|
|
|
166
190
|
if (this.isHybridMode()) {
|
|
167
191
|
return this._config;
|
|
168
192
|
}
|
|
193
|
+
// Native mode: store adapter, do not construct TideCloak client
|
|
194
|
+
if (this.isNativeMode()) {
|
|
195
|
+
if (!config.adapter) {
|
|
196
|
+
console.error("[loadConfig] Native mode requires config.adapter with platform-specific functions");
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
this._nativeAdapter = config.adapter;
|
|
200
|
+
return this._config;
|
|
201
|
+
}
|
|
169
202
|
try {
|
|
170
203
|
this._tc = new TideCloak({
|
|
171
204
|
url: config["auth-server-url"],
|
|
@@ -248,6 +281,128 @@ class IAMService {
|
|
|
248
281
|
})();
|
|
249
282
|
return this._hybridCallbackPromise;
|
|
250
283
|
}
|
|
284
|
+
// --- Native mode init: check stored tokens and subscribe to callbacks ---
|
|
285
|
+
if (this.isNativeMode()) {
|
|
286
|
+
// Guard against React StrictMode double-execution
|
|
287
|
+
if (this._nativeCallbackHandled) {
|
|
288
|
+
console.debug("[IAMService] Native callback already handled");
|
|
289
|
+
if (this._nativeCallbackPromise) {
|
|
290
|
+
console.debug("[IAMService] Waiting for pending native token exchange...");
|
|
291
|
+
return this._nativeCallbackPromise;
|
|
292
|
+
}
|
|
293
|
+
this._emit("ready", this._nativeAuthenticated);
|
|
294
|
+
return this._nativeAuthenticated;
|
|
295
|
+
}
|
|
296
|
+
this._nativeCallbackPromise = (async () => {
|
|
297
|
+
var _a;
|
|
298
|
+
// Check for stored tokens
|
|
299
|
+
const storedTokens = await this._nativeAdapter.getTokens();
|
|
300
|
+
// sessionMode: 'online' (default) = validate tokens, refresh if needed, require login if invalid
|
|
301
|
+
// sessionMode: 'offline' = accept stored tokens without validation (for offline-first apps)
|
|
302
|
+
const sessionMode = ((_a = this._config) === null || _a === void 0 ? void 0 : _a.sessionMode) || 'online';
|
|
303
|
+
if (storedTokens) {
|
|
304
|
+
console.debug("[IAMService] Found stored tokens in native mode, sessionMode:", sessionMode);
|
|
305
|
+
// Load doken if present
|
|
306
|
+
if (storedTokens.doken) {
|
|
307
|
+
this._nativeDoken = storedTokens.doken;
|
|
308
|
+
this._nativeDokenParsed = this._parseToken(storedTokens.doken);
|
|
309
|
+
console.debug("[IAMService] Loaded doken from stored tokens");
|
|
310
|
+
}
|
|
311
|
+
// Load voucher if present
|
|
312
|
+
if (storedTokens.voucher) {
|
|
313
|
+
this._nativeVoucher = storedTokens.voucher;
|
|
314
|
+
console.debug("[IAMService] Loaded voucher from stored tokens");
|
|
315
|
+
}
|
|
316
|
+
if (sessionMode === 'offline') {
|
|
317
|
+
// Offline mode: Accept stored tokens without validation
|
|
318
|
+
this._nativeTokens = storedTokens;
|
|
319
|
+
this._nativeAuthenticated = true;
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
// Online mode: Validate tokens before accepting
|
|
323
|
+
try {
|
|
324
|
+
const payload = JSON.parse(atob(storedTokens.accessToken.split('.')[1]));
|
|
325
|
+
const exp = payload.exp * 1000;
|
|
326
|
+
const now = Date.now();
|
|
327
|
+
if (exp > now) {
|
|
328
|
+
// Token still valid
|
|
329
|
+
console.debug("[IAMService] Online mode: token valid, authenticating");
|
|
330
|
+
this._nativeTokens = storedTokens;
|
|
331
|
+
this._nativeAuthenticated = true;
|
|
332
|
+
}
|
|
333
|
+
else if (storedTokens.refreshToken) {
|
|
334
|
+
// Token expired, try refresh
|
|
335
|
+
console.debug("[IAMService] Online mode: token expired, attempting refresh");
|
|
336
|
+
try {
|
|
337
|
+
const newTokens = await this._refreshNativeToken(storedTokens.refreshToken);
|
|
338
|
+
this._nativeTokens = newTokens;
|
|
339
|
+
this._nativeAuthenticated = true;
|
|
340
|
+
await this._nativeAdapter.saveTokens(newTokens);
|
|
341
|
+
console.debug("[IAMService] Online mode: token refreshed successfully");
|
|
342
|
+
}
|
|
343
|
+
catch (refreshErr) {
|
|
344
|
+
console.debug("[IAMService] Online mode: refresh failed, user must login", refreshErr);
|
|
345
|
+
await this._nativeAdapter.deleteTokens();
|
|
346
|
+
this._nativeAuthenticated = false;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
else {
|
|
350
|
+
// Token expired, no refresh token
|
|
351
|
+
console.debug("[IAMService] Online mode: token expired, no refresh token");
|
|
352
|
+
await this._nativeAdapter.deleteTokens();
|
|
353
|
+
this._nativeAuthenticated = false;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (parseErr) {
|
|
357
|
+
console.error("[IAMService] Online mode: failed to parse token", parseErr);
|
|
358
|
+
await this._nativeAdapter.deleteTokens();
|
|
359
|
+
this._nativeAuthenticated = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// Subscribe to auth callbacks from native app
|
|
364
|
+
this._nativeCallbackUnsubscribe = this._nativeAdapter.onAuthCallback(async ({ code, voucher, error, errorDescription }) => {
|
|
365
|
+
// Guard against duplicate callback processing
|
|
366
|
+
if (this._nativeCallbackProcessing || this._nativeAuthenticated) {
|
|
367
|
+
console.debug("[IAMService] Ignoring duplicate native callback");
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (error) {
|
|
371
|
+
console.error("[IAMService] Native auth error:", error, errorDescription);
|
|
372
|
+
this._emit("authError", new Error(`${error}: ${errorDescription || "Unknown error"}`));
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
if (code) {
|
|
376
|
+
await this._handleNativeCallback(code, voucher);
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
// Subscribe to encryption callbacks from native app (if adapter supports it)
|
|
380
|
+
if (this._nativeAdapter.onEncryptionCallback) {
|
|
381
|
+
this._nativeEncryptionCallbackUnsubscribe = this._nativeAdapter.onEncryptionCallback(({ operation, requestId, result, error }) => {
|
|
382
|
+
const pending = this._pendingEncryptionRequests.get(requestId);
|
|
383
|
+
if (pending) {
|
|
384
|
+
this._pendingEncryptionRequests.delete(requestId);
|
|
385
|
+
if (error) {
|
|
386
|
+
pending.reject(new Error(error));
|
|
387
|
+
}
|
|
388
|
+
else if (result) {
|
|
389
|
+
pending.resolve(result);
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
pending.reject(new Error("Empty result from encryption callback"));
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
else {
|
|
396
|
+
console.warn("[IAMService] Received encryption callback for unknown requestId:", requestId);
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
this._emit("ready", this._nativeAuthenticated);
|
|
401
|
+
this._nativeCallbackPromise = null;
|
|
402
|
+
return this._nativeAuthenticated;
|
|
403
|
+
})();
|
|
404
|
+
return this._nativeCallbackPromise;
|
|
405
|
+
}
|
|
251
406
|
// --- Front-channel mode ---
|
|
252
407
|
if (!this._tc) {
|
|
253
408
|
const err = new Error("TideCloak client not available");
|
|
@@ -291,10 +446,12 @@ class IAMService {
|
|
|
291
446
|
}
|
|
292
447
|
return this._config;
|
|
293
448
|
}
|
|
294
|
-
/** @returns {boolean} Whether there's a valid token (or session in hybrid mode) */
|
|
449
|
+
/** @returns {boolean} Whether there's a valid token (or session in hybrid/native mode) */
|
|
295
450
|
isLoggedIn() {
|
|
296
451
|
if (this.isHybridMode())
|
|
297
452
|
return this._hybridAuthenticated;
|
|
453
|
+
if (this.isNativeMode())
|
|
454
|
+
return this._nativeAuthenticated;
|
|
298
455
|
return !!this.getTideCloakClient().token;
|
|
299
456
|
}
|
|
300
457
|
/**
|
|
@@ -306,6 +463,28 @@ class IAMService {
|
|
|
306
463
|
if (this.isHybridMode()) {
|
|
307
464
|
throw new Error("getToken() not available in hybrid mode - tokens are server-side");
|
|
308
465
|
}
|
|
466
|
+
if (this.isNativeMode()) {
|
|
467
|
+
const tokens = await this._nativeAdapter.getTokens();
|
|
468
|
+
if (!tokens)
|
|
469
|
+
return null;
|
|
470
|
+
// Check if token is expired or about to expire (30 second buffer)
|
|
471
|
+
const now = Date.now();
|
|
472
|
+
const bufferMs = 30 * 1000;
|
|
473
|
+
if (now >= tokens.expiresAt - bufferMs) {
|
|
474
|
+
console.debug("[IAMService] Native token expired, refreshing...");
|
|
475
|
+
try {
|
|
476
|
+
const newTokens = await this._refreshNativeToken(tokens.refreshToken);
|
|
477
|
+
await this._nativeAdapter.saveTokens(newTokens);
|
|
478
|
+
this._nativeTokens = newTokens;
|
|
479
|
+
return newTokens.accessToken;
|
|
480
|
+
}
|
|
481
|
+
catch (err) {
|
|
482
|
+
console.error("[IAMService] Native token refresh failed:", err);
|
|
483
|
+
return null;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return tokens.accessToken;
|
|
487
|
+
}
|
|
309
488
|
const exp = this.getTokenExp();
|
|
310
489
|
if (exp < 3)
|
|
311
490
|
await this.updateIAMToken();
|
|
@@ -317,9 +496,22 @@ class IAMService {
|
|
|
317
496
|
* @throws {Error} In hybrid mode (tokens are server-side)
|
|
318
497
|
*/
|
|
319
498
|
getTokenExp() {
|
|
499
|
+
var _a;
|
|
320
500
|
if (this.isHybridMode()) {
|
|
321
501
|
throw new Error("getTokenExp() not available in hybrid mode - tokens are server-side");
|
|
322
502
|
}
|
|
503
|
+
if (this.isNativeMode()) {
|
|
504
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken))
|
|
505
|
+
return 0;
|
|
506
|
+
try {
|
|
507
|
+
const payload = JSON.parse(atob(this._nativeTokens.accessToken.split('.')[1]));
|
|
508
|
+
return Math.round(payload.exp - Date.now() / 1000);
|
|
509
|
+
}
|
|
510
|
+
catch (e) {
|
|
511
|
+
console.error("[IAMService] Failed to parse token for expiry:", e);
|
|
512
|
+
return 0;
|
|
513
|
+
}
|
|
514
|
+
}
|
|
323
515
|
const kc = this.getTideCloakClient();
|
|
324
516
|
return Math.round(kc.tokenParsed.exp + kc.timeSkew - Date.now() / 1000);
|
|
325
517
|
}
|
|
@@ -329,9 +521,13 @@ class IAMService {
|
|
|
329
521
|
* @throws {Error} In hybrid mode (tokens are server-side)
|
|
330
522
|
*/
|
|
331
523
|
getIDToken() {
|
|
524
|
+
var _a;
|
|
332
525
|
if (this.isHybridMode()) {
|
|
333
526
|
throw new Error("getIDToken() not available in hybrid mode - tokens are server-side");
|
|
334
527
|
}
|
|
528
|
+
if (this.isNativeMode()) {
|
|
529
|
+
return ((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.idToken) || null;
|
|
530
|
+
}
|
|
335
531
|
return this.getTideCloakClient().idToken;
|
|
336
532
|
}
|
|
337
533
|
/**
|
|
@@ -343,6 +539,9 @@ class IAMService {
|
|
|
343
539
|
if (this.isHybridMode()) {
|
|
344
540
|
throw new Error("getName() not available in hybrid mode - tokens are server-side");
|
|
345
541
|
}
|
|
542
|
+
if (this.isNativeMode()) {
|
|
543
|
+
return this.getValueFromToken('preferred_username');
|
|
544
|
+
}
|
|
346
545
|
return this.getTideCloakClient().tokenParsed.preferred_username;
|
|
347
546
|
}
|
|
348
547
|
/**
|
|
@@ -360,9 +559,23 @@ class IAMService {
|
|
|
360
559
|
* @throws {Error} In hybrid mode (role checks not available client-side)
|
|
361
560
|
*/
|
|
362
561
|
hasRealmRole(role) {
|
|
562
|
+
var _a, _b;
|
|
363
563
|
if (this.isHybridMode()) {
|
|
364
564
|
throw new Error("hasRealmRole() not available in hybrid mode - tokens are server-side");
|
|
365
565
|
}
|
|
566
|
+
if (this.isNativeMode()) {
|
|
567
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken))
|
|
568
|
+
return false;
|
|
569
|
+
try {
|
|
570
|
+
const payload = JSON.parse(atob(this._nativeTokens.accessToken.split('.')[1]));
|
|
571
|
+
const realmRoles = ((_b = payload.realm_access) === null || _b === void 0 ? void 0 : _b.roles) || [];
|
|
572
|
+
return realmRoles.includes(role);
|
|
573
|
+
}
|
|
574
|
+
catch (e) {
|
|
575
|
+
console.error("[IAMService] Failed to parse token for realm role check:", e);
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
366
579
|
return this.getTideCloakClient().hasRealmRole(role);
|
|
367
580
|
}
|
|
368
581
|
/**
|
|
@@ -373,9 +586,24 @@ class IAMService {
|
|
|
373
586
|
* @throws {Error} In hybrid mode (role checks not available client-side)
|
|
374
587
|
*/
|
|
375
588
|
hasClientRole(role, client) {
|
|
589
|
+
var _a, _b, _c, _d;
|
|
376
590
|
if (this.isHybridMode()) {
|
|
377
591
|
throw new Error("hasClientRole() not available in hybrid mode - tokens are server-side");
|
|
378
592
|
}
|
|
593
|
+
if (this.isNativeMode()) {
|
|
594
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken))
|
|
595
|
+
return false;
|
|
596
|
+
try {
|
|
597
|
+
const payload = JSON.parse(atob(this._nativeTokens.accessToken.split('.')[1]));
|
|
598
|
+
const clientId = client || ((_b = this._config) === null || _b === void 0 ? void 0 : _b.resource);
|
|
599
|
+
const clientRoles = ((_d = (_c = payload.resource_access) === null || _c === void 0 ? void 0 : _c[clientId]) === null || _d === void 0 ? void 0 : _d.roles) || [];
|
|
600
|
+
return clientRoles.includes(role);
|
|
601
|
+
}
|
|
602
|
+
catch (e) {
|
|
603
|
+
console.error("[IAMService] Failed to parse token for client role check:", e);
|
|
604
|
+
return false;
|
|
605
|
+
}
|
|
606
|
+
}
|
|
379
607
|
return this.getTideCloakClient().hasResourceRole(role, client);
|
|
380
608
|
}
|
|
381
609
|
/**
|
|
@@ -385,11 +613,23 @@ class IAMService {
|
|
|
385
613
|
* @throws {Error} In hybrid mode (tokens are server-side)
|
|
386
614
|
*/
|
|
387
615
|
getValueFromToken(key) {
|
|
388
|
-
var _a;
|
|
616
|
+
var _a, _b;
|
|
389
617
|
if (this.isHybridMode()) {
|
|
390
618
|
throw new Error("getValueFromToken() not available in hybrid mode - tokens are server-side");
|
|
391
619
|
}
|
|
392
|
-
|
|
620
|
+
if (this.isNativeMode()) {
|
|
621
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken))
|
|
622
|
+
return null;
|
|
623
|
+
try {
|
|
624
|
+
const payload = JSON.parse(atob(this._nativeTokens.accessToken.split('.')[1]));
|
|
625
|
+
return payload[key] !== undefined ? payload[key] : null;
|
|
626
|
+
}
|
|
627
|
+
catch (e) {
|
|
628
|
+
console.error("[IAMService] Failed to parse access token:", e);
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
return (_b = this.getTideCloakClient().tokenParsed[key]) !== null && _b !== void 0 ? _b : null;
|
|
393
633
|
}
|
|
394
634
|
/**
|
|
395
635
|
* Get custom claim from ID token.
|
|
@@ -398,11 +638,23 @@ class IAMService {
|
|
|
398
638
|
* @throws {Error} In hybrid mode (tokens are server-side)
|
|
399
639
|
*/
|
|
400
640
|
getValueFromIDToken(key) {
|
|
401
|
-
var _a;
|
|
641
|
+
var _a, _b;
|
|
402
642
|
if (this.isHybridMode()) {
|
|
403
643
|
throw new Error("getValueFromIDToken() not available in hybrid mode - tokens are server-side");
|
|
404
644
|
}
|
|
405
|
-
|
|
645
|
+
if (this.isNativeMode()) {
|
|
646
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.idToken))
|
|
647
|
+
return null;
|
|
648
|
+
try {
|
|
649
|
+
const payload = JSON.parse(atob(this._nativeTokens.idToken.split('.')[1]));
|
|
650
|
+
return payload[key] !== undefined ? payload[key] : null;
|
|
651
|
+
}
|
|
652
|
+
catch (e) {
|
|
653
|
+
console.error("[IAMService] Failed to parse ID token:", e);
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return (_b = this.getTideCloakClient().idTokenParsed[key]) !== null && _b !== void 0 ? _b : null;
|
|
406
658
|
}
|
|
407
659
|
/**
|
|
408
660
|
* Refreshes token if expired or about to expire.
|
|
@@ -413,6 +665,16 @@ class IAMService {
|
|
|
413
665
|
if (this.isHybridMode()) {
|
|
414
666
|
throw new Error("updateIAMToken() not available in hybrid mode - tokens are server-side");
|
|
415
667
|
}
|
|
668
|
+
if (this.isNativeMode()) {
|
|
669
|
+
// Native mode token refresh is handled in getToken()
|
|
670
|
+
// Just check if tokens need refresh and return status
|
|
671
|
+
const exp = this.getTokenExp();
|
|
672
|
+
if (exp < 30) {
|
|
673
|
+
// Force refresh via getToken
|
|
674
|
+
await this.getToken();
|
|
675
|
+
}
|
|
676
|
+
return exp < 30;
|
|
677
|
+
}
|
|
416
678
|
const kc = this.getTideCloakClient();
|
|
417
679
|
const refreshed = await kc.updateToken();
|
|
418
680
|
const expiresIn = this.getTokenExp();
|
|
@@ -428,9 +690,26 @@ class IAMService {
|
|
|
428
690
|
* @throws {Error} In hybrid mode (token refresh handled server-side)
|
|
429
691
|
*/
|
|
430
692
|
async forceUpdateToken() {
|
|
693
|
+
var _a;
|
|
431
694
|
if (this.isHybridMode()) {
|
|
432
695
|
throw new Error("forceUpdateToken() not available in hybrid mode - tokens are server-side");
|
|
433
696
|
}
|
|
697
|
+
if (this.isNativeMode()) {
|
|
698
|
+
// Force refresh by clearing cached tokens and refreshing
|
|
699
|
+
if ((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.refreshToken) {
|
|
700
|
+
try {
|
|
701
|
+
const newTokens = await this._refreshNativeToken(this._nativeTokens.refreshToken);
|
|
702
|
+
this._nativeTokens = newTokens;
|
|
703
|
+
await this._nativeAdapter.saveTokens(newTokens);
|
|
704
|
+
return true;
|
|
705
|
+
}
|
|
706
|
+
catch (err) {
|
|
707
|
+
console.error("[IAMService] Native force refresh failed:", err);
|
|
708
|
+
return false;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
return false;
|
|
712
|
+
}
|
|
434
713
|
document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
435
714
|
const kc = this.getTideCloakClient();
|
|
436
715
|
const refreshed = await kc.updateToken(-1);
|
|
@@ -444,12 +723,14 @@ class IAMService {
|
|
|
444
723
|
/**
|
|
445
724
|
* Start login redirect.
|
|
446
725
|
* In hybrid mode, initiates PKCE flow and redirects to IdP.
|
|
447
|
-
*
|
|
726
|
+
* In native mode, opens external browser with auth URL.
|
|
727
|
+
* @param {string} [returnUrl] - URL to redirect to after successful auth (hybrid/native mode)
|
|
448
728
|
*/
|
|
449
729
|
doLogin(returnUrl = "") {
|
|
450
730
|
var _a, _b;
|
|
451
731
|
console.debug("[IAMService.doLogin] Called with returnUrl:", returnUrl);
|
|
452
732
|
console.debug("[IAMService.doLogin] isHybridMode:", this.isHybridMode());
|
|
733
|
+
console.debug("[IAMService.doLogin] isNativeMode:", this.isNativeMode());
|
|
453
734
|
console.debug("[IAMService.doLogin] authMode config:", (_a = this._config) === null || _a === void 0 ? void 0 : _a.authMode);
|
|
454
735
|
if (this.isHybridMode()) {
|
|
455
736
|
// Catch and log any errors from the async function
|
|
@@ -458,6 +739,12 @@ class IAMService {
|
|
|
458
739
|
throw err;
|
|
459
740
|
});
|
|
460
741
|
}
|
|
742
|
+
if (this.isNativeMode()) {
|
|
743
|
+
return this._startNativeLogin(returnUrl).catch(err => {
|
|
744
|
+
console.error("[IAMService.doLogin] Error in native login:", err);
|
|
745
|
+
throw err;
|
|
746
|
+
});
|
|
747
|
+
}
|
|
461
748
|
this.getTideCloakClient().login({
|
|
462
749
|
redirectUri: (_b = this._config["redirectUri"]) !== null && _b !== void 0 ? _b : `${window.location.origin}/auth/redirect`
|
|
463
750
|
});
|
|
@@ -465,28 +752,39 @@ class IAMService {
|
|
|
465
752
|
/**
|
|
466
753
|
* Encrypt data via adapter.
|
|
467
754
|
* Not available in hybrid mode (encryption requires client-side doken).
|
|
755
|
+
* @param {{ data: string | Uint8Array, tags: string[] }[]} data - Array of objects to encrypt
|
|
756
|
+
* @returns {Promise<(string | Uint8Array)[]>} Array of encrypted values
|
|
468
757
|
*/
|
|
469
758
|
async doEncrypt(data) {
|
|
470
759
|
if (this.isHybridMode()) {
|
|
471
760
|
throw new Error("Encrypt not supported in hybrid mode (tokens are server-side)");
|
|
472
761
|
}
|
|
762
|
+
if (this.isNativeMode()) {
|
|
763
|
+
return this._nativeEncrypt(data);
|
|
764
|
+
}
|
|
473
765
|
return this.getTideCloakClient().encrypt(data);
|
|
474
766
|
}
|
|
475
767
|
/**
|
|
476
768
|
* Decrypt data via adapter.
|
|
477
769
|
* Not available in hybrid mode (decryption requires client-side doken).
|
|
770
|
+
* @param {{ encrypted: string | Uint8Array, tags: string[] }[]} data - Array of objects to decrypt
|
|
771
|
+
* @returns {Promise<(string | Uint8Array)[]>} Array of decrypted values
|
|
478
772
|
*/
|
|
479
773
|
async doDecrypt(data) {
|
|
480
774
|
if (this.isHybridMode()) {
|
|
481
775
|
throw new Error("Decrypt not supported in hybrid mode (tokens are server-side)");
|
|
482
776
|
}
|
|
777
|
+
if (this.isNativeMode()) {
|
|
778
|
+
return this._nativeDecrypt(data);
|
|
779
|
+
}
|
|
483
780
|
return this.getTideCloakClient().decrypt(data);
|
|
484
781
|
}
|
|
485
782
|
/**
|
|
486
783
|
* Logout, clear cookie/session, then redirect.
|
|
487
784
|
* In hybrid mode, clears local state and emits logout event.
|
|
785
|
+
* In native mode, deletes tokens via adapter and emits logout event.
|
|
488
786
|
*/
|
|
489
|
-
doLogout() {
|
|
787
|
+
async doLogout() {
|
|
490
788
|
var _a;
|
|
491
789
|
if (this.isHybridMode()) {
|
|
492
790
|
this._hybridAuthenticated = false;
|
|
@@ -494,6 +792,39 @@ class IAMService {
|
|
|
494
792
|
this._emit("logout");
|
|
495
793
|
return;
|
|
496
794
|
}
|
|
795
|
+
if (this.isNativeMode()) {
|
|
796
|
+
await this._nativeAdapter.deleteTokens();
|
|
797
|
+
this._nativeTokens = null;
|
|
798
|
+
this._nativeAuthenticated = false;
|
|
799
|
+
this._nativeDoken = null;
|
|
800
|
+
this._nativeDokenParsed = null;
|
|
801
|
+
this._nativeVoucher = null;
|
|
802
|
+
// Reset callback flags so next login reinitializes properly
|
|
803
|
+
this._nativeCallbackHandled = false;
|
|
804
|
+
this._nativeCallbackProcessing = false;
|
|
805
|
+
this._nativeCallbackPromise = null;
|
|
806
|
+
// Unsubscribe from callbacks (will be resubscribed on next init)
|
|
807
|
+
if (this._nativeCallbackUnsubscribe) {
|
|
808
|
+
this._nativeCallbackUnsubscribe();
|
|
809
|
+
this._nativeCallbackUnsubscribe = null;
|
|
810
|
+
}
|
|
811
|
+
if (this._nativeEncryptionCallbackUnsubscribe) {
|
|
812
|
+
this._nativeEncryptionCallbackUnsubscribe();
|
|
813
|
+
this._nativeEncryptionCallbackUnsubscribe = null;
|
|
814
|
+
}
|
|
815
|
+
// Close and cleanup the request enclave
|
|
816
|
+
if (this._nativeRequestEnclave) {
|
|
817
|
+
try {
|
|
818
|
+
this._nativeRequestEnclave.close();
|
|
819
|
+
}
|
|
820
|
+
catch (e) {
|
|
821
|
+
console.debug("[IAMService] Error closing request enclave:", e);
|
|
822
|
+
}
|
|
823
|
+
this._nativeRequestEnclave = null;
|
|
824
|
+
}
|
|
825
|
+
this._emit("logout");
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
497
828
|
document.cookie = 'kcToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT';
|
|
498
829
|
this.getTideCloakClient().logout({
|
|
499
830
|
redirectUri: (_a = this._config["redirectUri"]) !== null && _a !== void 0 ? _a : `${window.location.origin}/auth/redirect`
|
|
@@ -767,6 +1098,590 @@ class IAMService {
|
|
|
767
1098
|
return { handled: true, authenticated: false, returnUrl };
|
|
768
1099
|
}
|
|
769
1100
|
}
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
// NATIVE MODE SUPPORT (private helpers)
|
|
1103
|
+
// ---------------------------------------------------------------------------
|
|
1104
|
+
/**
|
|
1105
|
+
* Get OIDC configuration from the native config.
|
|
1106
|
+
* @private
|
|
1107
|
+
* @returns {{ authServerUrl: string, realm: string, clientId: string, scope: string }}
|
|
1108
|
+
*/
|
|
1109
|
+
_getNativeOIDCConfig() {
|
|
1110
|
+
const config = this._config;
|
|
1111
|
+
return {
|
|
1112
|
+
authServerUrl: config["auth-server-url"],
|
|
1113
|
+
realm: config.realm,
|
|
1114
|
+
clientId: config.resource,
|
|
1115
|
+
scope: config.scope || "openid profile email",
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
/**
|
|
1119
|
+
* Get encryption configuration from the native config.
|
|
1120
|
+
* Automatically selects clientOriginAuth based on window.location.origin.
|
|
1121
|
+
* @private
|
|
1122
|
+
* @returns {{ vendorId: string, homeOrkUrl: string, clientOriginAuth: string } | null}
|
|
1123
|
+
*/
|
|
1124
|
+
_getNativeEncryptionConfig() {
|
|
1125
|
+
const config = this._config;
|
|
1126
|
+
if (!config.vendorId) {
|
|
1127
|
+
return null;
|
|
1128
|
+
}
|
|
1129
|
+
// Auto-select clientOriginAuth based on current origin
|
|
1130
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
1131
|
+
const clientOriginAuthKey = `client-origin-auth-${origin}`;
|
|
1132
|
+
const clientOriginAuth = config[clientOriginAuthKey];
|
|
1133
|
+
return {
|
|
1134
|
+
vendorId: config.vendorId,
|
|
1135
|
+
homeOrkUrl: config.homeOrkUrl,
|
|
1136
|
+
clientOriginAuth,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Start native login flow: generate PKCE, open external browser with auth URL.
|
|
1141
|
+
* @private
|
|
1142
|
+
* @param {string} returnUrl - URL to redirect to after successful auth
|
|
1143
|
+
*/
|
|
1144
|
+
async _startNativeLogin(returnUrl = "") {
|
|
1145
|
+
console.debug("[IAMService._startNativeLogin] Starting native login flow");
|
|
1146
|
+
// Reset auth state to allow new callback to be processed
|
|
1147
|
+
// This is needed for re-login after session expiry
|
|
1148
|
+
this._nativeAuthenticated = false;
|
|
1149
|
+
this._nativeCallbackProcessing = false;
|
|
1150
|
+
const adapter = this._nativeAdapter;
|
|
1151
|
+
if (!adapter) {
|
|
1152
|
+
throw new Error("Native adapter not configured");
|
|
1153
|
+
}
|
|
1154
|
+
// Get OIDC config from the main config
|
|
1155
|
+
const { authServerUrl, realm, clientId, scope } = this._getNativeOIDCConfig();
|
|
1156
|
+
// Generate PKCE
|
|
1157
|
+
const { verifier, challenge, method } = await makePkce();
|
|
1158
|
+
console.debug("[IAMService._startNativeLogin] PKCE generated, verifier length:", verifier.length);
|
|
1159
|
+
// Store PKCE verifier in sessionStorage (will be retrieved when callback is received)
|
|
1160
|
+
sessionStorage.setItem("kc_pkce_verifier", verifier);
|
|
1161
|
+
if (returnUrl) {
|
|
1162
|
+
sessionStorage.setItem("kc_return_url", returnUrl);
|
|
1163
|
+
}
|
|
1164
|
+
// Get redirect URI from adapter (can be async)
|
|
1165
|
+
const redirectUri = await Promise.resolve(adapter.getRedirectUri());
|
|
1166
|
+
sessionStorage.setItem("kc_redirect_uri", redirectUri);
|
|
1167
|
+
// Build auth URL
|
|
1168
|
+
const authUrl = `${authServerUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/auth` +
|
|
1169
|
+
`?client_id=${encodeURIComponent(clientId)}` +
|
|
1170
|
+
`&redirect_uri=${encodeURIComponent(redirectUri)}` +
|
|
1171
|
+
`&response_type=code` +
|
|
1172
|
+
`&scope=${encodeURIComponent(scope)}` +
|
|
1173
|
+
`&code_challenge=${encodeURIComponent(challenge)}` +
|
|
1174
|
+
`&code_challenge_method=${encodeURIComponent(method)}` +
|
|
1175
|
+
`&prompt=login`;
|
|
1176
|
+
console.debug("[IAMService._startNativeLogin] Opening auth URL in external browser");
|
|
1177
|
+
// Open in external browser via adapter
|
|
1178
|
+
await adapter.openExternalUrl(authUrl);
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Handle native auth callback: exchange code for tokens.
|
|
1182
|
+
* @private
|
|
1183
|
+
* @param {string} code - Authorization code from IdP
|
|
1184
|
+
* @param {string} [voucher] - Optional voucher fetched during login (for encryption)
|
|
1185
|
+
*/
|
|
1186
|
+
async _handleNativeCallback(code, voucher) {
|
|
1187
|
+
// Guard against concurrent/duplicate callback processing
|
|
1188
|
+
if (this._nativeCallbackProcessing) {
|
|
1189
|
+
console.debug("[IAMService._handleNativeCallback] Already processing callback, ignoring duplicate");
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
this._nativeCallbackProcessing = true;
|
|
1193
|
+
this._nativeCallbackHandled = true;
|
|
1194
|
+
console.debug("[IAMService._handleNativeCallback] Handling native callback with code");
|
|
1195
|
+
const adapter = this._nativeAdapter;
|
|
1196
|
+
const { authServerUrl, realm, clientId } = this._getNativeOIDCConfig();
|
|
1197
|
+
// Retrieve PKCE verifier
|
|
1198
|
+
const verifier = sessionStorage.getItem("kc_pkce_verifier");
|
|
1199
|
+
const redirectUri = sessionStorage.getItem("kc_redirect_uri") || await Promise.resolve(adapter.getRedirectUri());
|
|
1200
|
+
const returnUrl = sessionStorage.getItem("kc_return_url") || "";
|
|
1201
|
+
// Clear session storage immediately to prevent reuse
|
|
1202
|
+
sessionStorage.removeItem("kc_pkce_verifier");
|
|
1203
|
+
sessionStorage.removeItem("kc_redirect_uri");
|
|
1204
|
+
sessionStorage.removeItem("kc_return_url");
|
|
1205
|
+
if (!verifier) {
|
|
1206
|
+
console.debug("[IAMService._handleNativeCallback] PKCE verifier not found, callback already processed");
|
|
1207
|
+
this._nativeCallbackProcessing = false;
|
|
1208
|
+
// Don't emit error - this is likely a duplicate callback after successful auth
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
// Exchange code for tokens at token endpoint
|
|
1213
|
+
const tokenUrl = `${authServerUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
|
|
1214
|
+
const body = new URLSearchParams({
|
|
1215
|
+
grant_type: "authorization_code",
|
|
1216
|
+
client_id: clientId,
|
|
1217
|
+
code: code,
|
|
1218
|
+
redirect_uri: redirectUri,
|
|
1219
|
+
code_verifier: verifier,
|
|
1220
|
+
});
|
|
1221
|
+
const response = await fetch(tokenUrl, {
|
|
1222
|
+
method: "POST",
|
|
1223
|
+
headers: {
|
|
1224
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1225
|
+
},
|
|
1226
|
+
body: body.toString(),
|
|
1227
|
+
});
|
|
1228
|
+
if (!response.ok) {
|
|
1229
|
+
const errorText = await response.text();
|
|
1230
|
+
throw new Error(`Token exchange failed: ${errorText}`);
|
|
1231
|
+
}
|
|
1232
|
+
const data = await response.json();
|
|
1233
|
+
console.debug("[IAMService._handleNativeCallback] Token exchange successful");
|
|
1234
|
+
// Note: Voucher is NOT fetched here - the RequestEnclave will fetch it
|
|
1235
|
+
// via its ORK iframe when encryption/decryption is needed. This avoids
|
|
1236
|
+
// the complexity of session cookie handling during login.
|
|
1237
|
+
// Build token object for storage
|
|
1238
|
+
const tokens = {
|
|
1239
|
+
accessToken: data.access_token,
|
|
1240
|
+
refreshToken: data.refresh_token,
|
|
1241
|
+
idToken: data.id_token,
|
|
1242
|
+
doken: data.doken, // Tide doken for encryption/decryption
|
|
1243
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
1244
|
+
};
|
|
1245
|
+
// Save tokens via adapter
|
|
1246
|
+
await adapter.saveTokens(tokens);
|
|
1247
|
+
this._nativeTokens = tokens;
|
|
1248
|
+
this._nativeDoken = data.doken;
|
|
1249
|
+
this._nativeDokenParsed = data.doken ? this._parseToken(data.doken) : null;
|
|
1250
|
+
// Note: _nativeVoucher is NOT set here - RequestEnclave fetches it on-demand
|
|
1251
|
+
this._nativeAuthenticated = true;
|
|
1252
|
+
this._nativeCallbackProcessing = false;
|
|
1253
|
+
this._emit("authSuccess");
|
|
1254
|
+
console.debug("[IAMService._handleNativeCallback] Native auth complete, returnUrl:", returnUrl);
|
|
1255
|
+
}
|
|
1256
|
+
catch (err) {
|
|
1257
|
+
console.error("[IAMService._handleNativeCallback] Token exchange error:", err);
|
|
1258
|
+
this._nativeAuthenticated = false;
|
|
1259
|
+
this._nativeCallbackProcessing = false;
|
|
1260
|
+
this._emit("authError", err);
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Refresh native token using refresh token.
|
|
1265
|
+
* @private
|
|
1266
|
+
* @param {string} refreshToken - The refresh token
|
|
1267
|
+
* @returns {Promise<Object>} New token data
|
|
1268
|
+
*/
|
|
1269
|
+
async _refreshNativeToken(refreshToken) {
|
|
1270
|
+
const { authServerUrl, realm, clientId } = this._getNativeOIDCConfig();
|
|
1271
|
+
const tokenUrl = `${authServerUrl}/realms/${encodeURIComponent(realm)}/protocol/openid-connect/token`;
|
|
1272
|
+
const body = new URLSearchParams({
|
|
1273
|
+
grant_type: "refresh_token",
|
|
1274
|
+
client_id: clientId,
|
|
1275
|
+
refresh_token: refreshToken,
|
|
1276
|
+
});
|
|
1277
|
+
const response = await fetch(tokenUrl, {
|
|
1278
|
+
method: "POST",
|
|
1279
|
+
headers: {
|
|
1280
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
1281
|
+
},
|
|
1282
|
+
body: body.toString(),
|
|
1283
|
+
});
|
|
1284
|
+
if (!response.ok) {
|
|
1285
|
+
const errorText = await response.text();
|
|
1286
|
+
throw new Error(`Token refresh failed: ${errorText}`);
|
|
1287
|
+
}
|
|
1288
|
+
const data = await response.json();
|
|
1289
|
+
// Update doken if present in refresh response
|
|
1290
|
+
if (data.doken) {
|
|
1291
|
+
this._nativeDoken = data.doken;
|
|
1292
|
+
this._nativeDokenParsed = this._parseToken(data.doken);
|
|
1293
|
+
console.debug("[IAMService] Updated doken from token refresh");
|
|
1294
|
+
}
|
|
1295
|
+
return {
|
|
1296
|
+
accessToken: data.access_token,
|
|
1297
|
+
refreshToken: data.refresh_token,
|
|
1298
|
+
idToken: data.id_token,
|
|
1299
|
+
doken: data.doken,
|
|
1300
|
+
expiresAt: Date.now() + data.expires_in * 1000,
|
|
1301
|
+
};
|
|
1302
|
+
}
|
|
1303
|
+
// ---------------------------------------------------------------------------
|
|
1304
|
+
// NATIVE MODE ENCRYPTION SUPPORT (private helpers)
|
|
1305
|
+
// ---------------------------------------------------------------------------
|
|
1306
|
+
/**
|
|
1307
|
+
* Initialize the native RequestEnclave for encryption/decryption.
|
|
1308
|
+
* @private
|
|
1309
|
+
* @param {string} voucherDataUrl - Pre-fetched voucher as data URL
|
|
1310
|
+
*/
|
|
1311
|
+
_initNativeRequestEnclave(voucherDataUrl) {
|
|
1312
|
+
if (!this._nativeDoken) {
|
|
1313
|
+
throw new Error("[IAMService] No doken available for encryption - user must be authenticated with Tide");
|
|
1314
|
+
}
|
|
1315
|
+
if (!this._nativeDokenParsed) {
|
|
1316
|
+
throw new Error("[IAMService] Doken not parsed");
|
|
1317
|
+
}
|
|
1318
|
+
// Get encryption config from the main config (auto-selects clientOriginAuth based on origin)
|
|
1319
|
+
const encryptionConfig = this._getNativeEncryptionConfig();
|
|
1320
|
+
if (!encryptionConfig || !encryptionConfig.vendorId || !encryptionConfig.clientOriginAuth) {
|
|
1321
|
+
throw new Error("[IAMService] Native encryption requires vendorId and client-origin-auth-{origin} in config");
|
|
1322
|
+
}
|
|
1323
|
+
// Reuse existing enclave if already initialized
|
|
1324
|
+
if (this._nativeRequestEnclave) {
|
|
1325
|
+
console.debug("[IAMService] Reusing existing RequestEnclave");
|
|
1326
|
+
return;
|
|
1327
|
+
}
|
|
1328
|
+
const homeOrkOrigin = this._nativeDokenParsed['t.uho'] || encryptionConfig.homeOrkUrl;
|
|
1329
|
+
if (!homeOrkOrigin) {
|
|
1330
|
+
throw new Error("[IAMService] Home ORK URL not available - check doken or config.homeOrkUrl");
|
|
1331
|
+
}
|
|
1332
|
+
console.debug("[IAMService] Initializing native RequestEnclave", {
|
|
1333
|
+
homeOrkOrigin,
|
|
1334
|
+
vendorId: encryptionConfig.vendorId,
|
|
1335
|
+
voucherURL: voucherDataUrl,
|
|
1336
|
+
});
|
|
1337
|
+
this._nativeRequestEnclave = new RequestEnclave({
|
|
1338
|
+
homeOrkOrigin,
|
|
1339
|
+
signed_client_origin: encryptionConfig.clientOriginAuth,
|
|
1340
|
+
vendorId: encryptionConfig.vendorId,
|
|
1341
|
+
voucherURL: voucherDataUrl,
|
|
1342
|
+
isRunningLocal: false,
|
|
1343
|
+
}).init({
|
|
1344
|
+
doken: this._nativeDoken,
|
|
1345
|
+
dokenRefreshCallback: async () => {
|
|
1346
|
+
// Refresh tokens and return the new doken
|
|
1347
|
+
await this.forceUpdateToken();
|
|
1348
|
+
if (!this._nativeDoken) {
|
|
1349
|
+
throw new Error("[IAMService] No doken available after token refresh");
|
|
1350
|
+
}
|
|
1351
|
+
return this._nativeDoken;
|
|
1352
|
+
},
|
|
1353
|
+
requireReloginCallback: async () => {
|
|
1354
|
+
// User needs to re-authenticate
|
|
1355
|
+
console.warn("[IAMService] Re-authentication required for encryption");
|
|
1356
|
+
this._emit("authError", new Error("Re-authentication required"));
|
|
1357
|
+
},
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
/**
|
|
1361
|
+
* Build the encryption page URL for external browser.
|
|
1362
|
+
* This page is hosted on the TideCloak server and has session cookies.
|
|
1363
|
+
* @private
|
|
1364
|
+
* @param {string} operation - 'encrypt' or 'decrypt'
|
|
1365
|
+
* @param {string} requestId - Unique request ID
|
|
1366
|
+
* @param {string} dataBase64 - Base64-encoded data to process
|
|
1367
|
+
* @param {string} tagsJson - JSON-encoded tags array
|
|
1368
|
+
* @param {string} callbackUrl - URL to redirect with result
|
|
1369
|
+
* @returns {string} URL to open in external browser
|
|
1370
|
+
*/
|
|
1371
|
+
_buildEncryptionPageUrl(operation, requestId, dataBase64, tagsJson, callbackUrl) {
|
|
1372
|
+
const { authServerUrl, realm, clientId } = this._getNativeOIDCConfig();
|
|
1373
|
+
const encryptionConfig = this._getNativeEncryptionConfig();
|
|
1374
|
+
// Build URL to a page on the TideCloak server that can perform encryption
|
|
1375
|
+
// This page will have access to session cookies
|
|
1376
|
+
const baseUrl = `${authServerUrl}/realms/${encodeURIComponent(realm)}/tide-encrypt`;
|
|
1377
|
+
const url = new URL(baseUrl);
|
|
1378
|
+
url.searchParams.set('operation', operation);
|
|
1379
|
+
url.searchParams.set('requestId', requestId);
|
|
1380
|
+
url.searchParams.set('data', dataBase64);
|
|
1381
|
+
url.searchParams.set('tags', tagsJson);
|
|
1382
|
+
url.searchParams.set('callback', callbackUrl);
|
|
1383
|
+
url.searchParams.set('vendorId', (encryptionConfig === null || encryptionConfig === void 0 ? void 0 : encryptionConfig.vendorId) || '');
|
|
1384
|
+
url.searchParams.set('clientId', clientId);
|
|
1385
|
+
return url.toString();
|
|
1386
|
+
}
|
|
1387
|
+
/**
|
|
1388
|
+
* Perform an encryption operation via external browser.
|
|
1389
|
+
* @private
|
|
1390
|
+
* @param {'encrypt' | 'decrypt'} operation - The operation to perform
|
|
1391
|
+
* @param {string} dataBase64 - Base64-encoded data to process
|
|
1392
|
+
* @param {string[]} tags - Tags for the operation
|
|
1393
|
+
* @returns {Promise<string>} Base64-encoded result
|
|
1394
|
+
*/
|
|
1395
|
+
async _doExternalBrowserOperation(operation, dataBase64, tags) {
|
|
1396
|
+
const adapter = this._nativeAdapter;
|
|
1397
|
+
// Generate unique request ID
|
|
1398
|
+
const requestId = `${operation}_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
1399
|
+
// Get callback URL for encryption operations
|
|
1400
|
+
let callbackUrl;
|
|
1401
|
+
if (adapter.getEncryptionRedirectUri) {
|
|
1402
|
+
callbackUrl = await Promise.resolve(adapter.getEncryptionRedirectUri());
|
|
1403
|
+
}
|
|
1404
|
+
else {
|
|
1405
|
+
// Default: use auth redirect URI with /encrypt/callback suffix
|
|
1406
|
+
const authRedirectUri = await Promise.resolve(adapter.getRedirectUri());
|
|
1407
|
+
// Replace /callback with /encrypt/callback, or append if no /callback
|
|
1408
|
+
if (authRedirectUri.endsWith('/callback')) {
|
|
1409
|
+
callbackUrl = authRedirectUri.replace('/callback', '/encrypt/callback');
|
|
1410
|
+
}
|
|
1411
|
+
else {
|
|
1412
|
+
callbackUrl = authRedirectUri + '/encrypt/callback';
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
// Build the URL
|
|
1416
|
+
const tagsJson = JSON.stringify(tags);
|
|
1417
|
+
const encryptionUrl = this._buildEncryptionPageUrl(operation, requestId, dataBase64, tagsJson, callbackUrl);
|
|
1418
|
+
console.debug(`[IAMService] Opening external browser for ${operation}:`, {
|
|
1419
|
+
requestId,
|
|
1420
|
+
callbackUrl,
|
|
1421
|
+
urlLength: encryptionUrl.length,
|
|
1422
|
+
});
|
|
1423
|
+
// Create a promise that will resolve when we get the callback
|
|
1424
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
1425
|
+
// Store the pending request
|
|
1426
|
+
this._pendingEncryptionRequests.set(requestId, { resolve, reject });
|
|
1427
|
+
// Set a timeout (60 seconds)
|
|
1428
|
+
const timeout = setTimeout(() => {
|
|
1429
|
+
if (this._pendingEncryptionRequests.has(requestId)) {
|
|
1430
|
+
this._pendingEncryptionRequests.delete(requestId);
|
|
1431
|
+
reject(new Error(`Encryption ${operation} timed out after 60 seconds`));
|
|
1432
|
+
}
|
|
1433
|
+
}, 60000);
|
|
1434
|
+
// Update the stored request to include timeout cleanup
|
|
1435
|
+
const pending = this._pendingEncryptionRequests.get(requestId);
|
|
1436
|
+
const originalResolve = pending.resolve;
|
|
1437
|
+
const originalReject = pending.reject;
|
|
1438
|
+
pending.resolve = (result) => {
|
|
1439
|
+
clearTimeout(timeout);
|
|
1440
|
+
originalResolve(result);
|
|
1441
|
+
};
|
|
1442
|
+
pending.reject = (error) => {
|
|
1443
|
+
clearTimeout(timeout);
|
|
1444
|
+
originalReject(error);
|
|
1445
|
+
};
|
|
1446
|
+
});
|
|
1447
|
+
// Open the URL in external browser
|
|
1448
|
+
await adapter.openExternalUrl(encryptionUrl);
|
|
1449
|
+
// Wait for the callback
|
|
1450
|
+
return resultPromise;
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Get the voucher URL for native mode.
|
|
1454
|
+
* @private
|
|
1455
|
+
* @returns {string} Voucher URL
|
|
1456
|
+
*/
|
|
1457
|
+
_getNativeVoucherUrl() {
|
|
1458
|
+
var _a;
|
|
1459
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken)) {
|
|
1460
|
+
throw new Error("[IAMService] No access token available for voucher URL");
|
|
1461
|
+
}
|
|
1462
|
+
const tokenPayload = this._parseToken(this._nativeTokens.accessToken);
|
|
1463
|
+
if (!tokenPayload) {
|
|
1464
|
+
throw new Error("[IAMService] Failed to parse access token for voucher URL");
|
|
1465
|
+
}
|
|
1466
|
+
const sid = tokenPayload.sid;
|
|
1467
|
+
if (!sid) {
|
|
1468
|
+
throw new Error("[IAMService] No session ID in access token for voucher URL");
|
|
1469
|
+
}
|
|
1470
|
+
const { authServerUrl, realm } = this._getNativeOIDCConfig();
|
|
1471
|
+
return `${authServerUrl}/realms/${encodeURIComponent(realm)}/tidevouchers/fromUserSession?sessionId=${encodeURIComponent(sid)}`;
|
|
1472
|
+
}
|
|
1473
|
+
/**
|
|
1474
|
+
* Check if user has a realm role (native mode helper).
|
|
1475
|
+
* @private
|
|
1476
|
+
* @param {string} role - Role to check
|
|
1477
|
+
* @returns {boolean}
|
|
1478
|
+
*/
|
|
1479
|
+
_nativeHasRealmRole(role) {
|
|
1480
|
+
var _a, _b;
|
|
1481
|
+
if (!((_a = this._nativeTokens) === null || _a === void 0 ? void 0 : _a.accessToken))
|
|
1482
|
+
return false;
|
|
1483
|
+
try {
|
|
1484
|
+
const payload = this._parseToken(this._nativeTokens.accessToken);
|
|
1485
|
+
const realmRoles = ((_b = payload === null || payload === void 0 ? void 0 : payload.realm_access) === null || _b === void 0 ? void 0 : _b.roles) || [];
|
|
1486
|
+
return realmRoles.includes(role);
|
|
1487
|
+
}
|
|
1488
|
+
catch (e) {
|
|
1489
|
+
return false;
|
|
1490
|
+
}
|
|
1491
|
+
}
|
|
1492
|
+
/**
|
|
1493
|
+
* Native mode encryption using RequestEnclave.
|
|
1494
|
+
* Uses either a pre-fetched voucher (as data URL) or the live voucherURL
|
|
1495
|
+
* that the ORK iframe will fetch with session cookies.
|
|
1496
|
+
* @private
|
|
1497
|
+
* @param {{ data: string | Uint8Array, tags: string[] }[]} toEncrypt
|
|
1498
|
+
* @returns {Promise<(string | Uint8Array)[]>}
|
|
1499
|
+
*/
|
|
1500
|
+
async _nativeEncrypt(toEncrypt) {
|
|
1501
|
+
// Ensure token is fresh
|
|
1502
|
+
await this.updateIAMToken();
|
|
1503
|
+
if (!Array.isArray(toEncrypt)) {
|
|
1504
|
+
throw new Error("Pass array as parameter");
|
|
1505
|
+
}
|
|
1506
|
+
if (!this._nativeAuthenticated) {
|
|
1507
|
+
throw new Error("Not authenticated");
|
|
1508
|
+
}
|
|
1509
|
+
// Convert and validate input
|
|
1510
|
+
const dataToSend = toEncrypt.map((e) => {
|
|
1511
|
+
if (typeof e !== 'object' || e === null) {
|
|
1512
|
+
throw new Error("All entries must be an object to encrypt");
|
|
1513
|
+
}
|
|
1514
|
+
for (const property of ['data', 'tags']) {
|
|
1515
|
+
if (!e[property]) {
|
|
1516
|
+
throw new Error(`The object is missing the required '${property}' property.`);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
if (!Array.isArray(e.tags)) {
|
|
1520
|
+
throw new Error("tags must be provided as a string array");
|
|
1521
|
+
}
|
|
1522
|
+
if (typeof e.data !== 'string' && !(e.data instanceof Uint8Array)) {
|
|
1523
|
+
throw new Error("data must be provided as string or Uint8Array");
|
|
1524
|
+
}
|
|
1525
|
+
// Check roles
|
|
1526
|
+
for (const tag of e.tags) {
|
|
1527
|
+
if (typeof tag !== 'string') {
|
|
1528
|
+
throw new Error("tags must be provided as an array of strings");
|
|
1529
|
+
}
|
|
1530
|
+
const tagAccess = this._nativeHasRealmRole(`_tide_${tag}.selfencrypt`);
|
|
1531
|
+
if (!tagAccess) {
|
|
1532
|
+
throw new Error(`User has not been given any access to '${tag}'`);
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
return {
|
|
1536
|
+
data: typeof e.data === 'string' ? this._stringToUint8Array(e.data) : e.data,
|
|
1537
|
+
tags: e.tags,
|
|
1538
|
+
isRaw: typeof e.data === 'string' ? false : true,
|
|
1539
|
+
};
|
|
1540
|
+
});
|
|
1541
|
+
// Get voucher URL - RequestEnclave will fetch it via ORK iframe
|
|
1542
|
+
const voucherUrl = this._getNativeVoucherUrl();
|
|
1543
|
+
console.debug("[IAMService._nativeEncrypt] Using voucherURL:", voucherUrl);
|
|
1544
|
+
this._initNativeRequestEnclave(voucherUrl);
|
|
1545
|
+
const encrypted = await this._nativeRequestEnclave.encrypt(dataToSend);
|
|
1546
|
+
return encrypted.map((cipher, i) => (dataToSend[i].isRaw ? cipher : this._bytesToBase64(cipher)));
|
|
1547
|
+
}
|
|
1548
|
+
/**
|
|
1549
|
+
* Native mode decryption using RequestEnclave.
|
|
1550
|
+
* Uses either a pre-fetched voucher (as data URL) or the live voucherURL
|
|
1551
|
+
* that the ORK iframe will fetch with session cookies.
|
|
1552
|
+
* @private
|
|
1553
|
+
* @param {{ encrypted: string | Uint8Array, tags: string[] }[]} toDecrypt
|
|
1554
|
+
* @returns {Promise<(string | Uint8Array)[]>}
|
|
1555
|
+
*/
|
|
1556
|
+
async _nativeDecrypt(toDecrypt) {
|
|
1557
|
+
// Ensure token is fresh
|
|
1558
|
+
await this.updateIAMToken();
|
|
1559
|
+
if (!Array.isArray(toDecrypt)) {
|
|
1560
|
+
throw new Error("Pass array as parameter");
|
|
1561
|
+
}
|
|
1562
|
+
if (!this._nativeAuthenticated) {
|
|
1563
|
+
throw new Error("Not authenticated");
|
|
1564
|
+
}
|
|
1565
|
+
// Convert and validate input
|
|
1566
|
+
const dataToSend = toDecrypt.map((e) => {
|
|
1567
|
+
if (typeof e !== 'object' || e === null) {
|
|
1568
|
+
throw new Error("All entries must be an object to decrypt");
|
|
1569
|
+
}
|
|
1570
|
+
for (const property of ['encrypted', 'tags']) {
|
|
1571
|
+
if (!e[property]) {
|
|
1572
|
+
throw new Error(`The object is missing the required '${property}' property.`);
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
if (!Array.isArray(e.tags)) {
|
|
1576
|
+
throw new Error("tags must be provided as a string array");
|
|
1577
|
+
}
|
|
1578
|
+
if (typeof e.encrypted !== 'string' && !(e.encrypted instanceof Uint8Array)) {
|
|
1579
|
+
throw new Error("encrypted must be provided as string or Uint8Array");
|
|
1580
|
+
}
|
|
1581
|
+
// Check roles
|
|
1582
|
+
for (const tag of e.tags) {
|
|
1583
|
+
if (typeof tag !== 'string') {
|
|
1584
|
+
throw new Error("tags must be provided as an array of strings");
|
|
1585
|
+
}
|
|
1586
|
+
const tagAccess = this._nativeHasRealmRole(`_tide_${tag}.selfdecrypt`);
|
|
1587
|
+
if (!tagAccess) {
|
|
1588
|
+
throw new Error(`User has not been given any access to '${tag}'`);
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
return {
|
|
1592
|
+
encrypted: typeof e.encrypted === 'string' ? this._base64ToBytes(e.encrypted) : e.encrypted,
|
|
1593
|
+
tags: e.tags,
|
|
1594
|
+
isRaw: typeof e.encrypted === 'string' ? false : true,
|
|
1595
|
+
};
|
|
1596
|
+
});
|
|
1597
|
+
// Get voucher URL - RequestEnclave will fetch it via ORK iframe
|
|
1598
|
+
const voucherUrl = this._getNativeVoucherUrl();
|
|
1599
|
+
console.debug("[IAMService._nativeDecrypt] Using voucherURL:", voucherUrl);
|
|
1600
|
+
this._initNativeRequestEnclave(voucherUrl);
|
|
1601
|
+
const decrypted = await this._nativeRequestEnclave.decrypt(dataToSend);
|
|
1602
|
+
return decrypted.map((d, i) => (dataToSend[i].isRaw ? d : this._stringFromUint8Array(d)));
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Convert string to Uint8Array.
|
|
1606
|
+
* @private
|
|
1607
|
+
*/
|
|
1608
|
+
_stringToUint8Array(str) {
|
|
1609
|
+
return new TextEncoder().encode(str);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* Convert Uint8Array to string.
|
|
1613
|
+
* @private
|
|
1614
|
+
*/
|
|
1615
|
+
_stringFromUint8Array(bytes) {
|
|
1616
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1617
|
+
}
|
|
1618
|
+
/**
|
|
1619
|
+
* Convert bytes to base64.
|
|
1620
|
+
* @private
|
|
1621
|
+
*/
|
|
1622
|
+
_bytesToBase64(bytes) {
|
|
1623
|
+
const binString = String.fromCodePoint(...bytes);
|
|
1624
|
+
return btoa(binString);
|
|
1625
|
+
}
|
|
1626
|
+
/**
|
|
1627
|
+
* Convert base64 to bytes.
|
|
1628
|
+
* @private
|
|
1629
|
+
*/
|
|
1630
|
+
_base64ToBytes(base64) {
|
|
1631
|
+
const binString = atob(base64);
|
|
1632
|
+
const len = binString.length;
|
|
1633
|
+
const bytes = new Uint8Array(len);
|
|
1634
|
+
for (let i = 0; i < len; i++) {
|
|
1635
|
+
bytes[i] = binString.codePointAt(i);
|
|
1636
|
+
}
|
|
1637
|
+
return bytes;
|
|
1638
|
+
}
|
|
1639
|
+
/**
|
|
1640
|
+
* Parse a JWT token and return its payload.
|
|
1641
|
+
* @private
|
|
1642
|
+
* @param {string} token - JWT token string
|
|
1643
|
+
* @returns {Object|null} Parsed token payload or null on error
|
|
1644
|
+
*/
|
|
1645
|
+
_parseToken(token) {
|
|
1646
|
+
if (!token)
|
|
1647
|
+
return null;
|
|
1648
|
+
try {
|
|
1649
|
+
const payload = JSON.parse(atob(token.split('.')[1]));
|
|
1650
|
+
return payload;
|
|
1651
|
+
}
|
|
1652
|
+
catch (e) {
|
|
1653
|
+
console.error("[IAMService] Failed to parse token:", e);
|
|
1654
|
+
return null;
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/**
|
|
1658
|
+
* Cleanup native mode resources.
|
|
1659
|
+
*/
|
|
1660
|
+
destroy() {
|
|
1661
|
+
if (this._nativeCallbackUnsubscribe) {
|
|
1662
|
+
this._nativeCallbackUnsubscribe();
|
|
1663
|
+
this._nativeCallbackUnsubscribe = null;
|
|
1664
|
+
}
|
|
1665
|
+
if (this._nativeEncryptionCallbackUnsubscribe) {
|
|
1666
|
+
this._nativeEncryptionCallbackUnsubscribe();
|
|
1667
|
+
this._nativeEncryptionCallbackUnsubscribe = null;
|
|
1668
|
+
}
|
|
1669
|
+
// Cleanup pending encryption requests
|
|
1670
|
+
for (const [requestId, pending] of this._pendingEncryptionRequests) {
|
|
1671
|
+
pending.reject(new Error("IAMService destroyed"));
|
|
1672
|
+
}
|
|
1673
|
+
this._pendingEncryptionRequests.clear();
|
|
1674
|
+
// Cleanup request enclave
|
|
1675
|
+
if (this._nativeRequestEnclave) {
|
|
1676
|
+
try {
|
|
1677
|
+
this._nativeRequestEnclave.close();
|
|
1678
|
+
}
|
|
1679
|
+
catch (e) {
|
|
1680
|
+
console.debug("[IAMService] Error closing request enclave:", e);
|
|
1681
|
+
}
|
|
1682
|
+
this._nativeRequestEnclave = null;
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
770
1685
|
}
|
|
771
1686
|
const IAMServiceInstance = new IAMService();
|
|
772
1687
|
export { IAMServiceInstance as IAMService };
|