thatcher 1.0.65 → 1.0.67

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thatcher",
3
- "version": "1.0.65",
3
+ "version": "1.0.67",
4
4
  "description": "A config-driven application framework for building data-intensive web apps without code.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
+ }
@@ -0,0 +1,37 @@
1
+ // Fixed-window per-key rate limiter, in-memory. Keyed by authenticated user id
2
+ // when available, otherwise by remote IP, so one anonymous IP hammering the
3
+ // API can't exhaust a budget shared with real users behind the same NAT --
4
+ // each authenticated user gets their own independent window.
5
+ const WINDOW_MS = 60 * 1000;
6
+ const DEFAULT_LIMIT = 300;
7
+ const buckets = new Map();
8
+
9
+ let sweepHandle = null;
10
+ function ensureSweep() {
11
+ if (sweepHandle) return;
12
+ sweepHandle = setInterval(() => {
13
+ const now = Date.now();
14
+ for (const [key, bucket] of buckets) {
15
+ if (now - bucket.windowStart > WINDOW_MS) buckets.delete(key);
16
+ }
17
+ }, WINDOW_MS);
18
+ if (sweepHandle.unref) sweepHandle.unref();
19
+ }
20
+
21
+ export function checkRateLimit(key, limit = DEFAULT_LIMIT) {
22
+ ensureSweep();
23
+ const now = Date.now();
24
+ let bucket = buckets.get(key);
25
+ if (!bucket || now - bucket.windowStart >= WINDOW_MS) {
26
+ bucket = { windowStart: now, count: 0 };
27
+ buckets.set(key, bucket);
28
+ }
29
+ bucket.count += 1;
30
+ const remaining = Math.max(0, limit - bucket.count);
31
+ const resetMs = bucket.windowStart + WINDOW_MS - now;
32
+ return { allowed: bucket.count <= limit, remaining, resetMs, limit };
33
+ }
34
+
35
+ export function resetRateLimiter() {
36
+ buckets.clear();
37
+ }
@@ -93,6 +93,20 @@ export function createServer(options) {
93
93
  // API routes
94
94
  if (pathname.startsWith('/api/')) {
95
95
  res.setHeader('Content-Type', 'application/json; charset=utf-8');
96
+
97
+ const { checkRateLimit } = await import('../lib/rate-limiter.js');
98
+ const rateUser = await resolveRequestUser(req);
99
+ const rateKey = rateUser ? `user:${rateUser.id}` : `ip:${req.socket?.remoteAddress || 'unknown'}`;
100
+ const rate = checkRateLimit(rateKey);
101
+ res.setHeader('X-RateLimit-Limit', String(rate.limit));
102
+ res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
103
+ res.setHeader('X-RateLimit-Reset', String(Math.ceil(rate.resetMs / 1000)));
104
+ if (!rate.allowed) {
105
+ res.setHeader('Retry-After', String(Math.ceil(rate.resetMs / 1000)));
106
+ res.writeHead(429);
107
+ res.end(JSON.stringify({ error: 'Rate limit exceeded, try again later' }));
108
+ return;
109
+ }
96
110
  const parts = pathname.slice(5).split('/').filter(Boolean); // remove /api/
97
111
 
98
112
  if (parts.length === 0) {
@@ -105,6 +119,14 @@ export function createServer(options) {
105
119
  const id = parts[1] || null;
106
120
  const action = parts[2] || null;
107
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
+
108
130
  if (req.method === 'POST' && id === 'import' && !action) {
109
131
  return await handleCsvImport(req, res, entity, thatcher, configEngine);
110
132
  }
@@ -1112,6 +1134,112 @@ async function handleCsvImport(req, res, entity, thatcher, configEngineArg) {
1112
1134
  }
1113
1135
  }
1114
1136
 
1137
+ function oauthRedirectUri(req) {
1138
+ const protocol = req.headers['x-forwarded-proto'] || 'http';
1139
+ const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost:3000';
1140
+ return `${protocol}://${host}/api/auth/google/callback`;
1141
+ }
1142
+
1143
+ async function handleOAuthGoogleStart(req, res) {
1144
+ const { getGoogle } = await import('../engine.server.js');
1145
+ let google;
1146
+ try {
1147
+ google = getGoogle();
1148
+ } catch (e) {
1149
+ apiLog.error(e.message);
1150
+ google = null;
1151
+ }
1152
+ if (!google) {
1153
+ res.writeHead(302, { Location: '/login?error=oauth_not_configured' });
1154
+ res.end();
1155
+ return;
1156
+ }
1157
+
1158
+ const { generateState, generateCodeVerifier } = await import('arctic');
1159
+ const { createOAuthState } = await import('../lib/oauth-state-store.js');
1160
+ const state = generateState();
1161
+ const codeVerifier = generateCodeVerifier();
1162
+ const stateKey = createOAuthState({ state, codeVerifier });
1163
+
1164
+ const url = google.createAuthorizationURL(state, codeVerifier, ['profile', 'email']);
1165
+ url.searchParams.set('state', stateKey);
1166
+
1167
+ res.writeHead(302, { Location: url.toString() });
1168
+ res.end();
1169
+ }
1170
+
1171
+ async function handleOAuthGoogleCallback(req, res) {
1172
+ const url = new URL(req.url, `http://${req.headers.host}`);
1173
+ const code = url.searchParams.get('code');
1174
+ const stateKey = url.searchParams.get('state');
1175
+
1176
+ const { consumeOAuthState } = await import('../lib/oauth-state-store.js');
1177
+ // Single-use consume: a matched key is deleted here on first read, so a
1178
+ // replayed callback URL cannot ride the same state twice, and a stateKey
1179
+ // this server never issued (or one already spent) fails the lookup outright.
1180
+ const stored = stateKey ? consumeOAuthState(stateKey) : null;
1181
+ if (!code || !stateKey || !stored) {
1182
+ res.writeHead(302, { Location: '/login?error=state_mismatch' });
1183
+ res.end();
1184
+ return;
1185
+ }
1186
+
1187
+ try {
1188
+ const { getGoogle, createSession } = await import('../engine.server.js');
1189
+ const google = getGoogle();
1190
+ if (!google) throw new Error('OAuth not configured');
1191
+
1192
+ const tokens = await google.validateAuthorizationCode(code, stored.codeVerifier);
1193
+ const accessToken = tokens.accessToken();
1194
+
1195
+ const userInfoRes = await fetch('https://openidconnect.googleapis.com/v1/userinfo', {
1196
+ headers: { Authorization: `Bearer ${accessToken}` },
1197
+ });
1198
+ if (!userInfoRes.ok) throw new Error('Failed to fetch user info');
1199
+ const googleUser = await userInfoRes.json();
1200
+
1201
+ // Never trust an unverified email to link/create a local account -- Google
1202
+ // returns email_verified=false for e.g. an unverified alias, and creating
1203
+ // or matching an account on that claim would let an attacker who controls
1204
+ // an unverified address impersonate a real user's account.
1205
+ if (!googleUser.email || googleUser.email_verified !== true) {
1206
+ res.writeHead(302, { Location: '/login?error=email_not_verified' });
1207
+ res.end();
1208
+ return;
1209
+ }
1210
+
1211
+ const { getBy, create } = await import('../lib/busybase/store.js');
1212
+ let user = await getBy('user', 'email', googleUser.email);
1213
+ if (!user) {
1214
+ const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
1215
+ const configEngine = getConfigEngineSync();
1216
+ const roles = configEngine.getRoles();
1217
+ const defaultRole = Object.keys(roles)[0] || 'clerk';
1218
+ user = await create('user', {
1219
+ email: googleUser.email,
1220
+ name: googleUser.name || googleUser.email,
1221
+ avatar: googleUser.picture || null,
1222
+ type: 'auditor',
1223
+ role: defaultRole,
1224
+ status: 'active',
1225
+ });
1226
+ }
1227
+
1228
+ const { sessionCookie } = await createSession(user.id);
1229
+ const cookieAttrs = [`Path=${sessionCookie.attributes.path || '/'}`, 'HttpOnly', `SameSite=${sessionCookie.attributes.sameSite || 'Lax'}`];
1230
+ if (sessionCookie.attributes.secure) cookieAttrs.push('Secure');
1231
+ res.writeHead(302, {
1232
+ Location: '/',
1233
+ 'Set-Cookie': `${sessionCookie.name}=${sessionCookie.value}; ${cookieAttrs.join('; ')}`,
1234
+ });
1235
+ res.end();
1236
+ } catch (err) {
1237
+ apiLog.error(err.message);
1238
+ res.writeHead(302, { Location: '/login?error=oauth_failed' });
1239
+ res.end();
1240
+ }
1241
+ }
1242
+
1115
1243
  const UPLOAD_ALLOWED_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'application/pdf', 'text/plain', 'text/csv', 'application/json']);
1116
1244
  const UPLOAD_MAX_SIZE = 10 * 1024 * 1024;
1117
1245
  const UPLOAD_DIR = path.join(process.cwd(), 'uploads');
@@ -89,12 +89,12 @@ export function renderAuditDashboard(user, auditData = {}) {
89
89
  const { summary = {}, recentActivity = [] } = auditData;
90
90
  const actRows = recentActivity.slice(0, 20).map(a =>
91
91
  `<tr data-row>
92
- <td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA')}</td>
93
- <td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
94
- <td data-col="entity">${a.entity_type||'-'}</td>
95
- <td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
96
- <td data-col="user">${a.user_name||a.user_id||'-'}</td>
97
- <td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${a.reason||'-'}</td>
92
+ <td data-col="time">${esc(new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA'))}</td>
93
+ <td data-col="action"><span class="pill pill-info">${esc(a.action||'-')}</span></td>
94
+ <td data-col="entity">${esc(a.entity_type||'-')}</td>
95
+ <td data-col="id" style="font-size:12px">${esc(a.entity_id||'-')}</td>
96
+ <td data-col="user">${esc(a.user_name||a.user_id||'-')}</td>
97
+ <td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${esc(a.reason||'-')}</td>
98
98
  </tr>`
99
99
  ).join('') || emptyRow(6, 'No audit records found');
100
100
 
@@ -124,7 +124,7 @@ export function renderAuditDashboard(user, auditData = {}) {
124
124
  export function renderSystemHealth(user, healthData = {}) {
125
125
  const { database = {}, server: srv = {}, entities = {} } = healthData;
126
126
  const entRows = Object.entries(entities).map(([n, c]) =>
127
- `<tr data-row><td data-col="entity">${n}</td><td data-col="count" style="text-align:right">${c}</td></tr>`
127
+ `<tr data-row><td data-col="entity">${esc(n)}</td><td data-col="count" style="text-align:right">${esc(String(c))}</td></tr>`
128
128
  ).join('') || emptyRow(2, 'No data');
129
129
 
130
130
  const statsHtml = `<div class="stats-row">${[
@@ -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
- }