Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
@@ -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
75 changes: 75 additions & 0 deletions apps/api/src/config/env-schema.ts
Original file line number Diff line number Diff line change
@@ -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<typeof parseEnv>;
87 changes: 87 additions & 0 deletions apps/api/src/config/env.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 3 additions & 10 deletions apps/api/src/config/env.ts
Original file line number Diff line number Diff line change
@@ -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);
17 changes: 17 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down