Skip to main content

rustio_core/
auth.rs

1//! Identity-in-context authentication.
2//!
3//! [`authenticate`] is an additive middleware: it attaches an [`Identity`]
4//! to the request context when a valid `Authorization: Bearer` token is
5//! provided, and does nothing otherwise. Handlers enforce their own
6//! requirement with [`require_auth`] / [`require_admin`].
7//!
8//! The built-in token mapping (`dev-admin` / `dev-user`) is for development
9//! only. As a safety guard, `authenticate` refuses to recognize any dev
10//! token when the `RUSTIO_ENV` environment variable is set to `"production"`
11//! (or `"prod"`). In that mode the middleware is a no-op and admin routes
12//! will return 401 — the correct fix is to register your own auth
13//! middleware that populates [`Identity`].
14
15use std::sync::atomic::{AtomicBool, Ordering};
16
17use crate::context::Context;
18use crate::error::Error;
19use crate::http::{Request, Response};
20use crate::middleware::Next;
21
22#[derive(Debug, Clone)]
23pub struct Identity {
24    pub user_id: String,
25    pub is_admin: bool,
26}
27
28/// One-shot latch so we only print the production warning once per process,
29/// no matter how many requests come in.
30static PRODUCTION_WARNED: AtomicBool = AtomicBool::new(false);
31
32/// `true` when `RUSTIO_ENV` indicates a production deployment.
33///
34/// Accepts `production` or `prod` (case-insensitive). Anything else —
35/// including unset — is treated as development.
36pub fn in_production() -> bool {
37    std::env::var("RUSTIO_ENV")
38        .map(|v| {
39            let v = v.to_ascii_lowercase();
40            v == "production" || v == "prod"
41        })
42        .unwrap_or(false)
43}
44
45pub async fn authenticate(mut req: Request, next: Next) -> Result<Response, Error> {
46    if in_production() {
47        // Emit a single loud warning the first time this runs in
48        // production. The user almost certainly meant to register a real
49        // auth middleware and forgot.
50        if !PRODUCTION_WARNED.swap(true, Ordering::Relaxed) {
51            eprintln!(
52                "rustio_core::auth: RUSTIO_ENV={} — built-in dev tokens are disabled. \
53                 Replace `authenticate` with your own middleware before accepting traffic.",
54                std::env::var("RUSTIO_ENV").unwrap_or_default()
55            );
56        }
57        // Skip dev-token handling entirely. An admin route will now
58        // return 401 to any caller, rather than accepting `dev-admin`.
59        return next.run(req).await;
60    }
61
62    // Accept the token from two places in priority order:
63    //   1. `Authorization: Bearer <token>` — used by API/curl callers.
64    //   2. `rustio_token` cookie — set by the admin login form so browser
65    //      users don't have to inject headers manually.
66    let token = bearer_token(&req)
67        .map(str::to_owned)
68        .or_else(|| req.cookie("rustio_token"));
69
70    if let Some(t) = token {
71        if let Some(identity) = dev_identity(&t) {
72            req.ctx_mut().insert(identity);
73        }
74    }
75    next.run(req).await
76}
77
78pub fn bearer_token(req: &Request) -> Option<&str> {
79    req.headers()
80        .get("authorization")
81        .and_then(|v| v.to_str().ok())
82        .and_then(|s| s.strip_prefix("Bearer "))
83}
84
85pub(crate) fn dev_identity(token: &str) -> Option<Identity> {
86    match token {
87        "dev-admin" => Some(Identity {
88            user_id: String::from("admin"),
89            is_admin: true,
90        }),
91        "dev-user" => Some(Identity {
92            user_id: String::from("user"),
93            is_admin: false,
94        }),
95        _ => None,
96    }
97}
98
99pub fn identity(ctx: &Context) -> Option<&Identity> {
100    ctx.get::<Identity>()
101}
102
103pub fn require_auth(ctx: &Context) -> Result<&Identity, Error> {
104    identity(ctx).ok_or(Error::Unauthorized)
105}
106
107pub fn require_admin(ctx: &Context) -> Result<&Identity, Error> {
108    let id = require_auth(ctx)?;
109    if !id.is_admin {
110        return Err(Error::Forbidden);
111    }
112    Ok(id)
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn user(is_admin: bool) -> Identity {
120        Identity {
121            user_id: String::from(if is_admin { "admin" } else { "user" }),
122            is_admin,
123        }
124    }
125
126    #[test]
127    fn identity_returns_none_when_absent() {
128        let ctx = Context::new();
129        assert!(identity(&ctx).is_none());
130    }
131
132    #[test]
133    fn identity_returns_reference_when_attached() {
134        let mut ctx = Context::new();
135        ctx.insert(user(false));
136        assert_eq!(identity(&ctx).map(|i| i.user_id.as_str()), Some("user"));
137    }
138
139    #[test]
140    fn require_auth_missing_returns_unauthorized() {
141        let ctx = Context::new();
142        assert!(matches!(require_auth(&ctx), Err(Error::Unauthorized)));
143    }
144
145    #[test]
146    fn require_auth_present_returns_identity() {
147        let mut ctx = Context::new();
148        ctx.insert(user(false));
149        let id = require_auth(&ctx).unwrap();
150        assert_eq!(id.user_id, "user");
151        assert!(!id.is_admin);
152    }
153
154    #[test]
155    fn require_admin_without_identity_returns_unauthorized() {
156        let ctx = Context::new();
157        assert!(matches!(require_admin(&ctx), Err(Error::Unauthorized)));
158    }
159
160    #[test]
161    fn require_admin_with_non_admin_returns_forbidden() {
162        let mut ctx = Context::new();
163        ctx.insert(user(false));
164        assert!(matches!(require_admin(&ctx), Err(Error::Forbidden)));
165    }
166
167    #[test]
168    fn require_admin_with_admin_returns_identity() {
169        let mut ctx = Context::new();
170        ctx.insert(user(true));
171        let id = require_admin(&ctx).unwrap();
172        assert_eq!(id.user_id, "admin");
173        assert!(id.is_admin);
174    }
175
176    #[test]
177    fn dev_identity_rejects_unknown_tokens() {
178        assert!(dev_identity("garbage").is_none());
179        assert!(dev_identity("").is_none());
180    }
181
182    #[test]
183    fn dev_identity_maps_known_tokens() {
184        let admin = dev_identity("dev-admin").unwrap();
185        assert!(admin.is_admin);
186        let user = dev_identity("dev-user").unwrap();
187        assert!(!user.is_admin);
188    }
189
190    #[test]
191    fn in_production_detects_known_values() {
192        // We don't touch env in tests — inspect the parser via an inline
193        // helper that mirrors the real function but takes a value directly.
194        fn detect(v: Option<&str>) -> bool {
195            v.map(|s| {
196                let s = s.to_ascii_lowercase();
197                s == "production" || s == "prod"
198            })
199            .unwrap_or(false)
200        }
201        assert!(detect(Some("production")));
202        assert!(detect(Some("PRODUCTION")));
203        assert!(detect(Some("prod")));
204        assert!(detect(Some("Prod")));
205        assert!(!detect(Some("dev")));
206        assert!(!detect(Some("staging")));
207        assert!(!detect(Some("")));
208        assert!(!detect(None));
209    }
210}