Skip to main content

ijima_server/
extractor.rs

1// Copyright (C) 2026 Industrial Algebra
2// SPDX-License-Identifier: Apache-2.0
3
4//! Axum extractor that authenticates requests via a Schubert capability
5//! token in the `Authorization: Bearer <token>` header.
6//!
7//! Requires both `http` and `server-auth`. The [`IjimaAuth`] is shared
8//! via axum's built-in [`Extension`] layer
9//! (`router.layer(Extension(Arc::new(ijima_auth)))`). The handler
10//! obtains an [`AuthenticatedPrincipal`] as a parameter; deeper
11//! capability checks go through [`crate::IjimaAuth::require`] on the
12//! same shared state.
13
14use axum::{
15    Extension,
16    extract::FromRequestParts,
17    http::{StatusCode, request::Parts},
18    response::IntoResponse,
19};
20use std::sync::Arc;
21
22use crate::auth::{AuthenticatedPrincipal, IjimaAuth};
23
24/// Extractor: validates the bearer token and yields the authenticated
25/// principal + capability.
26///
27/// ```ignore
28/// async fn handler(auth: AuthPrincipal) -> impl IntoResponse {
29///     format!("hello {}", auth.0.principal)
30/// }
31/// ```
32#[derive(Debug, Clone)]
33pub struct AuthPrincipal(pub AuthenticatedPrincipal);
34
35/// Error returned when authentication fails; maps to HTTP 401. When
36/// Schubert rate limiting is enabled and the principal's token bucket is
37/// exhausted, [`AuthRejection::RateLimited`] maps to HTTP 429.
38#[derive(Debug)]
39pub enum AuthRejection {
40    /// Authentication failed (missing/invalid token).
41    Unauthorized(&'static str),
42    /// Rate limit exceeded (Schubert `RateLimitExceeded`).
43    #[cfg(feature = "rate-limit")]
44    RateLimited,
45}
46
47/// Back-compat alias: the historical single-variant name.
48pub type AuthError = AuthRejection;
49
50impl IntoResponse for AuthRejection {
51    fn into_response(self) -> axum::response::Response {
52        match self {
53            AuthRejection::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg).into_response(),
54            #[cfg(feature = "rate-limit")]
55            AuthRejection::RateLimited => {
56                (StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response()
57            }
58        }
59    }
60}
61
62impl<S> FromRequestParts<S> for AuthPrincipal
63where
64    S: Send + Sync,
65{
66    type Rejection = AuthRejection;
67
68    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
69        let Extension(auth): Extension<Arc<IjimaAuth>> =
70            Extension::from_request_parts(parts, _state)
71                .await
72                .map_err(|_| AuthRejection::Unauthorized("auth state not installed"))?;
73
74        let header = parts
75            .headers
76            .get(axum::http::header::AUTHORIZATION)
77            .and_then(|h| h.to_str().ok())
78            .ok_or(AuthRejection::Unauthorized("missing Authorization header"))?;
79
80        let token = header
81            .strip_prefix("Bearer ")
82            .ok_or(AuthRejection::Unauthorized("expected 'Bearer <token>'"))?;
83
84        let principal = auth
85            .verify_bearer(token)
86            .map_err(|_| AuthRejection::Unauthorized("invalid capability token"))?;
87
88        // Schubert rate limiting: if a RateLimitState extension is
89        // installed, consume one token. The capability token drives
90        // authentication, authorization, AND throughput in one step.
91        #[cfg(feature = "rate-limit")]
92        {
93            if let Some(rl) = parts.extensions.get::<crate::rate_limit::RateLimitState>() {
94                crate::rate_limit::consume(rl, &principal)
95                    .map_err(|_| AuthRejection::RateLimited)?;
96            }
97        }
98
99        Ok(AuthPrincipal(principal))
100    }
101}
102
103#[cfg(test)]
104mod tests {
105    use super::*;
106    use ijima_core::capabilities::MEMORY_READ;
107
108    fn build_request(bearer: Option<String>) -> (Parts, ()) {
109        let mut builder = axum::http::Request::<()>::builder();
110        if let Some(b) = bearer {
111            builder = builder.header("authorization", format!("Bearer {b}"));
112        }
113        let req = builder.body(()).unwrap();
114        req.into_parts()
115    }
116
117    #[tokio::test]
118    async fn valid_bearer_header_yields_principal() {
119        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy must load"));
120        let bearer = auth.issue_bearer("elliott", MEMORY_READ).expect("issue");
121
122        let (mut parts, ()) = build_request(Some(bearer));
123        parts.extensions.insert(auth.clone());
124
125        let state: () = ();
126        let got = AuthPrincipal::from_request_parts(&mut parts, &state)
127            .await
128            .expect("must extract");
129        assert_eq!(got.0.principal.as_str(), "elliott");
130        assert_eq!(got.0.capability, MEMORY_READ);
131    }
132
133    #[tokio::test]
134    async fn missing_header_is_401() {
135        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy must load"));
136        let (mut parts, ()) = build_request(None);
137        parts.extensions.insert(auth);
138
139        let state: () = ();
140        let err = AuthPrincipal::from_request_parts(&mut parts, &state)
141            .await
142            .expect_err("must reject");
143        assert_eq!(err.into_response().status(), StatusCode::UNAUTHORIZED);
144    }
145}