From 8ff5fce1aa7807e680e4a48dcbc732b7f245d737 Mon Sep 17 00:00:00 2001 From: Alex Oliveira Date: Wed, 21 Oct 2026 13:46:43 +0000 Subject: [PATCH] feat(oidc): allow internal service transport --- CONTRIBUTING.md | 2 +- cli-framework-oidc/README.md | 7 + cli-framework-oidc/src/client/mod.rs | 161 +++++++++++++++++++- cli-framework-oidc/src/endpoint_security.rs | 45 ++++++ cli-framework-oidc/tests/client_flows.rs | 46 ++++++ skill/references/auth-and-oidc.md | 15 +- 6 files changed, 266 insertions(+), 10 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f7bcf1f..389313f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -54,7 +54,7 @@ chmod +x .git/hooks/pre-commit | `telemetry` (feature `telemetry`) | OpenTelemetry via the `tracing`→OTLP bridge (ADR 0068, spec 017/019/020). `TelemetryConfig`, `init_simple`/`init_batch`/`init_batch_without_subscriber`, `TelemetryGuard`, `Telemetry` handle + always-compiled `NoopTelemetry`. Auto `cli.command` spans (CLI dispatch, `version`, and `dispatch_tool_call`/MCP seams) + `cli.command.invocations`/`cli.command.duration_ms` metrics. `cli_framework::telemetry::propagation` extracts inbound W3C context in `ApiServerBuilder` and exposes `TracedRequestBuilder` for outbound injection. Spec 025 adds the telemetry level/attribution/probe axes (`axes`, `probe`, `probes`), deterministic policy resolution with kill switches and the end-user clamp (`policy`), the framework-owned settings store (`//telemetry.`, ADR 0077 — `store`), the generated `telemetry` manifest section and `_TELEMETRY_*` env layer (`manifest`, `env`), the redacting export boundary (ADR 0079 — `redact`, `exporter`, `resource`, `init::init_from_policy`, `init::sampler_for_policy`), subscriber composition through a write-once attach slot with a foreign-subscriber warning (ADR 0078 — `subscriber::install_default_logging`, `LoggingGuard`), the panic hook (`panic`), the first-run notice (`notice::notice_decision`, `startup`), six `doctor` checks (`doctor::telemetry_checks`: `telemetry.subscriber`, `telemetry.store`, `telemetry.endpoint`, `telemetry.policy`, `telemetry.identity`, `telemetry.env`), the bounded end-user flush budget (`guard::flush_within`) and the end-user Built-in `telemetry` command group (`status`/`info`/`set`/`enable`/`disable`/`reset` — `commands`), registered only for an `EndUser` deployment. App authors configure it through `AppBuilder`: `with_deployment`, `with_telemetry_defaults` (`author::TelemetryDefaults`), `with_telemetry_ops`, `with_telemetry_identity`, `with_telemetry_attrs`, `with_telemetry_never`; `with_telemetry(TelemetryConfig)` and `TelemetryConfig::from_env` are deprecated in v0.6.0 and removed in v0.8.0. End-user and operator documentation lives in [docs/telemetry.md](docs/telemetry.md). | | `self_install` (feature `self-install`) | ADR 0080. `InstallEnv` snapshots the process (OS, home, XDG/known-folder roots, PATH, env vars, TTY) so every operation is testable without touching the real machine. `options` (`SelfInstallOptions`), `layout` (bin dir, receipt path, asset names), `receipt` (`InstallReceipt`, versioned JSON), `method` (script / unmanaged / package-manager inference), `place` (same-directory temp + rename, Windows `.exe.old` swap, `startup_cleanup`), `shell_path` (shared `env`/`env.fish`, rc lines in existing files only), `windows_path` (`HKCU\Environment` `Path` + `WM_SETTINGCHANGE`), `ops` (`install`/`uninstall`/`status`, `--system`, `--from`), `release` (GitHub and HTTP sources, channels, downloads with the https/loopback rule), `archive` (`SHA256SUMS`, minisign over the sums, safe tar.gz/zip extraction, `--version` probe), `update` (`self update`/`self rollback`, bin-dir lock and work dir, `.prev`), `policy` (`self_update.*` enforced keys), `notice` (post-command update notice, daily cache; `App::run_with_args` runs it after the command so the telemetry level is known), `apps_features` (Windows `HKCU` Uninstall entry), `commands` (the `self` group, registered by `AppBuilder::with_self_install` for `EndUser` only) and `doctor` (`install.*` checks). Installer script templates live in `skill/references/install-templates/`; `scripts/ci/self-install-e2e.{sh,ps1}` test them against a loopback release, then update to a second demo build (`CFW_DEMO_VERSION`) and roll back. | -Also: `auth` (feature `auth`) — `TokenProvider` trait, `AccessToken`, `AuthError`, `AuthenticatedHttpClient`, four auto-registered `auth` commands; companion crate `cli-framework-oidc` provides `OidcClient` (feature `client`) and `oidc_validation_layer` / `oidc_validation_layer_multi` (feature `server`, one or several trusted issuers); `data_source`; `observability`, `testkit` behind features; `config` (feature `config`) — `ConfigBackend`/`ConfigStore`/`ConfigHandle` (spec 016); `FileBackend` always, `RegistryBackend` on Windows only; `config::manifest`/`Policy`/`resolution` (feature `config`, spec 021) — the plain-JSON config manifest (+ `#[derive(ConfigManifest)]`), the org `Policy` document, and the enforced-veto resolver, all networking-free; `config::managed` (feature `config-managed`, implies `config`+`auth`) — `PolicyClient`/`RoamingConfigClient` over `AuthenticatedHttpClient`; four auto-registered `config` commands (`show`/`manifest`/`profile`/`refresh`, feature `config-managed`) — spec 021's "Command surface", wired via `AppBuilder::with_config_manifest`/`with_policy_client` and `AppContext::opt_config_manifest`/`opt_policy_client`; `config::service` (feature `config-service`, implies `config`+`api-server`, spec 022) — the server side: `config_service_router` (self-authenticating via a crate-local `CallerIdentity` trait, never `ApiServerBuilder::auth()`), `PolicyStore`/`UserConfigStore` traits with `FsPolicyStore`/`InMemoryUserConfigStore` (tests/dev) and `PgPolicyStore`/`PgUserConfigStore` (`sqlx-core`+`sqlx-postgres` directly, never the `sqlx` facade) implementations, plus a hand-rolled SQL migration runner. +Also: `auth` (feature `auth`) — `TokenProvider` trait, `AccessToken`, `AuthError`, `AuthenticatedHttpClient`, four auto-registered `auth` commands; companion crate `cli-framework-oidc` provides `OidcClient` (feature `client`) and `oidc_validation_layer` / `oidc_validation_layer_multi` (feature `server`, one or several trusted issuers). Its client can route discovery and token requests through validated deployment-owned endpoints while preserving the public issuer as token identity; `data_source`; `observability`, `testkit` behind features; `config` (feature `config`) — `ConfigBackend`/`ConfigStore`/`ConfigHandle` (spec 016); `FileBackend` always, `RegistryBackend` on Windows only; `config::manifest`/`Policy`/`resolution` (feature `config`, spec 021) — the plain-JSON config manifest (+ `#[derive(ConfigManifest)]`), the org `Policy` document, and the enforced-veto resolver, all networking-free; `config::managed` (feature `config-managed`, implies `config`+`auth`) — `PolicyClient`/`RoamingConfigClient` over `AuthenticatedHttpClient`; four auto-registered `config` commands (`show`/`manifest`/`profile`/`refresh`, feature `config-managed`) — spec 021's "Command surface", wired via `AppBuilder::with_config_manifest`/`with_policy_client` and `AppContext::opt_config_manifest`/`opt_policy_client`; `config::service` (feature `config-service`, implies `config`+`api-server`, spec 022) — the server side: `config_service_router` (self-authenticating via a crate-local `CallerIdentity` trait, never `ApiServerBuilder::auth()`), `PolicyStore`/`UserConfigStore` traits with `FsPolicyStore`/`InMemoryUserConfigStore` (tests/dev) and `PgPolicyStore`/`PgUserConfigStore` (`sqlx-core`+`sqlx-postgres` directly, never the `sqlx` facade) implementations, plus a hand-rolled SQL migration runner. **Flow:** `AppBuilder` registers commands, groups, help ordering, and built-ins into one registry → `run` resolves id + `CommandArgs` → `await` `execute` on `AppContext`. Help, completion, command-spec export, chat, and MCP read the same frozen registry. Tool surfaces (chat / MCP) adapt inputs into `command_surface::tool_bridge` for shared parsing/validation/gating/dispatch. diff --git a/cli-framework-oidc/README.md b/cli-framework-oidc/README.md index 137cb27..997d6f3 100644 --- a/cli-framework-oidc/README.md +++ b/cli-framework-oidc/README.md @@ -46,6 +46,13 @@ async fn main() -> anyhow::Result<()> { } ``` +Cluster workloads may set `.discovery_url(...)` and +`.token_endpoint_url(...)` to route those requests through an internal +Service while `issuer_url` remains the public token identity. The discovery +document must still declare that exact issuer. Overrides require HTTPS, or +HTTP to loopback or a Kubernetes `.svc` name; pair an in-cluster override with +an exact NetworkPolicy lane. + ### Supported flows | Flow | `OidcFlow` variant | When to use | diff --git a/cli-framework-oidc/src/client/mod.rs b/cli-framework-oidc/src/client/mod.rs index 63462ec..df7316c 100644 --- a/cli-framework-oidc/src/client/mod.rs +++ b/cli-framework-oidc/src/client/mod.rs @@ -93,6 +93,8 @@ struct DiscoveryDoc { pub struct OidcClient { issuer_url: String, + discovery_url: Option, + token_endpoint_url: Option, client_id: String, flow: OidcFlow, scopes: Option>, @@ -176,7 +178,14 @@ impl OidcClient { async fn get_discovery(&self) -> Result<&DiscoveryDoc, cli_framework::auth::AuthError> { self.discovery - .get_or_try_init(|| fetch_discovery(&self.issuer_url, &self.http)) + .get_or_try_init(|| { + fetch_discovery( + &self.issuer_url, + self.discovery_url.as_deref(), + self.token_endpoint_url.as_deref(), + &self.http, + ) + }) .await } @@ -752,6 +761,8 @@ impl cli_framework::auth::TokenProvider for OidcClient { pub struct OidcClientBuilder { issuer_url: Option, + discovery_url: Option, + token_endpoint_url: Option, client_id: Option, flow: Option, scopes: Option>, @@ -768,6 +779,8 @@ impl OidcClientBuilder { fn new() -> Self { Self { issuer_url: None, + discovery_url: None, + token_endpoint_url: None, client_id: None, flow: None, scopes: None, @@ -787,6 +800,8 @@ impl OidcClientBuilder { /// |-----|----------|---------| /// | `{PREFIX}_ISSUER_URL` | yes | OIDC issuer / Keycloak realm URL | /// | `{PREFIX}_CLIENT_ID` | yes | OAuth client id | + /// | `{PREFIX}_DISCOVERY_URL` | no | Deployment-owned discovery transport; the document must still name `ISSUER_URL` | + /// | `{PREFIX}_TOKEN_ENDPOINT_URL` | no | Deployment-owned token transport; issuer identity is unchanged | /// | `{PREFIX}_CLIENT_SECRET` | no | Confidential-client secret (implies Client Credentials) | /// | `{PREFIX}_FLOW` | no | `device` \| `pkce` \| `client-credentials` \| `auto` | /// | `{PREFIX}_SCOPES` | no | Space- or comma-separated scopes | @@ -805,6 +820,8 @@ impl OidcClientBuilder { let issuer_url = var("ISSUER_URL").ok_or(OidcConfigError::MissingField("ISSUER_URL (env)"))?; let client_id = var("CLIENT_ID").ok_or(OidcConfigError::MissingField("CLIENT_ID (env)"))?; + let discovery_url = var("DISCOVERY_URL"); + let token_endpoint_url = var("TOKEN_ENDPOINT_URL"); let secret = var("CLIENT_SECRET"); let flow_kind = var("FLOW"); @@ -839,6 +856,12 @@ impl OidcClientBuilder { .issuer_url(issuer_url) .client_id(client_id) .flow(flow); + if let Some(url) = discovery_url { + builder = builder.discovery_url(url); + } + if let Some(url) = token_endpoint_url { + builder = builder.token_endpoint_url(url); + } if let Some(raw) = var("SCOPES") { let scopes: Vec = raw .split([',', ' ']) @@ -858,6 +881,22 @@ impl OidcClientBuilder { self } + /// Fetch discovery through this deployment-owned endpoint while keeping + /// `issuer_url` as the token identity. The returned document must still + /// declare the exact configured issuer. + pub fn discovery_url(mut self, url: impl Into) -> Self { + self.discovery_url = Some(url.into()); + self + } + + /// Send token grants to this deployment-owned endpoint instead of the + /// endpoint advertised by discovery. This changes transport only; cache + /// identity and issuer validation continue to use `issuer_url`. + pub fn token_endpoint_url(mut self, url: impl Into) -> Self { + self.token_endpoint_url = Some(url.into()); + self + } + pub fn client_id(mut self, id: impl Into) -> Self { self.client_id = Some(id.into()); self @@ -932,6 +971,14 @@ impl OidcClientBuilder { crate::endpoint_security::secure_issuer(raw_issuer) .map_err(|()| OidcConfigError::InsecureIssuer("unsafe issuer URL".to_owned()))?; let issuer_url = crate::normalize_issuer(raw_issuer)?; + let discovery_url = self + .discovery_url + .map(|url| validate_transport_override("discovery_url", url)) + .transpose()?; + let token_endpoint_url = self + .token_endpoint_url + .map(|url| validate_transport_override("token_endpoint_url", url)) + .transpose()?; let client_id = self .client_id .ok_or(OidcConfigError::MissingField("client_id"))?; @@ -952,6 +999,8 @@ impl OidcClientBuilder { Ok(OidcClient { issuer_url, + discovery_url, + token_endpoint_url, client_id, flow, scopes: self.scopes, @@ -1061,9 +1110,13 @@ fn make_http_client() -> Result { async fn fetch_discovery( issuer_url: &str, + discovery_url: Option<&str>, + token_endpoint_url: Option<&str>, http: &reqwest::Client, ) -> Result { - let url = format!("{}/.well-known/openid-configuration", issuer_url); + let url = discovery_url + .map(str::to_owned) + .unwrap_or_else(|| format!("{}/.well-known/openid-configuration", issuer_url)); let resp = http.get(&url) .send() @@ -1089,7 +1142,10 @@ async fn fetch_discovery( return Err(discovery_failure("OIDC discovery issuer mismatch")); } - let token_endpoint = required_discovered_endpoint(&doc, "token_endpoint")?; + let discovered_token_endpoint = required_discovered_endpoint(&doc, "token_endpoint")?; + let token_endpoint = token_endpoint_url + .map(str::to_owned) + .unwrap_or(discovered_token_endpoint); let device_authorization_endpoint = optional_discovered_endpoint(&doc, "device_authorization_endpoint")?; let authorization_endpoint = optional_discovered_endpoint(&doc, "authorization_endpoint")?; @@ -1100,6 +1156,18 @@ async fn fetch_discovery( }) } +fn validate_transport_override( + field: &'static str, + raw: String, +) -> Result { + crate::endpoint_security::secure_transport_endpoint(&raw).map_err(|()| { + OidcConfigError::InvalidFlow(format!( + "{field} must be absolute HTTPS, or HTTP to loopback or a Kubernetes .svc host" + )) + })?; + Ok(raw) +} + /// Read and validate a required metadata endpoint before any credential exists. fn required_discovered_endpoint( document: &serde_json::Value, @@ -1224,7 +1292,9 @@ mod discovery_security_tests { .mount(&server) .await; - let discovered = fetch_discovery(&server.uri(), &http).await.unwrap(); + let discovered = fetch_discovery(&server.uri(), None, None, &http) + .await + .unwrap(); assert!(discovered.token_endpoint.ends_with("/token?tenant=one")); assert!(discovered .authorization_endpoint @@ -1240,6 +1310,64 @@ mod discovery_security_tests { assert!(error.to_string().contains("omitted a required endpoint")); } + #[tokio::test] + async fn discovery_transport_override_does_not_override_issuer_identity() { + let transport = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/internal-discovery")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": "https://wrong.example/realms/rudaia", + "token_endpoint": "https://auth.example/realms/rudaia/token", + }))) + .mount(&transport) + .await; + + let error = match fetch_discovery( + "https://auth.example/realms/rudaia", + Some(&format!("{}/internal-discovery", transport.uri())), + Some(&format!("{}/internal-token", transport.uri())), + &make_http_client().unwrap(), + ) + .await + { + Ok(_) => panic!("an internal transport redefined issuer identity"), + Err(error) => error, + }; + assert_eq!( + error.to_string(), + "authentication provider error: OIDC discovery issuer mismatch" + ); + } + + #[test] + fn builder_rejects_unsafe_transport_overrides() { + let cache = tempfile::tempdir().unwrap(); + for unsafe_url in [ + "http://identity.example/token", + "http://10.0.0.1/token", + "https://user:secret@identity.example/token", + "file:///tmp/token", + ] { + let error = match test_builder("https://auth.example/realms/rudaia", cache.path()) + .discovery_url(unsafe_url) + .build() + { + Ok(_) => panic!("unsafe discovery transport was accepted"), + Err(error) => error, + }; + assert!(error.to_string().contains("discovery_url")); + + let error = match test_builder("https://auth.example/realms/rudaia", cache.path()) + .token_endpoint_url(unsafe_url) + .build() + { + Ok(_) => panic!("unsafe token transport was accepted"), + Err(error) => error, + }; + assert!(error.to_string().contains("token_endpoint_url")); + } + } + #[test] fn discovery_endpoint_validation_fails_closed_without_reflecting_urls() { for endpoint in [ @@ -1282,10 +1410,11 @@ mod discovery_security_tests { .mount(&issuer) .await; - let error = match fetch_discovery(&issuer.uri(), &make_http_client().unwrap()).await { - Ok(_) => panic!("redirected discovery must fail closed"), - Err(error) => error, - }; + let error = + match fetch_discovery(&issuer.uri(), None, None, &make_http_client().unwrap()).await { + Ok(_) => panic!("redirected discovery must fail closed"), + Err(error) => error, + }; assert_eq!( error.to_string(), "authentication provider error: OIDC discovery returned a non-success status" @@ -1568,8 +1697,24 @@ mod flow_contract_tests { std::env::set_var(format!("{pkce}_CLIENT_ID"), "native"); std::env::set_var(format!("{pkce}_FLOW"), "pkce"); std::env::set_var(format!("{pkce}_SCOPES"), "openid, profile email"); + std::env::set_var( + format!("{pkce}_DISCOVERY_URL"), + "http://identity.identity.svc/realms/r/.well-known/openid-configuration", + ); + std::env::set_var( + format!("{pkce}_TOKEN_ENDPOINT_URL"), + "http://identity.identity.svc/realms/r/token", + ); let builder = OidcClientBuilder::from_env(pkce).unwrap(); assert_eq!(builder.scopes.unwrap().len(), 3); + assert_eq!( + builder.discovery_url.as_deref(), + Some("http://identity.identity.svc/realms/r/.well-known/openid-configuration") + ); + assert_eq!( + builder.token_endpoint_url.as_deref(), + Some("http://identity.identity.svc/realms/r/token") + ); let missing_secret = "CFW_TEST_OIDC_CC_MISSING_SECRET"; std::env::set_var( diff --git a/cli-framework-oidc/src/endpoint_security.rs b/cli-framework-oidc/src/endpoint_security.rs index f2741d1..7b25cc3 100644 --- a/cli-framework-oidc/src/endpoint_security.rs +++ b/cli-framework-oidc/src/endpoint_security.rs @@ -26,6 +26,29 @@ pub(crate) fn secure_endpoint(raw: &str) -> Result { Ok(url) } +/// Parse an operator-configured in-cluster transport endpoint. +/// +/// This is deliberately separate from [`secure_endpoint`], which validates +/// endpoints learned from an untrusted discovery document and therefore +/// permits plain HTTP only on loopback. A deployment-owned override may also +/// use plain HTTP for a Kubernetes Service DNS name. NetworkPolicy supplies +/// the corresponding transport boundary; arbitrary external HTTP remains +/// forbidden. +pub(crate) fn secure_transport_endpoint(raw: &str) -> Result { + let url = Url::parse(raw).map_err(|_| ())?; + let host = url.host_str().ok_or(())?; + let loopback = matches!(host, "127.0.0.1" | "localhost" | "[::1]"); + let cluster_service = host.ends_with(".svc") || host.ends_with(".svc.cluster.local"); + if !(url.scheme() == "https" || url.scheme() == "http" && (loopback || cluster_service)) + || !url.username().is_empty() + || url.password().is_some() + || url.fragment().is_some() + { + return Err(()); + } + Ok(url) +} + /// Apply [`secure_endpoint`] and additionally reject issuer queries. /// /// Issuer queries are not part of the normalized issuer identity and must not @@ -75,4 +98,26 @@ mod tests { assert!(secure_issuer(invalid).is_err(), "{invalid}"); } } + + #[test] + fn deployment_transport_allows_only_https_loopback_or_kubernetes_services() { + for valid in [ + "https://identity.example/realms/r/.well-known/openid-configuration", + "http://127.0.0.1:8080/token", + "http://keycloak.keycloak.svc/realms/r/token", + "http://keycloak.keycloak.svc.cluster.local:8080/realms/r/token", + ] { + assert_eq!(secure_transport_endpoint(valid).unwrap().as_str(), valid); + } + for invalid in [ + "http://identity.example/token", + "http://10.0.0.1/token", + "ftp://keycloak.keycloak.svc/token", + "https://user:secret@identity.example/token", + "https://identity.example/token#fragment", + "relative", + ] { + assert!(secure_transport_endpoint(invalid).is_err(), "{invalid}"); + } + } } diff --git a/cli-framework-oidc/tests/client_flows.rs b/cli-framework-oidc/tests/client_flows.rs index 71742be..7457076 100644 --- a/cli-framework-oidc/tests/client_flows.rs +++ b/cli-framework-oidc/tests/client_flows.rs @@ -88,6 +88,52 @@ async fn cc_post_acquires_token() { assert_eq!(token.as_bearer(), "test-access-token"); } +#[tokio::test] +async fn cc_can_use_internal_discovery_and_token_transport_without_changing_issuer() { + let transport = MockServer::start().await; + let public_issuer = "https://auth.example/realms/rudaia"; + + Mock::given(method("GET")) + .and(path("/internal-discovery")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "issuer": public_issuer, + "token_endpoint": "https://auth.example/realms/rudaia/protocol/openid-connect/token", + }))) + .expect(1) + .mount(&transport) + .await; + Mock::given(method("POST")) + .and(path("/internal-token")) + .and(body_string_contains("grant_type=client_credentials")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "internal-transport-token", + "token_type": "Bearer", + "expires_in": 3600, + }))) + .expect(1) + .mount(&transport) + .await; + + let dir = TempDir::new().unwrap(); + let client = OidcClient::builder() + .issuer_url(public_issuer) + .discovery_url(format!("{}/internal-discovery", transport.uri())) + .token_endpoint_url(format!("{}/internal-token", transport.uri())) + .client_id("billing-product-data") + .flow(OidcFlow::ClientCredentials { + client_secret: make_secret("s3cr3t"), + token_auth: TokenAuthMethod::Post, + }) + .cache_dir(dir.path().to_path_buf()) + .build() + .unwrap(); + + assert_eq!(client.issuer_url(), public_issuer); + let token = client.token().await.expect("token over internal transport"); + assert_eq!(token.as_bearer(), "internal-transport-token"); + transport.verify().await; +} + #[tokio::test] async fn cc_basic_auth_acquires_token() { let server = MockServer::start().await; diff --git a/skill/references/auth-and-oidc.md b/skill/references/auth-and-oidc.md index 5992c00..93853f8 100644 --- a/skill/references/auth-and-oidc.md +++ b/skill/references/auth-and-oidc.md @@ -76,11 +76,24 @@ let client = OidcClient::builder() `issuer_url` is what Keycloak calls the **Realm URL** — it ends with `/realms/`. The client fetches `/.well-known/openid-configuration` on first use (lazy, cached for the client's lifetime). +Cluster workloads may keep that public issuer identity while routing discovery and confidential token grants through an internal Service: + +```rust +let client = OidcClient::builder() + .issuer_url("https://auth.example.com/realms/my-realm") + .discovery_url("http://keycloak.keycloak.svc.cluster.local:8080/realms/my-realm/.well-known/openid-configuration") + .token_endpoint_url("http://keycloak.keycloak.svc.cluster.local:8080/realms/my-realm/protocol/openid-connect/token") + // client_id, flow, cache... + .build()?; +``` + +The discovery document must still name the exact public issuer. These two settings change transport only. Explicit overrides require HTTPS, except that HTTP is accepted for loopback and Kubernetes `.svc` names so a NetworkPolicy can provide the in-cluster boundary. + **`cache_dir` is optional.** Set `.app_name("my-app")` and the cache defaults to `/cli-framework-oidc/my-app` — no `dirs` dependency or path-building in your app. Pass `.cache_dir(path)` only to override. ### Config from environment -Skip hand-wiring entirely — `from_env("PREFIX")` reads `{PREFIX}_ISSUER_URL`, `{PREFIX}_CLIENT_ID`, `{PREFIX}_CLIENT_SECRET` (optional), `{PREFIX}_FLOW` (optional), `{PREFIX}_SCOPES` (optional): +Skip hand-wiring entirely — `from_env("PREFIX")` reads `{PREFIX}_ISSUER_URL`, `{PREFIX}_CLIENT_ID`, `{PREFIX}_CLIENT_SECRET` (optional), `{PREFIX}_FLOW` (optional), `{PREFIX}_SCOPES` (optional), `{PREFIX}_DISCOVERY_URL` (optional), and `{PREFIX}_TOKEN_ENDPOINT_URL` (optional): ```rust use cli_framework_oidc::client::OidcClientBuilder;