Land Heimdall auth flow in hosted web layer
This commit is contained in:
parent
2de45c0f71
commit
3fa3be8dc9
8 changed files with 1240 additions and 37 deletions
|
|
@ -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}."
|
||||
|
|
|
|||
|
|
@ -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("/")
|
||||
|
|
|
|||
Loading…
Reference in a new issue