Skip to content

Commit a23123f

Browse files
committed
feat: Part C server auth enforcement - access_level, cookie auth, CORS migration, reCAPTCHA
C1: access_level enforcement via SET TRANSACTION READ ONLY in graphile middleware C2: Cookie-based session auth as fallback when no Bearer token present C3: CORS migration from api_modules to app_auth_settings.allowed_origins with backward compat C4: reCAPTCHA verification middleware for protected mutations (sign-up, password reset)
1 parent 331d894 commit a23123f

8 files changed

Lines changed: 276 additions & 19 deletions

File tree

graphql/server/src/middleware/api.ts

Lines changed: 78 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache';
88

99
import errorPage50x from '../errors/50x';
1010
import errorPage404Message from '../errors/404-message';
11-
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types';
11+
import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types';
1212
import './types';
1313

1414
const log = new Logger('api');
@@ -86,6 +86,26 @@ const RLS_MODULE_SQL = `
8686
LIMIT 1
8787
`;
8888

89+
/**
90+
* Query auth settings from the tenant DB private schema.
91+
* The table name follows the pattern: {privateSchema}.app_settings_auth
92+
* We select only the server-relevant columns.
93+
*/
94+
const AUTH_SETTINGS_SQL = (privateSchema: string) => `
95+
SELECT
96+
allowed_origins,
97+
cookie_secure,
98+
cookie_samesite,
99+
cookie_domain,
100+
cookie_httponly,
101+
cookie_max_age,
102+
cookie_path,
103+
enable_captcha,
104+
captcha_site_key
105+
FROM "${privateSchema}".app_settings_auth
106+
LIMIT 1
107+
`;
108+
89109
// =============================================================================
90110
// Types
91111
// =============================================================================
@@ -111,6 +131,18 @@ interface RlsModuleData {
111131
current_user_agent: string;
112132
}
113133

134+
interface AuthSettingsRow {
135+
allowed_origins: string[] | null;
136+
cookie_secure: boolean;
137+
cookie_samesite: string;
138+
cookie_domain: string | null;
139+
cookie_httponly: boolean;
140+
cookie_max_age: string | null;
141+
cookie_path: string;
142+
enable_captcha: boolean;
143+
captcha_site_key: string | null;
144+
}
145+
114146
interface RlsModuleRow {
115147
data: RlsModuleData | null;
116148
}
@@ -208,7 +240,22 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => {
208240
};
209241
};
210242

211-
const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({
243+
const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => {
244+
if (!row) return undefined;
245+
return {
246+
allowedOrigins: row.allowed_origins,
247+
cookieSecure: row.cookie_secure,
248+
cookieSamesite: row.cookie_samesite,
249+
cookieDomain: row.cookie_domain,
250+
cookieHttponly: row.cookie_httponly,
251+
cookieMaxAge: row.cookie_max_age,
252+
cookiePath: row.cookie_path,
253+
enableCaptcha: row.enable_captcha,
254+
captchaSiteKey: row.captcha_site_key,
255+
};
256+
};
257+
258+
const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({
212259
apiId: row.api_id,
213260
dbname: row.dbname || opts.pg?.database || '',
214261
anonRole: row.anon_role || 'anon',
@@ -219,6 +266,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleR
219266
domains: [],
220267
databaseId: row.database_id,
221268
isPublic: row.is_public,
269+
authSettings: toAuthSettings(authSettingsRow ?? null),
222270
});
223271

224272
const createAdminStructure = (
@@ -278,6 +326,28 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise<RlsModuleRow |
278326
return result.rows[0] ?? null;
279327
};
280328

329+
/**
330+
* Load server-relevant auth settings from the tenant DB private schema.
331+
* Fails gracefully if the table doesn't exist yet (pre-migration).
332+
*/
333+
const queryAuthSettings = async (
334+
opts: ApiOptions,
335+
dbname: string,
336+
rlsModuleRow: RlsModuleRow | null
337+
): Promise<AuthSettingsRow | null> => {
338+
if (!rlsModuleRow?.data?.authenticate_schema) return null;
339+
const privateSchema = rlsModuleRow.data.authenticate_schema;
340+
try {
341+
const tenantPool = getPgPool({ ...opts.pg, database: dbname });
342+
const result = await tenantPool.query<AuthSettingsRow>(AUTH_SETTINGS_SQL(privateSchema));
343+
return result.rows[0] ?? null;
344+
} catch (e: any) {
345+
// Table may not exist yet if the 2FA migration hasn't been applied
346+
log.debug(`[auth-settings] Failed to load auth settings from ${privateSchema}.app_settings_auth: ${e.message}`);
347+
return null;
348+
}
349+
};
350+
281351
// =============================================================================
282352
// Resolution Logic
283353
// =============================================================================
@@ -337,8 +407,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise<ApiStructure |
337407
}
338408

339409
const rlsModule = await queryRlsModule(pool, row.api_id);
340-
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
341-
return toApiStructure(row, opts, rlsModule);
410+
const authSettings = await queryAuthSettings(opts, row.dbname, rlsModule);
411+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
412+
return toApiStructure(row, opts, rlsModule, authSettings);
342413
};
343414

344415
const resolveMetaSchemaHeader = (
@@ -362,8 +433,9 @@ const resolveDomainLookup = async (ctx: ResolveContext): Promise<ApiStructure |
362433
}
363434

364435
const rlsModule = await queryRlsModule(pool, row.api_id);
365-
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}`);
366-
return toApiStructure(row, opts, rlsModule);
436+
const authSettings = await queryAuthSettings(opts, row.dbname, rlsModule);
437+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
438+
return toApiStructure(row, opts, rlsModule, authSettings);
367439
};
368440

369441
const buildDevFallbackError = async (

graphql/server/src/middleware/auth.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,20 @@ import './types'; // for Request type
99
const log = new Logger('auth');
1010
const isDev = () => getNodeEnv() === 'development';
1111

12+
/** Default cookie name for session tokens. */
13+
const SESSION_COOKIE_NAME = 'constructive_session';
14+
15+
/**
16+
* Extract a named cookie value from the raw Cookie header.
17+
* Avoids pulling in cookie-parser as a dependency.
18+
*/
19+
const parseCookieToken = (req: Request, cookieName: string): string | undefined => {
20+
const header = req.headers.cookie;
21+
if (!header) return undefined;
22+
const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`));
23+
return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined;
24+
};
25+
1226
export const createAuthenticateMiddleware = (
1327
opts: PgpmOptions
1428
): RequestHandler => {
@@ -60,8 +74,15 @@ export const createAuthenticateMiddleware = (
6074
`authType=${authType ?? 'none'}, hasToken=${!!authToken}`
6175
);
6276

63-
if (authType?.toLowerCase() === 'bearer' && authToken) {
64-
log.info('[auth] Processing bearer token authentication');
77+
// Resolve the credential: prefer Bearer header, fall back to session cookie
78+
const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME);
79+
const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken)
80+
? authToken
81+
: cookieToken;
82+
const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none');
83+
84+
if (effectiveToken) {
85+
log.info(`[auth] Processing ${tokenSource} authentication`);
6586
const context: Record<string, any> = {
6687
'jwt.claims.ip_address': req.clientIp,
6788
};
@@ -81,7 +102,7 @@ export const createAuthenticateMiddleware = (
81102
client: pool,
82103
context,
83104
query: authQuery,
84-
variables: [authToken],
105+
variables: [effectiveToken],
85106
});
86107

87108
log.info(`[auth] Query result: rowCount=${result?.rowCount}`);
@@ -111,7 +132,7 @@ export const createAuthenticateMiddleware = (
111132
return;
112133
}
113134
} else {
114-
log.info('[auth] No bearer token provided, using anonymous auth');
135+
log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth');
115136
}
116137

117138
req.token = token;
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { Logger } from '@pgpmjs/logger';
2+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
3+
import './types'; // for Request type
4+
5+
const log = new Logger('captcha');
6+
7+
/** Google reCAPTCHA verification endpoint */
8+
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
9+
10+
/**
11+
* Header name the client sends the CAPTCHA response token in.
12+
* Follows the common pattern: X-Captcha-Token.
13+
*/
14+
const CAPTCHA_HEADER = 'x-captcha-token';
15+
16+
/**
17+
* GraphQL mutation names that require CAPTCHA verification when enabled.
18+
* Only sign-up and password-reset are gated; normal sign-in is not.
19+
*/
20+
const CAPTCHA_PROTECTED_OPERATIONS = new Set([
21+
'signUp',
22+
'signUpWithMagicLink',
23+
'signUpWithSms',
24+
'resetPassword',
25+
'requestPasswordReset',
26+
]);
27+
28+
interface RecaptchaResponse {
29+
success: boolean;
30+
'error-codes'?: string[];
31+
}
32+
33+
/**
34+
* Attempt to extract the GraphQL operation name from the request body.
35+
* Works for both JSON and already-parsed bodies.
36+
*/
37+
const getOperationName = (req: Request): string | undefined => {
38+
const body = (req as any).body;
39+
if (!body) return undefined;
40+
// Already parsed (express.json ran first)
41+
if (typeof body === 'object' && body.operationName) {
42+
return body.operationName;
43+
}
44+
return undefined;
45+
};
46+
47+
/**
48+
* Verify a reCAPTCHA token with Google's API.
49+
*/
50+
const verifyToken = async (token: string, secretKey: string): Promise<boolean> => {
51+
try {
52+
const params = new URLSearchParams({ secret: secretKey, response: token });
53+
const res = await fetch(RECAPTCHA_VERIFY_URL, {
54+
method: 'POST',
55+
body: params,
56+
});
57+
const data = (await res.json()) as RecaptchaResponse;
58+
if (!data.success) {
59+
log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`);
60+
}
61+
return data.success;
62+
} catch (e: any) {
63+
log.error('[captcha] Error verifying token:', e.message);
64+
return false;
65+
}
66+
};
67+
68+
/**
69+
* Creates a CAPTCHA verification middleware.
70+
*
71+
* When `enable_captcha` is true in app_auth_settings, this middleware checks
72+
* the X-Captcha-Token header on protected mutations (sign-up, password reset).
73+
* The secret key is read from the RECAPTCHA_SECRET_KEY environment variable
74+
* (the public site key is stored in app_auth_settings for the frontend).
75+
*
76+
* Skips verification when:
77+
* - CAPTCHA is not enabled in auth settings
78+
* - The request is not a protected mutation
79+
* - No secret key is configured server-side
80+
*/
81+
export const createCaptchaMiddleware = (): RequestHandler => {
82+
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
83+
const authSettings = req.api?.authSettings;
84+
85+
// Skip if CAPTCHA is not enabled
86+
if (!authSettings?.enableCaptcha) {
87+
return next();
88+
}
89+
90+
// Only gate protected operations
91+
const opName = getOperationName(req);
92+
if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) {
93+
return next();
94+
}
95+
96+
// Secret key must be set server-side (env var, not stored in DB for security)
97+
const secretKey = process.env.RECAPTCHA_SECRET_KEY;
98+
if (!secretKey) {
99+
log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification');
100+
return next();
101+
}
102+
103+
const captchaToken = req.get(CAPTCHA_HEADER);
104+
if (!captchaToken) {
105+
res.status(200).json({
106+
errors: [{
107+
message: 'CAPTCHA verification required',
108+
extensions: { code: 'CAPTCHA_REQUIRED' },
109+
}],
110+
});
111+
return;
112+
}
113+
114+
const valid = await verifyToken(captchaToken, secretKey);
115+
if (!valid) {
116+
res.status(200).json({
117+
errors: [{
118+
message: 'CAPTCHA verification failed',
119+
extensions: { code: 'CAPTCHA_FAILED' },
120+
}],
121+
});
122+
return;
123+
}
124+
125+
log.info(`[captcha] Verified for operation=${opName}`);
126+
next();
127+
};
128+
};

graphql/server/src/middleware/cors.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,11 +33,18 @@ export const cors = (fallbackOrigin?: string): RequestHandler => {
3333

3434
// 2) Per-API allowlist sourced from req.api (if available)
3535
// createApiMiddleware runs before this in server.ts, so req.api should be set
36-
const api = (req as any).api as { apiModules?: any[]; domains?: string[] } | undefined;
36+
const api = (req as any).api as { apiModules?: any[]; domains?: string[]; authSettings?: { allowedOrigins?: string[] | null } } | undefined;
3737
if (api) {
38+
// Preferred: app_auth_settings.allowed_origins (new approach)
39+
const settingsOrigins = api.authSettings?.allowedOrigins || [];
40+
// Legacy: api_modules CORS entries (backward compat)
3841
const corsModules = (api.apiModules || []).filter((m: any) => m.name === 'cors') as { name: 'cors'; data: CorsModuleData }[];
3942
const siteUrls = api.domains || [];
40-
const listOfDomains = corsModules.reduce<string[]>((m, mod) => [...mod.data.urls, ...m], siteUrls);
43+
const listOfDomains = [
44+
...settingsOrigins,
45+
...corsModules.reduce<string[]>((m, mod) => [...mod.data.urls, ...m], []),
46+
...siteUrls,
47+
];
4148

4249
if (origin && listOfDomains.includes(origin)) {
4350
return callback(null, true);

graphql/server/src/middleware/graphile.ts

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,19 @@ const buildPreset = (
182182
}
183183

184184
if (req.token?.user_id) {
185-
return {
186-
pgSettings: {
187-
role: roleName,
188-
'jwt.claims.token_id': req.token.id,
189-
'jwt.claims.user_id': req.token.user_id,
190-
...context,
191-
},
185+
const pgSettings: Record<string, string> = {
186+
role: roleName,
187+
'jwt.claims.token_id': req.token.id,
188+
'jwt.claims.user_id': req.token.user_id,
189+
...context,
192190
};
191+
192+
// Enforce read-only transactions for read_only credentials (API keys, etc.)
193+
if (req.token.access_level === 'read_only') {
194+
pgSettings['default_transaction_read_only'] = 'on';
195+
}
196+
197+
return { pgSettings };
193198
}
194199
}
195200

graphql/server/src/middleware/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { ApiStructure } from '../types';
33
export type ConstructiveAPIToken = {
44
id?: string;
55
user_id?: string;
6+
access_level?: string;
7+
kind?: string;
68
[key: string]: unknown;
79
};
810

graphql/server/src/server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug-
3232
import { debugMemory } from './middleware/observability/debug-memory';
3333
import { localObservabilityOnly } from './middleware/observability/guard';
3434
import { createRequestLogger } from './middleware/observability/request-logger';
35+
import { createCaptchaMiddleware } from './middleware/captcha';
3536
import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload';
3637
import { startDebugSampler } from './diagnostics/debug-sampler';
3738

@@ -158,6 +159,7 @@ class Server {
158159
app.use(api);
159160
app.post('/upload', uploadAuthenticate, ...uploadRoute);
160161
app.use(authenticate);
162+
app.use(createCaptchaMiddleware());
161163
app.use(graphile(effectiveOpts));
162164
app.use(flush);
163165

0 commit comments

Comments
 (0)