feat(oidc): allow_private_network opt-in for self-hosted IdPs

The SSRF guard on OIDC endpoint URLs hard-refused any hostname
resolving to a non-public address, which made it impossible to use a
self-hosted IdP (Keycloak, Authentik, Dex) on an internal network —
even though the login-flow issuer is operator-configured, i.e. trusted
input.

Add [oidc] allow_private_network in config.toml (or
TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK), default off. When set, the
issuer and its discovered endpoints may resolve to private-range,
unique-local, CGNAT, and loopback addresses. Link-local, multicast,
unspecified, and reserved ranges stay refused regardless — cloud
metadata services live on link-local and no legitimate IdP does. The
HTTPS requirement and same-origin endpoint checks are unchanged.

The private-address refusal now raises OAuthSSRFPrivateAddressError,
and the OIDC wrapper appends the remediation hint to the error message
so the failure is self-service. mcp_oauth call sites — where endpoint
URLs come from untrusted remote-server metadata — do not get the knob
and keep the strict public-address rule.

(cherry picked from commit 9c74673fd4)
This commit is contained in:
Patrick Buckley
2026-07-06 21:36:47 -07:00
parent 043554bb2f
commit 0f17433e1f
5 changed files with 306 additions and 19 deletions
+37
View File
@@ -41,6 +41,7 @@ are set.
| `TURNSTONE_OIDC_PASSWORD_ENABLED` | No | `true` | Set to `false` to hide the password form and block all username/password logins (including admin). API tokens continue to work. |
| `TURNSTONE_OIDC_REDIRECT_BASE` | Yes | — | Externally-reachable origin for the OIDC redirect URI (e.g. `https://app.example.com`). Without this, OIDC will refuse to start. The previous Host-header fallback was unsafe under permissive reverse proxies. |
| `TURNSTONE_OIDC_TRUSTED_ENDPOINT_HOSTS` | No | — | Comma-separated list of additional hostnames whose endpoints the IdP discovery document is allowed to reference. See [Cross-host endpoints](#cross-host-endpoints). |
| `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK` | No | `false` | Allow the issuer (and its discovered endpoints) to resolve to private/internal addresses — needed for a self-hosted IdP on an internal network. See [Self-hosted and internal IdPs](#self-hosted-and-internal-idps). |
All four required fields — issuer, client ID, client secret, and
`TURNSTONE_OIDC_REDIRECT_BASE` — must be set. If any are missing OIDC
@@ -99,6 +100,40 @@ The same scheme / no-userinfo / SSRF rules apply to allow-listed hosts —
this knob only relaxes the same-origin check, not the security gates.
Each entry is a hostname (no scheme, no path).
### Self-hosted and internal IdPs
By default Turnstone refuses an issuer whose hostname resolves to a
private or internal address:
```
OIDCError: endpoint URL resolves to non-public address (10.0.0.5): https://auth.example.site
```
This is SSRF hardening, not a licensing or product restriction: the OIDC
flow makes server-side HTTP requests (discovery, JWKS, token exchange),
and refusing non-public destinations keeps a mistyped or maliciously
steered issuer from aiming those fetches at internal services. For a
self-hosted IdP (Keycloak, Authentik, Dex, …) on a private network,
opt in explicitly in `config.toml`:
```toml
[oidc]
allow_private_network = true
```
or via `TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true` (the env var wins
when both are set).
The opt-in admits private-range (RFC 1918), unique-local, CGNAT
(100.64/10 — tailnets), and loopback addresses. Link-local, multicast,
and reserved ranges stay refused even with the opt-in — cloud metadata
services (169.254.169.254) live there, and no legitimate IdP does. The
HTTPS requirement and the same-origin endpoint checks are unaffected.
This knob only affects the login-flow IdP configured here. OAuth
endpoints advertised by remote MCP servers are untrusted input and are
always held to the strict public-address rule.
### config.toml alternative
```toml
@@ -111,6 +146,8 @@ provider_name = "Google"
role_claim = "groups"
password_enabled = true
redirect_base = "https://app.example.com"
# Self-hosted IdP on an internal network (see "Self-hosted and internal IdPs")
allow_private_network = false
[oidc.role_map]
admin = "builtin-admin"
+62
View File
@@ -15,6 +15,7 @@ import pytest
from turnstone.core.oauth_ssrf import (
OAuthSSRFError,
OAuthSSRFPrivateAddressError,
effective_port,
is_localhost,
validate_discovered_endpoint,
@@ -86,6 +87,54 @@ class TestValidateUrlNoSSRF:
):
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
def test_private_address_raises_distinct_subclass(self) -> None:
# Callers with an operator opt-in (OIDC) catch the subclass to
# append the remediation hint; plain OAuthSSRFError catches still work.
with (
patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR),
pytest.raises(OAuthSSRFPrivateAddressError),
):
validate_url_no_ssrf("https://corp.example.com", allow_http=False)
def test_allow_private_accepts_rfc1918(self) -> None:
with patch("socket.getaddrinfo", return_value=self._PRIVATE_ADDR):
parsed = validate_url_no_ssrf(
"https://auth.corp.example.com", allow_http=False, allow_private=True
)
assert parsed.hostname == "auth.corp.example.com"
def test_allow_private_accepts_cgnat(self) -> None:
# 100.64/10 (RFC 6598, shared address space) — e.g. a tailnet-hosted IdP.
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("100.64.0.7", 0))]):
validate_url_no_ssrf("https://idp.tail.example", allow_http=False, allow_private=True)
def test_allow_private_accepts_loopback_hostname(self) -> None:
# A non-localhost hostname resolving to loopback (IdP behind a
# local reverse proxy) is operator-trusted under the opt-in.
with patch("socket.getaddrinfo", return_value=self._LOOPBACK_ADDR):
validate_url_no_ssrf("https://auth.internal", allow_http=False, allow_private=True)
def test_allow_private_still_rejects_link_local(self) -> None:
# Cloud metadata services live on link-local; no legitimate IdP does.
with (
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
pytest.raises(OAuthSSRFError, match="refused even with private"),
):
validate_url_no_ssrf("https://md.example.com", allow_http=False, allow_private=True)
def test_allow_private_still_rejects_unspecified(self) -> None:
with (
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("0.0.0.0", 0))]),
pytest.raises(OAuthSSRFError, match="refused even with private"),
):
validate_url_no_ssrf("https://zero.example.com", allow_http=False, allow_private=True)
def test_allow_private_does_not_relax_https(self) -> None:
with pytest.raises(OAuthSSRFError, match="must use HTTPS"):
validate_url_no_ssrf(
"http://auth.corp.example.com", allow_http=False, allow_private=True
)
def test_rejects_unresolvable(self) -> None:
import socket
@@ -122,6 +171,19 @@ class TestValidateDiscoveredEndpoint:
trusted_endpoint_hosts=frozenset(),
)
def test_allow_private_passes_through(self) -> None:
# Same-origin endpoint on a private-resolving issuer host is accepted
# when the operator opted in.
issuer = urllib.parse.urlparse("https://auth.corp.example.com")
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
validate_discovered_endpoint(
"https://auth.corp.example.com/token",
issuer,
allow_http=False,
trusted_endpoint_hosts=frozenset(),
allow_private=True,
)
def test_trusted_endpoint_host_passes(self) -> None:
issuer = urllib.parse.urlparse("https://idp.example.com")
with patch("socket.getaddrinfo", return_value=self._PUBLIC_ADDR):
+109
View File
@@ -90,6 +90,42 @@ class TestLoadOIDCConfig:
assert cfg.scopes == "openid"
assert cfg.provider_name == "Okta"
def test_load_oidc_config_allow_private_network_env(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.setenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", "true")
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.allow_private_network is True
def test_load_oidc_config_allow_private_network_toml(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.internal.example")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
with patch(
"turnstone.core.config.load_config",
return_value={"allow_private_network": True},
):
cfg = load_oidc_config()
assert cfg.allow_private_network is True
def test_load_oidc_config_allow_private_network_default_off(self, monkeypatch):
monkeypatch.setenv("TURNSTONE_OIDC_ISSUER", "https://auth.example.com")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_ID", "cid")
monkeypatch.setenv("TURNSTONE_OIDC_CLIENT_SECRET", "csecret")
monkeypatch.delenv("TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", raising=False)
with patch("turnstone.core.config.load_config", return_value={}):
cfg = load_oidc_config()
assert cfg.allow_private_network is False
def test_load_oidc_config_disabled_when_missing(self, monkeypatch):
monkeypatch.delenv("TURNSTONE_OIDC_ISSUER", raising=False)
monkeypatch.delenv("TURNSTONE_OIDC_CLIENT_ID", raising=False)
@@ -345,6 +381,27 @@ class TestValidateIssuerURL:
):
validate_issuer_url("https://idp.example.com")
def test_private_address_hint_mentions_opt_in(self):
"""The rejection message points the operator at allow_private_network."""
with (
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
pytest.raises(OIDCError, match="allow_private_network"),
):
validate_issuer_url("https://auth.internal.example")
def test_allow_private_accepts_private_issuer(self):
"""The opt-in accepts an issuer resolving to RFC 1918 space."""
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
validate_issuer_url("https://auth.internal.example", allow_private=True)
def test_allow_private_still_rejects_link_local(self):
"""Link-local (cloud metadata) is refused even with the opt-in."""
with (
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("169.254.169.254", 0))]),
pytest.raises(OIDCError, match="refused even with private"),
):
validate_issuer_url("https://md.internal.example", allow_private=True)
def test_rejects_http_non_localhost(self):
"""HTTP is rejected for non-localhost hosts."""
with pytest.raises(OIDCError, match="must use HTTPS"):
@@ -2166,6 +2223,58 @@ class TestDiscoverOIDC:
asyncio.run(_run())
def test_discover_oidc_private_issuer_rejected_by_default(self):
"""Without the opt-in, a private-resolving issuer disables OIDC."""
config = _make_config(
issuer="https://auth.internal.example",
authorization_endpoint="",
token_endpoint="",
userinfo_endpoint="",
jwks_uri="",
)
async def _run():
with patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]):
result = await discover_oidc(config)
assert result.enabled is False
asyncio.run(_run())
def test_discover_oidc_private_issuer_with_opt_in(self):
"""allow_private_network=True lets a private-resolving IdP discover."""
config = _make_config(
issuer="https://auth.internal.example",
allow_private_network=True,
authorization_endpoint="",
token_endpoint="",
userinfo_endpoint="",
jwks_uri="",
)
discovery_doc = {
"authorization_endpoint": "https://auth.internal.example/authorize",
"token_endpoint": "https://auth.internal.example/token",
"userinfo_endpoint": "https://auth.internal.example/userinfo",
"jwks_uri": "https://auth.internal.example/jwks",
}
mock_response = MagicMock()
mock_response.json.return_value = discovery_doc
mock_response.raise_for_status = MagicMock()
async def _run():
client = _mock_async_client(lambda url: _async_return(mock_response))
with (
patch("socket.getaddrinfo", return_value=[(2, 1, 6, "", ("10.0.0.5", 0))]),
patch("httpx.AsyncClient", return_value=client),
):
result = await discover_oidc(config)
assert result.enabled is True
assert result.token_endpoint == "https://auth.internal.example/token"
asyncio.run(_run())
def test_discover_oidc_failure(self):
"""Mock httpx error -> enabled=False returned."""
config = _make_config(
+51 -10
View File
@@ -58,6 +58,17 @@ class OAuthSSRFError(Exception):
"""
class OAuthSSRFPrivateAddressError(OAuthSSRFError):
"""A hostname resolved to a non-public address, specifically.
A distinct subclass so callers with an operator-facing opt-in
(``[oidc] allow_private_network``) can catch this case and append the
remediation hint, while callers with no such opt-in (``mcp_oauth``,
where endpoint URLs come from untrusted remote-server metadata) keep
catching :class:`OAuthSSRFError` and stay strict.
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -92,13 +103,23 @@ def effective_port(parsed: urllib.parse.ParseResult) -> int | None:
return {"http": 80, "https": 443}.get(parsed.scheme)
def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
def validate_url_no_ssrf(
url: str, *, allow_http: bool, allow_private: bool = False
) -> urllib.parse.ParseResult:
"""Run the scheme/userinfo/SSRF checks shared by issuer and discovered URLs.
Returns the parsed URL on success. Raises :class:`OAuthSSRFError` on
failure. The ``allow_http`` flag is the only knob: when ``True``,
``http://`` is accepted *if* the hostname is also a localhost form;
when ``False``, only ``https://`` is accepted.
failure. Two knobs: ``allow_http=True`` accepts ``http://`` *if* the
hostname is also a localhost form (when ``False``, only ``https://``
is accepted); ``allow_private=True`` accepts hostnames resolving to
private-range addresses (RFC 1918, ULA, CGNAT, loopback) for
operator-trusted URLs — a self-hosted IdP on an internal network.
Even with ``allow_private``, link-local, multicast, unspecified, and
reserved addresses stay refused: cloud metadata services
(169.254.169.254) are the canonical SSRF target, and no legitimate
IdP lives in those ranges. Non-public rejections raise the
:class:`OAuthSSRFPrivateAddressError` subclass so callers that *have*
an opt-in can point the operator at it.
"""
parsed = urllib.parse.urlparse(url)
@@ -127,8 +148,19 @@ def validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseRes
raise OAuthSSRFError(
f"endpoint hostname resolved to invalid IP {sockaddr[0]!r}: {hostname}"
) from exc
if not addr.is_global and not is_localhost(hostname):
raise OAuthSSRFError(f"endpoint URL resolves to non-public address ({addr}): {url}")
if addr.is_global or is_localhost(hostname):
continue
if allow_private:
if addr.is_link_local or addr.is_multicast or addr.is_unspecified or addr.is_reserved:
raise OAuthSSRFError(
f"endpoint URL resolves to a link-local/multicast/reserved "
f"address ({addr}), refused even with private addresses "
f"allowed: {url}"
)
continue
raise OAuthSSRFPrivateAddressError(
f"endpoint URL resolves to non-public address ({addr}): {url}"
)
return parsed
@@ -139,6 +171,7 @@ def validate_discovered_endpoint(
*,
allow_http: bool,
trusted_endpoint_hosts: frozenset[str],
allow_private: bool = False,
) -> None:
"""Validate an endpoint URL pulled from an OIDC/OAuth discovery document.
@@ -146,11 +179,12 @@ def validate_discovered_endpoint(
constraint: the endpoint host must equal the issuer host, be in the
well-known trust map, or be in the operator-supplied
``trusted_endpoint_hosts``. Effective port (with scheme defaults
applied) and scheme must match the issuer.
applied) and scheme must match the issuer. ``allow_private`` forwards
to :func:`validate_url_no_ssrf`.
Raises :class:`OAuthSSRFError` on validation failure.
"""
parsed = validate_url_no_ssrf(url, allow_http=allow_http)
parsed = validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
issuer_hostname = (issuer_parsed.hostname or "").lower()
endpoint_hostname = (parsed.hostname or "").lower()
@@ -182,7 +216,9 @@ def validate_discovered_endpoint(
)
async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
async def validate_url_no_ssrf_async(
url: str, *, allow_http: bool, allow_private: bool = False
) -> urllib.parse.ParseResult:
"""Async variant of :func:`validate_url_no_ssrf` for hot-path callers.
The synchronous variant calls ``socket.getaddrinfo``, which blocks
@@ -191,7 +227,9 @@ async def validate_url_no_ssrf_async(url: str, *, allow_http: bool) -> urllib.pa
:func:`asyncio.to_thread` to keep the loop responsive. This wrapper
centralises that wrapping so callers don't repeat the idiom.
"""
return await asyncio.to_thread(validate_url_no_ssrf, url, allow_http=allow_http)
return await asyncio.to_thread(
validate_url_no_ssrf, url, allow_http=allow_http, allow_private=allow_private
)
async def validate_discovered_endpoint_async(
@@ -200,6 +238,7 @@ async def validate_discovered_endpoint_async(
*,
allow_http: bool,
trusted_endpoint_hosts: frozenset[str],
allow_private: bool = False,
) -> None:
"""Async variant of :func:`validate_discovered_endpoint`."""
await asyncio.to_thread(
@@ -208,12 +247,14 @@ async def validate_discovered_endpoint_async(
issuer_parsed,
allow_http=allow_http,
trusted_endpoint_hosts=trusted_endpoint_hosts,
allow_private=allow_private,
)
__all__ = [
"KNOWN_TRUSTED_OAUTH_ENDPOINT_HOSTS",
"OAuthSSRFError",
"OAuthSSRFPrivateAddressError",
"effective_port",
"is_localhost",
"sanitize_log_text",
+47 -9
View File
@@ -27,6 +27,7 @@ if TYPE_CHECKING:
from turnstone.core.log import get_logger
from turnstone.core.oauth_ssrf import (
OAuthSSRFError,
OAuthSSRFPrivateAddressError,
is_localhost,
)
from turnstone.core.oauth_ssrf import (
@@ -105,7 +106,7 @@ class OIDCConfig:
Startup-config fields (set by :func:`load_oidc_config`):
``enabled``, ``issuer``, ``client_id``, ``client_secret``, ``scopes``,
``provider_name``, ``role_claim``, ``role_map``, ``password_enabled``,
``redirect_base``, ``trusted_endpoint_hosts``.
``redirect_base``, ``trusted_endpoint_hosts``, ``allow_private_network``.
Discovery-derived fields (set by :func:`discover_oidc`; empty before
discovery completes):
@@ -124,6 +125,10 @@ class OIDCConfig:
password_enabled: bool = True
redirect_base: str = ""
trusted_endpoint_hosts: tuple[str, ...] = ()
# Opt-in for self-hosted IdPs on internal networks: permit the issuer
# (and its same-origin discovered endpoints) to resolve to private
# addresses. Link-local/multicast/reserved stay refused regardless.
allow_private_network: bool = False
# Discovered from .well-known/openid-configuration
authorization_endpoint: str = ""
token_endpoint: str = ""
@@ -189,6 +194,9 @@ def load_oidc_config() -> OIDCConfig:
password_enabled = _env_or_cfg_bool(
"TURNSTONE_OIDC_PASSWORD_ENABLED", cfg, "password_enabled", True
)
allow_private_network = _env_or_cfg_bool(
"TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK", cfg, "allow_private_network", False
)
# Role map: env var is "admin:builtin-admin,eng:builtin-operator"
role_map_raw = os.environ.get("TURNSTONE_OIDC_ROLE_MAP", "").strip()
@@ -259,7 +267,12 @@ def load_oidc_config() -> OIDCConfig:
enabled = bool(issuer and client_id and client_secret)
if enabled:
log.info("OIDC enabled: issuer=%s provider=%s", issuer, provider_name)
log.info(
"OIDC enabled: issuer=%s provider=%s%s",
issuer,
provider_name,
" (private-network IdP allowed)" if allow_private_network else "",
)
else:
log.debug("OIDC not configured (issuer/client_id/client_secret incomplete)")
@@ -275,6 +288,7 @@ def load_oidc_config() -> OIDCConfig:
password_enabled=password_enabled,
redirect_base=redirect_base,
trusted_endpoint_hosts=trusted_endpoint_hosts,
allow_private_network=allow_private_network,
)
@@ -287,25 +301,43 @@ def load_oidc_config() -> OIDCConfig:
# ---------------------------------------------------------------------------
def _validate_url_no_ssrf(url: str, *, allow_http: bool) -> urllib.parse.ParseResult:
"""OIDC-flavoured wrapper around :func:`oauth_ssrf.validate_url_no_ssrf`."""
def _validate_url_no_ssrf(
url: str, *, allow_http: bool, allow_private: bool = False
) -> urllib.parse.ParseResult:
"""OIDC-flavoured wrapper around :func:`oauth_ssrf.validate_url_no_ssrf`.
The private-address rejection gets the remediation hint appended: the
login-flow issuer is operator-configured, so pointing the operator at
the ``allow_private_network`` opt-in is safe here (unlike ``mcp_oauth``,
where the URLs come from untrusted remote-server metadata and no such
opt-in exists).
"""
try:
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http)
return _ssrf_validate_url_no_ssrf(url, allow_http=allow_http, allow_private=allow_private)
except OAuthSSRFPrivateAddressError as exc:
raise OIDCError(
f"{exc} — to allow a self-hosted IdP on a private network, set "
"allow_private_network = true in the [oidc] section of config.toml "
"(or TURNSTONE_OIDC_ALLOW_PRIVATE_NETWORK=true)"
) from exc
except OAuthSSRFError as exc:
raise OIDCError(str(exc)) from exc
def validate_issuer_url(url: str) -> None:
def validate_issuer_url(url: str, *, allow_private: bool = False) -> None:
"""Validate an OIDC issuer URL to prevent SSRF.
Rejects:
- Non-HTTPS URLs (except localhost for development)
- URLs with embedded credentials (userinfo)
- Hostnames that resolve to private/internal/loopback IP addresses
- Hostnames that resolve to private/internal/loopback IP addresses,
unless ``allow_private`` is set (the ``allow_private_network``
opt-in for self-hosted IdPs; link-local/multicast/reserved
addresses stay refused regardless)
Raises :class:`OIDCError` on validation failure.
"""
_validate_url_no_ssrf(url, allow_http=True)
_validate_url_no_ssrf(url, allow_http=True, allow_private=allow_private)
def validate_discovered_endpoint(
@@ -314,6 +346,7 @@ def validate_discovered_endpoint(
*,
allow_http: bool,
trusted_endpoint_hosts: frozenset[str],
allow_private: bool = False,
) -> None:
"""Validate an endpoint pulled from an IdP discovery document.
@@ -340,6 +373,7 @@ def validate_discovered_endpoint(
issuer_parsed,
allow_http=allow_http,
trusted_endpoint_hosts=trusted_endpoint_hosts,
allow_private=allow_private,
)
except OAuthSSRFError as exc:
raise OIDCError(str(exc)) from exc
@@ -367,7 +401,9 @@ async def discover_oidc(
return dataclasses.replace(config, enabled=False)
try:
issuer_parsed = _validate_url_no_ssrf(config.issuer, allow_http=True)
issuer_parsed = _validate_url_no_ssrf(
config.issuer, allow_http=True, allow_private=config.allow_private_network
)
except OIDCError as exc:
log.warning("OIDC issuer URL rejected: %s", exc)
return dataclasses.replace(config, enabled=False)
@@ -422,6 +458,7 @@ async def discover_oidc(
issuer_parsed,
allow_http=allow_http,
trusted_endpoint_hosts=trusted_hosts,
allow_private=config.allow_private_network,
)
except OIDCError as exc:
log.warning("OIDC discovered %s rejected (url=%s): %s", name, endpoint_url, exc)
@@ -434,6 +471,7 @@ async def discover_oidc(
issuer_parsed,
allow_http=allow_http,
trusted_endpoint_hosts=trusted_hosts,
allow_private=config.allow_private_network,
)
except OIDCError as exc:
log.warning(