@actuate-media/cms-core 0.57.0 → 0.57.1
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/__tests__/api/stats-seo-cache.test.js +1 -1
- package/dist/__tests__/api/stats-seo-cache.test.js.map +1 -1
- package/dist/api/guarded-db.d.ts +13 -0
- package/dist/api/guarded-db.d.ts.map +1 -0
- package/dist/api/guarded-db.js +58 -0
- package/dist/api/guarded-db.js.map +1 -0
- package/dist/api/handler-factory.d.ts.map +1 -1
- package/dist/api/handler-factory.js +2 -1
- package/dist/api/handler-factory.js.map +1 -1
- package/dist/api/handlers.d.ts +5 -3
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +54 -13068
- package/dist/api/handlers.js.map +1 -1
- package/dist/api/route-helpers.d.ts +320 -0
- package/dist/api/route-helpers.d.ts.map +1 -0
- package/dist/api/route-helpers.js +1307 -0
- package/dist/api/route-helpers.js.map +1 -0
- package/dist/api/routes/ai-seo.d.ts +3 -0
- package/dist/api/routes/ai-seo.d.ts.map +1 -0
- package/dist/api/routes/ai-seo.js +377 -0
- package/dist/api/routes/ai-seo.js.map +1 -0
- package/dist/api/routes/ai-settings.d.ts +3 -0
- package/dist/api/routes/ai-settings.d.ts.map +1 -0
- package/dist/api/routes/ai-settings.js +764 -0
- package/dist/api/routes/ai-settings.js.map +1 -0
- package/dist/api/routes/auth.d.ts +3 -0
- package/dist/api/routes/auth.d.ts.map +1 -0
- package/dist/api/routes/auth.js +787 -0
- package/dist/api/routes/auth.js.map +1 -0
- package/dist/api/routes/collab.d.ts +3 -0
- package/dist/api/routes/collab.d.ts.map +1 -0
- package/dist/api/routes/collab.js +426 -0
- package/dist/api/routes/collab.js.map +1 -0
- package/dist/api/routes/dashboard.d.ts +7 -0
- package/dist/api/routes/dashboard.d.ts.map +1 -0
- package/dist/api/routes/dashboard.js +540 -0
- package/dist/api/routes/dashboard.js.map +1 -0
- package/dist/api/routes/documents.d.ts +3 -0
- package/dist/api/routes/documents.d.ts.map +1 -0
- package/dist/api/routes/documents.js +180 -0
- package/dist/api/routes/documents.js.map +1 -0
- package/dist/api/routes/folders.d.ts +3 -0
- package/dist/api/routes/folders.d.ts.map +1 -0
- package/dist/api/routes/folders.js +195 -0
- package/dist/api/routes/folders.js.map +1 -0
- package/dist/api/routes/forms.d.ts +3 -0
- package/dist/api/routes/forms.d.ts.map +1 -0
- package/dist/api/routes/forms.js +661 -0
- package/dist/api/routes/forms.js.map +1 -0
- package/dist/api/routes/globals.d.ts +3 -0
- package/dist/api/routes/globals.d.ts.map +1 -0
- package/dist/api/routes/globals.js +131 -0
- package/dist/api/routes/globals.js.map +1 -0
- package/dist/api/routes/media.d.ts +3 -0
- package/dist/api/routes/media.d.ts.map +1 -0
- package/dist/api/routes/media.js +521 -0
- package/dist/api/routes/media.js.map +1 -0
- package/dist/api/routes/meta.d.ts +3 -0
- package/dist/api/routes/meta.d.ts.map +1 -0
- package/dist/api/routes/meta.js +96 -0
- package/dist/api/routes/meta.js.map +1 -0
- package/dist/api/routes/page-templates.d.ts +3 -0
- package/dist/api/routes/page-templates.d.ts.map +1 -0
- package/dist/api/routes/page-templates.js +265 -0
- package/dist/api/routes/page-templates.js.map +1 -0
- package/dist/api/routes/presence.d.ts +3 -0
- package/dist/api/routes/presence.d.ts.map +1 -0
- package/dist/api/routes/presence.js +35 -0
- package/dist/api/routes/presence.js.map +1 -0
- package/dist/api/routes/preview.d.ts +3 -0
- package/dist/api/routes/preview.d.ts.map +1 -0
- package/dist/api/routes/preview.js +111 -0
- package/dist/api/routes/preview.js.map +1 -0
- package/dist/api/routes/redirects.d.ts +3 -0
- package/dist/api/routes/redirects.d.ts.map +1 -0
- package/dist/api/routes/redirects.js +790 -0
- package/dist/api/routes/redirects.js.map +1 -0
- package/dist/api/routes/saved-sections.d.ts +3 -0
- package/dist/api/routes/saved-sections.d.ts.map +1 -0
- package/dist/api/routes/saved-sections.js +1036 -0
- package/dist/api/routes/saved-sections.js.map +1 -0
- package/dist/api/routes/scheduling.d.ts +3 -0
- package/dist/api/routes/scheduling.d.ts.map +1 -0
- package/dist/api/routes/scheduling.js +373 -0
- package/dist/api/routes/scheduling.js.map +1 -0
- package/dist/api/routes/sections.d.ts +3 -0
- package/dist/api/routes/sections.d.ts.map +1 -0
- package/dist/api/routes/sections.js +500 -0
- package/dist/api/routes/sections.js.map +1 -0
- package/dist/api/routes/security-center.d.ts +3 -0
- package/dist/api/routes/security-center.d.ts.map +1 -0
- package/dist/api/routes/security-center.js +71 -0
- package/dist/api/routes/security-center.js.map +1 -0
- package/dist/api/routes/seo-public.d.ts +14 -0
- package/dist/api/routes/seo-public.d.ts.map +1 -0
- package/dist/api/routes/seo-public.js +930 -0
- package/dist/api/routes/seo-public.js.map +1 -0
- package/dist/api/routes/seo.d.ts +8 -0
- package/dist/api/routes/seo.d.ts.map +1 -0
- package/dist/api/routes/seo.js +848 -0
- package/dist/api/routes/seo.js.map +1 -0
- package/dist/api/routes/system.d.ts +3 -0
- package/dist/api/routes/system.d.ts.map +1 -0
- package/dist/api/routes/system.js +340 -0
- package/dist/api/routes/system.js.map +1 -0
- package/dist/api/routes/url-resolution.d.ts +3 -0
- package/dist/api/routes/url-resolution.d.ts.map +1 -0
- package/dist/api/routes/url-resolution.js +457 -0
- package/dist/api/routes/url-resolution.js.map +1 -0
- package/dist/api/routes/users.d.ts +3 -0
- package/dist/api/routes/users.d.ts.map +1 -0
- package/dist/api/routes/users.js +1162 -0
- package/dist/api/routes/users.js.map +1 -0
- package/dist/api/routes/webhooks.d.ts +3 -0
- package/dist/api/routes/webhooks.d.ts.map +1 -0
- package/dist/api/routes/webhooks.js +338 -0
- package/dist/api/routes/webhooks.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { verifyPassword, hashPassword, needsRehash, compareToDummyHash, } from '../../auth/password.js';
|
|
2
|
+
import { createSession, revokeSession } from '../../auth/session.js';
|
|
3
|
+
import { createPasswordReset, executePasswordReset } from '../../auth/reset.js';
|
|
4
|
+
import { getDB } from '../../db.js';
|
|
5
|
+
import { generateCodeVerifier, generateCodeChallenge, generateState, generateOAuthNonce, getAuthorizationUrl, handleOAuthCallback, } from '../../auth/oauth.js';
|
|
6
|
+
import { createMfaPendingToken, verifyMfaPendingToken, computeRequestFingerprint, } from '../../auth/mfa-pending.js';
|
|
7
|
+
import { generateToken as generateCsrfToken } from '../../security/csrf.js';
|
|
8
|
+
import { logEvent } from '../../security/audit.js';
|
|
9
|
+
import { verifyCaptcha, getCaptchaConfig } from '../../security/captcha.js';
|
|
10
|
+
import { encryptSecret, decryptSecret, encryptStringArray, decryptStringArray, } from '../../security/secret-storage.js';
|
|
11
|
+
import { getClientIp, isResolvedIp } from '../../security/client-ip.js';
|
|
12
|
+
import { getActuateConfig } from '../../config/runtime.js';
|
|
13
|
+
import { guardedDb } from '../guarded-db.js';
|
|
14
|
+
import { SECURITY_HEADERS, json, errorResponse, internalError, hasModel, modelNotAvailable, getSessionSecret, parseCookieHeader, requirePasswordReauth, requireAuth, loginLimiter, totpLimiter, checkRateLimitAsync, clientIp, enforceSessionLimitsForUser, getAdminPath, } from '../route-helpers.js';
|
|
15
|
+
export function registerAuthRoutes(router) {
|
|
16
|
+
const db = guardedDb;
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Auth routes
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
router.post('/auth/login', async (request) => {
|
|
21
|
+
try {
|
|
22
|
+
const body = (await request.json());
|
|
23
|
+
const { email, password } = body;
|
|
24
|
+
if (!email || !password) {
|
|
25
|
+
return errorResponse('Email and password are required', 400);
|
|
26
|
+
}
|
|
27
|
+
const ip = clientIp(request);
|
|
28
|
+
const userAgent = request.headers.get('user-agent');
|
|
29
|
+
// Bucket by IP when we have a trustworthy one; otherwise fall back to a
|
|
30
|
+
// global bucket. We deliberately avoid keying on the email alone — that
|
|
31
|
+
// would let attackers lock out legitimate users by spamming logins.
|
|
32
|
+
const rateLimitKey = isResolvedIp(ip) ? `login:${ip}` : 'login:unknown';
|
|
33
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
34
|
+
return errorResponse('Too many login attempts. Please try again later.', 429);
|
|
35
|
+
}
|
|
36
|
+
const captchaConfig = getCaptchaConfig();
|
|
37
|
+
if (captchaConfig.provider !== 'none') {
|
|
38
|
+
const captchaResult = await verifyCaptcha(body.captchaToken ?? '', captchaConfig, ip);
|
|
39
|
+
if (!captchaResult.success) {
|
|
40
|
+
return errorResponse('CAPTCHA verification failed. Please try again.', 403);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const d = db();
|
|
44
|
+
if (!hasModel(d, 'user'))
|
|
45
|
+
return modelNotAvailable('user');
|
|
46
|
+
const user = await d.user.findFirst({
|
|
47
|
+
where: { email: email.toLowerCase().trim() },
|
|
48
|
+
});
|
|
49
|
+
// User-enumeration timing defense (H5): when the email doesn't exist,
|
|
50
|
+
// OR when the account is OAuth-only (no passwordHash), still spend
|
|
51
|
+
// the same ~100-200ms running PBKDF2 against a dummy hash before
|
|
52
|
+
// returning the error. Without this, attackers can distinguish
|
|
53
|
+
// "no such user" / "OAuth-only" / "wrong password" by the response
|
|
54
|
+
// delta, enabling targeted phishing & credential stuffing.
|
|
55
|
+
if (!user) {
|
|
56
|
+
await compareToDummyHash(password);
|
|
57
|
+
return errorResponse('Invalid email or password', 401);
|
|
58
|
+
}
|
|
59
|
+
if (!user.passwordHash) {
|
|
60
|
+
await compareToDummyHash(password);
|
|
61
|
+
return errorResponse('This account uses social login. Please sign in with your OAuth provider.', 400);
|
|
62
|
+
}
|
|
63
|
+
const passwordValid = await verifyPassword(password, user.passwordHash);
|
|
64
|
+
if (!passwordValid) {
|
|
65
|
+
await logEvent({
|
|
66
|
+
event: 'login_failed',
|
|
67
|
+
userId: user.id,
|
|
68
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
69
|
+
userAgent: userAgent ?? undefined,
|
|
70
|
+
});
|
|
71
|
+
return errorResponse('Invalid email or password', 401);
|
|
72
|
+
}
|
|
73
|
+
if (!user.isActive) {
|
|
74
|
+
return errorResponse('Account is deactivated', 403);
|
|
75
|
+
}
|
|
76
|
+
// Suspended and locked accounts keep `isActive` true (so they remain
|
|
77
|
+
// distinct lifecycle states with preserved content/history) but must not
|
|
78
|
+
// be able to sign in. Checked here so an admin "Suspend"/"Lock" takes
|
|
79
|
+
// effect on the very next login attempt, not just on session revocation.
|
|
80
|
+
if (user.suspendedAt) {
|
|
81
|
+
return errorResponse('Account is suspended', 403);
|
|
82
|
+
}
|
|
83
|
+
if (user.lockedAt) {
|
|
84
|
+
return errorResponse('Account is locked', 403);
|
|
85
|
+
}
|
|
86
|
+
// H6: opportunistically upgrade stored hashes that use weaker
|
|
87
|
+
// PBKDF2 parameters than the current policy. We have the plaintext
|
|
88
|
+
// here (and we know it's correct) — re-hash and persist. Wrapped in
|
|
89
|
+
// try/catch so a transient DB failure never fails an otherwise-valid
|
|
90
|
+
// login; the user gets in, the upgrade just runs again next time.
|
|
91
|
+
if (needsRehash(user.passwordHash)) {
|
|
92
|
+
try {
|
|
93
|
+
const upgraded = await hashPassword(password);
|
|
94
|
+
await d.user.update({ where: { id: user.id }, data: { passwordHash: upgraded } });
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
console.warn('[actuate][login] password rehash skipped:', err instanceof Error ? err.message : err);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (user.totpEnabled) {
|
|
101
|
+
// Hand back an opaque short-lived token instead of the raw userId.
|
|
102
|
+
// The /auth/totp/login endpoint will verify both this token and a
|
|
103
|
+
// stable browser fingerprint before checking the TOTP code.
|
|
104
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
105
|
+
const mfaPendingToken = await createMfaPendingToken({ userId: user.id, fingerprint }, getSessionSecret());
|
|
106
|
+
return json({ data: { requiresTOTP: true, mfaPendingToken } });
|
|
107
|
+
}
|
|
108
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
109
|
+
const tempSessionId = crypto.randomUUID();
|
|
110
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
111
|
+
await db().session.create({
|
|
112
|
+
data: {
|
|
113
|
+
id: tempSessionId,
|
|
114
|
+
userId: user.id,
|
|
115
|
+
token,
|
|
116
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
117
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
118
|
+
userAgent: userAgent ?? null,
|
|
119
|
+
},
|
|
120
|
+
});
|
|
121
|
+
// Surface whether org-wide MFA is required but this account hasn't
|
|
122
|
+
// enrolled yet (past its grace window). The admin shell uses this to
|
|
123
|
+
// prompt enrollment. Computed best-effort; never blocks login (the
|
|
124
|
+
// account can still set up TOTP from inside the admin).
|
|
125
|
+
let mfaSetupRequired = false;
|
|
126
|
+
try {
|
|
127
|
+
const { getStoredSecuritySettings } = await import('../../security/center/gather.js');
|
|
128
|
+
const sec = await getStoredSecuritySettings(d);
|
|
129
|
+
if (sec.mfa.required && !user.totpEnabled) {
|
|
130
|
+
const graceMs = sec.mfa.gracePeriodDays * 24 * 60 * 60 * 1000;
|
|
131
|
+
const requiredAt = sec.mfa.requiredAt ? Date.parse(sec.mfa.requiredAt) : 0;
|
|
132
|
+
const createdAt = user.createdAt ? new Date(user.createdAt).getTime() : 0;
|
|
133
|
+
mfaSetupRequired = Date.now() >= Math.max(requiredAt || 0, createdAt || 0) + graceMs;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
/* policy unavailable — do not block login */
|
|
138
|
+
}
|
|
139
|
+
const response = json({
|
|
140
|
+
data: {
|
|
141
|
+
token,
|
|
142
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
143
|
+
mfaSetupRequired,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
147
|
+
const sessionCookie = [
|
|
148
|
+
`actuate_session=${token}`,
|
|
149
|
+
'Path=/',
|
|
150
|
+
'HttpOnly',
|
|
151
|
+
'SameSite=Lax',
|
|
152
|
+
'Max-Age=604800',
|
|
153
|
+
];
|
|
154
|
+
if (isProduction)
|
|
155
|
+
sessionCookie.push('Secure');
|
|
156
|
+
const csrfToken = await generateCsrfToken();
|
|
157
|
+
const csrfCookie = [`actuate_csrf=${csrfToken}`, 'Path=/', 'SameSite=Lax', 'Max-Age=86400'];
|
|
158
|
+
if (isProduction)
|
|
159
|
+
csrfCookie.push('Secure');
|
|
160
|
+
response.headers.append('Set-Cookie', sessionCookie.join('; '));
|
|
161
|
+
response.headers.append('Set-Cookie', csrfCookie.join('; '));
|
|
162
|
+
await logEvent({
|
|
163
|
+
event: 'login_success',
|
|
164
|
+
userId: user.id,
|
|
165
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
166
|
+
userAgent: userAgent ?? undefined,
|
|
167
|
+
});
|
|
168
|
+
return response;
|
|
169
|
+
}
|
|
170
|
+
catch (err) {
|
|
171
|
+
return internalError(err, 'login');
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Dev-only auth bypass — `GET /api/cms/dev/login`
|
|
176
|
+
//
|
|
177
|
+
// Triple-gated escape hatch for local development:
|
|
178
|
+
// 1. `ACTUATE_DEV_AUTH_BYPASS=1` must be set in the env, AND
|
|
179
|
+
// 2. `NODE_ENV` must NOT be `production`, AND
|
|
180
|
+
// 3. The request must originate from a loopback address (`127.*`, `::1`,
|
|
181
|
+
// or `localhost`).
|
|
182
|
+
//
|
|
183
|
+
// If any gate fails the route returns 404 — indistinguishable from a route
|
|
184
|
+
// that doesn't exist, so probing prod can't even confirm the feature is
|
|
185
|
+
// compiled in.
|
|
186
|
+
//
|
|
187
|
+
// When all three pass we look up or create a synthetic admin user
|
|
188
|
+
// (`dev-bypass@actuate.local`), mint a real session JWT (so every other
|
|
189
|
+
// route does normal session verification), set the `actuate_session`
|
|
190
|
+
// cookie, and 302 → `/securelogin`. The result is a fully-real session;
|
|
191
|
+
// only the credential check was skipped.
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
const handleDevLogin = async (request) => {
|
|
194
|
+
if (process.env.ACTUATE_DEV_AUTH_BYPASS !== '1')
|
|
195
|
+
return errorResponse('Not found', 404);
|
|
196
|
+
if (process.env.NODE_ENV === 'production')
|
|
197
|
+
return errorResponse('Not found', 404);
|
|
198
|
+
const ip = getClientIp(request);
|
|
199
|
+
const ipIsLoopback = ip === '127.0.0.1' ||
|
|
200
|
+
ip === '::1' ||
|
|
201
|
+
ip === '::ffff:127.0.0.1' ||
|
|
202
|
+
ip === 'localhost' ||
|
|
203
|
+
ip.startsWith('127.');
|
|
204
|
+
// `getClientIp` returns `'unknown'` whenever no trusted proxy header is
|
|
205
|
+
// present, which is the default for `next dev` — there's no proxy in
|
|
206
|
+
// front of the dev server. In that case fall back to the request URL's
|
|
207
|
+
// hostname, which Next.js sets from the actual TCP connection. We only
|
|
208
|
+
// accept the literal loopback hostnames; anything else is rejected.
|
|
209
|
+
let isLoopback = ipIsLoopback;
|
|
210
|
+
if (!isLoopback && (ip === 'unknown' || !ip)) {
|
|
211
|
+
try {
|
|
212
|
+
const host = new URL(request.url).hostname;
|
|
213
|
+
isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
isLoopback = false;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!isLoopback)
|
|
220
|
+
return errorResponse('Not found', 404);
|
|
221
|
+
try {
|
|
222
|
+
const d = getDB();
|
|
223
|
+
if (!hasModel(d, 'user')) {
|
|
224
|
+
return errorResponse('Dev bypass requires the User model — run prisma migrate first.', 500);
|
|
225
|
+
}
|
|
226
|
+
// Use the dedicated dev-bypass user if it already exists; otherwise
|
|
227
|
+
// mint one. The email is deliberately namespaced under `actuate.local`
|
|
228
|
+
// so it can never collide with a real user (which uses a real domain).
|
|
229
|
+
const DEV_BYPASS_EMAIL = 'dev-bypass@actuate.local';
|
|
230
|
+
let user = await d.user.findUnique({ where: { email: DEV_BYPASS_EMAIL } });
|
|
231
|
+
if (!user) {
|
|
232
|
+
try {
|
|
233
|
+
user = await d.user.create({
|
|
234
|
+
data: {
|
|
235
|
+
email: DEV_BYPASS_EMAIL,
|
|
236
|
+
name: 'Dev Bypass',
|
|
237
|
+
role: 'ADMIN',
|
|
238
|
+
// Bcrypt-shaped placeholder. No real password is ever set;
|
|
239
|
+
// the user cannot log in through the normal flow.
|
|
240
|
+
passwordHash: '$2b$10$dev.bypass.user.cannot.login.through.normal.flow',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
console.error('[actuate][dev-bypass] user create failed:', err);
|
|
246
|
+
throw err;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const tempSessionId = crypto.randomUUID();
|
|
250
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
251
|
+
if (hasModel(d, 'session')) {
|
|
252
|
+
await d.session.create({
|
|
253
|
+
data: {
|
|
254
|
+
id: tempSessionId,
|
|
255
|
+
userId: user.id,
|
|
256
|
+
token,
|
|
257
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
258
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
259
|
+
userAgent: request.headers.get('user-agent') ?? null,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const csrfToken = await generateCsrfToken();
|
|
264
|
+
const sessionCookie = [
|
|
265
|
+
`actuate_session=${token}`,
|
|
266
|
+
'Path=/',
|
|
267
|
+
'HttpOnly',
|
|
268
|
+
'SameSite=Lax',
|
|
269
|
+
'Max-Age=604800',
|
|
270
|
+
].join('; ');
|
|
271
|
+
const csrfCookie = [
|
|
272
|
+
`actuate_csrf=${csrfToken}`,
|
|
273
|
+
'Path=/',
|
|
274
|
+
'SameSite=Lax',
|
|
275
|
+
'Max-Age=86400',
|
|
276
|
+
].join('; ');
|
|
277
|
+
// Redirect back to the admin so the cookie attaches on the next
|
|
278
|
+
// navigation. `?next=` lets callers steer to a specific deep link.
|
|
279
|
+
const url = new URL(request.url);
|
|
280
|
+
const nextPath = url.searchParams.get('next') ?? '/securelogin';
|
|
281
|
+
const safeNext = nextPath.startsWith('/') ? nextPath : '/securelogin';
|
|
282
|
+
console.warn(`[actuate][dev-bypass] Issued admin session for ${user.email} (${user.id}) — DO NOT USE IN PRODUCTION.`);
|
|
283
|
+
const response = new Response(null, {
|
|
284
|
+
status: 302,
|
|
285
|
+
headers: {
|
|
286
|
+
Location: safeNext,
|
|
287
|
+
...SECURITY_HEADERS,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
response.headers.append('Set-Cookie', sessionCookie);
|
|
291
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
292
|
+
return response;
|
|
293
|
+
}
|
|
294
|
+
catch (err) {
|
|
295
|
+
return internalError(err, 'dev login');
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
router.get('/dev/login', handleDevLogin);
|
|
299
|
+
router.post('/dev/login', handleDevLogin);
|
|
300
|
+
router.post('/auth/logout', async (request) => {
|
|
301
|
+
try {
|
|
302
|
+
const auth = await requireAuth(request);
|
|
303
|
+
if (auth.error)
|
|
304
|
+
return auth.error;
|
|
305
|
+
await revokeSession(auth.session.sessionId, db());
|
|
306
|
+
const ip = clientIp(request);
|
|
307
|
+
await logEvent({
|
|
308
|
+
event: 'logout',
|
|
309
|
+
userId: auth.session.userId,
|
|
310
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
311
|
+
});
|
|
312
|
+
const response = json({ data: { success: true } });
|
|
313
|
+
response.headers.set('Set-Cookie', 'actuate_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
314
|
+
return response;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return internalError(err, 'logout');
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
router.post('/auth/forgot-password', async (request) => {
|
|
321
|
+
try {
|
|
322
|
+
const body = (await request.json());
|
|
323
|
+
const { email } = body;
|
|
324
|
+
if (!email) {
|
|
325
|
+
return errorResponse('Email is required', 400);
|
|
326
|
+
}
|
|
327
|
+
const ip = clientIp(request);
|
|
328
|
+
const rateLimitKey = isResolvedIp(ip) ? `forgot:${ip}` : 'forgot:unknown';
|
|
329
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
330
|
+
return errorResponse('Too many requests. Please try again later.', 429);
|
|
331
|
+
}
|
|
332
|
+
const d = db();
|
|
333
|
+
if (!hasModel(d, 'user'))
|
|
334
|
+
return modelNotAvailable('user');
|
|
335
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
336
|
+
const cmsConfig = getActuateConfig();
|
|
337
|
+
await createPasswordReset(d, email.toLowerCase().trim(), {
|
|
338
|
+
siteUrl,
|
|
339
|
+
adminPath: cmsConfig?.admin?.path ?? '/admin',
|
|
340
|
+
platform: cmsConfig?.platform,
|
|
341
|
+
});
|
|
342
|
+
await logEvent({
|
|
343
|
+
event: 'password_reset_request',
|
|
344
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
345
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
346
|
+
details: { email: email.toLowerCase().trim() },
|
|
347
|
+
});
|
|
348
|
+
return json({ data: { success: true } });
|
|
349
|
+
}
|
|
350
|
+
catch (err) {
|
|
351
|
+
return internalError(err, 'forgot-password');
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
router.post('/auth/reset-password', async (request) => {
|
|
355
|
+
try {
|
|
356
|
+
const body = (await request.json());
|
|
357
|
+
const { token, password } = body;
|
|
358
|
+
if (!token || !password) {
|
|
359
|
+
return errorResponse('Token and new password are required', 400);
|
|
360
|
+
}
|
|
361
|
+
if (password.length < 8) {
|
|
362
|
+
return errorResponse('Password must be at least 8 characters', 400);
|
|
363
|
+
}
|
|
364
|
+
const ip = clientIp(request);
|
|
365
|
+
const rateLimitKey = isResolvedIp(ip) ? `reset:${ip}` : 'reset:unknown';
|
|
366
|
+
if (!(await checkRateLimitAsync(loginLimiter, rateLimitKey))) {
|
|
367
|
+
return errorResponse('Too many requests. Please try again later.', 429);
|
|
368
|
+
}
|
|
369
|
+
const d = db();
|
|
370
|
+
if (!hasModel(d, 'user'))
|
|
371
|
+
return modelNotAvailable('user');
|
|
372
|
+
const result = await executePasswordReset(d, token, password);
|
|
373
|
+
if (!result.success) {
|
|
374
|
+
return errorResponse(result.error ?? 'Password reset failed', 400);
|
|
375
|
+
}
|
|
376
|
+
await logEvent({
|
|
377
|
+
event: 'password_reset_complete',
|
|
378
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
379
|
+
userAgent: request.headers.get('user-agent') ?? undefined,
|
|
380
|
+
});
|
|
381
|
+
return json({ data: { success: true } });
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
return internalError(err, 'reset-password');
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
router.get('/auth/me', async (request) => {
|
|
388
|
+
try {
|
|
389
|
+
const auth = await requireAuth(request);
|
|
390
|
+
if (auth.error)
|
|
391
|
+
return auth.error;
|
|
392
|
+
const user = await db().user.findUnique({
|
|
393
|
+
where: { id: auth.session.userId },
|
|
394
|
+
select: {
|
|
395
|
+
id: true,
|
|
396
|
+
email: true,
|
|
397
|
+
name: true,
|
|
398
|
+
role: true,
|
|
399
|
+
isActive: true,
|
|
400
|
+
totpEnabled: true,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
if (!user) {
|
|
404
|
+
return errorResponse('User not found', 404);
|
|
405
|
+
}
|
|
406
|
+
const response = json({ data: user });
|
|
407
|
+
// Bootstrap (or refresh) the double-submit CSRF cookie. Without this
|
|
408
|
+
// the admin app's first non-GET after a hard reload races to populate
|
|
409
|
+
// it; some users hit "Invalid CSRF token" on the very first save.
|
|
410
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
411
|
+
if (!cookies['actuate_csrf']) {
|
|
412
|
+
const csrfToken = await generateCsrfToken();
|
|
413
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
414
|
+
const csrfCookie = [
|
|
415
|
+
`actuate_csrf=${csrfToken}`,
|
|
416
|
+
'Path=/',
|
|
417
|
+
'SameSite=Lax',
|
|
418
|
+
'Max-Age=86400',
|
|
419
|
+
...(isProduction ? ['Secure'] : []),
|
|
420
|
+
].join('; ');
|
|
421
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
422
|
+
}
|
|
423
|
+
return response;
|
|
424
|
+
}
|
|
425
|
+
catch (err) {
|
|
426
|
+
return internalError(err, 'auth/me');
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
// ---------------------------------------------------------------------------
|
|
430
|
+
// TOTP routes
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
router.post('/auth/totp/setup', async (request) => {
|
|
433
|
+
try {
|
|
434
|
+
const auth = await requireAuth(request);
|
|
435
|
+
if (auth.error)
|
|
436
|
+
return auth.error;
|
|
437
|
+
// Sensitive operation — require recent password reauth so a stolen
|
|
438
|
+
// session can't silently rotate someone's TOTP secret.
|
|
439
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
440
|
+
if (reauthErr)
|
|
441
|
+
return reauthErr;
|
|
442
|
+
const { generateTOTPSecret, generateTOTPUri, generateBackupCodes } = await import('../../auth/totp.js');
|
|
443
|
+
const user = await db().user.findUnique({
|
|
444
|
+
where: { id: auth.session.userId },
|
|
445
|
+
select: { email: true, totpEnabled: true },
|
|
446
|
+
});
|
|
447
|
+
if (!user)
|
|
448
|
+
return errorResponse('User not found', 404);
|
|
449
|
+
if (user.totpEnabled)
|
|
450
|
+
return errorResponse('TOTP already enabled', 400);
|
|
451
|
+
const secret = generateTOTPSecret();
|
|
452
|
+
const uri = generateTOTPUri(secret, user.email);
|
|
453
|
+
const backups = generateBackupCodes();
|
|
454
|
+
// Persist encrypted; the plaintext is only returned to the user once
|
|
455
|
+
// (so they can scan the QR / save backup codes).
|
|
456
|
+
const encryptedSecret = await encryptSecret(secret);
|
|
457
|
+
const encryptedBackups = await encryptStringArray(backups);
|
|
458
|
+
await db().user.update({
|
|
459
|
+
where: { id: auth.session.userId },
|
|
460
|
+
data: { totpSecret: encryptedSecret, backupCodes: encryptedBackups },
|
|
461
|
+
});
|
|
462
|
+
return json({ data: { secret, uri, backupCodes: backups } });
|
|
463
|
+
}
|
|
464
|
+
catch (err) {
|
|
465
|
+
return internalError(err);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
router.post('/auth/totp/verify', async (request) => {
|
|
469
|
+
try {
|
|
470
|
+
const auth = await requireAuth(request);
|
|
471
|
+
if (auth.error)
|
|
472
|
+
return auth.error;
|
|
473
|
+
const body = (await request.json());
|
|
474
|
+
if (!body.code)
|
|
475
|
+
return errorResponse('Code is required', 400);
|
|
476
|
+
const { verifyTOTP } = await import('../../auth/totp.js');
|
|
477
|
+
const user = await db().user.findUnique({
|
|
478
|
+
where: { id: auth.session.userId },
|
|
479
|
+
select: { totpSecret: true },
|
|
480
|
+
});
|
|
481
|
+
if (!user?.totpSecret)
|
|
482
|
+
return errorResponse('TOTP not set up', 400);
|
|
483
|
+
const secret = await decryptSecret(user.totpSecret);
|
|
484
|
+
const valid = verifyTOTP(body.code, secret);
|
|
485
|
+
if (!valid)
|
|
486
|
+
return errorResponse('Invalid code', 400);
|
|
487
|
+
await db().user.update({ where: { id: auth.session.userId }, data: { totpEnabled: true } });
|
|
488
|
+
await logEvent({ event: 'totp_enabled', userId: auth.session.userId });
|
|
489
|
+
return json({ data: { enabled: true } });
|
|
490
|
+
}
|
|
491
|
+
catch (err) {
|
|
492
|
+
return internalError(err);
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
router.post('/auth/totp/disable', async (request) => {
|
|
496
|
+
try {
|
|
497
|
+
const auth = await requireAuth(request);
|
|
498
|
+
if (auth.error)
|
|
499
|
+
return auth.error;
|
|
500
|
+
// Disabling MFA is a high-impact security change. Require recent password
|
|
501
|
+
// reauth and revoke every other session so a compromised cookie loses
|
|
502
|
+
// access immediately after MFA goes away.
|
|
503
|
+
const reauthErr = await requirePasswordReauth(request, auth.session.userId);
|
|
504
|
+
if (reauthErr)
|
|
505
|
+
return reauthErr;
|
|
506
|
+
const d = db();
|
|
507
|
+
await d.user.update({
|
|
508
|
+
where: { id: auth.session.userId },
|
|
509
|
+
data: { totpEnabled: false, totpSecret: null, backupCodes: null },
|
|
510
|
+
});
|
|
511
|
+
if (hasModel(d, 'session')) {
|
|
512
|
+
await d.session.updateMany({
|
|
513
|
+
where: {
|
|
514
|
+
userId: auth.session.userId,
|
|
515
|
+
id: { not: auth.session.sessionId },
|
|
516
|
+
revokedAt: null,
|
|
517
|
+
},
|
|
518
|
+
data: { revokedAt: new Date() },
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
await logEvent({ event: 'totp_disabled', userId: auth.session.userId });
|
|
522
|
+
return json({ data: { enabled: false } });
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
return internalError(err);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
router.post('/auth/totp/login', async (request) => {
|
|
529
|
+
try {
|
|
530
|
+
const body = (await request.json());
|
|
531
|
+
if (!body.mfaPendingToken || !body.code) {
|
|
532
|
+
return errorResponse('mfaPendingToken and code are required', 400);
|
|
533
|
+
}
|
|
534
|
+
const ip = clientIp(request);
|
|
535
|
+
const userAgent = request.headers.get('user-agent');
|
|
536
|
+
// Per-IP and per-token rate limits. The per-token limit is the actual
|
|
537
|
+
// brute-force defence: even with IP rotation, an attacker has to obtain
|
|
538
|
+
// a fresh mfaPendingToken (which requires the password) for every
|
|
539
|
+
// window. The per-IP limit guards the IP-aware case and limits log noise.
|
|
540
|
+
const ipBucket = isResolvedIp(ip) ? `totp-ip:${ip}` : 'totp-ip:unknown';
|
|
541
|
+
if (!(await checkRateLimitAsync(totpLimiter, ipBucket))) {
|
|
542
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
543
|
+
}
|
|
544
|
+
let pending;
|
|
545
|
+
try {
|
|
546
|
+
pending = await verifyMfaPendingToken(body.mfaPendingToken, getSessionSecret());
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
return errorResponse('Session expired. Please sign in again.', 401);
|
|
550
|
+
}
|
|
551
|
+
// Validate the request comes from the same browser that completed the
|
|
552
|
+
// password step. This frustrates attempts to ship a captured pending
|
|
553
|
+
// token to a different host.
|
|
554
|
+
const fingerprint = await computeRequestFingerprint(ip, userAgent);
|
|
555
|
+
if (fingerprint !== pending.fingerprint) {
|
|
556
|
+
return errorResponse('Session fingerprint mismatch. Please sign in again.', 401);
|
|
557
|
+
}
|
|
558
|
+
// Per-userId bucket is what actually caps brute-force. With the default
|
|
559
|
+
// 10 attempts / 15 min, an attacker would need ~190 years to cover the
|
|
560
|
+
// 1M code space even with unbounded IPs.
|
|
561
|
+
const userBucket = `totp-user:${pending.userId}`;
|
|
562
|
+
if (!(await checkRateLimitAsync(totpLimiter, userBucket))) {
|
|
563
|
+
return errorResponse('Too many TOTP attempts. Please try again later.', 429);
|
|
564
|
+
}
|
|
565
|
+
const { verifyTOTP } = await import('../../auth/totp.js');
|
|
566
|
+
const user = await db().user.findUnique({
|
|
567
|
+
where: { id: pending.userId },
|
|
568
|
+
select: {
|
|
569
|
+
id: true,
|
|
570
|
+
email: true,
|
|
571
|
+
name: true,
|
|
572
|
+
role: true,
|
|
573
|
+
totpSecret: true,
|
|
574
|
+
totpEnabled: true,
|
|
575
|
+
backupCodes: true,
|
|
576
|
+
isActive: true,
|
|
577
|
+
},
|
|
578
|
+
});
|
|
579
|
+
if (!user || !user.isActive || !user.totpEnabled || !user.totpSecret) {
|
|
580
|
+
return errorResponse('Invalid request', 400);
|
|
581
|
+
}
|
|
582
|
+
const decryptedSecret = await decryptSecret(user.totpSecret);
|
|
583
|
+
let valid = verifyTOTP(body.code, decryptedSecret);
|
|
584
|
+
let usedBackupCode = false;
|
|
585
|
+
// Backup-code fallback. A user who has lost their authenticator can sign
|
|
586
|
+
// in with one of the one-time recovery codes issued at enrollment. Each
|
|
587
|
+
// code is single-use: on a match we remove it and re-persist the
|
|
588
|
+
// remaining codes so it can never be replayed. We compare against every
|
|
589
|
+
// stored code in constant time to avoid leaking which/whether one matched.
|
|
590
|
+
const storedBackups = Array.isArray(user.backupCodes) ? user.backupCodes : [];
|
|
591
|
+
if (!valid && storedBackups.length > 0) {
|
|
592
|
+
try {
|
|
593
|
+
const { findBackupCodeMatch } = await import('../../auth/totp.js');
|
|
594
|
+
const codes = await decryptStringArray(storedBackups);
|
|
595
|
+
const matchIdx = findBackupCodeMatch(codes, body.code);
|
|
596
|
+
if (matchIdx !== -1) {
|
|
597
|
+
valid = true;
|
|
598
|
+
usedBackupCode = true;
|
|
599
|
+
const remaining = codes.filter((_, i) => i !== matchIdx);
|
|
600
|
+
await db().user.update({
|
|
601
|
+
where: { id: user.id },
|
|
602
|
+
data: { backupCodes: await encryptStringArray(remaining) },
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
catch {
|
|
607
|
+
// Decryption failure (e.g. the encryption key was rotated) — treat
|
|
608
|
+
// as no match rather than throwing a 500 at the login screen.
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
if (!valid) {
|
|
612
|
+
await logEvent({
|
|
613
|
+
event: 'login_failed',
|
|
614
|
+
userId: user.id,
|
|
615
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
616
|
+
userAgent: userAgent ?? undefined,
|
|
617
|
+
details: { reason: 'totp_invalid' },
|
|
618
|
+
});
|
|
619
|
+
return errorResponse('Invalid code', 401);
|
|
620
|
+
}
|
|
621
|
+
const d = db();
|
|
622
|
+
await enforceSessionLimitsForUser(d, user.id);
|
|
623
|
+
const tempSessionId = crypto.randomUUID();
|
|
624
|
+
const token = await createSession({ userId: user.id, role: user.role, sessionId: tempSessionId }, { secret: getSessionSecret() });
|
|
625
|
+
await d.session.create({
|
|
626
|
+
data: {
|
|
627
|
+
id: tempSessionId,
|
|
628
|
+
userId: user.id,
|
|
629
|
+
token,
|
|
630
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
|
631
|
+
ipAddress: isResolvedIp(ip) ? ip : null,
|
|
632
|
+
userAgent: userAgent ?? null,
|
|
633
|
+
},
|
|
634
|
+
});
|
|
635
|
+
await logEvent({
|
|
636
|
+
event: 'login_success',
|
|
637
|
+
userId: user.id,
|
|
638
|
+
ipAddress: isResolvedIp(ip) ? ip : undefined,
|
|
639
|
+
userAgent: userAgent ?? undefined,
|
|
640
|
+
details: { mfa: usedBackupCode ? 'backup_code' : 'totp' },
|
|
641
|
+
});
|
|
642
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
643
|
+
const sessionCookie = [
|
|
644
|
+
`actuate_session=${token}`,
|
|
645
|
+
'Path=/',
|
|
646
|
+
'HttpOnly',
|
|
647
|
+
'SameSite=Lax',
|
|
648
|
+
`Max-Age=${7 * 24 * 3600}`,
|
|
649
|
+
...(isProduction ? ['Secure'] : []),
|
|
650
|
+
].join('; ');
|
|
651
|
+
const csrfToken = await generateCsrfToken();
|
|
652
|
+
const csrfCookie = [
|
|
653
|
+
`actuate_csrf=${csrfToken}`,
|
|
654
|
+
'Path=/',
|
|
655
|
+
'SameSite=Lax',
|
|
656
|
+
'Max-Age=86400',
|
|
657
|
+
...(isProduction ? ['Secure'] : []),
|
|
658
|
+
].join('; ');
|
|
659
|
+
const response = new Response(JSON.stringify({
|
|
660
|
+
data: {
|
|
661
|
+
token,
|
|
662
|
+
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
|
663
|
+
},
|
|
664
|
+
}), { status: 200, headers: { ...SECURITY_HEADERS } });
|
|
665
|
+
response.headers.append('Set-Cookie', sessionCookie);
|
|
666
|
+
response.headers.append('Set-Cookie', csrfCookie);
|
|
667
|
+
return response;
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
return internalError(err);
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
// ---------------------------------------------------------------------------
|
|
674
|
+
// OAuth routes
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
router.get('/auth/oauth/:provider', async (request, params) => {
|
|
677
|
+
try {
|
|
678
|
+
const provider = params.provider;
|
|
679
|
+
if (!['google', 'github', 'microsoft'].includes(provider)) {
|
|
680
|
+
return errorResponse('Unsupported OAuth provider', 400);
|
|
681
|
+
}
|
|
682
|
+
const secret = getSessionSecret();
|
|
683
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? new URL(request.url).origin;
|
|
684
|
+
const oauthProviders = {};
|
|
685
|
+
const envPrefix = provider.toUpperCase();
|
|
686
|
+
const clientId = process.env[`OAUTH_${envPrefix}_CLIENT_ID`] ?? '';
|
|
687
|
+
const clientSecret = process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '';
|
|
688
|
+
if (!clientId || !clientSecret) {
|
|
689
|
+
return errorResponse(`OAuth provider "${provider}" is not configured`, 400);
|
|
690
|
+
}
|
|
691
|
+
oauthProviders[provider] = {
|
|
692
|
+
clientId,
|
|
693
|
+
clientSecret,
|
|
694
|
+
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
695
|
+
};
|
|
696
|
+
const codeVerifier = generateCodeVerifier();
|
|
697
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
698
|
+
// Generate a one-time nonce, embed it in the signed state, and also set
|
|
699
|
+
// it on a host-only cookie. The callback will require both to match —
|
|
700
|
+
// this binds the OAuth flow to the browser that started it and prevents
|
|
701
|
+
// an attacker from delivering a state token to a victim's browser.
|
|
702
|
+
const nonce = generateOAuthNonce();
|
|
703
|
+
const state = await generateState(provider, codeVerifier, getAdminPath(), secret, nonce);
|
|
704
|
+
const url = getAuthorizationUrl(provider, oauthProviders[provider], state, codeChallenge);
|
|
705
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
706
|
+
const nonceCookie = [
|
|
707
|
+
`actuate_oauth_nonce=${nonce}`,
|
|
708
|
+
'Path=/',
|
|
709
|
+
'HttpOnly',
|
|
710
|
+
'SameSite=Lax',
|
|
711
|
+
'Max-Age=600',
|
|
712
|
+
...(isProduction ? ['Secure'] : []),
|
|
713
|
+
].join('; ');
|
|
714
|
+
const response = json({ data: { url } });
|
|
715
|
+
response.headers.append('Set-Cookie', nonceCookie);
|
|
716
|
+
return response;
|
|
717
|
+
}
|
|
718
|
+
catch (err) {
|
|
719
|
+
return internalError(err);
|
|
720
|
+
}
|
|
721
|
+
});
|
|
722
|
+
router.get('/auth/oauth/:provider/callback', async (request, params) => {
|
|
723
|
+
try {
|
|
724
|
+
const provider = params.provider;
|
|
725
|
+
if (!['google', 'github', 'microsoft'].includes(provider)) {
|
|
726
|
+
return errorResponse('Unsupported OAuth provider', 400);
|
|
727
|
+
}
|
|
728
|
+
const url = new URL(request.url);
|
|
729
|
+
const code = url.searchParams.get('code');
|
|
730
|
+
const stateToken = url.searchParams.get('state');
|
|
731
|
+
const errorParam = url.searchParams.get('error');
|
|
732
|
+
if (errorParam) {
|
|
733
|
+
const desc = url.searchParams.get('error_description') ?? errorParam;
|
|
734
|
+
return new Response(null, {
|
|
735
|
+
status: 302,
|
|
736
|
+
headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(desc)}` },
|
|
737
|
+
});
|
|
738
|
+
}
|
|
739
|
+
if (!code || !stateToken) {
|
|
740
|
+
return errorResponse('Missing code or state parameter', 400);
|
|
741
|
+
}
|
|
742
|
+
const secret = getSessionSecret();
|
|
743
|
+
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL ?? url.origin;
|
|
744
|
+
const oauthProviders = {};
|
|
745
|
+
const envPrefix = provider.toUpperCase();
|
|
746
|
+
oauthProviders[provider] = {
|
|
747
|
+
clientId: process.env[`OAUTH_${envPrefix}_CLIENT_ID`] ?? '',
|
|
748
|
+
clientSecret: process.env[`OAUTH_${envPrefix}_CLIENT_SECRET`] ?? '',
|
|
749
|
+
redirectUri: `${siteUrl}/api/cms/auth/oauth/${provider}/callback`,
|
|
750
|
+
};
|
|
751
|
+
const cookies = parseCookieHeader(request.headers.get('cookie') ?? '');
|
|
752
|
+
const expectedNonce = cookies['actuate_oauth_nonce'] ?? null;
|
|
753
|
+
const cmsConfig = getActuateConfig();
|
|
754
|
+
const allowSelfSignup = cmsConfig?.auth?.oauth?.allowSelfSignup === true;
|
|
755
|
+
const result = await handleOAuthCallback(provider, code, stateToken, oauthProviders, secret, db(), { expectedNonce, allowSelfSignup });
|
|
756
|
+
const isProduction = process.env.NODE_ENV === 'production';
|
|
757
|
+
const sessionCookieFlags = [
|
|
758
|
+
`actuate_session=${result.token}`,
|
|
759
|
+
'Path=/',
|
|
760
|
+
'HttpOnly',
|
|
761
|
+
'SameSite=Lax',
|
|
762
|
+
'Max-Age=604800',
|
|
763
|
+
];
|
|
764
|
+
if (siteUrl.startsWith('https') || isProduction) {
|
|
765
|
+
sessionCookieFlags.push('Secure');
|
|
766
|
+
}
|
|
767
|
+
const response = new Response(null, {
|
|
768
|
+
status: 302,
|
|
769
|
+
headers: { Location: getAdminPath() },
|
|
770
|
+
});
|
|
771
|
+
response.headers.append('Set-Cookie', sessionCookieFlags.join('; '));
|
|
772
|
+
// Clear the one-time nonce cookie regardless of outcome.
|
|
773
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
774
|
+
return response;
|
|
775
|
+
}
|
|
776
|
+
catch (err) {
|
|
777
|
+
const message = err instanceof Error ? err.message : 'OAuth callback failed';
|
|
778
|
+
const response = new Response(null, {
|
|
779
|
+
status: 302,
|
|
780
|
+
headers: { Location: `${getAdminPath()}?error=${encodeURIComponent(message)}` },
|
|
781
|
+
});
|
|
782
|
+
response.headers.append('Set-Cookie', 'actuate_oauth_nonce=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
|
783
|
+
return response;
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
//# sourceMappingURL=auth.js.map
|