aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! The session-scoped bearer the harness carries to this server's own MCP
//! endpoint.
//!
//! # It is not a human's token, and it is not a signed one
//!
//! A harness that reaches `/mcp` has to authenticate as SOMETHING. Passing the
//! operator's own bearer through would hand a subprocess the operator's whole
//! authority for as long as the token lives, and neither revoking it nor
//! scoping it would be possible. So the server mints a token OF ITS OWN per
//! session, and that token identifies the session — not a person.
//!
//! There is no signing key, deliberately. A signed token would need key
//! material this server does not have and must not invent, and its lifetime
//! would be whatever was baked into it. Instead the token is high-entropy
//! random and its SHA-256 DIGEST is stored on the session record: verification
//! hashes what the caller presented and compares. That makes two things true
//! that a signed token could not make true without more machinery:
//!
//! - **revocation is durable.** The token is admitted only while the session's
//!   projected lifecycle is not `ended`, and that projection is read from the
//!   store. There is no in-memory revocation set to die with the process that
//!   minted it.
//! - **it survives a restart.** The digest is a store fact, so a token minted
//!   before a restart still verifies after one, for exactly as long as its
//!   session lives.
//!
//! # Entropy
//!
//! Two version-4 UUIDs, hex-concatenated: 244 bits from the platform's own
//! random source, which is where `uuid`'s v4 gets its bytes. No new dependency
//! and no hand-rolled generator — and stated here rather than left for a reader
//! to work out from the code.

use aion_core::AssistantSessionId;
use sha2::{Digest, Sha256};

/// The request header a session's harness presents its bearer on.
///
/// The ordinary `authorization` header, so the MCP transport needs no special
/// case: the `/mcp` route recognises a session bearer by looking it up, not by
/// the header it arrived on.
pub(crate) const SESSION_TOKEN_SCHEME: &str = "Bearer";

/// The header naming WHICH session a call belongs to.
///
/// Sent beside the bearer because the bearer alone would have to be searched
/// for across every session; with the id, verification is one record read and
/// one digest comparison. The id is not a secret and proves nothing on its own —
/// the bearer is what is checked — but a call whose id and bearer disagree is
/// refused, which is what stops session A's token being used to read session
/// B's context.
pub(crate) const SESSION_ID_HEADER: &str = "x-aion-assistant-session";

/// A freshly minted session bearer: the secret to hand the harness, and the
/// digest to store.
///
/// The secret is carried in a struct with no `Debug` derive and no accessor
/// that renders it into a log line: it leaves this type exactly twice — into
/// the harness's MCP server specification, and nowhere else.
pub(crate) struct MintedSessionToken {
    secret: String,
    digest: String,
}

impl MintedSessionToken {
    /// Mint a token for `session_id`.
    pub(crate) fn mint() -> Self {
        let secret = format!(
            "{}{}",
            uuid::Uuid::new_v4().simple(),
            uuid::Uuid::new_v4().simple()
        );
        let digest = digest_of(&secret);
        Self { secret, digest }
    }

    /// The secret, for the one place it is allowed to go: the MCP server
    /// specification handed to the harness.
    pub(crate) fn secret(&self) -> &str {
        &self.secret
    }

    /// The digest to store on the session record.
    pub(crate) fn digest(&self) -> &str {
        &self.digest
    }
}

impl std::fmt::Debug for MintedSessionToken {
    /// Prints the digest and never the secret.
    ///
    /// A `Debug` that rendered the token would put it in every `?token` trace
    /// field and every error chain — which is exactly how a credential reaches
    /// a log file.
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        formatter
            .debug_struct("MintedSessionToken")
            .field("secret", &"<redacted>")
            .field("digest", &self.digest)
            .finish()
    }
}

/// The lowercase-hex SHA-256 digest of a presented token.
pub(crate) fn digest_of(secret: &str) -> String {
    let mut hasher = Sha256::new();
    hasher.update(secret.as_bytes());
    let digest = hasher.finalize();
    let mut hex = String::with_capacity(digest.len() * 2);
    for byte in digest {
        use std::fmt::Write as _;
        // Writing into a `String` cannot fail; the result is consumed so the
        // failure path is expressible rather than ignored.
        if write!(hex, "{byte:02x}").is_err() {
            return String::new();
        }
    }
    hex
}

/// Whether a presented secret matches a stored digest, in constant time over
/// the digest bytes.
///
/// Compared as digests rather than as tokens: the stored value IS a digest, and
/// hashing the presented secret first means a timing difference can leak at most
/// which digest prefix matched — never how much of the token was right.
#[must_use]
pub(crate) fn matches(presented: &str, stored_digest: &str) -> bool {
    let presented = digest_of(presented);
    if presented.len() != stored_digest.len() {
        return false;
    }
    presented
        .bytes()
        .zip(stored_digest.bytes())
        .fold(0_u8, |difference, (left, right)| {
            difference | (left ^ right)
        })
        == 0
}

/// The `authorization` header value a harness presents.
#[must_use]
pub(crate) fn authorization_value(secret: &str) -> String {
    format!("{SESSION_TOKEN_SCHEME} {secret}")
}

/// The session id header value a harness presents.
#[must_use]
pub(crate) fn session_header_value(session_id: AssistantSessionId) -> String {
    session_id.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_minted_token_verifies_against_its_own_digest_and_nothing_else() {
        let first = MintedSessionToken::mint();
        let second = MintedSessionToken::mint();
        assert!(matches(first.secret(), first.digest()));
        assert!(
            !matches(first.secret(), second.digest()),
            "one session's token must not verify against another's digest"
        );
        assert_ne!(
            first.secret(),
            second.secret(),
            "two mints must not collide"
        );
    }

    #[test]
    fn the_debug_rendering_carries_the_digest_and_never_the_secret() {
        let token = MintedSessionToken::mint();
        let rendered = format!("{token:?}");
        assert!(
            !rendered.contains(token.secret()),
            "the token must never reach a log line through Debug"
        );
        assert!(rendered.contains(token.digest()));
        assert!(rendered.contains("<redacted>"));
    }

    #[test]
    fn a_digest_is_sixty_four_lowercase_hex_characters() {
        let digest = digest_of("anything");
        assert_eq!(digest.len(), 64);
        assert!(
            digest
                .chars()
                .all(|character| character.is_ascii_hexdigit())
        );
        assert!(digest.chars().all(|character| !character.is_uppercase()));
    }

    #[test]
    fn a_mismatched_length_is_refused_rather_than_compared() {
        assert!(!matches("token", "short"));
    }
}