Skip to main content

amaters_net/
auth.rs

1//! Authentication middleware for AmateRS network layer.
2//!
3//! Provides a Tower middleware layer for pluggable authentication, a default
4//! `BearerTokenValidator` using HMAC-SHA256 JWTs via the `jsonwebtoken` crate,
5//! and an `AuthValidator` trait for custom implementations.
6//!
7//! # Architecture
8//!
9//! - [`AuthValidator`]: object-safe async trait — implementations validate raw
10//!   token bytes and return [`Claims`] on success.
11//! - [`AuthMiddlewareLayer<V>`]: implements [`tower_layer::Layer`]; wraps any
12//!   inner service with auth enforcement.
13//! - [`AuthMiddleware<S, V>`]: implements [`tower_service::Service`]; extracts
14//!   the `authorization` header, delegates to the validator, and either inserts
15//!   [`Claims`] into request extensions and calls the inner service, or returns
16//!   `tonic::Status::unauthenticated(...)`.
17//! - [`BearerTokenValidator`]: default validator that parses `Bearer <token>`
18//!   and verifies HMAC-SHA256 JWTs.
19
20use std::future::Future;
21use std::marker::PhantomData;
22use std::pin::Pin;
23use std::task::{Context, Poll};
24
25use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
26use serde::{Deserialize, Serialize};
27use tower_layer::Layer;
28use tower_service::Service;
29
30// ─── Claims ─────────────────────────────────────────────────────────────────
31
32/// JWT claims inserted into request extensions upon successful authentication.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct Claims {
35    /// Subject — typically the user/service identifier.
36    pub sub: String,
37    /// Expiry as a Unix timestamp (seconds since epoch).
38    pub exp: u64,
39    /// Roles granted to this subject.
40    pub roles: Vec<String>,
41}
42
43// ─── AuthError ───────────────────────────────────────────────────────────────
44
45/// Errors produced by the authentication layer.
46#[derive(Debug, thiserror::Error)]
47pub enum AuthError {
48    /// No `authorization` header was present.
49    #[error("missing authorization token")]
50    MissingToken,
51    /// The token was structurally invalid or its signature did not verify.
52    #[error("invalid token: {0}")]
53    InvalidToken(String),
54    /// The token has passed its expiry time.
55    #[error("token has expired")]
56    Expired,
57    /// The caller lacks sufficient privileges for the requested operation.
58    #[error("unauthorized")]
59    Unauthorized,
60}
61
62impl From<AuthError> for tonic::Status {
63    fn from(e: AuthError) -> tonic::Status {
64        tonic::Status::unauthenticated(e.to_string())
65    }
66}
67
68// ─── AuthValidator ───────────────────────────────────────────────────────────
69
70/// Object-safe trait for pluggable token validation.
71///
72/// Implementations receive the raw token string (already stripped of any
73/// scheme prefix) and return parsed [`Claims`] on success.
74///
75/// # Object-safety
76///
77/// The associated future is erased behind `Pin<Box<dyn Future + Send + '_>>`
78/// to keep the trait object-safe and usable with `dyn AuthValidator`.
79pub trait AuthValidator: Send + Sync {
80    /// Validate the raw token and return [`Claims`] on success.
81    ///
82    /// The `token` argument is the literal content of the `Authorization`
83    /// header value (e.g. `"Bearer eyJhbG..."`).
84    fn validate<'a>(
85        &'a self,
86        token: &'a str,
87    ) -> Pin<Box<dyn Future<Output = Result<Claims, AuthError>> + Send + 'a>>;
88}
89
90// ─── BearerTokenValidator ────────────────────────────────────────────────────
91
92/// Default validator that parses `Bearer <jwt>` and verifies HMAC-SHA256.
93///
94/// The expected Authorization header value format is:
95/// ```text
96/// Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
97/// ```
98#[derive(Clone)]
99pub struct BearerTokenValidator {
100    decoding_key: DecodingKey,
101    validation: Validation,
102}
103
104impl BearerTokenValidator {
105    /// Create a validator that verifies tokens signed with the given HMAC secret.
106    ///
107    /// Expiry is validated strictly — the leeway is set to zero so tokens
108    /// that have passed their `exp` claim are rejected immediately.
109    pub fn new(secret: &[u8]) -> Self {
110        let mut validation = Validation::new(Algorithm::HS256);
111        // Do not require `aud` by default — callers can set this on their own
112        // Validation instance if needed.
113        validation.validate_aud = false;
114        // Strict expiry: reject tokens the moment they expire (no leeway).
115        validation.leeway = 0;
116        Self {
117            decoding_key: DecodingKey::from_secret(secret),
118            validation,
119        }
120    }
121}
122
123impl AuthValidator for BearerTokenValidator {
124    fn validate<'a>(
125        &'a self,
126        token: &'a str,
127    ) -> Pin<Box<dyn Future<Output = Result<Claims, AuthError>> + Send + 'a>> {
128        // Strip the "Bearer " prefix.
129        let jwt = match token.strip_prefix("Bearer ") {
130            Some(t) => t.to_owned(),
131            None => {
132                return Box::pin(async {
133                    Err(AuthError::InvalidToken("not a Bearer token".to_string()))
134                });
135            }
136        };
137
138        let decoding_key = self.decoding_key.clone();
139        let validation = self.validation.clone();
140
141        Box::pin(async move {
142            match decode::<Claims>(&jwt, &decoding_key, &validation) {
143                Ok(token_data) => Ok(token_data.claims),
144                Err(e) => {
145                    use jsonwebtoken::errors::ErrorKind;
146                    match e.kind() {
147                        ErrorKind::ExpiredSignature => Err(AuthError::Expired),
148                        _ => Err(AuthError::InvalidToken(e.to_string())),
149                    }
150                }
151            }
152        })
153    }
154}
155
156// ─── AuthMiddlewareLayer ─────────────────────────────────────────────────────
157
158/// Tower [`Layer`] that wraps a service with authentication enforcement.
159///
160/// `V` must implement [`AuthValidator`] and also be `Clone + Send + Sync + 'static`
161/// so that the produced middleware can be cloned per-request.
162#[derive(Clone)]
163pub struct AuthMiddlewareLayer<V>
164where
165    V: AuthValidator + Clone + Send + Sync + 'static,
166{
167    validator: V,
168}
169
170impl<V> AuthMiddlewareLayer<V>
171where
172    V: AuthValidator + Clone + Send + Sync + 'static,
173{
174    /// Create a new layer using the given validator.
175    pub fn new(validator: V) -> Self {
176        Self { validator }
177    }
178}
179
180impl<S, V> Layer<S> for AuthMiddlewareLayer<V>
181where
182    V: AuthValidator + Clone + Send + Sync + 'static,
183{
184    type Service = AuthMiddleware<S, V>;
185
186    fn layer(&self, inner: S) -> Self::Service {
187        AuthMiddleware {
188            inner,
189            validator: self.validator.clone(),
190        }
191    }
192}
193
194// ─── AuthMiddleware ───────────────────────────────────────────────────────────
195
196/// Tower [`Service`] that enforces authentication on every request.
197///
198/// On each call it:
199/// 1. Extracts the `authorization` header (or the tonic metadata key
200///    `authorization`) from the request.
201/// 2. Calls `V::validate` on its value.
202/// 3. On success inserts the returned [`Claims`] into the request extensions
203///    and forwards the request to the inner service.
204/// 4. On failure returns an immediate `tonic::Status::unauthenticated(...)`.
205#[derive(Clone)]
206pub struct AuthMiddleware<S, V>
207where
208    V: AuthValidator + Clone + Send + Sync + 'static,
209{
210    inner: S,
211    validator: V,
212}
213
214impl<S, B, ResBody, V> Service<http::Request<B>> for AuthMiddleware<S, V>
215where
216    S: Service<http::Request<B>, Response = http::Response<ResBody>> + Clone + Send + 'static,
217    S::Future: Send + 'static,
218    S::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
219    B: Send + 'static,
220    ResBody: Default + Send + 'static,
221    V: AuthValidator + Clone + Send + Sync + 'static,
222{
223    type Response = http::Response<ResBody>;
224    type Error = S::Error;
225    type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
226
227    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
228        self.inner.poll_ready(cx)
229    }
230
231    fn call(&mut self, mut req: http::Request<B>) -> Self::Future {
232        // Extract token from "authorization" header.
233        let token_result = req
234            .headers()
235            .get(http::header::AUTHORIZATION)
236            .and_then(|v| v.to_str().ok())
237            .map(|s| s.to_owned())
238            .ok_or(AuthError::MissingToken);
239
240        let validator = self.validator.clone();
241        // Take a clone of the inner service for the async block.
242        // We need to call `poll_ready` on it, but since we already called it above,
243        // we use `std::mem::replace` pattern to avoid double-borrow.
244        let mut inner = self.inner.clone();
245        std::mem::swap(&mut self.inner, &mut inner);
246
247        Box::pin(async move {
248            let token = match token_result {
249                Ok(t) => t,
250                Err(e) => {
251                    return Ok(build_unauthenticated_response(e.to_string()));
252                }
253            };
254
255            match validator.validate(&token).await {
256                Ok(claims) => {
257                    req.extensions_mut().insert(claims);
258                    inner.call(req).await
259                }
260                Err(e) => Ok(build_unauthenticated_response(e.to_string())),
261            }
262        })
263    }
264}
265
266/// Build an `http::Response` that carries a gRPC `UNAUTHENTICATED` status.
267///
268/// The response body is default-constructed so callers do not need to
269/// specify a body type.
270fn build_unauthenticated_response<ResBody: Default>(message: String) -> http::Response<ResBody> {
271    let status = tonic::Status::unauthenticated(message);
272    let (mut parts, _body) = http::Response::new(ResBody::default()).into_parts();
273    parts.status = http::StatusCode::OK; // gRPC always uses 200 HTTP status
274    // Encode the gRPC status in trailers-only response headers.
275    parts.headers.insert(
276        "grpc-status",
277        http::HeaderValue::from_str(&(status.code() as i32).to_string())
278            .unwrap_or_else(|_| http::HeaderValue::from_static("16")),
279    );
280    if let Ok(v) = http::HeaderValue::from_str(status.message()) {
281        parts.headers.insert("grpc-message", v);
282    }
283    http::Response::from_parts(parts, ResBody::default())
284}
285
286// ─── Tests ────────────────────────────────────────────────────────────────────
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use jsonwebtoken::{Algorithm, EncodingKey, Header, encode};
292    use std::time::{SystemTime, UNIX_EPOCH};
293    use tower_service::Service as _;
294
295    // ── Helpers ───────────────────────────────────────────────────────────────
296
297    fn make_jwt(secret: &[u8], exp_offset_secs: i64) -> String {
298        let now = SystemTime::now()
299            .duration_since(UNIX_EPOCH)
300            .expect("system clock before UNIX epoch")
301            .as_secs();
302        let exp = if exp_offset_secs >= 0 {
303            now + exp_offset_secs as u64
304        } else {
305            now.saturating_sub(exp_offset_secs.unsigned_abs())
306        };
307        let claims = Claims {
308            sub: "test-user".to_string(),
309            exp,
310            roles: vec!["admin".to_string()],
311        };
312        encode(
313            &Header::new(Algorithm::HS256),
314            &claims,
315            &EncodingKey::from_secret(secret),
316        )
317        .expect("JWT encoding should not fail in tests")
318    }
319
320    // ── Always-ok validator ───────────────────────────────────────────────────
321
322    #[derive(Clone)]
323    struct AlwaysOkValidator;
324
325    impl AuthValidator for AlwaysOkValidator {
326        fn validate<'a>(
327            &'a self,
328            _token: &'a str,
329        ) -> Pin<Box<dyn Future<Output = Result<Claims, AuthError>> + Send + 'a>> {
330            Box::pin(async {
331                Ok(Claims {
332                    sub: "always-ok".to_string(),
333                    exp: u64::MAX,
334                    roles: vec![],
335                })
336            })
337        }
338    }
339
340    // ── Simple echo-body inner service ────────────────────────────────────────
341
342    #[derive(Clone)]
343    struct EchoService;
344
345    impl Service<http::Request<String>> for EchoService {
346        type Response = http::Response<String>;
347        type Error = Box<dyn std::error::Error + Send + Sync>;
348        type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>;
349
350        fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
351            Poll::Ready(Ok(()))
352        }
353
354        fn call(&mut self, req: http::Request<String>) -> Self::Future {
355            let claims = req.extensions().get::<Claims>().cloned();
356            Box::pin(async move {
357                let body = match claims {
358                    Some(c) => format!("sub={}", c.sub),
359                    None => "no-claims".to_string(),
360                };
361                Ok(http::Response::new(body))
362            })
363        }
364    }
365
366    // ─────────────────────────────────────────────────────────────────────────
367
368    #[tokio::test]
369    async fn test_auth_rejects_missing_token() {
370        let layer = AuthMiddlewareLayer::new(AlwaysOkValidator);
371        let mut svc = layer.layer(EchoService);
372
373        let req = http::Request::builder()
374            .body(String::new())
375            .expect("request builder should not fail");
376
377        let resp = svc.call(req).await.expect("service call should not error");
378
379        // Expect gRPC UNAUTHENTICATED status code (16) in headers.
380        let grpc_status = resp
381            .headers()
382            .get("grpc-status")
383            .and_then(|v| v.to_str().ok())
384            .unwrap_or("missing");
385        assert_eq!(
386            grpc_status, "16",
387            "expected grpc-status=16 (UNAUTHENTICATED)"
388        );
389    }
390
391    #[tokio::test]
392    async fn test_auth_accepts_valid_token() {
393        let secret = b"supersecret";
394        let jwt = make_jwt(secret, 3600);
395        let bearer = format!("Bearer {jwt}");
396
397        let layer = AuthMiddlewareLayer::new(BearerTokenValidator::new(secret));
398        let mut svc = layer.layer(EchoService);
399
400        let req = http::Request::builder()
401            .header(http::header::AUTHORIZATION, &bearer)
402            .body(String::new())
403            .expect("request builder should not fail");
404
405        let resp = svc.call(req).await.expect("service call should not error");
406
407        // Body should contain the subject from claims.
408        assert_eq!(resp.body(), "sub=test-user");
409        // No grpc-status header means success (inner service ran).
410        assert!(
411            resp.headers().get("grpc-status").is_none(),
412            "should not have grpc-status on success"
413        );
414    }
415
416    #[tokio::test]
417    async fn test_auth_custom_validator() {
418        let layer = AuthMiddlewareLayer::new(AlwaysOkValidator);
419        let mut svc = layer.layer(EchoService);
420
421        let req = http::Request::builder()
422            .header(http::header::AUTHORIZATION, "Bearer anything")
423            .body(String::new())
424            .expect("request builder should not fail");
425
426        let resp = svc.call(req).await.expect("service call should not error");
427
428        assert_eq!(resp.body(), "sub=always-ok");
429    }
430
431    /// Verify that `BearerTokenValidator` rejects tokens without "Bearer " prefix.
432    #[tokio::test]
433    async fn test_bearer_validator_rejects_non_bearer_prefix() {
434        let validator = BearerTokenValidator::new(b"secret");
435        let result = validator.validate("Token abc123").await;
436        assert!(
437            matches!(result, Err(AuthError::InvalidToken(_))),
438            "expected InvalidToken for non-Bearer scheme"
439        );
440    }
441
442    /// Verify that `BearerTokenValidator` rejects expired JWTs.
443    #[tokio::test]
444    async fn test_bearer_validator_rejects_expired() {
445        let secret = b"expiry-test-secret";
446        let jwt = make_jwt(secret, -1); // expired 1 second ago
447        let bearer = format!("Bearer {jwt}");
448
449        let validator = BearerTokenValidator::new(secret);
450        let result = validator.validate(&bearer).await;
451        assert!(
452            matches!(result, Err(AuthError::Expired)),
453            "expected Expired error for expired JWT, got: {:?}",
454            result
455        );
456    }
457
458    /// Verify that `AuthValidator` is object-safe.
459    #[test]
460    fn test_auth_validator_is_object_safe() {
461        // This should compile: we can store a &dyn AuthValidator.
462        let validator = BearerTokenValidator::new(b"test");
463        let _dyn_ref: &dyn AuthValidator = &validator;
464    }
465
466    /// Compile-time test: AuthMiddlewareLayer can be constructed without the
467    /// `compression` feature (just a compile check, no runtime assertions).
468    #[test]
469    fn test_layer_construction() {
470        let _layer: AuthMiddlewareLayer<AlwaysOkValidator> =
471            AuthMiddlewareLayer::new(AlwaysOkValidator);
472    }
473}