Skip to content

fix(oauth2): revoke refresh tokens on password reset#5177

Merged
OliverBryant merged 5 commits into
xorbitsai:mainfrom
OliverBryant:fix/oauth2-auth-privilege-hardening
Jul 14, 2026
Merged

fix(oauth2): revoke refresh tokens on password reset#5177
OliverBryant merged 5 commits into
xorbitsai:mainfrom
OliverBryant:fix/oauth2-auth-privilege-hardening

Conversation

@OliverBryant

Copy link
Copy Markdown
Collaborator

Summary

change_password in the advanced OAuth2 layer updated the password hash but never revoked the target user's refresh tokens. refresh_access_token only re-checks that the user is still enabled — not whether the password changed — so a leaked/stolen refresh token could keep minting fresh access tokens for up to REFRESH_TOKEN_EXPIRE_DAYS (7 days) after an admin resets the user's password.

This calls delete_user_refresh_tokens(user_id) right after the hash update, so a password reset terminates all outstanding sessions for that user.

Context

This is the last remaining finding from the #5058 security audit. Re-verifying all six findings against current main:

Finding Status
1 — eternal JWT (no exp check) already fixed (#5088)
2 — users:manageadmin escalation via permission grant already fixed (#5089)
3 — password reset of a more-privileged (admin) account already fixed (_reject_admin_target_takeover)
4 — password reset does not revoke sessions fixed here
5 — must_change_password not enforced + plaintext password logged no longer applicable: the auto-generated-admin path was replaced by the token-gated setup_admin flow, so no random password is logged and no account relies on must_change_password as a security boundary
6 — disabled-user API key bypass already fixed (user_enabled cache check)

Test

Adds test_oauth2_password_reset_revokes_tokens.py, which drives the real change_password handler against a temporary SQLite DB and asserts the target's refresh tokens are deleted while other users' tokens are untouched.

  • New + existing auth/oauth2 tests: 65 passed
  • pre-commit (black, flake8, isort, mypy, codespell): clean

change_password updated the password hash but left the user's refresh
tokens intact. refresh_access_token only re-checks that the user is
still enabled, not whether the password changed, so a leaked refresh
token could keep minting access tokens for up to
REFRESH_TOKEN_EXPIRE_DAYS after a reset.

Revoke the target's outstanding refresh tokens after updating the
password. Addresses the last remaining finding from the xorbitsai#5058 audit
(the others were fixed in xorbitsai#5088/xorbitsai#5089 and the setup-flow rework).
@XprobeBot XprobeBot added the bug Something isn't working label Jul 14, 2026
@XprobeBot XprobeBot added this to the v2.x milestone Jul 14, 2026

@gemini-code-assist gemini-code-assist Bot 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.

Code Review

This pull request addresses a security vulnerability by ensuring that resetting a user's password revokes all of their outstanding refresh tokens. It adds a call to delete_user_refresh_tokens in the change_password route and introduces comprehensive regression tests to verify this behavior. The review feedback points out that the test helper uses asyncio.get_event_loop(), which is deprecated since Python 3.10 and will fail in Python 3.12+, and suggests using asyncio.run() instead.

Comment thread xinference/api/tests/test_oauth2_password_reset_revokes_tokens.py Outdated
asyncio.get_event_loop() is deprecated when no loop is running and
raises RuntimeError on Python 3.12+; asyncio.run manages the loop
lifecycle and works across the 3.10-3.13 CI matrix.

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

Two findings from the original #5058 report are still not fully resolved:

  • Finding 4 remains raceable because password reset and refresh-token rotation are separate database operations.
  • Finding 5 still affects upgrades: an existing account with must_change_password=1 can obtain unrestricted admin tokens. #5144 removes the vulnerable bootstrap path for fresh installs, but there is no migration or server-side enforcement for existing databases.

The other findings appear to be addressed. The focused auth tests pass, but they do not cover these two scenarios.

Comment thread xinference/api/oauth2/advanced/routes.py Outdated
…rency

The previous fix revoked refresh tokens after updating the password, but
refresh_access_token performed its read-delete-insert rotation across three
separate transactions. A refresh could read a still-valid token, the reset
could delete all of the user's tokens, and the refresh could then insert a
replacement that survived the reset -- keeping a leaked session alive.

Move both flows into single BEGIN IMMEDIATE transactions that serialize on
SQLite's write lock (matching create_first_user), including across processes:

- Database.rotate_refresh_token: validate-delete-insert in one transaction,
  succeeding only if the old token still existed at delete time.
- Database.update_password_and_revoke_tokens: update the hash and delete the
  user's refresh tokens in one transaction.
- refresh_access_token bails out if the token was revoked before it took the
  write lock, so it never mints a session past a password reset.

Add a multi-threaded regression test that interleaves refresh and reset; it
reproduces surviving tokens against the old non-atomic code and passes here.
@OliverBryant

Copy link
Copy Markdown
Collaborator Author

Thanks for the careful review.

Finding 4 (race): fixed in e4da7f3 — see the inline reply. Rotation and password-reset revocation are now mutually atomic via BEGIN IMMEDIATE, with a concurrent regression test that reproduced the leak against the old code (31/60 rounds) and passes now.

Finding 5 (existing DBs with must_change_password=1): you're right that this is still open. It's a distinct concern from this PR's token-revocation fix — it needs a server-side gate in the auth dependency (__call__) that blocks a must_change_password user from every request except changing their own password, so the flag is enforced regardless of how the account was created. I'd prefer to do that in a separate, focused PR rather than widen this one. Happy to open it next — or fold it in here if you'd rather have both together. Which do you prefer?

…Finding 5)

The must_change_password flag was only returned to the frontend as a hint;
the auth dependency never enforced it. An existing account carrying
must_change_password=1 (e.g. migrated from an older database) could obtain
fully-functional tokens -- including admin -- and use them without ever
changing the password.

Add a gate in AdvancedAuthService.__call__, placed after the enabled check
and before the admin bypass, so even an admin-scoped account is blocked while
flagged. A flagged user may make exactly one request: PUT on their own
/v1/admin/users/{id}/password; everything else returns 403. The exempt path
is matched by regex anchored on the trailing segments, independent of any
router mount prefix, and only for the caller's own user id.

Add tests covering the block, the own-password-change exemption, the
admin-before-bypass ordering, rejection of changing another user's password,
that unflagged users are unaffected, and that a GET to the password path is
not a bypass.
@OliverBryant

Copy link
Copy Markdown
Collaborator Author

Finding 5: now enforced server-side in this PR (e75d827), as you suggested.

Added a gate in AdvancedAuthService.__call__, placed after the enabled check and before the admin bypass, so even an admin-scoped account is blocked while must_change_password=1. A flagged user may make exactly one request — PUT /v1/admin/users/{own_id}/password — and everything else returns 403. This is independent of how the account was created, so it covers rows carried over from an older database, which was the gap you flagged.

Two design notes:

  • The exempt path is matched by a regex anchored on the trailing /users/{id}/password segments (independent of any router mount prefix) and only for the caller's own user id; a GET to the same path is not exempt.
  • The exemption deliberately allows only the password change, nothing else. In practice the flagged accounts in question are the legacy bootstrap admins (they hold admin, so they still satisfy the users:manage guard on the change-password route via the admin bypass, which runs after this gate lets the request through). The current code no longer creates non-admin users with the flag set, so I did not widen the allowlist for a case that can't arise today — happy to revisit if you'd rather I add a self-service change-password route.

Full oauth2 suite (54 tests) and pre-commit pass. Tests: test_oauth2_must_change_password.py.

Comment thread xinference/api/oauth2/advanced/auth_service.py
The Finding 5 gate only covered JWTs; the API-key branch returns the user
before reaching it, so an existing API key owned by a legacy
must_change_password=1 account could still call model endpoints, bypassing
the flag.

Block the API-key path outright when the owning account is still flagged.
An API key can only reach model endpoints (it cannot change a password), so
there is no exempt request: the key stays blocked until the owner clears the
flag via a JWT password change, after which the same key works again. The
check reads must_change_password from the live DB row, so clearing the flag
takes effect immediately without recreating the key.

Add regression tests: a flagged account's API key is rejected with 403, and
the same key works again once the flag is cleared.

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

LGTM

@OliverBryant OliverBryant merged commit 6c98207 into xorbitsai:main Jul 14, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants