diff --git a/docs/oidc.md b/docs/oidc.md index 5caea75e..5f8e4ef3 100644 --- a/docs/oidc.md +++ b/docs/oidc.md @@ -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" diff --git a/tests/test_oauth_ssrf.py b/tests/test_oauth_ssrf.py index 7cd20636..d1551125 100644 --- a/tests/test_oauth_ssrf.py +++ b/tests/test_oauth_ssrf.py @@ -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): diff --git a/tests/test_oidc.py b/tests/test_oidc.py index 80540438..ef1bdbe7 100644 --- a/tests/test_oidc.py +++ b/tests/test_oidc.py @@ -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( diff --git a/turnstone/core/oauth_ssrf.py b/turnstone/core/oauth_ssrf.py index 0424dbe9..aa65f9b3 100644 --- a/turnstone/core/oauth_ssrf.py +++ b/turnstone/core/oauth_ssrf.py @@ -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", diff --git a/turnstone/core/oidc.py b/turnstone/core/oidc.py index f95584dd..6d226c33 100644 --- a/turnstone/core/oidc.py +++ b/turnstone/core/oidc.py @@ -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(