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

11 KiB

convoy brief_number depends_on files cross_brief_commitments
fix-auth-bypass 1
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
brief description
2 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 description
4 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 description
5 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.jsnew
  • 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):
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.jspages/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:
// 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.jspages/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.jslib/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.jslib/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.jslib/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.