| """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}
|
|
|