feat: add JWT authentication and gate the quiz flow - #6
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
JWT auth across backend and frontend, following the existing layering: primitives in
core/, a repository that is the only thing touching theuserscollection, aservice holding the logic, and a thin endpoint module that only maps outcomes to
status codes.
Why this matters beyond "add login"
user_idpreviously arrived in the body of/quiz/generate, so any caller couldattribute an attempt to anyone. It is now removed from
QuizGenerateRequestentirelyand taken from the token subject.
test_generate_attributes_the_quiz_to_the_token_not_the_bodysendsuser_id: "somebody-else"in the body with a token forreal-user, and asserts theservice receives
real-user.Scope note: all three quiz routes are gated, not just submit
The spec asked for submit. But
user_idonly ever arrived ongenerate, so gatingsubmit alone would have left the forgeable attribution in place. All of
/generate,/submit,/followupnow 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:
Stored document:
Test user deleted afterward.
Security choices
byte-identical 401s; a test asserts the responses are indistinguishable.
everything past that boundary — a password whose tail never mattered is worse than
a rejected one.
to probe the endpoint.
alg: noneis 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.xits 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. Itstrapped-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
bcryptdirectly, orpwdlib(its maintained successor), is contained tocore/security.py.Frontend
Token lives in
shared/api/token.js, not the auth feature — the request client needsit, and importing a feature from
sharedwould invert the layering. The clientattaches 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.
QuizPagehands control back to
Appon a 401 rather than showing an error the user cannot acton.
Two gaps left open, deliberately
Email uniqueness is racy.
register_userdoes a read-before-write, so concurrentsignups with the same address can both succeed.
models/user.pycarries the fix: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_idthey know. Auth establishes who you are; it does not yet enforce that thequiz 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 buildclean. 32 new backend testsacross
test_security.pyandtest_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