Skip to main content

ai_crew_sync/
auth.rs

1use axum::{
2    extract::{Request, State},
3    http::StatusCode,
4    middleware::Next,
5    response::{IntoResponse, Response},
6};
7use rand::Rng;
8use sha2::{Digest, Sha256};
9use sqlx::PgPool;
10use uuid::Uuid;
11
12pub const TOKEN_PREFIX: &str = "acs_";
13
14/// Header carrying the working context of the caller — in practice one per
15/// repository. Set once in an MCP client's configuration, it then rides on
16/// every request, which is the only option available: the transport is
17/// stateless, so there is nothing to negotiate once and remember.
18pub const SESSION_HEADER: &str = "x-crew-session";
19
20/// A session is a label, not a document. Long enough for a repository name.
21pub const MAX_SESSION_BYTES: usize = 64;
22
23/// Identity resolved from the bearer token, injected into the HTTP request
24/// extensions so tool handlers can read it. Every tool call is scoped to this.
25#[derive(Clone, Debug)]
26pub struct AuthCtx {
27    pub agent_id: Uuid,
28    pub agent_name: String,
29    pub team_id: Uuid,
30    pub team_slug: String,
31    /// Which of the agent's concurrent working contexts is calling. Empty is
32    /// the shared session: what every client that sends no header gets, and
33    /// what every row created before sessions existed carries.
34    ///
35    /// This is deliberately *not* identity. It arrives from a header rather
36    /// than from the token, so it is caller-controlled and must never be used
37    /// to decide **which agent** is speaking — only to partition that agent's
38    /// own presence, claims and locks.
39    pub session: String,
40}
41
42/// Read the session label from the request headers.
43///
44/// Normalised the way channel names are (trimmed, lower-cased) so that
45/// `Market-Data` and `market-data` are one session rather than two that
46/// silently fail to see each other's claims.
47/// Normalise and validate a session label, wherever it arrives from.
48///
49/// Shared by the `X-Crew-Session` header and by the `agent/session` half of a
50/// message address: a label a header would reject must not be reachable by
51/// addressing it instead, or a caller could store sessions that can never
52/// exist, and unbounded strings with them.
53///
54/// Normalised the way channel names are (trimmed, lower-cased) so that
55/// `Market-Data` and `market-data` are one session rather than two that
56/// silently fail to see each other's claims. Returns the reason on rejection
57/// so each caller can wrap it in its own error type.
58pub fn normalize_session(raw: &str) -> Result<String, String> {
59    let label = raw.trim().to_lowercase();
60    if label.is_empty() {
61        return Ok(String::new());
62    }
63    if label.len() > MAX_SESSION_BYTES {
64        return Err(format!(
65            "is {} bytes; the limit is {MAX_SESSION_BYTES}. Use a short label, \
66             such as the repository name",
67            label.len()
68        ));
69    }
70    if !label.is_ascii() {
71        return Err("must be ASCII".to_owned());
72    }
73    if label.chars().any(char::is_control) {
74        return Err("must not contain control characters".to_owned());
75    }
76    // A client whose config format has no default syntax sends the template
77    // itself when the variable is unset. Silently becoming a session named
78    // '${bus_session}' would split presence and claims for a reason nobody
79    // would think to look for.
80    if label.contains(['$', '{', '}']) {
81        return Err(
82            "looks like an unexpanded variable. Set the variable, or use a form \
83                    with a fallback such as ${BUS_SESSION:-} so an unset value sends \
84                    nothing at all"
85                .to_owned(),
86        );
87    }
88    // Reserved: a direct message addresses `agent/session`, so a session
89    // containing a slash would make that address ambiguous — and it is what
90    // keeps the read-cursor keys collision-free.
91    if label.contains('/') {
92        return Err(
93            "must not contain '/', which separates agent from session when \
94                    addressing a message"
95                .to_owned(),
96        );
97    }
98    Ok(label)
99}
100
101/// Read the session label from the request headers.
102fn session_from_headers(headers: &axum::http::HeaderMap) -> Result<String, AuthError> {
103    let Some(value) = headers.get(SESSION_HEADER) else {
104        return Ok(String::new());
105    };
106    let raw = value
107        .to_str()
108        .map_err(|_| AuthError::BadSession("must be ASCII".to_owned()))?;
109    normalize_session(raw).map_err(AuthError::BadSession)
110}
111
112/// Generate a fresh opaque token. Returned once, never stored in the clear.
113pub fn generate_token() -> String {
114    let mut bytes = [0u8; 32];
115    rand::rng().fill_bytes(&mut bytes);
116    format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
117}
118
119pub fn hash_token(raw: &str) -> Vec<u8> {
120    Sha256::digest(raw.trim().as_bytes()).to_vec()
121}
122
123/// First 12 characters, kept in plaintext purely so humans can tell tokens
124/// apart in `ai-crew-sync token list`.
125pub fn token_prefix(raw: &str) -> String {
126    raw.chars().take(12).collect()
127}
128
129struct AuthRow {
130    token_id: Uuid,
131    agent_id: Uuid,
132    agent_name: String,
133    agent_disabled: bool,
134    team_id: Uuid,
135    team_slug: String,
136}
137
138pub async fn resolve_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
139    if !raw.starts_with(TOKEN_PREFIX) {
140        return Err(AuthError::Invalid);
141    }
142    let hash = hash_token(raw);
143
144    let row = sqlx::query_as::<
145        _,
146        (
147            Uuid,
148            Uuid,
149            String,
150            Option<chrono::DateTime<chrono::Utc>>,
151            Uuid,
152            String,
153        ),
154    >(
155        r#"
156        SELECT t.id, a.id, a.name, a.disabled_at, tm.id, tm.slug
157        FROM api_tokens t
158        JOIN agents a ON a.id = t.agent_id
159        JOIN teams tm ON tm.id = a.team_id
160        WHERE t.token_hash = $1 AND t.revoked_at IS NULL
161        "#,
162    )
163    .bind(&hash)
164    .fetch_optional(pool)
165    .await
166    .map_err(|e| {
167        tracing::error!(error = %e, "token lookup failed");
168        AuthError::Internal
169    })?;
170
171    let Some((token_id, agent_id, agent_name, disabled_at, team_id, team_slug)) = row else {
172        return Err(AuthError::Invalid);
173    };
174    let row = AuthRow {
175        token_id,
176        agent_id,
177        agent_name,
178        agent_disabled: disabled_at.is_some(),
179        team_id,
180        team_slug,
181    };
182
183    if row.agent_disabled {
184        return Err(AuthError::Disabled);
185    }
186
187    // Best-effort: record usage without blocking the request path on failure.
188    let _ = sqlx::query("UPDATE api_tokens SET last_used_at = now() WHERE id = $1")
189        .bind(row.token_id)
190        .execute(pool)
191        .await;
192
193    Ok(AuthCtx {
194        agent_id: row.agent_id,
195        agent_name: row.agent_name,
196        team_id: row.team_id,
197        team_slug: row.team_slug,
198        // Filled in by the middleware from the request headers; the token
199        // itself says nothing about which session is using it.
200        session: String::new(),
201    })
202}
203
204#[derive(Debug)]
205pub enum AuthError {
206    Missing,
207    Invalid,
208    Disabled,
209    Internal,
210    /// Too many requests for this token; carries the seconds to wait.
211    Throttled(u64),
212    /// The `X-Crew-Session` header is present but unusable; carries what is
213    /// wrong with it.
214    BadSession(String),
215}
216
217impl IntoResponse for AuthError {
218    fn into_response(self) -> Response {
219        let retry_after = match self {
220            AuthError::Throttled(secs) => Some(secs),
221            _ => None,
222        };
223        // Decided before the match below, which consumes `self`.
224        let is_auth_challenge = matches!(self, AuthError::Missing | AuthError::Invalid);
225        // The consumer is a language model: say what to do, not just what
226        // went wrong.
227        let (status, msg) = match self {
228            AuthError::Missing => (StatusCode::UNAUTHORIZED, "missing bearer token".to_owned()),
229            AuthError::Invalid => (
230                StatusCode::UNAUTHORIZED,
231                "invalid or revoked token".to_owned(),
232            ),
233            AuthError::Disabled => (StatusCode::FORBIDDEN, "agent is disabled".to_owned()),
234            AuthError::Internal => (
235                StatusCode::INTERNAL_SERVER_ERROR,
236                "internal error".to_owned(),
237            ),
238            AuthError::Throttled(secs) => (
239                StatusCode::TOO_MANY_REQUESTS,
240                format!(
241                    "rate limit exceeded for this token; retry in {secs}s. \
242                     If you are polling, use wait_for_updates (it blocks until \
243                     something happens) instead of calling in a loop."
244                ),
245            ),
246            AuthError::BadSession(why) => (
247                StatusCode::BAD_REQUEST,
248                format!(
249                    "the {SESSION_HEADER} header {why}. It labels which of your \
250                     concurrent working contexts is calling — one per repository \
251                     is the usual choice. Omit it entirely to use the shared session."
252                ),
253            ),
254        };
255        let body = serde_json::json!({ "error": msg });
256        let mut resp = (status, axum::Json(body)).into_response();
257        if is_auth_challenge {
258            resp.headers_mut().insert(
259                axum::http::header::WWW_AUTHENTICATE,
260                axum::http::HeaderValue::from_static("Bearer"),
261            );
262        }
263        if let Some(secs) = retry_after
264            && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
265        {
266            resp.headers_mut()
267                .insert(axum::http::header::RETRY_AFTER, value);
268        }
269        resp
270    }
271}
272
273/// State for [`require_bearer`]: the pool plus the optional rate limiter.
274#[derive(Clone)]
275pub struct AuthState {
276    pub pool: PgPool,
277    pub limiter: Option<crate::ratelimit::RateLimiter>,
278}
279
280/// Axum middleware: validates the bearer token, charges the per-token rate
281/// limit, and inserts the resulting [`AuthCtx`] into the request extensions,
282/// where rmcp tool handlers read it.
283pub async fn require_bearer(
284    State(state): State<AuthState>,
285    mut req: Request,
286    next: Next,
287) -> Result<Response, AuthError> {
288    let raw = req
289        .headers()
290        .get(axum::http::header::AUTHORIZATION)
291        .and_then(|v| v.to_str().ok())
292        .and_then(|v| {
293            v.strip_prefix("Bearer ")
294                .or_else(|| v.strip_prefix("bearer "))
295        })
296        .map(str::trim)
297        .filter(|v| !v.is_empty())
298        .ok_or(AuthError::Missing)?
299        .to_owned();
300
301    // Charge the bucket on the token's hash before touching the database, so
302    // a flood of invalid tokens costs no query either.
303    if let Some(limiter) = &state.limiter
304        && let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
305    {
306        return Err(AuthError::Throttled(throttled.retry_after_secs));
307    }
308
309    // Validated before the token lookup: a malformed header is the caller's
310    // mistake either way, and rejecting it costs no query.
311    let session = session_from_headers(req.headers())?;
312
313    let mut ctx = resolve_token(&state.pool, &raw).await?;
314    ctx.session = session;
315    tracing::debug!(
316        agent = %ctx.agent_name,
317        team = %ctx.team_slug,
318        session = %ctx.session,
319        "authenticated"
320    );
321    req.extensions_mut().insert(ctx);
322    Ok(next.run(req).await)
323}
324
325#[cfg(test)]
326mod tests {
327    use super::*;
328    use axum::http::{HeaderMap, HeaderValue};
329
330    fn headers(value: &str) -> HeaderMap {
331        let mut h = HeaderMap::new();
332        h.insert(SESSION_HEADER, HeaderValue::from_str(value).unwrap());
333        h
334    }
335
336    fn err(value: &str) -> String {
337        match session_from_headers(&headers(value)) {
338            Err(AuthError::BadSession(why)) => why,
339            other => panic!("expected BadSession, got {other:?}"),
340        }
341    }
342
343    #[test]
344    fn absent_header_is_the_shared_session() {
345        assert_eq!(session_from_headers(&HeaderMap::new()).unwrap(), "");
346    }
347
348    #[test]
349    fn blank_header_is_the_shared_session() {
350        // A client interpolating an unset variable sends whitespace, not a
351        // missing header. That must not become a session named " ".
352        assert_eq!(session_from_headers(&headers("   ")).unwrap(), "");
353    }
354
355    #[test]
356    fn label_is_normalised_like_a_channel_name() {
357        // Otherwise `Market-Data` and `market-data` are two sessions that
358        // cannot see each other's claims.
359        assert_eq!(
360            session_from_headers(&headers("  Market-Data  ")).unwrap(),
361            "market-data"
362        );
363    }
364
365    #[test]
366    fn over_long_label_is_rejected_with_the_limit() {
367        let why = err(&"a".repeat(MAX_SESSION_BYTES + 1));
368        assert!(why.contains(&MAX_SESSION_BYTES.to_string()), "{why}");
369    }
370
371    #[test]
372    fn label_at_the_limit_is_accepted() {
373        let label = "a".repeat(MAX_SESSION_BYTES);
374        assert_eq!(session_from_headers(&headers(&label)).unwrap(), label);
375    }
376
377    #[test]
378    fn an_unexpanded_template_is_rejected_rather_than_becoming_a_session() {
379        let why = err("${BUS_SESSION}");
380        assert!(why.contains("unexpanded"), "{why}");
381    }
382
383    #[test]
384    fn slash_is_rejected_because_it_separates_agent_from_session() {
385        assert!(err("joaquin/market-data").contains('/'));
386    }
387
388    #[test]
389    fn internal_control_character_is_rejected() {
390        // HTTP permits a tab inside a field value, and trimming only removes
391        // the ones at the edges.
392        assert!(err("market\tdata").contains("control"));
393    }
394
395    #[test]
396    fn non_ascii_header_is_rejected() {
397        let mut h = HeaderMap::new();
398        h.insert(
399            SESSION_HEADER,
400            HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
401        );
402        match session_from_headers(&h) {
403            Err(AuthError::BadSession(_)) => {}
404            other => panic!("expected BadSession, got {other:?}"),
405        }
406    }
407}