From 811063553e1362f59dc5fe5b058271f31f612bfb Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Mon, 17 Aug 2026 15:33:25 -0700 Subject: [PATCH] feat(auth): validate authentication configuration --- AGENTS.md | 11 ++++ apps/api/.env.example | 7 +++ apps/api/src/config/env-schema.ts | 75 ++++++++++++++++++++++++++ apps/api/src/config/env.test.ts | 87 +++++++++++++++++++++++++++++++ apps/api/src/config/env.ts | 13 ++--- docs/architecture.md | 17 ++++++ 6 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/config/env-schema.ts create mode 100644 apps/api/src/config/env.test.ts diff --git a/AGENTS.md b/AGENTS.md index e3de78d..f051fb1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -424,6 +424,17 @@ When applicable: Do not disable lint or TypeScript rules merely to avoid fixing an issue without explaining why. +### File Purpose Comments + +Every new or modified source, configuration, and test file that supports +comments must begin with one concise comment describing the file's primary +responsibility. + +The comment should explain the file's purpose, not restate its implementation. +Keep it accurate as the responsibility changes. Do not bulk-edit unrelated +existing files solely to add comments, and do not add comments to generated +files or formats that do not support them. + ## Dependencies diff --git a/apps/api/.env.example b/apps/api/.env.example index 87711c5..11d81f3 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,3 +1,10 @@ +# Safe local configuration template; replace placeholders in a private .env file. PORT=3000 DATABASE_URL=postgresql://goforlift:goforlift_dev@localhost:5432/goforlift WEB_ORIGIN=http://localhost:5173 +API_ORIGIN=http://localhost:3000 +GOOGLE_CLIENT_ID=replace-with-google-client-id +GOOGLE_CLIENT_SECRET=replace-with-google-client-secret +GOOGLE_OIDC_REDIRECT_URI=http://localhost:3000/auth/google/callback +SESSION_SECRET=replace-with-at-least-32-random-characters +SESSION_DURATION_SECONDS=604800 diff --git a/apps/api/src/config/env-schema.ts b/apps/api/src/config/env-schema.ts new file mode 100644 index 0000000..7058da2 --- /dev/null +++ b/apps/api/src/config/env-schema.ts @@ -0,0 +1,75 @@ +/** Validates environment variables and derives typed runtime configuration. */ +import { z } from 'zod'; + +const SEVEN_DAYS_SECONDS = 7 * 24 * 60 * 60; + +const httpUrl = z.url().refine((value) => { + if (!URL.canParse(value)) { + return false; + } + + const protocol = new URL(value).protocol; + + return protocol === 'http:' || protocol === 'https:'; +}, 'Must use the http or https protocol'); + +const rawEnvSchema = z + .object({ + NODE_ENV: z + .enum(['development', 'test', 'production']) + .default('development'), + PORT: z.coerce.number().int().positive().default(3000), + DATABASE_URL: z.url(), + WEB_ORIGIN: httpUrl.default('http://localhost:5173'), + API_ORIGIN: httpUrl.default('http://localhost:3000'), + GOOGLE_CLIENT_ID: z.string().trim().min(1), + GOOGLE_CLIENT_SECRET: z.string().trim().min(1), + GOOGLE_OIDC_REDIRECT_URI: httpUrl, + SESSION_SECRET: z.string().min(32), + SESSION_DURATION_SECONDS: z.coerce + .number() + .int() + .positive() + .refine( + (value) => value === SEVEN_DAYS_SECONDS, + 'Must match the approved seven-day session lifetime', + ) + .default(SEVEN_DAYS_SECONDS), + }) + .superRefine((value, context) => { + if (value.NODE_ENV !== 'production') { + return; + } + + for (const key of [ + 'WEB_ORIGIN', + 'API_ORIGIN', + 'GOOGLE_OIDC_REDIRECT_URI', + ] as const) { + if (!value[key].startsWith('https://')) { + context.addIssue({ + code: 'custom', + message: 'Must use HTTPS in production', + path: [key], + }); + } + } + }); + +export function parseEnv(source: NodeJS.ProcessEnv) { + const parsed = rawEnvSchema.parse(source); + + return { + ...parsed, + SESSION_COOKIE: { + name: 'goforlift.sid', + httpOnly: true, + secure: parsed.NODE_ENV === 'production', + sameSite: 'lax' as const, + path: '/', + maxAgeMs: parsed.SESSION_DURATION_SECONDS * 1000, + }, + }; +} + +export type Env = ReturnType; diff --git a/apps/api/src/config/env.test.ts b/apps/api/src/config/env.test.ts new file mode 100644 index 0000000..d221f16 --- /dev/null +++ b/apps/api/src/config/env.test.ts @@ -0,0 +1,87 @@ +/** Verifies valid, missing, malformed, and environment-specific configuration. */ +import { describe, expect, it } from 'vitest'; +import { ZodError } from 'zod'; + +import { parseEnv } from './env-schema.js'; + +const validEnvironment = { + NODE_ENV: 'development', + PORT: '3000', + DATABASE_URL: 'postgresql://user:password@localhost:5432/goforlift', + WEB_ORIGIN: 'http://localhost:5173', + API_ORIGIN: 'http://localhost:3000', + GOOGLE_CLIENT_ID: 'example-client-id.apps.googleusercontent.com', + GOOGLE_CLIENT_SECRET: 'example-client-secret', + GOOGLE_OIDC_REDIRECT_URI: 'http://localhost:3000/auth/google/callback', + SESSION_SECRET: 'development-only-secret-at-least-32-characters', + SESSION_DURATION_SECONDS: '604800', +} satisfies NodeJS.ProcessEnv; + +describe('parseEnv', () => { + it('parses valid authentication configuration', () => { + const parsed = parseEnv(validEnvironment); + + expect(parsed.SESSION_DURATION_SECONDS).toBe(604800); + expect(parsed.SESSION_COOKIE).toEqual({ + name: 'goforlift.sid', + httpOnly: true, + secure: false, + sameSite: 'lax', + path: '/', + maxAgeMs: 604800000, + }); + }); + + it.each([ + 'GOOGLE_CLIENT_ID', + 'GOOGLE_CLIENT_SECRET', + 'GOOGLE_OIDC_REDIRECT_URI', + 'SESSION_SECRET', + ] as const)('rejects a missing %s', (key) => { + const environment = { ...validEnvironment }; + delete environment[key]; + + expect(() => parseEnv(environment)).toThrow(ZodError); + }); + + it('rejects malformed URLs and session durations', () => { + expect(() => + parseEnv({ + ...validEnvironment, + GOOGLE_OIDC_REDIRECT_URI: 'not-a-url', + SESSION_DURATION_SECONDS: 'one week', + }), + ).toThrow(ZodError); + }); + + it('rejects a session duration that differs from the approved policy', () => { + expect(() => + parseEnv({ + ...validEnvironment, + SESSION_DURATION_SECONDS: '86400', + }), + ).toThrow('Must match the approved seven-day session lifetime'); + }); + + it('requires HTTPS origins and redirects in production', () => { + expect(() => + parseEnv({ + ...validEnvironment, + NODE_ENV: 'production', + }), + ).toThrow('Must use HTTPS in production'); + }); + + it('enables secure cookies for valid production configuration', () => { + const parsed = parseEnv({ + ...validEnvironment, + NODE_ENV: 'production', + WEB_ORIGIN: 'https://app.goforlift.example', + API_ORIGIN: 'https://api.goforlift.example', + GOOGLE_OIDC_REDIRECT_URI: + 'https://api.goforlift.example/auth/google/callback', + }); + + expect(parsed.SESSION_COOKIE.secure).toBe(true); + }); +}); diff --git a/apps/api/src/config/env.ts b/apps/api/src/config/env.ts index 7413d85..5817c41 100644 --- a/apps/api/src/config/env.ts +++ b/apps/api/src/config/env.ts @@ -1,13 +1,6 @@ +/** Loads and validates the API's runtime environment during startup. */ import 'dotenv/config'; -import { z } from 'zod'; -const envSchema = z.object({ - NODE_ENV: z - .enum(['development', 'test', 'production']) - .default('development'), - PORT: z.coerce.number().int().positive().default(3000), - DATABASE_URL: z.string().url(), - WEB_ORIGIN: z.string().url().default('http://localhost:5173'), -}); +import { parseEnv } from './env-schema.js'; -export const env = envSchema.parse(process.env); +export const env = parseEnv(process.env); diff --git a/docs/architecture.md b/docs/architecture.md index b8376fd..bdd793a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -643,6 +643,23 @@ Provide safe example configuration through files such as: Never commit real credentials or secrets. +Authentication startup configuration includes: + +- `WEB_ORIGIN` and `API_ORIGIN` as exact HTTP(S) origins; production values + must use HTTPS +- `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` for the Google OIDC client +- `GOOGLE_OIDC_REDIRECT_URI` as the exact registered callback URL; production + must use HTTPS +- `SESSION_SECRET` with at least 32 characters +- `SESSION_DURATION_SECONDS=604800`, matching the approved fixed seven-day + lifetime + +The API validates these values before startup. Missing or malformed values must +fail fast without logging credential values. Derived cookie configuration uses +the approved policy and enables `Secure` automatically in production. Safe +development placeholders belong in `apps/api/.env.example`; real secrets do +not. + # 27. Authentication