//! The one spelling of the token an agent presents its own queue secret in, //! shared by the agent that formats it and the auth-callout responder that //! parses it back: //! [`AGENT_TOKEN_PREFIX`](crate::agent_token::AGENT_TOKEN_PREFIX), the agent's //! name, `.`, the secret. //! //! The secret itself lives at `swarm/agents//queue` in the swarm's //! secret store (`swarm_secret_client::queue`). This module knows nothing of //! the store, so an agent formats its token without linking a store client. /// Marks an `auth_token` as an agent's own credential. /// /// An OIDC access token may itself contain `.`, so the `.` /// shape alone does not tell the two apart. Authelia's access tokens start /// `authelia_at_`. pub const AGENT_TOKEN_PREFIX: &str = "swarm-agent."; /// An agent's own credential as presented at the queue. /// /// No `Debug`: `secret` is the credential itself. pub struct AgentToken<'a> { /// The agent the presenter claims to be. Unproven until `secret` is /// checked against that agent's stored credential. pub agent: &'a str, /// The presented secret. pub secret: &'a str, } /// A token that is not `.` after its prefix, or parts that /// would not make one. Carries the reason only: a token holds a secret. #[derive(Debug, thiserror::Error)] #[error("malformed agent token: {0}")] pub struct Malformed(pub &'static str); /// Spell `agent`'s credential as the token it presents at the queue. /// /// # Errors /// [`Malformed`] when `agent` or `secret` is empty or holds anything outside /// `[A-Za-z0-9_-]`. Either would make [`parse_agent_token`] split the token /// differently than it was joined. pub fn format_agent_token(agent: &str, secret: &str) -> Result { check_agent(agent)?; check_secret(secret)?; Ok(format!("{AGENT_TOKEN_PREFIX}{agent}.{secret}")) } /// Read a presented token back into an [`AgentToken`]. /// /// `None` when `token` does not start with [`AGENT_TOKEN_PREFIX`]: it is some /// other kind of token, not a malformed one of these. `Some(Err(_))` when it /// does and is not exactly `.` after it. #[must_use] pub fn parse_agent_token(token: &str) -> Option, Malformed>> { let rest = token.strip_prefix(AGENT_TOKEN_PREFIX)?; Some( rest.split_once('.') .ok_or(Malformed("no `.` between the agent and the secret")) .and_then(|(agent, secret)| { check_agent(agent)?; check_secret(secret)?; Ok(AgentToken { agent, secret }) }), ) } fn is_segment(s: &str) -> bool { !s.is_empty() && s.bytes() .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_') } /// The alphabet a store path segment allows, so the name cannot widen a /// subject with `.`, `*` or `>`, or address another agent's path. fn check_agent(agent: &str) -> Result<(), Malformed> { if is_segment(agent) { Ok(()) } else { Err(Malformed("the agent is not a single [A-Za-z0-9_-] segment")) } } /// The alphabet the controller mints secrets in, base64url without padding. /// The error never carries the value. fn check_secret(secret: &str) -> Result<(), Malformed> { if is_segment(secret) { Ok(()) } else { Err(Malformed( "the secret is empty or holds a byte outside [A-Za-z0-9_-]", )) } } #[cfg(test)] mod tests { use super::*; #[test] fn an_agent_token_round_trips() { let token = format_agent_token("atlas", "Ab9_-z").expect("legal"); assert_eq!(token, "swarm-agent.atlas.Ab9_-z"); let parsed = parse_agent_token(&token) .expect("carries the prefix") .expect("well-formed"); assert_eq!(parsed.agent, "atlas"); assert_eq!(parsed.secret, "Ab9_-z"); } /// Anything without the prefix belongs to the OIDC path, including a /// token that happens to look like `.`. #[test] fn a_token_without_the_prefix_is_not_an_agent_token() { for token in ["authelia_at_abc.def", "atlas.s3cr3t", "", "swarm-agent"] { assert!(parse_agent_token(token).is_none(), "{token:?}"); } } #[test] fn a_malformed_agent_token_is_refused() { for token in [ "swarm-agent.", "swarm-agent.atlas", "swarm-agent.atlas.", "swarm-agent..s3cr3t", "swarm-agent.atlas.s3.cr3t", "swarm-agent.at*las.s3cr3t", "swarm-agent.at>las.s3cr3t", "swarm-agent.at/las.s3cr3t", "swarm-agent.atlas.s3cr3t=", "swarm-agent.atlas.s3 cr3t", ] { assert!( matches!(parse_agent_token(token), Some(Err(_))), "{token:?} must be refused" ); } } /// What formats always parses back into the same two parts. #[test] fn formatting_refuses_what_parsing_would_split_differently() { assert!(format_agent_token("at.las", "s3cr3t").is_err()); assert!(format_agent_token("atlas", "s3.cr3t").is_err()); assert!(format_agent_token("atlas", "").is_err()); assert!(format_agent_token("", "s3cr3t").is_err()); } #[test] fn a_malformed_secret_is_not_echoed_in_the_error() { let Some(Err(e)) = parse_agent_token("swarm-agent.atlas.hunter2!") else { panic!("must be refused"); }; assert!(!e.to_string().contains("hunter2"), "{e}"); } }