@enterprisestandard/react 0.0.5-beta.20260115.1 → 0.0.5-beta.20260115.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/group-store.js +127 -0
- package/dist/iam.js +680 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +144 -3672
- package/dist/session-store.js +105 -0
- package/dist/sso-server.d.ts +1 -1
- package/dist/sso-server.d.ts.map +1 -1
- package/dist/sso-server.js +46 -0
- package/dist/sso.js +820 -0
- package/dist/tenant-server.js +6 -0
- package/dist/tenant.js +324 -0
- package/dist/types/base-user.js +1 -0
- package/dist/types/enterprise-user.js +1 -0
- package/dist/types/oidc-schema.js +328 -0
- package/dist/types/scim-schema.js +519 -0
- package/dist/types/standard-schema.js +1 -0
- package/dist/types/user.js +1 -0
- package/dist/types/workload-schema.js +208 -0
- package/dist/ui/sign-in-loading.js +8 -0
- package/dist/ui/signed-in.js +8 -0
- package/dist/ui/signed-out.js +8 -0
- package/dist/ui/sso-provider.js +275 -0
- package/dist/user-store.js +114 -0
- package/dist/utils.js +23 -0
- package/dist/vault.js +22 -0
- package/dist/workload-server.d.ts +1 -1
- package/dist/workload-server.d.ts.map +1 -1
- package/dist/workload-server.js +167 -0
- package/dist/workload-token-store.js +95 -0
- package/dist/workload.js +691 -0
- package/package.json +1 -1
- package/dist/index.js.map +0 -29
package/dist/sso.js
ADDED
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
import { idTokenClaimsSchema, oidcCallbackSchema, tokenResponseSchema } from './types/oidc-schema';
|
|
2
|
+
import { must } from './utils';
|
|
3
|
+
const jwksCache = new Map();
|
|
4
|
+
export function sso(config) {
|
|
5
|
+
let configWithDefaults;
|
|
6
|
+
const handlerDefaults = {
|
|
7
|
+
loginUrl: config?.loginUrl,
|
|
8
|
+
userUrl: config?.userUrl,
|
|
9
|
+
errorUrl: config?.errorUrl,
|
|
10
|
+
landingUrl: config?.landingUrl,
|
|
11
|
+
tokenUrl: config?.tokenUrl,
|
|
12
|
+
refreshUrl: config?.refreshUrl,
|
|
13
|
+
jwksUrl: config?.jwksUrl,
|
|
14
|
+
logoutUrl: config?.logoutUrl,
|
|
15
|
+
logoutBackChannelUrl: config?.logoutBackChannelUrl,
|
|
16
|
+
validation: config?.validation,
|
|
17
|
+
};
|
|
18
|
+
configWithDefaults = !config
|
|
19
|
+
? undefined
|
|
20
|
+
: {
|
|
21
|
+
...config,
|
|
22
|
+
authority: must(config.authority, "Missing 'authority' from SSO Config"),
|
|
23
|
+
token_url: must(config.token_url, "Missing 'token_url' from SSO Config"),
|
|
24
|
+
authorization_url: must(config.authorization_url, "Missing 'authorization_url' from SSO Config"),
|
|
25
|
+
client_id: must(config.client_id, "Missing 'client_id' from SSO Config"),
|
|
26
|
+
redirect_uri: must(config.redirect_uri, "Missing 'redirect_uri' from SSO Config"),
|
|
27
|
+
scope: must(config.scope, "Missing 'scope' from SSO Config"),
|
|
28
|
+
response_type: config.response_type ?? 'code',
|
|
29
|
+
cookies_secure: config.cookies_secure !== undefined ? config.cookies_secure : true,
|
|
30
|
+
cookies_same_site: config.cookies_same_site !== undefined ? config.cookies_same_site : 'Strict',
|
|
31
|
+
cookies_prefix: config.cookies_prefix ?? `es.sso.${config.client_id}`,
|
|
32
|
+
cookies_path: config.cookies_path ?? '/',
|
|
33
|
+
};
|
|
34
|
+
async function getUser(request) {
|
|
35
|
+
if (!configWithDefaults) {
|
|
36
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
const { tokens } = await getTokenFromCookies(request);
|
|
40
|
+
if (!tokens)
|
|
41
|
+
return undefined;
|
|
42
|
+
return await parseUser(tokens);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
console.error('Error parsing user from cookies:', error);
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function getRequiredUser(request) {
|
|
50
|
+
const user = await getUser(request);
|
|
51
|
+
if (user)
|
|
52
|
+
return user;
|
|
53
|
+
throw new Response('Unauthorized', {
|
|
54
|
+
status: 401,
|
|
55
|
+
statusText: 'Unauthorized',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
async function initiateLogin({ landingUrl, errorUrl }, requestUrl) {
|
|
59
|
+
if (!configWithDefaults) {
|
|
60
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
61
|
+
}
|
|
62
|
+
const state = generateRandomString();
|
|
63
|
+
const codeVerifier = generateRandomString(64);
|
|
64
|
+
// Normalize redirect_uri - use request URL if available, otherwise use the stored value
|
|
65
|
+
let normalizedRedirectUri = configWithDefaults.redirect_uri;
|
|
66
|
+
try {
|
|
67
|
+
new URL(normalizedRedirectUri);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
// If redirect_uri is not a valid URL, try to construct it from request URL
|
|
71
|
+
if (requestUrl) {
|
|
72
|
+
try {
|
|
73
|
+
const baseUrl = new URL(requestUrl);
|
|
74
|
+
const path = normalizedRedirectUri.startsWith('//')
|
|
75
|
+
? normalizedRedirectUri.slice(1)
|
|
76
|
+
: normalizedRedirectUri.startsWith('/')
|
|
77
|
+
? normalizedRedirectUri
|
|
78
|
+
: `/${normalizedRedirectUri}`;
|
|
79
|
+
normalizedRedirectUri = new URL(path, baseUrl.origin).toString();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// If we can't construct it, use authorization_url's origin as fallback
|
|
83
|
+
try {
|
|
84
|
+
const authUrl = new URL(configWithDefaults.authorization_url);
|
|
85
|
+
const path = normalizedRedirectUri.startsWith('//')
|
|
86
|
+
? normalizedRedirectUri.slice(1)
|
|
87
|
+
: normalizedRedirectUri.startsWith('/')
|
|
88
|
+
? normalizedRedirectUri
|
|
89
|
+
: `/${normalizedRedirectUri}`;
|
|
90
|
+
normalizedRedirectUri = new URL(path, authUrl.origin).toString();
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
throw new Error(`Invalid redirect_uri: "${configWithDefaults.redirect_uri}". It must be a valid absolute URL.`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const url = new URL(configWithDefaults.authorization_url);
|
|
99
|
+
url.searchParams.append('client_id', configWithDefaults.client_id);
|
|
100
|
+
url.searchParams.append('redirect_uri', normalizedRedirectUri);
|
|
101
|
+
url.searchParams.append('response_type', 'code');
|
|
102
|
+
url.searchParams.append('scope', configWithDefaults.scope);
|
|
103
|
+
url.searchParams.append('state', state);
|
|
104
|
+
const codeChallenge = await pkceChallengeFromVerifier(codeVerifier);
|
|
105
|
+
url.searchParams.append('code_challenge', codeChallenge);
|
|
106
|
+
url.searchParams.append('code_challenge_method', 'S256');
|
|
107
|
+
const val = {
|
|
108
|
+
state,
|
|
109
|
+
codeVerifier,
|
|
110
|
+
landingUrl,
|
|
111
|
+
errorUrl,
|
|
112
|
+
};
|
|
113
|
+
return new Response('Redirecting to SSO Provider', {
|
|
114
|
+
status: 302,
|
|
115
|
+
headers: {
|
|
116
|
+
Location: url.toString(),
|
|
117
|
+
'Set-Cookie': createCookie('state', val, 86400),
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
async function logout(request, _config) {
|
|
122
|
+
if (!configWithDefaults) {
|
|
123
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
124
|
+
}
|
|
125
|
+
// Try to revoke the refresh token on the server
|
|
126
|
+
try {
|
|
127
|
+
const refreshToken = getCookie('refresh', request);
|
|
128
|
+
if (refreshToken) {
|
|
129
|
+
await revokeToken(refreshToken);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
console.warn('Failed to revoke token:', error);
|
|
134
|
+
}
|
|
135
|
+
// Delete session from session store if configured
|
|
136
|
+
if (configWithDefaults.session_store) {
|
|
137
|
+
try {
|
|
138
|
+
const user = await getUser(request);
|
|
139
|
+
if (user?.sso?.profile.sid) {
|
|
140
|
+
const sid = user.sso.profile.sid;
|
|
141
|
+
await configWithDefaults.session_store.delete(sid);
|
|
142
|
+
console.log(`Session ${sid} deleted from store`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
console.warn('Failed to delete session:', error);
|
|
147
|
+
// Don't fail logout if session deletion fails
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Clear cookies
|
|
151
|
+
const clearHeaders = [
|
|
152
|
+
['Set-Cookie', clearCookie('access')],
|
|
153
|
+
['Set-Cookie', clearCookie('id')],
|
|
154
|
+
['Set-Cookie', clearCookie('refresh')],
|
|
155
|
+
['Set-Cookie', clearCookie('control')],
|
|
156
|
+
['Set-Cookie', clearCookie('state')],
|
|
157
|
+
];
|
|
158
|
+
// Check for redirect query parameter
|
|
159
|
+
const url = new URL(request.url);
|
|
160
|
+
const redirectTo = url.searchParams.get('redirect');
|
|
161
|
+
if (redirectTo) {
|
|
162
|
+
return new Response('Logged out', {
|
|
163
|
+
status: 302,
|
|
164
|
+
headers: [['Location', redirectTo], ...clearHeaders],
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
// Check if this is an AJAX request (expects JSON response)
|
|
168
|
+
const accept = request.headers.get('accept');
|
|
169
|
+
const isAjax = accept?.includes('application/json') || accept?.includes('text/javascript');
|
|
170
|
+
if (isAjax) {
|
|
171
|
+
return new Response(JSON.stringify({ success: true, message: 'Logged out' }), {
|
|
172
|
+
status: 200,
|
|
173
|
+
headers: [['Content-Type', 'application/json'], ...clearHeaders],
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
return new Response(`
|
|
178
|
+
<!DOCTYPE html><html lang="en"><body>
|
|
179
|
+
<h1>Logout Complete</h1>
|
|
180
|
+
<div style="display: none">
|
|
181
|
+
It is not recommended to show the default logout page. Include '?redirect=/someHomePage' or logout asynchronously.
|
|
182
|
+
Check the <a href="https://EnterpriseStandard.com/sso#logout">Enterprise Standard Packages</a> for more information.
|
|
183
|
+
</div>
|
|
184
|
+
</body></html>
|
|
185
|
+
`, {
|
|
186
|
+
status: 200,
|
|
187
|
+
headers: [['Content-Type', 'text/html'], ...clearHeaders],
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
async function logoutBackChannel(request) {
|
|
192
|
+
if (!configWithDefaults) {
|
|
193
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
194
|
+
}
|
|
195
|
+
if (!configWithDefaults.session_store) {
|
|
196
|
+
throw new Error('Back-Channel Logout requires session_store configuration');
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
// Parse the logout token from the request body
|
|
200
|
+
const contentType = request.headers.get('content-type');
|
|
201
|
+
if (!contentType || !contentType.includes('application/x-www-form-urlencoded')) {
|
|
202
|
+
return new Response('Invalid Content-Type, expected application/x-www-form-urlencoded', {
|
|
203
|
+
status: 400,
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
const body = await request.text();
|
|
207
|
+
const params = new URLSearchParams(body);
|
|
208
|
+
const logoutToken = params.get('logout_token');
|
|
209
|
+
if (!logoutToken) {
|
|
210
|
+
return new Response('Missing logout_token parameter', { status: 400 });
|
|
211
|
+
}
|
|
212
|
+
// Parse and verify the logout token JWT
|
|
213
|
+
const claims = await parseJwt(logoutToken);
|
|
214
|
+
// Extract sid (session ID) from the logout token
|
|
215
|
+
const sid = claims.sid;
|
|
216
|
+
if (!sid) {
|
|
217
|
+
console.warn('Back-Channel Logout: logout_token missing sid claim');
|
|
218
|
+
return new Response('Invalid logout_token: missing sid claim', { status: 400 });
|
|
219
|
+
}
|
|
220
|
+
// Delete the session from the store
|
|
221
|
+
await configWithDefaults.session_store.delete(sid);
|
|
222
|
+
console.log(`Back-Channel Logout: successfully deleted session ${sid}`);
|
|
223
|
+
return new Response('OK', { status: 200 });
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
console.error('Error during back-channel logout:', error);
|
|
227
|
+
return new Response('Internal Server Error', { status: 500 });
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function callbackHandler(request, validation) {
|
|
231
|
+
if (!configWithDefaults) {
|
|
232
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
233
|
+
}
|
|
234
|
+
const url = new URL(request.url);
|
|
235
|
+
const params = new URLSearchParams(url.search);
|
|
236
|
+
// Validate callback parameters using StandardSchema
|
|
237
|
+
const callbackParamsValidator = validation?.callbackParams ?? oidcCallbackSchema('builtin');
|
|
238
|
+
const paramsObject = Object.fromEntries(params.entries());
|
|
239
|
+
const paramsResult = await callbackParamsValidator['~standard'].validate(paramsObject);
|
|
240
|
+
if ('issues' in paramsResult) {
|
|
241
|
+
return new Response(JSON.stringify({
|
|
242
|
+
error: 'validation_failed',
|
|
243
|
+
message: 'OIDC callback parameters validation failed',
|
|
244
|
+
issues: paramsResult.issues?.map((i) => ({
|
|
245
|
+
path: i.path?.join('.'),
|
|
246
|
+
message: i.message,
|
|
247
|
+
})),
|
|
248
|
+
}), {
|
|
249
|
+
status: 400,
|
|
250
|
+
headers: { 'Content-Type': 'application/json' },
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
const { code: codeFromUrl, state: stateFromUrl } = paramsResult.value;
|
|
254
|
+
try {
|
|
255
|
+
const cookie = getCookie('state', request, true);
|
|
256
|
+
const { codeVerifier, state, landingUrl } = cookie ?? {};
|
|
257
|
+
must(codeVerifier, 'OIDC "codeVerifier" was not present in cookies, ensure that the SSO login was initiated correctly');
|
|
258
|
+
must(state, 'OIDC "stateVerifier" was not present in cookies, ensure that the SSO login was initiated correctly');
|
|
259
|
+
must(landingUrl, 'OIDC "landingUrl" was not present in cookies');
|
|
260
|
+
if (stateFromUrl !== state) {
|
|
261
|
+
throw new Error('SSO State Verifier failed, the "state" request parameter does not equal the "state" in the SSO cookie');
|
|
262
|
+
}
|
|
263
|
+
const tokenResponse = await exchangeCodeForToken(codeFromUrl, codeVerifier, validation, request.url);
|
|
264
|
+
const user = await parseUser(tokenResponse, validation);
|
|
265
|
+
// Create session if session_store is configured
|
|
266
|
+
if (configWithDefaults.session_store) {
|
|
267
|
+
try {
|
|
268
|
+
const sid = user.sso.profile.sid;
|
|
269
|
+
const sub = user.id;
|
|
270
|
+
if (sid && sub) {
|
|
271
|
+
const session = {
|
|
272
|
+
sid,
|
|
273
|
+
sub,
|
|
274
|
+
createdAt: new Date(),
|
|
275
|
+
lastActivityAt: new Date(),
|
|
276
|
+
};
|
|
277
|
+
await configWithDefaults.session_store.create(session);
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
console.warn('Session creation skipped: missing sid or sub in ID token claims');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
console.warn('Failed to create session:', error);
|
|
285
|
+
// Don't fail the login if session creation fails
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// Store/update user if user_store is configured
|
|
289
|
+
if (configWithDefaults.user_store) {
|
|
290
|
+
try {
|
|
291
|
+
const sub = user.id;
|
|
292
|
+
if (sub) {
|
|
293
|
+
const now = new Date();
|
|
294
|
+
const existingUser = await configWithDefaults.user_store.get(sub);
|
|
295
|
+
// Only create new users if JIT provisioning is enabled
|
|
296
|
+
// Always update existing users
|
|
297
|
+
if (existingUser || configWithDefaults.enable_jit_user_provisioning) {
|
|
298
|
+
// Merge existing custom data with updated user info
|
|
299
|
+
// This preserves TUserData fields that were previously set
|
|
300
|
+
const storedUser = {
|
|
301
|
+
...(existingUser ?? {}),
|
|
302
|
+
...user,
|
|
303
|
+
id: sub,
|
|
304
|
+
createdAt: existingUser?.createdAt ?? now,
|
|
305
|
+
updatedAt: now,
|
|
306
|
+
};
|
|
307
|
+
await configWithDefaults.user_store.upsert(storedUser);
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
console.warn('JIT user provisioning disabled: user not found in store and will not be created');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
else {
|
|
314
|
+
console.warn('User storage skipped: missing sub in ID token claims');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
console.warn('Failed to store user:', error);
|
|
319
|
+
// Don't fail the login if user storage fails
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return new Response('Authentication successful, redirecting', {
|
|
323
|
+
status: 302,
|
|
324
|
+
headers: [
|
|
325
|
+
['Location', landingUrl],
|
|
326
|
+
['Set-Cookie', clearCookie('state')],
|
|
327
|
+
...createJwtCookies(tokenResponse, user.sso.expires),
|
|
328
|
+
],
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
catch (error) {
|
|
332
|
+
console.error('Error during sign-in callback:', error);
|
|
333
|
+
try {
|
|
334
|
+
const cookie = getCookie('state', request, true);
|
|
335
|
+
const { errorUrl } = cookie ?? {};
|
|
336
|
+
if (errorUrl) {
|
|
337
|
+
return new Response('Redirecting to error url', {
|
|
338
|
+
status: 302,
|
|
339
|
+
headers: [['Location', errorUrl]],
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
catch (_err) {
|
|
344
|
+
console.warn('Error parsing the errorUrl from the OIDC cookie');
|
|
345
|
+
}
|
|
346
|
+
console.warn('No error page was found in the cookies. The user will be shown a default error page.');
|
|
347
|
+
return new Response('An error occurred during authentication, please return to the application homepage and try again.', {
|
|
348
|
+
status: 500,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
async function parseUser(token, validation) {
|
|
353
|
+
if (!configWithDefaults) {
|
|
354
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
355
|
+
}
|
|
356
|
+
const idToken = await parseJwt(token.id_token, validation);
|
|
357
|
+
const expiresIn = Number(token.refresh_expires_in ?? token.expires_in ?? 3600);
|
|
358
|
+
const expires = token.expires ? new Date(token.expires) : new Date(Date.now() + expiresIn * 1000);
|
|
359
|
+
return {
|
|
360
|
+
id: idToken.sub,
|
|
361
|
+
userName: idToken.preferred_username || '',
|
|
362
|
+
name: idToken.name || '',
|
|
363
|
+
email: idToken.email || '',
|
|
364
|
+
emails: [
|
|
365
|
+
{
|
|
366
|
+
value: idToken.email || '',
|
|
367
|
+
primary: true,
|
|
368
|
+
},
|
|
369
|
+
],
|
|
370
|
+
avatarUrl: idToken.picture,
|
|
371
|
+
sso: {
|
|
372
|
+
profile: {
|
|
373
|
+
...idToken,
|
|
374
|
+
iss: idToken.iss || configWithDefaults.authority,
|
|
375
|
+
aud: idToken.aud || configWithDefaults.client_id,
|
|
376
|
+
},
|
|
377
|
+
tenant: {
|
|
378
|
+
id: idToken.idp || idToken.iss || configWithDefaults.authority,
|
|
379
|
+
name: idToken.iss || configWithDefaults.authority,
|
|
380
|
+
},
|
|
381
|
+
scope: token.scope,
|
|
382
|
+
tokenType: token.token_type,
|
|
383
|
+
sessionState: token.session_state,
|
|
384
|
+
expires,
|
|
385
|
+
},
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
async function exchangeCodeForToken(code, codeVerifier, validation, requestUrl) {
|
|
389
|
+
if (!configWithDefaults) {
|
|
390
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
391
|
+
}
|
|
392
|
+
const tokenUrl = configWithDefaults.token_url;
|
|
393
|
+
// Normalize redirect_uri - use the same logic as in initiateLogin
|
|
394
|
+
let normalizedRedirectUri = configWithDefaults.redirect_uri;
|
|
395
|
+
try {
|
|
396
|
+
new URL(normalizedRedirectUri);
|
|
397
|
+
}
|
|
398
|
+
catch {
|
|
399
|
+
// If redirect_uri is not a valid URL, try to construct it from request URL
|
|
400
|
+
if (requestUrl) {
|
|
401
|
+
try {
|
|
402
|
+
const baseUrl = new URL(requestUrl);
|
|
403
|
+
const path = normalizedRedirectUri.startsWith('//')
|
|
404
|
+
? normalizedRedirectUri.slice(1)
|
|
405
|
+
: normalizedRedirectUri.startsWith('/')
|
|
406
|
+
? normalizedRedirectUri
|
|
407
|
+
: `/${normalizedRedirectUri}`;
|
|
408
|
+
normalizedRedirectUri = new URL(path, baseUrl.origin).toString();
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
// If we can't construct it, use token_url's origin as fallback
|
|
412
|
+
try {
|
|
413
|
+
const tokenUrlObj = new URL(tokenUrl);
|
|
414
|
+
const path = normalizedRedirectUri.startsWith('//')
|
|
415
|
+
? normalizedRedirectUri.slice(1)
|
|
416
|
+
: normalizedRedirectUri.startsWith('/')
|
|
417
|
+
? normalizedRedirectUri
|
|
418
|
+
: `/${normalizedRedirectUri}`;
|
|
419
|
+
normalizedRedirectUri = new URL(path, tokenUrlObj.origin).toString();
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
throw new Error(`Invalid redirect_uri: "${configWithDefaults.redirect_uri}". It must be a valid absolute URL.`);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const body = new URLSearchParams();
|
|
428
|
+
body.append('grant_type', 'authorization_code');
|
|
429
|
+
body.append('code', code);
|
|
430
|
+
body.append('redirect_uri', normalizedRedirectUri);
|
|
431
|
+
body.append('client_id', configWithDefaults.client_id);
|
|
432
|
+
if (configWithDefaults.client_secret) {
|
|
433
|
+
body.append('client_secret', configWithDefaults.client_secret);
|
|
434
|
+
}
|
|
435
|
+
body.append('code_verifier', codeVerifier);
|
|
436
|
+
try {
|
|
437
|
+
const response = await fetch(tokenUrl, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
headers: {
|
|
440
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
441
|
+
Accept: 'application/json',
|
|
442
|
+
},
|
|
443
|
+
body: body.toString(),
|
|
444
|
+
});
|
|
445
|
+
const data = await response.json();
|
|
446
|
+
if (!response.ok) {
|
|
447
|
+
console.error('Token exchange error:', data);
|
|
448
|
+
throw new Error(`Token exchange failed: ${data.error || response.statusText} - ${data.error_description || ''}`.trim());
|
|
449
|
+
}
|
|
450
|
+
// Validate token response using StandardSchema
|
|
451
|
+
const tokenResponseValidator = validation?.tokenResponse ?? tokenResponseSchema('builtin');
|
|
452
|
+
const tokenResult = await tokenResponseValidator['~standard'].validate(data);
|
|
453
|
+
if ('issues' in tokenResult) {
|
|
454
|
+
console.error('Token response validation failed:', tokenResult.issues);
|
|
455
|
+
throw new Error(`Token response validation failed: ${tokenResult.issues?.map((i) => i.message).join('; ')}`);
|
|
456
|
+
}
|
|
457
|
+
return tokenResult.value;
|
|
458
|
+
}
|
|
459
|
+
catch (error) {
|
|
460
|
+
console.error('Error during token exchange:', error);
|
|
461
|
+
throw error;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
async function refreshToken(refreshToken) {
|
|
465
|
+
return retryWithBackoff(async () => {
|
|
466
|
+
if (!configWithDefaults) {
|
|
467
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
468
|
+
}
|
|
469
|
+
const tokenUrl = configWithDefaults.token_url;
|
|
470
|
+
const body = new URLSearchParams();
|
|
471
|
+
body.append('grant_type', 'refresh_token');
|
|
472
|
+
body.append('refresh_token', refreshToken);
|
|
473
|
+
body.append('client_id', configWithDefaults.client_id);
|
|
474
|
+
const response = await fetch(tokenUrl, {
|
|
475
|
+
method: 'POST',
|
|
476
|
+
headers: {
|
|
477
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
478
|
+
Accept: 'application/json',
|
|
479
|
+
},
|
|
480
|
+
body: body.toString(),
|
|
481
|
+
});
|
|
482
|
+
const data = await response.json();
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
console.error('Token refresh error:', data);
|
|
485
|
+
throw new Error(`Token refresh failed: ${data.error || response.statusText} - ${data.error_description || ''}`.trim());
|
|
486
|
+
}
|
|
487
|
+
return data;
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
async function revokeToken(token) {
|
|
491
|
+
try {
|
|
492
|
+
if (!configWithDefaults) {
|
|
493
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
494
|
+
}
|
|
495
|
+
// Only attempt revocation if a revocation endpoint is explicitly configured
|
|
496
|
+
if (!configWithDefaults.revocation_endpoint) {
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const body = new URLSearchParams();
|
|
500
|
+
body.append('token', token);
|
|
501
|
+
body.append('token_type_hint', 'refresh_token');
|
|
502
|
+
body.append('client_id', configWithDefaults.client_id);
|
|
503
|
+
const response = await fetch(configWithDefaults.revocation_endpoint, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers: {
|
|
506
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
507
|
+
},
|
|
508
|
+
body: body.toString(),
|
|
509
|
+
});
|
|
510
|
+
if (!response.ok) {
|
|
511
|
+
console.warn('Token revocation failed:', response.status, response.statusText);
|
|
512
|
+
}
|
|
513
|
+
else {
|
|
514
|
+
console.log('Token revoked successfully');
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
catch (error) {
|
|
518
|
+
console.warn('Error revoking token:', error);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
async function fetchJwks() {
|
|
522
|
+
if (!configWithDefaults) {
|
|
523
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
524
|
+
}
|
|
525
|
+
const url = configWithDefaults.jwks_uri || `${configWithDefaults.authority}/protocol/openid-connect/certs`;
|
|
526
|
+
const cached = jwksCache.get(url);
|
|
527
|
+
if (cached)
|
|
528
|
+
return cached;
|
|
529
|
+
return retryWithBackoff(async () => {
|
|
530
|
+
if (!configWithDefaults)
|
|
531
|
+
throw new Error('SSO Manager not initialized');
|
|
532
|
+
const response = await fetch(url);
|
|
533
|
+
if (!response.ok)
|
|
534
|
+
throw new Error('Failed to fetch JWKS');
|
|
535
|
+
const jwks = await response.json();
|
|
536
|
+
jwksCache.set(url, jwks);
|
|
537
|
+
return jwks;
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
async function retryWithBackoff(operation, maxRetries = 3, baseDelay = 1000, maxDelay = 30000) {
|
|
541
|
+
let lastError = new Error('Placeholder Error');
|
|
542
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
543
|
+
try {
|
|
544
|
+
return await operation();
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
lastError = error instanceof Error ? error : new Error(String(error));
|
|
548
|
+
// Don't retry on authentication errors (4xx) or client errors
|
|
549
|
+
if (error instanceof Error && error.message.includes('400')) {
|
|
550
|
+
throw error;
|
|
551
|
+
}
|
|
552
|
+
if (attempt === maxRetries) {
|
|
553
|
+
throw lastError;
|
|
554
|
+
}
|
|
555
|
+
// Exponential backoff with jitter
|
|
556
|
+
const delay = Math.min(baseDelay * 2 ** attempt, maxDelay);
|
|
557
|
+
const jitter = Math.random() * 0.1 * delay;
|
|
558
|
+
await new Promise((resolve) => setTimeout(resolve, delay + jitter));
|
|
559
|
+
console.warn(`Retry attempt ${attempt + 1} after ${delay + jitter}ms delay`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
throw lastError;
|
|
563
|
+
}
|
|
564
|
+
async function parseJwt(token, validation) {
|
|
565
|
+
try {
|
|
566
|
+
const parts = token.split('.');
|
|
567
|
+
if (parts.length !== 3)
|
|
568
|
+
throw new Error('Invalid JWT');
|
|
569
|
+
const header = JSON.parse(atob(parts[0].replace(/-/g, '+').replace(/_/g, '/')));
|
|
570
|
+
const payload = JSON.parse(atob(parts[1].replace(/-/g, '+').replace(/_/g, '/')));
|
|
571
|
+
const signature = parts[2].replace(/-/g, '+').replace(/_/g, '/');
|
|
572
|
+
const publicKey = await getPublicKey(header.kid);
|
|
573
|
+
const encoder = new TextEncoder();
|
|
574
|
+
const data = encoder.encode(`${parts[0]}.${parts[1]}`);
|
|
575
|
+
const isValid = await crypto.subtle.verify('RSASSA-PKCS1-v1_5', publicKey, Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), data);
|
|
576
|
+
if (!isValid)
|
|
577
|
+
throw new Error('Invalid JWT signature');
|
|
578
|
+
// Validate ID token claims using StandardSchema
|
|
579
|
+
const idTokenClaimsValidator = validation?.idTokenClaims ?? idTokenClaimsSchema('builtin');
|
|
580
|
+
const claimsResult = await idTokenClaimsValidator['~standard'].validate(payload);
|
|
581
|
+
if ('issues' in claimsResult) {
|
|
582
|
+
console.error('ID token claims validation failed:', claimsResult.issues);
|
|
583
|
+
throw new Error(`ID token claims validation failed: ${claimsResult.issues?.map((i) => i.message).join('; ')}`);
|
|
584
|
+
}
|
|
585
|
+
return claimsResult.value;
|
|
586
|
+
}
|
|
587
|
+
catch (e) {
|
|
588
|
+
console.error('Error verifying JWT:', e);
|
|
589
|
+
throw e;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function generateRandomString(length = 32) {
|
|
593
|
+
const array = new Uint8Array(length);
|
|
594
|
+
crypto.getRandomValues(array);
|
|
595
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0'))
|
|
596
|
+
.join('')
|
|
597
|
+
.substring(0, length);
|
|
598
|
+
}
|
|
599
|
+
async function pkceChallengeFromVerifier(verifier) {
|
|
600
|
+
const encoder = new TextEncoder();
|
|
601
|
+
const data = encoder.encode(verifier);
|
|
602
|
+
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
603
|
+
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
604
|
+
const hashBase64 = btoa(String.fromCharCode(...hashArray))
|
|
605
|
+
.replace(/\+/g, '-')
|
|
606
|
+
.replace(/\//g, '_')
|
|
607
|
+
.replace(/=+$/, '');
|
|
608
|
+
return hashBase64;
|
|
609
|
+
}
|
|
610
|
+
async function getPublicKey(kid) {
|
|
611
|
+
const jwks = await fetchJwks();
|
|
612
|
+
const key = jwks.keys.find((k) => k.kid === kid);
|
|
613
|
+
if (!key)
|
|
614
|
+
throw new Error('Public key not found');
|
|
615
|
+
const publicKey = await crypto.subtle.importKey('jwk', {
|
|
616
|
+
kty: key.kty,
|
|
617
|
+
n: key.n,
|
|
618
|
+
e: key.e,
|
|
619
|
+
}, { name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' }, false, ['verify']);
|
|
620
|
+
return publicKey;
|
|
621
|
+
}
|
|
622
|
+
function createJwtCookies(token, expires) {
|
|
623
|
+
const control = {
|
|
624
|
+
expires_in: token.expires_in,
|
|
625
|
+
refresh_expires_in: token.refresh_expires_in,
|
|
626
|
+
scope: token.scope,
|
|
627
|
+
session_state: token.session_state,
|
|
628
|
+
token_type: token.token_type,
|
|
629
|
+
expires: expires.toISOString(),
|
|
630
|
+
};
|
|
631
|
+
return [
|
|
632
|
+
['Set-Cookie', createCookie('access', token.access_token, expires)],
|
|
633
|
+
['Set-Cookie', createCookie('id', token.id_token, expires)],
|
|
634
|
+
['Set-Cookie', createCookie('refresh', token.refresh_token ?? '', expires)],
|
|
635
|
+
['Set-Cookie', createCookie('control', control, expires)],
|
|
636
|
+
];
|
|
637
|
+
}
|
|
638
|
+
async function getTokenFromCookies(req) {
|
|
639
|
+
const access_token = getCookie('access', req);
|
|
640
|
+
const id_token = getCookie('id', req);
|
|
641
|
+
const refresh_token = getCookie('refresh', req);
|
|
642
|
+
const control = getCookie('control', req, true);
|
|
643
|
+
if (!access_token || !id_token || !refresh_token || !control) {
|
|
644
|
+
return { tokens: undefined, refreshHeaders: [] };
|
|
645
|
+
}
|
|
646
|
+
let tokenResponse = {
|
|
647
|
+
access_token,
|
|
648
|
+
id_token,
|
|
649
|
+
refresh_token,
|
|
650
|
+
...control,
|
|
651
|
+
};
|
|
652
|
+
// Check if access token is expired
|
|
653
|
+
if (control.expires && refresh_token && Date.now() > new Date(control.expires).getTime()) {
|
|
654
|
+
tokenResponse = await refreshToken(refresh_token);
|
|
655
|
+
// Create new cookies with refreshed tokens
|
|
656
|
+
const user = await parseUser(tokenResponse);
|
|
657
|
+
const refreshHeaders = createJwtCookies(tokenResponse, user.sso.expires);
|
|
658
|
+
return { tokens: tokenResponse, refreshHeaders };
|
|
659
|
+
}
|
|
660
|
+
return { tokens: tokenResponse, refreshHeaders: [] };
|
|
661
|
+
}
|
|
662
|
+
async function getJwt(request) {
|
|
663
|
+
const { tokens } = await getTokenFromCookies(request);
|
|
664
|
+
if (!tokens)
|
|
665
|
+
return undefined;
|
|
666
|
+
return tokens.access_token;
|
|
667
|
+
}
|
|
668
|
+
function createCookie(name, value, expires) {
|
|
669
|
+
if (!configWithDefaults) {
|
|
670
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
671
|
+
}
|
|
672
|
+
name = `${configWithDefaults.cookies_prefix}.${name}`;
|
|
673
|
+
if (typeof value !== 'string') {
|
|
674
|
+
value = btoa(JSON.stringify(value));
|
|
675
|
+
}
|
|
676
|
+
let exp;
|
|
677
|
+
if (expires instanceof Date) {
|
|
678
|
+
exp = `Expires=${expires.toUTCString()}`;
|
|
679
|
+
}
|
|
680
|
+
else if (typeof expires === 'number') {
|
|
681
|
+
exp = `Max-Age=${expires}`;
|
|
682
|
+
}
|
|
683
|
+
else {
|
|
684
|
+
throw new Error('Invalid expires type', expires);
|
|
685
|
+
}
|
|
686
|
+
if (value.length > 4000) {
|
|
687
|
+
throw new Error(`Error setting cookie: ${name}. Cookie length is: ${value.length}`);
|
|
688
|
+
}
|
|
689
|
+
return `${name}=${value}; ${exp}; Path=${configWithDefaults.cookies_path}; HttpOnly;${configWithDefaults.cookies_secure ? ' Secure;' : ''} SameSite=${configWithDefaults.cookies_same_site};`;
|
|
690
|
+
}
|
|
691
|
+
function clearCookie(name) {
|
|
692
|
+
if (!configWithDefaults) {
|
|
693
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
694
|
+
}
|
|
695
|
+
return `${configWithDefaults.cookies_prefix}.${name}=; Max-Age=0; Path=${configWithDefaults.cookies_path}; HttpOnly;${configWithDefaults.cookies_secure ? ' Secure;' : ''} SameSite=${configWithDefaults.cookies_same_site};`;
|
|
696
|
+
}
|
|
697
|
+
function getCookie(name, req, parse = false) {
|
|
698
|
+
if (!configWithDefaults) {
|
|
699
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
700
|
+
}
|
|
701
|
+
const header = req.headers.get('cookie');
|
|
702
|
+
if (!header)
|
|
703
|
+
return null;
|
|
704
|
+
const cookie = header
|
|
705
|
+
.split(';')
|
|
706
|
+
.find((row) => row.trim().startsWith(`${configWithDefaults.cookies_prefix}.${name}=`));
|
|
707
|
+
if (!cookie)
|
|
708
|
+
return null;
|
|
709
|
+
const val = cookie.split('=')[1].trim();
|
|
710
|
+
if (!parse)
|
|
711
|
+
return val;
|
|
712
|
+
const str = atob(val);
|
|
713
|
+
return JSON.parse(str);
|
|
714
|
+
}
|
|
715
|
+
async function handler(request, es) {
|
|
716
|
+
const { loginUrl, userUrl, errorUrl, landingUrl, tokenUrl, refreshUrl, logoutUrl, logoutBackChannelUrl, jwksUrl, validation, } = { ...handlerDefaults, ...es?.sso };
|
|
717
|
+
if (!configWithDefaults) {
|
|
718
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
719
|
+
}
|
|
720
|
+
if (!loginUrl) {
|
|
721
|
+
console.error('loginUrl is required');
|
|
722
|
+
}
|
|
723
|
+
const path = new URL(request.url).pathname;
|
|
724
|
+
// Handle both absolute URLs and relative paths for redirect_uri
|
|
725
|
+
let redirectUriPath;
|
|
726
|
+
try {
|
|
727
|
+
// Try to parse as absolute URL first
|
|
728
|
+
redirectUriPath = new URL(configWithDefaults.redirect_uri).pathname;
|
|
729
|
+
}
|
|
730
|
+
catch {
|
|
731
|
+
// If it's not a valid URL, try to construct from request URL
|
|
732
|
+
try {
|
|
733
|
+
const requestUrl = new URL(request.url);
|
|
734
|
+
// If redirect_uri starts with //, it's a protocol-relative URL, treat as path
|
|
735
|
+
const redirectUri = configWithDefaults.redirect_uri.startsWith('//')
|
|
736
|
+
? configWithDefaults.redirect_uri.slice(1)
|
|
737
|
+
: configWithDefaults.redirect_uri;
|
|
738
|
+
// Construct full URL from request origin + redirect_uri path
|
|
739
|
+
redirectUriPath = new URL(redirectUri, requestUrl.origin).pathname;
|
|
740
|
+
}
|
|
741
|
+
catch {
|
|
742
|
+
// Fallback: treat as path directly
|
|
743
|
+
redirectUriPath = configWithDefaults.redirect_uri.startsWith('/')
|
|
744
|
+
? configWithDefaults.redirect_uri
|
|
745
|
+
: `/${configWithDefaults.redirect_uri}`;
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
if (redirectUriPath === path) {
|
|
749
|
+
return callbackHandler(request, validation);
|
|
750
|
+
}
|
|
751
|
+
if (loginUrl === path) {
|
|
752
|
+
return initiateLogin({
|
|
753
|
+
landingUrl: landingUrl || '/',
|
|
754
|
+
errorUrl,
|
|
755
|
+
}, request.url);
|
|
756
|
+
}
|
|
757
|
+
if (userUrl === path) {
|
|
758
|
+
const { tokens, refreshHeaders } = await getTokenFromCookies(request);
|
|
759
|
+
if (!tokens) {
|
|
760
|
+
return new Response('User not logged in', { status: 401 });
|
|
761
|
+
}
|
|
762
|
+
const user = await parseUser(tokens);
|
|
763
|
+
return new Response(JSON.stringify(user), {
|
|
764
|
+
headers: [['Content-Type', 'application/json'], ...refreshHeaders],
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
if (tokenUrl === path) {
|
|
768
|
+
const { tokens, refreshHeaders } = await getTokenFromCookies(request);
|
|
769
|
+
if (!tokens) {
|
|
770
|
+
return new Response('User not logged in', { status: 401 });
|
|
771
|
+
}
|
|
772
|
+
return new Response(JSON.stringify({
|
|
773
|
+
token: tokens.access_token,
|
|
774
|
+
expires: tokens.expires,
|
|
775
|
+
}), {
|
|
776
|
+
headers: [['Content-Type', 'application/json'], ...refreshHeaders],
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
if (refreshUrl === path) {
|
|
780
|
+
const refresh_token = getCookie('refresh', request);
|
|
781
|
+
if (!refresh_token) {
|
|
782
|
+
return new Response('User not logged in', { status: 401 });
|
|
783
|
+
}
|
|
784
|
+
// Force a token refresh
|
|
785
|
+
const newTokenResponse = await refreshToken(refresh_token);
|
|
786
|
+
const user = await parseUser(newTokenResponse);
|
|
787
|
+
const refreshHeaders = createJwtCookies(newTokenResponse, user.sso.expires);
|
|
788
|
+
return new Response('Refresh Complete', {
|
|
789
|
+
status: 200,
|
|
790
|
+
headers: refreshHeaders,
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
if (logoutUrl === path) {
|
|
794
|
+
return logout(request, { landingUrl: landingUrl || '/' });
|
|
795
|
+
}
|
|
796
|
+
if (logoutBackChannelUrl === path) {
|
|
797
|
+
return logoutBackChannel(request);
|
|
798
|
+
}
|
|
799
|
+
if (jwksUrl === path) {
|
|
800
|
+
const jwks = await fetchJwks();
|
|
801
|
+
return new Response(JSON.stringify(jwks), {
|
|
802
|
+
headers: [['Content-Type', 'application/json']],
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
return new Response('Not Found', { status: 404 });
|
|
806
|
+
}
|
|
807
|
+
if (!configWithDefaults) {
|
|
808
|
+
throw new Error('Enterprise Standard SSO Manager not initialized');
|
|
809
|
+
}
|
|
810
|
+
return {
|
|
811
|
+
...configWithDefaults,
|
|
812
|
+
getUser,
|
|
813
|
+
getRequiredUser,
|
|
814
|
+
getJwt,
|
|
815
|
+
initiateLogin,
|
|
816
|
+
logout,
|
|
817
|
+
callbackHandler,
|
|
818
|
+
handler,
|
|
819
|
+
};
|
|
820
|
+
}
|