collabmd 0.1.21 → 0.1.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +94 -7
- package/bin/collabmd.js +3 -2
- package/docker-compose.yml +13 -1
- package/package.json +2 -1
- package/public/assets/css/style.css +1 -1
- package/public/assets/js/chunks/{chunk-HEWVH67U.js → chunk-CYK7KO7W.js} +8 -8
- package/public/assets/js/chunks/editor-session-S5AT7BMV.js +22 -0
- package/public/assets/js/{excalidraw-editor-TCIH6GJL.js → excalidraw-editor-5YWMGLKF.js} +145 -62
- package/public/assets/js/excalidraw-editor.js +1 -1
- package/public/assets/js/main.js +503 -182
- package/public/index.html +104 -84
- package/src/client/application/app-shell/git-feature.js +247 -9
- package/src/client/application/app-shell/ui-feature.js +65 -7
- package/src/client/application/app-shell/workspace-feature.js +46 -0
- package/src/client/application/app-shell-elements.js +2 -0
- package/src/client/application/mermaid-preview-hydrator.js +1 -1
- package/src/client/application/plantuml-preview-hydrator.js +1 -1
- package/src/client/application/workspace-coordinator.js +147 -18
- package/src/client/application/workspace-preview-controller.js +34 -0
- package/src/client/application/workspace-route-controller.js +104 -0
- package/src/client/bootstrap/collabmd-app-shell.js +95 -0
- package/src/client/domain/runtime-paths.js +5 -0
- package/src/client/excalidraw-editor.js +50 -2
- package/src/client/infrastructure/auth-client.js +219 -52
- package/src/client/infrastructure/browser-navigation-port.js +20 -0
- package/src/client/infrastructure/editor-collaboration-client.js +4 -0
- package/src/client/infrastructure/editor-session.js +47 -7
- package/src/client/infrastructure/editor-view-adapter.js +122 -58
- package/src/client/infrastructure/runtime-config.js +66 -0
- package/src/client/presentation/comment-ui-controller.js +82 -26
- package/src/client/presentation/comments-panel.js +2 -2
- package/src/client/presentation/excalidraw-embed-controller.js +60 -2
- package/src/client/presentation/file-history-view-controller.js +806 -0
- package/src/client/presentation/git-diff-view-controller.js +718 -121
- package/src/client/presentation/git-panel-controller.js +458 -66
- package/src/client/presentation/layout-controller.js +1 -1
- package/src/client/styles/components/actions.css +89 -0
- package/src/client/styles/components/feedback.css +43 -0
- package/src/client/styles/components/navigation.css +68 -0
- package/src/client/styles/components/scrollbars.css +104 -0
- package/src/client/styles/components/states.css +289 -0
- package/src/client/styles/components/surfaces.css +155 -0
- package/src/client/styles/features/backlinks.css +213 -0
- package/src/client/styles/features/collaboration-chat.css +228 -0
- package/src/client/styles/features/collaboration-dialogs.css +154 -0
- package/src/client/styles/features/collaboration-presence.css +103 -0
- package/src/client/styles/features/collaboration-ui.css +3 -0
- package/src/client/styles/features/comment-card.css +236 -0
- package/src/client/styles/features/comment-markdown.css +127 -0
- package/src/client/styles/features/comment-overlays.css +60 -0
- package/src/client/styles/features/comments-drawer.css +120 -0
- package/src/client/styles/features/comments.css +4 -0
- package/src/client/styles/features/diagram-preview.css +227 -0
- package/src/client/styles/features/diff-commit.css +268 -0
- package/src/client/styles/features/diff-lines.css +160 -0
- package/src/client/styles/features/diff-toolbar.css +98 -0
- package/src/client/styles/features/diff.css +3 -0
- package/src/client/styles/features/embedded-preview.css +218 -0
- package/src/client/styles/features/git-branch.css +155 -0
- package/src/client/styles/features/git-files.css +105 -0
- package/src/client/styles/features/git-history.css +339 -0
- package/src/client/styles/features/git.css +3 -0
- package/src/client/styles/features/media-lightbox.css +157 -0
- package/src/client/styles/features/outline.css +115 -0
- package/src/client/styles/features/preview-content.css +3 -0
- package/src/client/styles/features/preview-markdown.css +201 -0
- package/src/client/styles/features/preview-sidebars.css +2 -0
- package/src/client/styles/features/quick-switcher.css +167 -0
- package/src/client/styles/foundation/themes.css +347 -0
- package/src/client/styles/foundation/tokens.css +66 -0
- package/src/client/styles/foundation/utilities.css +36 -0
- package/src/client/styles/layout/editor-page.css +203 -0
- package/src/client/styles/layout/overlays.css +132 -0
- package/src/client/styles/layout/responsive.css +251 -0
- package/src/client/styles/layout/shell.css +314 -0
- package/src/client/styles/layout/sidebar.css +176 -0
- package/src/client/styles/layout/view-modes.css +92 -0
- package/src/client/styles/overrides/codemirror.css +53 -0
- package/src/client/styles/overrides/highlightjs.css +7 -0
- package/src/client/styles/overrides/vendor.css +2 -0
- package/src/client/styles/primitives/controls.css +276 -0
- package/src/client/styles/style.css +25 -4945
- package/src/server/auth/create-auth-service.js +373 -15
- package/src/server/auth/session-cookie.js +30 -4
- package/src/server/config/env.js +121 -5
- package/src/server/create-app-server.js +8 -0
- package/src/server/domain/collaboration/collaboration-room.js +90 -47
- package/src/server/index.js +3 -1
- package/src/server/infrastructure/git/command-runner.js +2 -1
- package/src/server/infrastructure/git/git-service.js +48 -2
- package/src/server/infrastructure/git/history-service.js +683 -0
- package/src/server/infrastructure/git/parsers.js +30 -0
- package/src/server/infrastructure/git/responses.js +73 -0
- package/src/server/infrastructure/http/create-auth-api-handler.js +30 -2
- package/src/server/infrastructure/http/create-git-api-command-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-handler.js +2 -0
- package/src/server/infrastructure/http/create-git-api-query-handler.js +61 -0
- package/src/server/infrastructure/http/create-request-handler.js +15 -0
- package/public/assets/js/chunks/editor-session-EAUBJCHH.js +0 -22
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
|
|
3
|
-
import
|
|
3
|
+
import * as oidc from 'openid-client';
|
|
4
|
+
|
|
5
|
+
import { createSessionCookieManager, createSignedCookieManager } from './session-cookie.js';
|
|
4
6
|
|
|
5
7
|
export const AUTH_STRATEGY_NONE = 'none';
|
|
6
8
|
export const AUTH_STRATEGY_PASSWORD = 'password';
|
|
@@ -12,6 +14,9 @@ export const SUPPORTED_AUTH_STRATEGIES = new Set([
|
|
|
12
14
|
AUTH_STRATEGY_OIDC,
|
|
13
15
|
]);
|
|
14
16
|
|
|
17
|
+
const OIDC_FLOW_TTL_MS = 10 * 60 * 1000;
|
|
18
|
+
const OIDC_PROVIDER_GOOGLE = 'google';
|
|
19
|
+
|
|
15
20
|
export function createRandomAuthPassword(length = 18) {
|
|
16
21
|
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
|
|
17
22
|
const bytes = randomBytes(length);
|
|
@@ -36,6 +41,7 @@ function createResponse(statusCode, body, options = {}) {
|
|
|
36
41
|
return {
|
|
37
42
|
body,
|
|
38
43
|
kind: options.kind || 'response',
|
|
44
|
+
redirectTo: options.redirectTo || '',
|
|
39
45
|
setCookie: options.setCookie || null,
|
|
40
46
|
statusCode,
|
|
41
47
|
};
|
|
@@ -60,7 +66,127 @@ function prependBasePath(basePath, pathname) {
|
|
|
60
66
|
return pathname === '/' ? basePath : `${basePath}${pathname}`;
|
|
61
67
|
}
|
|
62
68
|
|
|
69
|
+
function appendHashParam(path, key, value, publicBaseUrl) {
|
|
70
|
+
const url = new URL(path, publicBaseUrl);
|
|
71
|
+
const hashParams = new URLSearchParams(url.hash.startsWith('#') ? url.hash.slice(1) : url.hash);
|
|
72
|
+
hashParams.set(key, value);
|
|
73
|
+
url.hash = hashParams.toString();
|
|
74
|
+
return `${url.pathname}${url.search}${url.hash}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getDefaultReturnTo(basePath) {
|
|
78
|
+
return basePath ? `${basePath}/` : '/';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function sanitizeReturnTo(rawValue, { basePath = '', publicBaseUrl = '' } = {}) {
|
|
82
|
+
const fallback = getDefaultReturnTo(basePath);
|
|
83
|
+
const candidate = String(rawValue ?? '').trim();
|
|
84
|
+
if (!candidate) {
|
|
85
|
+
return fallback;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const resolved = new URL(candidate, `${publicBaseUrl || 'http://localhost'}/`);
|
|
90
|
+
const publicOrigin = publicBaseUrl ? new URL(publicBaseUrl).origin : resolved.origin;
|
|
91
|
+
if (resolved.origin !== publicOrigin) {
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (basePath) {
|
|
96
|
+
const withinBasePath = resolved.pathname === basePath || resolved.pathname.startsWith(`${basePath}/`);
|
|
97
|
+
if (!withinBasePath) {
|
|
98
|
+
return fallback;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
|
|
103
|
+
} catch {
|
|
104
|
+
return fallback;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function isNonEmptyString(value) {
|
|
109
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function normalizeEmailAddress(value) {
|
|
113
|
+
return String(value ?? '').trim().toLowerCase();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getEmailDomain(email) {
|
|
117
|
+
const normalized = normalizeEmailAddress(email);
|
|
118
|
+
const atIndex = normalized.lastIndexOf('@');
|
|
119
|
+
if (atIndex <= 0 || atIndex === normalized.length - 1) {
|
|
120
|
+
return '';
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return normalized.slice(atIndex + 1);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isOidcUserAllowed(oidcConfig, email) {
|
|
127
|
+
const normalizedEmail = normalizeEmailAddress(email);
|
|
128
|
+
const emailDomain = getEmailDomain(normalizedEmail);
|
|
129
|
+
const allowedEmails = Array.isArray(oidcConfig?.allowedEmails) ? oidcConfig.allowedEmails : [];
|
|
130
|
+
const allowedDomains = Array.isArray(oidcConfig?.allowedDomains) ? oidcConfig.allowedDomains : [];
|
|
131
|
+
|
|
132
|
+
if (allowedEmails.length === 0 && allowedDomains.length === 0) {
|
|
133
|
+
return { allowed: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (allowedEmails.includes(normalizedEmail)) {
|
|
137
|
+
return { allowed: true, reason: 'email' };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (allowedDomains.includes(emailDomain)) {
|
|
141
|
+
return { allowed: true, reason: 'domain' };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
allowed: false,
|
|
146
|
+
error: allowedEmails.length > 0 && allowedDomains.length > 0
|
|
147
|
+
? 'This Google account is not in the allowed email or domain list.'
|
|
148
|
+
: allowedEmails.length > 0
|
|
149
|
+
? 'This Google account is not in the allowed email list.'
|
|
150
|
+
: 'This Google account is not in an allowed domain.',
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readAuthenticatedOidcSession(sessionCookieManager, req) {
|
|
155
|
+
const session = sessionCookieManager.readSession(req);
|
|
156
|
+
if (session?.strategy !== AUTH_STRATEGY_OIDC) {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const user = session.user;
|
|
161
|
+
if (
|
|
162
|
+
!user
|
|
163
|
+
|| typeof user !== 'object'
|
|
164
|
+
|| !isNonEmptyString(user.sub)
|
|
165
|
+
|| !isNonEmptyString(user.email)
|
|
166
|
+
|| !isNonEmptyString(user.name)
|
|
167
|
+
) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const expiresAt = Number(session.expiresAt);
|
|
172
|
+
if (Number.isFinite(expiresAt) && expiresAt <= Date.now()) {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
...session,
|
|
178
|
+
user: {
|
|
179
|
+
email: user.email,
|
|
180
|
+
emailVerified: user.emailVerified === true,
|
|
181
|
+
name: user.name,
|
|
182
|
+
picture: typeof user.picture === 'string' ? user.picture : '',
|
|
183
|
+
sub: user.sub,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
63
188
|
function buildClientConfig(authConfig, { basePath = '' } = {}) {
|
|
189
|
+
const loginEndpoint = prependBasePath(basePath, '/api/auth/oidc/login');
|
|
64
190
|
const sessionEndpoint = prependBasePath(basePath, '/api/auth/session');
|
|
65
191
|
const statusEndpoint = prependBasePath(basePath, '/api/auth/status');
|
|
66
192
|
|
|
@@ -90,11 +216,14 @@ function buildClientConfig(authConfig, { basePath = '' } = {}) {
|
|
|
90
216
|
|
|
91
217
|
return {
|
|
92
218
|
enabled: true,
|
|
93
|
-
implemented:
|
|
219
|
+
implemented: true,
|
|
220
|
+
loginEndpoint,
|
|
221
|
+
provider: OIDC_PROVIDER_GOOGLE,
|
|
94
222
|
requiresLogin: true,
|
|
95
223
|
sessionEndpoint,
|
|
96
224
|
statusEndpoint,
|
|
97
225
|
strategy: AUTH_STRATEGY_OIDC,
|
|
226
|
+
submitLabel: 'Continue with Google',
|
|
98
227
|
};
|
|
99
228
|
}
|
|
100
229
|
|
|
@@ -107,6 +236,13 @@ function createPasswordStrategy(authConfig, sessionCookieManager, clientConfig)
|
|
|
107
236
|
}
|
|
108
237
|
|
|
109
238
|
return {
|
|
239
|
+
clearSession(req) {
|
|
240
|
+
return createResponse(200, { ok: true }, {
|
|
241
|
+
kind: 'logout_ok',
|
|
242
|
+
setCookie: sessionCookieManager.clearSession(req),
|
|
243
|
+
});
|
|
244
|
+
},
|
|
245
|
+
|
|
110
246
|
createSession(req, body = {}) {
|
|
111
247
|
if (typeof body.password !== 'string' || !body.password) {
|
|
112
248
|
return createResponse(400, { error: 'Missing password' }, { kind: 'invalid_request' });
|
|
@@ -128,6 +264,7 @@ function createPasswordStrategy(authConfig, sessionCookieManager, clientConfig)
|
|
|
128
264
|
auth: clientConfig,
|
|
129
265
|
authenticated: true,
|
|
130
266
|
ok: true,
|
|
267
|
+
user: null,
|
|
131
268
|
}, {
|
|
132
269
|
kind: 'session_created',
|
|
133
270
|
setCookie: sessionCookieManager.createSessionCookie(req, {
|
|
@@ -137,10 +274,15 @@ function createPasswordStrategy(authConfig, sessionCookieManager, clientConfig)
|
|
|
137
274
|
});
|
|
138
275
|
},
|
|
139
276
|
|
|
277
|
+
getAuthenticatedUser() {
|
|
278
|
+
return null;
|
|
279
|
+
},
|
|
280
|
+
|
|
140
281
|
getStatus(req) {
|
|
141
282
|
return createResponse(200, {
|
|
142
283
|
authenticated: hasValidSession(req),
|
|
143
284
|
auth: clientConfig,
|
|
285
|
+
user: null,
|
|
144
286
|
}, { kind: 'status' });
|
|
145
287
|
},
|
|
146
288
|
|
|
@@ -152,14 +294,23 @@ function createPasswordStrategy(authConfig, sessionCookieManager, clientConfig)
|
|
|
152
294
|
|
|
153
295
|
function createNoneStrategy(clientConfig) {
|
|
154
296
|
return {
|
|
297
|
+
clearSession() {
|
|
298
|
+
return createResponse(200, { ok: true, user: null }, { kind: 'logout_ok' });
|
|
299
|
+
},
|
|
300
|
+
|
|
155
301
|
createSession() {
|
|
156
302
|
return createResponse(405, { error: 'Authentication is disabled' }, { kind: 'disabled' });
|
|
157
303
|
},
|
|
158
304
|
|
|
305
|
+
getAuthenticatedUser() {
|
|
306
|
+
return null;
|
|
307
|
+
},
|
|
308
|
+
|
|
159
309
|
getStatus() {
|
|
160
310
|
return createResponse(200, {
|
|
161
311
|
authenticated: true,
|
|
162
312
|
auth: clientConfig,
|
|
313
|
+
user: null,
|
|
163
314
|
}, { kind: 'status' });
|
|
164
315
|
},
|
|
165
316
|
|
|
@@ -169,25 +320,204 @@ function createNoneStrategy(clientConfig) {
|
|
|
169
320
|
};
|
|
170
321
|
}
|
|
171
322
|
|
|
172
|
-
function createOidcStrategy(clientConfig,
|
|
323
|
+
function createOidcStrategy(authConfig, sessionCookieManager, flowCookieManager, clientConfig, { basePath = '' } = {}) {
|
|
324
|
+
let oidcConfigPromise = null;
|
|
325
|
+
|
|
326
|
+
function clearOidcCookies(req, includeSession = true) {
|
|
327
|
+
const cookies = [flowCookieManager.clear(req)];
|
|
328
|
+
if (includeSession) {
|
|
329
|
+
cookies.unshift(sessionCookieManager.clearSession(req));
|
|
330
|
+
}
|
|
331
|
+
return cookies;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function createErrorRedirect(req, message, flowPayload = null) {
|
|
335
|
+
const returnTo = sanitizeReturnTo(flowPayload?.returnTo, {
|
|
336
|
+
basePath,
|
|
337
|
+
publicBaseUrl: authConfig.oidc.publicBaseUrl,
|
|
338
|
+
});
|
|
339
|
+
return createResponse(302, null, {
|
|
340
|
+
kind: 'redirect',
|
|
341
|
+
redirectTo: appendHashParam(returnTo, 'auth_error', message, authConfig.oidc.publicBaseUrl),
|
|
342
|
+
setCookie: clearOidcCookies(req),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function getOidcConfiguration() {
|
|
347
|
+
if (!oidcConfigPromise) {
|
|
348
|
+
const allowInsecureRequests = new URL(authConfig.oidc.issuer).protocol !== 'https:';
|
|
349
|
+
oidcConfigPromise = oidc.discovery(
|
|
350
|
+
new URL(authConfig.oidc.issuer),
|
|
351
|
+
authConfig.oidc.clientId,
|
|
352
|
+
authConfig.oidc.clientSecret,
|
|
353
|
+
undefined,
|
|
354
|
+
allowInsecureRequests
|
|
355
|
+
? { execute: [oidc.allowInsecureRequests] }
|
|
356
|
+
: undefined,
|
|
357
|
+
).catch((error) => {
|
|
358
|
+
oidcConfigPromise = null;
|
|
359
|
+
throw error;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return oidcConfigPromise;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function getAuthenticatedSession(req) {
|
|
367
|
+
return readAuthenticatedOidcSession(sessionCookieManager, req);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function getAuthenticatedUser(req) {
|
|
371
|
+
return getAuthenticatedSession(req)?.user ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
173
374
|
return {
|
|
375
|
+
async beginLogin(req, requestUrl) {
|
|
376
|
+
const config = await getOidcConfiguration();
|
|
377
|
+
const returnTo = sanitizeReturnTo(requestUrl.searchParams.get('returnTo'), {
|
|
378
|
+
basePath,
|
|
379
|
+
publicBaseUrl: authConfig.oidc.publicBaseUrl,
|
|
380
|
+
});
|
|
381
|
+
const codeVerifier = oidc.randomPKCECodeVerifier();
|
|
382
|
+
const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
|
|
383
|
+
const nonce = oidc.randomNonce();
|
|
384
|
+
const state = oidc.randomState();
|
|
385
|
+
const redirectTo = oidc.buildAuthorizationUrl(config, {
|
|
386
|
+
code_challenge: codeChallenge,
|
|
387
|
+
code_challenge_method: 'S256',
|
|
388
|
+
...(authConfig.oidc.allowedDomains.length === 1 ? { hd: authConfig.oidc.allowedDomains[0] } : {}),
|
|
389
|
+
nonce,
|
|
390
|
+
redirect_uri: authConfig.oidc.callbackUrl,
|
|
391
|
+
response_type: 'code',
|
|
392
|
+
scope: 'openid email profile',
|
|
393
|
+
state,
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
return createResponse(302, null, {
|
|
397
|
+
kind: 'redirect',
|
|
398
|
+
redirectTo: redirectTo.href,
|
|
399
|
+
setCookie: flowCookieManager.create(req, {
|
|
400
|
+
createdAt: Date.now(),
|
|
401
|
+
nonce,
|
|
402
|
+
pkceCodeVerifier: codeVerifier,
|
|
403
|
+
returnTo,
|
|
404
|
+
state,
|
|
405
|
+
}, {
|
|
406
|
+
expires: new Date(Date.now() + OIDC_FLOW_TTL_MS),
|
|
407
|
+
}),
|
|
408
|
+
});
|
|
409
|
+
},
|
|
410
|
+
|
|
411
|
+
async completeLogin(req, requestUrl) {
|
|
412
|
+
const flowPayload = flowCookieManager.read(req);
|
|
413
|
+
if (!flowPayload) {
|
|
414
|
+
return createErrorRedirect(req, 'Authentication session expired. Try again.');
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
if (requestUrl.searchParams.get('error')) {
|
|
418
|
+
const errorMessage = requestUrl.searchParams.get('error_description')
|
|
419
|
+
|| requestUrl.searchParams.get('error')
|
|
420
|
+
|| 'Authentication failed';
|
|
421
|
+
return createErrorRedirect(req, errorMessage, flowPayload);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
if (
|
|
425
|
+
!isNonEmptyString(flowPayload.state)
|
|
426
|
+
|| !isNonEmptyString(flowPayload.nonce)
|
|
427
|
+
|| !isNonEmptyString(flowPayload.pkceCodeVerifier)
|
|
428
|
+
) {
|
|
429
|
+
return createErrorRedirect(req, 'Authentication session expired. Try again.', flowPayload);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
try {
|
|
433
|
+
const config = await getOidcConfiguration();
|
|
434
|
+
const callbackUrl = new URL(authConfig.oidc.callbackUrl);
|
|
435
|
+
callbackUrl.search = requestUrl.search;
|
|
436
|
+
const tokens = await oidc.authorizationCodeGrant(config, callbackUrl, {
|
|
437
|
+
expectedNonce: flowPayload.nonce,
|
|
438
|
+
expectedState: flowPayload.state,
|
|
439
|
+
idTokenExpected: true,
|
|
440
|
+
pkceCodeVerifier: flowPayload.pkceCodeVerifier,
|
|
441
|
+
});
|
|
442
|
+
const claims = tokens.claims();
|
|
443
|
+
|
|
444
|
+
if (
|
|
445
|
+
!claims
|
|
446
|
+
|| !isNonEmptyString(claims.sub)
|
|
447
|
+
|| !isNonEmptyString(claims.email)
|
|
448
|
+
|| claims.email_verified !== true
|
|
449
|
+
|| !isNonEmptyString(claims.name)
|
|
450
|
+
) {
|
|
451
|
+
return createErrorRedirect(req, 'Google account is missing a verified email or name.', flowPayload);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
const accessDecision = isOidcUserAllowed(authConfig.oidc, claims.email);
|
|
455
|
+
if (!accessDecision.allowed) {
|
|
456
|
+
return createErrorRedirect(req, accessDecision.error, flowPayload);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
const expiresAt = Number.isFinite(claims.exp)
|
|
460
|
+
? claims.exp * 1000
|
|
461
|
+
: Date.now() + (Math.max(Number(tokens.expires_in) || 3600, 1) * 1000);
|
|
462
|
+
const returnTo = sanitizeReturnTo(flowPayload.returnTo, {
|
|
463
|
+
basePath,
|
|
464
|
+
publicBaseUrl: authConfig.oidc.publicBaseUrl,
|
|
465
|
+
});
|
|
466
|
+
const user = {
|
|
467
|
+
email: claims.email,
|
|
468
|
+
emailVerified: true,
|
|
469
|
+
name: claims.name,
|
|
470
|
+
picture: typeof claims.picture === 'string' ? claims.picture : '',
|
|
471
|
+
sub: claims.sub,
|
|
472
|
+
};
|
|
473
|
+
|
|
474
|
+
return createResponse(302, null, {
|
|
475
|
+
kind: 'redirect',
|
|
476
|
+
redirectTo: returnTo,
|
|
477
|
+
setCookie: [
|
|
478
|
+
sessionCookieManager.createSessionCookie(req, {
|
|
479
|
+
authenticatedAt: Date.now(),
|
|
480
|
+
expiresAt,
|
|
481
|
+
strategy: AUTH_STRATEGY_OIDC,
|
|
482
|
+
user,
|
|
483
|
+
}),
|
|
484
|
+
flowCookieManager.clear(req),
|
|
485
|
+
],
|
|
486
|
+
});
|
|
487
|
+
} catch (error) {
|
|
488
|
+
console.error('[auth] OIDC callback failed:', error.message);
|
|
489
|
+
return createErrorRedirect(req, 'Google sign-in failed. Try again.', flowPayload);
|
|
490
|
+
}
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
clearSession(req) {
|
|
494
|
+
return createResponse(200, { ok: true, user: null }, {
|
|
495
|
+
kind: 'logout_ok',
|
|
496
|
+
setCookie: clearOidcCookies(req),
|
|
497
|
+
});
|
|
498
|
+
},
|
|
499
|
+
|
|
174
500
|
createSession() {
|
|
175
|
-
return createResponse(
|
|
501
|
+
return createResponse(405, {
|
|
176
502
|
auth: clientConfig,
|
|
177
|
-
code: '
|
|
178
|
-
error: 'OIDC
|
|
179
|
-
}, { kind: '
|
|
503
|
+
code: 'AUTH_LOGIN_REDIRECT_REQUIRED',
|
|
504
|
+
error: 'Use the OIDC login endpoint to authenticate.',
|
|
505
|
+
}, { kind: 'invalid_request' });
|
|
180
506
|
},
|
|
181
507
|
|
|
182
|
-
|
|
508
|
+
getAuthenticatedUser,
|
|
509
|
+
|
|
510
|
+
getStatus(req) {
|
|
511
|
+
const user = getAuthenticatedUser(req);
|
|
183
512
|
return createResponse(200, {
|
|
184
|
-
authenticated:
|
|
513
|
+
authenticated: Boolean(user),
|
|
185
514
|
auth: clientConfig,
|
|
515
|
+
user,
|
|
186
516
|
}, { kind: 'status' });
|
|
187
517
|
},
|
|
188
518
|
|
|
189
519
|
isAuthenticated(req) {
|
|
190
|
-
return
|
|
520
|
+
return Boolean(getAuthenticatedSession(req));
|
|
191
521
|
},
|
|
192
522
|
};
|
|
193
523
|
}
|
|
@@ -207,7 +537,18 @@ export function createAuthService(config) {
|
|
|
207
537
|
if (authConfig.strategy === AUTH_STRATEGY_PASSWORD) {
|
|
208
538
|
strategy = createPasswordStrategy(authConfig, sessionCookieManager, clientConfig);
|
|
209
539
|
} else if (authConfig.strategy === AUTH_STRATEGY_OIDC) {
|
|
210
|
-
|
|
540
|
+
const flowCookieManager = createSignedCookieManager({
|
|
541
|
+
cookieName: authConfig.oidc.flowCookieName,
|
|
542
|
+
cookiePath: config.basePath || '/',
|
|
543
|
+
secret: authConfig.sessionSecret,
|
|
544
|
+
});
|
|
545
|
+
strategy = createOidcStrategy(
|
|
546
|
+
authConfig,
|
|
547
|
+
sessionCookieManager,
|
|
548
|
+
flowCookieManager,
|
|
549
|
+
clientConfig,
|
|
550
|
+
{ basePath: config.basePath ?? '' },
|
|
551
|
+
);
|
|
211
552
|
} else {
|
|
212
553
|
strategy = createNoneStrategy(clientConfig);
|
|
213
554
|
}
|
|
@@ -241,17 +582,34 @@ export function createAuthService(config) {
|
|
|
241
582
|
};
|
|
242
583
|
},
|
|
243
584
|
|
|
585
|
+
beginOidcLogin(req, requestUrl) {
|
|
586
|
+
if (authConfig.strategy !== AUTH_STRATEGY_OIDC || typeof strategy.beginLogin !== 'function') {
|
|
587
|
+
return createResponse(404, { error: 'OIDC endpoint not found' }, { kind: 'not_found' });
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return strategy.beginLogin(req, requestUrl);
|
|
591
|
+
},
|
|
592
|
+
|
|
244
593
|
clearSession(req) {
|
|
245
|
-
return
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
594
|
+
return strategy.clearSession(req);
|
|
595
|
+
},
|
|
596
|
+
|
|
597
|
+
completeOidcLogin(req, requestUrl) {
|
|
598
|
+
if (authConfig.strategy !== AUTH_STRATEGY_OIDC || typeof strategy.completeLogin !== 'function') {
|
|
599
|
+
return createResponse(404, { error: 'OIDC endpoint not found' }, { kind: 'not_found' });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return strategy.completeLogin(req, requestUrl);
|
|
249
603
|
},
|
|
250
604
|
|
|
251
605
|
createSession(req, body) {
|
|
252
606
|
return strategy.createSession(req, body);
|
|
253
607
|
},
|
|
254
608
|
|
|
609
|
+
getAuthenticatedUser(req) {
|
|
610
|
+
return strategy.getAuthenticatedUser(req);
|
|
611
|
+
},
|
|
612
|
+
|
|
255
613
|
getClientConfig() {
|
|
256
614
|
return clientConfig;
|
|
257
615
|
},
|
|
@@ -51,6 +51,32 @@ export function createSessionCookieManager({
|
|
|
51
51
|
cookieName,
|
|
52
52
|
cookiePath = '/',
|
|
53
53
|
secret,
|
|
54
|
+
}) {
|
|
55
|
+
const signedCookieManager = createSignedCookieManager({
|
|
56
|
+
cookieName,
|
|
57
|
+
cookiePath,
|
|
58
|
+
secret,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
clearSession(req) {
|
|
63
|
+
return signedCookieManager.clear(req);
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
createSessionCookie(req, payload, options = {}) {
|
|
67
|
+
return signedCookieManager.create(req, payload, options);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
readSession(req) {
|
|
71
|
+
return signedCookieManager.read(req);
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function createSignedCookieManager({
|
|
77
|
+
cookieName,
|
|
78
|
+
cookiePath = '/',
|
|
79
|
+
secret,
|
|
54
80
|
}) {
|
|
55
81
|
function createCookieAttributes(req, { expires = null } = {}) {
|
|
56
82
|
const attributes = [
|
|
@@ -71,24 +97,24 @@ export function createSessionCookieManager({
|
|
|
71
97
|
}
|
|
72
98
|
|
|
73
99
|
return {
|
|
74
|
-
|
|
100
|
+
clear(req) {
|
|
75
101
|
return [
|
|
76
102
|
`${cookieName}=`,
|
|
77
103
|
...createCookieAttributes(req, { expires: new Date(0) }),
|
|
78
104
|
].join('; ');
|
|
79
105
|
},
|
|
80
106
|
|
|
81
|
-
|
|
107
|
+
create(req, payload, { expires = null } = {}) {
|
|
82
108
|
const serializedPayload = JSON.stringify(payload);
|
|
83
109
|
const encodedPayload = encodeBase64Url(serializedPayload);
|
|
84
110
|
const signature = encodeBase64Url(createSignature(encodedPayload, secret));
|
|
85
111
|
return [
|
|
86
112
|
`${cookieName}=${encodedPayload}.${signature}`,
|
|
87
|
-
...createCookieAttributes(req),
|
|
113
|
+
...createCookieAttributes(req, { expires }),
|
|
88
114
|
].join('; ');
|
|
89
115
|
},
|
|
90
116
|
|
|
91
|
-
|
|
117
|
+
read(req) {
|
|
92
118
|
const token = parseCookieHeader(req.headers.cookie).get(cookieName);
|
|
93
119
|
if (!token) {
|
|
94
120
|
return null;
|