use std::time::{Duration, Instant};
use crate::auth::cache::{self, CachedCred};
use crate::auth::oauth2::{self, DeviceAuth, OAuth2Params, PollOutcome};
use crate::config::v2::{Auth, AuthKind, OAuthGrant, Settings};
pub struct DevicePrompt<'a> {
pub verification_uri: &'a str,
pub verification_uri_complete: Option<&'a str>,
pub user_code: &'a str,
pub expires_in: Option<u64>,
}
pub fn resolve_auth<'a>(settings: &'a Settings, target: &str) -> Result<&'a Auth, String> {
if target == "intelligence" {
return settings
.intelligence
.auth
.as_ref()
.ok_or_else(|| "intelligence has no `auth:` block to log in with".to_string());
}
if let Some(name) = target.strip_prefix("mcp:") {
let s = settings
.mcp
.servers
.iter()
.find(|s| s.name == name)
.ok_or_else(|| format!("no mcp server named '{name}' in the config"))?;
return s
.auth
.as_ref()
.ok_or_else(|| format!("mcp server '{name}' has no `auth:` block to log in with"));
}
if let Some(name) = target.strip_prefix("service:") {
let e = settings
.services
.get(name)
.ok_or_else(|| format!("no service named '{name}' in the services: catalog"))?;
return e
.auth
.as_ref()
.ok_or_else(|| format!("services.{name} has no `auth:` block to log in with"));
}
Err(format!(
"unknown login target '{target}' (expected `intelligence`, `mcp:<name>` or `service:<name>`)"
))
}
pub use super::canonical_target;
pub fn params_from_auth(
auth: &Auth,
resource: Option<&str>,
timeout: Duration,
) -> Result<OAuth2Params, String> {
if auth.kind != AuthKind::Oauth2 {
return Err("login requires an `auth: { kind: oauth2 }` block".into());
}
if matches!(auth.grant, Some(OAuthGrant::ClientCredentials)) {
return Err("client_credentials is non-interactive — no login needed".into());
}
let client_id = auth
.client_id
.clone()
.ok_or("auth.client_id is required for oauth2")?;
let mut token_url = auth.token_url.clone();
let mut device_url = auth.device_authorization_url.clone();
let mut auth_url = auth.authorization_url.clone();
let mut issuer = auth.issuer.clone();
if issuer.is_none()
&& token_url.is_none()
&& device_url.is_none()
&& let Some(res) = resource
&& let Some(found) = crate::auth::challenge::discover_issuer(res, timeout)
{
issuer = Some(found);
}
if (token_url.is_none() || device_url.is_none() || auth_url.is_none())
&& let Some(issuer) = &issuer
{
let d = oauth2::discover(issuer, timeout)?;
token_url = token_url.or(d.token_endpoint);
device_url = device_url.or(d.device_authorization_endpoint);
auth_url = auth_url.or(d.authorization_endpoint);
}
Ok(OAuth2Params {
token_url: token_url
.ok_or("auth.token_url is required (or set auth.issuer for discovery)")?,
device_authorization_url: device_url,
authorization_url: auth_url,
client_id,
client_secret: auth.client_secret.as_ref().map(|s| s.0.clone()),
scopes: auth.scopes.clone(),
audience: auth.audience.clone(),
})
}
pub fn device_login(
params: &OAuth2Params,
timeout: Duration,
on_prompt: impl FnOnce(&DevicePrompt),
sleep: impl Fn(Duration),
) -> Result<CachedCred, String> {
let da: DeviceAuth = oauth2::start_device(params, timeout)?;
on_prompt(&DevicePrompt {
verification_uri: &da.verification_uri,
verification_uri_complete: da.verification_uri_complete.as_deref(),
user_code: &da.user_code,
expires_in: da.expires_in,
});
let mut interval = Duration::from_secs(da.interval.max(1));
let deadline =
Instant::now() + Duration::from_secs(da.expires_in.unwrap_or(900).clamp(30, 1800));
loop {
match oauth2::poll_device_once(params, &da.device_code, timeout)? {
PollOutcome::Token(t) => return Ok(tokens_to_cred(&t)),
PollOutcome::Pending => {}
PollOutcome::SlowDown => interval += Duration::from_secs(5),
}
if Instant::now() + interval >= deadline {
return Err("device code expired before authorization".into());
}
sleep(interval);
}
}
pub fn tokens_to_cred(t: &oauth2::Tokens) -> CachedCred {
let expires_at_ms = t
.expires_in
.map(|s| cache::now_ms() + s.saturating_mul(1000))
.unwrap_or(0);
CachedCred {
access_token: t.access_token.clone(),
refresh_token: t.refresh_token.clone(),
expires_at_ms,
token_type: t.token_type.clone(),
extra: Default::default(),
}
}
pub fn run_cli(settings: &Settings, target: &str, timeout: Duration) -> Result<String, String> {
let target = &canonical_target(settings, target);
let auth = resolve_auth(settings, target)?;
if auth.kind == AuthKind::Aws {
if auth.source.as_deref() != Some("sso") {
return Err(
"aws login is only for `source: sso` (env/static credentials need no login)".into(),
);
}
let p = crate::auth::aws_sso::SsoParams {
region: auth.region.clone().ok_or("aws sso: `region` is required")?,
start_url: auth
.sso_start_url
.clone()
.ok_or("aws sso: `sso_start_url` is required")?,
account_id: auth
.account_id
.clone()
.ok_or("aws sso: `account_id` is required")?,
role_name: auth
.role_name
.clone()
.ok_or("aws sso: `role_name` is required")?,
};
let cred = crate::auth::aws_sso::sso_login(
&p,
timeout,
|pr| print_prompt(target, pr),
std::thread::sleep,
)?;
let dir = cache::default_dir();
cache::store_file(&dir, target, &cred)?;
return Ok(format!(
"logged in to {target} (AWS SSO) — temporary credentials cached in {}",
dir.display()
));
}
let resource: Option<String> = if target == "intelligence" {
settings.intelligence.endpoints.first().cloned()
} else if let Some(name) = target.strip_prefix("mcp:") {
settings
.mcp
.servers
.iter()
.find(|s| s.name == name)
.map(|s| s.endpoint.clone())
} else if let Some(name) = target.strip_prefix("service:") {
settings.services.get(name).map(|e| e.endpoint.clone())
} else {
None
};
let params = params_from_auth(auth, resource.as_deref(), timeout)?;
let cred = if auth.grant == Some(OAuthGrant::AuthorizationCode) {
crate::auth::browser::browser_login(
¶ms,
|url| print_browser_prompt(target, url),
timeout,
)?
} else {
device_login(
¶ms,
timeout,
|p| print_prompt(target, p),
std::thread::sleep,
)?
};
let dir = cache::default_dir();
cache::store_file(&dir, target, &cred)?;
Ok(format!(
"logged in to {target} — token cached in {}",
dir.display()
))
}
fn print_browser_prompt(target: &str, url: &str) {
eprintln!("┌─ authorize agentd ──────────────────────────────");
eprintln!("│ target {target}");
eprintln!("│ open {url}");
eprintln!("│ waiting for the browser redirect… (Ctrl-C to cancel)");
eprintln!("└──────────────────────────────────────────────────");
}
fn print_prompt(target: &str, p: &DevicePrompt) {
let uri = p.verification_uri_complete.unwrap_or(p.verification_uri);
eprintln!("┌─ authorize agentd ──────────────────────────────");
eprintln!("│ target {target}");
eprintln!("│ visit {uri}");
eprintln!("│ code {}", p.user_code);
match p.expires_in {
Some(s) => eprintln!("│ waiting… (expires in {}s · Ctrl-C to cancel)", s),
None => eprintln!("│ waiting… (Ctrl-C to cancel)"),
}
eprintln!("└──────────────────────────────────────────────────");
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::v2::Secret;
fn oauth_block() -> Auth {
Auth {
kind: AuthKind::Oauth2,
issuer: None,
token_url: Some("https://as.example/token".into()),
device_authorization_url: Some("https://as.example/device".into()),
authorization_url: None,
client_id: Some("agentd".into()),
client_secret: None,
grant: Some(OAuthGrant::Device),
scopes: vec!["mcp:read".into()],
audience: None,
token: None,
header: None,
value: None,
region: None,
service: None,
source: None,
sso_start_url: None,
account_id: None,
role_name: None,
svid: None,
jwt_svid_file: None,
svid_file: None,
key_file: None,
}
}
#[test]
fn params_from_auth_maps_the_block() {
let p = params_from_auth(&oauth_block(), None, Duration::from_secs(5)).unwrap();
assert_eq!(p.token_url, "https://as.example/token");
assert_eq!(
p.device_authorization_url.as_deref(),
Some("https://as.example/device")
);
assert_eq!(p.client_id, "agentd");
assert_eq!(p.scopes, vec!["mcp:read".to_string()]);
}
#[test]
fn params_from_auth_rejects_non_oauth_and_client_creds() {
let mut a = oauth_block();
a.kind = AuthKind::Static;
assert!(params_from_auth(&a, None, Duration::from_secs(1)).is_err());
let mut a = oauth_block();
a.grant = Some(OAuthGrant::ClientCredentials);
assert!(params_from_auth(&a, None, Duration::from_secs(1)).is_err());
}
#[test]
fn tokens_to_cred_stamps_absolute_expiry() {
let t = oauth2::Tokens {
access_token: "at".into(),
refresh_token: Some("rt".into()),
expires_in: Some(3600),
token_type: Some("Bearer".into()),
scope: None,
};
let before = cache::now_ms();
let c = tokens_to_cred(&t);
assert_eq!(c.access_token, "at");
assert_eq!(c.refresh_token.as_deref(), Some("rt"));
assert!(c.expires_at_ms >= before + 3_600_000);
let _ = Secret("x".into());
}
}