From ed1398dfd1445bbe574f4867fae688ee8e8c84ca Mon Sep 17 00:00:00 2001 From: "SREDevOps.org Agent" Date: Sun, 25 Oct 2026 04:34:13 -0300 Subject: [PATCH] feat(gateway): SSO landing auto-connects and offers recreate sandbox (#15) After oauth2-proxy authentication, the gateway now renders a minimal SSO landing (no signup/token forms) that auto-connects to the sandbox and offers a single 'Recreate sandbox' action for a clean start. - _sso_session: stop blocking on wait_ready (provisioning is non-blocking; the proxy path still awaits readiness), so the landing reports 'Provisioning' instead of stalling for WAKE_TIMEOUT - landing: SSO branch renders a minimal page (signed-in user + sandbox state + auto-connect meta-refresh), replacing the generic signup/token forms - new POST /recreate: deletes the claim (cascade pod+PVC), waits out finalizers, re-provisions, and reconnects; CSRF-protected, SSO-only - tests: 109 passing (SSO landing provision/connect/recreate cases) Co-authored-by: SREDevOpsAgents --- .../gateway/gateway.py | 101 ++++++++++++++++-- .../gateway/test_gateway.py | 71 +++++++++--- 2 files changed, 151 insertions(+), 21 deletions(-) diff --git a/examples/dsh-agents-as-a-service/gateway/gateway.py b/examples/dsh-agents-as-a-service/gateway/gateway.py index 2865b5c..71b5dba 100644 --- a/examples/dsh-agents-as-a-service/gateway/gateway.py +++ b/examples/dsh-agents-as-a-service/gateway/gateway.py @@ -622,10 +622,12 @@ class Gateway: """Provision ``remote`` idempotently and mint/refresh a session token. The authenticating proxy has already verified ``remote``, so it is - trusted. A brand-new user is provisioned like ``signup`` (claim created, - token minted, sandbox awaited); an existing user whose session cookie is - missing or no longer matches gets a rotated token so a fresh cookie can - be issued. A still-valid session is reused untouched. + trusted. A brand-new user is provisioned (claim created, token minted); + an existing user whose session cookie is missing or no longer matches + gets a rotated token so a fresh cookie can be issued. A still-valid + session is reused untouched. Provisioning is non-blocking: the sandbox + is awaited by the proxy path (``_resolve_sandbox``), so the landing page + can report "Provisioning" instead of stalling for ``WAKE_TIMEOUT``. """ claim = await self.k8s.get_claim(remote) token, cookie_user = self.credentials(headers, cookies) @@ -643,7 +645,6 @@ class Gateway: await self.k8s.patch_token(remote, token_hash) else: await self._touch(remote) - await self.wait_ready(remote, self.settings.wake_timeout) else: await self.k8s.patch_token(remote, token_hash) await self._touch(remote) @@ -835,14 +836,65 @@ class Gateway: async def healthz(self, request: Request) -> JSONResponse: return JSONResponse({"status": "ok"}) + @staticmethod + def _sso_page(user: str, state: str) -> str: + """Minimal SSO landing: connect to the sandbox, or offer to recreate it. + + No signup/token forms — the authenticating proxy has already identified + the user. A "Provisioning" sandbox refreshes itself until a pod exists; + any other state auto-connects to ``/u//`` and offers the single + "Recreate sandbox" action. + """ + safe_user = html.escape(user) + path = quote(user) + if state == "Provisioning": + status = "Provisioning" + body = ( + '' + "

Provisioning your sandbox… this can take a minute.

" + ) + else: + status = html.escape(state) + confirm = ( + 'onsubmit="return confirm(\'Delete this sandbox and start fresh? ' + "Your workspace data will be wiped.')\"" + ) + body = ( + f'' + "

Connecting to your sandbox…

" + f'
' + '
' + ) + return ( + '' + '' + "DSH sandbox" + "

DeepSeek Harness sandbox

" + f"

Signed in as {safe_user}.

" + f"

Sandbox status: {status}.

" + f"{body}" + "" + ) + async def landing(self, request: Request) -> Response: remote = self.remote_user(request.headers) if remote: - response = RedirectResponse(f"/u/{remote}/", status_code=303) - await self._sso_session( + # SSO: auto-provision + connect. A temporary response captures the + # session cookie minted by _sso_session; the real page is rendered + # once the sandbox state is known. + response = HTMLResponse() + claim = await self._sso_session( remote, request.headers, request.cookies, response ) - return response + sandbox = await self._sandbox_of(claim) if claim else None + page = HTMLResponse(self._sso_page(remote, derive_state(sandbox))) + if "set-cookie" in response.headers: + page.headers["set-cookie"] = response.headers["set-cookie"] + return page token, user = self.credentials(request.headers, request.cookies) session = "" if user and token: @@ -1043,6 +1095,38 @@ against the JSON API. The browser session is an HttpOnly cookie.

{"user": user, "note": "claim deleted; sandbox and PVC cascade"} ) + async def recreate(self, request: Request) -> Response: + """Delete and re-provision the SSO user's sandbox for a clean start. + + SSO-only: the identity comes from the trusted proxy header. Deleting the + claim cascades to the sandbox pod and its PVC, wiping user state; we poll + until the claim is fully gone (finalizers) before recreating to avoid a + name-collision 409, then reconnect. + """ + remote = self.remote_user(request.headers) + if not remote: + return self._error(401, "unauthorized") + _token, cookie_user = self.credentials(request.headers, request.cookies) + if cookie_user is not None and cookie_user != remote: + return self._error(401, "unauthorized") + if self._mutation_from_cookie(request.headers, "POST", cookie_user): + return self._error(403, "cross-origin request rejected") + await self.k8s.delete_claim(remote) + self._clear_handshake(remote) + async with self.state.lock: + self.state.last_activity.pop(remote, None) + self.state.in_flight.pop(remote, None) + # Wait out any finalizer so the claim name is reusable. + for _ in range(30): + if await self.k8s.get_claim(remote) is None: + break + await asyncio.sleep(1) + response = RedirectResponse(f"/u/{remote}/", status_code=303) + await self._sso_session( + remote, request.headers, request.cookies, response + ) + return response + # -- HTTP proxy -------------------------------------------------------- async def proxy_http(self, request: Request) -> Response: @@ -1447,6 +1531,7 @@ def create_app( Route("/users", gateway.signup, methods=["POST"]), Route("/login", gateway.login, methods=["POST"]), Route("/logout", gateway.logout, methods=["POST"]), + Route("/recreate", gateway.recreate, methods=["POST"]), Route("/users/{user}", gateway.get_user), Route("/users/{user}", gateway.delete_user, methods=["DELETE"]), Route("/u/{user}", gateway.redirect_to_workspace), diff --git a/examples/dsh-agents-as-a-service/gateway/test_gateway.py b/examples/dsh-agents-as-a-service/gateway/test_gateway.py index 9f68026..f3db972 100644 --- a/examples/dsh-agents-as-a-service/gateway/test_gateway.py +++ b/examples/dsh-agents-as-a-service/gateway/test_gateway.py @@ -1460,32 +1460,36 @@ def test_remote_user_returns_valid_and_rejects_invalid(env): @pytest.mark.asyncio -async def test_sso_landing_provisions_new_user_and_redirects(env, monkeypatch): +async def test_sso_landing_new_user_shows_provisioning(env): enable_sso(env) - - async def ready(user, timeout): - return {"metadata": {"name": f"dsh-{user}-sbx"}} - - monkeypatch.setattr(env.gateway, "wait_ready", ready) async with asgi_client(env) as client: response = await client.get("/", headers={"X-Forwarded-User": "alice"}) - assert response.status_code == 303 - assert response.headers["location"] == "/u/alice/" + assert response.status_code == 200 + assert "text/html" in response.headers["content-type"] + assert "Signed in as alice" in response.text + assert "Provisioning" in response.text + assert "Recreate sandbox" not in response.text assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert [c[0] for c in env.k8s.created] == ["alice"] assert env.k8s.token_patches == [] @pytest.mark.asyncio -async def test_sso_landing_reuses_valid_session_without_rotation(env): +async def test_sso_landing_ready_connects_and_offers_recreate_only(env): enable_sso(env) add_user(env.k8s) async with asgi_client( env, cookies={"dsh_gw_session": f"alice:{TOKEN}"} ) as client: response = await client.get("/", headers={"X-Forwarded-User": "alice"}) - assert response.status_code == 303 - assert response.headers["location"] == "/u/alice/" + assert response.status_code == 200 + assert "Signed in as alice" in response.text + assert "Recreate sandbox" in response.text + # No signup/token forms for SSO users. + assert 'action="/users"' not in response.text + assert 'action="/login"' not in response.text + # Auto-connects to the workspace. + assert "url=/u/alice/" in response.text assert env.k8s.created == [] assert env.k8s.token_patches == [] @@ -1496,13 +1500,54 @@ async def test_sso_landing_rotates_token_when_session_missing(env): add_user(env.k8s) async with asgi_client(env) as client: response = await client.get("/", headers={"X-Forwarded-User": "alice"}) - assert response.status_code == 303 - assert response.headers["location"] == "/u/alice/" + assert response.status_code == 200 + assert "Recreate sandbox" in response.text assert re_cookie_user(response.headers["set-cookie"]) == "alice" assert [p[0] for p in env.k8s.token_patches] == ["alice"] assert env.k8s.created == [] +@pytest.mark.asyncio +async def test_recreate_deletes_and_reprovisions(env): + enable_sso(env) + add_user(env.k8s) + async with asgi_client(env) as client: + response = await client.post( + "/recreate", headers={"X-Forwarded-User": "alice"} + ) + assert response.status_code == 303 + assert response.headers["location"] == "/u/alice/" + assert env.k8s.deleted == ["alice"] + assert [c[0] for c in env.k8s.created] == ["alice"] + assert re_cookie_user(response.headers["set-cookie"]) == "alice" + + +@pytest.mark.asyncio +async def test_recreate_requires_sso(env): + add_user(env.k8s) + async with asgi_client(env) as client: + response = await client.post( + "/recreate", headers={"X-Forwarded-User": "alice"} + ) + assert response.status_code == 401 + assert env.k8s.deleted == [] + + +@pytest.mark.asyncio +async def test_recreate_rejects_cross_origin(env): + enable_sso(env) + add_user(env.k8s) + async with asgi_client( + env, cookies={"dsh_gw_session": f"alice:{TOKEN}"} + ) as client: + response = await client.post( + "/recreate", + headers={"X-Forwarded-User": "alice", "Origin": "http://evil.test"}, + ) + assert response.status_code == 403 + assert env.k8s.deleted == [] + + @pytest.mark.asyncio async def test_sso_get_user_authorizes_via_header(env): enable_sso(env)