Skip to content

Commit 647b999

Browse files
authored
Merge pull request #991 from constructive-io/devin/1776459081-part-c-server-auth-enforcement
feat: Part C - Server auth enforcement (access_level, cookie auth, reCAPTCHA, JWT claims)
2 parents 1281a53 + 788fdfe commit 647b999

7 files changed

Lines changed: 292 additions & 17 deletions

File tree

graphql/server/src/middleware/api.ts

Lines changed: 95 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,36 @@ const RLS_MODULE_SQL = `
8686
LIMIT 1
8787
`;
8888

89+
/**
90+
* Discover auth settings table location via public metaschema tables.
91+
* Joins sessions_module with metaschema_public.schema to resolve
92+
* the schema name + table name without touching private schemas.
93+
*/
94+
const AUTH_SETTINGS_DISCOVERY_SQL = `
95+
SELECT s.schema_name, sm.auth_settings_table AS table_name
96+
FROM metaschema_modules_public.sessions_module sm
97+
JOIN metaschema_public.schema s ON s.id = sm.schema_id
98+
LIMIT 1
99+
`;
100+
101+
/**
102+
* Query auth settings from the discovered table.
103+
* Schema and table name are resolved dynamically from metaschema modules.
104+
*/
105+
const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => `
106+
SELECT
107+
cookie_secure,
108+
cookie_samesite,
109+
cookie_domain,
110+
cookie_httponly,
111+
cookie_max_age,
112+
cookie_path,
113+
enable_captcha,
114+
captcha_site_key
115+
FROM "${schemaName}"."${tableName}"
116+
LIMIT 1
117+
`;
118+
89119
// =============================================================================
90120
// Types
91121
// =============================================================================
@@ -111,6 +141,17 @@ interface RlsModuleData {
111141
current_user_agent: string;
112142
}
113143

144+
interface AuthSettingsRow {
145+
cookie_secure: boolean;
146+
cookie_samesite: string;
147+
cookie_domain: string | null;
148+
cookie_httponly: boolean;
149+
cookie_max_age: string | null;
150+
cookie_path: string;
151+
enable_captcha: boolean;
152+
captcha_site_key: string | null;
153+
}
154+
114155
interface RlsModuleRow {
115156
data: RlsModuleData | null;
116157
}
@@ -208,7 +249,21 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => {
208249
};
209250
};
210251

211-
const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({
252+
const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => {
253+
if (!row) return undefined;
254+
return {
255+
cookieSecure: row.cookie_secure,
256+
cookieSamesite: row.cookie_samesite,
257+
cookieDomain: row.cookie_domain,
258+
cookieHttponly: row.cookie_httponly,
259+
cookieMaxAge: row.cookie_max_age,
260+
cookiePath: row.cookie_path,
261+
enableCaptcha: row.enable_captcha,
262+
captchaSiteKey: row.captcha_site_key,
263+
};
264+
};
265+
266+
const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({
212267
apiId: row.api_id,
213268
dbname: row.dbname || opts.pg?.database || '',
214269
anonRole: row.anon_role || 'anon',
@@ -219,6 +274,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleR
219274
domains: [],
220275
databaseId: row.database_id,
221276
isPublic: row.is_public,
277+
authSettings: toAuthSettings(authSettingsRow ?? null),
222278
});
223279

224280
const createAdminStructure = (
@@ -278,6 +334,37 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise<RlsModuleRow |
278334
return result.rows[0] ?? null;
279335
};
280336

337+
/**
338+
* Load server-relevant auth settings from the tenant DB.
339+
* Discovers the auth settings table dynamically by joining
340+
* metaschema_modules_public.sessions_module with metaschema_public.schema
341+
* (both public schemas). Fails gracefully if modules or table don't exist yet.
342+
*/
343+
const queryAuthSettings = async (
344+
opts: ApiOptions,
345+
dbname: string
346+
): Promise<AuthSettingsRow | null> => {
347+
try {
348+
const tenantPool = getPgPool({ ...opts.pg, database: dbname });
349+
350+
// Discover the auth settings schema + table name from public metaschema tables
351+
const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_DISCOVERY_SQL);
352+
const resolved = discovery.rows[0];
353+
if (!resolved) {
354+
log.debug('[auth-settings] No sessions_module row found in tenant DB');
355+
return null;
356+
}
357+
358+
// Query the discovered auth settings table
359+
const result = await tenantPool.query<AuthSettingsRow>(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name));
360+
return result.rows[0] ?? null;
361+
} catch (e: any) {
362+
// Table/module may not exist yet if the 2FA migration hasn't been applied
363+
log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`);
364+
return null;
365+
}
366+
};
367+
281368
// =============================================================================
282369
// Resolution Logic
283370
// =============================================================================
@@ -337,8 +424,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise<ApiStructure |
337424
}
338425

339426
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);
427+
const authSettings = await queryAuthSettings(opts, row.dbname);
428+
log.debug(`[api-name-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
429+
return toApiStructure(row, opts, rlsModule, authSettings);
342430
};
343431

344432
const resolveMetaSchemaHeader = (
@@ -362,8 +450,9 @@ const resolveDomainLookup = async (ctx: ResolveContext): Promise<ApiStructure |
362450
}
363451

364452
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);
453+
const authSettings = await queryAuthSettings(opts, row.dbname);
454+
log.debug(`[domain-lookup] resolved schemas: [${row.schemas?.join(', ')}], rlsModule: ${rlsModule ? 'found' : 'none'}, authSettings: ${authSettings ? 'found' : 'none'}`);
455+
return toApiStructure(row, opts, rlsModule, authSettings);
367456
};
368457

369458
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/graphile.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,28 @@ 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+
// Propagate credential metadata as JWT claims so PG functions
193+
// can read them via current_setting('jwt.claims.access_level') etc.
194+
if (req.token.access_level) {
195+
pgSettings['jwt.claims.access_level'] = req.token.access_level;
196+
}
197+
if (req.token.kind) {
198+
pgSettings['jwt.claims.kind'] = req.token.kind;
199+
}
200+
201+
// Enforce read-only transactions for read_only credentials (API keys, etc.)
202+
if (req.token.access_level === 'read_only') {
203+
pgSettings['default_transaction_read_only'] = 'on';
204+
}
205+
206+
return { pgSettings };
193207
}
194208
}
195209

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

0 commit comments

Comments
 (0)