loopable / api /routes_oauth.py
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
609fb78 verified
Raw
History Blame Contribute Delete
6.34 kB
"""routes_oauth.py β€” the OAuth connector surface (wave 22, contract C5 + A2/A3 / R12).
Thin over `oauth_connect`, the way `routes_automation` is thin over the engine: sessions,
shapes and status codes here; every decision that could be wrong lives in the module a gate
can drive without a server. GENERIC over `{provider}` (C5-A2): the routes read the registry,
so the day a second provider lands here is the day nothing in this file changes.
MOUNTED FROM `routes_automation` (not `main.py`): this wave's ownership fence gives no session
`main.py`, and `routes_automation` is already included there β€” so this router rides inside it
(`/api/v1` + `/oauth/...`). Lifting the include into `main.py` later is a two-line change that
alters no path.
⚠ THE TWO REDIRECT LAWS (A3): `/{provider}/start` answers **302 to the provider's consent
screen** β€” it is a top-level navigation the client reaches by `<a href>`, never JSON. The
callback 302s BACK to the return path the `state` carried (relative-only, sanitised by
`oauth_connect.safe_next`), so the user lands where they left β€” connected or not, whatever
went wrong rides in the query string; a dead-end error page where the app used to be reads as
"the product broke", not "the connect failed".
"""
import os
from fastapi import APIRouter, Depends, Request
from fastapi.responses import RedirectResponse
import oauth_connect
from deps import Session, err, require_session
router = APIRouter(prefix="/oauth")
def _redirect_uri(request: Request, provider: str) -> str:
"""The redirect URI this deployment registers at the provider β€” env-pinned when the
container sits behind a proxy that rewrites the scheme (the HF Space), else derived from
the request. MUST match a console-registered URI verbatim, so it is computed in exactly
one place.
⭐ WAVE 29 (R4): `deploy_web.py` now PUSHES `AIOS_PUBLIC_BASE` on every deploy, defaulted to
the same URL as `APP_BASE_URL`, so the pinned branch is the one that runs in production and
the request-derived fallback below is effectively dev-only.
β›” THAT MAKES THIS FUNCTION A CUSTOM-DOMAIN COUPLING, not merely a scheme fix. Whatever host
this returns is where the provider sends the user BACK, and the session cookie is host-only
(`aios_session.py:114-117`, no `domain=`) β€” so a callback base that disagrees with the host
the user actually browsed plants the session on the wrong hostname and they return logged
out. Moving the app to a new hostname means moving this value AND re-registering the
resulting URI in the provider console; one without the other fails closed.
Runbook: `.claude/wiki/research/loopable-domain-runbook.md`."""
base = (os.environ.get("AIOS_PUBLIC_BASE") or "").strip().rstrip("/")
if not base:
base = f"{request.url.scheme}://{request.url.netloc}"
return f"{base}/api/v1/oauth/{provider}/callback"
@router.get("/status")
def oauth_status(session: Session = Depends(require_session)):
"""C5's status shape for the SESSION user, one entry per registry provider:
`{google: {connected, email, reconnect, configured}}` today. The bit the email trigger's
`ready` reads through."""
return oauth_connect.status(session.runtime, session.uname)
def _offered_or_404(provider: str):
"""β›”β›” W32-T14 / OWNER ITEM 12 / R6 β€” A PROVIDER THE PRODUCT DOES NOT OFFER HAS NO DOOR.
The owner pasted the failure this replaces: clicking Connect on the Google card answered
`503 oauth_unavailable` as raw JSON. R6's fix is not a nicer error β€” it is that the flow is
not offered at all, so the honest status is the one for a URL that does not exist. `404`
rather than `503`, deliberately: a 503 says *"come back later"* about a door that is not
coming back until somebody pays for CASA verification (D-45, ~$540–1,800/yr).
⚠ Every door in this router goes through it, START included, because the JSON the owner saw
came from the start route and a guard on one leg is a guard on one leg.
"""
if not oauth_connect.offered(provider):
raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
@router.get("/{provider}/start")
def oauth_start(provider: str, request: Request, next: str = "",
session: Session = Depends(require_session)):
"""302 to the provider's consent screen (A3 β€” a navigation, never JSON). `?next=` is the
RELATIVE path the callback returns the browser to; it rides inside the single-use state,
sanitised, so the round trip cannot be steered off-origin."""
_offered_or_404(provider)
url, problem = oauth_connect.start(provider, session.uname,
_redirect_uri(request, provider), next_path=next)
if problem:
raise err(503 if "not configured" in problem else 404, "oauth_unavailable", problem)
return RedirectResponse(url, status_code=302)
@router.get("/{provider}/callback")
def oauth_callback(provider: str, request: Request,
session: Session = Depends(require_session),
state: str = "", code: str = "", error: str = ""):
"""The provider's redirect target. Exchanges the code, stores the per-user slot, and sends
the browser back to the state's return path β€” connected or not (see module header)."""
_offered_or_404(provider)
if error:
home = "/#/"
return RedirectResponse(f"{home}?oauthError={error[:80]}", status_code=302)
email, home, problem = oauth_connect.callback(session.runtime, session.uname, state, code)
sep = "&" if "?" in home else "?"
if problem:
return RedirectResponse(f"{home}{sep}oauthError=connect_failed", status_code=302)
return RedirectResponse(f"{home}{sep}connected={provider}", status_code=302)
@router.post("/{provider}/disconnect")
def oauth_disconnect(provider: str, session: Session = Depends(require_session)):
_offered_or_404(provider)
if oauth_connect.provider_def(provider) is None:
raise err(404, "unknown_provider", f"{provider!r} is not a connectable provider")
oauth_connect.disconnect(session.runtime, session.uname, provider)
return {"disconnected": provider}