diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 5ac900a78..430d9e53a 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -8,7 +8,7 @@ import { getPgPool } from 'pg-cache'; import errorPage50x from '../errors/50x'; import errorPage404Message from '../errors/404-message'; -import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, RlsModule } from '../types'; +import { ApiConfigResult, ApiError, ApiOptions, ApiStructure, AuthSettings, RlsModule } from '../types'; import './types'; const log = new Logger('api'); @@ -86,6 +86,36 @@ const RLS_MODULE_SQL = ` LIMIT 1 `; +/** + * Discover auth settings table location via public metaschema tables. + * Joins sessions_module with metaschema_public.schema to resolve + * the schema name + table name without touching private schemas. + */ +const AUTH_SETTINGS_DISCOVERY_SQL = ` + SELECT s.schema_name, sm.auth_settings_table AS table_name + FROM metaschema_modules_public.sessions_module sm + JOIN metaschema_public.schema s ON s.id = sm.schema_id + LIMIT 1 +`; + +/** + * Query auth settings from the discovered table. + * Schema and table name are resolved dynamically from metaschema modules. + */ +const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => ` + SELECT + cookie_secure, + cookie_samesite, + cookie_domain, + cookie_httponly, + cookie_max_age, + cookie_path, + enable_captcha, + captcha_site_key + FROM "${schemaName}"."${tableName}" + LIMIT 1 +`; + // ============================================================================= // Types // ============================================================================= @@ -111,6 +141,17 @@ interface RlsModuleData { current_user_agent: string; } +interface AuthSettingsRow { + cookie_secure: boolean; + cookie_samesite: string; + cookie_domain: string | null; + cookie_httponly: boolean; + cookie_max_age: string | null; + cookie_path: string; + enable_captcha: boolean; + captcha_site_key: string | null; +} + interface RlsModuleRow { data: RlsModuleData | null; } @@ -208,7 +249,21 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { }; }; -const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null): ApiStructure => ({ +const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => { + if (!row) return undefined; + return { + cookieSecure: row.cookie_secure, + cookieSamesite: row.cookie_samesite, + cookieDomain: row.cookie_domain, + cookieHttponly: row.cookie_httponly, + cookieMaxAge: row.cookie_max_age, + cookiePath: row.cookie_path, + enableCaptcha: row.enable_captcha, + captchaSiteKey: row.captcha_site_key, + }; +}; + +const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleRow | null, authSettingsRow?: AuthSettingsRow | null): ApiStructure => ({ apiId: row.api_id, dbname: row.dbname || opts.pg?.database || '', anonRole: row.anon_role || 'anon', @@ -219,6 +274,7 @@ const toApiStructure = (row: ApiRow, opts: ApiOptions, rlsModuleRow?: RlsModuleR domains: [], databaseId: row.database_id, isPublic: row.is_public, + authSettings: toAuthSettings(authSettingsRow ?? null), }); const createAdminStructure = ( @@ -278,6 +334,37 @@ const queryRlsModule = async (pool: Pool, apiId: string): Promise => { + try { + const tenantPool = getPgPool({ ...opts.pg, database: dbname }); + + // Discover the auth settings schema + table name from public metaschema tables + const discovery = await tenantPool.query<{ schema_name: string; table_name: string }>(AUTH_SETTINGS_DISCOVERY_SQL); + const resolved = discovery.rows[0]; + if (!resolved) { + log.debug('[auth-settings] No sessions_module row found in tenant DB'); + return null; + } + + // Query the discovered auth settings table + const result = await tenantPool.query(AUTH_SETTINGS_SQL(resolved.schema_name, resolved.table_name)); + return result.rows[0] ?? null; + } catch (e: any) { + // Table/module may not exist yet if the 2FA migration hasn't been applied + log.debug(`[auth-settings] Failed to load auth settings: ${e.message}`); + return null; + } +}; + // ============================================================================= // Resolution Logic // ============================================================================= @@ -337,8 +424,9 @@ const resolveApiNameHeader = async (ctx: ResolveContext): Promise getNodeEnv() === 'development'; +/** Default cookie name for session tokens. */ +const SESSION_COOKIE_NAME = 'constructive_session'; + +/** + * Extract a named cookie value from the raw Cookie header. + * Avoids pulling in cookie-parser as a dependency. + */ +const parseCookieToken = (req: Request, cookieName: string): string | undefined => { + const header = req.headers.cookie; + if (!header) return undefined; + const match = header.split(';').find((c) => c.trim().startsWith(`${cookieName}=`)); + return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; +}; + export const createAuthenticateMiddleware = ( opts: PgpmOptions ): RequestHandler => { @@ -60,8 +74,15 @@ export const createAuthenticateMiddleware = ( `authType=${authType ?? 'none'}, hasToken=${!!authToken}` ); - if (authType?.toLowerCase() === 'bearer' && authToken) { - log.info('[auth] Processing bearer token authentication'); + // Resolve the credential: prefer Bearer header, fall back to session cookie + const cookieToken = parseCookieToken(req, SESSION_COOKIE_NAME); + const effectiveToken = (authType?.toLowerCase() === 'bearer' && authToken) + ? authToken + : cookieToken; + const tokenSource = (authType?.toLowerCase() === 'bearer' && authToken) ? 'bearer' : (cookieToken ? 'cookie' : 'none'); + + if (effectiveToken) { + log.info(`[auth] Processing ${tokenSource} authentication`); const context: Record = { 'jwt.claims.ip_address': req.clientIp, }; @@ -81,7 +102,7 @@ export const createAuthenticateMiddleware = ( client: pool, context, query: authQuery, - variables: [authToken], + variables: [effectiveToken], }); log.info(`[auth] Query result: rowCount=${result?.rowCount}`); @@ -111,7 +132,7 @@ export const createAuthenticateMiddleware = ( return; } } else { - log.info('[auth] No bearer token provided, using anonymous auth'); + log.info('[auth] No credential provided (no bearer token or session cookie), using anonymous auth'); } req.token = token; diff --git a/graphql/server/src/middleware/captcha.ts b/graphql/server/src/middleware/captcha.ts new file mode 100644 index 000000000..60f7f90fd --- /dev/null +++ b/graphql/server/src/middleware/captcha.ts @@ -0,0 +1,128 @@ +import { Logger } from '@pgpmjs/logger'; +import type { NextFunction, Request, RequestHandler, Response } from 'express'; +import './types'; // for Request type + +const log = new Logger('captcha'); + +/** Google reCAPTCHA verification endpoint */ +const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify'; + +/** + * Header name the client sends the CAPTCHA response token in. + * Follows the common pattern: X-Captcha-Token. + */ +const CAPTCHA_HEADER = 'x-captcha-token'; + +/** + * GraphQL mutation names that require CAPTCHA verification when enabled. + * Only sign-up and password-reset are gated; normal sign-in is not. + */ +const CAPTCHA_PROTECTED_OPERATIONS = new Set([ + 'signUp', + 'signUpWithMagicLink', + 'signUpWithSms', + 'resetPassword', + 'requestPasswordReset', +]); + +interface RecaptchaResponse { + success: boolean; + 'error-codes'?: string[]; +} + +/** + * Attempt to extract the GraphQL operation name from the request body. + * Works for both JSON and already-parsed bodies. + */ +const getOperationName = (req: Request): string | undefined => { + const body = (req as any).body; + if (!body) return undefined; + // Already parsed (express.json ran first) + if (typeof body === 'object' && body.operationName) { + return body.operationName; + } + return undefined; +}; + +/** + * Verify a reCAPTCHA token with Google's API. + */ +const verifyToken = async (token: string, secretKey: string): Promise => { + try { + const params = new URLSearchParams({ secret: secretKey, response: token }); + const res = await fetch(RECAPTCHA_VERIFY_URL, { + method: 'POST', + body: params, + }); + const data = (await res.json()) as RecaptchaResponse; + if (!data.success) { + log.debug(`[captcha] Verification failed: ${data['error-codes']?.join(', ') ?? 'unknown'}`); + } + return data.success; + } catch (e: any) { + log.error('[captcha] Error verifying token:', e.message); + return false; + } +}; + +/** + * Creates a CAPTCHA verification middleware. + * + * When `enable_captcha` is true in app_auth_settings, this middleware checks + * the X-Captcha-Token header on protected mutations (sign-up, password reset). + * The secret key is read from the RECAPTCHA_SECRET_KEY environment variable + * (the public site key is stored in app_auth_settings for the frontend). + * + * Skips verification when: + * - CAPTCHA is not enabled in auth settings + * - The request is not a protected mutation + * - No secret key is configured server-side + */ +export const createCaptchaMiddleware = (): RequestHandler => { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const authSettings = req.api?.authSettings; + + // Skip if CAPTCHA is not enabled + if (!authSettings?.enableCaptcha) { + return next(); + } + + // Only gate protected operations + const opName = getOperationName(req); + if (!opName || !CAPTCHA_PROTECTED_OPERATIONS.has(opName)) { + return next(); + } + + // Secret key must be set server-side (env var, not stored in DB for security) + const secretKey = process.env.RECAPTCHA_SECRET_KEY; + if (!secretKey) { + log.warn('[captcha] enable_captcha is true but RECAPTCHA_SECRET_KEY env var is not set; skipping verification'); + return next(); + } + + const captchaToken = req.get(CAPTCHA_HEADER); + if (!captchaToken) { + res.status(200).json({ + errors: [{ + message: 'CAPTCHA verification required', + extensions: { code: 'CAPTCHA_REQUIRED' }, + }], + }); + return; + } + + const valid = await verifyToken(captchaToken, secretKey); + if (!valid) { + res.status(200).json({ + errors: [{ + message: 'CAPTCHA verification failed', + extensions: { code: 'CAPTCHA_FAILED' }, + }], + }); + return; + } + + log.info(`[captcha] Verified for operation=${opName}`); + next(); + }; +}; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index aa365e17a..4e3bb64e4 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -182,14 +182,28 @@ const buildPreset = ( } if (req.token?.user_id) { - return { - pgSettings: { - role: roleName, - 'jwt.claims.token_id': req.token.id, - 'jwt.claims.user_id': req.token.user_id, - ...context, - }, + const pgSettings: Record = { + role: roleName, + 'jwt.claims.token_id': req.token.id, + 'jwt.claims.user_id': req.token.user_id, + ...context, }; + + // Propagate credential metadata as JWT claims so PG functions + // can read them via current_setting('jwt.claims.access_level') etc. + if (req.token.access_level) { + pgSettings['jwt.claims.access_level'] = req.token.access_level; + } + if (req.token.kind) { + pgSettings['jwt.claims.kind'] = req.token.kind; + } + + // Enforce read-only transactions for read_only credentials (API keys, etc.) + if (req.token.access_level === 'read_only') { + pgSettings['default_transaction_read_only'] = 'on'; + } + + return { pgSettings }; } } diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index eb682a484..0d22e982c 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -3,6 +3,8 @@ import type { ApiStructure } from '../types'; export type ConstructiveAPIToken = { id?: string; user_id?: string; + access_level?: string; + kind?: string; [key: string]: unknown; }; diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 7aa57c4b2..63c9ceada 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -32,6 +32,7 @@ import { createDebugDatabaseMiddleware } from './middleware/observability/debug- import { debugMemory } from './middleware/observability/debug-memory'; import { localObservabilityOnly } from './middleware/observability/guard'; import { createRequestLogger } from './middleware/observability/request-logger'; +import { createCaptchaMiddleware } from './middleware/captcha'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -158,6 +159,7 @@ class Server { app.use(api); app.post('/upload', uploadAuthenticate, ...uploadRoute); app.use(authenticate); + app.use(createCaptchaMiddleware()); app.use(graphile(effectiveOpts)); app.use(flush); diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index fffdc3221..fdb50f0c0 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -38,6 +38,24 @@ export interface RlsModule { currentUserAgent: string; } +/** + * Server-visible subset of app_auth_settings (lives in the tenant DB private schema). + * Discovered dynamically via metaschema_modules_public.sessions_module. + * Loaded once per API resolution and cached alongside the ApiStructure. + */ +export interface AuthSettings { + /** Cookie configuration */ + cookieSecure?: boolean; + cookieSamesite?: string; + cookieDomain?: string | null; + cookieHttponly?: boolean; + cookieMaxAge?: string | null; + cookiePath?: string; + /** reCAPTCHA / CAPTCHA */ + enableCaptcha?: boolean; + captchaSiteKey?: string | null; +} + export interface ApiStructure { apiId?: string; dbname: string; @@ -49,6 +67,7 @@ export interface ApiStructure { domains?: string[]; databaseId?: string; isPublic?: boolean; + authSettings?: AuthSettings; } export type ApiError = { errorHtml: string };