aion-server 0.29.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
//! Resolving a session bearer into the session it speaks for.
//!
//! # Every call is checked against the STORE
//!
//! There is no in-memory revocation set and no cached admission. Each call reads
//! the session's record (for the digest) and its transcript projection (for the
//! lifecycle), so:
//!
//! - a token whose session has ENDED is refused, even though nothing expired it
//!   — the session's projected state is the revocation, and it is durable;
//! - a token minted before a restart still works after one, because the digest
//!   is a store fact rather than a process fact;
//! - session A's token presented with session B's id is refused, because the
//!   digest that would have to match is B's, and it does not.
//!
//! The cost is one record read and one transcript projection per MCP call. That
//! is the price of revocation that outlives the process, and it is paid on a
//! surface an agent calls a handful of times per turn — not per token chunk.

use aion_core::{AssistantSessionId, AssistantSessionState};

use crate::assistant::sessions::{AssistantSessionError, AssistantSessions};

/// The identity a call on `/assistant/mcp` carries: one session, and nothing
/// else.
///
/// There is deliberately no subject on it. The agent is not the operator: it
/// holds one conversation's authority, so a tool that wanted to act as the
/// operator would have to be given a different credential, and there is none to
/// give it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct AssistantSessionCaller {
    session_id: AssistantSessionId,
}

impl AssistantSessionCaller {
    /// The session this caller speaks for.
    pub(crate) const fn session_id(&self) -> AssistantSessionId {
        self.session_id
    }
}

/// Why a call on the assistant MCP route was not admitted.
///
/// Every variant answers `401` and every variant renders the SAME message to the
/// caller (see [`Self::client_message`]): telling an agent which of "no header",
/// "unknown session", "wrong token", "session over" applied would let it
/// enumerate sessions with a token it already holds. The distinction is kept
/// here so the server's own log can say what really happened.
#[derive(Debug, thiserror::Error)]
pub(crate) enum AssistantMcpAuthError {
    /// No `authorization: Bearer …` header, or one this server cannot read.
    #[error("the request carries no readable `authorization: Bearer …` header")]
    NoBearer,

    /// No `x-aion-assistant-session` header naming which session is calling.
    #[error("the request carries no `{header}` header naming the calling session")]
    NoSession {
        /// The header that was expected.
        header: &'static str,
    },

    /// The session header is not a session id.
    #[error("`{presented}` in the session header is not an assistant session id")]
    MalformedSession {
        /// What was presented.
        presented: String,
    },

    /// No record exists for the named session.
    #[error("assistant session {session_id} has no record on this server")]
    UnknownSession {
        /// The session that was named.
        session_id: AssistantSessionId,
    },

    /// The session record carries no bearer digest: it was opened with no MCP
    /// server of ours, so no token was ever minted for it.
    #[error("assistant session {session_id} was opened with no MCP server and mints no bearer")]
    NoTokenMinted {
        /// The session that was named.
        session_id: AssistantSessionId,
    },

    /// The presented bearer does not hash to the record's digest.
    #[error("the presented bearer does not verify against assistant session {session_id}")]
    WrongToken {
        /// The session that was named.
        session_id: AssistantSessionId,
    },

    /// The session is over. THE revocation: durable, and read from the
    /// projection rather than from anything this process remembers.
    #[error(
        "assistant session {session_id} has ended, so its bearer no longer authorizes anything"
    )]
    SessionEnded {
        /// The session that was named.
        session_id: AssistantSessionId,
    },

    /// The store could not be read, so admission could not be decided.
    ///
    /// Refused rather than admitted: a credential that cannot be checked is a
    /// credential that has not been checked.
    #[error("the assistant session store could not be read to authorize this call: {0}")]
    Unreadable(#[from] AssistantSessionError),
}

impl AssistantMcpAuthError {
    /// The one message every refusal renders to the caller.
    pub(crate) const fn client_message() -> &'static str {
        "this route accepts only the session-scoped bearer this server minted for a live assistant \
         session, presented with the session it belongs to. A human token does not authorize it, \
         and a session that has ended no longer does either."
    }
}

/// Header names the harness presents its identity on.
///
/// The ordinary `authorization` header for the secret, so no transport needs a
/// special case, and a dedicated header for the session id — which is not a
/// secret and proves nothing on its own, but turns verification into one record
/// read instead of a search across every session.
pub(crate) const AUTHORIZATION_HEADER: &str = "authorization";
/// The header naming which session is calling.
pub(crate) use crate::assistant::sessions::token::SESSION_ID_HEADER;

/// Resolve the presented headers into the session they authorize.
///
/// # Errors
///
/// [`AssistantMcpAuthError`] for every reason a call is not admitted. The
/// caller sees one message for all of them; the log sees the variant.
pub(crate) async fn resolve(
    sessions: &AssistantSessions,
    headers: &[(String, String)],
) -> Result<AssistantSessionCaller, AssistantMcpAuthError> {
    let presented = bearer(headers).ok_or(AssistantMcpAuthError::NoBearer)?;
    let named = header(headers, SESSION_ID_HEADER).ok_or(AssistantMcpAuthError::NoSession {
        header: SESSION_ID_HEADER,
    })?;
    let session_id = AssistantSessionId::parse(named).map_err(|_error| {
        AssistantMcpAuthError::MalformedSession {
            presented: named.to_owned(),
        }
    })?;
    let record = sessions
        .record(session_id)
        .await?
        .ok_or(AssistantMcpAuthError::UnknownSession { session_id })?;
    let digest = record
        .mcp_token_digest
        .as_deref()
        .ok_or(AssistantMcpAuthError::NoTokenMinted { session_id })?;
    if !crate::assistant::sessions::token::matches(presented, digest) {
        return Err(AssistantMcpAuthError::WrongToken { session_id });
    }
    // The lifecycle is read AFTER the digest verifies, so a caller with no valid
    // token learns nothing about whether a session is still running.
    let (state, _reason) = sessions.state_of_session(session_id).await?;
    if state == AssistantSessionState::Ended {
        return Err(AssistantMcpAuthError::SessionEnded { session_id });
    }
    Ok(AssistantSessionCaller { session_id })
}

/// The bearer secret from an `authorization` header, scheme-insensitively.
fn bearer(headers: &[(String, String)]) -> Option<&str> {
    let value = header(headers, AUTHORIZATION_HEADER)?;
    let (scheme, secret) = value.split_once(' ')?;
    if !scheme.eq_ignore_ascii_case(crate::assistant::sessions::token::SESSION_TOKEN_SCHEME) {
        return None;
    }
    let secret = secret.trim();
    (!secret.is_empty()).then_some(secret)
}

/// One header value, matched case-insensitively as HTTP requires.
fn header<'headers>(headers: &'headers [(String, String)], name: &str) -> Option<&'headers str> {
    headers
        .iter()
        .find(|(header, _value)| header.eq_ignore_ascii_case(name))
        .map(|(_header, value)| value.as_str())
}

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

    fn headers(pairs: &[(&str, &str)]) -> Vec<(String, String)> {
        pairs
            .iter()
            .map(|(name, value)| ((*name).to_owned(), (*value).to_owned()))
            .collect()
    }

    #[test]
    fn a_bearer_is_read_whatever_case_the_scheme_and_header_arrive_in() {
        let presented = headers(&[("Authorization", "bearer secret-token")]);
        assert_eq!(bearer(&presented), Some("secret-token"));
    }

    /// Another scheme is not a bearer. An agent presenting `Basic` credentials
    /// must be refused rather than have its password read as a token — which is
    /// what a naive "take everything after the space" would do.
    #[test]
    fn another_scheme_is_not_a_bearer() {
        assert_eq!(bearer(&headers(&[("authorization", "Basic abc")])), None);
        assert_eq!(bearer(&headers(&[("authorization", "Bearer")])), None);
        assert_eq!(bearer(&headers(&[("authorization", "Bearer   ")])), None);
        assert_eq!(bearer(&headers(&[])), None);
    }

    /// One message for every refusal: an agent holding a valid token for one
    /// session must not be able to tell "no such session" from "that is not
    /// your session" from "that session is over", because the three together
    /// enumerate other people's conversations.
    #[test]
    fn every_refusal_renders_the_same_message_to_the_caller() {
        let message = AssistantMcpAuthError::client_message();
        assert!(message.contains("session-scoped bearer"));
        assert!(
            !message.contains("not found") && !message.contains("ended,"),
            "the client-facing message must not distinguish the refusals: {message}"
        );
    }
}