diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 39f6dc7..016ce2a 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -31,6 +31,7 @@ The hosted web shell now also 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` now lands the first real shared-auth consumer path there: provider start, backend callback receipt, local JWT verification, and httpOnly session adoption - self-host and local runs stay permissive by default unless `GC_ACCESS_*` turns the seam on ## What is working @@ -132,9 +133,16 @@ Rules: Current stance: - Heimdall integration belongs in the hosted web layer, not in the reconstruction pipeline -- queued jobs now have local owner metadata so future auth can resolve job access from local session/account instead of provider ids -- the repo is only "auth-ready", not "auth-landed" -- `GC_ACCESS_MODE=trusted-header` exists as a thin local seam for future integration and route-policy tests; it is not the final Heimdall story +- queued jobs now resolve ownership from local session/account metadata instead of provider ids +- `GC_ACCESS_MODE=heimdall` is now the real hosted path: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts direct backend callbacks, verifies Heimdall Ed25519 access tokens locally, and adopts local cookie sessions without dragging provider tokens through browser URLs +- `GC_ACCESS_MODE=trusted-header` still exists as the cheap ingress seam for local route-policy tests and reverse-proxy experiments +- future auth work still belongs in `src/repixelizer/access.py` and `src/repixelizer/gui.py`, not in `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack + +Immediate auth follow-ups, if the user wants them: + +- add more providers or policy bindings without changing the solver +- surface session/logout state inside `/app/` if hosted UX needs it +- decide whether auth attempts or adopted sessions need persistence beyond this single-process hosted runtime ## Guardrails diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index ae94210..fff1cfa 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -30,7 +30,8 @@ Do not trust this file for the exact live HEAD. Always check git. - The live pipeline is `source -> lattice inference -> edge analysis -> phase-field reconstruction -> cleanup -> optional palette fit -> diagnostics`. - searched lattice inference now trusts autocorrelation only; it keeps a tiny near-best lag plateau and looks for cross-axis consensus before collapsing to one lattice family. the old change-interval / pixel-walk sizing path was cut after it kept steering the badge away from the `126` family, and the projected edge-energy spectral pass was also removed after floor-hugging to 2px / 626-family aliases on the dense landscape fixture - hosted demo inference now uses one direct autocorr-consensus size plus the cheap inferred phase, capped by the hosted output limit. it no longer silently pins unspecified jobs to `max_output_dimension` -- hosted web access now has an explicit seam in `src/repixelizer/access.py`; `src/repixelizer/gui.py` binds request subjects, guards hosted routes, and stamps queued jobs with local owner metadata for future Heimdall wiring +- hosted web access now has an explicit seam in `src/repixelizer/access.py`; `src/repixelizer/gui.py` binds request subjects, guards hosted routes, and stamps queued jobs with local owner metadata +- `GC_ACCESS_MODE=heimdall` is now landed in that web layer: Repixelizer creates auth attempts, asks Heimdall to start provider OAuth, accepts the direct backend callback, verifies Heimdall Ed25519 access tokens locally, and adopts an httpOnly local session cookie - The low-confidence phase rerank path is a short preview solve, not a second full optimizer. - the phase field now has a wider displacement budget, so moderate phase mistakes are allowed to get corrected by the field instead of relying purely on rerank ceremony - Cleanup is secondary and usually a no-op; the core result is supposed to come from the solver, not cleanup cosplay. @@ -39,7 +40,7 @@ Do not trust this file for the exact live HEAD. Always check git. - The old tray optimizer is dead and should stay dead unless the entire machine map changes for a real reason. - `E:\Projects\Heimdall\docs\architecture.md` is the future shared design for reusable GameCult-hosted access across experiments. - `E:\Projects\Heimdall\docs\app-profiles\repixelizer.md` is the Repixelizer-specific binding onto that future shared access layer. -- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`. +- current hosted-demo truth lives in `src/repixelizer/gui.py`: queue and limit machinery, hosted direct-autocorr inference defaults, provider-start / callback / adopt / logout auth routes, and a hosted-only landing page at `/` while self-host/local runs still redirect root straight to `/app/`. - product strategy is hosted convenience first: keep the code open, let self-hosters suffer voluntarily, and do not burn time on native desktop packaging unless it clearly beats hosting on revenue, support, or strategy. ## Critical Doctrine @@ -58,6 +59,7 @@ The current spine: - searched lattice inference currently trusts autocorrelation only, not the rejected pixel-walk or spectral priors - hosted demo inference uses the direct autocorr path and keeps phase rerank off - hosted route auth belongs in `src/repixelizer/access.py` + `src/repixelizer/gui.py`, not in the solver or pipeline +- the first Heimdall consumer path is live there: provider buttons on the landing page, direct backend callback handoff, local JWT verification, and cookie adoption - low-confidence phase rerank through short preview reconstruction - edge analysis feeding one projected displacement-field optimizer with a wider displacement leash - nearest-source final sampling from `uv0_px + disp_t` @@ -76,7 +78,7 @@ The exact current control flow is documented in - Do not let `state/evidence.jsonl` turn into an activity feed. - Do not restart broad exploratory surgery when the current weak spot is still one bounded tapered-contour blemish. - Do not drift into desktop-app fantasies just because the web GUI is nice now. The default commercial path is still hosted access. -- Do not let future Heimdall work smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. The seam is already in the hosted web layer; use it. +- Do not let future Heimdall work smear auth logic into `src/repixelizer/pipeline.py`, `src/repixelizer/inference.py`, or the solver stack. The seam is already in the hosted web layer; keep using it. ## Verification Guardrails @@ -114,11 +116,15 @@ sideways and starts calling itself architecture. Do not continue implementation automatically from a rehydrate-only request. -If the user asks to continue, the current next move is to take one bounded -hypothesis for tapered-contour behavior in `src/repixelizer/phase_field.py`, -check it against the pinned badge case and -`tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual -result or `source_structure` does not clearly improve. +If the user asks to continue, choose the branch deliberately: + +- auth branch: keep working only in `src/repixelizer/access.py` and + `src/repixelizer/gui.py` for bounded Heimdall polish such as provider + expansion, session UX, or persistence hardening +- solver branch: take one bounded hypothesis for tapered-contour behavior in + `src/repixelizer/phase_field.py`, check it against the pinned badge case and + `tests/fixtures/real/ai-badge-tip-focus.json`, and revert it if the visual + result or `source_structure` does not clearly improve ## Immediate Re-entry Instruction diff --git a/pyproject.toml b/pyproject.toml index a54e9fa..656822f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "fastapi>=0.115.0", "numpy>=2.1.0", "Pillow>=10.4.0", + "PyJWT[crypto]>=2.10.1", "python-multipart>=0.0.9", "torch>=2.6.0", "uvicorn>=0.30.0" diff --git a/src/repixelizer/access.py b/src/repixelizer/access.py index 426b559..29c528f 100644 --- a/src/repixelizer/access.py +++ b/src/repixelizer/access.py @@ -2,9 +2,18 @@ from __future__ import annotations import contextlib import contextvars +import json import os +import threading +import time +import urllib.error +import urllib.request +import uuid from dataclasses import dataclass, field from typing import Any, Iterator +from urllib.parse import urljoin + +import jwt _CURRENT_ACCESS_SUBJECT: contextvars.ContextVar["AccessSubject | None"] = contextvars.ContextVar( @@ -22,6 +31,22 @@ _PERMISSIVE_CAPABILITIES = frozenset( } ) +_HEIMDALL_PROVIDER_LABELS = { + "discord": "Discord", + "patreon": "Patreon", + "github": "GitHub", + "twitch": "Twitch", + "youtube": "YouTube", +} + +_DEFAULT_REPIXELIZER_PROVIDERS = ("discord", "patreon") +_CLOCK_SKEW_SECONDS = 30 +_AUTH_START_ENDPOINT_TEMPLATE = "/v1/oauth/{provider}/start" +_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" + def _env_text(name: str) -> str | None: raw = os.getenv(name) @@ -38,12 +63,83 @@ def _env_flag(name: str, default: bool) -> bool: return raw.strip().lower() in {"1", "true", "yes", "on"} +def _env_int(name: str, default: int) -> int: + raw = os.getenv(name) + if raw is None: + return default + try: + return int(raw) + except ValueError: + return default + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + if raw is None: + return default + try: + return float(raw) + except ValueError: + return default + + +def _trim_trailing_slash(value: str | None) -> str | None: + if value is None: + return None + return value.rstrip("/") + + def _parse_capabilities(raw: str | None) -> frozenset[str]: if not raw: return frozenset() return frozenset(part.strip() for part in raw.split(",") if part.strip()) +def _parse_csv(raw: str | None) -> tuple[str, ...]: + if not raw: + return () + return tuple(part.strip().lower() for part in raw.split(",") if part.strip()) + + +def _json_headers() -> dict[str, str]: + return { + "accept": "application/json", + "content-type": "application/json", + } + + +def _read_json_response(response) -> Any: + raw = response.read() + if not raw: + return {} + return json.loads(raw.decode("utf-8")) + + +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: + return _read_json_response(response) + + +def _post_json(url: str, payload: dict[str, Any], *, timeout_seconds: float) -> Any: + encoded = json.dumps(payload).encode("utf-8") + request = urllib.request.Request(url, data=encoded, headers=_json_headers(), method="POST") + with urllib.request.urlopen(request, timeout=timeout_seconds) as response: + return _read_json_response(response) + + +@dataclass(frozen=True, slots=True) +class AccessProvider: + slug: str + label: str + + def to_public_json(self) -> dict[str, str]: + return { + "slug": self.slug, + "label": self.label, + } + + @dataclass(frozen=True, slots=True) class AccessSubject: account_id: str | None = None @@ -88,13 +184,38 @@ class AccessRuntimeConfig: protect_queue: bool login_url: str | None logout_url: str | None + session_cookie_name: str + session_cookie_secure: bool + session_cookie_samesite: str + session_cookie_domain: str | None + providers: tuple[AccessProvider, ...] = () + heimdall_base_url: str | None = None + heimdall_issuer: str | None = None + heimdall_jwks_url: str | None = None + app_public_base_url: str | None = None + start_endpoint: str | None = None + auth_attempt_ttl_seconds: int = 600 + http_timeout_seconds: float = 10.0 + jwks_cache_seconds: int = 300 @property def enabled(self) -> bool: return self.mode != "off" + @property + def callback_url(self) -> str | None: + if self.app_public_base_url is None: + return None + return f"{self.app_public_base_url}{_AUTH_CALLBACK_PATH}" + + @property + def default_return_to(self) -> str | None: + if self.app_public_base_url is None: + return None + return f"{self.app_public_base_url}/app/" + def to_public_json(self) -> dict[str, Any]: - return { + payload: dict[str, Any] = { "enabled": self.enabled, "mode": self.mode, "required": self.required, @@ -103,6 +224,73 @@ class AccessRuntimeConfig: "loginUrl": self.login_url, "logoutUrl": self.logout_url, } + if self.mode == "heimdall": + payload.update( + { + "providers": [provider.to_public_json() for provider in self.providers], + "startEndpoint": self.start_endpoint, + "attemptEndpointTemplate": _AUTH_ATTEMPT_PATH_TEMPLATE, + } + ) + return payload + + +@dataclass(slots=True) +class AuthAttempt: + attempt_id: str + provider: str + return_to: str + created_at: float + expires_at: float + status: str = "pending" + authorization_url: str | None = None + state_expires_at: str | None = None + error: str | None = None + error_description: str | None = None + access_token: str | None = None + subject: AccessSubject | None = None + + def sync_status(self, *, now: float) -> None: + if self.status not in {"pending", "succeeded"}: + return + if now >= self.expires_at: + self.status = "expired" + self.error = "auth_attempt_expired" + self.error_description = "This sign-in attempt expired before the local session was adopted." + self.access_token = None + self.subject = None + + def to_public_json(self, *, now: float) -> dict[str, Any]: + self.sync_status(now=now) + payload: dict[str, Any] = { + "attemptId": self.attempt_id, + "provider": self.provider, + "status": self.status, + } + if self.status == "pending": + payload["returnTo"] = self.return_to + if self.status == "succeeded" and self.subject is not None: + payload["subject"] = self.subject.to_public_json() + payload["returnTo"] = self.return_to + if self.status in {"failed", "expired"}: + payload["error"] = self.error + payload["errorDescription"] = self.error_description + return payload + + +@dataclass(frozen=True, slots=True) +class AdoptedAuthSession: + access_token: str + subject: AccessSubject + return_to: str + expires_at: float + + def to_public_json(self) -> dict[str, Any]: + return { + "status": "authenticated", + "returnTo": self.return_to, + "subject": self.subject.to_public_json(), + } class AccessDenied(RuntimeError): @@ -112,6 +300,136 @@ class AccessDenied(RuntimeError): self.detail = detail +class AccessOperationError(RuntimeError): + def __init__(self, status_code: int, detail: str) -> None: + super().__init__(detail) + self.status_code = int(status_code) + self.detail = detail + + +class HeimdallVerifier: + def __init__( + self, + *, + issuer: str, + app_slug: str, + jwks_url: str, + timeout_seconds: float, + cache_ttl_seconds: int, + ) -> None: + self.issuer = issuer + self.app_slug = app_slug + self.jwks_url = jwks_url + self.timeout_seconds = timeout_seconds + self.cache_ttl_seconds = max(1, cache_ttl_seconds) + self._lock = threading.Lock() + self._cached_keys: dict[str, Any] = {} + self._cache_expires_at = 0.0 + + def verify(self, token: str, *, now: float | None = None) -> dict[str, Any]: + if not token: + raise AccessOperationError(400, "Heimdall callback did not include an access token.") + issued_at = time.time() if now is None else now + + try: + header = jwt.get_unverified_header(token) + except jwt.InvalidTokenError as exc: + raise AccessOperationError(400, "Heimdall callback carried a malformed access token.") from exc + + key_id = header.get("kid") + algorithm = header.get("alg") + if algorithm != "EdDSA" or not isinstance(key_id, str) or not key_id.strip(): + raise AccessOperationError(400, "Heimdall access token used an unsupported signing header.") + + key = self._get_key(key_id, now=issued_at) + try: + claims = jwt.decode( + token, + key=key, + algorithms=["EdDSA"], + audience=self.app_slug, + issuer=self.issuer, + options={ + "require": [ + "iss", + "aud", + "sub", + "sid", + "iat", + "nbf", + "exp", + "typ", + "account_id", + "access_revision", + "app", + "facts", + "capabilities", + "identities", + ], + }, + leeway=_CLOCK_SKEW_SECONDS, + ) + except jwt.InvalidTokenError as exc: + raise AccessOperationError(400, f"Heimdall access token failed local verification: {exc}") from exc + + if claims.get("typ") != "heimdall_access": + raise AccessOperationError(400, "Heimdall access token is not an access claim.") + app_claim = claims.get("app") + if not isinstance(app_claim, dict): + raise AccessOperationError(400, "Heimdall access token did not include a valid app claim.") + if app_claim.get("slug") != self.app_slug: + raise AccessOperationError(400, "Heimdall access token targeted a different app.") + if not isinstance(app_claim.get("profile_version"), str): + raise AccessOperationError(400, "Heimdall access token is missing the app profile version.") + for field_name in ("facts", "capabilities", "identities"): + if not isinstance(claims.get(field_name), list): + raise AccessOperationError(400, f"Heimdall access token field '{field_name}' is malformed.") + if claims.get("display_name") is not None and not isinstance(claims.get("display_name"), str): + raise AccessOperationError(400, "Heimdall access token has a malformed display name.") + if not isinstance(claims.get("access_revision"), int): + raise AccessOperationError(400, "Heimdall access token has a malformed access revision.") + return claims + + def _get_key(self, key_id: str, *, now: float) -> Any: + with self._lock: + if not self._cached_keys or now >= self._cache_expires_at: + self._refresh_keys(now=now) + key = self._cached_keys.get(key_id) + if key is not None: + return key + self._refresh_keys(now=now) + key = self._cached_keys.get(key_id) + if key is None: + raise AccessOperationError(400, f"Heimdall access token used unknown signing key '{key_id}'.") + return key + + def _refresh_keys(self, *, now: float) -> None: + try: + payload = _fetch_json(self.jwks_url, timeout_seconds=self.timeout_seconds) + except urllib.error.HTTPError as exc: + raise AccessOperationError(502, f"Failed to fetch Heimdall JWKS: HTTP {exc.code}.") from exc + except OSError as exc: + raise AccessOperationError(502, f"Failed to fetch Heimdall JWKS: {exc}.") from exc + if not isinstance(payload, dict) or not isinstance(payload.get("keys"), list): + raise AccessOperationError(502, "Heimdall JWKS response was malformed.") + + key_map: dict[str, Any] = {} + for entry in payload["keys"]: + if not isinstance(entry, dict): + continue + key_id = entry.get("kid") + if not isinstance(key_id, str) or not key_id.strip(): + continue + try: + key_map[key_id] = jwt.PyJWK.from_dict(entry).key + except jwt.InvalidKeyError: + continue + if not key_map: + raise AccessOperationError(502, "Heimdall JWKS did not expose any usable signing keys.") + self._cached_keys = key_map + self._cache_expires_at = now + float(self.cache_ttl_seconds) + + @contextlib.contextmanager def bind_access_subject(subject: AccessSubject) -> Iterator[None]: token = _CURRENT_ACCESS_SUBJECT.set(subject) @@ -122,24 +440,94 @@ def bind_access_subject(subject: AccessSubject) -> Iterator[None]: class AccessController: - def __init__(self, runtime: AccessRuntimeConfig) -> None: + def __init__(self, runtime: AccessRuntimeConfig, verifier: HeimdallVerifier | None = None) -> None: self.runtime = runtime + self._verifier = verifier + self._attempts: dict[str, AuthAttempt] = {} + self._attempt_lock = threading.Lock() @classmethod def from_env(cls, *, hosted_demo: bool) -> "AccessController": mode = (_env_text("GC_ACCESS_MODE") or "off").strip().lower() - if mode not in {"off", "trusted-header"}: + if mode not in {"off", "trusted-header", "heimdall"}: mode = "off" required_default = hosted_demo if mode != "off" else False + app_slug = _env_text("GC_ACCESS_APP_SLUG") or "repixelizer" + + providers: tuple[AccessProvider, ...] = () + heimdall_base_url = None + heimdall_issuer = None + heimdall_jwks_url = None + app_public_base_url = None + start_endpoint = None + login_url = _env_text("GC_ACCESS_LOGIN_URL") + logout_url = _env_text("GC_ACCESS_LOGOUT_URL") + verifier: HeimdallVerifier | None = None + timeout_seconds = _env_float("GC_ACCESS_HTTP_TIMEOUT_SECONDS", 10.0) + 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" + 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" + session_cookie_domain = _env_text("GC_ACCESS_SESSION_COOKIE_DOMAIN") + session_cookie_secure = _env_flag("GC_ACCESS_SESSION_COOKIE_SECURE", True) + + if mode == "heimdall": + heimdall_base_url = _trim_trailing_slash(_env_text("GC_ACCESS_HEIMDALL_BASE_URL")) + if heimdall_base_url is None: + raise RuntimeError("GC_ACCESS_HEIMDALL_BASE_URL is required when GC_ACCESS_MODE=heimdall.") + heimdall_issuer = _trim_trailing_slash(_env_text("GC_ACCESS_HEIMDALL_ISSUER")) or heimdall_base_url + heimdall_jwks_url = _env_text("GC_ACCESS_HEIMDALL_JWKS_URL") or f"{heimdall_base_url}/.well-known/jwks.json" + app_public_base_url = _trim_trailing_slash(_env_text("GC_ACCESS_APP_PUBLIC_BASE_URL")) + if app_public_base_url is None: + raise RuntimeError("GC_ACCESS_APP_PUBLIC_BASE_URL is required when GC_ACCESS_MODE=heimdall.") + session_cookie_secure = _env_flag( + "GC_ACCESS_SESSION_COOKIE_SECURE", + app_public_base_url.startswith("https://"), + ) + configured_providers = _parse_csv(_env_text("GC_ACCESS_ALLOWED_PROVIDERS")) + if not configured_providers and app_slug == "repixelizer": + configured_providers = _DEFAULT_REPIXELIZER_PROVIDERS + providers = tuple( + AccessProvider(slug=provider, label=_HEIMDALL_PROVIDER_LABELS.get(provider, provider.title())) + for provider in configured_providers + ) + if not providers: + raise RuntimeError("GC_ACCESS_ALLOWED_PROVIDERS must name at least one provider in Heimdall mode.") + login_url = login_url or "/" + logout_url = logout_url or _AUTH_LOGOUT_PATH + start_endpoint = _AUTH_START_PATH + verifier = HeimdallVerifier( + issuer=heimdall_issuer, + app_slug=app_slug, + jwks_url=heimdall_jwks_url, + timeout_seconds=timeout_seconds, + cache_ttl_seconds=jwks_cache_seconds, + ) + runtime = AccessRuntimeConfig( mode=mode, - app_slug=_env_text("GC_ACCESS_APP_SLUG") or "repixelizer", + app_slug=app_slug, required=_env_flag("GC_ACCESS_REQUIRED", required_default), protect_queue=_env_flag("GC_ACCESS_PROTECT_QUEUE", False), - login_url=_env_text("GC_ACCESS_LOGIN_URL"), - logout_url=_env_text("GC_ACCESS_LOGOUT_URL"), + login_url=login_url, + logout_url=logout_url, + session_cookie_name=session_cookie_name, + session_cookie_secure=session_cookie_secure, + session_cookie_samesite=session_cookie_samesite, + session_cookie_domain=session_cookie_domain, + providers=providers, + heimdall_base_url=heimdall_base_url, + heimdall_issuer=heimdall_issuer, + heimdall_jwks_url=heimdall_jwks_url, + app_public_base_url=app_public_base_url, + start_endpoint=start_endpoint, + auth_attempt_ttl_seconds=attempt_ttl_seconds, + http_timeout_seconds=timeout_seconds, + jwks_cache_seconds=jwks_cache_seconds, ) - return cls(runtime) + return cls(runtime, verifier=verifier) def public_payload(self) -> dict[str, Any]: return self.runtime.to_public_json() @@ -186,6 +574,219 @@ class AccessController: def app_gate_redirect_url(self) -> str: return self.runtime.login_url or "/" + def start_auth_attempt(self, provider: str) -> dict[str, Any]: + self._require_heimdall_mode() + provider_slug = provider.strip().lower() + allowed_providers = {entry.slug for entry in self.runtime.providers} + if provider_slug not in allowed_providers: + raise AccessOperationError(400, f"Provider '{provider_slug}' is not enabled for this hosted demo.") + + attempt_id = uuid.uuid4().hex + now = time.time() + return_to = self.runtime.default_return_to + callback_url = self.runtime.callback_url + heimdall_base_url = self.runtime.heimdall_base_url + if return_to is None or callback_url is None or heimdall_base_url is None: + raise AccessOperationError(503, "Hosted Heimdall auth is misconfigured on this Repixelizer node.") + + attempt = AuthAttempt( + attempt_id=attempt_id, + provider=provider_slug, + return_to=return_to, + created_at=now, + expires_at=now + float(self.runtime.auth_attempt_ttl_seconds), + ) + with self._attempt_lock: + self._prune_attempts_locked(now=now) + self._attempts[attempt_id] = attempt + + request_payload = { + "appSlug": self.runtime.app_slug, + "mode": "sign_in", + "returnTo": return_to, + "handoff": { + "kind": "backend_callback", + "attemptId": attempt_id, + "callbackUrl": callback_url, + }, + } + start_url = urljoin(f"{heimdall_base_url}/", _AUTH_START_ENDPOINT_TEMPLATE.format(provider=provider_slug).lstrip("/")) + try: + response_payload = _post_json( + start_url, + request_payload, + timeout_seconds=self.runtime.http_timeout_seconds, + ) + except urllib.error.HTTPError as exc: + self._forget_attempt(attempt_id) + detail = self._http_error_detail(exc) + raise AccessOperationError(exc.code, detail) from exc + except OSError as exc: + self._forget_attempt(attempt_id) + raise AccessOperationError(502, f"Failed to contact Heimdall: {exc}.") from exc + + if not isinstance(response_payload, dict): + self._forget_attempt(attempt_id) + raise AccessOperationError(502, "Heimdall start response was malformed.") + authorization_url = response_payload.get("authorizationUrl") + if not isinstance(authorization_url, str) or not authorization_url.strip(): + self._forget_attempt(attempt_id) + raise AccessOperationError(502, "Heimdall start response did not include an authorization URL.") + + with self._attempt_lock: + stored = self._attempts.get(attempt_id) + if stored is not None: + stored.authorization_url = authorization_url + state_expires_at = response_payload.get("stateExpiresAt") + if isinstance(state_expires_at, str): + stored.state_expires_at = state_expires_at + + return { + "attemptId": attempt_id, + "provider": provider_slug, + "authorizationUrl": authorization_url, + "returnTo": return_to, + "statusEndpoint": _AUTH_ATTEMPT_PATH_TEMPLATE.format(attemptId=attempt_id), + } + + def get_auth_attempt_status(self, attempt_id: str) -> dict[str, Any]: + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + return attempt.to_public_json(now=time.time()) + + def adopt_auth_attempt(self, attempt_id: str) -> AdoptedAuthSession: + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + attempt.sync_status(now=time.time()) + if attempt.status == "pending": + raise AccessOperationError(409, "That sign-in attempt has not finished yet.") + if attempt.status in {"failed", "expired"}: + raise AccessOperationError(409, attempt.error_description or "That sign-in attempt did not succeed.") + if attempt.access_token is None or attempt.subject is None: + raise AccessOperationError(500, "The sign-in attempt finished without an adoptable session.") + claims = attempt.subject.claims + expires_at = float(claims.get("exp", int(time.time()))) + return AdoptedAuthSession( + access_token=attempt.access_token, + subject=attempt.subject, + return_to=attempt.return_to, + expires_at=expires_at, + ) + + def receive_backend_handoff(self, payload: Any) -> None: + self._require_heimdall_mode() + if not isinstance(payload, dict): + raise AccessOperationError(400, "Heimdall callback payload was not valid JSON.") + + attempt_id = payload.get("attemptId") + provider = payload.get("provider") + app_slug = payload.get("appSlug") + status = payload.get("status") + if payload.get("source") != "heimdall" or payload.get("kind") != "oauth_result": + raise AccessOperationError(400, "Callback payload was not identified as a Heimdall oauth result.") + if payload.get("handoffKind") != "backend_callback": + raise AccessOperationError(400, "Callback payload used the wrong handoff kind.") + if not isinstance(attempt_id, str) or not attempt_id.strip(): + raise AccessOperationError(400, "Heimdall callback did not include an attempt id.") + if not isinstance(provider, str) or not provider.strip(): + raise AccessOperationError(400, "Heimdall callback did not include a provider slug.") + if app_slug != self.runtime.app_slug: + raise AccessOperationError(400, "Heimdall callback targeted the wrong app slug.") + if status not in {"success", "error"}: + raise AccessOperationError(400, "Heimdall callback used an unknown status.") + + with self._attempt_lock: + self._prune_attempts_locked(now=time.time()) + attempt = self._attempts.get(attempt_id) + if attempt is None: + raise AccessOperationError(404, "Unknown auth attempt.") + if attempt.provider != provider: + raise AccessOperationError(409, "Heimdall callback provider did not match the waiting auth attempt.") + + if status == "error": + error_code = payload.get("error") + error_description = payload.get("errorDescription") + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "failed" + attempt.error = error_code if isinstance(error_code, str) else "oauth_callback_failed" + attempt.error_description = ( + error_description + if isinstance(error_description, str) and error_description.strip() + else "Heimdall reported an upstream auth failure." + ) + attempt.access_token = None + attempt.subject = None + return + + access_token = payload.get("accessToken") + if not isinstance(access_token, str) or not access_token.strip(): + raise AccessOperationError(400, "Heimdall callback succeeded without an access token.") + claims = self._verify_access_token(access_token) + subject = self._subject_from_claims(claims) + if "app_access" not in subject.capabilities: + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "failed" + attempt.error = "access_denied" + attempt.error_description = ( + "Heimdall authenticated the account, but it did not grant Repixelizer app access." + ) + attempt.access_token = None + attempt.subject = None + return + + session_payload = payload.get("session") + if not isinstance(session_payload, dict): + raise AccessOperationError(400, "Heimdall callback did not include session metadata.") + session_id = session_payload.get("sessionId") + account_id = session_payload.get("accountId") + access_revision = session_payload.get("accessRevision") + if session_id != subject.session_id or account_id != subject.account_id or access_revision != subject.access_revision: + raise AccessOperationError(400, "Heimdall callback session metadata did not match the access token.") + + expires_at = float(claims["exp"]) + with self._attempt_lock: + attempt = self._attempts.get(attempt_id) + if attempt is not None: + attempt.status = "succeeded" + attempt.error = None + attempt.error_description = None + attempt.access_token = access_token + attempt.subject = subject + attempt.expires_at = expires_at + + def attach_session_cookie(self, response: Any, session: AdoptedAuthSession) -> None: + max_age = max(0, int(session.expires_at - time.time())) + response.set_cookie( + key=self.runtime.session_cookie_name, + value=session.access_token, + max_age=max_age, + 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( + key=self.runtime.session_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": return @@ -202,6 +803,8 @@ class AccessController: ) if self.runtime.mode == "trusted-header": return self._trusted_header_subject(request, allow_anonymous=allow_anonymous) + if self.runtime.mode == "heimdall": + return self._heimdall_subject(request, allow_anonymous=allow_anonymous) return AccessSubject(auth_mode=self.runtime.mode) def _trusted_header_subject(self, request: Any, *, allow_anonymous: bool) -> AccessSubject: @@ -229,3 +832,61 @@ class AccessController: display_name=display_name, auth_mode=self.runtime.mode, ) + + 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.") + try: + claims = self._verify_access_token(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) + + def _subject_from_claims(self, claims: dict[str, Any]) -> AccessSubject: + return AccessSubject( + account_id=str(claims["account_id"]), + session_id=str(claims["sid"]), + access_revision=int(claims["access_revision"]), + capabilities=frozenset(str(entry) for entry in claims.get("capabilities", [])), + display_name=str(claims["display_name"]) if claims.get("display_name") is not None else None, + auth_mode="heimdall", + claims=claims, + ) + + def _verify_access_token(self, token: str) -> dict[str, Any]: + if self._verifier is None: + raise AccessOperationError(503, "Heimdall verification is not configured on this node.") + return self._verifier.verify(token) + + def _require_heimdall_mode(self) -> None: + if self.runtime.mode != "heimdall": + raise AccessOperationError(404, "Heimdall auth is not enabled in this runtime mode.") + + def _forget_attempt(self, attempt_id: str) -> None: + with self._attempt_lock: + self._attempts.pop(attempt_id, None) + + def _prune_attempts_locked(self, *, now: float) -> None: + stale_ids = [] + for attempt_id, attempt in self._attempts.items(): + attempt.sync_status(now=now) + if attempt.status == "expired" and now - attempt.expires_at > 60.0: + stale_ids.append(attempt_id) + for attempt_id in stale_ids: + self._attempts.pop(attempt_id, None) + + def _http_error_detail(self, exc: urllib.error.HTTPError) -> str: + try: + payload = _read_json_response(exc) + except Exception: + payload = None + if isinstance(payload, dict): + detail = payload.get("detail") or payload.get("errorDescription") or payload.get("message") + if isinstance(detail, str) and detail.strip(): + return detail + return f"Heimdall rejected the auth start request with HTTP {exc.code}." diff --git a/src/repixelizer/gui.py b/src/repixelizer/gui.py index 3319c06..790e03d 100644 --- a/src/repixelizer/gui.py +++ b/src/repixelizer/gui.py @@ -16,7 +16,7 @@ from typing import Any import numpy as np from PIL import Image, ImageDraw -from .access import AccessController, AccessDenied, bind_access_subject +from .access import AccessController, AccessDenied, AccessOperationError, bind_access_subject from .diagnostics import _displacement_preview_rgba from .inference import inference_to_json from .observe import PipelineCancelled @@ -26,7 +26,7 @@ from .types import InferenceResult, PaletteResult def _require_gui_dependencies(): try: - from fastapi import FastAPI, File, Form, HTTPException, UploadFile + from fastapi import FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, StreamingResponse from fastapi.staticfiles import StaticFiles except ImportError as exc: # pragma: no cover - exercised only when GUI deps missing @@ -38,6 +38,8 @@ def _require_gui_dependencies(): "File": File, "Form": Form, "HTTPException": HTTPException, + "Request": Request, + "Response": Response, "UploadFile": UploadFile, "HTMLResponse": HTMLResponse, "JSONResponse": JSONResponse, @@ -877,27 +879,62 @@ def _versioned_gui_index(static_dir: Path) -> str: return html -def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = None) -> str: +def _json_html(value: Any) -> str: + return json.dumps(value, separators=(",", ":")).replace("<", "\\u003c") + + +def _landing_page_html( + config: HostedDemoConfig, + auth: dict[str, Any] | None = None, + subject: dict[str, Any] | None = None, +) -> str: demo_limits = ( f"{config.max_output_dimension}px output cap, " f"{config.default_steps} default solver steps, " f"{config.queue_capacity} waiting slots" ) - auth_enabled = bool(auth and auth.get("enabled")) - login_url = None if auth is None else auth.get("loginUrl") - primary_href = login_url or "/app/" - primary_label = "Sign in to the demo" if auth_enabled else "Open the demo" + auth_payload = auth or {} + subject_payload = subject or { + "authenticated": False, + "accountId": None, + "sessionId": None, + "accessRevision": None, + "displayName": None, + "capabilities": [], + "authMode": "off", + } + auth_enabled = bool(auth_payload.get("enabled")) + authenticated = bool(subject_payload.get("authenticated")) + primary_href = "/app/" if authenticated or not auth_enabled else "#auth" + primary_label = "Continue to the demo" if authenticated else ("Sign in to the demo" if auth_enabled else "Open the demo") + providers = auth_payload.get("providers") if isinstance(auth_payload.get("providers"), list) else [] + provider_button_html = "" + if auth_enabled and not authenticated: + fragments = [] + for provider in providers: + if not isinstance(provider, dict): + continue + slug = provider.get("slug") + label = provider.get("label") + if not isinstance(slug, str) or not isinstance(label, str): + continue + fragments.append( + f'' + ) + provider_button_html = "".join(fragments) hero_note = ( - "Hosted access will be gated through Heimdall once the auth pass lands." + "Heimdall handles the provider dance, Repixelizer keeps the local verdict, and the browser mostly gets to stop couriering secrets." if auth_enabled else "It is not a miracle worker, and it is definitely not here to pretend it invented hand-authored pixel art from nothing." ) auth_notice = "" - if auth_enabled and not login_url: + if auth_enabled and not auth_payload.get("providers"): auth_notice = ( - '
' + '" ) + elif auth_enabled: + auth_notice = '' return f""" @@ -1022,11 +1059,23 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N color: #1b1202; box-shadow: inset 0 -2px 0 rgba(0,0,0,0.24); }} + button.button {{ + cursor: pointer; + }} + .button[disabled] {{ + opacity: 0.55; + cursor: wait; + }} .button.secondary {{ background: linear-gradient(180deg, rgba(15, 40, 64, 0.96), rgba(7, 19, 32, 0.98)); color: var(--text-bright); border-color: var(--border); }} + .button.ghost {{ + background: transparent; + color: var(--text-muted); + border-color: rgba(255, 216, 74, 0.2); + }} .stats, .grid {{ display: grid; gap: 14px; @@ -1093,6 +1142,13 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N margin-top: 18px; color: var(--text-muted); }} + .status-line {{ + margin-top: 16px; + min-height: 1.2em; + }} + .auth-provider-row {{ + margin-top: 14px; + }} @media (max-width: 900px) {{ .hero-grid, .grid, .stats {{ grid-template-columns: 1fr; @@ -1121,10 +1177,15 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = NRepixelizer takes AI-slop pixel cosplay, infers the lattice it should have had, then drags it back onto an actual coherent mosaic.
-