deckhearth/.convoys/fix-auth-bypass/brief-1-central-jwt-secret-helper.md
Randall Stillwell 1667b87ee3 convoy(fix-auth-bypass): architect plan + 5 briefs (Wave A/B/C dispatch)
Architect pass for P0 security-critical convoy (closes ship-blockers
#1, #2, #4, #5, #6 partial). Produces 5 briefs with explicit
slice_dependencies for /multitask fan-out.

Decomposition:
- Brief 1: Central JWT secret helper + 24h token TTL (8 files, ~120 LOC)
- Brief 2: Remove the synthetic-admin bypass (2 files, ~25 LOC net negative)
- Brief 3: Delete 4 dev-only endpoints + CI guard (6 files, ~30 LOC)
- Brief 4: Tighten auth surface — CORS + rate limit (5 files, ~150 LOC)
- Brief 5: Install vitest + auth tests + re-enable CI test job (8 files, ~280 LOC)

Total estimate: ~600 LOC across 5 PRs. All under 400-LOC budget.

Wave A (parallel from t=0): Briefs 1 + 3 (disjoint files)
Wave B (parallel after Brief 1): Briefs 2 + 4 (disjoint subsets of Brief 1's exports)
Wave C (after Briefs 2 + 4): Brief 5 alone (lockfile sequencing + functional dep on Brief 2's null contract)

Architect's calls (3 decisions documented in convoy file Decisions log):
- Token TTL = 24h (matches current login.js UX; security-conservative)
- Rate-limit = @upstash/ratelimit@^2.0.8 + @upstash/redis@^1.38.0
  (DIY-Postgres needs schema change OOS; DIY-memory broken on Vercel
  cold starts; next-rate-limit is stale)
- Vitest in this convoy (not split to adopt-vitest); pinned to ^3.2.4
  to dodge vitest@4's non-optional vite peer dep

Boot-the-brief findings (9 verifications, 0 revisions):
- 24/24 getUserFromRequest callers already handle null correctly —
  Brief 2 is safer than the convoy file predicted
- 7 JWT_SECRET literal sites match AGENTS.md gotcha #3 exactly
- Dev endpoints have zero runtime references (only doc references) —
  safe to delete
- Cross-brief commitments declared in both directions for every
  Brief-1 -> {2,4,5} pair

Risk list: 12 risks documented (R1-R12). Headlines:
- R1: JWT_SECRET fail-loud throws may break unexpected import chains
- R3: existing tokens stop verifying once literal fallback removed
  (one-time "log back in" pre-launch is acceptable)
- R5-R6: rate-limit IP extraction + Upstash quota; fail-open mitigation
- R10: JWT_SECRET rotation now requires a deploy (no silent fallback)

Pre-merge env-var checklist (user action required before Brief 4 ships):
- UPSTASH_REDIS_REST_URL (new — Vercel project settings)
- UPSTASH_REDIS_REST_TOKEN (new — Vercel project settings)
- JWT_SECRET (verify already set — no fallback any more)

Awaiting human gate 1 (plan approval) before implementers run.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 02:58:16 -05:00

179 lines
11 KiB
Markdown

---
convoy: fix-auth-bypass
brief_number: 1
depends_on: []
files:
- lib/auth-secret.js
- lib/permission-middleware.js
- pages/api/auth-utils.js
- pages/api/auth/login.js
- pages/api/auth/register.js
- pages/api/auth/verify.js
- pages/api/favorites.js
- pages/api/users/search.js
cross_brief_commitments:
- brief: 2
description: |
Brief 2 modifies `lib/permission-middleware.js` (replaces the synthetic-admin
fallback in `getUserFromRequest`) and `pages/api/auth/verify.js` (removes the
no-token admin-fetch branch). This brief MUST land first, because Brief 2
relies on the `JWT_SECRET` import already being in place.
- brief: 4
description: |
Brief 4 modifies `pages/api/auth/login.js` and `pages/api/auth/register.js`
(drops `Access-Control-Allow-Origin: '*'`, wraps with rate limiter). This
brief MUST land first, because Brief 4 builds on the post-refactor login /
register handlers (no `JWT_SECRET` literal, `generateToken` from `auth-utils`).
- brief: 5
description: |
Brief 5 (vitest + tests) imports `JWT_SECRET` and `JWT_TOKEN_TTL` from
`lib/auth-secret.js` in test setup. This brief MUST land first.
---
# Brief 1: Central JWT secret helper + 24h token TTL
## Goal (1 sentence)
Create `lib/auth-secret.js` as the single source of truth for `JWT_SECRET` (fail-loud at module load if unset) and `JWT_TOKEN_TTL = '24h'`, then refactor the 7 files currently embedding `process.env.JWT_SECRET || '…'` literals to import from it.
## Files in scope (do not edit anything else)
- `lib/auth-secret.js`**new**
- `lib/permission-middleware.js` — modified (literal → import)
- `pages/api/auth-utils.js` — modified (literal → import; `'7d'``JWT_TOKEN_TTL`)
- `pages/api/auth/login.js` — modified (literal → import; inline `jwt.sign(...)``generateToken(user)` from `auth-utils`; drop now-unused `jwt` import)
- `pages/api/auth/register.js` — modified (same as login)
- `pages/api/auth/verify.js` — modified (literal → import). **Do NOT remove the no-token admin-fetch branch here** — that's Brief 2's scope. Just swap the secret literal for the import.
- `pages/api/favorites.js` — modified (literal → import)
- `pages/api/users/search.js` — modified (literal → import)
## Conventions to follow
- `.cursor/rules/auth-and-permissions.mdc` § "Token model" — JWT model + signing surface.
- `.cursor/rules/api-routes.mdc` § "Authentication & Authorization" — handler shape stays the same; only the secret source changes.
- `.cursor/rules/no-go-zones.mdc` — do not edit any file outside `files:` above. In particular: no edits to `lib/auth-context.js`, `lib/admin-auth.js`, `lib/use-auth.js`, `lib/database.js`, `pages/_app.js`, or any UI file. Auth-context cleanup is the future `single-auth-provider` convoy.
- `package.json` formatting: 2-space indent, `"type": "module"` is set — use ES module imports throughout.
- Existing `import` style in `pages/api/auth-utils.js`: relative paths, no aliases. Match.
- No `engines` block change.
- No new dependencies in `package.json`. (Brief 4 adds `@upstash/ratelimit`; Brief 5 adds `vitest`. This brief adds nothing.)
## Acceptance criteria
### `lib/auth-secret.js` (new)
- [ ] File contains exactly two named exports: `JWT_SECRET` and `JWT_TOKEN_TTL`.
- [ ] `JWT_SECRET` reads `process.env.JWT_SECRET`. If unset OR empty string, the module **throws at import time** with a clear, actionable message that names the env var and points at `.env.local`. Verbatim shape (or near-verbatim — the message body can be reworded but the shape must be):
```js
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error(
'JWT_SECRET environment variable is not set. ' +
'Set it in .env.local for local dev, or in the Vercel project settings for deploys. ' +
'Generate a strong secret with: openssl rand -hex 32'
);
}
export { JWT_SECRET };
export const JWT_TOKEN_TTL = '24h';
```
- [ ] **No fallback string literal.** A previous fallback `'your-secret-key-change-in-production'` is what we are explicitly removing — do not reintroduce it under any condition.
- [ ] No length check (a length check is tempting but not required by the convoy and risks breaking existing valid-but-shorter dev secrets in `.env.local`; defer to a future hardening pass).
- [ ] No default export.
- [ ] No top-level side effects beyond the throw on missing env (no `console.log`, no `dotenv.config()` — Next.js loads `.env.local` automatically, and tests load env via `test/setup.js` in Brief 5).
### `pages/api/auth-utils.js`
- [ ] Line 4 (`const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';`) **deleted**.
- [ ] Add at top of file: `import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js';`
- [ ] `generateToken(user)` returns `jwt.sign({...}, JWT_SECRET, { expiresIn: JWT_TOKEN_TTL })` — the literal `'7d'` is replaced. **This is the canonical token-minting function.**
- [ ] `verifyToken(token)` continues to call `jwt.verify(token, JWT_SECRET)` (no expiry param needed on verify).
- [ ] No other behavior change. `hashPassword`, `verifyPassword`, `isAdmin`, `getUserById` are untouched.
### `pages/api/auth/login.js`
- [ ] Line 5 (`const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';`) **deleted**.
- [ ] Replace `import jwt from 'jsonwebtoken';` (line 2) with `import { generateToken } from '../../auth-utils.js';`. The path is `pages/api/auth/login.js``pages/api/auth-utils.js`, so relative import is `../auth-utils.js`. Verify by reading line 4 of `pages/api/auth/register.js` for the existing relative-import pattern (`'../../../lib/slug-utils.js'`).
- [ ] Replace the inline JWT mint:
```js
// before (lines 51-55)
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
// after
const token = generateToken({ id: user.id, email: user.email, role: user.role });
```
Note the param shape change: `generateToken` reads `user.id` (not `user.userId`), per the existing implementation in `auth-utils.js`.
- [ ] **No CORS change here.** Brief 4 will tighten `Access-Control-Allow-Origin: '*'`. Leave it alone in this brief.
- [ ] **No rate-limit wiring here.** Brief 4 wraps with `@upstash/ratelimit`. Leave the handler shape alone.
### `pages/api/auth/register.js`
- [ ] Line 6 (`const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';`) **deleted**.
- [ ] Replace `import jwt from 'jsonwebtoken';` with `import { generateToken } from '../auth-utils.js';`. Relative path: `pages/api/auth/register.js``pages/api/auth-utils.js` is `'../auth-utils.js'`.
- [ ] Replace the inline JWT mint at lines 142-147 with `const token = generateToken({ id: user.id, email: user.email, role: user.role });`
- [ ] Same CORS / rate-limit hands-off rule as login.
### `pages/api/auth/verify.js`
- [ ] Line 4 literal **deleted**.
- [ ] Add `import { JWT_SECRET } from '../../../lib/auth-secret.js';` at top. Path correctness: `pages/api/auth/verify.js``lib/auth-secret.js` is `'../../../lib/auth-secret.js'`.
- [ ] Keep `jwt.verify(token, JWT_SECRET)` inline (do not refactor to call `verifyToken` from `auth-utils.js` — that would change error semantics, and Brief 2 is already going to touch this file. Keep this brief mechanical).
- [ ] **Do NOT remove the no-token admin-fetch branch (lines 25-39).** That is Brief 2's job. Touching it here splits the security fix across two PRs unnecessarily.
### `pages/api/favorites.js`
- [ ] Line 4 literal **deleted**.
- [ ] Add `import { JWT_SECRET } from '../../lib/auth-secret.js';` at top. Path: `pages/api/favorites.js``lib/auth-secret.js` is `'../../lib/auth-secret.js'`.
- [ ] Keep `jwt.verify(token, JWT_SECRET)` inline. No other change.
### `pages/api/users/search.js`
- [ ] Line 4 literal **deleted**.
- [ ] Add `import { JWT_SECRET } from '../../../lib/auth-secret.js';` at top. Path: `pages/api/users/search.js``lib/auth-secret.js` is `'../../../lib/auth-secret.js'`.
- [ ] Keep `jwt.verify(token, JWT_SECRET)` inline. No other change.
### `lib/permission-middleware.js`
- [ ] Line 4 literal **deleted**.
- [ ] Add `import { JWT_SECRET } from './auth-secret.js';` at top.
- [ ] **Keep the rest of `getUserFromRequest` unchanged in this brief.** The synthetic-admin fallback removal is Brief 2's job.
- [ ] `withCollectionPermission`, `checkCollectionPermission`, `logCollectionActivity` are untouched.
### Repo-wide grep verification (run before opening PR)
- [ ] `rg "process\.env\.JWT_SECRET" --type js` returns **zero hits** in `lib/`, `pages/`. (Hits in `.convoys/`, `.cursor/`, `AGENTS.md`, `docs/` are documentation references — leave them alone in this brief.)
- [ ] `rg "your-secret-key" --type js` returns zero hits.
- [ ] `rg "'7d'" --type js pages/api/auth-utils.js` returns zero hits (replaced by `JWT_TOKEN_TTL`).
- [ ] `rg "'24h'" --type js pages/api/auth/` returns zero hits (replaced via `generateToken`).
### Smoke (manual, no test runner yet — Brief 5 adds vitest)
Document that you ran these in the PR description (not enforced in CI):
- [ ] `npm run lint` exits 0 (or matches the existing baseline — pre-existing errors are fine, no new ones).
- [ ] `npm run dev` boots; visit `http://localhost:3000/login`; submit valid credentials; observe that `localStorage.auth_token` is set and decoding the token shows `exp - iat ≈ 86400` (24h, not 7 days).
- [ ] Temporarily unset `JWT_SECRET` in `.env.local` and run `npm run dev`. Confirm the server logs the thrown error and the page returns 500. **Re-set `JWT_SECRET` before opening the PR.**
- [ ] `npm run build` succeeds. Vercel's preview deploy on the PR is green.
### Out of scope (do not do these)
- [ ] No edit to `pages/_app.js`, `lib/auth-context.js`, `lib/admin-auth.js`, `lib/use-auth.js`. Client-side context cleanup is the future `single-auth-provider` convoy.
- [ ] No edit to `AGENTS.md` or `.cursor/rules/auth-and-permissions.mdc`. Doc-writer pass updates these after the convoy lands.
- [ ] No removal of the synthetic-admin fallback in `getUserFromRequest` — Brief 2.
- [ ] No removal of the no-token admin branch in `verify.js` — Brief 2.
- [ ] No CORS changes — Brief 4.
- [ ] No rate-limit wiring — Brief 4.
- [ ] No test files — Brief 5.
- [ ] No deletion of `pages/api/test-*.js`, `pages/api/simple.js`, `pages/api/setup-database.js` — Brief 3.
## Rationale (≤3 sentences)
Centralizing `JWT_SECRET` removes 7 copies of the fallback literal in one PR, making the eventual fail-closed runtime behavior trivial to audit. Co-locating `JWT_TOKEN_TTL` in the same module canonicalizes 24h (matching current `login.js` behavior, which is what existing users have been getting) and resolves the silent inconsistency between `auth-utils.generateToken` (`'7d'`) and `login.js` (`'24h'`). Routing `login.js` and `register.js` through `auth-utils.generateToken` removes a second, drift-prone JWT-mint call site; the alternative — leaving inline `jwt.sign` everywhere — would make the next refactor more painful for no gain.