Coverage for src / quber / playground / auth.py: 39%
108 statements
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
« prev ^ index » next coverage.py v7.14.0, created at 2026-09-23 22:14 -0400
1"""WorkOS sign-in for the hosted playground.
3Every request the app serves passes the login gate. A request carrying a live
4session cookie for the configured organization goes through; otherwise the
5gate redirects a browser to ``/login`` or refuses an API call with 401. A few
6paths are open without a session: the sign-in routes themselves, the health
7route the load balancer and the startup page poll, and the static assets,
8which are the app's code and carry no document data.
10Four routes come with the gate. ``/login`` sends the browser to WorkOS,
11``/callback`` finishes the sign-in WorkOS sends back and sets the cookie,
12``/logout`` clears the cookie and ends the WorkOS session, and ``/api/me``
13tells the client who is signed in so it can offer sign-out. The sign-in
14itself, the organization check and the cookie all come from
15``quber.playground.session``, which the wake function shares: a person who
16signs in while no task is running gets the same cookie from the function,
17and the gate here accepts it once the task is up.
19The gate is a pure ASGI middleware rather than a Starlette
20``BaseHTTPMiddleware`` so the answer and batch streams pass through untouched.
21It checks the organization on every request, not only at sign-in, so a
22cookie issued under another organization, or before the setting changed, is
23refused like an expired one.
25``install`` decides whether the sign-in is on. All four settings unset means
26the developer host, where the app runs open. All four set means the hosted
27playground. A partial set is a misconfiguration and is refused at startup,
28because the alternative is a hosted app that silently runs open.
30The gate is also where signed-in use is counted. Every request that passes
31with a valid session is one unit of activity, and ``ActivityMeter`` reports
32the count to CloudWatch once a minute when a namespace is configured. The
33hosted service's idle alarm watches that metric rather than the load
34balancer's request count, because a public hostname is never quiet: internet
35scanners reach the sign-in page every few minutes, and only a session tells a
36person apart from them.
37"""
39from __future__ import annotations
41import threading
42import time
43from typing import Optional
44from urllib.parse import urlencode
46from fastapi import FastAPI, Request
47from loguru import logger
48from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
49from starlette.types import ASGIApp, Receive, Scope, Send
51from quber.playground import session
52from quber.settings import PlaygroundSettings
54OPEN_PATHS = frozenset({"/login", "/callback", "/logout", "/healthz"})
55OPEN_PREFIXES = ("/static/",)
56ACTIVITY_METRIC = "AuthenticatedRequests"
57ACTIVITY_INTERVAL_SECONDS = 60
58# The app serves the mark from its static files; the wake function inlines it.
59MARK_SRC = "/static/qubera-mark-160.png"
62class ActivityMeter:
63 """Counts requests that carried a valid session and reports the count to
64 CloudWatch once a minute. A minute with nothing to report is reported as
65 zero, so the metric has a datapoint whenever a task is up; the alarm's
66 missing-data rule covers the time nothing is up at all."""
68 def __init__(self, namespace: str, service: str) -> None:
69 self.namespace = namespace
70 self.service = service
71 self.count = 0
72 self.lock = threading.Lock()
73 threading.Thread(target=self.run, name="activity-meter", daemon=True).start()
75 def record(self) -> None:
76 with self.lock:
77 self.count += 1
79 def run(self) -> None:
80 import boto3
82 client = boto3.client("cloudwatch")
83 while True:
84 time.sleep(ACTIVITY_INTERVAL_SECONDS)
85 with self.lock:
86 count, self.count = self.count, 0
87 try:
88 client.put_metric_data(
89 Namespace=self.namespace,
90 MetricData=[
91 {
92 "MetricName": ACTIVITY_METRIC,
93 "Dimensions": [{"Name": "Service", "Value": self.service}],
94 "Value": count,
95 "Unit": "Count",
96 }
97 ],
98 )
99 except Exception as exc:
100 logger.warning("activity metric not sent: {}", exc)
103class LoginGate:
104 def __init__(
105 self,
106 app: ASGIApp,
107 *,
108 secret: str,
109 organization_id: str,
110 meter: Optional[ActivityMeter] = None,
111 ) -> None:
112 self.app = app
113 self.secret = secret
114 self.organization_id = organization_id
115 self.meter = meter
117 async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
118 if scope["type"] != "http":
119 await self.app(scope, receive, send)
120 return
121 path = scope["path"]
122 if path in OPEN_PATHS or path.startswith(OPEN_PREFIXES):
123 await self.app(scope, receive, send)
124 return
125 request = Request(scope, receive)
126 identity = session.verify(self.secret, self.organization_id, request.cookies.get(session.COOKIE))
127 if identity is not None:
128 if self.meter is not None:
129 self.meter.record()
130 scope.setdefault("state", {})["identity"] = identity
131 await self.app(scope, receive, send)
132 return
133 if path.startswith("/api/"):
134 response = JSONResponse({"detail": "login required"}, status_code=401)
135 else:
136 query = scope.get("query_string", b"").decode()
137 target = path + ("?" + query if query else "")
138 response = RedirectResponse("/login?" + urlencode({"next": target}), status_code=302)
139 await response(scope, receive, send)
142def request_base(request: Request) -> str:
143 """The site the browser used. The load balancer terminates TLS, so the
144 browser's scheme arrives in the forwarded header."""
145 scheme = request.headers.get("x-forwarded-proto") or request.url.scheme
146 return session.base_url(scheme, request.headers.get("host") or request.url.netloc)
149def secure(request: Request) -> bool:
150 """Whether to mark the cookie Secure: it is then sent back only over HTTPS,
151 which is every hosted request."""
152 return request.headers.get("x-forwarded-proto") == "https"
155def install(app: FastAPI, settings: PlaygroundSettings) -> bool:
156 """Put the sign-in in front of ``app`` when settings ask for it.
158 Returns whether the sign-in is on. Raises when the four settings are only
159 partly set, so a hosted app never starts open by accident. ``/api/me`` is
160 added either way, answering an empty object on the developer host.
161 """
162 values = (
163 settings.workos_api_key,
164 settings.workos_client_id,
165 settings.workos_organization_id,
166 settings.session_secret,
167 )
168 if not any(values):
170 def me_open() -> dict[str, str]:
171 return {}
173 app.add_api_route("/api/me", me_open, methods=["GET"], include_in_schema=False)
174 return False
175 if not all(values):
176 raise RuntimeError(
177 "WORKOS_API_KEY, WORKOS_CLIENT_ID, WORKOS_ORGANIZATION_ID and PLAYGROUND_SESSION_SECRET "
178 "must be set together; the sign-in is on only when all four are present."
179 )
180 api_key, client_id, organization_id, secret = (str(v) for v in values)
181 config = session.WorkOS(
182 api_key=api_key, client_id=client_id, organization_id=organization_id, session_secret=secret
183 )
184 meter = (
185 ActivityMeter(settings.activity_namespace, "quber-playground")
186 if settings.activity_namespace
187 else None
188 )
190 def login(request: Request, next: Optional[str] = None) -> HTMLResponse:
191 return HTMLResponse(session.login_page(config, request_base(request), next, MARK_SRC))
193 # Plain functions, so the call to WorkOS runs on the thread pool and never
194 # blocks the event loop.
195 def callback(request: Request, code: Optional[str] = None, state: Optional[str] = None) -> Response:
196 outcome = session.complete_sign_in(config, request_base(request), code, state, MARK_SRC)
197 if outcome.token is None or outcome.location is None:
198 logger.info("sign-in not accepted: status {}", outcome.status)
199 return HTMLResponse(outcome.page or "", status_code=outcome.status)
200 response = RedirectResponse(outcome.location, status_code=303)
201 response.headers.append("set-cookie", session.cookie_header(outcome.token, secure=secure(request)))
202 return response
204 def logout(request: Request) -> RedirectResponse:
205 identity = session.signed_identity(secret, request.cookies.get(session.COOKIE))
206 base = request_base(request)
207 target = session.logout_url(identity.sid, base) if identity and identity.sid else "/"
208 response = RedirectResponse(target, status_code=302)
209 response.headers.append("set-cookie", session.clear_cookie_header(secure=secure(request)))
210 return response
212 def me(request: Request) -> dict[str, str]:
213 identity: Optional[session.Identity] = getattr(request.state, "identity", None)
214 return {"email": identity.email} if identity is not None else {}
216 app.add_api_route("/login", login, methods=["GET"], response_class=HTMLResponse, include_in_schema=False)
217 app.add_api_route("/callback", callback, methods=["GET"], include_in_schema=False)
218 app.add_api_route("/logout", logout, methods=["GET"], include_in_schema=False)
219 app.add_api_route("/api/me", me, methods=["GET"], include_in_schema=False)
220 app.add_middleware(LoginGate, secret=secret, organization_id=organization_id, meter=meter)
221 return True