diff --git a/apps/api/src/controllers/auth.ts b/apps/api/src/controllers/auth.ts index c068aa5..909e323 100644 --- a/apps/api/src/controllers/auth.ts +++ b/apps/api/src/controllers/auth.ts @@ -9,7 +9,7 @@ import { errors } from '../lib/http-error.js'; const accessCookieOpts: CookieOptions = { httpOnly: true, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/', maxAge: authService.ACCESS_TOKEN_TTL_MS, }; @@ -17,7 +17,7 @@ const accessCookieOpts: CookieOptions = { const refreshCookieOpts: CookieOptions = { httpOnly: true, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/api/auth', maxAge: authService.REFRESH_TOKEN_TTL_MS, }; diff --git a/apps/api/src/env.ts b/apps/api/src/env.ts index f67790d..0e3656d 100644 --- a/apps/api/src/env.ts +++ b/apps/api/src/env.ts @@ -11,4 +11,7 @@ if (!parsed.success) { process.exit(1); } -export const env = parsed.data; +export const env = { + ...parsed.data, + COOKIE_SECURE: parsed.data.COOKIE_SECURE ?? parsed.data.NODE_ENV === 'production', +}; diff --git a/apps/api/src/middleware/csrf.ts b/apps/api/src/middleware/csrf.ts index a559fc3..6be29a7 100644 --- a/apps/api/src/middleware/csrf.ts +++ b/apps/api/src/middleware/csrf.ts @@ -12,7 +12,7 @@ export function issueCsrfToken(res: Response): string { const opts: CookieOptions = { httpOnly: false, sameSite: 'lax', - secure: env.NODE_ENV === 'production', + secure: env.COOKIE_SECURE, path: '/', }; res.cookie(CSRF_COOKIE, token, opts); diff --git a/docker-compose.yml b/docker-compose.yml index ea44ac7..7b5a918 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -27,6 +27,9 @@ services: DATABASE_URL: file:/data/vector.db JWT_SECRET: ${JWT_SECRET:?JWT_SECRET is required — see .env.example} CLIENT_ORIGIN: ${CLIENT_ORIGIN:-http://localhost:8080} + # Browsers drop Secure cookies over plain HTTP. Flip to "true" once + # this deployment sits behind TLS (reverse proxy, Cloudflare, etc). + COOKIE_SECURE: ${COOKIE_SECURE:-false} volumes: - vector-data:/data healthcheck: diff --git a/packages/shared/src/env.ts b/packages/shared/src/env.ts index 6176bab..7d79808 100644 --- a/packages/shared/src/env.ts +++ b/packages/shared/src/env.ts @@ -13,5 +13,10 @@ export const ApiEnv = z.object({ message: 'JWT_SECRET still matches the default placeholder — generate a real secret', }), CLIENT_ORIGIN: z.string().url().default('http://localhost:5173'), + // Whether to mark auth + CSRF cookies Secure. Must be false for plain-HTTP + // deployments (browsers silently drop Secure cookies over http://). Leave + // unset to fall back to NODE_ENV === 'production'. + COOKIE_SECURE: z + .preprocess((v) => (typeof v === 'string' ? v === 'true' : v), z.boolean().optional()), }); export type ApiEnv = z.infer;