Skip to content

feat: Slice 4 — auth failure modes & token lifecycle hardening#14

Merged
itlamb merged 3 commits into
mainfrom
afk/issue-4
May 7, 2026
Merged

feat: Slice 4 — auth failure modes & token lifecycle hardening#14
itlamb merged 3 commits into
mainfrom
afk/issue-4

Conversation

@itlamb
Copy link
Copy Markdown
Member

@itlamb itlamb commented May 7, 2026

Summary

  • requireAuth now correctly rejects Bearer abc (double-space token) as Malformed Authorization header (not just Invalid token)
  • login use-case logs a SHA-256 hash of the email address on failure — never the plaintext email
  • Patient/doctor beforeLoad guards detect an existing (but invalid) token and redirect to /login?reason=session-expired
  • Login page renders a session-expired toast banner when the ?reason=session-expired search param is present
  • Login page shows per-field validation errors from the 422 issues array next to the offending fields

Test plan

  • pnpm verify clean (6 test files, 41 tests)
  • Integration: expired/tampered/wrong-secret/empty/double-space/no-header/deleted-sub JWT cases
  • Integration: 422 with both email+password errors listed; 503 on DB unreachable; 415 body
  • Log event tests: auth.login.success fields; auth.login.failed contains no plaintext email
  • pnpm test:e2e — tampered token → session-expired toast (requires running stack)
  • pnpm test:e2e — unauthenticated → silent redirect (no toast)

Closes #4

🤖 Generated with Claude Code

itlamb and others added 2 commits May 7, 2026 13:41
- GET /auth/me: expired/tampered/wrong-secret/empty/double-space/no-header/deleted-sub cases
- POST /auth/login: both-fields-invalid 422, DB-unreachable 503, 415 body
- Log events: auth.login.success fields, auth.login.failed must not contain plaintext email
- e2e: tampered token -> /login with session-expired toast; unauthenticated -> silent redirect

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…og, session-expired toast

- requireAuth rejects 'Bearer  abc' (double-space token) as Malformed Authorization header
- login use-case logs SHA-256 hash of email, never plaintext
- patient/doctor beforeLoad detects existing token and redirects with reason=session-expired
- login page renders session-expired toast from ?reason=session-expired search param
- login page shows per-field validation errors on 422 response

Closes #4

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@claude
Copy link
Copy Markdown

claude Bot commented May 7, 2026

Claude finished @itlamb's task in 3m 45s —— View job


Review: Slice 4 — auth failure modes & token lifecycle

Verdict: APPROVE (with non-blocking notes)

This is a tight, well-scoped slice. It pins exactly the failure-mode polish that was missing on top of the auth groundwork from issues #1#3, and the tests are appropriately external-behavior focused.


Spec compliance vs. acceptance criteria

AC Status Where
Expired JWT → 401 Token expired require-auth.ts:31-33, test auth.test.ts:197-205
Tampered JWT → 401 Invalid token auth.test.ts:207-216
Different-secret JWT → 401 Invalid token auth.test.ts:218-226
Wrong scheme / empty / double-space → Malformed Authorization header require-auth.ts:20-27; the new token[0] === ' ' check at line 25 covers Bearer abc
Missing header → Authorization header required require-auth.ts:16-18
Deleted sub → 401 Account not found me-route.ts:25, auth.test.ts:228-243
422 lists both invalid fields auth.test.ts:93-106
415 on text/plain login-route.ts:17-19, auth.test.ts:108-118
503 on DB unreachable auth.test.ts:120-133 (uses real bad URL — no mocking, good)
Hashed-email + neutral identical 401 for wrong-creds / unknown-email login.ts:9-11,26,32; auth.test.ts:265-280
auth.login.success log fields auth.test.ts:247-263
Logger redaction (nested) already in logger.test.ts from Slice 1
Tampered token → /login + session-expired toast (e2e) auth.spec.ts:76-91
Missing token → silent /login (e2e) auth.spec.ts:69-74 (asserts toast not visible)
422 surfaces field errors in form login.tsx:66-73,113-115,131-133

All hard acceptance criteria are met.


Architecture findings

✅ Layering is clean: route → use-case → infrastructure. No layer-skipping.
✅ Co-located tests, contracts imported from @medbridge/contracts, no any, no console.log.

⚠️ The issue's body (not the AC checklist) called for a global TanStack Query error handler on the QueryClient (onError for 401s). The implementation puts that logic in each route's beforeLoad instead (patient.tsx:10-25, doctor.tsx:10-25). Functionally equivalent for this slice (only one authenticated query exists), but the chosen pattern won't generalise — every future protected query/mutation will need to repeat the redirect-on-401 dance unless a global QueryCache({ onError }) is added. Worth a follow-up before Module 01 lands, not a blocker here.


Security findings

✅ Wrong-creds and unknown-email return byte-identical 401 bodies and emit the same auth.login.failed event with the same emailHash shape — no user enumeration via response or log content.
✅ SHA-256 over email.toLowerCase() is consistent with the lower-cased email comparison in findUserByEmail, so log correlation works.
JWT_SECRET is narrowed via Pick<AppConfig, 'JWT_SECRET'> everywhere it's passed.

Minor — none of these are blockers:

  • The emailHash is unsalted SHA-256, so a known-dictionary attacker who exfiltrates logs could reverse it. Acceptable for a prototype, but worth flagging as an ADR-worthy decision before this hits any non-throwaway environment.
  • require-auth.ts:25 checks token[0] === ' ' to catch the double-space case. Correct but cryptic; the comment-free style fits CLAUDE.md so I'd leave it.

Test quality

✅ All new tests assert observable behaviour (status, body, log lines via captured sink) — no implementation-coupling.
503 test uses a real DB connection to a refused port instead of mocking. Matches the project's "no mocking the DB" rule.
✅ The plaintext-email-leak test (auth.test.ts:276-279) checks the joined log output — solid pinning test.

Gaps, all minor:

  1. No log-event assertion for auth.login.db_error. The 503 test verifies status+body but not that the named log event fired. AC says "Every failure path writes the named structured log event" — this one slips through.
  2. No log-event assertion for the deleted-sub case. The "Account not found" test verifies HTTP behaviour but not the log line. (get-user-profile.ts:23 emits auth.token.invalid for this case, which is reasonable but unasserted.)
  3. No unit/component test for the new login-page logicValidationError extraction, fieldErrors, the session-expired banner. The e2e covers the toast path; the per-field error path is only covered at the API contract level.
  4. The PR's own test plan has the two e2e checkboxes unticked — author flagged that they require a running stack. Worth a sanity-run before merge.

Suggestions (non-blocking)

  • 415 on the client surfaces "Invalid credentials". login.tsx:51 treats anything that isn't 200 or 422 as Error('invalid_credentials'), so a 415 / 503 from the API renders the wrong-creds banner. The issue body called for "Something went wrong" on 415 specifically. Fix this →
  • Add log-event assertions for auth.login.db_error and the deleted-sub path to fully satisfy the AC line "Every failure path writes the named structured log event". Fix this →
  • Migrate the redirect-on-401 logic to a global QueryCache({ onError }) in main.tsx before Module 01 introduces more authenticated queries — otherwise each new feature folder will copy the beforeLoad pattern. Fix this →
  • Consider documenting the unsalted-email-hash decision in docs/ADRs/ so a future reader knows it's a deliberate prototype tradeoff, not an oversight.

@itlamb itlamb merged commit 5d0b2a8 into main May 7, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Slice 4: Auth failure modes & token lifecycle hardening

1 participant