Skip to content

feat: Session token rotation(M2-11012) - #2105

Open
sricharan-varanasi wants to merge 5 commits into
session-token-short-lived-web-adminfrom
session-token-rotation
Open

feat: Session token rotation(M2-11012)#2105
sricharan-varanasi wants to merge 5 commits into
session-token-short-lived-web-adminfrom
session-token-rotation

Conversation

@sricharan-varanasi

Copy link
Copy Markdown
Contributor
  • Tests for the changes have been added
  • Related documentation has been added / updated
  • For new features, QA automation engineers have been tagged
  • OSS packages added to
    Curious open source credit page

📝 Description

🔗 Jira Ticket M2-11012

This PR adds refresh-token rotation for web/admin clients so active sessions can be kept alive indefinitely, while limiting how long a leaked/stolen refresh token stays useful.

Changes include:

  • Every issued token now carries a family claim (the login refresh token's own jti), shared by every token descended from that login. Optional/backward-compatible - existing tokens without it are unaffected.
  • New TokenRotationService: stores a short-lived (60s, configurable) Redis "grace record" mapping a just-rotated refresh token to its replacement pair, and manages family-level revocation via the existing token blacklist table.
  • refresh_access_token now branches on the token's client claim:
    • web/admin: each refresh issues a brand-new refresh token (rotation) plus a new access token, and blacklists the old refresh token.
    • Presenting an already-rotated token within the grace window returns the same replacement pair (idempotent - absorbs same-tab/cross-tab races and retries).
    • Presenting an already-used token outside the grace window is treated as reuse: the entire token family is revoked, forcing re-login.
    • mobile / unknown / legacy: unchanged, same refresh token reused as before.
  • Logout (delete_access_token / delete_refresh_token) now revokes the whole token family for web/admin, so logging out with a superseded token can't leave a still-live rotated token behind.
  • Added logger calls distinguishing refresh outcomes (rotated, grace_redeemed, reuse detected) for observability.
  • New config: AUTHENTICATION__REFRESH_TOKEN__ROTATION_GRACE_SECONDS (default 60).

No frontend changes are required to ship this - the refresh response shape is unchanged, and both admin and web already persist whatever token pair a refresh call returns.

✏️ Notes

Stacked on top of session-token-short-lived-web-admin (short-lived web/admin token lifetimes). Frontend keep-alive/idle-timeout work (admin/web tickets) is separate and not a prerequisite for this to ship safely.

@adeiji adeiji 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.

Looks good @sricharan-varanasi ! I left a few comments, not blocking, but let me know what you think.

}
)
# Mark the old refresh token used, and record the replacement for the grace window.
await AuthenticationService(session).revoke_token(

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.

What are your thoughts on this edge case?

So we're handling quick-succession refreshes, but for parallel requests, being that this is a transaction, there could be an instance where parallel requests call multiple inserts and the race loser's insert fails, which would raise a 500 error and then force a logout in the client. This could be an issue since sometimes the frontend may send multiple requests at practically the same time.

Should we catch that and return the winner's replacement pair from the grace record? Because we know it's already "passed" it just now needs to not send the 500 incorrectly.

access_token = replacement.access_token
refresh_token = replacement.refresh_token
refresh_outcome = "grace_redeemed"
elif await AuthenticationService(session).is_revoked(InternalToken(payload=token_data)):

@adeiji adeiji Jul 27, 2026

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.

When retrieving the grace record from Redis, if Redis is down we still get back None, so a legit duplicate refresh would be flagged as theft and revoke the whole family. Should we handle Redis being down differently here? What do you think?

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