@telicent-oss/fe-auth-lib 0.0.1-TELFE-1371.32 โ 0.0.1-TELFE-1371.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/AuthServerOAuth2Client.js +912 -790
|
@@ -1,869 +1,991 @@
|
|
|
1
1
|
// Import schemas for validation (Node.js/bundler environments)
|
|
2
2
|
let GetUserInfoSchema;
|
|
3
3
|
let AuthServerOAuth2ClientConfigSchema;
|
|
4
|
-
if (typeof require !==
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
4
|
+
if (typeof require !== "undefined") {
|
|
5
|
+
try {
|
|
6
|
+
const schemas = require("./schemas.js");
|
|
7
|
+
GetUserInfoSchema = schemas.GetUserInfoSchema;
|
|
8
|
+
AuthServerOAuth2ClientConfigSchema =
|
|
9
|
+
schemas.AuthServerOAuth2ClientConfigSchema;
|
|
10
|
+
} catch (e) {
|
|
11
|
+
console.warn(
|
|
12
|
+
"Zod schema not available, validation will be skipped:",
|
|
13
|
+
e.message
|
|
14
|
+
);
|
|
15
|
+
}
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
// Unified OAuth2 Client Implementation using Auth Server Session Management
|
|
15
19
|
// Works for both same-domain and cross-domain scenarios with automatic detection
|
|
16
20
|
class AuthServerOAuth2Client {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
redirectUri: 'http://demo.telicent.localhost/callback.html', // Default - should be overridden
|
|
34
|
-
popupRedirectUri: null, // Must be provided for popup flows
|
|
35
|
-
scope: 'openid email profile offline_access',
|
|
36
|
-
apiUrl: 'http://api.telicent.localhost',
|
|
37
|
-
onLogout: () => {
|
|
38
|
-
window.alert('You are now logged out. Redirecting to /');
|
|
39
|
-
window.location.href = '/';
|
|
40
|
-
},
|
|
41
|
-
...config
|
|
42
|
-
};
|
|
43
|
-
|
|
44
|
-
// Auto-detect if this is a cross-domain client
|
|
45
|
-
this.isCrossDomain = this.detectCrossDomain();
|
|
46
|
-
console.log(`๐ง Initialized ${this.isCrossDomain ? 'cross-domain' : 'same-domain'} OAuth2 client`);
|
|
47
|
-
console.log(`๐ง Client config:`, {
|
|
48
|
-
clientId: this.config.clientId,
|
|
49
|
-
redirectUri: this.config.redirectUri,
|
|
50
|
-
authServerUrl: this.config.authServerUrl,
|
|
51
|
-
currentOrigin: window.location.origin
|
|
52
|
-
});
|
|
21
|
+
// Event constants for popup communication
|
|
22
|
+
static OAUTH_SUCCESS = "oauth-success";
|
|
23
|
+
static OAUTH_ERROR = "oauth-error";
|
|
24
|
+
constructor(config) {
|
|
25
|
+
if (AuthServerOAuth2ClientConfigSchema && config) {
|
|
26
|
+
try {
|
|
27
|
+
AuthServerOAuth2ClientConfigSchema.parse(config);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
console.error(
|
|
30
|
+
"โ Invalid AuthServerOAuth2Client configuration:",
|
|
31
|
+
error.errors || error.message
|
|
32
|
+
);
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Invalid AuthServerOAuth2Client configuration: ${error.message}`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
53
37
|
}
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
39
|
+
this.config = {
|
|
40
|
+
clientId: "spa-client", // Default - should be overridden
|
|
41
|
+
authServerUrl: "http://auth.telicent.localhost",
|
|
42
|
+
redirectUri: "http://demo.telicent.localhost/callback.html", // Default - should be overridden
|
|
43
|
+
popupRedirectUri: null, // Must be provided for popup flows
|
|
44
|
+
scope: "openid email profile offline_access",
|
|
45
|
+
apiUrl: "http://api.telicent.localhost",
|
|
46
|
+
onLogout: () => {
|
|
47
|
+
window.alert("You are now logged out. Redirecting to /");
|
|
48
|
+
window.location.href = "/";
|
|
49
|
+
},
|
|
50
|
+
...config,
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// Auto-detect if this is a cross-domain client
|
|
54
|
+
this.isCrossDomain = this.detectCrossDomain();
|
|
55
|
+
console.log(
|
|
56
|
+
`๐ง Initialized ${
|
|
57
|
+
this.isCrossDomain ? "cross-domain" : "same-domain"
|
|
58
|
+
} OAuth2 client`
|
|
59
|
+
);
|
|
60
|
+
console.log(`๐ง Client config:`, {
|
|
61
|
+
clientId: this.config.clientId,
|
|
62
|
+
redirectUri: this.config.redirectUri,
|
|
63
|
+
authServerUrl: this.config.authServerUrl,
|
|
64
|
+
currentOrigin: window.location.origin,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Detect if this is a cross-domain client based on current origin vs auth server URL
|
|
69
|
+
detectCrossDomain() {
|
|
70
|
+
try {
|
|
71
|
+
const currentOrigin = window.location.origin;
|
|
72
|
+
const authServerUrl = new URL(this.config.authServerUrl);
|
|
73
|
+
const authServerOrigin = authServerUrl.origin;
|
|
74
|
+
|
|
75
|
+
const currentHost = new URL(currentOrigin).hostname;
|
|
76
|
+
const authServerHost = authServerUrl.hostname;
|
|
77
|
+
|
|
78
|
+
// Check if domains are different or if they don't share a common parent domain
|
|
79
|
+
const isSameDomain =
|
|
80
|
+
currentHost === authServerHost ||
|
|
81
|
+
(currentHost.endsWith(".telicent.localhost") &&
|
|
82
|
+
authServerHost.endsWith(".telicent.localhost"));
|
|
83
|
+
|
|
84
|
+
console.log(
|
|
85
|
+
`๐ Domain detection: current=${currentHost}, auth=${authServerHost}, sameDomain=${isSameDomain}`
|
|
86
|
+
);
|
|
87
|
+
return !isSameDomain;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
console.warn(
|
|
90
|
+
"Error detecting domain context, defaulting to cross-domain:",
|
|
91
|
+
error
|
|
92
|
+
);
|
|
93
|
+
return true; // Default to cross-domain for safety
|
|
75
94
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Generate code verifier for PKCE
|
|
98
|
+
generateCodeVerifier() {
|
|
99
|
+
const array = new Uint8Array(32);
|
|
100
|
+
crypto.getRandomValues(array);
|
|
101
|
+
return this.base64URLEncode(array);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Generate code challenge from verifier
|
|
105
|
+
async generateCodeChallenge(codeVerifier) {
|
|
106
|
+
const encoder = new TextEncoder();
|
|
107
|
+
const data = encoder.encode(codeVerifier);
|
|
108
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
109
|
+
return this.base64URLEncode(new Uint8Array(digest));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Base64 URL encoding
|
|
113
|
+
base64URLEncode(array) {
|
|
114
|
+
return btoa(String.fromCharCode.apply(null, array))
|
|
115
|
+
.replace(/\+/g, "-")
|
|
116
|
+
.replace(/\//g, "_")
|
|
117
|
+
.replace(/=/g, "");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Generate random state
|
|
121
|
+
generateState() {
|
|
122
|
+
const array = new Uint8Array(16);
|
|
123
|
+
crypto.getRandomValues(array);
|
|
124
|
+
return this.base64URLEncode(array);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Generate random nonce for ID token binding
|
|
128
|
+
generateNonce() {
|
|
129
|
+
const array = new Uint8Array(16);
|
|
130
|
+
crypto.getRandomValues(array);
|
|
131
|
+
return this.base64URLEncode(array);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Start OAuth2 login flow (redirects current window)
|
|
135
|
+
async login(redirectUri = null) {
|
|
136
|
+
const state = this.generateState();
|
|
137
|
+
const nonce = this.generateNonce();
|
|
138
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
139
|
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
|
140
|
+
|
|
141
|
+
// Use provided redirectUri or fall back to config default
|
|
142
|
+
const finalRedirectUri = redirectUri || this.config.redirectUri;
|
|
143
|
+
|
|
144
|
+
// Store PKCE values, nonce, and redirectUri in sessionStorage (temporary)
|
|
145
|
+
sessionStorage.setItem("oauth_state", state);
|
|
146
|
+
sessionStorage.setItem("oauth_nonce", nonce);
|
|
147
|
+
sessionStorage.setItem("oauth_code_verifier", codeVerifier);
|
|
148
|
+
sessionStorage.setItem("oauth_redirect_uri", finalRedirectUri);
|
|
149
|
+
|
|
150
|
+
// Build authorization URL
|
|
151
|
+
const params = new URLSearchParams({
|
|
152
|
+
response_type: "code",
|
|
153
|
+
client_id: this.config.clientId,
|
|
154
|
+
redirect_uri: finalRedirectUri,
|
|
155
|
+
return_to: window.location.href,
|
|
156
|
+
scope: this.config.scope,
|
|
157
|
+
state: state,
|
|
158
|
+
nonce: nonce,
|
|
159
|
+
code_challenge: codeChallenge,
|
|
160
|
+
code_challenge_method: "S256",
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
const authUrl = `${
|
|
164
|
+
this.config.authServerUrl
|
|
165
|
+
}/oauth2/authorize?${params.toString()}`;
|
|
166
|
+
console.log("Redirecting current window to:", authUrl);
|
|
167
|
+
|
|
168
|
+
// Always redirect current window
|
|
169
|
+
window.location.href = authUrl;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Start OAuth2 login flow in popup window
|
|
173
|
+
async loginWithPopup(
|
|
174
|
+
redirectUri = null,
|
|
175
|
+
features = "width=600,height=700,scrollbars=yes,resizable=yes"
|
|
176
|
+
) {
|
|
177
|
+
// Use provided redirectUri or fall back to config popupRedirectUri
|
|
178
|
+
const finalRedirectUri = redirectUri || this.config.popupRedirectUri;
|
|
179
|
+
|
|
180
|
+
if (!finalRedirectUri) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
"redirectUri is required for popup login. Either provide it as a parameter or configure popupRedirectUri in the client config."
|
|
183
|
+
);
|
|
82
184
|
}
|
|
83
185
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
186
|
+
const state = this.generateState();
|
|
187
|
+
const nonce = this.generateNonce();
|
|
188
|
+
const codeVerifier = this.generateCodeVerifier();
|
|
189
|
+
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
|
190
|
+
|
|
191
|
+
// Store PKCE values, nonce, and redirectUri in sessionStorage (temporary)
|
|
192
|
+
sessionStorage.setItem("oauth_state", state);
|
|
193
|
+
sessionStorage.setItem("oauth_nonce", nonce);
|
|
194
|
+
sessionStorage.setItem("oauth_code_verifier", codeVerifier);
|
|
195
|
+
sessionStorage.setItem("oauth_redirect_uri", finalRedirectUri);
|
|
196
|
+
|
|
197
|
+
// Build authorization URL
|
|
198
|
+
const params = new URLSearchParams({
|
|
199
|
+
response_type: "code",
|
|
200
|
+
client_id: this.config.clientId,
|
|
201
|
+
redirect_uri: finalRedirectUri,
|
|
202
|
+
scope: this.config.scope,
|
|
203
|
+
state: state,
|
|
204
|
+
nonce: nonce,
|
|
205
|
+
code_challenge: codeChallenge,
|
|
206
|
+
code_challenge_method: "S256",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const authUrl = `${
|
|
210
|
+
this.config.authServerUrl
|
|
211
|
+
}/oauth2/authorize?${params.toString()}`;
|
|
212
|
+
console.log("Opening popup with URL:", authUrl);
|
|
213
|
+
console.log("Popup features:", features);
|
|
214
|
+
|
|
215
|
+
// Use window.top to break out of iframe context (e.g., Storybook)
|
|
216
|
+
const targetWindow = window.top || window;
|
|
217
|
+
const popup = targetWindow.open(authUrl, "_blank", features);
|
|
218
|
+
console.log("Popup opened:", popup ? "Success" : "Blocked or failed");
|
|
219
|
+
|
|
220
|
+
// Start popup flow and listen for messages
|
|
221
|
+
if (popup) {
|
|
222
|
+
this.startPopupFlow(popup);
|
|
90
223
|
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Handle callback after authorization - creates session via auth-server
|
|
227
|
+
async handleCallback(callbackParams = null) {
|
|
228
|
+
let urlParams;
|
|
229
|
+
if (callbackParams) {
|
|
230
|
+
// Use provided parameters directly
|
|
231
|
+
urlParams = new URLSearchParams(callbackParams);
|
|
232
|
+
} else {
|
|
233
|
+
// Fall back to reading from current URL
|
|
234
|
+
urlParams = new URLSearchParams(window.location.search);
|
|
235
|
+
}
|
|
236
|
+
const code = urlParams.get("code");
|
|
237
|
+
const state = urlParams.get("state");
|
|
238
|
+
const error = urlParams.get("error");
|
|
91
239
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
return btoa(String.fromCharCode.apply(null, array))
|
|
95
|
-
.replace(/\+/g, '-')
|
|
96
|
-
.replace(/\//g, '_')
|
|
97
|
-
.replace(/=/g, '');
|
|
240
|
+
if (error) {
|
|
241
|
+
throw new Error(`OAuth error: ${error}`);
|
|
98
242
|
}
|
|
99
243
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const array = new Uint8Array(16);
|
|
103
|
-
crypto.getRandomValues(array);
|
|
104
|
-
return this.base64URLEncode(array);
|
|
244
|
+
if (!code || !state) {
|
|
245
|
+
throw new Error("Missing code or state parameter");
|
|
105
246
|
}
|
|
106
247
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
crypto.getRandomValues(array);
|
|
111
|
-
return this.base64URLEncode(array);
|
|
248
|
+
const storedState = sessionStorage.getItem("oauth_state");
|
|
249
|
+
if (state !== storedState) {
|
|
250
|
+
throw new Error("Invalid state parameter");
|
|
112
251
|
}
|
|
113
252
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const codeVerifier = this.generateCodeVerifier();
|
|
119
|
-
const codeChallenge = await this.generateCodeChallenge(codeVerifier);
|
|
120
|
-
|
|
121
|
-
// Use provided redirectUri or fall back to config default
|
|
122
|
-
const finalRedirectUri = redirectUri || this.config.redirectUri;
|
|
123
|
-
|
|
124
|
-
// Store PKCE values, nonce, and redirectUri in sessionStorage (temporary)
|
|
125
|
-
sessionStorage.setItem('oauth_state', state);
|
|
126
|
-
sessionStorage.setItem('oauth_nonce', nonce);
|
|
127
|
-
sessionStorage.setItem('oauth_code_verifier', codeVerifier);
|
|
128
|
-
sessionStorage.setItem('oauth_redirect_uri', finalRedirectUri);
|
|
129
|
-
|
|
130
|
-
// Build authorization URL
|
|
131
|
-
const params = new URLSearchParams({
|
|
132
|
-
response_type: 'code',
|
|
133
|
-
client_id: this.config.clientId,
|
|
134
|
-
redirect_uri: finalRedirectUri,
|
|
135
|
-
scope: this.config.scope,
|
|
136
|
-
state: state,
|
|
137
|
-
nonce: nonce,
|
|
138
|
-
code_challenge: codeChallenge,
|
|
139
|
-
code_challenge_method: 'S256'
|
|
140
|
-
});
|
|
253
|
+
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
|
254
|
+
if (!codeVerifier) {
|
|
255
|
+
throw new Error("Missing code verifier");
|
|
256
|
+
}
|
|
141
257
|
|
|
142
|
-
|
|
143
|
-
|
|
258
|
+
const redirectUri = sessionStorage.getItem("oauth_redirect_uri");
|
|
259
|
+
if (!redirectUri) {
|
|
260
|
+
throw new Error("Missing redirect URI");
|
|
261
|
+
}
|
|
144
262
|
|
|
145
|
-
|
|
263
|
+
// Exchange authorization code for session (secure - access token never exposed to client)
|
|
264
|
+
const tokenResponse = await fetch(
|
|
265
|
+
`${this.config.authServerUrl}/oauth2/token`,
|
|
266
|
+
{
|
|
267
|
+
method: "POST",
|
|
268
|
+
headers: {
|
|
269
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
270
|
+
Accept: "application/json",
|
|
271
|
+
},
|
|
272
|
+
credentials: "include", // Include cookies for session
|
|
273
|
+
body: new URLSearchParams({
|
|
274
|
+
grant_type: "authorization_code",
|
|
275
|
+
client_id: this.config.clientId,
|
|
276
|
+
code: code,
|
|
277
|
+
redirect_uri: redirectUri,
|
|
278
|
+
code_verifier: codeVerifier,
|
|
279
|
+
}),
|
|
280
|
+
}
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
if (!tokenResponse.ok) {
|
|
284
|
+
let errorDetails;
|
|
285
|
+
try {
|
|
286
|
+
// Try to parse JSON error response first
|
|
287
|
+
errorDetails = await tokenResponse.json();
|
|
288
|
+
} catch (e) {
|
|
289
|
+
// Fallback to text if not JSON
|
|
290
|
+
const errorText = await tokenResponse.text();
|
|
291
|
+
errorDetails = { error: "unknown", error_description: errorText };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Handle specific consent_required error
|
|
295
|
+
if (errorDetails.error === "consent_required") {
|
|
296
|
+
console.log("Consent required - redirecting to proper OAuth2 flow");
|
|
297
|
+
|
|
298
|
+
// The SPA should not directly call /oauth2/session without consent
|
|
299
|
+
// Instead, redirect to start the proper OAuth2 authorization flow
|
|
300
|
+
// which will handle consent properly through OAuth2AuthenticationSuccessHandler
|
|
301
|
+
const authUrl = this.buildAuthorizationUrl();
|
|
302
|
+
console.log("Redirecting to OAuth2 authorization flow:", authUrl);
|
|
146
303
|
window.location.href = authUrl;
|
|
304
|
+
return; // Don't throw error, we're redirecting
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// For other errors, throw as before
|
|
308
|
+
const errorMessage =
|
|
309
|
+
errorDetails.error_description || errorDetails.error || "Unknown error";
|
|
310
|
+
throw new Error(
|
|
311
|
+
`Token exchange and session creation failed: ${errorMessage}`
|
|
312
|
+
);
|
|
147
313
|
}
|
|
148
314
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
nonce: nonce,
|
|
177
|
-
code_challenge: codeChallenge,
|
|
178
|
-
code_challenge_method: 'S256'
|
|
179
|
-
});
|
|
180
|
-
|
|
181
|
-
const authUrl = `${this.config.authServerUrl}/oauth2/authorize?${params.toString()}`;
|
|
182
|
-
console.log('Opening popup with URL:', authUrl);
|
|
183
|
-
console.log('Popup features:', features);
|
|
184
|
-
|
|
185
|
-
// Use window.top to break out of iframe context (e.g., Storybook)
|
|
186
|
-
const targetWindow = window.top || window;
|
|
187
|
-
const popup = targetWindow.open(authUrl, '_blank', features);
|
|
188
|
-
console.log('Popup opened:', popup ? 'Success' : 'Blocked or failed');
|
|
189
|
-
|
|
190
|
-
// Start popup flow and listen for messages
|
|
191
|
-
if (popup) {
|
|
192
|
-
this.startPopupFlow(popup);
|
|
193
|
-
}
|
|
315
|
+
const sessionData = await tokenResponse.json();
|
|
316
|
+
console.log(
|
|
317
|
+
"๐ Session created securely via domain cookies (access token never exposed)"
|
|
318
|
+
);
|
|
319
|
+
console.log("๐ Session data:", sessionData);
|
|
320
|
+
|
|
321
|
+
// For cross-domain clients, store session ID for Authorization header use
|
|
322
|
+
// since cookies won't work across different domains
|
|
323
|
+
console.log("๐ Cross-domain check:", {
|
|
324
|
+
isCrossDomain: this.isCrossDomain,
|
|
325
|
+
sessionIsCrossDomain: sessionData.isCrossDomain,
|
|
326
|
+
hasSessionToken: !!sessionData.sessionToken,
|
|
327
|
+
sessionToken: sessionData.sessionToken,
|
|
328
|
+
});
|
|
329
|
+
if (
|
|
330
|
+
this.isCrossDomain &&
|
|
331
|
+
sessionData.isCrossDomain &&
|
|
332
|
+
sessionData.sessionToken
|
|
333
|
+
) {
|
|
334
|
+
sessionStorage.setItem("auth_session_id", sessionData.sessionToken);
|
|
335
|
+
console.log(
|
|
336
|
+
"๐ Cross-domain client: Session ID stored for Authorization header use"
|
|
337
|
+
);
|
|
338
|
+
} else {
|
|
339
|
+
console.log(
|
|
340
|
+
"โ Cross-domain session storage skipped - conditions not met"
|
|
341
|
+
);
|
|
194
342
|
}
|
|
195
343
|
|
|
196
|
-
//
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
344
|
+
// Step 2: Retrieve ID token separately for enhanced security
|
|
345
|
+
// All clients now use session-based authentication for maximum security
|
|
346
|
+
try {
|
|
347
|
+
// Add a small delay to ensure session is fully propagated
|
|
348
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
349
|
+
|
|
350
|
+
const idTokenResponse = await this.makeAuthenticatedRequest(
|
|
351
|
+
`${this.config.authServerUrl}/session/idtoken`,
|
|
352
|
+
{ skipAutoLogout: true }
|
|
353
|
+
);
|
|
354
|
+
|
|
355
|
+
if (!idTokenResponse.ok) {
|
|
356
|
+
console.warn(
|
|
357
|
+
"Failed to retrieve ID token, but continuing with callback completion"
|
|
358
|
+
);
|
|
359
|
+
// Don't throw - allow callback to complete without ID token
|
|
360
|
+
} else {
|
|
361
|
+
const idTokenData = await idTokenResponse.json();
|
|
362
|
+
console.log(
|
|
363
|
+
"ID token retrieved in separate call for enhanced security"
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
// Store ID token for offline user information access
|
|
367
|
+
if (idTokenData.id_token) {
|
|
368
|
+
// Validate ID token with nonce before storing
|
|
369
|
+
if (!this.validateIdToken(idTokenData.id_token)) {
|
|
370
|
+
console.warn(
|
|
371
|
+
"ID token validation failed, but continuing with callback"
|
|
372
|
+
);
|
|
373
|
+
} else {
|
|
374
|
+
sessionStorage.setItem("auth_id_token", idTokenData.id_token);
|
|
375
|
+
console.log("ID token validated and stored in sessionStorage");
|
|
376
|
+
}
|
|
212
377
|
}
|
|
378
|
+
}
|
|
379
|
+
} catch (error) {
|
|
380
|
+
console.warn(
|
|
381
|
+
"Error retrieving ID token during callback, but continuing:",
|
|
382
|
+
error
|
|
383
|
+
);
|
|
384
|
+
// Don't throw - allow callback to complete even if ID token retrieval fails
|
|
385
|
+
}
|
|
213
386
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
387
|
+
// Clean up PKCE values
|
|
388
|
+
sessionStorage.removeItem("oauth_state");
|
|
389
|
+
sessionStorage.removeItem("oauth_code_verifier");
|
|
390
|
+
// Note: oauth_nonce is removed in validateIdToken after successful validation
|
|
217
391
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
throw new Error('Invalid state parameter');
|
|
221
|
-
}
|
|
392
|
+
return sessionData;
|
|
393
|
+
}
|
|
222
394
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
395
|
+
// Check if user is authenticated by checking session via auth-server
|
|
396
|
+
async isAuthenticated() {
|
|
397
|
+
try {
|
|
398
|
+
const headers = {
|
|
399
|
+
Accept: "application/json",
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
// For cross-domain clients, include session ID in Authorization header
|
|
403
|
+
// For same-domain clients, cookies are included automatically
|
|
404
|
+
const sessionId = sessionStorage.getItem("auth_session_id");
|
|
405
|
+
if (this.isCrossDomain && sessionId) {
|
|
406
|
+
headers["Authorization"] = `Bearer ${sessionId}`;
|
|
407
|
+
console.log(
|
|
408
|
+
"๐ Using session ID in Authorization header for authentication check"
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const response = await fetch(
|
|
413
|
+
`${this.config.authServerUrl}/session/check`,
|
|
414
|
+
{
|
|
415
|
+
method: "GET",
|
|
416
|
+
headers: headers,
|
|
417
|
+
credentials: "include", // Include cookies for same-domain fallback
|
|
226
418
|
}
|
|
419
|
+
);
|
|
420
|
+
|
|
421
|
+
if (response.ok) {
|
|
422
|
+
const result = await response.json();
|
|
423
|
+
console.log(result);
|
|
424
|
+
return true;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return false;
|
|
428
|
+
} catch (error) {
|
|
429
|
+
console.error("Authentication check error:", error);
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
227
433
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
434
|
+
// Helper function to decode JWT token payload
|
|
435
|
+
decodeJWT(token) {
|
|
436
|
+
try {
|
|
437
|
+
const base64Url = token.split(".")[1];
|
|
438
|
+
const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
|
|
439
|
+
const jsonPayload = decodeURIComponent(
|
|
440
|
+
atob(base64)
|
|
441
|
+
.split("")
|
|
442
|
+
.map(function (c) {
|
|
443
|
+
return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2);
|
|
444
|
+
})
|
|
445
|
+
.join("")
|
|
446
|
+
);
|
|
447
|
+
return JSON.parse(jsonPayload);
|
|
448
|
+
} catch (error) {
|
|
449
|
+
console.error("Error decoding JWT:", error);
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
232
453
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
})
|
|
454
|
+
// Validate ID token for session recovery (without nonce)
|
|
455
|
+
validateIdTokenForRecovery(idToken) {
|
|
456
|
+
try {
|
|
457
|
+
const payload = this.decodeJWT(idToken);
|
|
458
|
+
if (!payload) {
|
|
459
|
+
console.error("Failed to decode ID token payload");
|
|
460
|
+
return false;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// Validate audience (client ID)
|
|
464
|
+
if (payload.aud !== this.config.clientId) {
|
|
465
|
+
console.error("Audience validation failed", {
|
|
466
|
+
expected: this.config.clientId,
|
|
467
|
+
actual: payload.aud,
|
|
248
468
|
});
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const errorText = await tokenResponse.text();
|
|
258
|
-
errorDetails = { error: 'unknown', error_description: errorText };
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
// Handle specific consent_required error
|
|
262
|
-
if (errorDetails.error === 'consent_required') {
|
|
263
|
-
console.log('Consent required - redirecting to proper OAuth2 flow');
|
|
264
|
-
|
|
265
|
-
// The SPA should not directly call /oauth2/session without consent
|
|
266
|
-
// Instead, redirect to start the proper OAuth2 authorization flow
|
|
267
|
-
// which will handle consent properly through OAuth2AuthenticationSuccessHandler
|
|
268
|
-
const authUrl = this.buildAuthorizationUrl();
|
|
269
|
-
console.log('Redirecting to OAuth2 authorization flow:', authUrl);
|
|
270
|
-
window.location.href = authUrl;
|
|
271
|
-
return; // Don't throw error, we're redirecting
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
// For other errors, throw as before
|
|
275
|
-
const errorMessage = errorDetails.error_description || errorDetails.error || 'Unknown error';
|
|
276
|
-
throw new Error(`Token exchange and session creation failed: ${errorMessage}`);
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
const sessionData = await tokenResponse.json();
|
|
280
|
-
console.log('๐ Session created securely via domain cookies (access token never exposed)');
|
|
281
|
-
console.log('๐ Session data:', sessionData);
|
|
282
|
-
|
|
283
|
-
// For cross-domain clients, store session ID for Authorization header use
|
|
284
|
-
// since cookies won't work across different domains
|
|
285
|
-
console.log('๐ Cross-domain check:', {
|
|
286
|
-
isCrossDomain: this.isCrossDomain,
|
|
287
|
-
sessionIsCrossDomain: sessionData.isCrossDomain,
|
|
288
|
-
hasSessionToken: !!sessionData.sessionToken,
|
|
289
|
-
sessionToken: sessionData.sessionToken
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Validate expiration
|
|
473
|
+
if (!payload.exp || payload.exp * 1000 < Date.now()) {
|
|
474
|
+
console.error("Token expired", {
|
|
475
|
+
exp: payload.exp,
|
|
476
|
+
now: Math.floor(Date.now() / 1000),
|
|
290
477
|
});
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
// Don't throw - allow callback to complete without ID token
|
|
312
|
-
} else {
|
|
313
|
-
const idTokenData = await idTokenResponse.json();
|
|
314
|
-
console.log('ID token retrieved in separate call for enhanced security');
|
|
315
|
-
|
|
316
|
-
// Store ID token for offline user information access
|
|
317
|
-
if (idTokenData.id_token) {
|
|
318
|
-
// Validate ID token with nonce before storing
|
|
319
|
-
if (!this.validateIdToken(idTokenData.id_token)) {
|
|
320
|
-
console.warn('ID token validation failed, but continuing with callback');
|
|
321
|
-
} else {
|
|
322
|
-
sessionStorage.setItem('auth_id_token', idTokenData.id_token);
|
|
323
|
-
console.log('ID token validated and stored in sessionStorage');
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
} catch (error) {
|
|
328
|
-
console.warn('Error retrieving ID token during callback, but continuing:', error);
|
|
329
|
-
// Don't throw - allow callback to complete even if ID token retrieval fails
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// Clean up PKCE values
|
|
333
|
-
sessionStorage.removeItem('oauth_state');
|
|
334
|
-
sessionStorage.removeItem('oauth_code_verifier');
|
|
335
|
-
// Note: oauth_nonce is removed in validateIdToken after successful validation
|
|
336
|
-
|
|
337
|
-
return sessionData;
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Validate issued at time (not too old)
|
|
482
|
+
if (payload.iat && payload.iat * 1000 < Date.now() - 60 * 60 * 1000) {
|
|
483
|
+
// 1 hour max age
|
|
484
|
+
console.error("Token issued too long ago", {
|
|
485
|
+
iat: payload.iat,
|
|
486
|
+
maxAge: "1 hour",
|
|
487
|
+
});
|
|
488
|
+
return false;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
console.log(
|
|
492
|
+
"ID token validation for recovery successful (no nonce check)"
|
|
493
|
+
);
|
|
494
|
+
return true;
|
|
495
|
+
} catch (error) {
|
|
496
|
+
console.error("ID token validation error:", error);
|
|
497
|
+
return false;
|
|
338
498
|
}
|
|
499
|
+
}
|
|
339
500
|
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
501
|
+
// Validate ID token with nonce and other security checks
|
|
502
|
+
validateIdToken(idToken) {
|
|
503
|
+
try {
|
|
504
|
+
const payload = this.decodeJWT(idToken);
|
|
505
|
+
if (!payload) {
|
|
506
|
+
console.error("Failed to decode ID token payload");
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Validate nonce
|
|
511
|
+
const storedNonce = sessionStorage.getItem("oauth_nonce");
|
|
512
|
+
if (!storedNonce) {
|
|
513
|
+
console.error("No stored nonce found for validation");
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (!payload.nonce || payload.nonce !== storedNonce) {
|
|
518
|
+
console.error("Nonce validation failed", {
|
|
519
|
+
stored: storedNonce,
|
|
520
|
+
token: payload.nonce,
|
|
521
|
+
});
|
|
522
|
+
return false;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Validate audience (client ID)
|
|
526
|
+
if (payload.aud !== this.config.clientId) {
|
|
527
|
+
console.error("Audience validation failed", {
|
|
528
|
+
expected: this.config.clientId,
|
|
529
|
+
actual: payload.aud,
|
|
530
|
+
});
|
|
531
|
+
return false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Validate expiration
|
|
535
|
+
if (!payload.exp || payload.exp * 1000 < Date.now()) {
|
|
536
|
+
console.error("Token expired", {
|
|
537
|
+
exp: payload.exp,
|
|
538
|
+
now: Math.floor(Date.now() / 1000),
|
|
539
|
+
});
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Validate issued at time (not too old)
|
|
544
|
+
if (payload.iat && payload.iat * 1000 < Date.now() - 5 * 60 * 1000) {
|
|
545
|
+
console.error("Token issued too long ago", {
|
|
546
|
+
iat: payload.iat,
|
|
547
|
+
maxAge: "5 minutes",
|
|
548
|
+
});
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Clean up nonce after successful validation
|
|
553
|
+
sessionStorage.removeItem("oauth_nonce");
|
|
554
|
+
console.log("ID token validation successful");
|
|
555
|
+
return true;
|
|
556
|
+
} catch (error) {
|
|
557
|
+
console.error("ID token validation error:", error);
|
|
558
|
+
return false;
|
|
372
559
|
}
|
|
560
|
+
}
|
|
373
561
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
562
|
+
// Check if ID token is expired
|
|
563
|
+
isIdTokenExpired() {
|
|
564
|
+
try {
|
|
565
|
+
const idToken = sessionStorage.getItem("auth_id_token");
|
|
566
|
+
if (!idToken) return true;
|
|
567
|
+
|
|
568
|
+
const payload = this.decodeJWT(idToken);
|
|
569
|
+
if (!payload || !payload.exp) return true;
|
|
570
|
+
|
|
571
|
+
// Check if token is expired (with 30 second buffer)
|
|
572
|
+
const now = Math.floor(Date.now() / 1000);
|
|
573
|
+
return payload.exp < now + 30;
|
|
574
|
+
} catch (error) {
|
|
575
|
+
console.error("Error checking ID token expiration:", error);
|
|
576
|
+
return true;
|
|
387
577
|
}
|
|
578
|
+
}
|
|
388
579
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
// Validate audience (client ID)
|
|
399
|
-
if (payload.aud !== this.config.clientId) {
|
|
400
|
-
console.error('Audience validation failed', {
|
|
401
|
-
expected: this.config.clientId,
|
|
402
|
-
actual: payload.aud
|
|
403
|
-
});
|
|
404
|
-
return false;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Validate expiration
|
|
408
|
-
if (!payload.exp || payload.exp * 1000 < Date.now()) {
|
|
409
|
-
console.error('Token expired', {
|
|
410
|
-
exp: payload.exp,
|
|
411
|
-
now: Math.floor(Date.now() / 1000)
|
|
412
|
-
});
|
|
413
|
-
return false;
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
// Validate issued at time (not too old)
|
|
417
|
-
if (payload.iat && payload.iat * 1000 < Date.now() - (60 * 60 * 1000)) { // 1 hour max age
|
|
418
|
-
console.error('Token issued too long ago', {
|
|
419
|
-
iat: payload.iat,
|
|
420
|
-
maxAge: '1 hour'
|
|
421
|
-
});
|
|
422
|
-
return false;
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
console.log('ID token validation for recovery successful (no nonce check)');
|
|
426
|
-
return true;
|
|
427
|
-
|
|
428
|
-
} catch (error) {
|
|
429
|
-
console.error('ID token validation error:', error);
|
|
430
|
-
return false;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
580
|
+
// Get user info from ID token (offline method)
|
|
581
|
+
getUserInfo() {
|
|
582
|
+
try {
|
|
583
|
+
const idToken = sessionStorage.getItem("auth_id_token");
|
|
584
|
+
if (!idToken) {
|
|
585
|
+
console.log("No ID token found in storage");
|
|
586
|
+
return null;
|
|
587
|
+
}
|
|
433
588
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
console.error('Failed to decode ID token payload');
|
|
440
|
-
return false;
|
|
441
|
-
}
|
|
442
|
-
|
|
443
|
-
// Validate nonce
|
|
444
|
-
const storedNonce = sessionStorage.getItem('oauth_nonce');
|
|
445
|
-
if (!storedNonce) {
|
|
446
|
-
console.error('No stored nonce found for validation');
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
if (!payload.nonce || payload.nonce !== storedNonce) {
|
|
451
|
-
console.error('Nonce validation failed', {
|
|
452
|
-
stored: storedNonce,
|
|
453
|
-
token: payload.nonce
|
|
454
|
-
});
|
|
455
|
-
return false;
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
// Validate audience (client ID)
|
|
459
|
-
if (payload.aud !== this.config.clientId) {
|
|
460
|
-
console.error('Audience validation failed', {
|
|
461
|
-
expected: this.config.clientId,
|
|
462
|
-
actual: payload.aud
|
|
463
|
-
});
|
|
464
|
-
return false;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Validate expiration
|
|
468
|
-
if (!payload.exp || payload.exp * 1000 < Date.now()) {
|
|
469
|
-
console.error('Token expired', {
|
|
470
|
-
exp: payload.exp,
|
|
471
|
-
now: Math.floor(Date.now() / 1000)
|
|
472
|
-
});
|
|
473
|
-
return false;
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
// Validate issued at time (not too old)
|
|
477
|
-
if (payload.iat && payload.iat * 1000 < Date.now() - (5 * 60 * 1000)) {
|
|
478
|
-
console.error('Token issued too long ago', {
|
|
479
|
-
iat: payload.iat,
|
|
480
|
-
maxAge: '5 minutes'
|
|
481
|
-
});
|
|
482
|
-
return false;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
// Clean up nonce after successful validation
|
|
486
|
-
sessionStorage.removeItem('oauth_nonce');
|
|
487
|
-
console.log('ID token validation successful');
|
|
488
|
-
return true;
|
|
489
|
-
|
|
490
|
-
} catch (error) {
|
|
491
|
-
console.error('ID token validation error:', error);
|
|
492
|
-
return false;
|
|
493
|
-
}
|
|
494
|
-
}
|
|
589
|
+
const payload = this.decodeJWT(idToken);
|
|
590
|
+
if (!payload) {
|
|
591
|
+
console.log("Failed to decode ID token");
|
|
592
|
+
return null;
|
|
593
|
+
}
|
|
495
594
|
|
|
496
|
-
|
|
497
|
-
|
|
595
|
+
// Check if token is expired
|
|
596
|
+
if (this.isIdTokenExpired()) {
|
|
597
|
+
console.log("ID token is expired");
|
|
598
|
+
return null;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Build user info object from token payload
|
|
602
|
+
const userInfo = {
|
|
603
|
+
sub: payload.sub,
|
|
604
|
+
email: payload.email,
|
|
605
|
+
preferred_name: payload.preferred_name,
|
|
606
|
+
iss: payload.iss,
|
|
607
|
+
aud: payload.aud,
|
|
608
|
+
exp: payload.exp,
|
|
609
|
+
iat: payload.iat,
|
|
610
|
+
jti: payload.jti,
|
|
611
|
+
// Optional OIDC claims
|
|
612
|
+
nonce: payload.nonce,
|
|
613
|
+
auth_time: payload.auth_time,
|
|
614
|
+
sid: payload.sid,
|
|
615
|
+
azp: payload.azp,
|
|
616
|
+
// FE client additions
|
|
617
|
+
name: payload.preferred_name || payload.name || payload.sub,
|
|
618
|
+
token_expired: false,
|
|
619
|
+
token_expires_at: new Date(payload.exp * 1000).toISOString(),
|
|
620
|
+
source: "id_token",
|
|
621
|
+
externalProvider: payload,
|
|
622
|
+
};
|
|
623
|
+
|
|
624
|
+
// Validate against schema if available
|
|
625
|
+
if (GetUserInfoSchema) {
|
|
498
626
|
try {
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
return true;
|
|
627
|
+
const validated = GetUserInfoSchema.parse(userInfo);
|
|
628
|
+
console.log("User info validated successfully against schema");
|
|
629
|
+
return validated;
|
|
630
|
+
} catch (validationError) {
|
|
631
|
+
console.error("User info validation failed:", validationError);
|
|
632
|
+
// Return unvalidated data but log the error
|
|
633
|
+
console.warn(
|
|
634
|
+
"Returning unvalidated user info. Validation errors:",
|
|
635
|
+
validationError.errors
|
|
636
|
+
);
|
|
637
|
+
return userInfo;
|
|
511
638
|
}
|
|
512
|
-
|
|
639
|
+
}
|
|
513
640
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
console.log('No ID token found in storage');
|
|
520
|
-
return null;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const payload = this.decodeJWT(idToken);
|
|
524
|
-
if (!payload) {
|
|
525
|
-
console.log('Failed to decode ID token');
|
|
526
|
-
return null;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Check if token is expired
|
|
530
|
-
if (this.isIdTokenExpired()) {
|
|
531
|
-
console.log('ID token is expired');
|
|
532
|
-
return null;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
// Build user info object from token payload
|
|
536
|
-
const userInfo = {
|
|
537
|
-
sub: payload.sub,
|
|
538
|
-
email: payload.email,
|
|
539
|
-
preferred_name: payload.preferred_name,
|
|
540
|
-
iss: payload.iss,
|
|
541
|
-
aud: payload.aud,
|
|
542
|
-
exp: payload.exp,
|
|
543
|
-
iat: payload.iat,
|
|
544
|
-
jti: payload.jti,
|
|
545
|
-
// Optional OIDC claims
|
|
546
|
-
nonce: payload.nonce,
|
|
547
|
-
auth_time: payload.auth_time,
|
|
548
|
-
sid: payload.sid,
|
|
549
|
-
azp: payload.azp,
|
|
550
|
-
// FE client additions
|
|
551
|
-
name: payload.preferred_name || payload.name || payload.sub,
|
|
552
|
-
token_expired: false,
|
|
553
|
-
token_expires_at: new Date(payload.exp * 1000).toISOString(),
|
|
554
|
-
source: 'id_token',
|
|
555
|
-
externalProvider: payload
|
|
556
|
-
};
|
|
557
|
-
|
|
558
|
-
// Validate against schema if available
|
|
559
|
-
if (GetUserInfoSchema) {
|
|
560
|
-
try {
|
|
561
|
-
const validated = GetUserInfoSchema.parse(userInfo);
|
|
562
|
-
console.log('User info validated successfully against schema');
|
|
563
|
-
return validated;
|
|
564
|
-
} catch (validationError) {
|
|
565
|
-
console.error('User info validation failed:', validationError);
|
|
566
|
-
// Return unvalidated data but log the error
|
|
567
|
-
console.warn('Returning unvalidated user info. Validation errors:', validationError.errors);
|
|
568
|
-
return userInfo;
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// Return without validation if schema not available
|
|
573
|
-
return userInfo;
|
|
574
|
-
} catch (error) {
|
|
575
|
-
console.error('Error getting user info from ID token:', error);
|
|
576
|
-
return null;
|
|
577
|
-
}
|
|
641
|
+
// Return without validation if schema not available
|
|
642
|
+
return userInfo;
|
|
643
|
+
} catch (error) {
|
|
644
|
+
console.error("Error getting user info from ID token:", error);
|
|
645
|
+
return null;
|
|
578
646
|
}
|
|
647
|
+
}
|
|
579
648
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
if (response.ok) {
|
|
588
|
-
const data = await response.json();
|
|
589
|
-
return {
|
|
590
|
-
...data,
|
|
591
|
-
source: this.isCrossDomain ? 'oauth2_userinfo_api_cross_domain' : 'oauth2_userinfo_api_same_domain'
|
|
592
|
-
};
|
|
593
|
-
}
|
|
594
|
-
return null;
|
|
595
|
-
} catch (error) {
|
|
596
|
-
console.error('Error getting user info from OAuth2 API:', error);
|
|
597
|
-
return null;
|
|
598
|
-
}
|
|
599
|
-
}
|
|
649
|
+
// Get fresh user info from OAuth2 userinfo endpoint (UNIFIED ENDPOINT)
|
|
650
|
+
async getUserInfoFromAPI() {
|
|
651
|
+
try {
|
|
652
|
+
const response = await this.makeAuthenticatedRequest(
|
|
653
|
+
`${this.config.authServerUrl}/userinfo`
|
|
654
|
+
);
|
|
600
655
|
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
return
|
|
656
|
+
if (response.ok) {
|
|
657
|
+
const data = await response.json();
|
|
658
|
+
return {
|
|
659
|
+
...data,
|
|
660
|
+
source: this.isCrossDomain
|
|
661
|
+
? "oauth2_userinfo_api_cross_domain"
|
|
662
|
+
: "oauth2_userinfo_api_same_domain",
|
|
663
|
+
};
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
} catch (error) {
|
|
667
|
+
console.error("Error getting user info from OAuth2 API:", error);
|
|
668
|
+
return null;
|
|
604
669
|
}
|
|
670
|
+
}
|
|
605
671
|
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
}
|
|
672
|
+
// Get raw ID token from storage
|
|
673
|
+
getRawIdToken() {
|
|
674
|
+
return sessionStorage.getItem("auth_id_token");
|
|
675
|
+
}
|
|
611
676
|
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
return decodeURIComponent(value);
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
return null;
|
|
677
|
+
// Get CSRF token from cookie (same-domain only)
|
|
678
|
+
getCsrfToken() {
|
|
679
|
+
if (this.isCrossDomain) {
|
|
680
|
+
return null; // Cross-domain clients don't use CSRF tokens
|
|
620
681
|
}
|
|
621
682
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
// Choose authentication method based on domain context
|
|
630
|
-
if (this.isCrossDomain) {
|
|
631
|
-
// Cross-domain: Use session ID in Authorization header
|
|
632
|
-
const sessionId = sessionStorage.getItem('auth_session_id');
|
|
633
|
-
if (sessionId) {
|
|
634
|
-
headers['Authorization'] = `Bearer ${sessionId}`;
|
|
635
|
-
console.log('Using session ID in Authorization header for cross-domain request to:', url);
|
|
636
|
-
}
|
|
637
|
-
} else {
|
|
638
|
-
// Same-domain: Add CSRF token for state-changing requests
|
|
639
|
-
if (options.method && ['POST', 'PUT', 'DELETE'].includes(options.method.toUpperCase())) {
|
|
640
|
-
const csrfToken = this.getCsrfToken();
|
|
641
|
-
if (csrfToken) {
|
|
642
|
-
headers['X-XSRF-TOKEN'] = csrfToken;
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
const requestOptions = {
|
|
648
|
-
...options,
|
|
649
|
-
headers: headers,
|
|
650
|
-
credentials: 'include' // Always include cookies for same-domain fallback
|
|
651
|
-
};
|
|
652
|
-
|
|
653
|
-
const response = await fetch(url, requestOptions);
|
|
654
|
-
|
|
655
|
-
if (response.status === 401) {
|
|
656
|
-
// Don't auto-logout during callback flow or logout operations to prevent infinite loops
|
|
657
|
-
const isCallbackFlow = options.skipAutoLogout || url.includes('/session/idtoken');
|
|
658
|
-
const isLogoutOperation = url.includes('/session/logout');
|
|
659
|
-
const isSessionCheck = url.includes('/session/check');
|
|
660
|
-
const isUserInfoOperation = url.includes('/userinfo');
|
|
661
|
-
|
|
662
|
-
if (!isCallbackFlow && !isLogoutOperation && !isSessionCheck && !isUserInfoOperation) {
|
|
663
|
-
console.log('Session expired, redirecting to login');
|
|
664
|
-
// Clear expired session data
|
|
665
|
-
this.clearLocalStorage();
|
|
666
|
-
// Redirect to login instead of logout
|
|
667
|
-
this.login();
|
|
668
|
-
throw new Error('Session expired');
|
|
669
|
-
} else {
|
|
670
|
-
console.warn('401 response during protected operation, not auto-logging out to prevent loops');
|
|
671
|
-
}
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
return response;
|
|
683
|
+
const cookies = document.cookie.split(";");
|
|
684
|
+
for (let cookie of cookies) {
|
|
685
|
+
const [name, value] = cookie.trim().split("=");
|
|
686
|
+
if (name === "XSRF-TOKEN") {
|
|
687
|
+
return decodeURIComponent(value);
|
|
688
|
+
}
|
|
675
689
|
}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Make authenticated API request using appropriate authentication method
|
|
694
|
+
async makeAuthenticatedRequest(url, options = {}) {
|
|
695
|
+
const headers = {
|
|
696
|
+
Accept: "application/json",
|
|
697
|
+
...options.headers,
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// Choose authentication method based on domain context
|
|
701
|
+
if (this.isCrossDomain) {
|
|
702
|
+
// Cross-domain: Use session ID in Authorization header
|
|
703
|
+
const sessionId = sessionStorage.getItem("auth_session_id");
|
|
704
|
+
if (sessionId) {
|
|
705
|
+
headers["Authorization"] = `Bearer ${sessionId}`;
|
|
706
|
+
console.log(
|
|
707
|
+
"Using session ID in Authorization header for cross-domain request to:",
|
|
708
|
+
url
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
} else {
|
|
712
|
+
// Same-domain: Add CSRF token for state-changing requests
|
|
713
|
+
if (
|
|
714
|
+
options.method &&
|
|
715
|
+
["POST", "PUT", "DELETE"].includes(options.method.toUpperCase())
|
|
716
|
+
) {
|
|
717
|
+
const csrfToken = this.getCsrfToken();
|
|
718
|
+
if (csrfToken) {
|
|
719
|
+
headers["X-XSRF-TOKEN"] = csrfToken;
|
|
699
720
|
}
|
|
721
|
+
}
|
|
722
|
+
}
|
|
700
723
|
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
724
|
+
const requestOptions = {
|
|
725
|
+
...options,
|
|
726
|
+
headers: headers,
|
|
727
|
+
credentials: "include", // Always include cookies for same-domain fallback
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
const response = await fetch(url, requestOptions);
|
|
731
|
+
|
|
732
|
+
if (response.status === 401) {
|
|
733
|
+
// QUESTION: would I ever use this method to call session check?
|
|
734
|
+
// I can only envisage scenarios where app endpoints get hit using this?
|
|
735
|
+
//
|
|
736
|
+
// Don't auto-logout during callback flow or logout operations to prevent infinite loops
|
|
737
|
+
const isCallbackFlow =
|
|
738
|
+
options.skipAutoLogout || url.includes("/session/idtoken");
|
|
739
|
+
const isLogoutOperation = url.includes("/session/logout");
|
|
740
|
+
const isSessionCheck = url.includes("/session/check");
|
|
741
|
+
const isUserInfoOperation = url.includes("/userinfo");
|
|
742
|
+
|
|
743
|
+
if (
|
|
744
|
+
!isCallbackFlow &&
|
|
745
|
+
!isLogoutOperation &&
|
|
746
|
+
!isSessionCheck &&
|
|
747
|
+
!isUserInfoOperation
|
|
748
|
+
) {
|
|
749
|
+
console.log("Session expired, redirecting to login");
|
|
750
|
+
// Clear expired session data
|
|
751
|
+
this.clearLocalStorage();
|
|
752
|
+
// Redirect to login instead of logout
|
|
753
|
+
this.login();
|
|
754
|
+
throw new Error("Session expired");
|
|
755
|
+
} else {
|
|
756
|
+
console.warn(
|
|
757
|
+
"401 response during protected operation, not auto-logging out to prevent loops"
|
|
758
|
+
);
|
|
759
|
+
}
|
|
706
760
|
}
|
|
707
761
|
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
762
|
+
return response;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Prepare request options with authentication
|
|
766
|
+
beforeRequest(options = {}) {
|
|
767
|
+
const headers = {
|
|
768
|
+
Accept: "application/json", // Exact match with original
|
|
769
|
+
...options.headers,
|
|
770
|
+
};
|
|
771
|
+
|
|
772
|
+
// Choose authentication method based on domain context
|
|
773
|
+
if (this.isCrossDomain) {
|
|
774
|
+
// Cross-domain: Use session ID in Authorization header
|
|
775
|
+
const sessionId = sessionStorage.getItem("auth_session_id");
|
|
776
|
+
if (sessionId) {
|
|
777
|
+
headers["Authorization"] = `Bearer ${sessionId}`;
|
|
778
|
+
}
|
|
779
|
+
} else {
|
|
780
|
+
// Same-domain: Add CSRF token for state-changing requests
|
|
781
|
+
if (
|
|
782
|
+
options.method &&
|
|
783
|
+
["POST", "PUT", "DELETE"].includes(options.method.toUpperCase())
|
|
784
|
+
) {
|
|
785
|
+
const csrfToken = this.getCsrfToken();
|
|
786
|
+
if (csrfToken) {
|
|
787
|
+
headers["X-XSRF-TOKEN"] = csrfToken;
|
|
727
788
|
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
728
791
|
|
|
729
|
-
|
|
792
|
+
return {
|
|
793
|
+
...options,
|
|
794
|
+
headers,
|
|
795
|
+
credentials: "include", // Always include cookies for same-domain fallback
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// Handle response and authentication errors
|
|
800
|
+
afterRequest(response, url, options = {}) {
|
|
801
|
+
if (response.status === 401) {
|
|
802
|
+
// Don't auto-logout during callback flow or logout operations to prevent infinite loops
|
|
803
|
+
const isCallbackFlow =
|
|
804
|
+
options.skipAutoLogout || url.includes("/session/idtoken");
|
|
805
|
+
const isLogoutOperation = url.includes("/session/logout");
|
|
806
|
+
const isSessionCheck = url.includes("/session/check");
|
|
807
|
+
const isUserInfoOperation = url.includes("/userinfo");
|
|
808
|
+
|
|
809
|
+
if (
|
|
810
|
+
!isCallbackFlow &&
|
|
811
|
+
!isLogoutOperation &&
|
|
812
|
+
!isSessionCheck &&
|
|
813
|
+
!isUserInfoOperation
|
|
814
|
+
) {
|
|
815
|
+
console.log("Session expired, redirecting to login");
|
|
816
|
+
// Clear expired session data
|
|
817
|
+
this.clearLocalStorage();
|
|
818
|
+
// Redirect to login instead of logout
|
|
819
|
+
this.login();
|
|
820
|
+
throw new Error("Session expired");
|
|
821
|
+
} else {
|
|
822
|
+
console.warn(
|
|
823
|
+
"401 response during protected operation, not auto-logging out to prevent loops"
|
|
824
|
+
);
|
|
825
|
+
}
|
|
730
826
|
}
|
|
731
827
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
828
|
+
return response;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// Finish popup flow - sends callback URL to parent for processing
|
|
832
|
+
finishPopupFlow() {
|
|
833
|
+
// Send callback URL to parent with clientId for disambiguation
|
|
834
|
+
if (window.opener) {
|
|
835
|
+
window.opener.postMessage(
|
|
836
|
+
{
|
|
837
|
+
type: "oauth-callback",
|
|
838
|
+
clientId: this.config.clientId,
|
|
839
|
+
callbackUrl: window.location.href,
|
|
840
|
+
},
|
|
841
|
+
"*"
|
|
842
|
+
);
|
|
742
843
|
}
|
|
844
|
+
}
|
|
743
845
|
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
// Check if auth server returned external logout redirect
|
|
765
|
-
if (response.ok) {
|
|
766
|
-
try {
|
|
767
|
-
const logoutData = await response.json();
|
|
768
|
-
|
|
769
|
-
if (logoutData.external_logout && logoutData.logout_url) {
|
|
770
|
-
console.log('๐ External IDP logout required, redirecting to:', logoutData.logout_url);
|
|
771
|
-
|
|
772
|
-
// Clear local storage before external redirect
|
|
773
|
-
this.clearLocalStorage();
|
|
774
|
-
|
|
775
|
-
// Redirect to external IDP logout (e.g., Keycloak)
|
|
776
|
-
window.location.href = logoutData.logout_url;
|
|
777
|
-
return; // Don't proceed with local redirect
|
|
778
|
-
}
|
|
779
|
-
} catch (jsonError) {
|
|
780
|
-
console.warn('Logout response is not JSON, continuing with standard logout');
|
|
781
|
-
}
|
|
782
|
-
}
|
|
783
|
-
} catch (error) {
|
|
784
|
-
console.error('Logout error (ignoring):', error);
|
|
785
|
-
// Ignore logout errors - we're logging out anyway
|
|
846
|
+
// Logout - destroys session via auth-server with external IDP Single Logout support
|
|
847
|
+
async logout() {
|
|
848
|
+
try {
|
|
849
|
+
const headers = {
|
|
850
|
+
Accept: "application/json",
|
|
851
|
+
};
|
|
852
|
+
|
|
853
|
+
// For cross-domain clients, include session ID in Authorization header
|
|
854
|
+
const sessionId = sessionStorage.getItem("auth_session_id");
|
|
855
|
+
if (this.isCrossDomain && sessionId) {
|
|
856
|
+
headers["Authorization"] = `Bearer ${sessionId}`;
|
|
857
|
+
console.log("๐ช Using session ID in Authorization header for logout");
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const response = await fetch(
|
|
861
|
+
`${this.config.authServerUrl}/session/logout`,
|
|
862
|
+
{
|
|
863
|
+
method: "POST",
|
|
864
|
+
headers: headers,
|
|
865
|
+
credentials: "include", // Include cookies for same-domain fallback
|
|
786
866
|
}
|
|
867
|
+
);
|
|
787
868
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
this.config.onLogout();
|
|
793
|
-
}
|
|
869
|
+
// Check if auth server returned external logout redirect
|
|
870
|
+
if (response.ok) {
|
|
871
|
+
try {
|
|
872
|
+
const logoutData = await response.json();
|
|
794
873
|
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
console.log('Popup message received:', event.data);
|
|
801
|
-
|
|
802
|
-
// Only respond to messages from our client
|
|
803
|
-
if (event.data.clientId !== this.config.clientId) {
|
|
804
|
-
console.log('Ignoring message from different client:', event.data.clientId);
|
|
805
|
-
return;
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
if (event.data.type === 'oauth-success') {
|
|
809
|
-
console.log('OAuth success received from popup');
|
|
810
|
-
popup.close();
|
|
811
|
-
window.removeEventListener('message', handleMessage);
|
|
812
|
-
// Notify parent that auth completed
|
|
813
|
-
window.dispatchEvent(new CustomEvent(AuthServerOAuth2Client.OAUTH_SUCCESS, { detail: event.data }));
|
|
814
|
-
} else if (event.data.type === 'oauth-error') {
|
|
815
|
-
console.log('OAuth error received from popup');
|
|
816
|
-
popup.close();
|
|
817
|
-
window.removeEventListener('message', handleMessage);
|
|
818
|
-
// Notify parent that auth failed
|
|
819
|
-
window.dispatchEvent(new CustomEvent(AuthServerOAuth2Client.OAUTH_ERROR, { detail: event.data }));
|
|
820
|
-
} else if (event.data.type === 'oauth-callback') {
|
|
821
|
-
console.log('OAuth callback received from popup');
|
|
822
|
-
popup.close();
|
|
823
|
-
window.removeEventListener('message', handleMessage);
|
|
824
|
-
// Notify parent that callback is ready
|
|
825
|
-
window.dispatchEvent(new CustomEvent('oauth-callback', { detail: event.data }));
|
|
826
|
-
}
|
|
827
|
-
};
|
|
874
|
+
if (logoutData.external_logout && logoutData.logout_url) {
|
|
875
|
+
console.log(
|
|
876
|
+
"๐ External IDP logout required, redirecting to:",
|
|
877
|
+
logoutData.logout_url
|
|
878
|
+
);
|
|
828
879
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
880
|
+
// Clear local storage before external redirect
|
|
881
|
+
this.clearLocalStorage();
|
|
882
|
+
|
|
883
|
+
// Redirect to external IDP logout (e.g., Keycloak)
|
|
884
|
+
window.location.href = logoutData.logout_url;
|
|
885
|
+
return; // Don't proceed with local redirect
|
|
886
|
+
}
|
|
887
|
+
} catch (jsonError) {
|
|
888
|
+
console.warn(
|
|
889
|
+
"Logout response is not JSON, continuing with standard logout"
|
|
890
|
+
);
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
} catch (error) {
|
|
894
|
+
console.error("Logout error (ignoring):", error);
|
|
895
|
+
// Ignore logout errors - we're logging out anyway
|
|
840
896
|
}
|
|
841
897
|
|
|
842
|
-
//
|
|
843
|
-
clearLocalStorage()
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
898
|
+
// Standard logout flow - clear storage and redirect locally
|
|
899
|
+
this.clearLocalStorage();
|
|
900
|
+
|
|
901
|
+
this.config.onLogout();
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// Start popup flow - track popup window and listen for OAuth callback messages
|
|
905
|
+
startPopupFlow(popup) {
|
|
906
|
+
console.log("Starting popup tracking...");
|
|
907
|
+
|
|
908
|
+
const handleMessage = (event) => {
|
|
909
|
+
console.log("Popup message received:", event.data);
|
|
910
|
+
|
|
911
|
+
// Only respond to messages from our client
|
|
912
|
+
if (event.data.clientId !== this.config.clientId) {
|
|
913
|
+
console.log(
|
|
914
|
+
"Ignoring message from different client:",
|
|
915
|
+
event.data.clientId
|
|
916
|
+
);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (event.data.type === "oauth-success") {
|
|
921
|
+
console.log("OAuth success received from popup");
|
|
922
|
+
popup.close();
|
|
923
|
+
window.removeEventListener("message", handleMessage);
|
|
924
|
+
// Notify parent that auth completed
|
|
925
|
+
window.dispatchEvent(
|
|
926
|
+
new CustomEvent(AuthServerOAuth2Client.OAUTH_SUCCESS, {
|
|
927
|
+
detail: event.data,
|
|
928
|
+
})
|
|
929
|
+
);
|
|
930
|
+
} else if (event.data.type === "oauth-error") {
|
|
931
|
+
console.log("OAuth error received from popup");
|
|
932
|
+
popup.close();
|
|
933
|
+
window.removeEventListener("message", handleMessage);
|
|
934
|
+
// Notify parent that auth failed
|
|
935
|
+
window.dispatchEvent(
|
|
936
|
+
new CustomEvent(AuthServerOAuth2Client.OAUTH_ERROR, {
|
|
937
|
+
detail: event.data,
|
|
938
|
+
})
|
|
939
|
+
);
|
|
940
|
+
} else if (event.data.type === "oauth-callback") {
|
|
941
|
+
console.log("OAuth callback received from popup");
|
|
942
|
+
popup.close();
|
|
943
|
+
window.removeEventListener("message", handleMessage);
|
|
944
|
+
// Notify parent that callback is ready
|
|
945
|
+
window.dispatchEvent(
|
|
946
|
+
new CustomEvent("oauth-callback", { detail: event.data })
|
|
947
|
+
);
|
|
948
|
+
}
|
|
949
|
+
};
|
|
950
|
+
|
|
951
|
+
window.addEventListener("message", handleMessage);
|
|
952
|
+
console.log("Added message listener for popup");
|
|
953
|
+
|
|
954
|
+
// Also check if popup is closed manually
|
|
955
|
+
const checkClosed = setInterval(() => {
|
|
956
|
+
if (popup.closed) {
|
|
957
|
+
clearInterval(checkClosed);
|
|
958
|
+
window.removeEventListener("message", handleMessage);
|
|
959
|
+
console.log("Popup closed manually - cleaning up listeners");
|
|
960
|
+
}
|
|
961
|
+
}, 1000);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Helper method to clear all auth-related storage
|
|
965
|
+
clearLocalStorage() {
|
|
966
|
+
sessionStorage.removeItem("auth_id_token");
|
|
967
|
+
sessionStorage.removeItem("auth_session_id"); // Clear cross-domain session ID
|
|
968
|
+
sessionStorage.removeItem("oauth_state"); // cleanup in case of incomplete flows
|
|
969
|
+
sessionStorage.removeItem("oauth_code_verifier"); // cleanup in case of incomplete flows
|
|
970
|
+
sessionStorage.removeItem("oauth_nonce"); // cleanup in case of incomplete flows
|
|
971
|
+
console.log("๐งน Cleared all auth-related storage");
|
|
972
|
+
}
|
|
851
973
|
}
|
|
852
974
|
|
|
853
975
|
// ES module exports (for modern bundlers) - only when not in browser global context
|
|
854
|
-
if (typeof module !==
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
} else if (typeof exports !==
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
976
|
+
if (typeof module !== "undefined" && module.exports) {
|
|
977
|
+
// CommonJS (Node.js)
|
|
978
|
+
module.exports = AuthServerOAuth2Client;
|
|
979
|
+
module.exports.default = AuthServerOAuth2Client;
|
|
980
|
+
module.exports.AuthServerOAuth2Client = AuthServerOAuth2Client;
|
|
981
|
+
} else if (typeof exports !== "undefined") {
|
|
982
|
+
// ES modules
|
|
983
|
+
exports.default = AuthServerOAuth2Client;
|
|
984
|
+
exports.AuthServerOAuth2Client = AuthServerOAuth2Client;
|
|
863
985
|
}
|
|
864
986
|
|
|
865
987
|
// Create global OAuth client instance for browser use
|
|
866
|
-
if (typeof window !==
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
}
|
|
988
|
+
if (typeof window !== "undefined") {
|
|
989
|
+
window.AuthServerOAuth2Client = AuthServerOAuth2Client;
|
|
990
|
+
window.authServerOAuth2Client = new AuthServerOAuth2Client();
|
|
991
|
+
}
|