miryad_core/auth/
middleware.rs1use axum::extract::{FromRef, FromRequestParts};
2use axum::http::request::Parts;
3
4use crate::auth::cookie::extract_session;
5use crate::auth::error::AuthError;
6use crate::auth::state::MiryadAuthState;
7
8pub struct AuthUser {
11 pub subject: String,
12 pub email: Option<String>,
13 pub id_token: String,
14}
15
16impl<S> FromRequestParts<S> for AuthUser
17where
18 S: Send + Sync,
19 MiryadAuthState: FromRef<S>,
20{
21 type Rejection = AuthError;
22
23 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
24 let auth_state = MiryadAuthState::from_ref(state);
25
26 let cookie_header = parts
27 .headers
28 .get("Cookie")
29 .and_then(|v| v.to_str().ok())
30 .map(std::string::ToString::to_string);
31
32 let identity = extract_session(cookie_header.as_deref(), &auth_state.cookie_key)
33 .inspect_err(|e| tracing::debug!("auth rejected: {}", e))?;
34
35 tracing::debug!(subject = %identity.subject, "auth ok");
36 Ok(Self {
37 subject: identity.subject,
38 email: identity.email,
39 id_token: identity.id_token,
40 })
41 }
42}
43
44#[cfg(test)]
45mod tests {
46 use super::*;
47 use crate::auth::cookie::build_set_cookie;
48 use crate::auth::oidc::{MockOidcClient, OidcIdentity};
49 use ::cookie::Key;
50 use axum::{
51 Router,
52 body::Body,
53 http::{Request, StatusCode},
54 routing::get,
55 };
56 use tower::ServiceExt;
57
58 fn mock_db() -> sea_orm::DatabaseConnection {
59 sea_orm::MockDatabase::new(sea_orm::DatabaseBackend::Sqlite).into_connection()
60 }
61
62 fn test_state() -> MiryadAuthState {
63 MiryadAuthState {
64 oidc_client: std::sync::Arc::new(MockOidcClient),
65 cookie_key: Key::from(&[0u8; 64]),
66 post_login_redirect: "/".to_string(),
67 post_logout_redirect: "/".to_string(),
68 db: mock_db(),
69 }
70 }
71
72 async fn protected_handler(user: AuthUser) -> String {
73 user.subject
74 }
75
76 fn make_app() -> Router {
77 Router::new()
78 .route("/protected", get(protected_handler))
79 .with_state(test_state())
80 }
81
82 #[tokio::test]
83 async fn protected_without_cookie_returns_401() {
84 let app = make_app();
85 let req = Request::builder()
86 .uri("/protected")
87 .body(Body::empty())
88 .expect("valid request");
89 let resp = app.oneshot(req).await.expect("router does not fail");
90 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
91 }
92
93 #[tokio::test]
94 async fn protected_with_valid_session_passes_and_exposes_subject() {
95 let key = Key::from(&[0u8; 64]);
96 let exp = std::time::SystemTime::now()
97 .duration_since(std::time::UNIX_EPOCH)
98 .expect("system clock is after epoch")
99 .as_secs()
100 + 3600;
101 let jwt = format!("header.{}.sig", {
102 use base64::Engine;
103 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#))
104 });
105 let identity = OidcIdentity {
106 id_token: jwt,
107 subject: "user-123".to_string(),
108 email: Some("test@example.com".to_string()),
109 };
110 let set_cookie = build_set_cookie(&identity, &key);
111 let cookie_value = set_cookie
112 .split(';')
113 .next()
114 .expect("cookie pair present")
115 .to_string();
116
117 let app = Router::new()
118 .route("/protected", get(protected_handler))
119 .with_state(MiryadAuthState {
120 oidc_client: std::sync::Arc::new(MockOidcClient),
121 cookie_key: key,
122 post_login_redirect: "/".to_string(),
123 post_logout_redirect: "/".to_string(),
124 db: mock_db(),
125 });
126 let req = Request::builder()
127 .uri("/protected")
128 .header("Cookie", cookie_value)
129 .body(Body::empty())
130 .expect("valid request");
131 let resp = app.oneshot(req).await.expect("router does not fail");
132 assert_eq!(resp.status(), StatusCode::OK);
133 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
134 .await
135 .expect("readable body");
136 assert_eq!(&body[..], b"user-123");
137 }
138}