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