diff --git a/graphql/server/src/middleware/api.ts b/graphql/server/src/middleware/api.ts index 430d9e53a..4eafc5a52 100644 --- a/graphql/server/src/middleware/api.ts +++ b/graphql/server/src/middleware/api.ts @@ -104,6 +104,8 @@ const AUTH_SETTINGS_DISCOVERY_SQL = ` */ const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => ` SELECT + enable_cookie_auth, + require_csrf_for_auth, cookie_secure, cookie_samesite, cookie_domain, @@ -142,6 +144,8 @@ interface RlsModuleData { } interface AuthSettingsRow { + enable_cookie_auth: boolean; + require_csrf_for_auth: boolean; cookie_secure: boolean; cookie_samesite: string; cookie_domain: string | null; @@ -252,6 +256,8 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => { const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => { if (!row) return undefined; return { + enableCookieAuth: row.enable_cookie_auth, + requireCsrfForAuth: row.require_csrf_for_auth, cookieSecure: row.cookie_secure, cookieSamesite: row.cookie_samesite, cookieDomain: row.cookie_domain, diff --git a/graphql/server/src/middleware/auth.ts b/graphql/server/src/middleware/auth.ts index 8a2019346..0e483398e 100644 --- a/graphql/server/src/middleware/auth.ts +++ b/graphql/server/src/middleware/auth.ts @@ -136,6 +136,7 @@ export const createAuthenticateMiddleware = ( } req.token = token; + req.tokenSource = tokenSource as 'bearer' | 'cookie' | 'none'; } else { log.info( `[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` + diff --git a/graphql/server/src/middleware/cookie-plugin.ts b/graphql/server/src/middleware/cookie-plugin.ts new file mode 100644 index 000000000..e008af7ce --- /dev/null +++ b/graphql/server/src/middleware/cookie-plugin.ts @@ -0,0 +1,292 @@ +import { Logger } from '@pgpmjs/logger'; +import { getNodeEnv } from '@pgpmjs/env'; +import type { GraphileConfig } from 'graphile-config'; +import type { Request } from 'express'; +import type { AuthSettings } from '../types'; + +const log = new Logger('cookie-plugin'); + +/** Default cookie name for session tokens (matches auth.ts). */ +const SESSION_COOKIE_NAME = 'constructive_session'; + +/** Default cookie name for device tokens (long-lived trusted device). */ +const DEVICE_COOKIE_NAME = 'constructive_device_token'; + +/** + * GraphQL mutation names that return an access_token on success. + * When cookie auth is enabled, the server sets an HttpOnly session cookie + * from the access_token in the response payload. + */ +const AUTH_MUTATIONS_SIGN_IN = new Set([ + 'signIn', + 'signUp', + 'signInSso', + 'signUpSso', + 'signInMagicLink', + 'signInEmailOtp', + 'signInSmsOtp', + 'signInOneTimeToken', + 'signInCrossOrigin', + 'completeMfaChallenge', +]); + +/** + * GraphQL mutation names that should clear the session cookie. + */ +const AUTH_MUTATIONS_SIGN_OUT = new Set([ + 'signOut', + 'revokeSession', +]); + +// --------------------------------------------------------------------------- +// Cookie Helpers +// --------------------------------------------------------------------------- + +/** + * Parse a PostgreSQL interval string (e.g. "7 days", "24 hours", "30 minutes") + * into milliseconds. Supports common auth-relevant durations. + */ +const parseIntervalToMs = (interval: string): number => { + const normalized = interval.trim().toLowerCase(); + + // Try numeric-only (assume seconds) + const numOnly = Number(normalized); + if (!isNaN(numOnly) && numOnly > 0) { + return numOnly * 1000; + } + + // Match patterns like "7 days", "24 hours", "30 minutes", "1 year" + const match = normalized.match(/^(\d+)\s*(second|minute|hour|day|week|month|year)s?$/); + if (!match) return 0; + + const value = parseInt(match[1], 10); + const unit = match[2]; + + const multipliers: Record = { + second: 1000, + minute: 60 * 1000, + hour: 60 * 60 * 1000, + day: 24 * 60 * 60 * 1000, + week: 7 * 24 * 60 * 60 * 1000, + month: 30 * 24 * 60 * 60 * 1000, + year: 365 * 24 * 60 * 60 * 1000, + }; + + return value * (multipliers[unit] || 0); +}; + +/** + * Build cookie options from AuthSettings. + * Falls back to secure defaults when settings are missing. + */ +const buildCookieOptions = ( + settings: AuthSettings | undefined, +): Record => { + const secure = settings?.cookieSecure ?? (getNodeEnv() === 'production'); + const sameSite = (settings?.cookieSamesite ?? 'lax') as 'strict' | 'lax' | 'none'; + const httpOnly = settings?.cookieHttponly ?? true; + const path = settings?.cookiePath ?? '/'; + const domain = settings?.cookieDomain ?? undefined; + + const opts: Record = { + httpOnly, + secure, + sameSite, + path, + }; + if (domain) { + opts.domain = domain; + } + + // maxAge from settings is an interval string (e.g. "7 days"). + // Cookie maxAge is in milliseconds. We parse common interval formats. + const maxAgeStr = settings?.cookieMaxAge; + if (maxAgeStr) { + const ms = parseIntervalToMs(maxAgeStr); + if (ms > 0) { + opts.maxAge = ms; + } + } + + return opts; +}; + +/** + * Extract the access_token from a GraphQL JSON response body. + * Auth mutations return { data: { mutationName: { accessToken: "..." } } } + * PostGraphile camelCases the output columns, so we look for accessToken. + */ +const extractAccessToken = (body: Record, operationName: string): string | undefined => { + const data = body.data as Record | undefined; + if (!data) return undefined; + + const mutationResult = data[operationName] as Record | undefined; + if (!mutationResult) return undefined; + + // PostGraphile wraps in { result: { ... } } for function mutations + const result = (mutationResult.result ?? mutationResult) as Record; + + return (result.accessToken ?? result.access_token) as string | undefined; +}; + +/** + * Extract device_id from a GraphQL JSON response body. + * Sign-in mutations may return a device_id when device tracking is enabled. + */ +const extractDeviceId = (body: Record, operationName: string): string | undefined => { + const data = body.data as Record | undefined; + if (!data) return undefined; + const mutationResult = data[operationName] as Record | undefined; + if (!mutationResult) return undefined; + const result = (mutationResult.result ?? mutationResult) as Record; + return (result.deviceId ?? result.device_id) as string | undefined; +}; + +/** + * Serialize a single Set-Cookie value from cookie options. + */ +const serializeCookie = ( + name: string, + value: string, + opts: Record, +): string => { + const parts = [`${name}=${encodeURIComponent(value)}`]; + + if (opts.maxAge != null) { + const maxAge = Math.floor(Number(opts.maxAge) / 1000); // Cookie Max-Age is in seconds + parts.push(`Max-Age=${maxAge}`); + } + if (opts.domain) parts.push(`Domain=${opts.domain}`); + if (opts.path) parts.push(`Path=${opts.path}`); + if (opts.httpOnly) parts.push('HttpOnly'); + if (opts.secure) parts.push('Secure'); + if (opts.sameSite) { + const ss = String(opts.sameSite); + parts.push(`SameSite=${ss.charAt(0).toUpperCase() + ss.slice(1)}`); + } + + return parts.join('; '); +}; + +/** + * Serialize a Set-Cookie header for clearing (expiring) a cookie. + */ +const serializeClearCookie = ( + name: string, + opts: Record, +): string => { + const parts = [`${name}=`]; + parts.push('Expires=Thu, 01 Jan 1970 00:00:00 GMT'); + parts.push('Max-Age=0'); + if (opts.domain) parts.push(`Domain=${opts.domain}`); + if (opts.path) parts.push(`Path=${opts.path}`); + if (opts.httpOnly) parts.push('HttpOnly'); + if (opts.secure) parts.push('Secure'); + if (opts.sameSite) { + const ss = String(opts.sameSite); + parts.push(`SameSite=${ss.charAt(0).toUpperCase() + ss.slice(1)}`); + } + return parts.join('; '); +}; + +// --------------------------------------------------------------------------- +// grafserv processRequest Plugin +// --------------------------------------------------------------------------- + +/** + * grafserv plugin that injects Set-Cookie headers into GraphQL responses + * for auth mutations when cookie auth is enabled. + * + * Uses the official `processRequest` middleware hook — no monkey-patching + * of res.writeHead/res.end. The plugin wraps the entire request->result + * pipeline and modifies the Result's headers before grafserv writes them + * to the Node.js response. + * + * When `enable_cookie_auth` is false (default), this plugin is a no-op. + * Bearer token authentication continues to work regardless of this setting. + */ +export const CookiePlugin: GraphileConfig.Plugin = { + name: 'CookieLifecyclePlugin', + version: '1.0.0', + grafserv: { + middleware: { + processRequest(next, event) { + return (async () => { + const result = await next(); + if (!result) return result; + + // Access Express req from the grafserv request context + const reqContext = event.requestDigest.requestContext as { + expressv4?: { req?: Request }; + }; + const req = reqContext.expressv4?.req; + if (!req) return result; + + const authSettings = req.api?.authSettings; + + // Skip if cookie auth is not enabled — complete no-op + if (!authSettings?.enableCookieAuth) return result; + + const opName = (req as unknown as { body?: { operationName?: string } }).body?.operationName; + if (!opName) return result; + + const isSignIn = AUTH_MUTATIONS_SIGN_IN.has(opName); + const isSignOut = AUTH_MUTATIONS_SIGN_OUT.has(opName); + if (!isSignIn && !isSignOut) return result; + + // Parse the response body from the result + let body: Record | undefined; + try { + if (result.type === 'json') { + body = result.json as Record; + } else if (result.type === 'buffer') { + body = JSON.parse(result.buffer.toString('utf8')) as Record; + } + } catch { + // Not valid JSON — skip cookie processing + } + if (!body) return result; + + const cookieHeaders: string[] = []; + const cookieOpts = buildCookieOptions(authSettings); + + if (isSignOut) { + cookieHeaders.push(serializeClearCookie(SESSION_COOKIE_NAME, cookieOpts)); + log.info(`[cookie] Cleared session cookie for operation=${opName}`); + } else if (isSignIn) { + const accessToken = extractAccessToken(body, opName); + if (accessToken) { + cookieHeaders.push(serializeCookie(SESSION_COOKIE_NAME, accessToken, cookieOpts)); + log.info(`[cookie] Set session cookie for operation=${opName}`); + } + + const deviceId = extractDeviceId(body, opName); + if (deviceId) { + const deviceOpts = { ...cookieOpts, maxAge: 90 * 24 * 60 * 60 * 1000 }; + cookieHeaders.push(serializeCookie(DEVICE_COOKIE_NAME, deviceId, deviceOpts)); + log.info(`[cookie] Set device token cookie for operation=${opName}`); + } + } + + // Inject Set-Cookie headers into the result + if (cookieHeaders.length > 0) { + // Node.js writeHead accepts string[] for Set-Cookie. + // grafserv types Result.headers as Record but the + // Node adapter passes them straight to writeHead which handles arrays. + const headers = result.headers as Record; + const existing = headers['Set-Cookie']; + if (Array.isArray(existing)) { + headers['Set-Cookie'] = [...existing, ...cookieHeaders]; + } else if (typeof existing === 'string') { + headers['Set-Cookie'] = [existing, ...cookieHeaders]; + } else { + headers['Set-Cookie'] = cookieHeaders; + } + } + + return result; + })(); + }, + }, + }, +}; diff --git a/graphql/server/src/middleware/csrf.ts b/graphql/server/src/middleware/csrf.ts new file mode 100644 index 000000000..6c92460f8 --- /dev/null +++ b/graphql/server/src/middleware/csrf.ts @@ -0,0 +1,147 @@ +import crypto from 'node:crypto'; +import { Logger } from '@pgpmjs/logger'; +import type { NextFunction, Request, Response } from 'express'; +import './types'; // for Request type + +const log = new Logger('csrf'); + +/** Cookie name for the CSRF double-submit token. */ +const CSRF_COOKIE_NAME = 'constructive_csrf'; + +/** Header the client must echo the CSRF token in on mutations. */ +const CSRF_HEADER = 'x-csrf-token'; + +/** + * HTTP methods that mutate state and therefore require CSRF validation. + * GET and HEAD are safe methods — they never require a token. + */ +const UNSAFE_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +/** + * Creates a CSRF double-submit-cookie protection middleware. + * + * When `enable_cookie_auth` is true in app_auth_settings, this middleware: + * 1. Sets a CSRF token cookie on every response (setToken) + * 2. Validates the X-CSRF-Token header matches the cookie on unsafe + * methods for cookie-authenticated requests (protect) + * 3. Provides an error handler for CSRF validation failures (errorHandler) + * + * When `enable_cookie_auth` is false (default), all three middlewares are + * complete no-ops — zero overhead, zero behavior change. The server works + * exactly as it does today with bearer-only authentication. + */ +export const createCsrfProtectionMiddleware = () => { + /** + * Set the CSRF token cookie on every response. + * Only active when cookie auth is enabled for this tenant. + */ + const setToken = (req: Request, res: Response, next: NextFunction): void => { + const authSettings = req.api?.authSettings; + + // No-op when cookie auth is disabled + if (!authSettings?.enableCookieAuth) { + return next(); + } + + // Generate a new CSRF token if the client doesn't already have one + const existingToken = parseCookieValue(req.headers.cookie, CSRF_COOKIE_NAME); + if (!existingToken) { + const token = crypto.randomBytes(32).toString('hex'); + res.cookie(CSRF_COOKIE_NAME, token, { + httpOnly: false, // Client JS needs to read this and send it as a header + secure: authSettings.cookieSecure ?? (process.env.NODE_ENV === 'production'), + sameSite: (authSettings.cookieSamesite as 'strict' | 'lax' | 'none') ?? 'lax', + path: authSettings.cookiePath ?? '/', + ...(authSettings.cookieDomain ? { domain: authSettings.cookieDomain } : {}), + }); + log.debug('[csrf] Set CSRF token cookie'); + } + + next(); + }; + + /** + * Validate the CSRF token on unsafe methods for cookie-authenticated requests. + * Only active when cookie auth is enabled AND the request was authenticated via cookie. + * Bearer-authenticated requests are exempt — they're not vulnerable to CSRF. + */ + const protect = (req: Request, res: Response, next: NextFunction): void => { + const authSettings = req.api?.authSettings; + + // No-op when cookie auth is disabled + if (!authSettings?.enableCookieAuth) { + return next(); + } + + // Only validate unsafe methods + if (!UNSAFE_METHODS.has(req.method)) { + return next(); + } + + // Only validate cookie-authenticated requests — bearer tokens are CSRF-safe + if (req.tokenSource !== 'cookie') { + return next(); + } + + // Skip CSRF if the tenant has explicitly opted out + if (authSettings.requireCsrfForAuth === false) { + return next(); + } + + const cookieToken = parseCookieValue(req.headers.cookie, CSRF_COOKIE_NAME); + const headerToken = req.get(CSRF_HEADER); + + if (!cookieToken || !headerToken) { + log.warn(`[csrf] Missing CSRF token: cookie=${!!cookieToken} header=${!!headerToken}`); + res.status(200).json({ + errors: [{ + message: 'CSRF token required for cookie-authenticated mutations', + extensions: { code: 'CSRF_TOKEN_REQUIRED' }, + }], + }); + return; + } + + if (!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) { + log.warn('[csrf] CSRF token mismatch'); + res.status(200).json({ + errors: [{ + message: 'Invalid CSRF token', + extensions: { code: 'INVALID_CSRF_TOKEN' }, + }], + }); + return; + } + + next(); + }; + + /** + * Error handler for CSRF validation failures. + * Catches any CSRF errors that bubble up and returns a 403. + */ + const errorHandler = (err: Error & { code?: string }, _req: Request, res: Response, next: NextFunction): void => { + if (err.code === 'CSRF_TOKEN_REQUIRED' || err.code === 'INVALID_CSRF_TOKEN') { + res.status(403).json({ + errors: [{ + message: err.message, + extensions: { code: err.code }, + }], + }); + return; + } + next(err); + }; + + return { setToken, protect, errorHandler }; +}; + +/** + * Extract a named cookie value from the raw Cookie header. + * Avoids pulling in cookie-parser as a dependency for this one value. + */ +const parseCookieValue = (header: string | undefined, name: string): string | undefined => { + if (!header) return undefined; + const match = header.split(';').find((c) => c.trim().startsWith(`${name}=`)); + return match ? decodeURIComponent(match.split('=')[1].trim()) : undefined; +}; diff --git a/graphql/server/src/middleware/graphile.ts b/graphql/server/src/middleware/graphile.ts index e6ccb3cf7..0b28d59da 100644 --- a/graphql/server/src/middleware/graphile.ts +++ b/graphql/server/src/middleware/graphile.ts @@ -13,6 +13,7 @@ import './types'; // for Request type import { isGraphqlObservabilityEnabled } from '../diagnostics/observability'; import { HandlerCreationError } from '../errors/api-errors'; import { observeGraphileBuild } from './observability/graphile-build-stats'; +import { CookiePlugin } from './cookie-plugin'; const maskErrorLog = new Logger('graphile:maskError'); @@ -197,6 +198,7 @@ const buildPreset = ( ): GraphileConfig.Preset => { return { extends: [ConstructivePreset], + plugins: [CookiePlugin], pgServices: [ makePgService({ pool, diff --git a/graphql/server/src/middleware/types.ts b/graphql/server/src/middleware/types.ts index 0d22e982c..cd73c109d 100644 --- a/graphql/server/src/middleware/types.ts +++ b/graphql/server/src/middleware/types.ts @@ -8,6 +8,9 @@ export type ConstructiveAPIToken = { [key: string]: unknown; }; +/** How the current request was authenticated. */ +export type TokenSource = 'bearer' | 'cookie' | 'none'; + declare global { namespace Express { interface Request { @@ -17,6 +20,8 @@ declare global { databaseId?: string; requestId?: string; token?: ConstructiveAPIToken; + /** How the credential was resolved: bearer header, session cookie, or none. */ + tokenSource?: TokenSource; } } } diff --git a/graphql/server/src/server.ts b/graphql/server/src/server.ts index 63c9ceada..75c09792d 100644 --- a/graphql/server/src/server.ts +++ b/graphql/server/src/server.ts @@ -33,6 +33,7 @@ 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 { createCsrfProtectionMiddleware } from './middleware/csrf'; import { createUploadAuthenticateMiddleware, uploadRoute } from './middleware/upload'; import { startDebugSampler } from './diagnostics/debug-sampler'; @@ -159,11 +160,25 @@ class Server { app.use(api); app.post('/upload', uploadAuthenticate, ...uploadRoute); app.use(authenticate); + + // CSRF double-submit-cookie protection (no-op when enable_cookie_auth is false) + const csrfMiddleware = createCsrfProtectionMiddleware(); + app.use(csrfMiddleware.setToken); + app.use(csrfMiddleware.protect); + + // CAPTCHA verification on protected mutations (no-op when enable_captcha is false) app.use(createCaptchaMiddleware()); + + // Cookie lifecycle (set/clear session cookies) is handled by the grafserv + // CookiePlugin registered in buildPreset() — no Express middleware needed. + // The plugin uses the official processRequest hook and is a complete no-op + // when enable_cookie_auth is false. + app.use(graphile(effectiveOpts)); app.use(flush); // Error handling - MUST be LAST + app.use(csrfMiddleware.errorHandler as any); // CSRF validation errors → 403 app.use(notFoundHandler); // Catches unmatched routes (404) app.use(errorHandler); // Catches all thrown errors diff --git a/graphql/server/src/types.ts b/graphql/server/src/types.ts index fdb50f0c0..568889a3a 100644 --- a/graphql/server/src/types.ts +++ b/graphql/server/src/types.ts @@ -44,6 +44,9 @@ export interface RlsModule { * Loaded once per API resolution and cached alongside the ApiStructure. */ export interface AuthSettings { + /** Feature toggles */ + enableCookieAuth?: boolean; + requireCsrfForAuth?: boolean; /** Cookie configuration */ cookieSecure?: boolean; cookieSamesite?: string;