Skip to content

feat: add JWT authentication and gate the quiz flow - #6

Merged
krishhimself merged 1 commit into
mainfrom
feat/auth
Aug 22, 2026
Merged

feat: add JWT authentication and gate the quiz flow#6
krishhimself merged 1 commit into
mainfrom
feat/auth

Conversation

@krishhimself

Copy link
Copy Markdown
Owner

Summary

JWT auth across backend and frontend, following the existing layering: primitives in
core/, a repository that is the only thing touching the users collection, a
service holding the logic, and a thin endpoint module that only maps outcomes to
status codes.

Why this matters beyond "add login"

user_id previously arrived in the body of /quiz/generate, so any caller could
attribute an attempt to anyone. It is now removed from QuizGenerateRequest entirely
and taken from the token subject.

test_generate_attributes_the_quiz_to_the_token_not_the_body sends
user_id: "somebody-else" in the body with a token for real-user, and asserts the
service receives real-user.

Scope note: all three quiz routes are gated, not just submit

The spec asked for submit. But user_id only ever arrived on generate, so gating
submit alone would have left the forgeable attribution in place. All of
/generate, /submit, /followup now require a token.

Live verification

Against the real backend and real Atlas, spending zero Gemini quota — step 8 uses
a nonexistent quiz, so a 404 proves auth passed without generating anything:

1. register            -> 201  token issued
2. register duplicate  -> 409  "That email is already registered."
3. login (UPPERCASE)   -> 200  token issued          <- email folding works
4. login wrong pw      -> 401  "Incorrect email or password."
5. login unknown email -> 401  "Incorrect email or password."   <- identical to #4
6. submit NO token     -> 401
7. submit BAD token    -> 401
8. submit GOOD token   -> 404  <- gate passed a valid token through

Stored document:

fields          : ['_id', 'created_at', 'email', 'hashed_password', 'role']
hash prefix     : $2b$12$  (bcrypt)
plaintext absent: True

Test user deleted afterward.

Security choices

  • Login cannot enumerate accounts. Unknown email and wrong password return
    byte-identical 401s; a test asserts the responses are indistinguishable.
  • Passwords over 72 bytes are rejected, not truncated. bcrypt silently ignores
    everything past that boundary — a password whose tail never mattered is worse than
    a rejected one.
  • A corrupt stored hash is a failed login, not a 500, so a bad row cannot be used
    to probe the endpoint.
  • alg: none is rejected, along with expired, tampered, and foreign-secret tokens.

Dependency problem worth reviewing

passlib[bcrypt] as specified does not work on current versions. passlib 1.7.4
(last released 2020) probes bcrypt.__about__, removed in 4.1; against bcrypt 5.x
its backend self-test fails outright with password cannot be longer than 72 bytes,
breaking hashing entirely rather than degrading.

Kept passlib as specified, pinned bcrypt<5, verified on 4.3.0. Its
trapped-exception traceback is silenced so it stops printing on every boot.

The cost is a pinned crypto dependency behind an unmaintained wrapper. Dropping
passlib for bcrypt directly, or pwdlib (its maintained successor), is contained to
core/security.py.

Frontend

Token lives in shared/api/token.js, not the auth feature — the request client needs
it, and importing a feature from shared would invert the layering. The client
attaches the header automatically so no feature has to remember, and clears the token
on any 401 so the UI cannot insist it is logged in while every call fails. QuizPage
hands control back to App on a 401 rather than showing an error the user cannot act
on.

Two gaps left open, deliberately

Email uniqueness is racy. register_user does a read-before-write, so concurrent
signups with the same address can both succeed. models/user.py carries the fix:

db.users.create_index("email", unique=True)

Creating indexes is a migration concern rather than something to bury in a service call.

No ownership check on quiz attempts. A logged-in user can submit against any
quiz_id they know. Auth establishes who you are; it does not yet enforce that the
quiz is yours. Given the anonymous-first profiles on the roadmap, that model is a
decision rather than an oversight — but it is a real IDOR today.

Verification

93 tests pass (83 backend, 10 frontend), vite build clean. 32 new backend tests
across test_security.py and test_auth_api.py.

Pre-existing quiz API tests now run through an authenticated client; rejection of
missing and bad tokens is covered separately in test_auth_api.py.

Not verified: the interactive browser flow — the Chrome extension would not
connect. The login and register forms have never been submitted in a browser.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj

Backend follows the existing layering: security primitives in core, a users
repository that is the only thing touching that collection, an auth service holding
the logic, and a thin endpoint module that only maps outcomes to status codes.

Quiz attribution is the reason this exists. user_id previously arrived in the body
of /quiz/generate, so any caller could attribute an attempt to anyone. It is now
removed from QuizGenerateRequest entirely and taken from the token subject.

All three quiz routes are gated, not just submit as specified. Gating submit alone
would have left the forgeable attribution in place, since generate is the only route
user_id ever arrived on. A quiz attempt is a record of what a specific person
understood, so an unattributed one is not meaningful.

Two deliberate choices in the auth surface. Login answers identically for an unknown
email and a wrong password, so the endpoint cannot be used to enumerate accounts.
Registration returns a token directly rather than requiring a second call. Emails are
folded to lowercase on both write and lookup.

Passwords over 72 bytes are rejected rather than accepted. bcrypt silently ignores
everything past that boundary, and a password whose tail never mattered is worse than
a rejected one. A corrupt stored hash is treated as a failed login rather than a 500,
so a bad row cannot be used to probe the endpoint.

On the dependency: passlib[bcrypt] as specified does not work against current
versions. passlib 1.7.4 was last released in 2020 and probes bcrypt.__about__, which
4.1 removed; against bcrypt 5.x its backend self-test fails outright and hashing
breaks entirely rather than degrading. bcrypt is pinned below 5 and verified on
4.3.0, and passlib's trapped-exception traceback is silenced so it stops printing on
every boot. Dropping passlib for bcrypt directly, or pwdlib, would remove the pin and
is contained to core/security.py.

pydantic[email] is added for EmailStr validation. TokenResponse carries role so the
client knows what it is logged in as without decoding the token.

Frontend stores the token in shared/api rather than the auth feature, because the
request client needs it and importing a feature from shared would invert the
layering. The client attaches the header automatically so no feature has to remember,
and clears the token on any 401 so the UI cannot insist it is logged in while every
call fails. QuizPage hands control back to App on a 401 rather than showing an error
the user cannot act on.

Two gaps left open and documented rather than papered over. Email uniqueness is a
read-before-write and races under concurrent signup; models/user.py carries the index
command that actually fixes it. And there is no ownership check on quiz attempts - a
logged-in user can submit against any quiz_id they know. Auth establishes who you
are; it does not yet enforce that the quiz is yours, and the anonymous-first profiles
on the roadmap should decide that model.

Verified live against Atlas with no Gemini quota spent: register 201, duplicate 409,
login with an uppercased email 200, wrong password and unknown email both 401 with
identical bodies, submit without a token 401, with a forged token 401, and with a
valid token 404 for a nonexistent quiz - proving the gate passes a good token
through. Stored document holds a $2b$ hash and no plaintext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PET9qKZXhgjEbZK7MReYQj
@krishhimself
krishhimself merged commit e09ad87 into main Aug 22, 2026
2 checks passed
@krishhimself
krishhimself deleted the feat/auth branch August 22, 2026 15:49
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.

1 participant