Skip to content

Commit 4c2ba66

Browse files
committed
feat: Tier 0 server auth - cookie lifecycle, CSRF enforcement, device tokens, SSO wiring
- Add cookie lifecycle middleware (cookie.ts): intercepts auth mutation responses and sets/clears HttpOnly session cookies when enable_cookie_auth is true in app_auth_settings. Handles all sign-in/sign-up/sign-out mutations. - Add CSRF protection middleware (csrf.ts): wires @constructive-io/csrf package into server. Only enforces on cookie-authenticated requests, completely skips Bearer token requests. Controlled by require_csrf_for_auth toggle in app_auth_settings. - Add device token cookie support: on sign-in responses that include a device_id, sets a long-lived (90 day) constructive_device_token cookie for trusted device tracking. - Add SSO/OAuth route middleware (oauth.ts): mounts /auth/:provider and /auth/:provider/callback routes. On successful OAuth callback, calls sign_in_sso() on the tenant DB private schema and optionally sets session cookie when cookie auth is enabled. - Expand AuthSettings interface with enableCookieAuth and requireCsrfForAuth toggles. Update AUTH_SETTINGS_SQL query to fetch these columns. - Set req.tokenSource on authenticated requests so downstream middleware knows whether auth came from bearer header, cookie, or none. - Add cookie-parser, @constructive-io/csrf, @constructive-io/oauth as server dependencies. Backward compatibility: - All features are opt-in via app_auth_settings toggles (default: off) - Bearer token authentication continues to work exactly as before - CSRF only enforces on cookie-authenticated requests - No changes to existing GraphQL mutations or API contracts
1 parent 53adb7e commit 4c2ba66

10 files changed

Lines changed: 3161 additions & 7425 deletions

File tree

graphql/server/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,10 @@
4141
"backend"
4242
],
4343
"dependencies": {
44+
"@constructive-io/csrf": "workspace:^",
4445
"@constructive-io/graphql-env": "workspace:^",
4546
"@constructive-io/graphql-types": "workspace:^",
47+
"@constructive-io/oauth": "workspace:^",
4648
"@constructive-io/s3-utils": "workspace:^",
4749
"@constructive-io/upload-names": "workspace:^",
4850
"@constructive-io/url-domains": "workspace:^",
@@ -53,6 +55,7 @@
5355
"@pgpmjs/server-utils": "workspace:^",
5456
"@pgpmjs/types": "workspace:^",
5557
"@pgsql/quotes": "^17.1.0",
58+
"cookie-parser": "^1.4.7",
5659
"cors": "^2.8.6",
5760
"deepmerge": "^4.3.1",
5861
"express": "^5.2.1",
@@ -79,6 +82,7 @@
7982
},
8083
"devDependencies": {
8184
"@aws-sdk/client-s3": "^3.1009.0",
85+
"@types/cookie-parser": "^1.4.10",
8286
"@types/cors": "^2.8.17",
8387
"@types/express": "^5.0.6",
8488
"@types/graphql-upload": "^8.0.12",

graphql/server/src/middleware/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ const AUTH_SETTINGS_DISCOVERY_SQL = `
104104
*/
105105
const AUTH_SETTINGS_SQL = (schemaName: string, tableName: string) => `
106106
SELECT
107+
enable_cookie_auth,
108+
require_csrf_for_auth,
107109
cookie_secure,
108110
cookie_samesite,
109111
cookie_domain,
@@ -142,6 +144,8 @@ interface RlsModuleData {
142144
}
143145

144146
interface AuthSettingsRow {
147+
enable_cookie_auth: boolean;
148+
require_csrf_for_auth: boolean;
145149
cookie_secure: boolean;
146150
cookie_samesite: string;
147151
cookie_domain: string | null;
@@ -252,6 +256,8 @@ const toRlsModule = (row: RlsModuleRow | null): RlsModule | undefined => {
252256
const toAuthSettings = (row: AuthSettingsRow | null): AuthSettings | undefined => {
253257
if (!row) return undefined;
254258
return {
259+
enableCookieAuth: row.enable_cookie_auth,
260+
requireCsrfForAuth: row.require_csrf_for_auth,
255261
cookieSecure: row.cookie_secure,
256262
cookieSamesite: row.cookie_samesite,
257263
cookieDomain: row.cookie_domain,

graphql/server/src/middleware/auth.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ export const createAuthenticateMiddleware = (
136136
}
137137

138138
req.token = token;
139+
req.tokenSource = tokenSource as 'bearer' | 'cookie' | 'none';
139140
} else {
140141
log.info(
141142
`[auth] Skipping auth: authFn=${authFn ?? 'none'}, ` +
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
import { Logger } from '@pgpmjs/logger';
2+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
3+
import type { AuthSettings } from '../types';
4+
import './types'; // for Request type
5+
6+
const log = new Logger('cookie');
7+
8+
/** Default cookie name for session tokens (matches auth.ts). */
9+
const SESSION_COOKIE_NAME = 'constructive_session';
10+
11+
/** Default cookie name for device tokens (long-lived trusted device). */
12+
const DEVICE_COOKIE_NAME = 'constructive_device_token';
13+
14+
/**
15+
* GraphQL mutation names that return an access_token on success.
16+
* When cookie auth is enabled, the server sets an HttpOnly session cookie
17+
* from the access_token in the response payload.
18+
*/
19+
const AUTH_MUTATIONS_SIGN_IN = new Set([
20+
'signIn',
21+
'signUp',
22+
'signInSso',
23+
'signUpSso',
24+
'signInMagicLink',
25+
'signInEmailOtp',
26+
'signInSmsOtp',
27+
'signInOneTimeToken',
28+
'signInCrossOrigin',
29+
'completeMfaChallenge',
30+
]);
31+
32+
/**
33+
* GraphQL mutation names that should clear the session cookie.
34+
*/
35+
const AUTH_MUTATIONS_SIGN_OUT = new Set([
36+
'signOut',
37+
'revokeSession',
38+
]);
39+
40+
/**
41+
* Attempt to extract the GraphQL operation name from the request body.
42+
* Works for both JSON and already-parsed bodies.
43+
*/
44+
const getOperationName = (req: Request): string | undefined => {
45+
const body = (req as any).body;
46+
if (!body) return undefined;
47+
if (typeof body === 'object' && body.operationName) {
48+
return body.operationName;
49+
}
50+
return undefined;
51+
};
52+
53+
/**
54+
* Build cookie options from AuthSettings.
55+
* Falls back to secure defaults when settings are missing.
56+
*/
57+
const buildCookieOptions = (
58+
settings: AuthSettings | undefined,
59+
): Record<string, unknown> => {
60+
const secure = settings?.cookieSecure ?? (process.env.NODE_ENV === 'production');
61+
const sameSite = (settings?.cookieSamesite ?? 'lax') as 'strict' | 'lax' | 'none';
62+
const httpOnly = settings?.cookieHttponly ?? true;
63+
const path = settings?.cookiePath ?? '/';
64+
const domain = settings?.cookieDomain ?? undefined;
65+
66+
const opts: Record<string, unknown> = {
67+
httpOnly,
68+
secure,
69+
sameSite,
70+
path,
71+
};
72+
if (domain) {
73+
opts.domain = domain;
74+
}
75+
76+
// maxAge from settings is an interval string (e.g. "7 days").
77+
// Express cookie maxAge is in milliseconds. We parse common interval formats.
78+
const maxAgeStr = settings?.cookieMaxAge;
79+
if (maxAgeStr) {
80+
const ms = parseIntervalToMs(maxAgeStr);
81+
if (ms > 0) {
82+
opts.maxAge = ms;
83+
}
84+
}
85+
86+
return opts;
87+
};
88+
89+
/**
90+
* Parse a PostgreSQL interval string (e.g. "7 days", "24 hours", "30 minutes")
91+
* into milliseconds. Supports common auth-relevant durations.
92+
*/
93+
const parseIntervalToMs = (interval: string): number => {
94+
const normalized = interval.trim().toLowerCase();
95+
96+
// Try numeric-only (assume seconds)
97+
const numOnly = Number(normalized);
98+
if (!isNaN(numOnly) && numOnly > 0) {
99+
return numOnly * 1000;
100+
}
101+
102+
// Match patterns like "7 days", "24 hours", "30 minutes", "1 year"
103+
const match = normalized.match(/^(\d+)\s*(second|minute|hour|day|week|month|year)s?$/);
104+
if (!match) return 0;
105+
106+
const value = parseInt(match[1], 10);
107+
const unit = match[2];
108+
109+
const multipliers: Record<string, number> = {
110+
second: 1000,
111+
minute: 60 * 1000,
112+
hour: 60 * 60 * 1000,
113+
day: 24 * 60 * 60 * 1000,
114+
week: 7 * 24 * 60 * 60 * 1000,
115+
month: 30 * 24 * 60 * 60 * 1000,
116+
year: 365 * 24 * 60 * 60 * 1000,
117+
};
118+
119+
return value * (multipliers[unit] || 0);
120+
};
121+
122+
/**
123+
* Extract the access_token from a GraphQL JSON response body.
124+
* Auth mutations return { data: { mutationName: { accessToken: "..." } } }
125+
* PostGraphile camelCases the output columns, so we look for accessToken.
126+
*/
127+
const extractAccessToken = (body: any, operationName: string): string | undefined => {
128+
if (!body?.data) return undefined;
129+
130+
// The mutation result is nested under the camelCase mutation name
131+
const mutationResult = body.data[operationName];
132+
if (!mutationResult) return undefined;
133+
134+
// PostGraphile wraps in { result: { ... } } for function mutations
135+
const result = mutationResult.result ?? mutationResult;
136+
137+
// Look for access_token or accessToken in the result
138+
return result?.accessToken ?? result?.access_token ?? undefined;
139+
};
140+
141+
/**
142+
* Extract device_id from a GraphQL JSON response body.
143+
* Sign-in mutations may return a device_id when device tracking is enabled.
144+
*/
145+
const extractDeviceId = (body: any, operationName: string): string | undefined => {
146+
if (!body?.data) return undefined;
147+
const mutationResult = body.data[operationName];
148+
if (!mutationResult) return undefined;
149+
const result = mutationResult.result ?? mutationResult;
150+
return result?.deviceId ?? result?.device_id ?? undefined;
151+
};
152+
153+
/**
154+
* Creates the cookie lifecycle middleware.
155+
*
156+
* When `enable_cookie_auth` is true in app_auth_settings:
157+
* - On sign-in/sign-up mutations: intercepts the response and sets an HttpOnly
158+
* session cookie from the returned access_token.
159+
* - On sign-out/revoke mutations: clears the session cookie.
160+
* - On sign-in with device tracking: sets a long-lived device token cookie.
161+
*
162+
* When `enable_cookie_auth` is false (default): this middleware is a no-op.
163+
* Bearer token authentication continues to work regardless of this setting.
164+
*/
165+
export const createCookieMiddleware = (): RequestHandler => {
166+
return (req: Request, res: Response, next: NextFunction): void => {
167+
const authSettings = req.api?.authSettings;
168+
169+
// Skip if cookie auth is not enabled
170+
if (!authSettings?.enableCookieAuth) {
171+
return next();
172+
}
173+
174+
const opName = getOperationName(req);
175+
if (!opName) {
176+
return next();
177+
}
178+
179+
// Sign-out: clear session cookie before passing through
180+
if (AUTH_MUTATIONS_SIGN_OUT.has(opName)) {
181+
const cookieOpts = buildCookieOptions(authSettings);
182+
res.clearCookie(SESSION_COOKIE_NAME, cookieOpts);
183+
log.info(`[cookie] Cleared session cookie for operation=${opName}`);
184+
return next();
185+
}
186+
187+
// Sign-in: intercept the response to set session cookie
188+
if (AUTH_MUTATIONS_SIGN_IN.has(opName)) {
189+
// Monkey-patch res.json to intercept the GraphQL response
190+
const originalJson = res.json.bind(res);
191+
res.json = (body: any) => {
192+
try {
193+
const accessToken = extractAccessToken(body, opName);
194+
if (accessToken) {
195+
const cookieOpts = buildCookieOptions(authSettings);
196+
res.cookie(SESSION_COOKIE_NAME, accessToken, cookieOpts);
197+
log.info(`[cookie] Set session cookie for operation=${opName}`);
198+
}
199+
200+
// Also handle device token cookie
201+
const deviceId = extractDeviceId(body, opName);
202+
if (deviceId) {
203+
const deviceCookieOpts = buildCookieOptions(authSettings);
204+
// Device tokens are long-lived (90 days default)
205+
deviceCookieOpts.maxAge = 90 * 24 * 60 * 60 * 1000;
206+
res.cookie(DEVICE_COOKIE_NAME, deviceId, deviceCookieOpts);
207+
log.info(`[cookie] Set device token cookie for operation=${opName}`);
208+
}
209+
} catch (e: any) {
210+
log.error(`[cookie] Error processing response for ${opName}:`, e.message);
211+
}
212+
213+
return originalJson(body);
214+
};
215+
}
216+
217+
next();
218+
};
219+
};
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { Logger } from '@pgpmjs/logger';
2+
import { createCsrfMiddleware, csrfErrorHandler } from '@constructive-io/csrf';
3+
import type { NextFunction, Request, RequestHandler, Response } from 'express';
4+
import './types'; // for Request type
5+
6+
const log = new Logger('csrf');
7+
8+
/**
9+
* Creates CSRF protection middleware that is aware of auth settings and token source.
10+
*
11+
* CSRF is only enforced when ALL of the following are true:
12+
* 1. `require_csrf_for_auth` is enabled in app_auth_settings
13+
* 2. `enable_cookie_auth` is enabled in app_auth_settings
14+
* 3. The current request was authenticated via a session cookie (not Bearer token)
15+
*
16+
* When the request uses a Bearer token (API tokens, service credentials),
17+
* CSRF protection is skipped because Bearer tokens are not automatically
18+
* attached by the browser and are therefore not vulnerable to CSRF attacks.
19+
*
20+
* This ensures the engineering team can continue using API tokens with zero changes
21+
* while gradually enabling cookie-based auth with CSRF protection.
22+
*/
23+
export const createCsrfProtectionMiddleware = (): {
24+
setToken: RequestHandler;
25+
protect: RequestHandler;
26+
errorHandler: (err: Error, req: Request, res: Response, next: NextFunction) => void;
27+
} => {
28+
const csrf = createCsrfMiddleware({
29+
cookieName: 'csrf_token',
30+
headerName: 'x-csrf-token',
31+
cookieOptions: {
32+
httpOnly: true,
33+
secure: process.env.NODE_ENV === 'production',
34+
sameSite: 'lax',
35+
maxAge: 86400,
36+
path: '/',
37+
},
38+
});
39+
40+
/**
41+
* Sets the CSRF token cookie on every request when CSRF is enabled.
42+
* This ensures the client has a token to submit on mutations.
43+
*/
44+
const setToken: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
45+
const authSettings = req.api?.authSettings;
46+
47+
// Only set CSRF cookie when both cookie auth AND CSRF are enabled
48+
if (!authSettings?.enableCookieAuth || !authSettings?.requireCsrfForAuth) {
49+
return next();
50+
}
51+
52+
csrf.setToken(req as any, res as any, next);
53+
};
54+
55+
/**
56+
* Validates the CSRF token on mutations when the request is cookie-authenticated.
57+
* Skips validation for Bearer-authenticated or anonymous requests.
58+
*/
59+
const protect: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
60+
const authSettings = req.api?.authSettings;
61+
62+
// Skip if cookie auth or CSRF is not enabled
63+
if (!authSettings?.enableCookieAuth || !authSettings?.requireCsrfForAuth) {
64+
return next();
65+
}
66+
67+
// Only enforce CSRF for cookie-authenticated requests.
68+
// Bearer token requests are immune to CSRF by design.
69+
if (req.tokenSource !== 'cookie') {
70+
return next();
71+
}
72+
73+
log.debug('[csrf] Enforcing CSRF protection for cookie-authenticated request');
74+
csrf.protect(req as any, res as any, next);
75+
};
76+
77+
/**
78+
* Error handler that converts CSRF errors into GraphQL-style error responses.
79+
* Must be registered as Express error-handling middleware (4 args).
80+
*/
81+
const errorHandler = (err: Error, _req: Request, res: Response, next: NextFunction): void => {
82+
csrfErrorHandler(err, _req, res as any, next);
83+
};
84+
85+
return { setToken, protect, errorHandler };
86+
};

0 commit comments

Comments
 (0)