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). Owned string so the
41    /// verify path can carry its detail (expired vs revoked vs forged —
42    /// the ADR-0001 composition rule: which check fired is telemetry).
43    Unauthorized(String),
44    /// Rate limit exceeded (Schubert `RateLimitExceeded`).
45    #[cfg(feature = "rate-limit")]
46    RateLimited,
47}
48
49/// Back-compat alias: the historical single-variant name.
50pub type AuthError = AuthRejection;
51
52impl IntoResponse for AuthRejection {
53    fn into_response(self) -> axum::response::Response {
54        match self {
55            AuthRejection::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg).into_response(),
56            #[cfg(feature = "rate-limit")]
57            AuthRejection::RateLimited => {
58                (StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response()
59            }
60        }
61    }
62}
63
64impl<S> FromRequestParts<S> for AuthPrincipal
65where
66    S: Send + Sync,
67{
68    type Rejection = AuthRejection;
69
70    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
71        let Extension(auth): Extension<Arc<IjimaAuth>> =
72            Extension::from_request_parts(parts, _state)
73                .await
74                .map_err(|_| AuthRejection::Unauthorized("auth state not installed".into()))?;
75
76        let header = parts
77            .headers
78            .get(axum::http::header::AUTHORIZATION)
79            .and_then(|h| h.to_str().ok())
80            .ok_or(AuthRejection::Unauthorized(
81                "missing Authorization header".into(),
82            ))?;
83
84        let token = header
85            .strip_prefix("Bearer ")
86            .ok_or(AuthRejection::Unauthorized(
87                "expected 'Bearer <token>'".into(),
88            ))?;
89
90        let principal = auth.verify_bearer(token).map_err(|e| {
91            // Surface the reason ("token revoked", "grant verify: grant
92            // expired …", decode failures) instead of a blanket message —
93            // operators must distinguish deprovisioning from forgery.
94            AuthRejection::Unauthorized(e.to_string())
95        })?;
96
97        // Schubert rate limiting: if a RateLimitState extension is
98        // installed, consume one token. The capability token drives
99        // authentication, authorization, AND throughput in one step.
100        #[cfg(feature = "rate-limit")]
101        {
102            if let Some(rl) = parts.extensions.get::<crate::rate_limit::RateLimitState>() {
103                crate::rate_limit::consume(rl, &principal)
104                    .map_err(|_| AuthRejection::RateLimited)?;
105            }
106        }
107
108        Ok(AuthPrincipal(principal))
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use ijima_core::capabilities::MEMORY_READ;
116
117    fn build_request(bearer: Option<String>) -> (Parts, ()) {
118        let mut builder = axum::http::Request::<()>::builder();
119        if let Some(b) = bearer {
120            builder = builder.header("authorization", format!("Bearer {b}"));
121        }
122        let req = builder.body(()).unwrap();
123        req.into_parts()
124    }
125
126    #[tokio::test]
127    async fn valid_bearer_header_yields_principal() {
128        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy must load"));
129        let bearer = auth.issue_bearer("elliott", MEMORY_READ).expect("issue");
130
131        let (mut parts, ()) = build_request(Some(bearer));
132        parts.extensions.insert(auth.clone());
133
134        let state: () = ();
135        let got = AuthPrincipal::from_request_parts(&mut parts, &state)
136            .await
137            .expect("must extract");
138        assert_eq!(got.0.principal.as_str(), "elliott");
139        assert!(got.0.may(MEMORY_READ));
140    }
141
142    #[tokio::test]
143    async fn missing_header_is_401() {
144        let auth = Arc::new(IjimaAuth::from_embedded_policy().expect("policy must load"));
145        let (mut parts, ()) = build_request(None);
146        parts.extensions.insert(auth);
147
148        let state: () = ();
149        let err = AuthPrincipal::from_request_parts(&mut parts, &state)
150            .await
151            .expect_err("must reject");
152        assert_eq!(err.into_response().status(), StatusCode::UNAUTHORIZED);
153    }
154}