use std::collections::HashMap;
use std::sync::Arc;
use apcore::{ErrorCode, ModuleError};
use apcore_mcp::{Authenticator, Identity, JWTAuthenticator};
use async_trait::async_trait;
const TOKEN_IDENTITY_TYPE: &str = "token";
const TOKEN_IDENTITY_ID: &str = "apexe-token";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AuthMode {
Token,
Jwt,
None,
}
impl AuthMode {
pub fn parse(value: &str) -> Option<Self> {
match value {
"token" => Some(Self::Token),
"jwt" => Some(Self::Jwt),
"none" => Some(Self::None),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Token => "token",
Self::Jwt => "jwt",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, Default)]
pub struct AuthOptions {
pub mode: Option<AuthMode>,
pub token: Option<String>,
pub jwt_secret: Option<String>,
pub allow_unauthenticated_bind: bool,
}
pub enum ResolvedAuth {
Disabled,
Token {
authenticator: Arc<dyn Authenticator>,
token: String,
generated: bool,
},
Jwt {
authenticator: Arc<dyn Authenticator>,
},
}
impl std::fmt::Debug for ResolvedAuth {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Disabled => f.write_str("ResolvedAuth::Disabled"),
Self::Token { generated, .. } => f
.debug_struct("ResolvedAuth::Token")
.field("token", &"<redacted>")
.field("generated", generated)
.finish(),
Self::Jwt { .. } => f.write_str("ResolvedAuth::Jwt"),
}
}
}
impl ResolvedAuth {
pub fn authenticator(&self) -> Option<Arc<dyn Authenticator>> {
match self {
Self::Disabled => None,
Self::Token { authenticator, .. } | Self::Jwt { authenticator } => {
Some(authenticator.clone())
}
}
}
pub fn require_auth(&self) -> bool {
!matches!(self, Self::Disabled)
}
}
pub fn is_loopback_host(host: &str) -> bool {
if host == "localhost" {
return true;
}
let trimmed = host.trim_start_matches('[').trim_end_matches(']');
trimmed
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
}
#[allow(clippy::result_large_err)] pub fn resolve_auth(
transport: &str,
host: &str,
opts: &AuthOptions,
) -> Result<ResolvedAuth, ModuleError> {
if transport == "stdio" {
if opts.mode.is_some_and(|mode| mode != AuthMode::None) || opts.token.is_some() {
tracing::warn!(
"Ignoring --auth on the stdio transport: the trust boundary is the \
parent/child process relationship, and a token adds nothing to it"
);
}
return Ok(ResolvedAuth::Disabled);
}
let loopback = is_loopback_host(host);
let resolved = match opts.mode.unwrap_or(AuthMode::Token) {
AuthMode::None => resolve_none(host, loopback, opts.allow_unauthenticated_bind),
AuthMode::Token => Ok(resolve_token(opts.token.clone())),
AuthMode::Jwt => resolve_jwt(opts.jwt_secret.as_deref()),
}?;
warn_if_credential_crosses_the_network(&resolved, host, loopback);
Ok(resolved)
}
fn warn_if_credential_crosses_the_network(resolved: &ResolvedAuth, host: &str, loopback: bool) {
if loopback || !resolved.require_auth() {
return;
}
tracing::warn!(
host,
"This bind serves plain HTTP, so the credential it requires is sent in cleartext and \
anything on the path can replay it. Put apexe behind a TLS-terminating reverse proxy, \
or bind to 127.0.0.1 and reach it through a tunnel."
);
}
#[allow(clippy::result_large_err)] fn resolve_none(
host: &str,
loopback: bool,
acknowledged: bool,
) -> Result<ResolvedAuth, ModuleError> {
if !loopback && !acknowledged {
return Err(ModuleError::new(
ErrorCode::GeneralInvalidInput,
format!(
"Refusing to start: `--auth none` on the non-loopback bind '{host}' would expose \
every wrapped binary on this host to the network with no credential. apexe wraps \
arbitrary local commands, so this is a remote-execution entry point rather than \
an unauthenticated API. Bind to 127.0.0.1, use `--auth token`, or pass \
`--allow-unauthenticated-bind` to state that you mean it."
),
));
}
if !loopback {
tracing::warn!(
host,
"Serving with NO authentication on a non-loopback bind, as explicitly acknowledged"
);
} else {
tracing::warn!("Authentication disabled on a loopback bind (--auth none)");
}
Ok(ResolvedAuth::Disabled)
}
fn resolve_token(supplied: Option<String>) -> ResolvedAuth {
let (token, generated) = match supplied
.map(|t| t.trim().to_string())
.filter(|t| !t.is_empty())
{
Some(token) => (token, false),
None => (generate_token(), true),
};
ResolvedAuth::Token {
authenticator: Arc::new(StaticTokenAuthenticator::new(token.clone())),
token,
generated,
}
}
#[allow(clippy::result_large_err)] fn resolve_jwt(secret: Option<&str>) -> Result<ResolvedAuth, ModuleError> {
let secret = secret.filter(|s| !s.is_empty()).ok_or_else(|| {
ModuleError::new(
ErrorCode::GeneralInvalidInput,
"`--auth jwt` needs a signing secret: pass `--jwt-secret <value>` or set \
APEXE_JWT_SECRET."
.to_string(),
)
})?;
let authenticator = JWTAuthenticator::new(secret, None, None, None, None, None, Some(true));
Ok(ResolvedAuth::Jwt {
authenticator: Arc::new(authenticator),
})
}
fn generate_token() -> String {
format!(
"{}{}",
uuid::Uuid::new_v4().simple(),
uuid::Uuid::new_v4().simple()
)
}
fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
if left.len() != right.len() {
return false;
}
let mut diff: u8 = 0;
for (l, r) in left.iter().zip(right.iter()) {
diff |= l ^ r;
}
diff == 0
}
pub struct StaticTokenAuthenticator {
token: String,
}
impl StaticTokenAuthenticator {
pub fn new(token: String) -> Self {
Self { token }
}
}
impl std::fmt::Debug for StaticTokenAuthenticator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("StaticTokenAuthenticator")
.field("token", &"<redacted>")
.finish()
}
}
#[async_trait]
impl Authenticator for StaticTokenAuthenticator {
async fn authenticate(&self, headers: &HashMap<String, String>) -> Option<Identity> {
let presented = headers
.get("authorization")?
.strip_prefix("Bearer ")
.or_else(|| headers.get("authorization")?.strip_prefix("bearer "))?
.trim();
if !constant_time_eq(presented.as_bytes(), self.token.as_bytes()) {
return None;
}
Some(Identity::new(
TOKEN_IDENTITY_ID.to_string(),
TOKEN_IDENTITY_TYPE.to_string(),
vec![],
HashMap::new(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn bearer(value: &str) -> HashMap<String, String> {
HashMap::from([("authorization".to_string(), format!("Bearer {value}"))])
}
#[test]
fn test_is_loopback_host_recognizes_local_binds() {
assert!(is_loopback_host("127.0.0.1"));
assert!(is_loopback_host("127.0.0.53"));
assert!(is_loopback_host("::1"));
assert!(is_loopback_host("[::1]"));
assert!(is_loopback_host("localhost"));
}
#[test]
fn test_is_loopback_host_treats_unknown_as_remote() {
assert!(!is_loopback_host("0.0.0.0"));
assert!(!is_loopback_host("192.168.1.10"));
assert!(!is_loopback_host("example.com"));
}
#[test]
fn test_resolve_auth_stdio_is_disabled() {
let resolved = resolve_auth("stdio", "127.0.0.1", &AuthOptions::default()).unwrap();
assert!(!resolved.require_auth());
assert!(resolved.authenticator().is_none());
}
#[test]
fn test_resolve_auth_http_defaults_to_generated_token() {
let resolved = resolve_auth("http", "127.0.0.1", &AuthOptions::default()).unwrap();
assert!(resolved.require_auth());
match resolved {
ResolvedAuth::Token {
token, generated, ..
} => {
assert!(generated);
assert_eq!(token.len(), 64);
}
_ => panic!("expected a generated token"),
}
}
#[test]
fn test_resolve_auth_uses_supplied_token() {
let opts = AuthOptions {
token: Some("supplied-secret".to_string()),
..AuthOptions::default()
};
match resolve_auth("http", "0.0.0.0", &opts).unwrap() {
ResolvedAuth::Token {
token, generated, ..
} => {
assert!(!generated);
assert_eq!(token, "supplied-secret");
}
_ => panic!("expected the supplied token"),
}
}
#[test]
fn test_resolve_auth_trims_whitespace_from_a_supplied_token() {
let opts = AuthOptions {
token: Some(" supplied-secret\n".to_string()),
..AuthOptions::default()
};
match resolve_auth("http", "0.0.0.0", &opts).unwrap() {
ResolvedAuth::Token { token, .. } => {
assert_eq!(token, "supplied-secret");
}
_ => panic!("expected the supplied token"),
}
}
#[test]
fn test_resolve_auth_treats_a_whitespace_only_token_as_absent() {
let opts = AuthOptions {
token: Some(" ".to_string()),
..AuthOptions::default()
};
match resolve_auth("http", "0.0.0.0", &opts).unwrap() {
ResolvedAuth::Token {
token, generated, ..
} => {
assert!(
generated,
"a whitespace-only token must be treated as absent"
);
assert_eq!(token.len(), 64);
}
_ => panic!("expected a generated token"),
}
}
#[tokio::test]
async fn test_a_configured_token_with_surrounding_whitespace_still_authenticates() {
let opts = AuthOptions {
token: Some(" abc123\n".to_string()),
..AuthOptions::default()
};
let resolved = resolve_auth("http", "0.0.0.0", &opts).unwrap();
let authenticator = resolved.authenticator().expect("token mode installs one");
let identity = authenticator
.authenticate(&bearer("abc123"))
.await
.expect("the trimmed configured token must match the presented one");
assert_eq!(identity.id(), TOKEN_IDENTITY_ID);
}
fn capture_warnings(run: impl FnOnce()) -> String {
#[derive(Clone)]
struct Buffer(Arc<Mutex<Vec<u8>>>);
impl std::io::Write for Buffer {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().expect("test buffer").extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for Buffer {
type Writer = Self;
fn make_writer(&'a self) -> Self::Writer {
self.clone()
}
}
let buffer = Buffer(Arc::new(Mutex::new(Vec::new())));
let subscriber = tracing_subscriber::fmt()
.with_writer(buffer.clone())
.with_ansi(false)
.finish();
tracing::subscriber::with_default(subscriber, run);
let bytes = buffer.0.lock().expect("test buffer").clone();
String::from_utf8(bytes).expect("tracing output is UTF-8")
}
#[test]
fn test_resolve_auth_warns_that_a_remote_credential_is_sent_in_cleartext() {
let warnings = capture_warnings(|| {
resolve_auth("http", "0.0.0.0", &AuthOptions::default())
.expect("a generated token needs no acknowledgement");
});
assert!(
warnings.contains("cleartext"),
"a token on a non-loopback bind must warn about the wire: {warnings}"
);
assert!(
warnings.contains("reverse proxy"),
"the warning must name the remedy: {warnings}"
);
}
#[test]
fn test_resolve_auth_warns_for_jwt_on_a_non_loopback_bind_too() {
let opts = AuthOptions {
mode: Some(AuthMode::Jwt),
jwt_secret: Some("s3cret".to_string()),
..AuthOptions::default()
};
let warnings = capture_warnings(|| {
resolve_auth("http", "0.0.0.0", &opts).expect("a supplied secret is enough");
});
assert!(
warnings.contains("cleartext"),
"JWT on a non-loopback bind must warn too: {warnings}"
);
}
#[test]
fn test_resolve_auth_stays_quiet_about_the_wire_on_loopback() {
let warnings = capture_warnings(|| {
resolve_auth("http", "127.0.0.1", &AuthOptions::default())
.expect("loopback defaults to a generated token");
});
assert!(
!warnings.contains("cleartext"),
"loopback must not warn about the wire: {warnings}"
);
}
#[test]
fn test_resolve_auth_does_not_claim_cleartext_when_no_credential_is_required() {
let opts = AuthOptions {
mode: Some(AuthMode::None),
allow_unauthenticated_bind: true,
..AuthOptions::default()
};
let warnings = capture_warnings(|| {
resolve_auth("http", "0.0.0.0", &opts).expect("acknowledged");
});
assert!(
!warnings.contains("cleartext"),
"there is no credential to send in cleartext: {warnings}"
);
}
#[test]
fn test_resolve_auth_refuses_none_on_non_loopback_bind() {
let opts = AuthOptions {
mode: Some(AuthMode::None),
..AuthOptions::default()
};
let err = resolve_auth("http", "0.0.0.0", &opts)
.expect_err("--auth none on a public bind must refuse to start");
assert_eq!(err.code, ErrorCode::GeneralInvalidInput);
assert!(err.message.contains("--allow-unauthenticated-bind"));
}
#[test]
fn test_resolve_auth_allows_acknowledged_none_on_non_loopback_bind() {
let opts = AuthOptions {
mode: Some(AuthMode::None),
allow_unauthenticated_bind: true,
..AuthOptions::default()
};
let resolved = resolve_auth("http", "0.0.0.0", &opts).unwrap();
assert!(!resolved.require_auth());
}
#[test]
fn test_resolve_auth_allows_none_on_loopback_without_acknowledgement() {
let opts = AuthOptions {
mode: Some(AuthMode::None),
..AuthOptions::default()
};
assert!(resolve_auth("http", "127.0.0.1", &opts).is_ok());
}
#[test]
fn test_resolve_auth_jwt_requires_secret() {
let opts = AuthOptions {
mode: Some(AuthMode::Jwt),
..AuthOptions::default()
};
let err = resolve_auth("http", "127.0.0.1", &opts)
.expect_err("--auth jwt without a secret must refuse to start");
assert!(err.message.contains("--jwt-secret"));
}
#[test]
fn test_auth_mode_parse_roundtrip() {
for mode in [AuthMode::Token, AuthMode::Jwt, AuthMode::None] {
assert_eq!(AuthMode::parse(mode.as_str()), Some(mode));
}
assert_eq!(AuthMode::parse("basic"), None);
}
#[tokio::test]
async fn test_static_token_authenticator_accepts_matching_token() {
let auth = StaticTokenAuthenticator::new("s3cret".to_string());
let identity = auth
.authenticate(&bearer("s3cret"))
.await
.expect("matching token authenticates");
assert_eq!(identity.id(), TOKEN_IDENTITY_ID);
}
#[tokio::test]
async fn test_static_token_authenticator_rejects_wrong_or_missing_token() {
let auth = StaticTokenAuthenticator::new("s3cret".to_string());
assert!(auth.authenticate(&bearer("wrong")).await.is_none());
assert!(auth.authenticate(&bearer("s3cre")).await.is_none());
assert!(auth.authenticate(&HashMap::new()).await.is_none());
assert!(auth
.authenticate(&HashMap::from([(
"authorization".to_string(),
"s3cret".to_string()
)]))
.await
.is_none());
}
#[test]
fn test_constant_time_eq_matches_equality() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
}
#[test]
fn test_generate_token_is_unique_and_long() {
let first = generate_token();
let second = generate_token();
assert_ne!(first, second);
assert_eq!(first.len(), 64);
assert!(first.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn test_static_token_authenticator_debug_hides_token() {
let auth = StaticTokenAuthenticator::new("s3cret".to_string());
let rendered = format!("{auth:?}");
assert!(!rendered.contains("s3cret"), "token leaked: {rendered}");
}
}