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