fix(dashboard-auth): pin OIDC discovery to the configured issuer origin

_fetch_discovery followed redirects but only pinned the document's
self-asserted issuer field, so one cleartext or attacker-hosted hop
could serve a forged document claiming the configured issuer with
attacker jwks_uri and token_endpoint. Verify then accepted
attacker-signed ID tokens and the code exchange POSTed the client
secret to the attacker's token endpoint.

The resolved response.url must now share the configured issuer's
origin (scheme, host, port with default-port normalisation) before
the body is parsed. Same-origin canonicalisation redirects still
pass, and the issuer-field pin remains as the misconfig check it is.
This commit is contained in:
beardthelion
2026-09-19 15:06:20 -05:00
committed by Teknium
parent ba3b1eb3c3
commit 07c1953ea1
2 changed files with 173 additions and 3 deletions

View File

@@ -64,6 +64,15 @@ def _require_https_or_loopback(url: str, *, field: str) -> str:
raise ProviderError(f"OIDC {field} must be https:// (or http on localhost), got {url!r}")
def _origin(url: str) -> tuple:
"""``(scheme, hostname, port)`` for an origin compare, with default ports
normalised so ``https://h`` and ``https://h:443`` are the same origin."""
parts = urllib.parse.urlparse(url)
scheme = parts.scheme.lower()
return (scheme, (parts.hostname or "").lower(),
parts.port or {"https": 443, "http": 80}.get(scheme))
class SelfHostedOIDCProvider(JwtOAuthProvider):
"""Generic self-hosted OpenID Connect provider (authorization-code + PKCE)."""
@@ -188,14 +197,23 @@ class SelfHostedOIDCProvider(JwtOAuthProvider):
try:
# follow_redirects=True: many IDPs answer discovery with a 3xx (Authentik
# canonicalises .well-known; proxies upgrade http→https) and httpx defaults to
# not following. Safe because the issuer pin and HTTPS checks below validate the
# *resolved* document. The token/revocation POSTs deliberately do NOT follow
# not following. The token/revocation POSTs deliberately do NOT follow
# redirects (they carry an auth code / refresh token).
response = httpx.get(url, headers=JSON_HEADERS, timeout=_DISCOVERY_TIMEOUT_SEC, follow_redirects=True)
except httpx.RequestError as exc:
raise ProviderError(f"OIDC discovery unreachable: {exc}") from exc
if response.status_code != 200:
raise ProviderError(f"OIDC discovery returned {response.status_code} for {url!r}")
# Resolved-origin pin: the document only counts as the IDP's when the url that
# actually served it shares the configured issuer's origin. The ``issuer`` field
# inside the body is attacker-controlled content and cannot prove where the
# document came from — a single cleartext or attacker-hosted redirect hop could
# otherwise serve a forged document asserting the configured issuer with
# attacker jwks_uri / token_endpoint.
if _origin(str(response.url)) != _origin(self._issuer):
raise ProviderError(
f"OIDC discovery resolved to {response.url}, outside the configured "
f"issuer's origin ({self._issuer!r})")
payload = parse_json_body(response)
if not payload:
raise ProviderError("OIDC discovery returned a non-JSON body")