Skip to content

feat(auth): issue rotating refresh tokens (SHD-1084) - #35

Merged
janosmurai merged 1 commit into
mainfrom
SHD-1084/refresh-tokens
Oct 19, 2026
Merged

janosmurai merged 1 commit into
mainfrom
SHD-1084/refresh-tokens

Conversation

@janosmurai

@janosmurai janosmurai commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

Type of change

  • New feature
  • Bug fix
  • Breaking change
  • Refactoring or maintenance
  • Documentation update

Related issue

Closes SHD-1084

Description

/v1/login returned only a one-hour access token, so clients that need a session longer than an hour kept the user's password and logged in again. This PR adds rotating refresh tokens, so clients can drop the password.

API

  • POST /v1/login now also returns refresh_token and refresh_token_expires_in. The change is additive.
  • POST /v1/token/refresh with {"refresh_token": ...} returns a new access token and a new refresh token, in the same AccessTokenResponse shape. The token that was sent is invalidated.
  • POST /v1/logout with the same body revokes the session. It always answers 204, so it doesn't reveal which tokens exist.

Security model

  • A refresh token is 32 random bytes (aws-lc-rs SystemRandom). Only its SHA-256 digest is stored. That's enough at this entropy, and it keeps refreshing cheap compared to Argon2.
  • All tokens rotated out of one login share a family_id. Presenting a token that was already rotated means it was copied, so the whole family is revoked, including the legitimate successor.
  • Within 30s of a rotation (REFRESH_TOKEN_REUSE_GRACE), presenting the old token just gets a 401, without revoking. This covers two tabs or requests refreshing at the same time. Rotation is a conditional UPDATE ... WHERE used_at IS NULL plus the successor INSERT, in one transaction, so only one concurrent refresh can succeed.
  • A refresh token is valid for 30 days, capped at the user's account expiry, the same way access tokens are capped. If the account has expired when the client refreshes, the family is revoked.
  • refresh_tokens.user_id uses ON DELETE CASCADE, so deleting a user also deletes their sessions.
  • Expired rows are cleaned up on login. Used and revoked rows are kept until they expire, so presenting them is still recognised as reuse.

Validation

  • cargo fmt --check
  • cargo clippy --all-targets --all-features -- -D warnings
  • cargo build --verbose --all-features
  • cargo test --verbose --all-features

Additional validation details:

  • Ran just check: 170 tests passed.
  • shaide-db tests: rotation stores the successor in the same family, a token rotates only once, revoking a family doesn't affect other families, and cleanup deletes only expired rows.
  • Route tests (routes::users::tests):
    • the refresh round trip, including that the new access token validates for the right user;
    • presenting the old token within the grace period returns 401 and keeps the session;
    • reuse after the grace period revokes the successor too;
    • reuse revokes only its own session (another login of the same user keeps working);
    • logout revokes the session, and answers 204 for unknown tokens;
    • an unknown token returns 401;
    • an expired account returns 401 and its family is revoked;
    • the refresh token doesn't outlive the account.
  • .sqlx offline data regenerated with cargo sqlx prepare --workspace: 6 new query files, existing ones unchanged.

Deployment and compatibility

  • API: additive. Existing clients ignore the new login fields. shaide-cli deserializes AccessTokenResponse, so it needs a server with this change. Both ship from this repo.
  • Configuration: none. Refresh tokens are opaque and don't depend on JWT_SECRET, so rotating the JWT secret logs users out of their access tokens but not their sessions.

Documentation

The README's Authentication section now covers refresh, rotation, reuse detection and logout. The new endpoints are in the OpenAPI spec.

Checklist

  • I added or updated tests for changed behavior, or explained why tests are not needed.
  • I updated user-facing and API documentation where necessary.
  • My commits follow the Conventional Commits specification.
  • Every commit includes a Signed-off-by trailer (git commit -s).

Additional information

  • The ticket also mentions revoking sessions on user disable and password change. The server has no endpoints for either yet. When they're added, they should call revoke_refresh_token_family for each of the user's families, or delete the user's rows.
  • The grace window trades a little security for not logging users out on a benign race. During those 30 seconds, an attacker replaying the just-rotated token gets a 401 but doesn't trigger revocation.

AI assistance:

  • Tool: Claude Code
  • Model: Claude Opus 5.5
  • Used for: design, implementation, tests, README section and this description
  • Human verification: done

@janosmurai
janosmurai requested a review from gaboraxem as a code owner October 18, 2026 17:51
Signed-off-by: janosmurai <janos.murai@axem.dev>
@janosmurai
janosmurai force-pushed the SHD-1084/refresh-tokens branch from e0dd660 to 87d5f36 Compare October 19, 2026 12:29
@janosmurai
janosmurai merged commit 559e267 into main Oct 19, 2026
1 check passed
@janosmurai
janosmurai deleted the SHD-1084/refresh-tokens branch October 19, 2026 12:56
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