From 684ed49280994a822bbefe93050f7cdb0d6dd8d1 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Thu, 23 Jul 2026 15:56:18 +0545 Subject: [PATCH 1/3] feat(montandon-eoapi): enforce external-token revocation in stac-auth-proxy stac-auth-proxy validates go-api JWTs locally (signature + exp) only, so a token revoked before its natural expiry stays valid for up to a year. Add a revocation check via go-api's verify endpoint (go-api issue #2794). stac-auth-proxy v1.1.1 has no config hook for token validation, so inject a montandon_revocation.py module (mounted through the configmap) that wraps EnforceAuthMiddleware.validate_token at app-construction time: - Reject tokens with no jti or a jti go-api reports inactive (401). - Static jti blacklist (env) checked first as a permanent kill-switch. - Positive-only in-memory cache, 300s TTL. - Verify URL derived from OIDC_DISCOVERY_URL; pod fails to start if unset. - Fail policy configurable (deployed fail-open until the verify endpoint is live in prod goadmin, then flip to fail-closed). - Patch guard raises on upstream signature drift so revocation can never be silently disabled. Rename the filters configmap/volume to stac-auth-proxy-modules / custom-modules since it now carries the auth patch alongside the CQL2 filter. --- .../montandon-eoapi/application.yaml | 15 +- .../stac-auth-proxy/montandon_filters.py | 7 + .../stac-auth-proxy/montandon_revocation.py | 207 ++++++++++++++++++ .../{filters-cm.yaml => modules-cm.yaml} | 4 +- 4 files changed, 229 insertions(+), 4 deletions(-) create mode 100644 applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py rename applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/{filters-cm.yaml => modules-cm.yaml} (64%) diff --git a/applications/argocd/staging/applications/montandon-eoapi/application.yaml b/applications/argocd/staging/applications/montandon-eoapi/application.yaml index 6a07a20..b9d5813 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/application.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/application.yaml @@ -152,6 +152,11 @@ spec: ROOT_PATH: "/stac" COLLECTIONS_FILTER_CLS: stac_auth_proxy.montandon_filters:CollectionsFilter ITEMS_FILTER_CLS: stac_auth_proxy.montandon_filters:ItemsFilter + # External-token revocation (go-api issue #2794), injected via montandon_revocation.py. + GOAPI_TOKEN_VERIFY_TTL: "300" + GOAPI_TOKEN_VERIFY_TIMEOUT: "3" + GOAPI_TOKEN_VERIFY_FAIL_OPEN: "true" + GOAPI_TOKEN_STATIC_BLACKLIST: "fec23689-bf00-401e-9f9c-a65684f44b5f" # NOTE: Token ID used in hackathon ingress: enabled: true host: "montandon-eoapi-stage.ifrc.org" @@ -172,14 +177,18 @@ spec: cpu: 200m memory: "1024Mi" extraVolumes: - - name: filters + - name: custom-modules configMap: - name: stac-auth-proxy-filters + name: stac-auth-proxy-modules extraVolumeMounts: - - name: filters + - name: custom-modules mountPath: /app/src/stac_auth_proxy/montandon_filters.py subPath: montandon_filters.py readOnly: true + - name: custom-modules + mountPath: /app/src/stac_auth_proxy/montandon_revocation.py + subPath: montandon_revocation.py + readOnly: true destination: server: https://kubernetes.default.svc namespace: montandon-eoapi diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py index 46000e2..bb06d16 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_filters.py @@ -17,6 +17,13 @@ import httpx +# Apply the external-token revocation monkeypatch to EnforceAuthMiddleware. stac-auth-proxy +# imports this filter module during app construction (before serving requests), which is the +# only Helm-reachable hook to run the patch. Keep this call. +from .montandon_revocation import apply_patch + +apply_patch() + logger = logging.getLogger(__name__) if not (UPSTREAM_URL := os.environ.get("UPSTREAM_URL")): diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py new file mode 100644 index 0000000..62fd74b --- /dev/null +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py @@ -0,0 +1,207 @@ +""" +External-token revocation for stac-auth-proxy. + +stac-auth-proxy validates go-api JWTs locally (signature + exp via the go-api JWKS) but +cannot detect a token that go-api has *revoked* before its natural expiry. go-api tracks +revocation centrally and exposes a lightweight introspection endpoint keyed by the token's +`jti`. This module adds that check without forking stac-auth-proxy. + +There is no config hook for token validation in stac-auth-proxy v1.1.1, so we monkeypatch +`EnforceAuthMiddleware.validate_token` at import time. This module is imported by +`montandon_filters.py`, which stac-auth-proxy imports at app-construction time (before it +serves any request) when loading the COLLECTIONS_FILTER_CLS / ITEMS_FILTER_CLS classes. + +Design notes / decisions (see go-api issue #2794): +- Only go-api *external tokens* reach this proxy; every external token carries a `jti`. + A token with no `jti`, or a `jti` go-api does not recognise, is rejected (401). +- A static blacklist (env) is checked first and always wins — a permanent kill-switch that + works even before/without the go-api verify endpoint. +- Positive results only are cached, short TTL, so a revoked token is never served stale. +- Fail policy on go-api outage is configurable; default is fail-closed. It is deployed + fail-open initially (until the verify endpoint is live in prod goadmin) then flipped. +- If the patch cannot be applied (upstream symbol changed), we raise at import so the pod + crashes on boot rather than silently serving traffic with revocation disabled. +""" + +import inspect +import logging +import os +import threading +import time +from typing import cast +from urllib.parse import urlparse, urlunparse + +import httpx +from fastapi import HTTPException, status +from stac_auth_proxy.middleware import EnforceAuthMiddleware + +logger = logging.getLogger(__name__) + + +def _bool_env(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _verify_url_from_oidc() -> str: + """Derive the go-api verify URL from the OIDC issuer origin. + + The verify endpoint always lives on the same host as the OIDC provider, so we reuse + OIDC_DISCOVERY_URL rather than taking a separate URL. Raise if it is unset or + unparseable so the pod crashes on boot instead of running without a verify endpoint. + """ + oidc = os.environ.get("OIDC_DISCOVERY_URL") + parts = urlparse(oidc or "") + if not (parts.scheme and parts.netloc): + raise RuntimeError( + "montandon_revocation: cannot derive verify URL; OIDC_DISCOVERY_URL is unset " + "or invalid (%r)." % (oidc,) + ) + return urlunparse( + (parts.scheme, parts.netloc, "/api/v2/external-token/verify/", "", "", "") + ) + + +# --- Configuration (read once at import) ------------------------------------------------ + +VERIFY_URL: str = _verify_url_from_oidc() +CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TTL", "300")) +VERIFY_TIMEOUT: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TIMEOUT", "3")) +FAIL_OPEN: bool = _bool_env("GOAPI_TOKEN_VERIFY_FAIL_OPEN", False) +STATIC_BLACKLIST: frozenset[str] = frozenset( + jti.strip() + for jti in os.environ.get("GOAPI_TOKEN_STATIC_BLACKLIST", "").split(",") + if jti.strip() +) + +# --- Positive-only in-memory cache (per pod) -------------------------------------------- +# jti -> monotonic expiry. Single-process/single-event-loop deployment, but guard with a +# lock anyway since we are called from a sync context. +_cache: dict[str, float] = {} +_cache_lock = threading.Lock() + +_client = httpx.Client(timeout=VERIFY_TIMEOUT) + + +def _cache_get(jti: str) -> bool: + with _cache_lock: + expiry = _cache.get(jti) + if expiry is None: + return False + if expiry <= time.monotonic(): + _cache.pop(jti, None) + return False + return True + + +def _cache_put(jti: str) -> None: + with _cache_lock: + _cache[jti] = time.monotonic() + CACHE_TTL + + +def _reject(detail: str) -> HTTPException: + return HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=detail, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +def _is_active(jti: str) -> bool: + """Ask go-api whether the token is still active. Applies fail policy on outage.""" + try: + resp = _client.post(VERIFY_URL, json={"jti": jti}) + except httpx.HTTPError as exc: + logger.warning( + "go-api verify call failed (%s); fail_open=%s", + type(exc).__name__, + FAIL_OPEN, + ) + return FAIL_OPEN + + if resp.status_code == 200: + try: + return bool(resp.json().get("active")) + except ValueError: + logger.error("go-api verify returned non-JSON 200; rejecting") + return False + if resp.status_code == 400: + # Malformed jti. Should not happen (jti comes from a validated token). Reject. + logger.error("go-api verify returned 400 for jti; rejecting") + return False + # Any other status (incl. 404 before the endpoint is live) -> outage -> fail policy. + logger.warning( + "go-api verify returned %s; fail_open=%s", resp.status_code, FAIL_OPEN + ) + return FAIL_OPEN + + +def check_revocation(payload: dict | None) -> None: + """Raise HTTPException(401) if the token behind `payload` is revoked/unknown.""" + if payload is None: + # Anonymous / public request, no token to check. + return + + jti = payload.get("jti") + if not jti: + raise _reject("Token is missing jti") + + # Static blacklist wins over everything (permanent kill-switch). + if jti in STATIC_BLACKLIST: + logger.info("Rejecting statically-blacklisted jti %s", jti) + raise _reject("Token has been revoked") + + if _cache_get(jti): + return + + if not _is_active(jti): + logger.info("Rejecting revoked/unknown jti %s", jti) + raise _reject("Token has been revoked") + + _cache_put(jti) + + +# --- Monkeypatch -------------------------------------------------------------------------- + + +def apply_patch() -> None: + """Wrap EnforceAuthMiddleware.validate_token with the revocation check. + + Called explicitly from montandon_filters.py at import time (app construction, + before any request is served). Idempotent and raises if the target symbol is + missing, so revocation can never be silently disabled. + """ + original = getattr(EnforceAuthMiddleware, "validate_token", None) + if not callable(original): + raise RuntimeError( + "montandon_revocation: EnforceAuthMiddleware.validate_token not found; " + "stac-auth-proxy internals changed. Refusing to start with revocation disabled." + ) + # Idempotent: return early if already patched, before inspecting the (wrapper) signature. + if getattr(original, "_montandon_revocation_patched", False): + return + # Guard against upstream signature drift: we rely on it returning the JWT payload. + params = list(inspect.signature(original).parameters) + if "auth_header" not in params: + raise RuntimeError( + "montandon_revocation: unexpected validate_token signature %r; " + "refusing to start with revocation disabled." % (params,) + ) + + def validate_token(self, *args, **kwargs): + payload = cast(dict | None, original(self, *args, **kwargs)) + check_revocation(payload) + return payload + + validate_token._montandon_revocation_patched = True # type: ignore[attr-defined] + EnforceAuthMiddleware.validate_token = validate_token # type: ignore[assignment] + logger.info( + "montandon_revocation: patched EnforceAuthMiddleware.validate_token " + "(verify_url=%s, ttl=%ss, fail_open=%s, blacklist=%d)", + VERIFY_URL, + CACHE_TTL, + FAIL_OPEN, + len(STATIC_BLACKLIST), + ) diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml b/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml similarity index 64% rename from applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml rename to applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml index 5fbb4eb..bf92f3b 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/filters-cm.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/templates/stac-auth-proxy/modules-cm.yaml @@ -1,7 +1,7 @@ apiVersion: v1 kind: ConfigMap metadata: - name: stac-auth-proxy-filters + name: stac-auth-proxy-modules {{- with .Values.commonAnnotations }} annotations: {{- toYaml . | nindent 4 }} @@ -9,3 +9,5 @@ metadata: data: montandon_filters.py: | {{ .Files.Get "files/stac-auth-proxy/montandon_filters.py" | indent 4 }} + montandon_revocation.py: | +{{ .Files.Get "files/stac-auth-proxy/montandon_revocation.py" | indent 4 }} From 83374b90ad013f497ff0025be4ab1e7c734e71f7 Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 09:29:27 +0545 Subject: [PATCH 2/3] feat(montandon-eoapi): per-outcome TTLs for token revocation cache Cache each verify outcome with its own TTL instead of one positive TTL: - verified active:true -> 300s (GOAPI_TOKEN_VERIFY_TTL) - fail-open allow during a go-api outage -> 30s (GOAPI_TOKEN_VERIFY_OUTAGE_TTL), so revocation resumes within seconds of recovery instead of being masked for a full positive-cache window - definitive deny (go-api reports the jti inactive/unknown) -> 24h (GOAPI_TOKEN_VERIFY_DENY_TTL), so a known-revoked token is not re-checked on every request Only definitive 200 active:false denials are negative-cached; a fail-closed outage rejects but is not cached, so recovery is never masked. The cache now stores the (allowed, expiry) decision and check_revocation honors a cached allow or deny before calling go-api. --- .../montandon-eoapi/application.yaml | 6 ++ .../stac-auth-proxy/montandon_revocation.py | 72 +++++++++++++------ 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/applications/argocd/staging/applications/montandon-eoapi/application.yaml b/applications/argocd/staging/applications/montandon-eoapi/application.yaml index b9d5813..85c804d 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/application.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/application.yaml @@ -154,6 +154,12 @@ spec: ITEMS_FILTER_CLS: stac_auth_proxy.montandon_filters:ItemsFilter # External-token revocation (go-api issue #2794), injected via montandon_revocation.py. GOAPI_TOKEN_VERIFY_TTL: "300" + # Short TTL for a fail-open allow served during a go-api outage, so revocation + # resumes within seconds of recovery instead of being masked for a full TTL. + GOAPI_TOKEN_VERIFY_OUTAGE_TTL: "30" + # TTL for a definitive deny (go-api reported the jti inactive/unknown): 24h, so a + # known-revoked token is not re-checked against go-api on every request. + GOAPI_TOKEN_VERIFY_DENY_TTL: "86400" GOAPI_TOKEN_VERIFY_TIMEOUT: "3" GOAPI_TOKEN_VERIFY_FAIL_OPEN: "true" GOAPI_TOKEN_STATIC_BLACKLIST: "fec23689-bf00-401e-9f9c-a65684f44b5f" # NOTE: Token ID used in hackathon diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py index 62fd74b..475c453 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py @@ -68,6 +68,14 @@ def _verify_url_from_oidc() -> str: VERIFY_URL: str = _verify_url_from_oidc() CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TTL", "300")) +# Short TTL for a fail-open "allow" served during a go-api outage, so revocation resumes +# within seconds of go-api recovering instead of being masked for a full CACHE_TTL window. +OUTAGE_CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_OUTAGE_TTL", "30")) +# TTL for a definitive deny (go-api reported the jti inactive/unknown). Long, since a +# revoked/unknown jti effectively never becomes valid again; avoids re-hitting go-api on +# every request from an abandoned or leaked token. Only definitive denials are cached here +# (never a transient fail-closed outage), so recovery is not masked. +DENY_CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_DENY_TTL", "86400")) VERIFY_TIMEOUT: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TIMEOUT", "3")) FAIL_OPEN: bool = _bool_env("GOAPI_TOKEN_VERIFY_FAIL_OPEN", False) STATIC_BLACKLIST: frozenset[str] = frozenset( @@ -76,29 +84,32 @@ def _verify_url_from_oidc() -> str: if jti.strip() ) -# --- Positive-only in-memory cache (per pod) -------------------------------------------- -# jti -> monotonic expiry. Single-process/single-event-loop deployment, but guard with a -# lock anyway since we are called from a sync context. -_cache: dict[str, float] = {} +# --- In-memory decision cache (per pod) ------------------------------------------------- +# jti -> (allowed, monotonic expiry). Caches both allows (short TTL) and definitive denies +# (long TTL). Single-process/single-event-loop deployment, but guard with a lock anyway +# since we are called from a sync context. +_cache: dict[str, tuple[bool, float]] = {} _cache_lock = threading.Lock() _client = httpx.Client(timeout=VERIFY_TIMEOUT) -def _cache_get(jti: str) -> bool: +def _cache_get(jti: str) -> bool | None: + """Return the cached decision (True=allow, False=deny), or None on miss/expiry.""" with _cache_lock: - expiry = _cache.get(jti) - if expiry is None: - return False + entry = _cache.get(jti) + if entry is None: + return None + allowed, expiry = entry if expiry <= time.monotonic(): _cache.pop(jti, None) - return False - return True + return None + return allowed -def _cache_put(jti: str) -> None: +def _cache_put(jti: str, allowed: bool, ttl: float) -> None: with _cache_lock: - _cache[jti] = time.monotonic() + CACHE_TTL + _cache[jti] = (allowed, time.monotonic() + ttl) def _reject(detail: str) -> HTTPException: @@ -109,8 +120,17 @@ def _reject(detail: str) -> HTTPException: ) -def _is_active(jti: str) -> bool: - """Ask go-api whether the token is still active. Applies fail policy on outage.""" +def _verify(jti: str) -> tuple[bool, float]: + """Ask go-api whether the token is active. + + Returns ``(allowed, cache_ttl)`` where cache_ttl is how long THIS decision may be + cached (0 = do not cache). Outcomes: + - 200 active:true -> (True, CACHE_TTL) verified allow, short TTL + - 200 active:false -> (False, DENY_CACHE_TTL) definitive deny, long TTL + - outage/other -> fail policy; a fail-open allow caches briefly (OUTAGE_CACHE_TTL), + a fail-closed deny is NOT cached (0) so recovery isn't masked + - 400 / non-JSON -> (False, 0) anomalous, reject but don't cache + """ try: resp = _client.post(VERIFY_URL, json={"jti": jti}) except httpx.HTTPError as exc: @@ -119,23 +139,24 @@ def _is_active(jti: str) -> bool: type(exc).__name__, FAIL_OPEN, ) - return FAIL_OPEN + return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) if resp.status_code == 200: try: - return bool(resp.json().get("active")) + active = bool(resp.json().get("active")) except ValueError: logger.error("go-api verify returned non-JSON 200; rejecting") - return False + return False, 0 + return active, (CACHE_TTL if active else DENY_CACHE_TTL) if resp.status_code == 400: # Malformed jti. Should not happen (jti comes from a validated token). Reject. logger.error("go-api verify returned 400 for jti; rejecting") - return False + return False, 0 # Any other status (incl. 404 before the endpoint is live) -> outage -> fail policy. logger.warning( "go-api verify returned %s; fail_open=%s", resp.status_code, FAIL_OPEN ) - return FAIL_OPEN + return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) def check_revocation(payload: dict | None) -> None: @@ -153,15 +174,20 @@ def check_revocation(payload: dict | None) -> None: logger.info("Rejecting statically-blacklisted jti %s", jti) raise _reject("Token has been revoked") - if _cache_get(jti): + cached = _cache_get(jti) + if cached is True: return + if cached is False: + logger.info("Rejecting cached-inactive jti %s", jti) + raise _reject("Token has been revoked") - if not _is_active(jti): + allowed, ttl = _verify(jti) + if ttl > 0: + _cache_put(jti, allowed, ttl) + if not allowed: logger.info("Rejecting revoked/unknown jti %s", jti) raise _reject("Token has been revoked") - _cache_put(jti) - # --- Monkeypatch -------------------------------------------------------------------------- From 461a522a01ccbdf75e8cb68efcda59a05b4bcd5e Mon Sep 17 00:00:00 2001 From: thenav56 Date: Fri, 24 Jul 2026 15:41:58 +0545 Subject: [PATCH 3/3] fix(montandon-eoapi): harden token-revocation verify + cache Address code-review findings in montandon_revocation.py: - Decode the verify response strictly: only `active is True` allows and only `active is False` is a definitive (cacheable) deny. A 200 without a boolean `active` (field drift / interstitial served as 200) now rejects WITHOUT caching, so a valid token is never locked out for the 24h deny TTL; a stringy `"active":"false"` no longer coerces to allow. - Bound the decision cache with GOAPI_TOKEN_VERIFY_CACHE_MAX (default 50000); on overflow drop expired entries then the soonest-to-expire, so a long-lived pod seeing many distinct jtis can't grow unbounded. - Never let a token check become a 500: _verify wraps the call in a catch-all mapping to the fail policy, and a non-string jti is rejected before it can be used as an unhashable cache key. - Validate numeric env vars (_num_env): clear RuntimeError on non-numeric or negative values instead of an opaque float() crash. Blocking sync httpx on the event loop is retained by design (bounded by VERIFY_TIMEOUT, absorbed by the cache). --- .../montandon-eoapi/application.yaml | 2 + .../stac-auth-proxy/montandon_revocation.py | 137 +++++++++++++----- 2 files changed, 102 insertions(+), 37 deletions(-) diff --git a/applications/argocd/staging/applications/montandon-eoapi/application.yaml b/applications/argocd/staging/applications/montandon-eoapi/application.yaml index 85c804d..e89b07d 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/application.yaml +++ b/applications/argocd/staging/applications/montandon-eoapi/application.yaml @@ -160,6 +160,8 @@ spec: # TTL for a definitive deny (go-api reported the jti inactive/unknown): 24h, so a # known-revoked token is not re-checked against go-api on every request. GOAPI_TOKEN_VERIFY_DENY_TTL: "86400" + # Upper bound on cached jti decisions (memory guard on a long-lived pod). + GOAPI_TOKEN_VERIFY_CACHE_MAX: "50000" GOAPI_TOKEN_VERIFY_TIMEOUT: "3" GOAPI_TOKEN_VERIFY_FAIL_OPEN: "true" GOAPI_TOKEN_STATIC_BLACKLIST: "fec23689-bf00-401e-9f9c-a65684f44b5f" # NOTE: Token ID used in hackathon diff --git a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py index 475c453..f693216 100644 --- a/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py +++ b/applications/argocd/staging/applications/montandon-eoapi/internal/files/stac-auth-proxy/montandon_revocation.py @@ -16,7 +16,9 @@ A token with no `jti`, or a `jti` go-api does not recognise, is rejected (401). - A static blacklist (env) is checked first and always wins — a permanent kill-switch that works even before/without the go-api verify endpoint. -- Positive results only are cached, short TTL, so a revoked token is never served stale. +- Decisions are cached per outcome with their own TTL: verified allows briefly, definitive + denies for a long window, fail-open outage allows very briefly; a fail-closed outage is + never cached so recovery is not masked. The cache is size-bounded. - Fail policy on go-api outage is configurable; default is fail-closed. It is deployed fail-open initially (until the verify endpoint is live in prod goadmin) then flipped. - If the patch cannot be applied (upstream symbol changed), we raise at import so the pod @@ -45,6 +47,30 @@ def _bool_env(name: str, default: bool) -> bool: return raw.strip().lower() in ("1", "true", "yes", "on") +def _num_env(name: str, default: float, *, minimum: float = 0.0) -> float: + """Parse a numeric env var, failing on boot with a clear message on bad input. + + Empty/unset falls back to ``default``. A non-numeric or out-of-range value raises + RuntimeError (consistent with _verify_url_from_oidc) instead of an opaque + ``float()`` ValueError, and prevents footguns like a negative TTL that would make + every cache entry expire immediately. + """ + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = float(raw) + except ValueError: + raise RuntimeError( + "montandon_revocation: %s must be a number, got %r." % (name, raw) + ) from None + if value < minimum: + raise RuntimeError( + "montandon_revocation: %s must be >= %s, got %s." % (name, minimum, value) + ) + return value + + def _verify_url_from_oidc() -> str: """Derive the go-api verify URL from the OIDC issuer origin. @@ -67,16 +93,20 @@ def _verify_url_from_oidc() -> str: # --- Configuration (read once at import) ------------------------------------------------ VERIFY_URL: str = _verify_url_from_oidc() -CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TTL", "300")) +CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_TTL", 300) # Short TTL for a fail-open "allow" served during a go-api outage, so revocation resumes # within seconds of go-api recovering instead of being masked for a full CACHE_TTL window. -OUTAGE_CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_OUTAGE_TTL", "30")) +OUTAGE_CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_OUTAGE_TTL", 30) # TTL for a definitive deny (go-api reported the jti inactive/unknown). Long, since a # revoked/unknown jti effectively never becomes valid again; avoids re-hitting go-api on # every request from an abandoned or leaked token. Only definitive denials are cached here # (never a transient fail-closed outage), so recovery is not masked. -DENY_CACHE_TTL: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_DENY_TTL", "86400")) -VERIFY_TIMEOUT: float = float(os.environ.get("GOAPI_TOKEN_VERIFY_TIMEOUT", "3")) +DENY_CACHE_TTL: float = _num_env("GOAPI_TOKEN_VERIFY_DENY_TTL", 86400) +VERIFY_TIMEOUT: float = _num_env("GOAPI_TOKEN_VERIFY_TIMEOUT", 3, minimum=0.001) +# Upper bound on cached jti decisions; keeps memory bounded on a long-lived pod that sees +# many distinct (leaked/rotated/one-off) jtis. On overflow we drop expired entries first, +# then the soonest-to-expire. +CACHE_MAX_ENTRIES: int = int(_num_env("GOAPI_TOKEN_VERIFY_CACHE_MAX", 50000, minimum=1)) FAIL_OPEN: bool = _bool_env("GOAPI_TOKEN_VERIFY_FAIL_OPEN", False) STATIC_BLACKLIST: frozenset[str] = frozenset( jti.strip() @@ -107,9 +137,25 @@ def _cache_get(jti: str) -> bool | None: return allowed +def _evict_locked(now: float) -> None: + """Bound cache size. Caller must hold _cache_lock. Drop expired entries first, then, + if still over the cap, the soonest-to-expire ones.""" + for k in [k for k, (_, exp) in _cache.items() if exp <= now]: + _cache.pop(k, None) + overflow = len(_cache) - CACHE_MAX_ENTRIES + if overflow > 0: + for k in sorted(_cache, key=lambda k: _cache[k][1])[:overflow]: + _cache.pop(k, None) + + def _cache_put(jti: str, allowed: bool, ttl: float) -> None: + if ttl <= 0: + return # decisions with a 0 ttl (anomalies, uncached outages) are never cached + now = time.monotonic() with _cache_lock: - _cache[jti] = (allowed, time.monotonic() + ttl) + _cache[jti] = (allowed, now + ttl) + if len(_cache) > CACHE_MAX_ENTRIES: + _evict_locked(now) def _reject(detail: str) -> HTTPException: @@ -120,43 +166,59 @@ def _reject(detail: str) -> HTTPException: ) +def _fail_policy() -> tuple[bool, float]: + """Decision for an outage: fail-open allows briefly (cached OUTAGE_CACHE_TTL); a + fail-closed deny is NOT cached (ttl 0) so recovery is not masked.""" + return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) + + def _verify(jti: str) -> tuple[bool, float]: """Ask go-api whether the token is active. Returns ``(allowed, cache_ttl)`` where cache_ttl is how long THIS decision may be cached (0 = do not cache). Outcomes: - - 200 active:true -> (True, CACHE_TTL) verified allow, short TTL - - 200 active:false -> (False, DENY_CACHE_TTL) definitive deny, long TTL - - outage/other -> fail policy; a fail-open allow caches briefly (OUTAGE_CACHE_TTL), - a fail-closed deny is NOT cached (0) so recovery isn't masked - - 400 / non-JSON -> (False, 0) anomalous, reject but don't cache + - 200 active is True -> (True, CACHE_TTL) verified allow, short TTL + - 200 active is False -> (False, DENY_CACHE_TTL) definitive deny, long TTL + - 200 without a boolean `active` -> (False, 0) anomalous body: reject, don't cache + - 400 -> (False, 0) anomalous, reject but don't cache + - outage / other -> fail policy (see _fail_policy) + + Any unexpected error is treated as an outage rather than propagating, so a token check + can never surface as an HTTP 500 (which would bypass the intended fail policy). """ try: resp = _client.post(VERIFY_URL, json={"jti": jti}) - except httpx.HTTPError as exc: - logger.warning( - "go-api verify call failed (%s); fail_open=%s", - type(exc).__name__, - FAIL_OPEN, - ) - return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) - if resp.status_code == 200: - try: - active = bool(resp.json().get("active")) - except ValueError: - logger.error("go-api verify returned non-JSON 200; rejecting") + if resp.status_code == 200: + active = resp.json().get("active") + if active is True: + return True, CACHE_TTL + if active is False: + return False, DENY_CACHE_TTL + # 200 but no boolean `active` (field drift, interstitial served as 200, ...). + # Do NOT treat as a definitive deny: reject this request but don't cache it, + # so a valid token is not locked out for DENY_CACHE_TTL. + logger.error( + "go-api verify 200 lacked a boolean 'active' (%r); rejecting, not caching", + active, + ) return False, 0 - return active, (CACHE_TTL if active else DENY_CACHE_TTL) - if resp.status_code == 400: - # Malformed jti. Should not happen (jti comes from a validated token). Reject. - logger.error("go-api verify returned 400 for jti; rejecting") - return False, 0 - # Any other status (incl. 404 before the endpoint is live) -> outage -> fail policy. - logger.warning( - "go-api verify returned %s; fail_open=%s", resp.status_code, FAIL_OPEN - ) - return FAIL_OPEN, (OUTAGE_CACHE_TTL if FAIL_OPEN else 0) + if resp.status_code == 400: + # Malformed jti. Should not happen (jti comes from a validated token). Reject. + logger.error("go-api verify returned 400 for jti; rejecting") + return False, 0 + # Any other status (incl. 404 before the endpoint is live) -> outage. + logger.warning( + "go-api verify returned %s; fail_open=%s", resp.status_code, FAIL_OPEN + ) + return _fail_policy() + except Exception as exc: + # Network error, non-JSON body, or anything unexpected -> honour the fail policy + # rather than letting it become a 500. + logger.warning( + "go-api verify call failed (%s); fail_open=%s", type(exc).__name__, FAIL_OPEN + ) + return _fail_policy() def check_revocation(payload: dict | None) -> None: @@ -166,8 +228,10 @@ def check_revocation(payload: dict | None) -> None: return jti = payload.get("jti") - if not jti: - raise _reject("Token is missing jti") + # Require a non-empty string jti. A non-string would be unhashable as a cache key + # (500) or bypass the blacklist; a validated external token always carries a str jti. + if not jti or not isinstance(jti, str): + raise _reject("Token is missing a valid jti") # Static blacklist wins over everything (permanent kill-switch). if jti in STATIC_BLACKLIST: @@ -182,8 +246,7 @@ def check_revocation(payload: dict | None) -> None: raise _reject("Token has been revoked") allowed, ttl = _verify(jti) - if ttl > 0: - _cache_put(jti, allowed, ttl) + _cache_put(jti, allowed, ttl) # no-op when ttl <= 0 if not allowed: logger.info("Rejecting revoked/unknown jti %s", jti) raise _reject("Token has been revoked")