Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clean-oauth-retries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"gradio": patch
---

fix: Reset stale OAuth sessions and preserve the retry count across callbacks
29 changes: 19 additions & 10 deletions gradio/oauth.py
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,8 @@ async def oauth_redirect_callback(request: fastapi.Request) -> RedirectResponse:
# losing the state. A workaround is to delete the cookie and redirect the user to the login page again.
# See https://github.com/lepture/authlib/issues/622 for more details.

# Delete all keys that are related to the OAuth state, just in case
for key in list(request.session.keys()):
if key.startswith("_state_huggingface"):
request.session.pop(key)
# Reset the session so the next attempt cannot reuse stale OAuth state.
request.session.clear()

# Parse query params
nb_redirects = int(request.query_params.get("_nb_redirects", 0))
Expand All @@ -137,10 +135,17 @@ async def oauth_redirect_callback(request: fastapi.Request) -> RedirectResponse:
" Cannot redirect to non-iframe view."
Comment on lines 135 to 139
) from None
host_url = "https://" + host.rstrip("/")
return RedirectResponse(host_url + login_uri)

# Redirect the user to the login page again
return RedirectResponse(login_uri)
response = RedirectResponse(host_url + login_uri)
else:
# Redirect the user to the login page again
response = RedirectResponse(login_uri)

# SessionMiddleware cannot expire a cookie whose signature it could not
# decode, so delete it explicitly as well.
response.delete_cookie(
"session", secure=True, httponly=True, samesite="none"
)
return response

# OAuth login worked => store the user info in the session and redirect
request.session["oauth_info"] = oauth_info
Expand Down Expand Up @@ -202,6 +207,10 @@ def _generate_redirect_uri(request: fastapi.Request) -> str:
# otherwise => keep query params
target = "/?" + urllib.parse.urlencode(request.query_params)

callback_query_params = {"_target_url": target}
if "_nb_redirects" in request.query_params:
callback_query_params["_nb_redirects"] = request.query_params["_nb_redirects"]

# On Spaces, the redirect URI must always be https://<space_host>/login/callback,
# so if a custom domain is used, we need to replace it with the hf.space URL
if space_host := os.getenv("SPACE_HOST"):
Expand All @@ -210,12 +219,12 @@ def _generate_redirect_uri(request: fastapi.Request) -> str:
0
] # When custom domain is used, SPACE_HOST is a comma-separated list
print(f"SPACE_HOST after split: {space_host}")
redirect_uri = f"https://{space_host}/login/callback?{urllib.parse.urlencode({'_target_url': target})}"
redirect_uri = f"https://{space_host}/login/callback?{urllib.parse.urlencode(callback_query_params)}"
print(f"Redirect URI: {redirect_uri}")
return redirect_uri

redirect_uri = request.url_for("oauth_redirect_callback").include_query_params(
_target_url=target
**callback_query_params
)
redirect_uri_as_str = str(redirect_uri)
return redirect_uri_as_str
Expand Down
71 changes: 71 additions & 0 deletions test/test_routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2440,6 +2440,77 @@ def test_json_postprocessing_with_queue_false(connect):


class TestOAuthSecurity:
def test_oauth_redirect_preserves_retry_count(self, monkeypatch):
from urllib.parse import parse_qs, urlparse

from gradio.oauth import _generate_redirect_uri

monkeypatch.setenv("SPACE_HOST", "space.hf.space")
scope = {
"type": "http",
"method": "GET",
"headers": [],
"query_string": b"_nb_redirects=2&_target_url=%2Fapp",
}

redirect_uri = _generate_redirect_uri(Request(scope))

assert parse_qs(urlparse(redirect_uri).query) == {
"_nb_redirects": ["2"],
"_target_url": ["/app"],
}

def test_mismatching_oauth_state_clears_stale_session_cookie(self, monkeypatch):
from authlib.integrations.base_client.errors import MismatchingStateError
from fastapi.responses import RedirectResponse
from starlette.middleware.sessions import SessionMiddleware

from gradio import oauth

class FakeHuggingFaceOAuth:
async def authorize_redirect(self, request, redirect_uri):
return RedirectResponse(redirect_uri)

async def authorize_access_token(self, request):
assert request.cookies["session"] == "stale-invalid-cookie"
assert not request.session
raise MismatchingStateError()

class FakeOAuth:
def __init__(self):
self.huggingface = FakeHuggingFaceOAuth()

def register(self, **kwargs):
pass

monkeypatch.setattr("authlib.integrations.starlette_client.OAuth", FakeOAuth)
monkeypatch.setattr(oauth, "OAUTH_CLIENT_ID", "client")
monkeypatch.setattr(oauth, "OAUTH_CLIENT_SECRET", "secret")
monkeypatch.setattr(oauth, "OAUTH_SCOPES", "openid")
monkeypatch.setattr(oauth, "OPENID_PROVIDER_URL", "https://huggingface.co")

app = FastAPI()
oauth._add_oauth_routes(app)
app.add_middleware(
SessionMiddleware, # type: ignore
secret_key="secret",
https_only=True,
)

with TestClient(app, base_url="https://space.hf.space") as client:
client.cookies.set(
"session", "stale-invalid-cookie", domain="space.hf.space"
)
response = client.get(
"/login/callback?_target_url=/app", follow_redirects=False
)

assert response.headers["location"] == (
"/login/huggingface?_nb_redirects=1&_target_url=%2Fapp"
)
assert 'session=""' in response.headers["set-cookie"]
assert "Max-Age=0" in response.headers["set-cookie"]

def test_redirect_to_target_blocks_external_urls(self):
"""_redirect_to_target should strip scheme/host to prevent open redirects."""
from gradio.oauth import _redirect_to_target
Expand Down
Loading