From d90bf8eeefc4ac34e6d665b1c28650f0470da547 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 20 May 2026 13:43:53 +0100 Subject: [PATCH] Refresh Heimdall sessions in hosted access --- docs/implementation-plan.md | 2 + notes/fresh-workspace-handoff.md | 5 + src/repixelizer/access.py | 179 ++++++++++++++++++++++++++----- src/repixelizer/gui.py | 6 +- state/evidence.jsonl | 1 + state/map.yaml | 5 +- tests/test_gui.py | 74 +++++++++++++ 7 files changed, 246 insertions(+), 26 deletions(-) diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index e1e1953..fe8b9c6 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -30,6 +30,8 @@ The hosted web shell has an auth seam: - `src/repixelizer/access.py` owns the app-local access boundary and local ownership checks - `src/repixelizer/gui.py` owns hosted route policy, landing-page behavior, and queue/job stamping - `GC_ACCESS_MODE=heimdall` supports provider start, backend callback receipt, local JWT verification, and httpOnly session adoption +- Heimdall mode also keeps a separate httpOnly refresh cookie and asks + Heimdall to reissue short-lived app claims before restarting provider OAuth - self-host and local runs stay permissive by default unless `GC_ACCESS_*` turns the seam on ## What is working diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index 038972a..ac8307c 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -42,6 +42,11 @@ Do not continue implementation automatically from a rehydrate-only request. - Low-confidence autocorr candidates can be preview-reranked; hosted mode does not forcibly disable rerank. - Hosted access lives in `src/repixelizer/access.py` and `src/repixelizer/gui.py`. - `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally and adopts httpOnly local sessions. +- Heimdall mode stores a separate httpOnly refresh cookie + (`gc_access_token_refresh` by default) and calls Heimdall's + `/v1/apps/repixelizer/sessions/refresh` endpoint with Repixelizer-owned + Discord/Patreon entitlement policies before sending the user through provider + OAuth again. - Self-host/local runs stay permissive by default unless `GC_ACCESS_*` enables access policy. - Do not smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. diff --git a/src/repixelizer/access.py b/src/repixelizer/access.py index 7edd1bc..40b5f98 100644 --- a/src/repixelizer/access.py +++ b/src/repixelizer/access.py @@ -10,6 +10,7 @@ import urllib.error import urllib.request import uuid from dataclasses import dataclass, field +from datetime import datetime from typing import Any, Iterator from urllib.parse import urljoin @@ -20,6 +21,10 @@ _CURRENT_ACCESS_SUBJECT: contextvars.ContextVar["AccessSubject | None"] = contex "repixelizer_current_access_subject", default=None, ) +_CURRENT_REFRESHED_SESSION: contextvars.ContextVar["AdoptedAuthSession | None"] = contextvars.ContextVar( + "repixelizer_current_refreshed_session", + default=None, +) _PERMISSIVE_CAPABILITIES = frozenset( { @@ -46,6 +51,7 @@ _AUTH_CALLBACK_PATH = "/api/auth/heimdall/callback" _AUTH_START_PATH = "/api/auth/heimdall/start" _AUTH_ATTEMPT_PATH_TEMPLATE = "/api/auth/attempts/{attemptId}" _AUTH_LOGOUT_PATH = "/api/auth/logout" +_SESSION_REFRESH_ENDPOINT_TEMPLATE = "/v1/apps/{appSlug}/sessions/refresh" def _env_text(name: str) -> str | None: @@ -115,6 +121,13 @@ def _read_json_response(response) -> Any: return json.loads(raw.decode("utf-8")) +def _iso_to_epoch_seconds(value: str) -> float: + text = value.strip() + if text.endswith("Z"): + text = f"{text[:-1]}+00:00" + return datetime.fromisoformat(text).timestamp() + + def _fetch_json(url: str, *, timeout_seconds: float) -> Any: request = urllib.request.Request(url, headers={"accept": "application/json"}, method="GET") with urllib.request.urlopen(request, timeout=timeout_seconds) as response: @@ -198,6 +211,7 @@ class AccessRuntimeConfig: login_url: str | None logout_url: str | None session_cookie_name: str + refresh_cookie_name: str session_cookie_secure: bool session_cookie_samesite: str session_cookie_domain: str | None @@ -264,6 +278,8 @@ class AuthAttempt: error: str | None = None error_description: str | None = None access_token: str | None = None + refresh_token: str | None = None + refresh_expires_at: float | None = None subject: AccessSubject | None = None def sync_status(self, *, now: float) -> None: @@ -274,6 +290,8 @@ class AuthAttempt: self.error = "auth_attempt_expired" self.error_description = "This sign-in attempt expired before the local session was adopted." self.access_token = None + self.refresh_token = None + self.refresh_expires_at = None self.subject = None def to_public_json(self, *, now: float) -> dict[str, Any]: @@ -297,9 +315,11 @@ class AuthAttempt: @dataclass(frozen=True, slots=True) class AdoptedAuthSession: access_token: str + refresh_token: str | None subject: AccessSubject return_to: str expires_at: float + refresh_expires_at: float | None = None def to_public_json(self) -> dict[str, Any]: return { @@ -486,6 +506,7 @@ class AccessController: attempt_ttl_seconds = max(60, _env_int("GC_ACCESS_AUTH_ATTEMPT_TTL_SECONDS", 900)) jwks_cache_seconds = max(60, _env_int("GC_ACCESS_JWKS_CACHE_SECONDS", 300)) session_cookie_name = _env_text("GC_ACCESS_SESSION_COOKIE_NAME") or "gc_access_token" + refresh_cookie_name = _env_text("GC_ACCESS_REFRESH_COOKIE_NAME") or f"{session_cookie_name}_refresh" session_cookie_samesite = (_env_text("GC_ACCESS_SESSION_COOKIE_SAMESITE") or "lax").lower() if session_cookie_samesite not in {"lax", "strict", "none"}: session_cookie_samesite = "lax" @@ -536,6 +557,7 @@ class AccessController: login_url=login_url, logout_url=logout_url, session_cookie_name=session_cookie_name, + refresh_cookie_name=refresh_cookie_name, session_cookie_secure=session_cookie_secure, session_cookie_samesite=session_cookie_samesite, session_cookie_domain=session_cookie_domain, @@ -557,6 +579,12 @@ class AccessController: def public_payload(self) -> dict[str, Any]: return self.runtime.to_public_json() + def pop_refreshed_session(self) -> AdoptedAuthSession | None: + session = _CURRENT_REFRESHED_SESSION.get() + if session is not None: + _CURRENT_REFRESHED_SESSION.set(None) + return session + def current_subject(self) -> AccessSubject: subject = _CURRENT_ACCESS_SUBJECT.get() if subject is not None: @@ -635,21 +663,9 @@ class AccessController: "callbackUrl": callback_url, }, } - if ( - provider_slug == "discord" - and self.runtime.discord_access_guild_id - and self.runtime.discord_access_role_ids - ): - request_payload["entitlementPolicy"] = { - "kind": "discord_role_access", - "guildId": self.runtime.discord_access_guild_id, - "allowedRoleIds": list(self.runtime.discord_access_role_ids), - } - elif provider_slug == "patreon": - request_payload["entitlementPolicy"] = { - "kind": "patreon_membership_access", - "requiredTierTitle": self.runtime.patreon_required_tier_title or "Inner Sanctum", - } + entitlement_policy = self._entitlement_policy(provider_slug) + if entitlement_policy is not None: + request_payload["entitlementPolicy"] = entitlement_policy start_url = urljoin(f"{heimdall_base_url}/", _AUTH_START_ENDPOINT_TEMPLATE.format(provider=provider_slug).lstrip("/")) try: response_payload = _post_json( @@ -689,6 +705,55 @@ class AccessController: "statusEndpoint": _AUTH_ATTEMPT_PATH_TEMPLATE.format(attemptId=attempt_id), } + def refresh_session(self, refresh_token: str) -> AdoptedAuthSession: + self._require_heimdall_mode() + heimdall_base_url = self.runtime.heimdall_base_url + if heimdall_base_url is None: + raise AccessOperationError(503, "Hosted Heimdall auth is misconfigured on this Repixelizer node.") + refresh_url = urljoin( + f"{heimdall_base_url}/", + _SESSION_REFRESH_ENDPOINT_TEMPLATE.format(appSlug=self.runtime.app_slug).lstrip("/"), + ) + payload: dict[str, Any] = { + "refreshToken": refresh_token, + } + policies = [ + policy + for policy in (self._entitlement_policy(provider.slug) for provider in self.runtime.providers) + if policy is not None + ] + if policies: + payload["entitlementPolicies"] = policies + try: + response_payload = _post_json(refresh_url, payload, timeout_seconds=self.runtime.http_timeout_seconds) + except urllib.error.HTTPError as exc: + raise AccessOperationError(exc.code, self._http_error_detail(exc)) from exc + except OSError as exc: + raise AccessOperationError(502, f"Failed to contact Heimdall: {exc}.") from exc + if not isinstance(response_payload, dict): + raise AccessOperationError(502, "Heimdall refresh response was malformed.") + access_token = response_payload.get("accessToken") + if not isinstance(access_token, str) or not access_token.strip(): + raise AccessOperationError(502, "Heimdall refresh response did not include an access token.") + claims = self._verify_access_token(access_token) + subject = self._subject_from_claims(claims) + refresh_token_next = response_payload.get("refreshToken") + refresh_payload = response_payload.get("refresh") + refresh_expires_at = None + if isinstance(refresh_payload, dict): + refresh_expires_at_raw = refresh_payload.get("expiresAt") + if isinstance(refresh_expires_at_raw, str): + with contextlib.suppress(ValueError): + refresh_expires_at = _iso_to_epoch_seconds(refresh_expires_at_raw) + return AdoptedAuthSession( + access_token=access_token, + refresh_token=refresh_token_next if isinstance(refresh_token_next, str) and refresh_token_next.strip() else None, + subject=subject, + return_to=self.runtime.default_return_to or "/app/", + expires_at=float(claims["exp"]), + refresh_expires_at=refresh_expires_at, + ) + def get_auth_attempt_status(self, attempt_id: str) -> dict[str, Any]: with self._attempt_lock: self._prune_attempts_locked(now=time.time()) @@ -714,9 +779,11 @@ class AccessController: expires_at = float(claims.get("exp", int(time.time()))) return AdoptedAuthSession( access_token=attempt.access_token, + refresh_token=attempt.refresh_token, subject=attempt.subject, return_to=attempt.return_to, expires_at=expires_at, + refresh_expires_at=attempt.refresh_expires_at, ) def receive_backend_handoff(self, payload: Any) -> None: @@ -763,6 +830,8 @@ class AccessController: else "Heimdall reported an upstream auth failure." ) attempt.access_token = None + attempt.refresh_token = None + attempt.refresh_expires_at = None attempt.subject = None return @@ -781,6 +850,8 @@ class AccessController: "Heimdall authenticated the account, but it did not grant Repixelizer app access." ) attempt.access_token = None + attempt.refresh_token = None + attempt.refresh_expires_at = None attempt.subject = None return @@ -794,6 +865,14 @@ class AccessController: raise AccessOperationError(400, "Heimdall callback session metadata did not match the access token.") expires_at = float(claims["exp"]) + refresh_token = payload.get("refreshToken") + refresh_payload = payload.get("refresh") + refresh_expires_at = None + if isinstance(refresh_payload, dict): + refresh_expires_at_raw = refresh_payload.get("expiresAt") + if isinstance(refresh_expires_at_raw, str): + with contextlib.suppress(ValueError): + refresh_expires_at = _iso_to_epoch_seconds(refresh_expires_at_raw) with self._attempt_lock: attempt = self._attempts.get(attempt_id) if attempt is not None: @@ -801,6 +880,8 @@ class AccessController: attempt.error = None attempt.error_description = None attempt.access_token = access_token + attempt.refresh_token = refresh_token if isinstance(refresh_token, str) and refresh_token.strip() else None + attempt.refresh_expires_at = refresh_expires_at attempt.subject = subject attempt.expires_at = expires_at @@ -816,6 +897,18 @@ class AccessController: path="/", domain=self.runtime.session_cookie_domain, ) + if session.refresh_token: + refresh_expires_at = session.refresh_expires_at or session.expires_at + response.set_cookie( + key=self.runtime.refresh_cookie_name, + value=session.refresh_token, + max_age=max(0, int(refresh_expires_at - time.time())), + httponly=True, + secure=self.runtime.session_cookie_secure, + samesite=self.runtime.session_cookie_samesite, + path="/", + domain=self.runtime.session_cookie_domain, + ) def clear_session_cookie(self, response: Any) -> None: response.delete_cookie( @@ -826,6 +919,14 @@ class AccessController: httponly=True, samesite=self.runtime.session_cookie_samesite, ) + response.delete_cookie( + key=self.runtime.refresh_cookie_name, + path="/", + domain=self.runtime.session_cookie_domain, + secure=self.runtime.session_cookie_secure, + httponly=True, + samesite=self.runtime.session_cookie_samesite, + ) def _require_capability(self, subject: AccessSubject, capability: str) -> None: if self.runtime.mode == "off": @@ -875,17 +976,29 @@ class AccessController: def _heimdall_subject(self, request: Any, *, allow_anonymous: bool) -> AccessSubject: token = request.cookies.get(self.runtime.session_cookie_name) - if token is None or not token.strip(): - if allow_anonymous: - return AccessSubject(auth_mode=self.runtime.mode) - raise AccessDenied(401, "Sign-in required.") + if token is not None and token.strip(): + try: + claims = self._verify_access_token(token) + return self._subject_from_claims(claims) + except AccessOperationError: + pass + refreshed = self._refresh_subject_from_cookie(request) + if refreshed is not None: + return refreshed + if allow_anonymous: + return AccessSubject(auth_mode=self.runtime.mode) + raise AccessDenied(401, "Sign-in required.") + + def _refresh_subject_from_cookie(self, request: Any) -> AccessSubject | None: + refresh_token = request.cookies.get(self.runtime.refresh_cookie_name) + if refresh_token is None or not refresh_token.strip(): + return None try: - claims = self._verify_access_token(token) + session = self.refresh_session(refresh_token) except AccessOperationError: - if allow_anonymous: - return AccessSubject(auth_mode=self.runtime.mode) - raise AccessDenied(401, "Sign-in required.") from None - return self._subject_from_claims(claims) + return None + _CURRENT_REFRESHED_SESSION.set(session) + return session.subject def _subject_from_claims(self, claims: dict[str, Any]) -> AccessSubject: return AccessSubject( @@ -903,6 +1016,24 @@ class AccessController: raise AccessOperationError(503, "Heimdall verification is not configured on this node.") return self._verifier.verify(token) + def _entitlement_policy(self, provider_slug: str) -> dict[str, Any] | None: + if ( + provider_slug == "discord" + and self.runtime.discord_access_guild_id + and self.runtime.discord_access_role_ids + ): + return { + "kind": "discord_role_access", + "guildId": self.runtime.discord_access_guild_id, + "allowedRoleIds": list(self.runtime.discord_access_role_ids), + } + if provider_slug == "patreon": + return { + "kind": "patreon_membership_access", + "requiredTierTitle": self.runtime.patreon_required_tier_title or "Inner Sanctum", + } + return None + def _require_heimdall_mode(self) -> None: if self.runtime.mode != "heimdall": raise AccessOperationError(404, "Heimdall auth is not enabled in this runtime mode.") diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index f24088b..a9267ab 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -1071,7 +1071,11 @@ def create_app(): async def call_with_subject(subject): with bind_access_subject(subject): - return await call_next(request) + response = await call_next(request) + refreshed = access_controller.pop_refreshed_session() + if refreshed is not None: + access_controller.attach_session_cookie(response, refreshed) + return response def resolve_job_subject(capability: str): parts = [part for part in path.split("/") if part] diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 0e85c5b..2d06353 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -27,3 +27,4 @@ {"ts": "2026-04-29T17:29:40+00:00", "type": "implementation", "status": "ok", "note": "Hosted GUI now emits explicit service logs for queue saturation, job queue/start/completion/failure/cancel events, and wait/run/total seconds so load pressure shows up in journald instead of only in transient SSE state."} {"ts": "2026-04-29T21:40:11+00:00", "type": "implementation", "status": "ok", "note": "Fixed GUI failed-response handling so submit/config/queue/cancel errors read the Response body once, preserving the real server error instead of throwing 'body stream already read'."} {"ts": "2026-04-29T21:42:58+00:00", "type": "implementation", "status": "ok", "note": "Oversize uploads were hitting nginx 413 before FastAPI and the GUI masked the response with a double-read error; GUI now preflights file size and reads failed responses once so users see the actual upload limit."} +{"ts": "2026-05-20T12:43:01+00:00", "type": "implementation", "status": "ok", "note": "Repixelizer Heimdall mode now stores a separate httpOnly refresh cookie and calls Heimdall's session-refresh endpoint with app-owned Discord/Patreon entitlement policies before forcing provider OAuth again."} diff --git a/state/map.yaml b/state/map.yaml index 9afa771..c79bb8f 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -51,6 +51,7 @@ accepted_design: - Hosted demo inference uses autocorr lattice candidates, capped by the hosted output limit, with low-confidence preview rerank available. - Hosted mode owns the public front door in `src/repixelizer/gui.py`: `/` is a landing page there, while local/self-host runs keep root as a plain redirect to `/app/`. - `src/repixelizer/access.py` owns the hosted access seam: trusted-header ingress still works, `GC_ACCESS_MODE=heimdall` verifies Heimdall Ed25519 access tokens locally, and auth attempts are adopted into local httpOnly cookie sessions. + - In Heimdall mode, Repixelizer keeps a separate httpOnly refresh cookie and asks Heimdall to reissue a short-lived access claim before forcing the user back through provider OAuth. - The solver starts from fixed lattice centers, adds tiny seeded noise, follows a direct hierarchical flatness signal, enforces local lattice coherence through spring relaxation, then rounds to real source pixels for final output. - Cleanup is local and secondary; it does not define the core reconstruction model. - `source_fidelity` and `source_structure` separate lattice agreement from visible structure. @@ -87,7 +88,9 @@ current_status: low-confidence autocorr candidates can be preview-reranked. Hosted web access is auth-landed without smearing into the solver: `src/repixelizer/access.py` verifies Heimdall Ed25519 access tokens - locally, tracks auth attempts, and adopts local cookie sessions, while + locally, tracks auth attempts, adopts local cookie sessions, and refreshes + expired/missing access cookies through Heimdall's signed session-refresh + endpoint before falling back to provider OAuth, while `src/repixelizer/gui.py` owns provider-start routes, direct backend callback receipt, hosted landing-page login buttons, route guards, and queue/job ownership checks. Self-host remains permissive by default unless diff --git a/tests/test_gui.py b/tests/test_gui.py index b587f5f..f8e1baa 100644 --- a/tests/test_gui.py +++ b/tests/test_gui.py @@ -913,6 +913,80 @@ def test_gui_heimdall_callback_flow_adopts_local_cookie_session(monkeypatch, tmp assert app_headers["cache-control"] == "no-store, no-cache, must-revalidate, max-age=0" +def test_gui_heimdall_session_endpoint_refreshes_from_refresh_cookie(monkeypatch, tmp_path: Path) -> None: + from repixelizer.gui import create_app + + private_key, jwk, key_id = _heimdall_signing_material() + refresh_calls: list[tuple[str, dict[str, object], float]] = [] + + def fake_post_json(url: str, payload: dict[str, object], *, timeout_seconds: float): + refresh_calls.append((url, payload, timeout_seconds)) + return { + "session": { + "accountId": "acct-1", + "sessionId": "sess-1", + "appSlug": "repixelizer", + "accessRevision": 4, + "expiresAt": "2026-04-27T13:00:00.000Z", + }, + "accessToken": _heimdall_access_token(private_key, key_id=key_id), + "refreshToken": "refresh-token-2", + "refresh": {"expiresAt": "2999-04-27T13:00:00.000Z"}, + "claimSet": {}, + "verification": { + "issuer": "https://heimdall.gamecult.org", + "jwksUri": "https://heimdall.gamecult.org/.well-known/jwks.json", + "alg": "EdDSA", + "kid": key_id, + }, + "sharedCapabilities": ["app_access", "queue_submit"], + "hybridCapabilities": [], + } + + def fake_fetch_json(url: str, *, timeout_seconds: float): + del timeout_seconds + assert url == "https://heimdall.gamecult.org/.well-known/jwks.json" + return {"keys": [jwk]} + + monkeypatch.setenv("REPIXELIZER_HOSTED_DEMO", "1") + monkeypatch.setenv("REPIXELIZER_SPOOL_DIR", str(tmp_path / "spool")) + monkeypatch.setenv("GC_ACCESS_MODE", "heimdall") + monkeypatch.setenv("GC_ACCESS_HEIMDALL_BASE_URL", "https://heimdall.gamecult.org") + monkeypatch.setenv("GC_ACCESS_APP_PUBLIC_BASE_URL", "https://repixelizer.gamecult.org") + monkeypatch.setenv("GC_ACCESS_ALLOWED_PROVIDERS", "discord,patreon") + monkeypatch.setenv("REPIXELIZER_ACCESS_DISCORD_GUILD_ID", "gamecult-guild") + monkeypatch.setenv("REPIXELIZER_ACCESS_DISCORD_ALLOWED_ROLE_IDS", "role-repixelizer,role-patreon") + monkeypatch.setenv("REPIXELIZER_ACCESS_PATREON_TIER_TITLE", "Inner Sanctum") + monkeypatch.setattr("repixelizer.access._post_json", fake_post_json) + monkeypatch.setattr("repixelizer.access._fetch_json", fake_fetch_json) + + app = create_app() + + status, headers, body = asyncio.run( + _get_response(app, "/api/auth/session", headers={"cookie": "gc_access_token_refresh=refresh-token-1"}) + ) + payload = _response_json(type("Response", (), {"body": body})()) + + assert status == 200 + assert payload["subject"]["authenticated"] is True + assert payload["subject"]["accountId"] == "acct-1" + assert refresh_calls + assert refresh_calls[0][0] == "https://heimdall.gamecult.org/v1/apps/repixelizer/sessions/refresh" + assert refresh_calls[0][1]["refreshToken"] == "refresh-token-1" + assert refresh_calls[0][1]["entitlementPolicies"] == [ + { + "kind": "discord_role_access", + "guildId": "gamecult-guild", + "allowedRoleIds": ["role-repixelizer", "role-patreon"], + }, + { + "kind": "patreon_membership_access", + "requiredTierTitle": "Inner Sanctum", + }, + ] + assert "set-cookie" in headers + + def test_gui_heimdall_patreon_auth_start_sends_membership_policy(monkeypatch, tmp_path: Path) -> None: from repixelizer.gui import create_app