use anyhow::{Context, Result, anyhow};
use openidconnect::{ClientId, IssuerUrl, core::CoreProviderMetadata};
use serde::Deserialize;
use super::{DiscoveryOverrides, OidcConfig};
const ISSUER_ENV: &str = "OIDC_ISSUER";
const CLIENT_ID_ENV: &str = "OIDC_CLIENT_ID";
const AUDIENCE_ENV: &str = "OIDC_AUDIENCE";
#[derive(Debug, Deserialize)]
struct ServerConfig {
oidc: Option<ServerOidcConfig>,
}
#[derive(Debug, Deserialize)]
struct ServerOidcConfig {
issuer: Option<String>,
#[serde(rename = "clientId", alias = "client_id")]
client_id: Option<String>,
audience: Option<String>,
}
pub async fn resolve_config(
server_url: &str,
overrides: &DiscoveryOverrides,
http: &reqwest::Client,
) -> Result<OidcConfig> {
let server_cfg = fetch_server_oidc(server_url, http).await;
let issuer_env = std::env::var(ISSUER_ENV).ok();
let client_id_env = std::env::var(CLIENT_ID_ENV).ok();
let audience_env = std::env::var(AUDIENCE_ENV).ok();
let issuer = pick(
overrides.issuer.as_deref(),
server_cfg.as_ref().and_then(|c| c.issuer.as_deref()),
issuer_env.as_deref(),
)
.ok_or_else(|| {
anyhow!(
"no OIDC issuer found; pass --issuer, configure {ISSUER_ENV}, or point --url at a \
control plane that exposes /config"
)
})?;
let client_id = pick(
overrides.client_id.as_deref(),
server_cfg.as_ref().and_then(|c| c.client_id.as_deref()),
client_id_env.as_deref(),
)
.ok_or_else(|| {
anyhow!("no OIDC client_id found; pass --client-id or configure {CLIENT_ID_ENV}")
})?;
let audience = pick(
overrides.audience.as_deref(),
server_cfg.as_ref().and_then(|c| c.audience.as_deref()),
audience_env.as_deref(),
)
.map(str::to_string);
let issuer_url = IssuerUrl::new(issuer.to_string())
.with_context(|| format!("invalid OIDC issuer URL: {issuer}"))?;
Ok(OidcConfig {
issuer: issuer_url,
client_id: ClientId::new(client_id.to_string()),
audience,
scopes: OidcConfig::default_scopes(),
})
}
pub async fn load_provider_metadata(
cfg: &OidcConfig,
http: &reqwest::Client,
) -> Result<CoreProviderMetadata> {
match CoreProviderMetadata::discover_async(cfg.issuer.clone(), http).await {
Ok(metadata) => Ok(metadata),
Err(err) if is_issuer_mismatch(&err) => {
let toggled = toggle_trailing_slash(cfg.issuer.as_str());
let retry_issuer = openidconnect::IssuerUrl::new(toggled.clone())
.with_context(|| format!("invalid retry issuer URL: {toggled}"))?;
CoreProviderMetadata::discover_async(retry_issuer, http)
.await
.with_context(|| {
format!(
"fetching OIDC discovery document from {}/.well-known/openid-configuration \
(first attempt failed with issuer mismatch; retried with trailing slash \
toggled to {toggled})",
cfg.issuer.as_str().trim_end_matches('/')
)
})
}
Err(err) => Err(anyhow!(
"fetching OIDC discovery document from {}/.well-known/openid-configuration: {err}",
cfg.issuer.as_str().trim_end_matches('/')
)),
}
}
fn is_issuer_mismatch<E: std::fmt::Display>(err: &E) -> bool {
let s = err.to_string().to_ascii_lowercase();
s.contains("issuer")
&& (s.contains("unexpected")
|| s.contains("did not match")
|| s.contains("mismatch")
|| s.contains("expected"))
}
fn toggle_trailing_slash(url: &str) -> String {
if let Some(stripped) = url.strip_suffix('/') {
stripped.to_string()
} else {
format!("{url}/")
}
}
async fn fetch_server_oidc(server_url: &str, http: &reqwest::Client) -> Option<ServerOidcConfig> {
let url = join_config_url(server_url).ok()?;
let resp = http.get(url).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let cfg: ServerConfig = resp.json().await.ok()?;
cfg.oidc
}
fn join_config_url(server_url: &str) -> Result<url::Url> {
let trimmed = server_url.trim_end_matches('/');
let joined = format!("{trimmed}/config");
url::Url::parse(&joined).with_context(|| format!("invalid server URL: {server_url}"))
}
fn pick<'a>(
flag: Option<&'a str>,
server: Option<&'a str>,
env: Option<&'a str>,
) -> Option<&'a str> {
[flag, server, env]
.into_iter()
.flatten()
.find(|s| !s.is_empty())
}
pub fn http_client() -> Result<reqwest::Client> {
reqwest::Client::builder()
.user_agent(concat!("boxlite-cli/", env!("CARGO_PKG_VERSION")))
.timeout(std::time::Duration::from_secs(20))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| anyhow!("building HTTP client: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn pick_prefers_flag_over_server_and_env() {
assert_eq!(
pick(Some("flag"), Some("server"), Some("env")),
Some("flag")
);
}
#[test]
fn pick_falls_through_empty_strings() {
assert_eq!(pick(Some(""), Some("server"), Some("env")), Some("server"));
assert_eq!(pick(None, Some(""), Some("env")), Some("env"));
assert_eq!(pick(None, None, Some("")), None);
}
#[test]
fn join_config_url_appends_to_prefix() {
let u = join_config_url("https://api.boxlite.ai/api").unwrap();
assert_eq!(u.as_str(), "https://api.boxlite.ai/api/config");
}
#[test]
fn join_config_url_strips_trailing_slash() {
let u = join_config_url("https://api.boxlite.ai/api/").unwrap();
assert_eq!(u.as_str(), "https://api.boxlite.ai/api/config");
}
#[test]
fn toggle_trailing_slash_round_trips() {
let no_slash = "https://issuer.example.com";
let with_slash = "https://issuer.example.com/";
assert_eq!(toggle_trailing_slash(no_slash), with_slash);
assert_eq!(toggle_trailing_slash(with_slash), no_slash);
assert_eq!(
toggle_trailing_slash(&toggle_trailing_slash(no_slash)),
no_slash
);
}
#[test]
fn is_issuer_mismatch_recognizes_openidconnect_error_shape() {
struct E(&'static str);
impl std::fmt::Display for E {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.0)
}
}
assert!(is_issuer_mismatch(&E(
"Validation error: unexpected issuer URI `https://x/` (expected `https://x`)"
)));
assert!(is_issuer_mismatch(&E(
"Issuer URL in provider metadata did not match expected value"
)));
assert!(!is_issuer_mismatch(&E("connection refused")));
assert!(!is_issuer_mismatch(&E("invalid JSON in discovery doc")));
}
}