Skip to content

feat: add refresh token support - #82

Merged
addisonkline merged 1 commit into
mainfrom
kline/v2-refresh-token
Jun 29, 2026
Merged

feat: add refresh token support#82
addisonkline merged 1 commit into
mainfrom
kline/v2-refresh-token

Conversation

@addisonkline

Copy link
Copy Markdown
Collaborator

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_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. 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

  • ProtocolRefreshTokenRecord; refresh_token/expires_in added to the token response; new AuthRefreshPostRequest / AuthRefreshPostResponse / AuthLogoutPostResponse.
  • Backends — six refresh-token methods on the MAILServerBackend protocol, implemented for both memory (persisted via fs checkpoints) and sqlite (new refresh_tokens table, FK ON DELETE CASCADE). Dual-backend conformance tests.
  • ServerPOST /auth/token mints the token + sets the cookie; new POST /auth/refresh (rotate + reuse detection + fail-closed if owner deleted) and POST /auth/logout; POST /auth/password/reset now revokes all of the principal's families.
  • Config — env-only MAIL_REFRESH_TOKEN_EXPIRE_DAYS (required), MAIL_COOKIE_SECURE (default on), MAIL_COOKIE_DOMAIN (optional), consistent with the existing MAIL_JWT_* vars.
  • Client — new mail refresh command (alias rt); mail login now surfaces the refresh token.
  • Docs — HTTP API reference (incl. a browser silent-refresh pattern note), server/client quickstarts, .env.example, regenerated spec/openapi.yaml.

Design decisions (confirmed during planning)

  1. Stateful (not a second long-lived JWT) — so logout / password-reset can actually revoke sessions.
  2. Absolute expiry, rotation carries expires_at forward (no sliding window).
  3. Refresh tokens persist across memory-backend restarts.
  4. Logout returns {"status":"success"}.
  5. Concurrent-refresh handling: client single-flight, no server grace window.
  6. MAIL_REFRESH_TOKEN_EXPIRE_DAYS is hard-required (mirrors MAIL_JWT_*).

Full plan: .plans/refresh_tokens.md.

Testing

  • In-process suite: 738 passed, 1 skipped, 6 xfailed (pre-existing stub xfails).
  • e2e (server subprocess): 6 passed.
  • ruff + mypy clean across all changed files.
  • New tests: dual-backend conformance (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. the Secure flag (test_refresh_auth_helpers.py), and CLI mail refresh.

Notes for reviewers

  • The dual-backend conformance test caught a real cross-backend bug: SQLite DateTime round-trips tz-naive while memory is tz-aware — fixed by normalizing to UTC in the serializer (refresh_token_from_row).
  • FastAPI applies cookies set on the injected Response only on return, not on raise. 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 via max-age. Cookie clearing works on all success paths and logout.

🤖 Generated with Claude Code

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 rheaton64 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. 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.

  2. 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

@addisonkline
addisonkline merged commit 989b459 into main Jun 29, 2026
2 checks passed
@addisonkline
addisonkline deleted the kline/v2-refresh-token branch June 29, 2026 19:12
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.

2 participants