Skip to content

Fix OAuth token expiry and implement refresh token rotation#3102

Merged
gantoine merged 8 commits intorommapp:masterfrom
HydroSulphide:fix-oauth-token-expiry-and-refresh-rotation
Mar 10, 2026
Merged

Fix OAuth token expiry and implement refresh token rotation#3102
gantoine merged 8 commits intorommapp:masterfrom
HydroSulphide:fix-oauth-token-expiry-and-refresh-rotation

Conversation

@HydroSulphide
Copy link
Copy Markdown
Contributor

@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
Copy Markdown

greptile-apps bot commented Mar 9, 2026

Greptile Summary

This PR fixes two long-standing OAuth issues: access tokens were never checked against their expiry date, and refresh tokens could be reused indefinitely. It adds expiry validation to get_current_active_user_from_bearer_token, introduces a proper consume_refresh_token method (using Redis GETDEL for atomic one-time-use enforcement), and implements refresh token rotation so every successful refresh issues a fresh token pair.

Key observations:

  • hybrid_auth.py is the most critical gap: The PR description states that expired-bearer-token exceptions are now handled gracefully in hybrid_auth.py, but the actual diff only adds a blank line. get_current_active_user_from_bearer_token now raises OAuthCredentialsException (HTTP 401) for expired tokens, and Starlette's AuthenticationMiddleware does not catch HTTPException. This means expired bearer tokens will return 401 on all routes — including public ones that are accessible without authentication.
  • Dead code in auth.py: The if not user or not claims and if not user.enabled guards after consume_refresh_token(...) are unreachable — consume_refresh_token always raises on any failure path, never returning falsy values.
  • Sub-second TTL truncation in create_refresh_token: int(expires_delta.total_seconds()) rounds positive sub-second deltas to 0, which Redis's SETEX rejects, even though the <= timedelta(0) guard passes.
  • Test isolation gap in expired_refresh_token fixture: the JTI is never registered in Redis, so the test validates two rejection conditions at once and would still pass even if the expiry check were removed.
  • test_refreshing_oauth_token_basic does not assert the newly added refresh_token or refresh_expires response fields.

Confidence Score: 2/5

  • Not safe to merge — the missing try/except in hybrid_auth.py causes expired bearer tokens to return 401 on every route, including public ones.
  • The core token-rotation and expiry logic is sound (atomic getdel, correct expiry math, clean method split), but the side-effect of raising in get_current_active_user_from_bearer_token was not compensated for in HybridAuthBackend.authenticate. Any client holding an expired access token will receive hard 401s on all endpoints until they refresh — breaking unauthenticated / kiosk-mode routes. The dead-code guards and test isolation issues are secondary concerns.
  • Pay close attention to backend/handler/auth/hybrid_auth.py (missing exception handling for the new raising behavior) and backend/endpoints/auth.py (unreachable guards after consume_refresh_token).

Important Files Changed

Filename Overview
backend/handler/auth/base_handler.py Core auth logic heavily refactored: create_oauth_token made private, new create_access_token/create_refresh_token split, new consume_refresh_token with atomic getdel, and expiry check added to get_current_active_user_from_bearer_token. Sub-second TTL truncation issue in create_refresh_token; expiry check in the bearer-token path now raises instead of returning (None, None), which was not compensated for in hybrid_auth.py.
backend/endpoints/auth.py Token endpoint updated to use consume_refresh_token and issue a new refresh token on every refresh (rotation). Returns refresh_token and refresh_expires in both grant flows. Dead-code guards (if not user or not claims and the duplicate if not user.enabled) remain after consume_refresh_token, since that function always raises rather than returning falsy values.
backend/handler/auth/hybrid_auth.py Only a blank line was added. The PR description claims to have added graceful 401 handling, but get_current_active_user_from_bearer_token now raises OAuthCredentialsException for expired tokens without any try/except in this file — expired bearer tokens will bubble through Starlette's AuthenticationMiddleware (which does not catch HTTPException) and return 401 even for public endpoints.
backend/tests/conftest.py New expired_refresh_token fixture manually constructs a JWT with hardcoded jti: "expired-test-jti" that is never registered in Redis, so the test validates expiry rejection and unregistered-JTI rejection simultaneously — obscuring which path actually rejects the token.
backend/tests/endpoints/test_oauth.py Good new coverage: token rotation test and expired-refresh-token test added. The pre-existing test_refreshing_oauth_token_basic does not assert the newly returned refresh_token or refresh_expires fields.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Client sends refresh request] --> B[consume_refresh_token called]
    B --> C{JWT decode OK?}
    C -- No --> D[raise OAuthCredentialsException 401]
    C -- Yes --> E{Token expired?}
    E -- Yes --> D
    E -- No --> F{iss == romm:oauth AND type == refresh?}
    F -- No --> D
    F -- Yes --> G[Redis GETDEL on jti]
    G --> H{Returned valid?}
    H -- No --> D
    H -- Yes --> I[Lookup user in DB]
    I --> J{User exists and enabled?}
    J -- No --> D
    J -- Yes --> K[Issue new access token]
    K --> L[Issue new refresh token and store jti in Redis]
    L --> M[Return both tokens to client]

    N[Client sends API request with stale bearer token] --> O[HybridAuthBackend.authenticate]
    O --> P[get_current_active_user_from_bearer_token called]
    P --> Q{Token expired?}
    Q -- No --> R[Continue auth flow normally]
    Q -- Yes --> S[raises OAuthCredentialsException]
    S --> T[No try/except in hybrid_auth.py]
    T --> U[Exception propagates through AuthenticationMiddleware]
    U --> V[401 returned even on public routes]
Loading

Comments Outside Diff (3)

  1. backend/tests/conftest.py, line 162-177 (link)

    Expired refresh token fixture bypasses Redis JTI registration

    The expired_refresh_token fixture builds a JWT directly via jwt.encode with a hardcoded jti ("expired-test-jti") but never calls redis_client.setex(f"refresh-jti:expired-test-jti", ...). As a result, the test test_refreshing_oauth_token_expired_refresh_token silently validates two failure modes simultaneously:

    1. The expiry check (now > claims["exp"]) fires first → raises OAuthCredentialsException → 401 ✓
    2. If the expiry check were ever removed or reordered, the getdel(f"refresh-jti:expired-test-jti") would return Noneb"valid" → also 401, masking the regression.

    The test title says "expired refresh token", but the fixture would produce the same 401 result even on a valid-expiry token because the JTI is never in Redis. This means the test doesn't purely verify expiry-based rejection.

    A cleaner approach is to use oauth_handler.create_refresh_token(...) directly (which does register the JTI in Redis) with a very short but positive delta, then manipulate time in the assertion, or to store the JTI explicitly so the test isolates exactly one rejection path:

    @pytest.fixture
    def expired_refresh_token(admin_user: User) -> str:
        expire = int((datetime.now(timezone.utc) + timedelta(seconds=-1)).timestamp())
        jti = "expired-test-jti"
        redis_client.setex(f"refresh-jti:{jti}", 60, "valid")   # register so only expiry rejects
    
        return jwt.encode(
            {"alg": ALGORITHM},
            {
                "sub": admin_user.username,
                "iss": "romm:oauth",
                "scopes": " ".join(admin_user.oauth_scopes),
                "type": "refresh",
                "jti": jti,
                "exp": expire,
            },
            oct_key,
        )
  2. backend/tests/endpoints/test_oauth.py, line 17-31 (link)

    New refresh_token and refresh_expires response fields not asserted

    This PR adds refresh_token and refresh_expires to every token response, including the refresh_token grant flow. The pre-existing test_refreshing_oauth_token_basic test only checks access_token, token_type, and expires — the two newly-added fields go completely unverified.

    The new rotation test (test_refresh_token_rotation_invalidates_old_token) does assert first_body["refresh_token"] incidentally, but test_refreshing_oauth_token_basic is the canonical "happy path" test and should be updated to assert both new fields:

    (You'll need to import REFRESH_TOKEN_EXPIRE_DAYS from endpoints.auth alongside ACCESS_TOKEN_EXPIRE_SECONDS.)

  3. backend/handler/auth/base_handler.py, line 271-292 (link)

    Sub-second positive expires_delta silently truncates Redis TTL to 0

    The guard on line 272 correctly rejects <= timedelta(0), but the Redis SETEX call on line 286 computes:

    int(expires_delta.total_seconds())

    For any positive delta shorter than 1 second (e.g., timedelta(milliseconds=500)total_seconds() = 0.5), int(...) truncates to 0. Redis's SETEX command rejects a TTL of 0 with ERR invalid expire time in 'setex', raising a redis.exceptions.ResponseError at runtime even though the guard passed.

    The production token lifetime (timedelta(days=7)) is well above this threshold, so it won't be triggered in normal usage. However, it is a silent footgun for any future caller that passes a very short delta (e.g., in new test scenarios). The guard should be updated to check the truncated integer value rather than the raw timedelta:

    ttl_seconds = int(expires_delta.total_seconds())
    if ttl_seconds <= 0:
        raise ValueError("expires_delta must result in a positive TTL (>= 1 second) for refresh tokens")

    Then pass ttl_seconds directly to redis_client.setex(...) so the computed value is only evaluated once and the guard always matches what Redis actually receives.

Last reviewed commit: f0e5aba

@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
Copy Markdown
Member

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

Three tests were also implemented to check initial implementation that now invalidates expired access and refresh tokens and also rotating refresh tokens.

Since I introduced wrapper functions for create_oauth_token to distinguish between access and refresh token there is no need to set the token type in the data dict, since the type is now enforced in the wrapper functions create_access_token and create_refresh_token.

By convention I renamed create_oauth_token to _create_oauth_token as it is considered a private helper function now.
@HydroSulphide HydroSulphide force-pushed the fix-oauth-token-expiry-and-refresh-rotation branch from a456e18 to 0233697 Compare March 10, 2026 07:15
Copy link
Copy Markdown
Member

@gantoine gantoine left a comment

Choose a reason for hiding this comment

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

looks great!

@gantoine gantoine merged commit 332fca9 into rommapp:master Mar 10, 2026
4 checks passed
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