use std::sync::{Arc, Mutex};
use std::time::Duration;
use crate::auth::cache::{self, CachedCred};
use crate::auth::oauth2::{self, OAuth2Params};
use crate::config::AuthSpec;
const REFRESH_SKEW_MS: u64 = 60_000;
pub struct TokenSource {
params: OAuth2Params,
timeout: Duration,
cache: Mutex<Option<CachedCred>>,
}
impl TokenSource {
pub fn new(params: OAuth2Params, timeout: Duration, seed: Option<CachedCred>) -> TokenSource {
TokenSource {
params,
timeout,
cache: Mutex::new(seed),
}
}
pub fn bearer(&self) -> Result<String, String> {
let now = cache::now_ms();
let refresh_token = {
let g = self.cache.lock().unwrap_or_else(|e| e.into_inner());
match g.as_ref() {
Some(c) if c.valid_at(now, REFRESH_SKEW_MS) => return Ok(c.access_token.clone()),
Some(c) => c.refresh_token.clone(),
None => return Err("no cached credential — run `agentd login`".into()),
}
};
let rt = refresh_token
.ok_or("cached token expired with no refresh token — run `agentd login`")?;
let toks = oauth2::refresh(&self.params, &rt, self.timeout)?;
let cred = crate::auth::login::tokens_to_cred(&toks);
let access = cred.access_token.clone();
*self.cache.lock().unwrap_or_else(|e| e.into_inner()) = Some(cred);
Ok(access)
}
}
pub struct BearerSigner {
source: Arc<TokenSource>,
}
impl ::mcp::http::RequestSigner for BearerSigner {
fn sign(&self, _m: &str, _a: &str, _p: &str, _b: &[u8]) -> Vec<(String, String)> {
match self.source.bearer() {
Ok(tok) => vec![("Authorization".to_string(), format!("Bearer {tok}"))],
Err(_) => Vec::new(),
}
}
}
pub struct StaticSigner {
header: String,
value_template: String,
}
impl ::mcp::http::RequestSigner for StaticSigner {
fn sign(&self, _m: &str, _a: &str, _p: &str, _b: &[u8]) -> Vec<(String, String)> {
let env = |k: &str| std::env::var(k).ok();
match crate::sec::secret::resolve(&self.value_template, &env) {
Ok(v) => vec![(self.header.clone(), v)],
Err(_) => Vec::new(),
}
}
}
fn params_from_spec(auth: &AuthSpec, timeout: Duration) -> Result<OAuth2Params, String> {
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();
if token_url.is_none()
&& let Some(issuer) = &auth.issuer
{
let d = oauth2::discover(issuer, timeout)?;
token_url = token_url.or(d.token_endpoint);
device_url = device_url.or(d.device_authorization_endpoint);
}
Ok(OAuth2Params {
token_url: token_url.ok_or("auth.token_url is required (or set auth.issuer)")?,
device_authorization_url: device_url,
authorization_url: auth.authorization_url.clone(),
client_id,
client_secret: auth.client_secret.clone(),
scopes: auth.scopes.clone(),
audience: auth.audience.clone(),
})
}
pub fn token_source_for(
auth: &AuthSpec,
target: &str,
timeout: Duration,
) -> Result<Option<Arc<TokenSource>>, String> {
if auth.kind != "oauth2" || auth.grant.as_deref() == Some("client_credentials") {
return Ok(None);
}
let params = params_from_spec(auth, timeout)?;
let seed = cache::load_file(&cache::default_dir(), target);
Ok(Some(Arc::new(TokenSource::new(params, timeout, seed))))
}
pub fn signer_for(
auth: &AuthSpec,
target: &str,
timeout: Duration,
) -> Result<Option<Arc<dyn ::mcp::http::RequestSigner>>, String> {
match auth.kind.as_str() {
"static" => {
if let Some(tok) = &auth.token {
Ok(Some(Arc::new(StaticSigner {
header: "Authorization".to_string(),
value_template: format!("Bearer {tok}"),
})))
} else if let (Some(h), Some(v)) = (&auth.header, &auth.value) {
Ok(Some(Arc::new(StaticSigner {
header: h.clone(),
value_template: v.clone(),
})))
} else {
Err("auth.kind static needs `token` or `header`+`value`".into())
}
}
"oauth2" => {
let params = params_from_spec(auth, timeout)?;
match auth.grant.as_deref() {
Some("client_credentials") => {
let cs = auth
.client_secret
.clone()
.ok_or("client_credentials needs client_secret")?;
let spec = crate::config::McpOauthSpec {
token_url: params.token_url,
client_id: params.client_id,
client_secret: cs,
scope: (!params.scopes.is_empty()).then(|| params.scopes.join(" ")),
};
Ok(Some(Arc::new(crate::mcp::oauth::OAuthBearerSigner::new(
spec, timeout,
))))
}
_ => {
let dir = cache::default_dir();
let seed = cache::load_file(&dir, target);
let source = Arc::new(TokenSource::new(params, timeout, seed));
Ok(Some(Arc::new(BearerSigner { source })))
}
}
}
"aws" => Ok(Some(crate::auth::aws::SigV4Signer::from_spec(
auth, target,
)?)),
"spiffe" => match auth.svid.as_deref().unwrap_or("jwt") {
"jwt" => {
let path = auth
.jwt_svid_file
.clone()
.ok_or("spiffe: `jwt_svid_file` is required for svid jwt")?;
Ok(Some(Arc::new(StaticSigner {
header: "Authorization".to_string(),
value_template: format!("Bearer {{{{secret-file:{path}}}}}"),
})))
}
"x509" => Ok(None),
other => Err(format!("spiffe: unknown svid '{other}' (want jwt|x509)")),
},
other => Err(format!("unknown auth.kind '{other}'")),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ::mcp::http::RequestSigner;
fn params() -> OAuth2Params {
OAuth2Params {
token_url: "https://as/token".into(),
device_authorization_url: None,
authorization_url: None,
client_id: "c".into(),
client_secret: None,
scopes: vec![],
audience: None,
}
}
#[test]
fn spiffe_jwt_svid_is_a_rotating_bearer() {
let f = tempfile::NamedTempFile::new().unwrap();
std::fs::write(f.path(), "eyJ.jwt.svid").unwrap();
let auth = AuthSpec {
kind: "spiffe".into(),
svid: Some("jwt".into()),
jwt_svid_file: Some(f.path().to_str().unwrap().to_string()),
..Default::default()
};
let signer = signer_for(&auth, "mcp:x", Duration::from_secs(5))
.unwrap()
.unwrap();
let h = signer.sign("POST", "a", "/p", b"");
assert_eq!(
h,
vec![(
"Authorization".to_string(),
"Bearer eyJ.jwt.svid".to_string()
)]
);
let x509 = AuthSpec {
kind: "spiffe".into(),
svid: Some("x509".into()),
svid_file: Some("/s".into()),
key_file: Some("/k".into()),
..Default::default()
};
assert!(
signer_for(&x509, "mcp:x", Duration::from_secs(1))
.unwrap()
.is_none()
);
}
#[test]
fn token_source_serves_a_valid_cached_token_without_network() {
let seed = CachedCred {
access_token: "live".into(),
refresh_token: Some("rt".into()),
expires_at_ms: cache::now_ms() + 3_600_000,
token_type: Some("Bearer".into()),
extra: Default::default(),
};
let src = TokenSource::new(params(), Duration::from_secs(5), Some(seed));
assert_eq!(src.bearer().unwrap(), "live");
let signer = BearerSigner {
source: Arc::new(TokenSource::new(
params(),
Duration::from_secs(5),
Some(CachedCred {
access_token: "live".into(),
expires_at_ms: cache::now_ms() + 3_600_000,
..Default::default()
}),
)),
};
let h = signer.sign("POST", "a", "/p", b"");
assert_eq!(
h,
vec![("Authorization".to_string(), "Bearer live".to_string())]
);
}
#[test]
fn token_source_without_a_seed_asks_for_login() {
let src = TokenSource::new(params(), Duration::from_secs(5), None);
assert!(src.bearer().unwrap_err().contains("agentd login"));
}
#[test]
fn static_signer_resolves_a_secret_per_request() {
unsafe { std::env::set_var("AUTH_DEV_STATIC_TOKEN", "s3cr3t") };
let s = StaticSigner {
header: "Authorization".into(),
value_template: "Bearer {{secret:AUTH_DEV_STATIC_TOKEN}}".into(),
};
let h = s.sign("POST", "a", "/p", b"");
assert_eq!(h[0].1, "Bearer s3cr3t");
unsafe { std::env::remove_var("AUTH_DEV_STATIC_TOKEN") };
}
}