Skip to main content

atd_server_http/
bearer.rs

1//! Bearer-token parsing + broker handoff.
2//!
3//! SP-streamable-http §4.4 + SP-token-broker-phase2 §4.4: extract
4//! `Authorization: Bearer …`, look it up via the broker's
5//! `resolve_bearer` extension point, surface the result as a typed
6//! outcome the route handler maps to a precise HTTP status + headers.
7//!
8//! Phase-2 mapping (spec §4.4):
9//!
10//! | outcome | HTTP status | header |
11//! |---|---|---|
12//! | `Validated(_)` / `Anonymous` | 200 (continue) | — |
13//! | `HeaderMissing` (require_bearer) | 401 | `WWW-Authenticate: Bearer` |
14//! | `BearerEmpty` | 400 | — |
15//! | `NoBrokerConfigured` | 501 | — |
16//! | `Unknown` (broker `Ok(None)`) | 401 | `WWW-Authenticate: Bearer error="invalid_token"` |
17//! | `Expired` (broker `Err(Expired)`) | 401 | `WWW-Authenticate: Bearer error="invalid_token", error_description="expired"` |
18//! | `Revoked(_)` (broker `Err(Revoked)`) | 401 | `WWW-Authenticate: Bearer error="invalid_token", error_description="revoked"` |
19//! | `BrokerNotConfigured` (broker `Err(NotConfigured)`) | 501 | — |
20//! | `Lookup(_)` (broker `Err(Lookup)` — transient) | 503 | `Retry-After: 5` |
21//! | `Internal(_)` (broker `Err(Internal)` — bug) | 500 | — |
22
23use std::sync::Arc;
24
25use atd_runtime::secrets::{BearerIdentity, BrokerError, TokenBroker};
26use axum::http::{HeaderMap, StatusCode};
27
28/// Outcome of a single bearer resolution.
29///
30/// The route handler in `server.rs` consumes this via [`Self::http_status`]
31/// / [`Self::www_authenticate`] / [`Self::retry_after`] /
32/// [`Self::rejection_message`] to build the response. Variants are
33/// deliberately fine-grained to match SP-token-broker-phase2 §4.4's
34/// observable-distinction contract: adopters' UIs (Celia's "agent
35/// revoked" toast vs "code expired" prompt vs "you mistyped, try
36/// again") need them.
37#[derive(Debug)]
38#[non_exhaustive]
39pub enum BearerOutcome {
40    /// No `Authorization` header present and `require_bearer == false`.
41    /// Route handler proceeds with an empty `CapabilitySet`.
42    Anonymous,
43    /// Bearer header present and broker returned `Ok(Some(identity))`.
44    Validated(BearerIdentity),
45
46    // ---- non-OK outcomes (each maps to a distinct status + headers) ----
47    /// `require_bearer == true` and no `Authorization` header.
48    HeaderMissing,
49    /// `Authorization: Bearer ` present but token after the prefix is
50    /// empty — pure client error (per spec §4.4 input contract).
51    BearerEmpty,
52    /// Header present and recognised as `Bearer …` but no broker is
53    /// wired into `HttpServerConfig.shared.token_broker`. Adopter-side
54    /// deployment misconfiguration (501 — server cannot honor the
55    /// contract it claims).
56    NoBrokerConfigured,
57    /// Broker returned `Ok(None)` — token is well-formed but unknown.
58    Unknown,
59    /// Broker returned `Err(Expired)`.
60    Expired,
61    /// Broker returned `Err(Revoked(reason))`.
62    Revoked(String),
63    /// Broker returned `Err(NotConfigured)` — broker is wired but its
64    /// `resolve_bearer` is the default-impl stub. Distinct from
65    /// `NoBrokerConfigured`: the broker exists, it just won't engage.
66    BrokerNotConfigured,
67    /// Broker returned `Err(Lookup(reason))` — transient look-up
68    /// failure (DB hiccup, network blip). Spec §4.4 says 503 +
69    /// `Retry-After: 5`. The 2026-04 phase-1 doc had this conflated
70    /// with "malformed bearer"; phase-2 clarifies it as transient.
71    Lookup(String),
72    /// Broker returned `Err(Internal(reason))` — broker bug. 500.
73    Internal(String),
74}
75
76impl BearerOutcome {
77    /// HTTP status code this outcome maps to. `Validated` / `Anonymous`
78    /// return `200` as a sentinel — they should not be passed to the
79    /// error response path.
80    pub fn http_status(&self) -> StatusCode {
81        match self {
82            Self::Validated(_) | Self::Anonymous => StatusCode::OK,
83            Self::BearerEmpty => StatusCode::BAD_REQUEST,
84            Self::HeaderMissing | Self::Unknown | Self::Expired | Self::Revoked(_) => {
85                StatusCode::UNAUTHORIZED
86            }
87            Self::NoBrokerConfigured | Self::BrokerNotConfigured => StatusCode::NOT_IMPLEMENTED,
88            Self::Lookup(_) => StatusCode::SERVICE_UNAVAILABLE,
89            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
90        }
91    }
92
93    /// Value for the `WWW-Authenticate` header, or `None` if no
94    /// auth-challenge should be sent.
95    pub fn www_authenticate(&self) -> Option<&'static str> {
96        match self {
97            Self::HeaderMissing => Some(r#"Bearer"#),
98            Self::Unknown => Some(r#"Bearer error="invalid_token""#),
99            Self::Expired => Some(r#"Bearer error="invalid_token", error_description="expired""#),
100            Self::Revoked(_) => {
101                Some(r#"Bearer error="invalid_token", error_description="revoked""#)
102            }
103            _ => None,
104        }
105    }
106
107    /// Value for the `Retry-After` header in seconds, or `None`. Per
108    /// spec §4.4, only the transient `Lookup` outcome sends this.
109    pub fn retry_after(&self) -> Option<u64> {
110        match self {
111            Self::Lookup(_) => Some(5),
112            _ => None,
113        }
114    }
115
116    /// Human-readable rejection message for the error response body.
117    /// `None` for the success / continue outcomes.
118    pub fn rejection_message(&self) -> Option<String> {
119        match self {
120            Self::Validated(_) | Self::Anonymous => None,
121            Self::HeaderMissing => Some("Authorization: Bearer required".into()),
122            Self::BearerEmpty => Some("bearer is empty after `Bearer ` prefix".into()),
123            Self::NoBrokerConfigured => {
124                Some("token broker not configured for HTTP bearer auth".into())
125            }
126            Self::Unknown => Some("bearer not recognised".into()),
127            Self::Expired => Some("bearer expired".into()),
128            Self::Revoked(msg) => Some(format!("bearer revoked: {msg}")),
129            Self::BrokerNotConfigured => {
130                Some("broker does not support bearer auth (override resolve_bearer)".into())
131            }
132            Self::Lookup(msg) => Some(format!("broker lookup failed (transient): {msg}")),
133            Self::Internal(msg) => Some(format!("broker internal error: {msg}")),
134        }
135    }
136
137    /// `true` if this outcome means "continue dispatch"; `false` if
138    /// the response is an early-return error.
139    pub fn is_admitted(&self) -> bool {
140        matches!(self, Self::Validated(_) | Self::Anonymous)
141    }
142}
143
144/// Parse the optional `Authorization: Bearer …` header from `headers`
145/// and run the broker policy described in SP-streamable-http §4.4 +
146/// SP-token-broker-phase2 §4.4. Returns a typed [`BearerOutcome`].
147pub async fn resolve_bearer(
148    headers: &HeaderMap,
149    broker: Option<&Arc<dyn TokenBroker>>,
150    require_bearer: bool,
151) -> BearerOutcome {
152    let raw = headers
153        .get("authorization")
154        .and_then(|v| v.to_str().ok())
155        .and_then(|h| h.strip_prefix("Bearer "));
156
157    let Some(token) = raw else {
158        return if require_bearer {
159            BearerOutcome::HeaderMissing
160        } else {
161            BearerOutcome::Anonymous
162        };
163    };
164
165    // Spec §4.4 input contract: empty bearer is a 400-class client error,
166    // handled before calling the broker.
167    if token.trim().is_empty() {
168        return BearerOutcome::BearerEmpty;
169    }
170
171    let Some(broker) = broker else {
172        return BearerOutcome::NoBrokerConfigured;
173    };
174
175    match broker.resolve_bearer(token).await {
176        Ok(Some(identity)) => BearerOutcome::Validated(identity),
177        Ok(None) => BearerOutcome::Unknown,
178        Err(BrokerError::NotConfigured) => BearerOutcome::BrokerNotConfigured,
179        Err(BrokerError::Expired) => BearerOutcome::Expired,
180        Err(BrokerError::Revoked(msg)) => BearerOutcome::Revoked(msg),
181        Err(BrokerError::Lookup(msg)) => BearerOutcome::Lookup(msg),
182        Err(BrokerError::Internal(msg)) => BearerOutcome::Internal(msg),
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use atd_runtime::secrets::{ResolveBearerFuture, ResolveFuture};
190    use axum::http::HeaderValue;
191
192    fn headers_with_bearer(token: &str) -> HeaderMap {
193        let mut h = HeaderMap::new();
194        h.insert(
195            "authorization",
196            HeaderValue::from_str(&format!("Bearer {token}")).unwrap(),
197        );
198        h
199    }
200
201    #[tokio::test]
202    async fn missing_header_anonymous_mode_returns_anonymous() {
203        let h = HeaderMap::new();
204        let outcome = resolve_bearer(&h, None, false).await;
205        assert!(matches!(outcome, BearerOutcome::Anonymous));
206        assert_eq!(outcome.http_status(), StatusCode::OK);
207        assert!(outcome.is_admitted());
208    }
209
210    #[tokio::test]
211    async fn missing_header_strict_mode_returns_header_missing() {
212        let h = HeaderMap::new();
213        let outcome = resolve_bearer(&h, None, true).await;
214        assert!(matches!(outcome, BearerOutcome::HeaderMissing));
215        assert_eq!(outcome.http_status(), StatusCode::UNAUTHORIZED);
216        assert_eq!(outcome.www_authenticate(), Some("Bearer"));
217        assert!(outcome.retry_after().is_none());
218        assert!(!outcome.is_admitted());
219    }
220
221    #[tokio::test]
222    async fn bearer_present_but_empty_returns_bearer_empty() {
223        let mut h = HeaderMap::new();
224        h.insert("authorization", HeaderValue::from_static("Bearer   "));
225        let outcome = resolve_bearer(&h, None, true).await;
226        assert!(matches!(outcome, BearerOutcome::BearerEmpty));
227        assert_eq!(outcome.http_status(), StatusCode::BAD_REQUEST);
228    }
229
230    #[tokio::test]
231    async fn bearer_without_broker_returns_no_broker_configured() {
232        let h = headers_with_bearer("xyz");
233        let outcome = resolve_bearer(&h, None, true).await;
234        assert!(matches!(outcome, BearerOutcome::NoBrokerConfigured));
235        assert_eq!(outcome.http_status(), StatusCode::NOT_IMPLEMENTED);
236        assert!(outcome.www_authenticate().is_none());
237    }
238
239    // ---- broker-error fixtures + status-mapping coverage ----
240
241    struct FixedErrBroker(BrokerError);
242
243    impl std::fmt::Debug for FixedErrBroker {
244        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
245            f.debug_struct("FixedErrBroker").finish()
246        }
247    }
248
249    impl TokenBroker for FixedErrBroker {
250        fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
251            Box::pin(async { Ok(None) })
252        }
253        fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
254            let err = match &self.0 {
255                BrokerError::NotConfigured => BrokerError::NotConfigured,
256                BrokerError::Expired => BrokerError::Expired,
257                BrokerError::Revoked(s) => BrokerError::Revoked(s.clone()),
258                BrokerError::Lookup(s) => BrokerError::Lookup(s.clone()),
259                BrokerError::Internal(s) => BrokerError::Internal(s.clone()),
260            };
261            Box::pin(async move { Err(err) })
262        }
263    }
264
265    fn arc_broker(err: BrokerError) -> Arc<dyn TokenBroker> {
266        Arc::new(FixedErrBroker(err))
267    }
268
269    #[tokio::test]
270    async fn broker_returns_none_maps_to_unknown_401_invalid_token() {
271        struct NoneBroker;
272        impl std::fmt::Debug for NoneBroker {
273            fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274                f.debug_struct("NoneBroker").finish()
275            }
276        }
277        impl TokenBroker for NoneBroker {
278            fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
279                Box::pin(async { Ok(None) })
280            }
281            fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
282                Box::pin(async { Ok(None) })
283            }
284        }
285        let h = headers_with_bearer("anything");
286        let broker: Arc<dyn TokenBroker> = Arc::new(NoneBroker);
287        let outcome = resolve_bearer(&h, Some(&broker), true).await;
288        assert!(matches!(outcome, BearerOutcome::Unknown));
289        assert_eq!(outcome.http_status(), StatusCode::UNAUTHORIZED);
290        assert_eq!(
291            outcome.www_authenticate(),
292            Some(r#"Bearer error="invalid_token""#)
293        );
294    }
295
296    #[tokio::test]
297    async fn broker_expired_maps_to_401_with_error_description_expired() {
298        let h = headers_with_bearer("anything");
299        let broker = arc_broker(BrokerError::Expired);
300        let outcome = resolve_bearer(&h, Some(&broker), true).await;
301        assert!(matches!(outcome, BearerOutcome::Expired));
302        assert_eq!(outcome.http_status(), StatusCode::UNAUTHORIZED);
303        assert_eq!(
304            outcome.www_authenticate(),
305            Some(r#"Bearer error="invalid_token", error_description="expired""#)
306        );
307    }
308
309    #[tokio::test]
310    async fn broker_revoked_maps_to_401_with_error_description_revoked() {
311        let h = headers_with_bearer("anything");
312        let broker = arc_broker(BrokerError::Revoked("user revoked".into()));
313        let outcome = resolve_bearer(&h, Some(&broker), true).await;
314        match &outcome {
315            BearerOutcome::Revoked(msg) => assert_eq!(msg, "user revoked"),
316            other => panic!("expected Revoked, got {other:?}"),
317        }
318        assert_eq!(outcome.http_status(), StatusCode::UNAUTHORIZED);
319        assert_eq!(
320            outcome.www_authenticate(),
321            Some(r#"Bearer error="invalid_token", error_description="revoked""#)
322        );
323    }
324
325    #[tokio::test]
326    async fn broker_lookup_maps_to_503_with_retry_after_5() {
327        let h = headers_with_bearer("anything");
328        let broker = arc_broker(BrokerError::Lookup("sqlite locked".into()));
329        let outcome = resolve_bearer(&h, Some(&broker), true).await;
330        assert!(matches!(outcome, BearerOutcome::Lookup(_)));
331        assert_eq!(outcome.http_status(), StatusCode::SERVICE_UNAVAILABLE);
332        assert_eq!(outcome.retry_after(), Some(5));
333        // No WWW-Authenticate; this is a server-side hiccup, not an
334        // auth challenge.
335        assert!(outcome.www_authenticate().is_none());
336    }
337
338    #[tokio::test]
339    async fn broker_internal_maps_to_500_no_headers() {
340        let h = headers_with_bearer("anything");
341        let broker = arc_broker(BrokerError::Internal("oh no".into()));
342        let outcome = resolve_bearer(&h, Some(&broker), true).await;
343        assert!(matches!(outcome, BearerOutcome::Internal(_)));
344        assert_eq!(outcome.http_status(), StatusCode::INTERNAL_SERVER_ERROR);
345        assert!(outcome.www_authenticate().is_none());
346        assert!(outcome.retry_after().is_none());
347    }
348
349    #[tokio::test]
350    async fn broker_not_configured_maps_to_501() {
351        let h = headers_with_bearer("anything");
352        let broker = arc_broker(BrokerError::NotConfigured);
353        let outcome = resolve_bearer(&h, Some(&broker), true).await;
354        assert!(matches!(outcome, BearerOutcome::BrokerNotConfigured));
355        assert_eq!(outcome.http_status(), StatusCode::NOT_IMPLEMENTED);
356    }
357
358    // ---- happy path ----
359
360    struct FixedOkBroker(BearerIdentity);
361    impl std::fmt::Debug for FixedOkBroker {
362        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
363            f.debug_struct("FixedOkBroker").finish()
364        }
365    }
366    impl TokenBroker for FixedOkBroker {
367        fn resolve<'a>(&'a self, _caller_id: Option<&'a str>) -> ResolveFuture<'a> {
368            Box::pin(async { Ok(None) })
369        }
370        fn resolve_bearer<'a>(&'a self, _bearer: &'a str) -> ResolveBearerFuture<'a> {
371            let id = self.0.clone();
372            Box::pin(async move { Ok(Some(id)) })
373        }
374    }
375
376    #[tokio::test]
377    async fn good_bearer_returns_validated_identity() {
378        let id = BearerIdentity {
379            caller_id: "agent-A".into(),
380            granted_capabilities: vec!["records:read".into()],
381            secrets: None,
382            expires_at: None,
383            cache_until: None,
384        };
385        let h = headers_with_bearer("good-token");
386        let broker: Arc<dyn TokenBroker> = Arc::new(FixedOkBroker(id));
387        let outcome = resolve_bearer(&h, Some(&broker), true).await;
388        match &outcome {
389            BearerOutcome::Validated(id) => {
390                assert_eq!(id.caller_id, "agent-A");
391                assert_eq!(id.granted_capabilities, vec!["records:read"]);
392            }
393            other => panic!("expected Validated, got {other:?}"),
394        }
395        assert_eq!(outcome.http_status(), StatusCode::OK);
396        assert!(outcome.is_admitted());
397    }
398}