use anyhow::Result;
use kunobi_auth::client::{AuthClient, ServiceConfig, TofuResult, TofuStore};
use super::config::{AuthMode, CliConfig};
const DEVICE_GRANT_SCOPE: &str = "openid profile email offline_access";
pub async fn login(
context_override: Option<&str>,
endpoint_override: Option<&str>,
device: bool,
retrust: bool,
) -> Result<()> {
let config = CliConfig::load()?;
let config = config.resolve(context_override, endpoint_override)?;
let endpoint = config.endpoint.as_str();
if config.auth != AuthMode::Oidc {
println!(
"Context uses auth={}. Browser/device login is only needed for auth=oidc.",
config.auth
);
return Ok(());
}
println!("Discovering auth configuration from {endpoint}...");
let service_config = if retrust {
let service_config = kunobi_auth::client::discover_unpinned(endpoint).await?;
if let Some(change) = retrust_pin(&TofuStore::new()?, &service_config)? {
eprintln!("{change}");
}
service_config
} else {
discover_pinned(endpoint).await?
};
let client = AuthClient::new(service_config)?;
if device {
println!("Starting device authorization flow...");
client
.device_login(DEVICE_GRANT_SCOPE, |prompt| {
eprintln!();
if let Some(complete) = &prompt.verification_uri_complete {
eprintln!(" Open this URL on any browser:");
eprintln!(" {complete}");
eprintln!();
eprintln!(
" Or visit {} and enter code: {}",
prompt.verification_uri, prompt.user_code
);
} else {
eprintln!(" Open this URL on any browser:");
eprintln!(" {}", prompt.verification_uri);
eprintln!();
eprintln!(" Then enter code: {}", prompt.user_code);
}
eprintln!();
eprintln!(
" Code expires in {} seconds. Polling…",
prompt.expires_in.as_secs()
);
eprintln!();
})
.await?;
} else {
println!("Opening browser for authentication...");
client.login().await?;
}
println!("Authenticated successfully!");
Ok(())
}
pub async fn logout(context_override: Option<&str>, endpoint_override: Option<&str>) -> Result<()> {
let config = CliConfig::load()?;
let config = config.resolve(context_override, endpoint_override)?;
let endpoint = config.endpoint.as_str();
if config.auth != AuthMode::Oidc {
println!(
"Context uses auth={}. Browser logout is only needed for auth=oidc.",
config.auth
);
return Ok(());
}
let service_config = discover_pinned(endpoint).await?;
let client = AuthClient::new(service_config)?;
client.logout_async().await?;
println!("Logged out (token revoked at IdP).");
Ok(())
}
fn retrust_pin(store: &TofuStore, service_config: &ServiceConfig) -> Result<Option<String>> {
let endpoint = &service_config.endpoint;
let issuer = &service_config.issuer;
let audience = service_config.audience.as_deref().unwrap_or("");
let change = match store.verify(endpoint, issuer, audience)? {
TofuResult::Trusted => return Ok(None),
TofuResult::FirstConnect { .. } => None,
TofuResult::IssuerChanged {
previous, current, ..
} => Some(format!(
"Re-pinned the auth issuer for {endpoint}: {previous:?} -> {current:?} \
(audience now {audience:?})"
)),
TofuResult::AudienceChanged {
previous, current, ..
} => Some(format!(
"Re-pinned the auth audience for {endpoint}: {previous:?} -> {current:?}"
)),
other => anyhow::bail!("unexpected TOFU result for {endpoint}: {other:?}"),
};
store.trust(endpoint, issuer, audience)?;
Ok(change)
}
pub(crate) async fn discover_pinned(endpoint: &str) -> Result<ServiceConfig> {
ServiceConfig::discover(endpoint)
.await
.map_err(retrust_hint)
}
fn retrust_hint(error: anyhow::Error) -> anyhow::Error {
let message = error.to_string();
let Some(detail) = message.strip_prefix("TOFU: ") else {
return error;
};
let detail = [
". If this change is expected",
"; call trust()",
"; re-run trust()",
]
.iter()
.find_map(|hint| detail.split_once(hint).map(|(kept, _)| kept))
.unwrap_or(detail);
anyhow::anyhow!(
"{detail}. If the server's auth changed on purpose, for example from SSH keys to OIDC, \
run `kobe login --retrust`"
)
}
#[cfg(test)]
mod tests {
#[test]
fn a_pin_mismatch_names_kobe_login_retrust_instead_of_trust() {
for (library, kept) in [
(
"TOFU: audience changed for https://kobe.example (possible MITM): pinned \"kobe-system\", presented \"\". If this change is expected, re-establish trust explicitly with trust()",
"audience changed for https://kobe.example (possible MITM): pinned \"kobe-system\", presented \"\"",
),
(
"TOFU: issuer changed for https://kobe.example (possible MITM): pinned \"ssh\", presented \"https://clerk.example\". If this change is expected, re-establish trust explicitly with trust()",
"issuer changed for https://kobe.example (possible MITM): pinned \"ssh\", presented \"https://clerk.example\"",
),
(
"TOFU: refusing to trust unpinned service https://kobe.example; call trust() to establish first-use trust",
"refusing to trust unpinned service https://kobe.example",
),
] {
let message = super::retrust_hint(anyhow::anyhow!(library)).to_string();
assert!(message.starts_with(kept), "{message}");
assert!(message.ends_with("run `kobe login --retrust`"), "{message}");
assert!(!message.contains("trust()"), "{message}");
}
}
#[test]
fn other_errors_pass_through_unchanged() {
let message = super::retrust_hint(anyhow::anyhow!("connection refused")).to_string();
assert_eq!(message, "connection refused");
}
use super::*;
fn oidc_config(audience: Option<&str>) -> ServiceConfig {
let mut config = ServiceConfig::new("https://kobe.example", "https://idp.example", "cli");
config.audience = audience.map(str::to_string);
config
}
fn store(directory: &tempfile::TempDir) -> TofuStore {
TofuStore::with_path(directory.path().join("known.json"))
}
#[test]
fn retrust_moves_an_ssh_pin_to_the_oidc_issuer() {
let directory = tempfile::tempdir().unwrap();
let store = store(&directory);
store
.trust("https://kobe.example", "ssh", "kobe-system")
.unwrap();
let config = oidc_config(None);
assert!(
store
.check_and_pin("https://kobe.example", "https://idp.example", "")
.is_err()
);
let change = retrust_pin(&store, &config).unwrap().unwrap();
assert!(
change.contains("\"ssh\" -> \"https://idp.example\""),
"{change}"
);
store
.check_and_pin("https://kobe.example", "https://idp.example", "")
.unwrap();
}
#[test]
fn retrust_moves_a_changed_audience() {
let directory = tempfile::tempdir().unwrap();
let store = store(&directory);
store
.trust("https://kobe.example", "https://idp.example", "cli")
.unwrap();
let change = retrust_pin(&store, &oidc_config(None)).unwrap().unwrap();
assert!(change.contains("audience"), "{change}");
store
.check_and_pin("https://kobe.example", "https://idp.example", "")
.unwrap();
}
#[test]
fn retrust_is_silent_when_the_pin_already_matches_or_is_new() {
let directory = tempfile::tempdir().unwrap();
let store = store(&directory);
assert_eq!(
retrust_pin(&store, &oidc_config(Some("kobe"))).unwrap(),
None
);
assert_eq!(
retrust_pin(&store, &oidc_config(Some("kobe"))).unwrap(),
None
);
store
.check_and_pin("https://kobe.example", "https://idp.example", "kobe")
.unwrap();
}
}