thatcher 1.0.66 → 1.0.68
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/lib/config-generator-engine.js +55 -1
- package/src/lib/oauth-state-store.js +39 -0
- package/src/lib/workflow-engine.js +6 -1
- package/src/server/server.js +173 -0
- package/src/app/api/auth/google/callback/route.js +0 -97
- package/src/app/api/auth/google/route.js +0 -61
package/package.json
CHANGED
|
@@ -106,10 +106,64 @@ function withWebhookDefaults(masterConfig) {
|
|
|
106
106
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
+
const LEAD_ENTITY_DEFAULT = {
|
|
110
|
+
label: 'Lead',
|
|
111
|
+
label_plural: 'Leads',
|
|
112
|
+
system_entity: true,
|
|
113
|
+
fields: {
|
|
114
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
115
|
+
company: { type: 'text', label: 'Company' },
|
|
116
|
+
email: { type: 'email', label: 'Email' },
|
|
117
|
+
phone: { type: 'text', label: 'Phone' },
|
|
118
|
+
source: { type: 'text', label: 'Source' },
|
|
119
|
+
status: { type: 'enum', options: ['new', 'contacted', 'qualified', 'disqualified'], default: 'new', label: 'Status' },
|
|
120
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
121
|
+
notes: { type: 'textarea', label: 'Notes' },
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const OPPORTUNITY_PIPELINE_WORKFLOW = {
|
|
126
|
+
state_field: 'stage',
|
|
127
|
+
stages: [
|
|
128
|
+
{ name: 'prospecting', label: 'Prospecting', forward: ['qualification'], backward: [] },
|
|
129
|
+
{ name: 'qualification', label: 'Qualification', forward: ['proposal'], backward: ['prospecting'] },
|
|
130
|
+
{ name: 'proposal', label: 'Proposal', forward: ['negotiation'], backward: ['qualification'] },
|
|
131
|
+
{ name: 'negotiation', label: 'Negotiation', forward: ['won', 'lost'], backward: ['proposal'] },
|
|
132
|
+
{ name: 'won', label: 'Won', forward: [], backward: [] },
|
|
133
|
+
{ name: 'lost', label: 'Lost', forward: [], backward: [] },
|
|
134
|
+
],
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const OPPORTUNITY_ENTITY_DEFAULT = {
|
|
138
|
+
label: 'Opportunity',
|
|
139
|
+
label_plural: 'Opportunities',
|
|
140
|
+
system_entity: true,
|
|
141
|
+
workflow: 'opportunity_pipeline',
|
|
142
|
+
fields: {
|
|
143
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
144
|
+
lead_id: { type: 'ref', ref: 'lead', label: 'Lead' },
|
|
145
|
+
value: { type: 'currency', label: 'Value' },
|
|
146
|
+
stage: { type: 'enum', options: ['prospecting', 'qualification', 'proposal', 'negotiation', 'won', 'lost'], default: 'prospecting', label: 'Stage' },
|
|
147
|
+
expected_close_date: { type: 'date', label: 'Expected Close Date' },
|
|
148
|
+
owner_id: { type: 'ref', ref: 'user', label: 'Owner' },
|
|
149
|
+
probability: { type: 'number', min: 0, max: 100, label: 'Probability (%)' },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function withCrmDefaults(masterConfig) {
|
|
154
|
+
const entities = { ...(masterConfig.entities || {}) };
|
|
155
|
+
const workflows = { ...(masterConfig.workflows || {}) };
|
|
156
|
+
let changed = false;
|
|
157
|
+
if (!entities.lead) { entities.lead = LEAD_ENTITY_DEFAULT; changed = true; }
|
|
158
|
+
if (!entities.opportunity) { entities.opportunity = OPPORTUNITY_ENTITY_DEFAULT; changed = true; }
|
|
159
|
+
if (!workflows.opportunity_pipeline) { workflows.opportunity_pipeline = OPPORTUNITY_PIPELINE_WORKFLOW; changed = true; }
|
|
160
|
+
return changed ? { ...masterConfig, entities, workflows } : masterConfig;
|
|
161
|
+
}
|
|
162
|
+
|
|
109
163
|
export class ConfigGeneratorEngine {
|
|
110
164
|
constructor(masterConfig) {
|
|
111
165
|
if (!masterConfig) throw new Error('[ConfigGeneratorEngine] masterConfig is required');
|
|
112
|
-
this.masterConfig = deepFreeze(withWebhookDefaults(withMultiTenancyDefaults(masterConfig)));
|
|
166
|
+
this.masterConfig = deepFreeze(withCrmDefaults(withWebhookDefaults(withMultiTenancyDefaults(masterConfig))));
|
|
113
167
|
this.specCache = new LRUCache(100);
|
|
114
168
|
this.debugMode = false;
|
|
115
169
|
this._plugins = new Map();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
|
|
3
|
+
// Short-lived (5 min), single-use state store for OAuth CSRF protection. A
|
|
4
|
+
// long-lived state (e.g. reusing the session TTL, as the orphaned
|
|
5
|
+
// app/api/auth/google route did) widens the CSRF window far past what an
|
|
6
|
+
// authorization-code round trip needs; 5 minutes is generous for a login flow
|
|
7
|
+
// and small for an attacker to exploit.
|
|
8
|
+
const TTL_MS = 5 * 60 * 1000;
|
|
9
|
+
const store = new Map();
|
|
10
|
+
|
|
11
|
+
let sweepHandle = null;
|
|
12
|
+
function ensureSweep() {
|
|
13
|
+
if (sweepHandle) return;
|
|
14
|
+
sweepHandle = setInterval(() => {
|
|
15
|
+
const now = Date.now();
|
|
16
|
+
for (const [key, entry] of store) {
|
|
17
|
+
if (entry.expiresAt < now) store.delete(key);
|
|
18
|
+
}
|
|
19
|
+
}, TTL_MS);
|
|
20
|
+
if (sweepHandle.unref) sweepHandle.unref();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createOAuthState(data) {
|
|
24
|
+
ensureSweep();
|
|
25
|
+
const key = crypto.randomBytes(32).toString('hex');
|
|
26
|
+
store.set(key, { data, expiresAt: Date.now() + TTL_MS });
|
|
27
|
+
return key;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Single-use: a matched state is deleted on first read, so a replayed
|
|
31
|
+
// callback URL (e.g. from browser history or a leaked Referer) cannot
|
|
32
|
+
// re-consume the same state a second time.
|
|
33
|
+
export function consumeOAuthState(key) {
|
|
34
|
+
const entry = store.get(key);
|
|
35
|
+
if (!entry) return null;
|
|
36
|
+
store.delete(key);
|
|
37
|
+
if (entry.expiresAt < Date.now()) return null;
|
|
38
|
+
return entry.data;
|
|
39
|
+
}
|
|
@@ -147,7 +147,12 @@ export function getTransitionStatus(record) {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
export async function transition(entityType, entityId, workflowName, toState, user, reason = '') {
|
|
150
|
-
|
|
150
|
+
// get(...,{user}) enforces the same row/org access scoping every other read
|
|
151
|
+
// path applies -- without it, a transition could read and act on a record
|
|
152
|
+
// outside the caller's org/row-access simply because this is a state-machine
|
|
153
|
+
// write rather than a plain field update, the exact bypass class bulk-ops'
|
|
154
|
+
// delete/set_field actions already close via the same call shape.
|
|
155
|
+
const record = await get(entityType, entityId, { user });
|
|
151
156
|
if (!record) throw new AppError('Record not found', 'NOT_FOUND', HTTP.NOT_FOUND);
|
|
152
157
|
|
|
153
158
|
validateTransition(workflowName, record.status || record.stage, toState, user);
|
package/src/server/server.js
CHANGED
|
@@ -119,6 +119,18 @@ export function createServer(options) {
|
|
|
119
119
|
const id = parts[1] || null;
|
|
120
120
|
const action = parts[2] || null;
|
|
121
121
|
|
|
122
|
+
if (req.method === 'GET' && entity === 'auth' && id === 'google' && !action) {
|
|
123
|
+
return await handleOAuthGoogleStart(req, res);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (req.method === 'GET' && entity === 'auth' && id === 'google' && action === 'callback') {
|
|
127
|
+
return await handleOAuthGoogleCallback(req, res);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (req.method === 'POST' && id && action === 'transition') {
|
|
131
|
+
return await handleEntityTransition(req, res, entity, id, thatcher, configEngine);
|
|
132
|
+
}
|
|
133
|
+
|
|
122
134
|
if (req.method === 'POST' && id === 'import' && !action) {
|
|
123
135
|
return await handleCsvImport(req, res, entity, thatcher, configEngine);
|
|
124
136
|
}
|
|
@@ -498,6 +510,61 @@ async function handleBulkOperation(req, res, entityName, thatcher, configEngineA
|
|
|
498
510
|
}
|
|
499
511
|
}
|
|
500
512
|
|
|
513
|
+
async function handleEntityTransition(req, res, entityName, id, thatcher, configEngineArg) {
|
|
514
|
+
const user = await resolveRequestUser(req);
|
|
515
|
+
if (!user) {
|
|
516
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
517
|
+
res.end(JSON.stringify({ error: 'Authentication required' }));
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
522
|
+
if (!configEngine) {
|
|
523
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
524
|
+
configEngine = getConfigEngineSync();
|
|
525
|
+
}
|
|
526
|
+
let spec;
|
|
527
|
+
try {
|
|
528
|
+
spec = configEngine.generateEntitySpec(entityName);
|
|
529
|
+
} catch {
|
|
530
|
+
res.writeHead(404);
|
|
531
|
+
res.end(JSON.stringify({ error: `Entity "${entityName}" not found` }));
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
let body;
|
|
536
|
+
try {
|
|
537
|
+
body = await readBody(req);
|
|
538
|
+
} catch (e) {
|
|
539
|
+
res.writeHead(400);
|
|
540
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
543
|
+
const workflowName = body?.workflow || spec.workflow;
|
|
544
|
+
const toState = body?.toState;
|
|
545
|
+
if (!workflowName || !toState) {
|
|
546
|
+
res.writeHead(400);
|
|
547
|
+
res.end(JSON.stringify({ error: 'workflow and toState required' }));
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
const { requirePermission } = await import('../lib/auth-middleware.js');
|
|
553
|
+
await requirePermission(user, spec, 'edit');
|
|
554
|
+
const { transition } = await import('../lib/workflow-engine.js');
|
|
555
|
+
// transition() itself now reads via get(...,{user}) -- the same
|
|
556
|
+
// row/org-access-scoped path every other read uses -- so a caller
|
|
557
|
+
// cannot drag-drop a record outside their access into a new stage.
|
|
558
|
+
const updated = await transition(entityName, id, workflowName, toState, user, body?.reason || '');
|
|
559
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
560
|
+
res.end(JSON.stringify({ ok: true, data: updated }));
|
|
561
|
+
} catch (err) {
|
|
562
|
+
apiLog.error(err.message);
|
|
563
|
+
res.writeHead(err.status || 400);
|
|
564
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
501
568
|
async function handleCreateEntityTemplate(req, res, thatcher, configEngineArg) {
|
|
502
569
|
const user = await requireAuthedPartner(req, res);
|
|
503
570
|
if (!user) return;
|
|
@@ -1126,6 +1193,112 @@ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
|
|
|
1126
1193
|
}
|
|
1127
1194
|
}
|
|
1128
1195
|
|
|
1196
|
+
function oauthRedirectUri(req) {
|
|
1197
|
+
const protocol = req.headers['x-forwarded-proto'] || 'http';
|
|
1198
|
+
const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3000';
|
|
1199
|
+
return `${protocol}://${host}/api/auth/google/callback`;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
async function handleOAuthGoogleStart(req, res) {
|
|
1203
|
+
const { getGoogle } = await import('../engine.server.js');
|
|
1204
|
+
let google;
|
|
1205
|
+
try {
|
|
1206
|
+
google = getGoogle();
|
|
1207
|
+
} catch (e) {
|
|
1208
|
+
apiLog.error(e.message);
|
|
1209
|
+
google = null;
|
|
1210
|
+
}
|
|
1211
|
+
if (!google) {
|
|
1212
|
+
res.writeHead(302, { Location: '/login?error=oauth_not_configured' });
|
|
1213
|
+
res.end();
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
const { generateState, generateCodeVerifier } = await import('arctic');
|
|
1218
|
+
const { createOAuthState } = await import('../lib/oauth-state-store.js');
|
|
1219
|
+
const state = generateState();
|
|
1220
|
+
const codeVerifier = generateCodeVerifier();
|
|
1221
|
+
const stateKey = createOAuthState({ state, codeVerifier });
|
|
1222
|
+
|
|
1223
|
+
const url = google.createAuthorizationURL(state, codeVerifier, ['profile', 'email']);
|
|
1224
|
+
url.searchParams.set('state', stateKey);
|
|
1225
|
+
|
|
1226
|
+
res.writeHead(302, { Location: url.toString() });
|
|
1227
|
+
res.end();
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
async function handleOAuthGoogleCallback(req, res) {
|
|
1231
|
+
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
1232
|
+
const code = url.searchParams.get('code');
|
|
1233
|
+
const stateKey = url.searchParams.get('state');
|
|
1234
|
+
|
|
1235
|
+
const { consumeOAuthState } = await import('../lib/oauth-state-store.js');
|
|
1236
|
+
// Single-use consume: a matched key is deleted here on first read, so a
|
|
1237
|
+
// replayed callback URL cannot ride the same state twice, and a stateKey
|
|
1238
|
+
// this server never issued (or one already spent) fails the lookup outright.
|
|
1239
|
+
const stored = stateKey ? consumeOAuthState(stateKey) : null;
|
|
1240
|
+
if (!code || !stateKey || !stored) {
|
|
1241
|
+
res.writeHead(302, { Location: '/login?error=state_mismatch' });
|
|
1242
|
+
res.end();
|
|
1243
|
+
return;
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
try {
|
|
1247
|
+
const { getGoogle, createSession } = await import('../engine.server.js');
|
|
1248
|
+
const google = getGoogle();
|
|
1249
|
+
if (!google) throw new Error('OAuth not configured');
|
|
1250
|
+
|
|
1251
|
+
const tokens = await google.validateAuthorizationCode(code, stored.codeVerifier);
|
|
1252
|
+
const accessToken = tokens.accessToken();
|
|
1253
|
+
|
|
1254
|
+
const userInfoRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
|
|
1255
|
+
headers: { Authorization: `Bearer ${accessToken}` },
|
|
1256
|
+
});
|
|
1257
|
+
if (!userInfoRes.ok) throw new Error('Failed to fetch user info');
|
|
1258
|
+
const googleUser = await userInfoRes.json();
|
|
1259
|
+
|
|
1260
|
+
// Never trust an unverified email to link/create a local account -- Google
|
|
1261
|
+
// returns email_verified=false for e.g. an unverified alias, and creating
|
|
1262
|
+
// or matching an account on that claim would let an attacker who controls
|
|
1263
|
+
// an unverified address impersonate a real user's account.
|
|
1264
|
+
if (!googleUser.email || googleUser.email_verified !== true) {
|
|
1265
|
+
res.writeHead(302, { Location: '/login?error=email_not_verified' });
|
|
1266
|
+
res.end();
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
const { getBy, create } = await import('../lib/busybase/store.js');
|
|
1271
|
+
let user = await getBy('user', 'email', googleUser.email);
|
|
1272
|
+
if (!user) {
|
|
1273
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
1274
|
+
const configEngine = getConfigEngineSync();
|
|
1275
|
+
const roles = configEngine.getRoles();
|
|
1276
|
+
const defaultRole = Object.keys(roles)[0] || 'clerk';
|
|
1277
|
+
user = await create('user', {
|
|
1278
|
+
email: googleUser.email,
|
|
1279
|
+
name: googleUser.name || googleUser.email,
|
|
1280
|
+
avatar: googleUser.picture || null,
|
|
1281
|
+
type: 'auditor',
|
|
1282
|
+
role: defaultRole,
|
|
1283
|
+
status: 'active',
|
|
1284
|
+
});
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
const { sessionCookie } = await createSession(user.id);
|
|
1288
|
+
const cookieAttrs = [`Path=${sessionCookie.attributes.path || '/'}`, 'HttpOnly', `SameSite=${sessionCookie.attributes.sameSite || 'Lax'}`];
|
|
1289
|
+
if (sessionCookie.attributes.secure) cookieAttrs.push('Secure');
|
|
1290
|
+
res.writeHead(302, {
|
|
1291
|
+
Location: '/',
|
|
1292
|
+
'Set-Cookie': `${sessionCookie.name}=${sessionCookie.value}; ${cookieAttrs.join('; ')}`,
|
|
1293
|
+
});
|
|
1294
|
+
res.end();
|
|
1295
|
+
} catch (err) {
|
|
1296
|
+
apiLog.error(err.message);
|
|
1297
|
+
res.writeHead(302, { Location: '/login?error=oauth_failed' });
|
|
1298
|
+
res.end();
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1129
1302
|
const UPLOAD_ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'application/json']);
|
|
1130
1303
|
const UPLOAD_MAX_SIZE = 10 * 1024 * 1024;
|
|
1131
1304
|
const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
|
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
import { createLogger } from '@/lib/logger.js';
|
|
2
|
-
import { createSession } from '@/engine.server';
|
|
3
|
-
|
|
4
|
-
const log = createLogger('[OAuth]');
|
|
5
|
-
import { getBy, create } from '@/engine';
|
|
6
|
-
import { Google } from 'arctic';
|
|
7
|
-
import { GOOGLE_APIS } from '@/config/constants';
|
|
8
|
-
import { config } from '@/config';
|
|
9
|
-
import { getConfigEngine } from '@/lib/config-generator-engine';
|
|
10
|
-
import { globalManager } from '@/lib/hot-reload/mutex';
|
|
11
|
-
import { NextResponse } from '@/lib/next-shim';
|
|
12
|
-
import {
|
|
13
|
-
validateOAuthProvider,
|
|
14
|
-
getOAuthCookie,
|
|
15
|
-
deleteOAuthCookie,
|
|
16
|
-
buildOAuthErrorResponse,
|
|
17
|
-
validateOAuthState,
|
|
18
|
-
} from '@/lib/auth-route-helpers';
|
|
19
|
-
|
|
20
|
-
export async function GET(request) {
|
|
21
|
-
const protocol = request.headers['x-forwarded-proto'] || 'http';
|
|
22
|
-
const host = request.headers['x-forwarded-host'] || request.headers.host || 'localhost:3000';
|
|
23
|
-
const redirectUri = `${protocol}://${host}/api/auth/google/callback`;
|
|
24
|
-
|
|
25
|
-
const dynamicGoogle = new Google(
|
|
26
|
-
config.auth.google.clientId,
|
|
27
|
-
config.auth.google.clientSecret,
|
|
28
|
-
redirectUri
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
const { valid, error } = validateOAuthProvider(dynamicGoogle);
|
|
32
|
-
if (!valid) {
|
|
33
|
-
return buildOAuthErrorResponse(error, request);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const url = new URL(request.url);
|
|
37
|
-
const code = url.searchParams.get('code');
|
|
38
|
-
const stateKey = url.searchParams.get('state'); // Google returns the key we sent
|
|
39
|
-
|
|
40
|
-
const storedData = await getOAuthCookie(stateKey);
|
|
41
|
-
const state = storedData?.state;
|
|
42
|
-
const codeVerifier = storedData?.codeVerifier;
|
|
43
|
-
|
|
44
|
-
const stateValidation = validateOAuthState(code, stateKey, state, codeVerifier);
|
|
45
|
-
if (!stateValidation.valid) {
|
|
46
|
-
log.warn('state validation failed:', { error: stateValidation.error });
|
|
47
|
-
return buildOAuthErrorResponse(stateValidation.error, request);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
try {
|
|
51
|
-
const tokens = await dynamicGoogle.validateAuthorizationCode(code, codeVerifier);
|
|
52
|
-
const accessToken = tokens.accessToken;
|
|
53
|
-
|
|
54
|
-
const googleResponse = await fetch(GOOGLE_APIS.oauth2, {
|
|
55
|
-
headers: { Authorization: `Bearer ${accessToken}` },
|
|
56
|
-
});
|
|
57
|
-
|
|
58
|
-
if (!googleResponse.ok) {
|
|
59
|
-
throw new Error('Failed to fetch user info');
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const googleUser = await googleResponse.json();
|
|
63
|
-
|
|
64
|
-
const user = await globalManager.lock('oauth-user-create', async () => {
|
|
65
|
-
let existing = getBy('user', 'email', googleUser.email);
|
|
66
|
-
if (existing) return existing;
|
|
67
|
-
|
|
68
|
-
const engine = await getConfigEngine();
|
|
69
|
-
const roles = engine.getRoles();
|
|
70
|
-
const defaultRole = Object.keys(roles)[0] || 'clerk';
|
|
71
|
-
|
|
72
|
-
return create('user', {
|
|
73
|
-
email: googleUser.email,
|
|
74
|
-
name: googleUser.name,
|
|
75
|
-
avatar: googleUser.picture,
|
|
76
|
-
type: 'auditor',
|
|
77
|
-
role: defaultRole,
|
|
78
|
-
status: 'active',
|
|
79
|
-
});
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
const { sessionCookie } = await createSession(user.id);
|
|
83
|
-
|
|
84
|
-
await deleteOAuthCookie(stateKey);
|
|
85
|
-
|
|
86
|
-
const redirectUrl = new URL('/', request.url);
|
|
87
|
-
const response = NextResponse.redirect(redirectUrl);
|
|
88
|
-
|
|
89
|
-
const cookieValue = `${sessionCookie.value}; Path=${sessionCookie.attributes.path || '/'}; HttpOnly${sessionCookie.attributes.secure ? '; Secure' : ''}; SameSite=${sessionCookie.attributes.sameSite || 'Lax'}`;
|
|
90
|
-
response.headers.set('Set-Cookie', `${sessionCookie.name}=${cookieValue}`);
|
|
91
|
-
|
|
92
|
-
return response;
|
|
93
|
-
} catch (error) {
|
|
94
|
-
log.error('Google OAuth error:', { message: error.message });
|
|
95
|
-
return buildOAuthErrorResponse('oauth_failed', request);
|
|
96
|
-
}
|
|
97
|
-
}
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
import { NextResponse } from '@/lib/next-shim';
|
|
2
|
-
import { createLogger } from '@/lib/logger.js';
|
|
3
|
-
|
|
4
|
-
const log = createLogger('[OAuth]');
|
|
5
|
-
import { google } from '@/engine.server';
|
|
6
|
-
import { Google } from 'arctic';
|
|
7
|
-
import { generateState, generateCodeVerifier } from 'arctic';
|
|
8
|
-
import { globalManager } from '@/lib/hot-reload/mutex';
|
|
9
|
-
import { config } from '@/config';
|
|
10
|
-
import { validateOAuthProvider, setOAuthCookie, buildOAuthErrorResponse } from '@/lib/auth-route-helpers';
|
|
11
|
-
|
|
12
|
-
export async function GET(request) {
|
|
13
|
-
const url = new URL(request.url);
|
|
14
|
-
const isCheck = url.searchParams.get('check') === '1';
|
|
15
|
-
|
|
16
|
-
if (isCheck) {
|
|
17
|
-
const { valid } = validateOAuthProvider(google);
|
|
18
|
-
return new Response(JSON.stringify({ configured: valid }), {
|
|
19
|
-
status: 200,
|
|
20
|
-
headers: { 'Content-Type': 'application/json' }
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const protocol = request.headers['x-forwarded-proto'] || 'http';
|
|
25
|
-
const host = request.headers['x-forwarded-host'] || request.headers.host || 'localhost:3000';
|
|
26
|
-
const redirectUri = `${protocol}://${host}/api/auth/google/callback`;
|
|
27
|
-
|
|
28
|
-
const dynamicGoogle = new Google(
|
|
29
|
-
config.auth.google.clientId,
|
|
30
|
-
config.auth.google.clientSecret,
|
|
31
|
-
redirectUri
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
const { valid, error } = validateOAuthProvider(dynamicGoogle);
|
|
35
|
-
if (!valid) {
|
|
36
|
-
return buildOAuthErrorResponse(error);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
return globalManager.lock('oauth-state-init', async () => {
|
|
40
|
-
const state = generateState();
|
|
41
|
-
const codeVerifier = generateCodeVerifier();
|
|
42
|
-
|
|
43
|
-
const stateKey = await setOAuthCookie('google_oauth_state', { state, codeVerifier });
|
|
44
|
-
|
|
45
|
-
const url = await dynamicGoogle.createAuthorizationURL(stateKey, codeVerifier, {
|
|
46
|
-
scopes: ['profile', 'email'],
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
return NextResponse.redirect(url);
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function HEAD(_request) {
|
|
54
|
-
try {
|
|
55
|
-
const { valid } = validateOAuthProvider(google);
|
|
56
|
-
return new Response(null, { status: valid ? 200 : 503 });
|
|
57
|
-
} catch (error) {
|
|
58
|
-
log.error('HEAD error:', { message: error.message });
|
|
59
|
-
return new Response(null, { status: 500 });
|
|
60
|
-
}
|
|
61
|
-
}
|