Skip to main content

jerrycan_auth/
guard.rs

1//! Guards are dependencies (spec §4.3): `Session<T>`/`Bearer<T>` are extractors
2//! returning 401; `require_role` returns 403. No auth middleware.
3
4use jerrycan_core::{Error, FromRequest, Headers, RequestCtx, Result};
5use serde::de::DeserializeOwned;
6
7/// Session extractor: decrypts the `jerrycan_session` cookie into `T`.
8/// Absent/invalid cookie → 401. Requires the `Auth` extension to be registered.
9pub struct Session<T>(pub T);
10
11impl<T: DeserializeOwned + Send> FromRequest for Session<T> {
12    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
13        let auth = ctx.resolve::<crate::Auth>().await?;
14        let headers = Headers::from_request(ctx).await?;
15        let cookie_header = headers.get("cookie").ok_or_else(Error::unauthorized)?;
16        let token = auth
17            .sessions()
18            .read_cookie(cookie_header)
19            .ok_or_else(Error::unauthorized)?;
20        auth.sessions().decode::<T>(&token).map(Session)
21    }
22}
23
24/// Bearer JWT extractor: verifies the `Authorization: Bearer <jwt>` token into `T`.
25pub struct Bearer<T>(pub T);
26
27impl<T: DeserializeOwned + Send> FromRequest for Bearer<T> {
28    async fn from_request(ctx: &mut RequestCtx) -> Result<Self> {
29        let auth = ctx.resolve::<crate::Auth>().await?;
30        let headers = Headers::from_request(ctx).await?;
31        let value = headers
32            .get("authorization")
33            .ok_or_else(Error::unauthorized)?;
34        let token = value
35            .strip_prefix("Bearer ")
36            .ok_or_else(Error::unauthorized)?;
37        crate::jwt::decode::<T>(token, auth.jwt_key()).map(Bearer)
38    }
39}
40
41/// Role check helper for generated guards: `403` when the role doesn't match.
42pub fn require_role(actual: &str, required: &str) -> Result<()> {
43    if actual == required {
44        Ok(())
45    } else {
46        Err(Error::forbidden())
47    }
48}
49
50#[cfg(test)]
51mod tests {
52    use super::*;
53    use crate::Auth;
54    use jerrycan_core::{App, Dep, Json, get, post};
55    use serde::{Deserialize, Serialize};
56
57    #[derive(Serialize, Deserialize, Clone)]
58    struct User {
59        // Stringified pk (mirrors storage's TEXT owner_id) so a uuid user id works.
60        id: String,
61        role: String,
62    }
63
64    async fn login(auth: Dep<Auth>) -> Result<jerrycan_core::Response> {
65        // Issue a session cookie for a fixed user (test login).
66        let cookie = auth.sessions().set_cookie(&User {
67            id: "1".into(),
68            role: "admin".into(),
69        })?;
70        let mut res = jerrycan_core::IntoResponse::into_response("ok");
71        res.headers_mut().insert(
72            jerrycan_core::http::header::SET_COOKIE,
73            jerrycan_core::http::HeaderValue::from_str(&cookie).unwrap(),
74        );
75        Ok(res)
76    }
77
78    async fn whoami(Session(user): Session<User>) -> Json<String> {
79        Json(user.id)
80    }
81
82    fn app() -> App {
83        App::new()
84            .extend(Auth::with_secret("a-very-long-development-secret-string!!"))
85            .route("/login", post(login))
86            .route("/me", get(whoami))
87    }
88
89    #[tokio::test]
90    async fn no_cookie_is_401() {
91        let t = app().into_test();
92        assert_eq!(
93            t.get("/me").await.status(),
94            jerrycan_core::http::StatusCode::UNAUTHORIZED
95        );
96    }
97
98    #[tokio::test]
99    async fn login_then_authenticated_request_succeeds() {
100        let t = app().into_test();
101        let login = t.post_json("/login", &()).await;
102        let set_cookie = login.headers()["set-cookie"].to_str().unwrap().to_string();
103        let cookie = set_cookie.split(';').next().unwrap().to_string(); // jerrycan_session=...
104        let res = t.get_with("/me", &[("cookie", &cookie)]).await;
105        assert_eq!(res.status(), jerrycan_core::http::StatusCode::OK);
106        assert_eq!(res.json::<String>(), "1");
107    }
108
109    #[tokio::test]
110    async fn require_role_rejects_wrong_role_with_403() {
111        async fn admin_only(Session(user): Session<User>) -> Result<&'static str> {
112            require_role(&user.role, "superadmin")?;
113            Ok("secret")
114        }
115        let t = App::new()
116            .extend(Auth::with_secret("a-very-long-development-secret-string!!"))
117            .route("/login", post(login))
118            .route("/admin", get(admin_only))
119            .into_test();
120        let login = t.post_json("/login", &()).await;
121        let cookie = login.headers()["set-cookie"]
122            .to_str()
123            .unwrap()
124            .split(';')
125            .next()
126            .unwrap()
127            .to_string();
128        let res = t.get_with("/admin", &[("cookie", &cookie)]).await;
129        assert_eq!(res.status(), jerrycan_core::http::StatusCode::FORBIDDEN);
130    }
131}