X Tutup
Skip to content

Fix OAuth token expiry and implement refresh token rotation#3102

Open
HydroSulphide wants to merge 2 commits intorommapp:masterfrom
HydroSulphide:fix-oauth-token-expiry-and-refresh-rotation
Open

Fix OAuth token expiry and implement refresh token rotation#3102
HydroSulphide wants to merge 2 commits intorommapp:masterfrom
HydroSulphide:fix-oauth-token-expiry-and-refresh-rotation

Conversation

@HydroSulphide
Copy link

@HydroSulphide HydroSulphide commented Mar 9, 2026

Description
The current state of OAuth token implementation is that they are always valid, because of a missing check against their expiration date. I implemented this check in get_current_active_user_from_bearer_token(). I also implemented refresh token rotation.

This PR changes the OAuth token flow so expired access tokens are rejected correctly, refresh tokens are consumed and rotated on refresh, and old refresh tokens can no longer be reused. The /token endpoint now uses consume_refresh_token() for the refresh flow, creates a new refresh token on every successful refresh, and returns refresh_token, expires_in, and refresh_expires_in in the response. I also updated the TokenResponse type accordingly.

Additionally, I added a follow-up fix for bearer authentication in hybrid_auth.py: if get_current_active_user_from_bearer_token() raises an HTTPException with status code 401, it is now handled gracefully instead of causing an internal server error during API calls with expired access tokens.

I tested the changes locally with a token rotation test flow that verifies valid access tokens work before expiry, expired access tokens are rejected by the server, refresh tokens produce a new token pair, old refresh tokens fail after rotation, and expired refresh tokens are rejected.

Most importantly: I fixed a typo in a comment.

Checklist

  • I've tested the changes locally
  • I've updated relevant comments
  • I've assigned reviewers for this PR
  • I've added unit tests that cover the changes

@greptile-apps
Copy link

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes two bugs in the OAuth implementation — missing expiry enforcement on access tokens and an internal server error when expired tokens were used via the bearer auth path — and adds proper refresh token rotation so that each refresh consumes the old token and issues a new pair.

Key changes:

  • Expiry check added to get_current_active_user_from_bearer_token so expired access tokens now correctly return 401 instead of being accepted indefinitely.
  • Refresh token rotation: consume_refresh_token validates and invalidates (via Redis JTI) the incoming refresh token, and auth.py issues a new refresh token on every successful refresh.
  • hybrid_auth.py: Bearer token 401 exceptions are now caught gracefully, preventing an ISE when an expired access token is submitted via the middleware auth path.
  • TokenResponse: Renamed expiresexpires_in and added refresh_expires_in to align with the OAuth 2.0 standard.
  • Latent bug in hybrid_auth.py: The except HTTPException block only explicitly returns when status_code == 401; a non-401 HTTPException would fall through with user/claims unbound, causing a NameError. A raise should be added for all other status codes.
  • Token consumed before full validation in consume_refresh_token: The Redis JTI is deleted before the database user lookup completes. A database error after the delete would permanently consume the refresh token without issuing a new one, logging the user out.

Confidence Score: 3/5

  • This PR fixes real bugs but introduces two new issues — one logic defect and one latent crash — that should be addressed before merging.
  • The core implementation (expiry enforcement, token rotation, hybrid auth fix) is sound and well-structured. However, the JTI deletion ordering in consume_refresh_token can permanently consume a refresh token if a database error occurs mid-validation, and the incomplete exception guard in hybrid_auth.py introduces a latent NameError path. Both are straightforward to fix.
  • backend/handler/auth/base_handler.py (consume_refresh_token JTI deletion order) and backend/handler/auth/hybrid_auth.py (incomplete exception guard) need attention before merging.

Important Files Changed

Filename Overview
backend/handler/auth/base_handler.py Adds create_access_token, create_refresh_token (with Redis JTI tracking), and consume_refresh_token; adds expiry check to get_current_active_user_from_bearer_token. Key issue: JTI is deleted from Redis before completing user database lookups, risking permanent token consumption on DB errors.
backend/handler/auth/hybrid_auth.py Wraps bearer token validation in a try/except to gracefully handle 401 errors, but the guard is incomplete: a non-401 HTTPException would be silently swallowed, leaving user and claims unbound and causing a NameError on the subsequent check.
backend/endpoints/auth.py Switches refresh flow to use consume_refresh_token, adds token rotation (new refresh token issued on every refresh), renames expires to expires_in, and adds refresh_expires_in. Contains a redundant post-consume type check that is unreachable.
backend/endpoints/responses/oauth.py Renames expires to expires_in and adds refresh_expires_in field to TokenResponse TypedDict — straightforward, correct change.

Sequence Diagram

sequenceDiagram
    participant Client
    participant /token Endpoint
    participant OAuthHandler
    participant Redis
    participant DB

    Note over Client, DB: Initial login (password grant)
    Client->>+/token Endpoint: POST /token (grant_type=password)
    /token Endpoint->>OAuthHandler: create_access_token(...)
    OAuthHandler-->>/ token Endpoint: access_token (30 min)
    /token Endpoint->>OAuthHandler: create_refresh_token(...)
    OAuthHandler->>Redis: SETEX refresh-jti:{jti} 7d "valid"
    OAuthHandler-->>/ token Endpoint: refresh_token (7 days)
    /token Endpoint-->>-Client: {access_token, refresh_token, expires_in, refresh_expires_in}

    Note over Client, DB: Token refresh (rotation)
    Client->>+/token Endpoint: POST /token (grant_type=refresh_token)
    /token Endpoint->>OAuthHandler: consume_refresh_token(token)
    OAuthHandler->>OAuthHandler: decode JWT + verify exp/iss/type
    OAuthHandler->>Redis: GET refresh-jti:{jti}
    Redis-->>OAuthHandler: "valid"
    OAuthHandler->>Redis: DEL refresh-jti:{jti}
    OAuthHandler->>DB: get_user_by_username(sub)
    DB-->>OAuthHandler: user
    OAuthHandler-->>/ token Endpoint: (user, claims)
    /token Endpoint->>OAuthHandler: create_access_token(...)
    OAuthHandler-->>/ token Endpoint: new access_token
    /token Endpoint->>OAuthHandler: create_refresh_token(...)
    OAuthHandler->>Redis: SETEX refresh-jti:{new_jti} 7d "valid"
    OAuthHandler-->>/ token Endpoint: new refresh_token
    /token Endpoint-->>-Client: {new access_token, new refresh_token, ...}

    Note over Client, DB: Bearer token used in API request
    Client->>+HybridAuthBackend: GET /api/... Authorization: Bearer {access_token}
    HybridAuthBackend->>OAuthHandler: get_current_active_user_from_bearer_token(token)
    OAuthHandler->>OAuthHandler: decode + verify exp/iss
    OAuthHandler->>DB: get_user_by_username(sub)
    DB-->>OAuthHandler: user
    OAuthHandler-->>HybridAuthBackend: (user, claims)
    HybridAuthBackend-->>-Client: 200 OK (authenticated)
Loading

Comments Outside Diff (1)

  1. backend/endpoints/auth.py, line 110-113 (link)

    Unreachable / redundant type check after consume_refresh_token

    consume_refresh_token already asserts payload.claims.get("type") == "refresh" and raises OAuthCredentialsException (401) if it does not match. By the time execution reaches line 110, claims.get("type") is guaranteed to be "refresh". This check will never trigger and can be removed.

Last reviewed commit: ad09bab

Comment on lines +318 to +327
redis_client.delete(f"refresh-jti:{jti}")

username = payload.claims.get("sub")
if not username:
raise OAuthCredentialsException

user = db_user_handler.get_user_by_username(username)
if user is None:
raise OAuthCredentialsException

Copy link

Choose a reason for hiding this comment

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

Refresh token consumed before user validation completes

The JTI is deleted from Redis (line 318) before the username and user database lookups (lines 320–326). If the database lookup raises an unexpected exception (e.g., a transient DB error), the refresh token is permanently consumed but no new token pair is ever issued. The client would be left with no valid refresh token and no way to recover short of re-authenticating from scratch.

The delete should be deferred until all validations have passed:

Suggested change
redis_client.delete(f"refresh-jti:{jti}")
username = payload.claims.get("sub")
if not username:
raise OAuthCredentialsException
user = db_user_handler.get_user_by_username(username)
if user is None:
raise OAuthCredentialsException
username = payload.claims.get("sub")
if not username:
raise OAuthCredentialsException
user = db_user_handler.get_user_by_username(username)
if user is None:
raise OAuthCredentialsException
if not user.enabled:
raise UserDisabledException
redis_client.delete(f"refresh-jti:{jti}")
return user, payload.claims

Comment on lines +44 to 51
try:
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token)
except HTTPException as exc:
if exc.status_code == 401:
return None

if user is None or claims is None:
return None
Copy link

Choose a reason for hiding this comment

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

Non-401 HTTPException causes NameError on fallthrough

The except block only explicitly returns when exc.status_code == 401. If a different status code is raised (e.g., a future change adds a 403-level exception), the except block silently finishes without returning, and execution falls through to if user is None or claims is None — but at that point user and claims are unbound, causing a NameError.

While all current exceptions from get_current_active_user_from_bearer_token happen to be 401 (both OAuthCredentialsException and UserDisabledException are 401), the guard should be made defensive so this cannot silently break with future changes:

Suggested change
try:
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token)
except HTTPException as exc:
if exc.status_code == 401:
return None
if user is None or claims is None:
return None
try:
user, claims = await oauth_handler.get_current_active_user_from_bearer_token(token)
except HTTPException as exc:
if exc.status_code == 401:
return None
raise

@gantoine gantoine added the auth Bugs related to authentication, OIDC, OpenID and their providers label Mar 9, 2026
@gantoine gantoine assigned gantoine and unassigned gantoine Mar 9, 2026
@gantoine gantoine self-requested a review March 9, 2026 22:13
@gantoine
Copy link
Member

@HydroSulphide i'll have a look once the comments and failing tests are fixed!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth Bugs related to authentication, OIDC, OpenID and their providers

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

X Tutup