Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (`<config_dir>/<app>/telemetry.<json\|toml>`, ADR 0077 — `store`), the generated `telemetry` manifest section and `<APP>_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.

Expand Down
7 changes: 7 additions & 0 deletions cli-framework-oidc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
161 changes: 153 additions & 8 deletions cli-framework-oidc/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ struct DiscoveryDoc {

pub struct OidcClient {
issuer_url: String,
discovery_url: Option<String>,
token_endpoint_url: Option<String>,
client_id: String,
flow: OidcFlow,
scopes: Option<Vec<String>>,
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -752,6 +761,8 @@ impl cli_framework::auth::TokenProvider for OidcClient {

pub struct OidcClientBuilder {
issuer_url: Option<String>,
discovery_url: Option<String>,
token_endpoint_url: Option<String>,
client_id: Option<String>,
flow: Option<OidcFlow>,
scopes: Option<Vec<String>>,
Expand All @@ -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,
Expand All @@ -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 |
Expand All @@ -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");

Expand Down Expand Up @@ -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<String> = raw
.split([',', ' '])
Expand All @@ -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<String>) -> 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<String>) -> Self {
self.token_endpoint_url = Some(url.into());
self
}

pub fn client_id(mut self, id: impl Into<String>) -> Self {
self.client_id = Some(id.into());
self
Expand Down Expand Up @@ -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"))?;
Expand All @@ -952,6 +999,8 @@ impl OidcClientBuilder {

Ok(OidcClient {
issuer_url,
discovery_url,
token_endpoint_url,
client_id,
flow,
scopes: self.scopes,
Expand Down Expand Up @@ -1061,9 +1110,13 @@ fn make_http_client() -> Result<reqwest::Client, OidcConfigError> {

async fn fetch_discovery(
issuer_url: &str,
discovery_url: Option<&str>,
token_endpoint_url: Option<&str>,
http: &reqwest::Client,
) -> Result<DiscoveryDoc, cli_framework::auth::AuthError> {
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()
Expand All @@ -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")?;
Expand All @@ -1100,6 +1156,18 @@ async fn fetch_discovery(
})
}

fn validate_transport_override(
field: &'static str,
raw: String,
) -> Result<String, OidcConfigError> {
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,
Expand Down Expand Up @@ -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
Expand All @@ -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 [
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading