Skip to content

feat: Tier 0 server auth - cookie lifecycle, CSRF enforcement, device tokens, SSO wiring#1004

Open
pyramation wants to merge 3 commits intomainfrom
devin/1776502080-tier0-cookie-csrf-sso
Open

feat: Tier 0 server auth - cookie lifecycle, CSRF enforcement, device tokens, SSO wiring#1004
pyramation wants to merge 3 commits intomainfrom
devin/1776502080-tier0-cookie-csrf-sso

Conversation

@pyramation
Copy link
Copy Markdown
Contributor

@pyramation pyramation commented Apr 18, 2026

Summary

Adds the server-side foundation for cookie-based authentication as an opt-in layer alongside existing Bearer token auth. All features are gated behind app_auth_settings toggles (enable_cookie_auth, require_csrf_for_auth) and are off by default — zero behavior changes for existing API token flows.

New middleware files:

  • cookie.ts — Intercepts auth mutation responses (signIn, signUp, completeMfaChallenge, etc.) to set/clear an HttpOnly constructive_session cookie from the returned access_token. Also sets a long-lived constructive_device_token cookie when device tracking returns a device_id. Only active when enable_cookie_auth = true. Uses res.writeHead()/res.end() interception (not res.json()) because grafserv bypasses Express response helpers — see details below.

  • csrf.ts — Wires the existing @constructive-io/csrf package into the middleware chain. CSRF validation is only enforced on cookie-authenticated requests (req.tokenSource === 'cookie'). Bearer token requests skip CSRF entirely since they're not vulnerable to CSRF attacks.

  • oauth.ts — Mounts /auth/:provider, /auth/:provider/callback, and /auth/providers routes. On successful OAuth callback, calls sign_in_sso() on the tenant DB private schema and optionally sets the session cookie.

  • captcha.ts (modified) — Now accepts ConstructiveOptions and reads opts.captcha.recaptchaSecretKey instead of process.env.RECAPTCHA_SECRET_KEY.

Other changes:

  • AuthSettings interface and AUTH_SETTINGS_SQL expanded with enableCookieAuth and requireCsrfForAuth
  • req.tokenSource ('bearer' | 'cookie' | 'none') set in auth middleware for downstream use
  • cookie-parser, @constructive-io/csrf, @constructive-io/oauth added as server dependencies
  • Middleware wired into server.ts in the correct order: cookieParser → api → OAuth routes → authenticate → CSRF setToken → CSRF protect → captcha → cookie lifecycle → graphile

Env var convention refactoring

All new middleware follows the workspace convention of zero direct process.env access:

  • New types: OAuthOptions, OAuthProviderCredentials, CaptchaOptions added to ConstructiveOptions (in graphql/types)
  • Centralized parsing: OAUTH_* (12 env vars) and RECAPTCHA_SECRET_KEY are now parsed in getGraphQLEnvVars() (graphql/env/src/env.ts) — same pattern as SMTP_*, API_*, etc.
  • getNodeEnv(): All process.env.NODE_ENV checks in cookie.ts, csrf.ts, oauth.ts, captcha.ts, and server.ts replaced with getNodeEnv() from @pgpmjs/env
  • Config via opts: oauth.ts reads providers/baseUrl/redirects from opts.oauth.*; captcha.ts reads the secret key from opts.captcha.recaptchaSecretKey
  • Result: Zero process.env references in any new middleware file

grafserv response interception

Research into grafserv (PostGraphile v5) source confirmed it never calls res.json(). Instead, it writes responses directly via:

// grafserv/dist/servers/node/index.js, "json" case (~line 105)
const buffer = Buffer.from(JSON.stringify(json), "utf8");
headers["Content-Length"] = String(buffer.length);
res.writeHead(statusCode, headers);
res.end(buffer);

The cookie middleware therefore:

  1. Defers res.writeHead() — captures statusCode and headers without sending them
  2. Intercepts res.end() — parses the JSON body from the buffer, extracts accessToken/deviceId, builds Set-Cookie header values manually via serializeCookie()/serializeClearCookie() helpers
  3. Merges cookie headers into the deferred headers, then flushes writeHead + end together

Review & Testing Checklist for Human

  • writeHead/end deferral in cookie.ts (~lines 252–330): If any grafserv code path calls res.end() without first calling res.writeHead(), deferredStatusCode will be undefined and the interceptor will skip writeHead entirely, falling through to originalEnd — this should be safe (Node.js auto-sends headers), but verify no edge cases produce malformed responses. Also confirm Content-Length (set by grafserv based on body buffer size) remains accurate after Set-Cookie headers are injected into the headers object (it should, since Content-Length refers to the body, not headers).
  • Operation name extraction: Cookie middleware reads req.body.operationName to identify auth mutations. This requires the body to be parsed before this middleware runs, and the client must send operationName in the request. Unnamed mutations will silently skip cookie-setting — confirm this is acceptable or if a fallback (AST parsing) is needed.
  • OAuth email_verified hardcoded to false (oauth.ts line 80): The SSO flow always passes false for email_verified to sign_in_sso(). Providers like Google do return verified email status — confirm whether this conservative default is acceptable or if provider-specific logic is needed.
  • OAuth WeakMap<object, Request> pattern (oauth.ts ~line 148): The request is stashed keyed on req.query to pass it into the onSuccess callback. If the OAuth package reconstructs the query object rather than preserving the same reference, the lookup will silently fail and sign_in_sso() won't be called. Verify with an actual OAuth flow.
  • OAuth provider discovery hardcoded to 4 providers (graphql/env/src/env.ts): Only google, github, facebook, linkedin are parsed from env vars. Adding a new provider requires a code change in getGraphQLEnvVars(). Confirm this is acceptable vs. a dynamic scan approach.
  • Device token cookie maxAge hardcoded to 90 days (cookie.ts line ~297): Not configurable via app_auth_settings. Confirm this is acceptable for now or should be a DB-configurable value.

Recommended test plan: Enable enable_cookie_auth and require_csrf_for_auth in a tenant's app_auth_settings, then:

  1. Call signIn mutation with Bearer token → verify no cookie is set, CSRF is not enforced
  2. Call signIn mutation without Bearer token → verify constructive_session cookie is set in response
  3. Call a mutation with only the session cookie → verify CSRF enforcement kicks in (403 without token)
  4. Call signOut → verify session cookie is cleared
  5. Verify existing Bearer token flows are completely unchanged

Notes

  • The lockfile diff is large but is mostly YAML formatting changes (multi-line → single-line resolution fields) plus the 3 new dependencies (cookie-parser, @types/cookie-parser, and workspace links for csrf/oauth).
  • No unit tests are included for the new middleware files. Consider adding tests for parseIntervalToMs, serializeCookie/serializeClearCookie, the cookie extraction helpers, and the CSRF skip logic.
  • The OAuth middleware uses a WeakMap<object, Request> keyed on req.query to pass the Express request into the onSuccess callback — this is a workaround for the OAuth package's callback API not exposing the raw request.
  • Pre-existing process.env usage in upload.ts (MAX_UPLOAD_FILE_SIZE) and graphile.ts (NODE_ENV for explain) are out of scope for this PR.

Link to Devin session: https://app.devin.ai/sessions/12acfda2a5434d2686c63515cfeb2610
Requested by: @pyramation

… 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
@devin-ai-integration
Copy link
Copy Markdown
Contributor

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@socket-security
Copy link
Copy Markdown

socket-security bot commented Apr 18, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​cookie-parser@​1.4.101001008481100

View full report

…okie injection

grafserv (PostGraphile v5) writes responses via res.writeHead() + res.end(buffer),
completely bypassing Express's res.json(). The previous res.json monkey-patch would
never fire for GraphQL responses.

New approach: defer res.writeHead(), intercept res.end() to parse the JSON body,
compute Set-Cookie headers, merge them into the deferred headers, then flush
writeHead + end together. Also adds serializeCookie/serializeClearCookie helpers
since we can't rely on Express's res.cookie() when headers are managed manually.
…NodeEnv()

- Add OAuthOptions and CaptchaOptions to ConstructiveOptions type
- Add OAUTH_* and RECAPTCHA_SECRET_KEY parsing to getGraphQLEnvVars()
- Replace process.env.NODE_ENV with getNodeEnv() in cookie.ts, csrf.ts, oauth.ts, server.ts
- Refactor oauth.ts to read providers/baseUrl/redirects from opts.oauth
- Refactor captcha.ts to read recaptchaSecretKey from opts.captcha
- Pass effectiveOpts to createCaptchaMiddleware() in server.ts
- Zero process.env references remain in new middleware files
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant