Land Heimdall auth flow in hosted web layer

This commit is contained in:
Meta Vens 2026-04-27 00:35:24 +01:00
commit 3fa3be8dc9
8 changed files with 1240 additions and 37 deletions

View file

@ -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}."

View file

@ -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'<button class="button secondary" type="button" data-provider="{slug}">Login with {label}</button>'
)
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 = (
'<p class="footer">Auth is enabled here, but no Heimdall login URL has been wired yet. '
'That means the front door is ready and the key is still missing.</p>'
'<p class="footer" id="authNotice">Auth is enabled here, but no providers are configured yet. '
"The front door exists, but somebody still owes it an actual key.</p>"
)
elif auth_enabled:
auth_notice = '<p class="footer" id="authNotice">Choose a provider. The main page stays put while Heimdall and the backend sort out the paperwork in another tab.</p>'
return f"""<!doctype html>
<html lang="en">
<head>
@ -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 = N
<p class="lede">
Repixelizer takes AI-slop pixel cosplay, infers the lattice it should have had, then drags it back onto an actual coherent mosaic.
</p>
<div class="cta-row">
<a class="button" href="{primary_href}">{primary_label}</a>
<div class="cta-row" id="primaryActions">
<a class="button" href="{primary_href}" id="primaryAction">{primary_label}</a>
<button class="button secondary" id="authSignOut" type="button" {"hidden" if not authenticated else ""}>Sign out</button>
<a class="button secondary" href="https://github.com/GameCult/repixelizer">View the repo</a>
</div>
<div class="cta-row auth-provider-row" id="authProviders">{provider_button_html}</div>
<p class="status-line footer" id="authStateLine">
{"Already authenticated as " + str(subject_payload.get("displayName") or subject_payload.get("accountId") or "a local creature") + "." if authenticated else ""}
</p>
<div class="stats">
<div class="stat">
<strong>One engine</strong>
@ -1167,6 +1228,144 @@ def _landing_page_html(config: HostedDemoConfig, auth: dict[str, Any] | None = N
{auth_notice}
</section>
</main>
<script>
const authConfig = {_json_html(auth_payload)};
const sessionSubject = {_json_html(subject_payload)};
const providerRow = document.getElementById("authProviders");
const primaryAction = document.getElementById("primaryAction");
const authStateLine = document.getElementById("authStateLine");
const authNotice = document.getElementById("authNotice");
const signOutButton = document.getElementById("authSignOut");
const defaultPrimaryLabel = {json.dumps(primary_label)};
let activeAttemptId = null;
function setNotice(text) {{
if (authNotice) {{
authNotice.textContent = text;
}}
if (authStateLine) {{
authStateLine.textContent = text;
}}
}}
function setBusy(providerSlug, busy) {{
if (!providerRow) {{
return;
}}
for (const node of providerRow.querySelectorAll("button[data-provider]")) {{
node.disabled = busy;
}}
if (primaryAction && primaryAction instanceof HTMLElement) {{
primaryAction.textContent = busy ? `Waiting on ${{providerSlug}}...` : defaultPrimaryLabel;
}}
}}
async function pollAttempt(attemptId, providerLabel) {{
for (;;) {{
const response = await fetch(`/api/auth/attempts/${{attemptId}}`, {{
method: "GET",
cache: "no-store",
credentials: "same-origin",
}});
const payload = await response.json().catch(() => ({{ status: "failed", errorDescription: "Auth polling failed." }}));
if (!response.ok) {{
setNotice(payload.errorDescription || payload.detail || "Auth polling failed.");
setBusy(providerLabel, false);
return;
}}
if (payload.status === "pending") {{
await new Promise((resolve) => window.setTimeout(resolve, 900));
continue;
}}
if (payload.status === "succeeded") {{
const adopt = await fetch(`/api/auth/attempts/${{attemptId}}/adopt`, {{
method: "POST",
credentials: "same-origin",
}});
const adopted = await adopt.json().catch(() => ({{ status: "failed", detail: "Session adoption failed." }}));
if (!adopt.ok) {{
setNotice(adopted.detail || "Session adoption failed.");
setBusy(providerLabel, false);
return;
}}
window.location.href = adopted.returnTo || "/app/";
return;
}}
setNotice(payload.errorDescription || "Sign-in failed.");
setBusy(providerLabel, false);
return;
}}
}}
async function startAuth(provider) {{
if (!authConfig.enabled || !authConfig.startEndpoint) {{
return;
}}
setBusy(provider.label, true);
setNotice(`Opening ${{provider.label}}. If the browser throws a fit, that is still somehow considered normal web behavior.`);
try {{
const response = await fetch(authConfig.startEndpoint, {{
method: "POST",
headers: {{
"content-type": "application/json",
}},
credentials: "same-origin",
body: JSON.stringify({{ provider: provider.slug }}),
}});
const payload = await response.json().catch(() => ({{ detail: "Auth start failed." }}));
if (!response.ok) {{
setNotice(payload.detail || "Auth start failed.");
setBusy(provider.label, false);
return;
}}
activeAttemptId = payload.attemptId;
const opened = window.open(payload.authorizationUrl, "_blank");
if (!opened) {{
setNotice(`Popup blocked. Opening ${{provider.label}} in this tab because the browser demanded drama.`);
window.location.href = payload.authorizationUrl;
return;
}}
void pollAttempt(payload.attemptId, provider.label);
}} catch (error) {{
setNotice(error instanceof Error ? error.message : "Auth start failed.");
setBusy(provider.label, false);
}}
}}
if (authConfig.enabled && Array.isArray(authConfig.providers) && providerRow && !sessionSubject.authenticated) {{
for (const provider of authConfig.providers) {{
const button = providerRow.querySelector(`button[data-provider="${{provider.slug}}"]`);
if (button instanceof HTMLButtonElement) {{
button.addEventListener("click", () => {{
void startAuth(provider);
}});
}}
}}
}}
if (primaryAction && authConfig.enabled && !sessionSubject.authenticated) {{
primaryAction.addEventListener("click", (event) => {{
event.preventDefault();
const firstProvider = Array.isArray(authConfig.providers) ? authConfig.providers[0] : null;
if (firstProvider) {{
void startAuth(firstProvider);
}}
}});
}}
if (signOutButton) {{
signOutButton.addEventListener("click", async () => {{
try {{
await fetch(authConfig.logoutUrl || "/api/auth/logout", {{
method: "POST",
credentials: "same-origin",
}});
}} finally {{
window.location.href = "/";
}}
}});
}}
</script>
</body>
</html>
"""
@ -1178,6 +1377,8 @@ def create_app():
File = deps["File"]
Form = deps["Form"]
HTTPException = deps["HTTPException"]
Request = deps["Request"]
Response = deps["Response"]
UploadFile = deps["UploadFile"]
HTMLResponse = deps["HTMLResponse"]
JSONResponse = deps["JSONResponse"]
@ -1229,7 +1430,7 @@ def create_app():
return subject
try:
if path in {"/", "/api/health", "/api/config", "/api/auth/session"}:
if path in {"/", "/api/health", "/api/config", "/api/auth/session"} or path.startswith("/api/auth/"):
subject = access_controller.peek_request_subject(request)
elif path == "/api/queue":
if access_controller.runtime.protect_queue:
@ -1275,6 +1476,57 @@ def create_app():
}
)
@app.post("/api/auth/heimdall/start")
async def start_heimdall_auth(request: Request):
try:
payload = await request.json()
except Exception:
payload = {}
provider = payload.get("provider") if isinstance(payload, dict) else None
if not isinstance(provider, str) or not provider.strip():
return JSONResponse({"detail": "Auth start requires a provider slug."}, status_code=400)
try:
started = access_controller.start_auth_attempt(provider)
except AccessOperationError as exc:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
return JSONResponse(started, status_code=201)
@app.get("/api/auth/attempts/{attempt_id}")
def get_auth_attempt(attempt_id: str):
try:
payload = access_controller.get_auth_attempt_status(attempt_id)
except AccessOperationError as exc:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
return JSONResponse(payload)
@app.post("/api/auth/attempts/{attempt_id}/adopt")
def adopt_auth_attempt(attempt_id: str):
try:
adopted = access_controller.adopt_auth_attempt(attempt_id)
except AccessOperationError as exc:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
response = JSONResponse(adopted.to_public_json())
access_controller.attach_session_cookie(response, adopted)
return response
@app.post("/api/auth/logout")
def logout_auth():
response = JSONResponse({"status": "signed_out"})
access_controller.clear_session_cookie(response)
return response
@app.post("/api/auth/heimdall/callback")
async def heimdall_callback(request: Request):
try:
payload = await request.json()
except Exception:
return JSONResponse({"detail": "Heimdall callback body was not valid JSON."}, status_code=400)
try:
access_controller.receive_backend_handoff(payload)
except AccessOperationError as exc:
return JSONResponse({"detail": exc.detail}, status_code=exc.status_code)
return Response(status_code=204)
@app.get("/api/queue")
def get_queue():
return JSONResponse(manager.get_queue_summary())
@ -1429,7 +1681,13 @@ def create_app():
@app.get("/")
def root():
if config.hosted_demo:
return HTMLResponse(_landing_page_html(config, access_controller.public_payload()))
return HTMLResponse(
_landing_page_html(
config,
access_controller.public_payload(),
access_controller.current_subject().to_public_json(),
)
)
return RedirectResponse(url="/app/")
else: # pragma: no cover - only relevant before frontend assets exist
@app.get("/")