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/// Prefix of a session credential: a window's proof of which window it is,
15/// derived from an agent token (see `migrations/0013`). Distinct from both
16/// other prefixes, and deliberately not a `acs_` extension — a session
17/// credential presented where an agent token is expected fails on the prefix
18/// before any lookup.
19pub const SESSION_TOKEN_PREFIX: &str = "acss_";
20
21/// Header carrying the connection epoch of an authenticated session. A
22/// request whose epoch is older than the session's current one belongs to a
23/// connection that has been replaced.
24pub const EPOCH_HEADER: &str = "x-crew-epoch";
25
26/// How long a session credential authenticates for, unless the caller asks
27/// for less. A window outlives a coffee break and not a weekend.
28pub const SESSION_TTL_SECS: i64 = 24 * 3600;
29pub const MAX_SESSION_TTL_SECS: i64 = 24 * 3600;
30
31/// Prefix of an administrative credential. Deliberately not an extension of
32/// [`TOKEN_PREFIX`]: `acsa_` does not start with `acs_`, so an administrative
33/// credential presented to `/mcp` fails the prefix check before any lookup,
34/// and an agent token presented to `/admin` does the same. The two classes
35/// live in different tables and never resolve as each other.
36pub const ADMIN_TOKEN_PREFIX: &str = "acsa_";
37
38/// Header carrying the working context of the caller — in practice one per
39/// repository. Set once in an MCP client's configuration, it then rides on
40/// every request, which is the only option available: the transport is
41/// stateless, so there is nothing to negotiate once and remember.
42pub const SESSION_HEADER: &str = "x-crew-session";
43
44/// A session is a label, not a document. Long enough for a repository name.
45pub const MAX_SESSION_BYTES: usize = 64;
46
47/// Identity resolved from the bearer token, injected into the HTTP request
48/// extensions so tool handlers can read it. Every tool call is scoped to this.
49#[derive(Clone, Debug)]
50pub struct AuthCtx {
51    pub agent_id: Uuid,
52    pub agent_name: String,
53    pub team_id: Uuid,
54    pub team_slug: String,
55    /// Which of the agent's concurrent working contexts is calling. Empty is
56    /// the shared session: what every client that sends no header gets, and
57    /// what every row created before sessions existed carries.
58    ///
59    /// This is deliberately *not* identity. It arrives from a header rather
60    /// than from the token, so it is caller-controlled and must never be used
61    /// to decide **which agent** is speaking — only to partition that agent's
62    /// own presence, claims and locks.
63    ///
64    /// When [`Self::session_id`] is set the value was *proven* rather than
65    /// asserted: it came from the session credential, and a header that
66    /// disagreed was refused before this struct existed.
67    pub session: String,
68    /// Set when the caller authenticated with a session credential
69    /// (`acss_…`): the row in `agent_sessions` it belongs to. `None` is a
70    /// plain agent token, where `session` is only a label.
71    pub session_id: Option<Uuid>,
72    /// Connection epoch of that session, for callers that fence stale
73    /// connections. `None` without a session credential.
74    pub session_epoch: Option<i64>,
75    /// The `api_tokens` row that authenticated this request, when it was an
76    /// agent token. Carried so that registering a session can hang it off
77    /// the exact credential used *without* the tool layer ever handling the
78    /// secret. `None` for a session credential (which cannot register) and
79    /// for the dashboard's team-only context.
80    pub token_id: Option<Uuid>,
81}
82
83impl AuthCtx {
84    /// True when the session label was proven by a credential rather than
85    /// asserted in a header. Anything that gates *access* on a session must
86    /// require this; anything that merely partitions one agent's own work
87    /// does not.
88    pub fn session_is_authenticated(&self) -> bool {
89        self.session_id.is_some()
90    }
91}
92
93/// Read the session label from the request headers.
94///
95/// Normalised the way channel names are (trimmed, lower-cased) so that
96/// `Market-Data` and `market-data` are one session rather than two that
97/// silently fail to see each other's claims.
98/// Normalise and validate a session label, wherever it arrives from.
99///
100/// Shared by the `X-Crew-Session` header and by the `agent/session` half of a
101/// message address: a label a header would reject must not be reachable by
102/// addressing it instead, or a caller could store sessions that can never
103/// exist, and unbounded strings with them.
104///
105/// Normalised the way channel names are (trimmed, lower-cased) so that
106/// `Market-Data` and `market-data` are one session rather than two that
107/// silently fail to see each other's claims. Returns the reason on rejection
108/// so each caller can wrap it in its own error type.
109pub fn normalize_session(raw: &str) -> Result<String, String> {
110    let label = raw.trim().to_lowercase();
111    if label.is_empty() {
112        return Ok(String::new());
113    }
114    if label.len() > MAX_SESSION_BYTES {
115        return Err(format!(
116            "is {} bytes; the limit is {MAX_SESSION_BYTES}. Use a short label, \
117             such as the repository name",
118            label.len()
119        ));
120    }
121    if !label.is_ascii() {
122        return Err("must be ASCII".to_owned());
123    }
124    if label.chars().any(char::is_control) {
125        return Err("must not contain control characters".to_owned());
126    }
127    // A client whose config format has no default syntax sends the template
128    // itself when the variable is unset. Silently becoming a session named
129    // '${bus_session}' would split presence and claims for a reason nobody
130    // would think to look for.
131    if label.contains(['$', '{', '}']) {
132        return Err(
133            "looks like an unexpanded variable. Set the variable, or use a form \
134                    with a fallback such as ${BUS_SESSION:-} so an unset value sends \
135                    nothing at all"
136                .to_owned(),
137        );
138    }
139    // Reserved: a direct message addresses `agent/session`, so a session
140    // containing a slash would make that address ambiguous — and it is what
141    // keeps the read-cursor keys collision-free.
142    if label.contains('/') {
143        return Err(
144            "must not contain '/', which separates agent from session when \
145                    addressing a message"
146                .to_owned(),
147        );
148    }
149    Ok(label)
150}
151
152/// Read the connection epoch from the request headers, when one is sent.
153/// Absent means "do not fence me", which is what every existing client sends.
154fn epoch_from_headers(headers: &axum::http::HeaderMap) -> Result<Option<i64>, AuthError> {
155    let Some(value) = headers.get(EPOCH_HEADER) else {
156        return Ok(None);
157    };
158    let raw = value
159        .to_str()
160        .ok()
161        .map(str::trim)
162        .filter(|v| !v.is_empty())
163        .ok_or_else(|| AuthError::BadSession(format!("{EPOCH_HEADER} must be ASCII")))?;
164    let epoch: i64 = raw
165        .parse()
166        .map_err(|_| AuthError::BadSession(format!("{EPOCH_HEADER} must be a positive integer")))?;
167    if epoch <= 0 {
168        return Err(AuthError::BadSession(format!(
169            "{EPOCH_HEADER} must be a positive integer"
170        )));
171    }
172    Ok(Some(epoch))
173}
174
175/// Read the session label from the request headers.
176fn session_from_headers(headers: &axum::http::HeaderMap) -> Result<String, AuthError> {
177    let Some(value) = headers.get(SESSION_HEADER) else {
178        return Ok(String::new());
179    };
180    let raw = value
181        .to_str()
182        .map_err(|_| AuthError::BadSession("must be ASCII".to_owned()))?;
183    normalize_session(raw).map_err(AuthError::BadSession)
184}
185
186/// Generate a fresh opaque token. Returned once, never stored in the clear.
187pub fn generate_token() -> String {
188    let mut bytes = [0u8; 32];
189    rand::rng().fill_bytes(&mut bytes);
190    format!("{TOKEN_PREFIX}{}", hex::encode(bytes))
191}
192
193/// Generate a fresh session credential.
194pub fn generate_session_token() -> String {
195    let mut bytes = [0u8; 32];
196    rand::rng().fill_bytes(&mut bytes);
197    format!("{SESSION_TOKEN_PREFIX}{}", hex::encode(bytes))
198}
199
200/// Generate a fresh administrative credential. Same entropy and hashing as
201/// an agent token; only the prefix differs.
202pub fn generate_admin_token() -> String {
203    let mut bytes = [0u8; 32];
204    rand::rng().fill_bytes(&mut bytes);
205    format!("{ADMIN_TOKEN_PREFIX}{}", hex::encode(bytes))
206}
207
208pub fn hash_token(raw: &str) -> Vec<u8> {
209    Sha256::digest(raw.trim().as_bytes()).to_vec()
210}
211
212/// First 12 characters, kept in plaintext purely so humans can tell tokens
213/// apart in `ai-crew-sync token list`.
214pub fn token_prefix(raw: &str) -> String {
215    raw.chars().take(12).collect()
216}
217
218struct AuthRow {
219    token_id: Uuid,
220    agent_id: Uuid,
221    agent_name: String,
222    agent_disabled: bool,
223    team_id: Uuid,
224    team_slug: String,
225}
226
227pub async fn resolve_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
228    if raw.starts_with(SESSION_TOKEN_PREFIX) {
229        return resolve_session_token(pool, raw).await;
230    }
231    if !raw.starts_with(TOKEN_PREFIX) {
232        return Err(AuthError::Invalid);
233    }
234    let hash = hash_token(raw);
235
236    let row = sqlx::query_as::<
237        _,
238        (
239            Uuid,
240            Uuid,
241            String,
242            Option<chrono::DateTime<chrono::Utc>>,
243            Uuid,
244            String,
245        ),
246    >(
247        r#"
248        SELECT t.id, a.id, a.name, a.disabled_at, tm.id, tm.slug
249        FROM api_tokens t
250        JOIN agents a ON a.id = t.agent_id
251        JOIN teams tm ON tm.id = a.team_id
252        WHERE t.token_hash = $1 AND t.revoked_at IS NULL
253        "#,
254    )
255    .bind(&hash)
256    .fetch_optional(pool)
257    .await
258    .map_err(|e| {
259        tracing::error!(error = %e, "token lookup failed");
260        AuthError::Internal
261    })?;
262
263    let Some((token_id, agent_id, agent_name, disabled_at, team_id, team_slug)) = row else {
264        return Err(AuthError::Invalid);
265    };
266    let row = AuthRow {
267        token_id,
268        agent_id,
269        agent_name,
270        agent_disabled: disabled_at.is_some(),
271        team_id,
272        team_slug,
273    };
274
275    if row.agent_disabled {
276        return Err(AuthError::Disabled);
277    }
278
279    // Best-effort: record usage without blocking the request path on failure.
280    let _ = sqlx::query("UPDATE api_tokens SET last_used_at = now() WHERE id = $1")
281        .bind(row.token_id)
282        .execute(pool)
283        .await;
284
285    Ok(AuthCtx {
286        agent_id: row.agent_id,
287        agent_name: row.agent_name,
288        team_id: row.team_id,
289        team_slug: row.team_slug,
290        // Filled in by the middleware from the request headers; the token
291        // itself says nothing about which session is using it.
292        session: String::new(),
293        session_id: None,
294        session_epoch: None,
295        token_id: Some(row.token_id),
296    })
297}
298
299/// Resolve a session credential. Everything it is comes from the parent
300/// token: agent, team, and whether it may authenticate at all. One query, so
301/// a revoked parent or a disabled agent cannot be raced past.
302async fn resolve_session_token(pool: &PgPool, raw: &str) -> Result<AuthCtx, AuthError> {
303    let row: Option<(
304        Uuid,
305        String,
306        i64,
307        Uuid,
308        String,
309        Option<chrono::DateTime<chrono::Utc>>,
310        Uuid,
311        String,
312        bool,
313        bool,
314        bool,
315    )> = sqlx::query_as(
316        r#"
317        SELECT s.id,
318               s.label,
319               s.epoch,
320               a.id,
321               a.name,
322               a.disabled_at,
323               tm.id,
324               tm.slug,
325               (s.revoked_at IS NOT NULL) AS session_revoked,
326               (s.expires_at <= now()) AS session_expired,
327               (t.revoked_at IS NOT NULL) AS parent_revoked
328        FROM agent_sessions s
329        JOIN api_tokens t ON t.id = s.parent_token
330        JOIN agents a ON a.id = s.agent_id
331        JOIN teams tm ON tm.id = a.team_id
332        WHERE s.token_hash = $1
333        "#,
334    )
335    .bind(hash_token(raw))
336    .fetch_optional(pool)
337    .await
338    .map_err(|e| {
339        tracing::error!(error = %e, "session lookup failed");
340        AuthError::Internal
341    })?;
342
343    let Some((
344        session_id,
345        label,
346        epoch,
347        agent_id,
348        agent_name,
349        agent_disabled,
350        team_id,
351        team_slug,
352        session_revoked,
353        session_expired,
354        parent_revoked,
355    )) = row
356    else {
357        return Err(AuthError::Invalid);
358    };
359    if agent_disabled.is_some() {
360        return Err(AuthError::Disabled);
361    }
362    // A session is not a credential of its own: it lives exactly as long as
363    // the token it was derived from.
364    if parent_revoked || session_revoked {
365        return Err(AuthError::Invalid);
366    }
367    if session_expired {
368        return Err(AuthError::SessionExpired);
369    }
370
371    // Usage bookkeeping must never queue behind a write in flight. A guarded
372    // mutation holds a share lock on this row for its whole transaction, so a
373    // plain UPDATE here would make every concurrent request of the same
374    // window wait for it. SKIP LOCKED steps aside instead, and the one-minute
375    // floor means a busy window writes this once a minute rather than once a
376    // call. Best-effort either way: it is a timestamp, not the request.
377    let _ = sqlx::query(
378        "UPDATE agent_sessions SET last_used_at = now()
379          WHERE id IN (
380              SELECT id FROM agent_sessions
381               WHERE id = $1
382                 AND (last_used_at IS NULL OR last_used_at < now() - interval '60 seconds')
383               FOR UPDATE SKIP LOCKED
384          )",
385    )
386    .bind(session_id)
387    .execute(pool)
388    .await;
389
390    Ok(AuthCtx {
391        agent_id,
392        agent_name,
393        team_id,
394        team_slug,
395        // Proven, not asserted: the middleware refuses a header that
396        // disagrees rather than letting it win.
397        session: label,
398        session_id: Some(session_id),
399        session_epoch: Some(epoch),
400        // A session credential is not an agent token: it may not register.
401        token_id: None,
402    })
403}
404
405#[derive(Debug)]
406pub enum AuthError {
407    Missing,
408    Invalid,
409    Disabled,
410    Internal,
411    /// Too many requests for this token; carries the seconds to wait.
412    Throttled(u64),
413    /// The `X-Crew-Session` header is present but unusable; carries what is
414    /// wrong with it.
415    BadSession(String),
416    /// A session credential whose 24-hour lifetime ran out.
417    SessionExpired,
418    /// The header says one session, the credential proves another.
419    SessionMismatch {
420        proven: String,
421        claimed: String,
422    },
423    /// The request's epoch is older than the session's; this connection was
424    /// replaced. Carries the current epoch.
425    StaleEpoch {
426        current: i64,
427        sent: i64,
428    },
429}
430
431impl IntoResponse for AuthError {
432    fn into_response(self) -> Response {
433        let retry_after = match self {
434            AuthError::Throttled(secs) => Some(secs),
435            _ => None,
436        };
437        // Decided before the match below, which consumes `self`.
438        let is_auth_challenge = matches!(self, AuthError::Missing | AuthError::Invalid);
439        // The consumer is a language model: say what to do, not just what
440        // went wrong.
441        let (status, msg) = match self {
442            AuthError::Missing => (StatusCode::UNAUTHORIZED, "missing bearer token".to_owned()),
443            AuthError::Invalid => (
444                StatusCode::UNAUTHORIZED,
445                "invalid or revoked token".to_owned(),
446            ),
447            AuthError::Disabled => (StatusCode::FORBIDDEN, "agent is disabled".to_owned()),
448            AuthError::Internal => (
449                StatusCode::INTERNAL_SERVER_ERROR,
450                "internal error".to_owned(),
451            ),
452            AuthError::Throttled(secs) => (
453                StatusCode::TOO_MANY_REQUESTS,
454                format!(
455                    "rate limit exceeded for this token; retry in {secs}s. \
456                     If you are polling, use wait_for_updates (it blocks until \
457                     something happens) instead of calling in a loop."
458                ),
459            ),
460            AuthError::SessionExpired => (
461                StatusCode::UNAUTHORIZED,
462                "this session credential has expired. Register a new session with \
463                 register_session using your agent token; your session label, and \
464                 everything filed under it, is unchanged."
465                    .to_owned(),
466            ),
467            AuthError::SessionMismatch { proven, claimed } => (
468                StatusCode::FORBIDDEN,
469                format!(
470                    "the {SESSION_HEADER} header says '{claimed}' but this credential \
471                     authenticates session '{proven}'. A session credential proves which \
472                     window it is; drop the header, or send the one you hold."
473                ),
474            ),
475            AuthError::StaleEpoch { current, sent } => (
476                StatusCode::CONFLICT,
477                format!(
478                    "this connection is stale: it carries epoch {sent} and the session is \
479                     at {current}, so another process resumed this window after you. Stop \
480                     writing as it — resume the session to take over, or exit."
481                ),
482            ),
483            AuthError::BadSession(why) => (
484                StatusCode::BAD_REQUEST,
485                format!(
486                    "the {SESSION_HEADER} header {why}. It labels which of your \
487                     concurrent working contexts is calling — one per repository \
488                     is the usual choice. Omit it entirely to use the shared session."
489                ),
490            ),
491        };
492        let body = serde_json::json!({ "error": msg });
493        let mut resp = (status, axum::Json(body)).into_response();
494        if is_auth_challenge {
495            resp.headers_mut().insert(
496                axum::http::header::WWW_AUTHENTICATE,
497                axum::http::HeaderValue::from_static("Bearer"),
498            );
499        }
500        if let Some(secs) = retry_after
501            && let Ok(value) = axum::http::HeaderValue::from_str(&secs.to_string())
502        {
503            resp.headers_mut()
504                .insert(axum::http::header::RETRY_AFTER, value);
505        }
506        resp
507    }
508}
509
510/// State for [`require_bearer`]: the pool plus the optional rate limiter.
511#[derive(Clone)]
512pub struct AuthState {
513    pub pool: PgPool,
514    pub limiter: Option<crate::ratelimit::RateLimiter>,
515}
516
517/// Axum middleware: validates the bearer token, charges the per-token rate
518/// limit, and inserts the resulting [`AuthCtx`] into the request extensions,
519/// where rmcp tool handlers read it.
520pub async fn require_bearer(
521    State(state): State<AuthState>,
522    mut req: Request,
523    next: Next,
524) -> Result<Response, AuthError> {
525    let raw = req
526        .headers()
527        .get(axum::http::header::AUTHORIZATION)
528        .and_then(|v| v.to_str().ok())
529        .and_then(|v| {
530            v.strip_prefix("Bearer ")
531                .or_else(|| v.strip_prefix("bearer "))
532        })
533        .map(str::trim)
534        .filter(|v| !v.is_empty())
535        .ok_or(AuthError::Missing)?
536        .to_owned();
537
538    // Charge the bucket on the token's hash before touching the database, so
539    // a flood of invalid tokens costs no query either.
540    if let Some(limiter) = &state.limiter
541        && let Err(throttled) = limiter.check(&hex::encode(hash_token(&raw)))
542    {
543        return Err(AuthError::Throttled(throttled.retry_after_secs));
544    }
545
546    // Validated before the token lookup: a malformed header is the caller's
547    // mistake either way, and rejecting it costs no query.
548    let session = session_from_headers(req.headers())?;
549    let epoch = epoch_from_headers(req.headers())?;
550
551    let mut ctx = resolve_token(&state.pool, &raw).await?;
552    match ctx.session_id {
553        // An agent token: the header *is* the session, as it always was.
554        None => ctx.session = session,
555        // A session credential: the label is proven. A header that agrees is
556        // harmless and one that disagrees is refused, so a caller can never
557        // widen a proven session into someone else's by sending a label.
558        Some(_) => {
559            if !session.is_empty() && session != ctx.session {
560                return Err(AuthError::SessionMismatch {
561                    proven: ctx.session,
562                    claimed: session,
563                });
564            }
565            if let (Some(current), Some(sent)) = (ctx.session_epoch, epoch)
566                && sent < current
567            {
568                return Err(AuthError::StaleEpoch { current, sent });
569            }
570        }
571    }
572    tracing::debug!(
573        agent = %ctx.agent_name,
574        team = %ctx.team_slug,
575        session = %ctx.session,
576        "authenticated"
577    );
578    req.extensions_mut().insert(ctx);
579    Ok(next.run(req).await)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use axum::http::{HeaderMap, HeaderValue};
586
587    fn headers(value: &str) -> HeaderMap {
588        let mut h = HeaderMap::new();
589        h.insert(SESSION_HEADER, HeaderValue::from_str(value).unwrap());
590        h
591    }
592
593    fn err(value: &str) -> String {
594        match session_from_headers(&headers(value)) {
595            Err(AuthError::BadSession(why)) => why,
596            other => panic!("expected BadSession, got {other:?}"),
597        }
598    }
599
600    #[test]
601    fn absent_header_is_the_shared_session() {
602        assert_eq!(session_from_headers(&HeaderMap::new()).unwrap(), "");
603    }
604
605    #[test]
606    fn blank_header_is_the_shared_session() {
607        // A client interpolating an unset variable sends whitespace, not a
608        // missing header. That must not become a session named " ".
609        assert_eq!(session_from_headers(&headers("   ")).unwrap(), "");
610    }
611
612    #[test]
613    fn label_is_normalised_like_a_channel_name() {
614        // Otherwise `Market-Data` and `market-data` are two sessions that
615        // cannot see each other's claims.
616        assert_eq!(
617            session_from_headers(&headers("  Market-Data  ")).unwrap(),
618            "market-data"
619        );
620    }
621
622    #[test]
623    fn over_long_label_is_rejected_with_the_limit() {
624        let why = err(&"a".repeat(MAX_SESSION_BYTES + 1));
625        assert!(why.contains(&MAX_SESSION_BYTES.to_string()), "{why}");
626    }
627
628    #[test]
629    fn label_at_the_limit_is_accepted() {
630        let label = "a".repeat(MAX_SESSION_BYTES);
631        assert_eq!(session_from_headers(&headers(&label)).unwrap(), label);
632    }
633
634    #[test]
635    fn an_unexpanded_template_is_rejected_rather_than_becoming_a_session() {
636        let why = err("${BUS_SESSION}");
637        assert!(why.contains("unexpanded"), "{why}");
638    }
639
640    #[test]
641    fn slash_is_rejected_because_it_separates_agent_from_session() {
642        assert!(err("joaquin/market-data").contains('/'));
643    }
644
645    #[test]
646    fn internal_control_character_is_rejected() {
647        // HTTP permits a tab inside a field value, and trimming only removes
648        // the ones at the edges.
649        assert!(err("market\tdata").contains("control"));
650    }
651
652    #[test]
653    fn non_ascii_header_is_rejected() {
654        let mut h = HeaderMap::new();
655        h.insert(
656            SESSION_HEADER,
657            HeaderValue::from_bytes(&[0xff, 0xfe]).unwrap(),
658        );
659        match session_from_headers(&h) {
660            Err(AuthError::BadSession(_)) => {}
661            other => panic!("expected BadSession, got {other:?}"),
662        }
663    }
664}