feat: add refresh token support - #82
Conversation
Add a stateful, rotating refresh-token mechanism so browsers (and other long-lived clients) can renew their access-token JWT without re-entering a password. Refresh tokens are opaque, high-entropy strings stored hashed (sha256) and grouped into families: login starts a family, each rotation keeps the family and carries its absolute `expires_at` forward unchanged (no sliding window). Presenting a revoked-or-rotated token is treated as reuse and revokes the whole family. Only interactive principals (users/admins) get refresh tokens; agents and daemons re-authenticate with their credentials. Delivery is dual: the token is returned in the response body and set as an `httpOnly; Secure; SameSite=Strict` cookie scoped to `/auth`, so browsers get silent renewal while the wider API stays header-only and CSRF-immune; the CLI sends the token back in the request body. - protocol: `RefreshTokenRecord`; `refresh_token`/`expires_in` on the token response; `AuthRefreshPostRequest`/`AuthRefreshPostResponse`/`AuthLogoutPostResponse` - backends: six refresh-token methods on the protocol, implemented for both the memory (persisted via fs checkpoints) and sqlite (new `refresh_tokens` table, FK cascade on user deletion) backends, with dual-backend conformance tests - server: `/auth/token` mints + sets the cookie, new `/auth/refresh` (rotate + reuse detection + fail-closed on deleted owner) and `/auth/logout`, and `/auth/password/reset` now revokes all of the principal's families - config: env-only `MAIL_REFRESH_TOKEN_EXPIRE_DAYS` (required), `MAIL_COOKIE_SECURE`, `MAIL_COOKIE_DOMAIN` - client: `mail refresh` command; `mail login` surfaces the refresh token - docs: HTTP API reference, quickstarts, .env.example, regenerated openapi.yaml Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
rheaton64
left a comment
There was a problem hiding this comment.
Reviewed end-to-end. Approving — the design is sound and the
test coverage covers each property at the right layer.
What I checked
Stateful refresh with rotation. Tokens stored as sha256
hashes; rotation creates a new row and sets the old row's
rotated_at. The reuse-detection check (record.revoked or record.rotated_at is not None) triggers
revoke_refresh_family(record.family_id) — that's the
load-bearing security property. A leaked token presented after
rotation kills the whole family, not just the leaked token.
Absolute expiry, no sliding window. record.expires_at <= now() checked on every refresh. Rotation carries the family's
expires_at forward unchanged. Predictable: a family always
dies at a fixed time. The right call for security — sliding
windows compound badly when combined with reuse-detection edge
cases.
Fail-closed on owner deletion.
user_agent_exists(record.owner_address) checked after
freshness and before rotation. If an admin deletes a user
while their refresh token is in flight, the next refresh
attempt revokes the family. This is the property the
test_refresh_flow owner-deleted case verifies.
Cookie + body dual delivery. httpOnly; Secure; SameSite=Strict cookie scoped to /auth for browsers;
body field for CLI. The PR notes that the cookie wins when
both are present — I verified the read path in
_read_refresh_token, which checks the cookie first via
request.cookies.get(...) before falling back to the body.
Interactive principal restriction.
is_interactive_principal returns True only for MAILUser | MAILAdmin. Agents and daemons get no refresh token —
they re-authenticate with credentials. This is the right call:
unattended principals have no UX cost from re-auth, and
issuing them refresh tokens would only widen the attack
surface. Chorus's agent flow is unaffected.
Logout is idempotent. post_auth_logout revokes the
family if the token resolves but always clears the cookie and
returns status='success'. A stale or absent token still
logs the client out cleanly — the right shape for browsers
that might race with their own auth state.
Password reset cascades. The PR notes that password reset
revokes all of the principal's families — that's the right
default for a primary credential change. Verified in
test_refresh_flow's password-reset cascade case.
What I noticed for chorus's own usage
Chorus calls POST /auth/token with admin credentials
per-operation (delete_agent, register_webhook, etc.) — minting
a fresh JWT and discarding it after the operation completes.
After this PR merges, those admin auths will ALSO create a
refresh_token row that chorus never uses.
Not a correctness issue — the rows expire on their own at
MAIL_REFRESH_TOKEN_EXPIRE_DAYS. Chorus's existing
MailClient.auth_token just extracts access_token from
the response and ignores the new refresh_token /
expires_in fields, so the response-shape change is
backwards-compatible.
Minor accumulation rate; not worth changing the chorus pattern
for v1. If the accumulation ever becomes load-bearing on the
sqlite backend's storage, the right fix is a periodic cleanup
of expired rows (and Addison may already have a sweep in
mind).
Small notes (neither blocking)
-
The dual-backend conformance test catching the tz-naive
sqlite round-trip bug is exactly the value of running the
conformance suite over both backends. Worth keeping that
discipline as the storage layer evolves. -
Concurrent-refresh handling pushed to client single-flight
with no server grace window. That's the simpler answer
and the safer one for v1. If browser clients ever hit the
race (e.g., two tabs racing the refresh on a 401), the
single-flight pattern in the client is the place to add
grace, not the server.
Verifying chorus stays clean
No chorus changes needed for this PR. MailClient.auth_token
extracts access_token only; the new response fields are
silently ignored. Verified locally — all 332 chorus tests
still pass with the new shape on the response.
Approving. Merge when ready. Have a great weekend.
— minichorus-pm
Summary
Adds a stateful, rotating refresh-token mechanism so browsers (and other long-lived clients) can renew their access-token JWT without re-entering a password.
Refresh tokens are opaque, high-entropy strings stored hashed (sha256) and grouped into families: login starts a family, each rotation keeps the family and carries its absolute
expires_atforward unchanged (no sliding window). Presenting a revoked-or-rotated token is treated as reuse and revokes the whole family. Only interactive principals (users/admins) get refresh tokens; agents and daemons re-authenticate with their credentials.Delivery is dual: the token is returned in the response body and set as an
httpOnly; Secure; SameSite=Strictcookie scoped to/auth. Browsers get silent renewal via the cookie while the wider API stays header-only and CSRF-immune; the CLI sends the token back in the request body. The cookie takes precedence when both are present.What changed
RefreshTokenRecord;refresh_token/expires_inadded to the token response; newAuthRefreshPostRequest/AuthRefreshPostResponse/AuthLogoutPostResponse.MAILServerBackendprotocol, implemented for both memory (persisted via fs checkpoints) and sqlite (newrefresh_tokenstable, FKON DELETE CASCADE). Dual-backend conformance tests.POST /auth/tokenmints the token + sets the cookie; newPOST /auth/refresh(rotate + reuse detection + fail-closed if owner deleted) andPOST /auth/logout;POST /auth/password/resetnow revokes all of the principal's families.MAIL_REFRESH_TOKEN_EXPIRE_DAYS(required),MAIL_COOKIE_SECURE(default on),MAIL_COOKIE_DOMAIN(optional), consistent with the existingMAIL_JWT_*vars.mail refreshcommand (aliasrt);mail loginnow surfaces the refresh token..env.example, regeneratedspec/openapi.yaml.Design decisions (confirmed during planning)
expires_atforward (no sliding window).{"status":"success"}.MAIL_REFRESH_TOKEN_EXPIRE_DAYSis hard-required (mirrorsMAIL_JWT_*).Full plan:
.plans/refresh_tokens.md.Testing
ruff+mypyclean across all changed files.test_refresh_tokens_backend.py), HTTP flow incl. rotation/reuse/expiry/owner-deleted/logout/password-reset cascade (test_refresh_flow.py), auth/cookie helpers incl. theSecureflag (test_refresh_auth_helpers.py), and CLImail refresh.Notes for reviewers
DateTimeround-trips tz-naive while memory is tz-aware — fixed by normalizing to UTC in the serializer (refresh_token_from_row).Responseonly on return, not onraise. So refresh failure paths (reuse/expired/owner-deleted) revoke the family server-side (the real protection) but don't actively clear the stale cookie on that 401; it's neutralized server-side and expires viamax-age. Cookie clearing works on all success paths and logout.🤖 Generated with Claude Code