Skip to content

feat(auth): add JWT access tokens with rotating refresh sessions (E1.2)#73

Merged
parthrohit22 merged 6 commits into
devfrom
feature/auth-tokens
Jul 12, 2026
Merged

feat(auth): add JWT access tokens with rotating refresh sessions (E1.2)#73
parthrohit22 merged 6 commits into
devfrom
feature/auth-tokens

Conversation

@SHAURYAKSHARMA24

@SHAURYAKSHARMA24 SHAURYAKSHARMA24 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Summary

Implements E1.2 — native token authentication on top of E1.1's identity model, per the build-vs-buy decision recorded on #60 (native JWT over Auth0: PARTHA is open-source and self-hosted, so the core login path shouldn't require a vendor tenant, and the local User + scoping work was needed either way).

The branch contains only E1.2 auth and hardening changes on top of the current dev. #70 merged into dev on 2026-07-12 and this branch was rebased cleanly onto it; it carries no #70 diff.

What's here

  • Endpoints: POST /auth/register, /auth/login, /auth/refresh, /auth/logout, GET /auth/me.
  • Access tokens: HS256 JWTs, 15-minute TTL, signed with AUTH_SECRET_KEY. Staging/production refuse to boot without an explicit secret (and now enforce a ≥32-char floor outside dev/test); development/test substitute a fixed, clearly-labelled insecure value.
  • Refresh tokens: opaque 256-bit secrets in an httpOnly SameSite=Lax cookie scoped to /auth (XSS cannot read it; localhost:5173 → localhost:8000 is same-site, so dev works over plain http; Secure flag turns on outside dev/test). Only the sha256 of a token is ever stored. Every /auth/refresh rotates within a token family via an atomic UPDATE ... WHERE used_at IS NULL claim (the row lock serializes concurrent racers so only one winner mints a successor).
  • Concurrent refresh is strict fail-closed by design: if two requests present the same refresh token at once, exactly one wins the atomic claim; the loser is treated as replay and revokes the entire token family — including the winner's own successor. The winner's just-issued access token stays valid until its 15-minute expiry (it's stateless), but the refresh session is dead, so the user must sign in again later. This is intentional: any concurrent use of a refresh token is treated as possible theft, and silently preserving the winner's successor instead would let an attacker who wins a race keep a live session while the legitimate client is logged out. A more forgiving design (recoverable successor state, a grace window, device binding) is a larger protocol decision explicitly out of scope for E1.2. Consequence for the frontend (E1.4, E1.4 — Frontend auth flow (login/register, token handling, guarded routes) #64): the auth client must serialize refresh calls — one shared in-flight refresh per session, other 401s queued behind it — never send concurrent /auth/refresh requests for the same browser session. Noted on E1.4 — Frontend auth flow (login/register, token handling, guarded routes) #64.
  • Duplicate registration is race-safe: the existence check alone can't see a concurrent registration for the same email that commits between the check and the insert; the unique constraint is the real guard. The commit's IntegrityError is now caught, the session rolled back, and — only after confirming the email really does exist — the same 409/conflict_error the normal duplicate path returns is raised. An unrelated integrity failure is never mislabeled as a duplicate-email conflict, and no DB error text/table/constraint name is ever exposed.
  • Passwords: argon2id (argon2-cffi). Unknown emails and credential-less accounts (the seed user) burn the same dummy verification as a real attempt, and all credential failures return byte-identical 401 bodies — login cannot be used to enumerate accounts. Registration requires ≥10 chars; login deliberately doesn't reveal the policy.
  • Dependency seam: get_current_user is now strict (valid Bearer or 401) — first consumer is /auth/me. Repository routes move to get_current_user_or_default: a presented token is always validated strictly (bad token = 401, never a silent fallback to the seed user); only the absence of a token falls back to the temporary pre-auth attribution. That fallback (and the X-Dev-User header that drives it in development/test) is not removed by this PR — it's intentionally retained until E1.3 flips enforcement once the frontend can sign in (E1.4) — this PR deliberately breaks nothing.
  • Migration 0003_auth_credentials (21 chars — respects the 32-char alembic_version limit that bit feat(auth): add User model and per-user repository ownership (E1.1) #70), correctly based on the already-merged 0002_users_and_repo_owner: adds users.password_hash (nullable — pre-auth rows must never become logins) and the refresh_tokens table (hash, family, expiry, used/revoked timestamps, all indexed).
  • Model metadata registration follows dev's existing convention: app/models/__init__.py imports RefreshToken alongside RepositoryRecord/User, and main.py/alembic/env.py rely on that package-init side effect via from app.models.base import Base alone — no explicit per-name imports, no noqa: F401 anywhere in the backend. (Base.metadata.tables verified to contain repositories, users, refresh_tokens.)

Dependencies: PyJWT, argon2-cffi, pydantic[email] (pulls in email-validator/dnspython) — unchanged since the last update; apps/backend/requirements.txt was regenerated once from a clean venv and is not touched again here (nothing new to pin: IntegrityError handling uses SQLAlchemy, already a dependency). networkx/GitPython remain absent.

Type

  • Bug fix
  • Feature
  • Engineering task
  • Documentation
  • Security

Verification

  • Frontend build/lint run where relevant — no frontend feature changes; ran the full frontend regression check (lint/test/build) to confirm the merged test(frontend): stand up Vitest + React Testing Library harness (E3.2) #76 Vitest harness wasn't disturbed
  • Backend tests run where relevant
  • Docker Compose config/runtime readiness checked where relevant — migration runs via the existing alembic upgrade head CMD; CI's Docker Compose job passed on this head
  • Release/deployment docs updated where relevant — .env.example documents AUTH_SECRET_KEY + TTLs, including the generation command
  • Manual verification described below

Commands/results (local, Windows, Python 3.13; CI is Linux with a postgres:16 service):

$ python -m pytest
118 passed, 2 skipped, 5 warnings
# The 2 skips are the Postgres-gated concurrency test (no local Postgres/Docker
# in this environment) and one pre-existing platform-gated skip. CI's Backend
# job runs both for real — see the fresh CI status below for this head.

# What the auth tests prove, including this pass's two hardening fixes:
- duplicate registration: the normal (existence-check) 409 path, AND a
  deterministic commit-time race (two sessions, same normalized email,
  interleaved so the loser's commit collides on the unique constraint) ->
  same public 409/conflict_error, session usable after rollback, exactly
  one user row survives, normalization (case/whitespace) holds across the race
- refresh rotation: old cookie invalid, successor works
- reuse detection: replaying a rotated token revokes the WHOLE family
  (deterministic test proves the winner's own successor becomes unusable too,
  and every row in the family ends up with revoked_at set)
- atomic claim: a second claim of an already-used token affects zero rows
  (deterministic), and under real Postgres concurrency exactly one of two
  simultaneous refreshes succeeds - extended to also prove the *final* state:
  the winner's successor is captured, then shown unusable from a fresh
  session, with every family row confirmed revoked (gated on
  PARTHA_TEST_PG_URL; thread timeouts/unexpected exceptions fail the test
  rather than being read as an expected rejection)
- logout revokes and is idempotent
- expired / garbage / missing tokens -> 401
- wrong-password and unknown-email responses are byte-identical
- storage hygiene: password_hash starts with $argon2id$, refresh_tokens
  holds only 64-char sha256 hex, the raw token value appears nowhere
- bad Bearer on tolerant routes -> 401 (no silent seed-user fallback)
- anonymous access to /repositories unchanged (nothing breaks pre-E1.4)
- AUTH_SECRET_KEY < 32 chars outside dev/test fails startup

# Migration chain on a fresh SQLite DB:
$ alembic upgrade head && alembic downgrade base && alembic upgrade head
3 upgrades / 3 downgrades / 3 re-upgrades, schema verified;
Base.metadata.tables == {repositories, users, refresh_tokens}

# Lockfile: unchanged this pass (no new dependency), so not regenerated;
# already validated in an independent clean venv on the prior push.

CI status: see the PR checks for the current head — do not treat this section as a substitute for the live status.

Risk and Rollback

  • Risk level: Medium (new security-critical code; no change to existing route behaviour).
  • The enforcement flip is explicitly not in this PR and is E1.3's job — anonymous use of the current app is untouched, so a broken frontend between merges is impossible by construction.
  • Concurrent-refresh semantics are intentionally strict fail-closed (see above) — the frontend must serialize refresh calls; noted on E1.4 — Frontend auth flow (login/register, token handling, guarded routes) #64.
  • AUTH_SECRET_KEY handling: never logged, required outside dev/test with a ≥32-char floor, documented generation command. Rotating the secret invalidates all outstanding access tokens (acceptable: 15-min TTL) but not refresh sessions.
  • Known deliberate scope cuts (tracked for follow-up, not silently missing): email verification, password reset, MFA, /auth rate limiting (E2.1's budget list already includes /auth/login + /auth/register).
  • Docker/Compose validation ran through CI's postgres:16 service and Docker Compose job on this head, not a local Docker install — none was available in this environment.
  • Rollback: revert + alembic downgrade 0002_users_and_repo_owner.

Checklist

  • No unrelated behavior changes
  • No secrets, local env files, generated build artifacts, or local databases committed
  • API behavior documented if changed — new /auth/* endpoints described above; existing routes unchanged
  • UI states covered for loading, empty, error, and success where relevant — n/a, no UI in this PR
  • Security/privacy impact considered — argon2id, hashed rotating refresh tokens, strict family revocation on reuse (documented above), anti-enumeration responses, httpOnly cookie transport, race-safe duplicate registration

Closes #62

Comment thread apps/backend/app/auth/security.py Fixed
Comment thread apps/backend/alembic/env.py Fixed
Comment thread apps/backend/alembic/env.py Fixed
Comment thread apps/backend/alembic/env.py Fixed
Comment thread apps/backend/app/main.py Fixed
Comment thread apps/backend/tests/test_auth_concurrency.py Fixed
@SHAURYAKSHARMA24

Copy link
Copy Markdown
Collaborator Author

Dispositions for the automated review threads:

  • security.py empty except — addressed. burn_password_check now uses contextlib.suppress(VerifyMismatchError, VerificationError) with a comment explaining the result is discarded on purpose: the call exists only so an unknown-account login costs the same as a real one (anti-enumeration), so a mismatch is the expected, ignored outcome.
  • alembic/env.py and main.py "unused import" (RefreshToken/RepositoryRecord/User) — false positives. These imports exist purely for their side effect of registering the models in Base.metadata (so create_all and Alembic autogenerate see the full schema). They already carry # noqa: F401 with an explanatory comment; the bot doesn't honour noqa. Removing them would drop the tables from metadata.

Also added since first review: atomic refresh rotation (concurrency-safe), an AUTH_SECRET_KEY ≥32-char floor outside dev/test, a deterministic single-use claim test, and a real-Postgres threaded concurrency test wired into the Backend CI job.

Native token authentication on top of the E1.1 identity model. Purely
additive: existing routes keep their current behaviour, so nothing breaks
before the frontend can sign in (E1.4), and the single enforcement flip
stays E1.3's job.

- /auth/register, /auth/login, /auth/refresh, /auth/logout, /auth/me.
  Access tokens are HS256 JWTs (~15 min TTL) signed with AUTH_SECRET_KEY,
  which staging/production refuse to start without.
- Refresh tokens are opaque 256-bit secrets delivered in an httpOnly
  SameSite=Lax cookie scoped to /auth; only their sha256 is stored. Each
  refresh rotates the token within a family; presenting an already-used
  token is treated as theft and revokes the whole family, logging the event.
- Passwords are hashed with argon2id. Unknown emails and credential-less
  accounts (seed user) burn the same dummy verification as a real attempt,
  and every credential failure returns an identical 401 body, so responses
  cannot enumerate accounts. Registration enforces a 10-char minimum.
- get_current_user now strictly requires a Bearer token (first consumer:
  /auth/me). Repository routes move to get_current_user_or_default, which
  validates a presented token strictly (bad token = 401, never a silent
  fallback) and otherwise keeps the temporary pre-auth attribution that
  E1.3 will delete.
- Migration 0003_auth_credentials adds users.password_hash and the
  refresh_tokens table; upgrade/downgrade/upgrade verified on SQLite, and
  the revision id stays under the 32-char alembic_version limit.
- New deps: PyJWT, argon2-cffi, pydantic[email].

Tests: 17 new covering rotation, family revocation on reuse, logout
idempotency, expired/garbage/missing tokens, indistinguishable credential
failures, storage hygiene (argon2id hashes, no raw tokens in the database),
and that anonymous access to existing routes still works. Full suite:
109 passed, 1 skipped.
…or (E1.2)

Pre-review hardening for the auth PR.

- Refresh rotation is now atomic. The old read-then-write let two requests
  presenting the same token both pass the used_at check and each mint a
  successor. Rotation now claims the token with a single
  UPDATE ... WHERE used_at IS NULL: the row lock serializes racers and only the
  one whose update affects a row proceeds; the loser is treated as a concurrent
  replay and its family is revoked.
- Enforce AUTH_SECRET_KEY strength: outside development/test the signing key
  must be at least 32 characters, so a weak HS256 secret fails fast at startup
  instead of silently weakening every token.
- Replace the burn-check's empty except with contextlib.suppress plus a comment
  explaining the discarded result is intentional (anti-enumeration timing).

Tests:
- Deterministic single-use claim test (runs on any engine) proving a second
  claim of the same token affects zero rows.
- A real-Postgres threaded test: two concurrent refreshes of one token, exactly
  one succeeds. Gated on PARTHA_TEST_PG_URL and wired into the Backend CI job,
  which now runs a postgres:16 service so the race is exercised for real (SQLite
  serializes writes and cannot). Skips locally without the env var.
- Secret-strength validation test.

Full suite: 111 passed, 2 skipped (Postgres-gated + pre-existing).
…-validator (E1.2)

Owed since #72 merged the pinned-lockfile convention after this branch forked.
Regenerated from a clean venv per the file header procedure (pip install
./apps/backend into an empty venv, then freeze) so no stale packages from a
dev environment leak in.

Adds argon2-cffi + argon2-cffi-bindings (password hashing), PyJWT (access
tokens), and email-validator + dnspython (pydantic[email]'s validation
extra) - exactly the new dependencies pyproject.toml declares for E1.2.
Every other pinned version is unchanged; networkx and GitPython remain
absent.
The code-quality bot's finding on test_auth_concurrency.py:49 stayed
resolved-but-not-outdated through the rebase (the line was untouched), so
it was still live. Only token_id is asserted on; raw was never read.
AuthService.register() checked for an existing email, then inserted and
committed - two simultaneous registrations for the same normalized email
could both pass the check, and the loser's commit would raise a raw
IntegrityError, surfacing as a generic 500 instead of the intended 409.

Catch IntegrityError on commit, roll back (leaving the session usable),
and re-check whether the email now exists before reporting the same
ConflictServiceError the normal duplicate path already returns - any other
integrity failure is re-raised rather than mislabeled as a duplicate email.
No database error text, table, or constraint name is ever exposed; the
unique constraint itself is unchanged and remains the real guard.

Added a deterministic regression test that reproduces the exact race by
intercepting the losing session's own commit() to run a second, independent
session's successful registration first - the same database-level
interleaving two real concurrent requests would produce - then verifies:
the public 409/conflict_error contract, that the session is usable after
rollback, that email normalization holds across the race (mixed case and
whitespace on both sides), and that exactly one user row exists afterward.
The existing Postgres concurrency test only proved "one refresh wins, one
is rejected." It didn't prove or document what happens to the winner's own
successor once the loser's replay handling revokes the whole family - which
is the actual, intentional, strict fail-closed behavior: any concurrent
presentation of the same refresh token is treated as possible theft, so the
winner is logged out too rather than silently kept alive. Improving UX here
(e.g. preserving the winner) would need a larger protocol decision -
recoverable successor state, a grace window, device binding - that is out
of scope for E1.2 and is not implemented here.

Extended the real-Postgres test to capture the winner's successor, then
from a fresh session after both threads finish: confirm the successor is
itself rejected, confirm every row in the token family has revoked_at set,
and confirm no unused/unrevoked row remains. Thread timeouts and unexpected
exceptions (anything other than the intended UnauthorizedError) now fail
the test explicitly rather than being folded into "rejected."

Added a deterministic companion test (SQLite, any engine) that proves the
same service-level rule sequentially, without needing threads or a real row
lock - the atomic claim primitive itself is already covered separately by
test_claim_token_is_single_use; this proves what happens next once a claim
is lost.
@parthrohit22 parthrohit22 merged commit c2b9782 into dev Jul 12, 2026
9 checks passed
@parthrohit22 parthrohit22 deleted the feature/auth-tokens branch July 12, 2026 16:37
SHAURYAKSHARMA24 added a commit that referenced this pull request Jul 12, 2026
…RS/security headers and concurrent atomicity (E2.1)

Pre-merge correctness pass after rebasing onto dev (post-#73 auth merge):
the Redis client built in build_rate_limit_store was never closed, leaking
its connection pool across app restarts/reloads. lifespan now closes it via
a duck-typed aclose() on shutdown, a no-op for the in-memory store.

Also strengthens test coverage per review: a 429 now has an explicit test
proving it still carries CORS and security headers (RateLimitMiddleware is
innermost of the three), and the gated real-Redis test gains a concurrent-hit
case proving the atomic Lua script yields exact, non-overlapping counts under
real parallel load rather than only sequential calls.
parthrohit22 pushed a commit that referenced this pull request Jul 12, 2026
…RS/security headers and concurrent atomicity (E2.1)

Pre-merge correctness pass after rebasing onto dev (post-#73 auth merge):
the Redis client built in build_rate_limit_store was never closed, leaking
its connection pool across app restarts/reloads. lifespan now closes it via
a duck-typed aclose() on shutdown, a no-op for the in-memory store.

Also strengthens test coverage per review: a 429 now has an explicit test
proving it still carries CORS and security headers (RateLimitMiddleware is
innermost of the three), and the gated real-Redis test gains a concurrent-hit
case proving the atomic Lua script yields exact, non-overlapping counts under
real parallel load rather than only sequential calls.
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.

E1.2 — Implement JWT issue/verify with refresh-token rotation

2 participants