File size: 6,338 Bytes
609fb78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
"""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}