| """The sign-in routes a self-hosted server needs, mounted on Gradio's app. |
| |
| **Why these exist at all.** On a Space, Hugging Face injects an OAuth app and |
| Gradio's own `/login/huggingface` and `/login/callback` are real, so |
| `gr.OAuthProfile` arrives filled in and none of this runs. Off a Space those |
| same routes are mocked and sign every visitor in as a fake profile, which is |
| the trap `identity.refuse_mocked_oauth` refuses to start into. These routes are |
| the other half of that refusal: the way to have real sign-in without a Space, |
| rather than merely being told you cannot have the fake one. |
| |
| The flow is `distinct_server.identity.OpenIdProvider`, which follows |
| https://huggingface.co/docs/hub/en/oauth. Nothing about the protocol is |
| decided here. What is decided here is where the three per-attempt secrets live |
| between the redirect and the callback: in the visitor's own signed session |
| cookie, never in a process-wide dictionary that two visitors could collide in |
| and never in a URL. |
| |
| **The session cookie is signed, not encrypted, and that is enough because of |
| what goes in it.** A subject and a username, which the person already knows |
| about themselves, plus a state and a nonce which are worthless once used. |
| There is no token in it: the access token is used once, at the callback, to |
| ask who this is, and then dropped. A server that kept it would be holding a |
| credential to somebody's Hugging Face account for no reason. |
| """ |
|
|
| from __future__ import annotations |
|
|
| import os |
| import secrets |
| from typing import Any, Mapping, Optional |
|
|
| from fastapi import Request |
| from fastapi.responses import RedirectResponse |
| from starlette.middleware.sessions import SessionMiddleware |
|
|
| from .identity import ( |
| Identity, |
| IdentityError, |
| NotConfigured, |
| OpenIdProvider, |
| PendingSignIn, |
| dev_auth_permitted, |
| from_environment, |
| gradio_oauth_is_real, |
| ) |
|
|
| |
| |
| |
| LOGIN_PATH = "/auth/login" |
| CALLBACK_PATH = "/auth/callback" |
| LOGOUT_PATH = "/auth/logout" |
|
|
| |
| SESSION_IDENTITY = "distinct_identity" |
| SESSION_PENDING = "distinct_pending" |
|
|
| |
| |
| |
| |
| ENV_SESSION_SECRET = "DISTINCT_SESSION_SECRET" |
|
|
|
|
| def session_secret(environ: Optional[Mapping[str, str]] = None) -> str: |
| environ = os.environ if environ is None else environ |
| configured = environ.get(ENV_SESSION_SECRET) |
| if configured: |
| return configured |
| return secrets.token_urlsafe(32) |
|
|
|
|
| def identity_from_session(session: Mapping[str, Any]) -> Optional[Identity]: |
| """Read back a signed-in person, or None. |
| |
| Rebuilt through `Identity` rather than trusted as a dictionary, so a |
| cookie that somehow carried a blank subject is refused here instead of |
| becoming a viewer id that matches everything. |
| """ |
|
|
| stored = session.get(SESSION_IDENTITY) |
| if not isinstance(stored, Mapping): |
| return None |
| try: |
| return Identity( |
| provider=str(stored.get("provider", "")), |
| subject=str(stored.get("subject", "")), |
| username=str(stored.get("username", "")), |
| name=str(stored.get("name", "")), |
| ) |
| except IdentityError: |
| return None |
|
|
|
|
| def attach(app: Any, *, provider: Optional[OpenIdProvider] = None) -> bool: |
| """Mount the routes, and say whether there was anything to mount. |
| |
| Returns False when sign-in is not configured for this deployment, which is |
| the Space case and the development case. It is not an error: on a Space |
| Gradio's own routes are real and these would be a second, conflicting way |
| in, and in development `DISTINCT_DEV_AUTH` has already said out loud that |
| there is no sign-in. |
| """ |
|
|
| |
| |
| |
| |
| |
| |
| if gradio_oauth_is_real() or dev_auth_permitted(): |
| return False |
| if provider is None: |
| try: |
| provider = from_environment() |
| except NotConfigured: |
| return False |
|
|
| app.add_middleware( |
| SessionMiddleware, |
| secret_key=session_secret(), |
| same_site="lax", |
| https_only=provider.redirect_uri.startswith("https://"), |
| ) |
|
|
| |
| |
| |
| |
| |
| @app.get(LOGIN_PATH) |
| async def login(request: Request): |
| pending = provider.begin() |
| request.session[SESSION_PENDING] = { |
| "state": pending.state, |
| "nonce": pending.nonce, |
| "code_verifier": pending.code_verifier, |
| } |
| return RedirectResponse(pending.url, status_code=302) |
|
|
| @app.get(CALLBACK_PATH) |
| async def callback(request: Request): |
| stored = request.session.pop(SESSION_PENDING, None) |
| if not isinstance(stored, Mapping): |
| |
| |
| |
| return RedirectResponse("/?signin=expired", status_code=302) |
| pending = PendingSignIn( |
| url="", |
| state=str(stored.get("state", "")), |
| nonce=str(stored.get("nonce", "")), |
| code_verifier=str(stored.get("code_verifier", "")), |
| ) |
| try: |
| identity = provider.complete( |
| request.query_params.get("code", ""), |
| state=request.query_params.get("state", ""), |
| pending=pending, |
| ) |
| except IdentityError: |
| |
| |
| return RedirectResponse("/?signin=refused", status_code=302) |
| request.session[SESSION_IDENTITY] = { |
| "provider": identity.provider, |
| "subject": identity.subject, |
| "username": identity.username, |
| "name": identity.name, |
| } |
| return RedirectResponse("/", status_code=302) |
|
|
| @app.get(LOGOUT_PATH) |
| async def logout(request: Request): |
| request.session.clear() |
| return RedirectResponse("/", status_code=302) |
|
|
| return True |
|
|