1use std::sync::Arc;
20use std::time::{Duration, SystemTime, UNIX_EPOCH};
21
22use cookie::{Cookie, SameSite, time::Duration as CookieDuration};
23use rand::RngCore;
24use url::Url;
25
26use crate::error::Result;
27use crate::store::{Session, SessionStore};
28
29pub const SESSION_COOKIE: &str = "assay_session";
32
33pub const CSRF_COOKIE: &str = "assay_csrf";
36
37pub const DEFAULT_SESSION_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 30);
41
42#[derive(Clone)]
46pub struct SessionManager {
47 store: Arc<dyn SessionStore>,
48 default_duration: Duration,
49}
50
51impl SessionManager {
52 pub fn new(store: Arc<dyn SessionStore>, default_duration: Duration) -> Self {
56 Self {
57 store,
58 default_duration,
59 }
60 }
61
62 pub fn with_default_duration(store: Arc<dyn SessionStore>) -> Self {
64 Self::new(store, DEFAULT_SESSION_DURATION)
65 }
66
67 pub async fn create(&self, user_id: &str) -> Result<Session> {
72 let id = format!("sess_{}", random_token());
73 let csrf_token = format!("csrf_{}", random_token());
74 let created_at = now_secs();
75 let expires_at = created_at + self.default_duration.as_secs_f64();
76 let session = Session {
77 id,
78 user_id: user_id.to_string(),
79 csrf_token,
80 created_at,
81 expires_at,
82 ip_hash: None,
83 user_agent_hash: None,
84 };
85 self.store.create(&session).await?;
86 Ok(session)
87 }
88
89 pub async fn resolve(&self, id: &str) -> Result<Option<Session>> {
94 let Some(session) = self.store.get(id).await? else {
95 return Ok(None);
96 };
97 if session.expires_at <= now_secs() {
98 return Ok(None);
99 }
100 Ok(Some(session))
101 }
102
103 pub async fn rotate(&self, old_id: &str) -> Result<Option<Session>> {
109 let Some(old) = self.store.get(old_id).await? else {
110 return Ok(None);
111 };
112 self.store.delete(old_id).await?;
116 let new_id = format!("sess_{}", random_token());
117 let csrf_token = format!("csrf_{}", random_token());
118 let session = Session {
119 id: new_id,
120 user_id: old.user_id,
121 csrf_token,
122 created_at: now_secs(),
123 expires_at: old.expires_at,
124 ip_hash: old.ip_hash,
125 user_agent_hash: old.user_agent_hash,
126 };
127 self.store.create(&session).await?;
128 Ok(Some(session))
129 }
130
131 pub async fn revoke(&self, id: &str) -> Result<bool> {
133 Ok(self.store.delete(id).await?)
134 }
135
136 pub async fn revoke_for_user(&self, user_id: &str) -> Result<u64> {
139 Ok(self.store.delete_for_user(user_id).await?)
140 }
141
142 pub fn store(&self) -> &Arc<dyn SessionStore> {
146 &self.store
147 }
148}
149
150pub fn cookie_for(session: &Session, public_url: &Url) -> Cookie<'static> {
158 let max_age = max_age_for(session);
159 let secure = is_secure(public_url);
160 Cookie::build((SESSION_COOKIE, session.id.clone()))
161 .path("/")
162 .secure(secure)
163 .http_only(true)
164 .same_site(SameSite::Lax)
165 .max_age(max_age)
166 .build()
167 .into_owned()
168}
169
170pub fn csrf_cookie_for(session: &Session) -> Cookie<'static> {
174 let max_age = max_age_for(session);
175 Cookie::build((CSRF_COOKIE, session.csrf_token.clone()))
176 .path("/")
177 .secure(true)
178 .http_only(false)
179 .same_site(SameSite::Lax)
180 .max_age(max_age)
181 .build()
182 .into_owned()
183}
184
185fn max_age_for(session: &Session) -> CookieDuration {
190 let secs = (session.expires_at - now_secs()).max(0.0) as i64;
191 CookieDuration::seconds(secs)
192}
193
194fn is_secure(public_url: &Url) -> bool {
200 public_url.scheme().eq_ignore_ascii_case("https")
201}
202
203fn now_secs() -> f64 {
204 SystemTime::now()
205 .duration_since(UNIX_EPOCH)
206 .unwrap_or_default()
207 .as_secs_f64()
208}
209
210fn random_token() -> String {
211 let mut buf = [0u8; 32];
212 rand::rng().fill_bytes(&mut buf);
213 data_encoding::BASE64URL_NOPAD.encode(&buf)
214}
215
216use axum::Router;
219use axum::extract::{FromRef, State};
220use axum::http::{HeaderMap, StatusCode, header};
221use axum::response::{IntoResponse, Json, Response};
222use axum::routing::{delete, get, post};
223use serde::Deserialize;
224use serde_json::json;
225
226use crate::ctx::AuthCtx;
227
228pub fn router<S>() -> Router<S>
231where
232 S: Clone + Send + Sync + 'static,
233 AuthCtx: FromRef<S>,
234{
235 Router::new()
236 .route("/login", post(login_post))
237 .route("/session", delete(logout_delete))
238 .route("/whoami", get(whoami_get))
239 .route("/passkey/register/start", post(passkey_register_start))
240 .route("/passkey/register/finish", post(passkey_register_finish))
241 .route("/passkey/auth/start", post(passkey_auth_start))
242 .route("/passkey/auth/finish", post(passkey_auth_finish))
243}
244
245#[derive(Deserialize)]
246struct LoginBody {
247 email: String,
248 password: String,
249}
250
251async fn login_post(State(ctx): State<AuthCtx>, Json(body): Json<LoginBody>) -> Response {
252 let user = match ctx.users.get_user_by_email(&body.email).await {
253 Ok(Some(u)) => u,
254 _ => return unauthorized("invalid credentials"),
255 };
256 let stored = match ctx.users.get_password_hash(&user.id).await {
257 Ok(Some(h)) => h,
258 _ => return unauthorized("invalid credentials"),
259 };
260 let hasher = crate::password::PasswordHasher::default();
261 let ok = match hasher.verify(&body.password, &stored) {
262 Ok(b) => b,
263 Err(_) => return unauthorized("invalid credentials"),
264 };
265 if !ok {
266 return unauthorized("invalid credentials");
267 }
268 let mgr = SessionManager::with_default_duration(ctx.sessions.clone());
269 let session = match mgr.create(&user.id).await {
270 Ok(s) => s,
271 Err(e) => return server_error(&format!("create session: {e}")),
272 };
273 let public_url = ctx
274 .oidc_provider
275 .as_ref()
276 .map(|p| p.public_url.clone())
277 .unwrap_or_else(|| url::Url::parse("http://localhost").unwrap());
278 let cookie = cookie_for(&session, &public_url);
279 let csrf = csrf_cookie_for(&session);
280 let mut response = (
281 StatusCode::OK,
282 Json(json!({
283 "user_id": user.id,
284 "email": user.email,
285 "csrf_token": session.csrf_token,
286 })),
287 )
288 .into_response();
289 if let Ok(value) = cookie.to_string().parse() {
290 response.headers_mut().append(header::SET_COOKIE, value);
291 }
292 if let Ok(value) = csrf.to_string().parse() {
293 response.headers_mut().append(header::SET_COOKIE, value);
294 }
295 response
296}
297
298async fn logout_delete(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
299 if let Some(sid) = parse_cookie(&headers, SESSION_COOKIE) {
300 let _ = ctx.sessions.delete(&sid).await;
301 }
302 let mut response = (StatusCode::NO_CONTENT, "").into_response();
303 let clear = format!(
304 "{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
305 SESSION_COOKIE
306 );
307 if let Ok(v) = clear.parse() {
308 response.headers_mut().append(header::SET_COOKIE, v);
309 }
310 response
311}
312
313async fn whoami_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
314 let sid = match parse_cookie(&headers, SESSION_COOKIE) {
315 Some(s) => s,
316 None => return unauthorized("no session"),
317 };
318 let mgr = SessionManager::with_default_duration(ctx.sessions.clone());
319 let session = match mgr.resolve(&sid).await {
320 Ok(Some(s)) => s,
321 _ => return unauthorized("session unknown"),
322 };
323 let user = match ctx.users.get_user_by_id(&session.user_id).await {
324 Ok(Some(u)) => u,
325 _ => return unauthorized("user unknown"),
326 };
327 (
328 StatusCode::OK,
329 Json(json!({
330 "user_id": user.id,
331 "email": user.email,
332 "email_verified": user.email_verified,
333 "display_name": user.display_name,
334 })),
335 )
336 .into_response()
337}
338
339#[derive(Deserialize)]
340struct PasskeyRegisterStartBody {
341 user_id: String,
342 user_name: String,
343 display_name: String,
344}
345
346async fn passkey_register_start(
347 State(ctx): State<AuthCtx>,
348 Json(body): Json<PasskeyRegisterStartBody>,
349) -> Response {
350 let Some(mgr) = ctx.passkeys.as_ref() else {
351 return svc_unavailable("passkey manager not configured");
352 };
353 let uuid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, body.user_id.as_bytes());
354 match mgr
355 .start_registration(
356 uuid,
357 &body.user_name,
358 &body.display_name,
359 Some(&body.user_id),
360 )
361 .await
362 {
363 Ok((challenge, state)) => {
364 let state_blob = serde_json::to_string(&state).unwrap_or_default();
365 (
366 StatusCode::OK,
367 Json(json!({
368 "challenge": challenge,
369 "state": state_blob,
370 })),
371 )
372 .into_response()
373 }
374 Err(e) => bad_request(&format!("start_registration: {e}")),
375 }
376}
377
378#[derive(Deserialize)]
379struct PasskeyRegisterFinishBody {
380 user_id: String,
381 state: String,
382 response: serde_json::Value,
383}
384
385async fn passkey_register_finish(
386 State(ctx): State<AuthCtx>,
387 Json(body): Json<PasskeyRegisterFinishBody>,
388) -> Response {
389 let Some(mgr) = ctx.passkeys.as_ref() else {
390 return svc_unavailable("passkey manager not configured");
391 };
392 let state: webauthn_rs::prelude::PasskeyRegistration = match serde_json::from_str(&body.state) {
393 Ok(s) => s,
394 Err(e) => return bad_request(&format!("decode state: {e}")),
395 };
396 let response: webauthn_rs::prelude::RegisterPublicKeyCredential =
397 match serde_json::from_value(body.response) {
398 Ok(r) => r,
399 Err(e) => return bad_request(&format!("decode response: {e}")),
400 };
401 let passkey = match mgr.finish_registration(&state, &response) {
402 Ok(p) => p,
403 Err(e) => return bad_request(&format!("finish_registration: {e}")),
404 };
405 let cred = crate::passkey::passkey_to_cred(&passkey, now_secs());
406 if let Err(e) = ctx.users.add_passkey(&body.user_id, &cred).await {
407 return server_error(&format!("persist passkey: {e}"));
408 }
409 (
410 StatusCode::OK,
411 Json(json!({"credential_id": data_encoding::BASE64URL_NOPAD.encode(&cred.credential_id)})),
412 )
413 .into_response()
414}
415
416#[derive(Deserialize)]
417struct PasskeyAuthStartBody {
418 user_id: String,
419 #[serde(default)]
423 passkeys: Vec<serde_json::Value>,
424}
425
426async fn passkey_auth_start(
427 State(ctx): State<AuthCtx>,
428 Json(body): Json<PasskeyAuthStartBody>,
429) -> Response {
430 let Some(mgr) = ctx.passkeys.as_ref() else {
431 return svc_unavailable("passkey manager not configured");
432 };
433 let _ = body.user_id;
434 let mut creds: Vec<webauthn_rs::prelude::Passkey> = Vec::with_capacity(body.passkeys.len());
435 for v in body.passkeys {
436 match serde_json::from_value(v) {
437 Ok(p) => creds.push(p),
438 Err(e) => return bad_request(&format!("decode passkey: {e}")),
439 }
440 }
441 if creds.is_empty() {
442 return bad_request("passkeys list is empty");
443 }
444 match mgr.start_authentication_with(&creds) {
445 Ok((challenge, state)) => (
446 StatusCode::OK,
447 Json(json!({
448 "challenge": challenge,
449 "state": serde_json::to_string(&state).unwrap_or_default(),
450 })),
451 )
452 .into_response(),
453 Err(e) => bad_request(&format!("start_authentication: {e}")),
454 }
455}
456
457#[derive(Deserialize)]
458struct PasskeyAuthFinishBody {
459 state: String,
460 response: serde_json::Value,
461}
462
463async fn passkey_auth_finish(
464 State(ctx): State<AuthCtx>,
465 Json(body): Json<PasskeyAuthFinishBody>,
466) -> Response {
467 let Some(mgr) = ctx.passkeys.as_ref() else {
468 return svc_unavailable("passkey manager not configured");
469 };
470 let state: webauthn_rs::prelude::PasskeyAuthentication = match serde_json::from_str(&body.state)
471 {
472 Ok(s) => s,
473 Err(e) => return bad_request(&format!("decode state: {e}")),
474 };
475 let response: webauthn_rs::prelude::PublicKeyCredential =
476 match serde_json::from_value(body.response) {
477 Ok(r) => r,
478 Err(e) => return bad_request(&format!("decode response: {e}")),
479 };
480 match mgr.finish_authentication(&state, &response) {
481 Ok(result) => (
482 StatusCode::OK,
483 Json(json!({
484 "credential_id": data_encoding::BASE64URL_NOPAD.encode(&result.credential_id),
485 "sign_count": result.sign_count,
486 "user_verified": result.user_verified,
487 })),
488 )
489 .into_response(),
490 Err(e) => unauthorized(&format!("finish_authentication: {e}")),
491 }
492}
493
494fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
495 let raw = headers.get(header::COOKIE)?.to_str().ok()?;
496 for kv in raw.split(';') {
497 let kv = kv.trim();
498 if let Some((k, v)) = kv.split_once('=')
499 && k == name
500 {
501 return Some(v.to_string());
502 }
503 }
504 None
505}
506
507fn unauthorized(msg: &str) -> Response {
508 (StatusCode::UNAUTHORIZED, Json(json!({"error": msg}))).into_response()
509}
510fn bad_request(msg: &str) -> Response {
511 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
512}
513fn server_error(msg: &str) -> Response {
514 (
515 StatusCode::INTERNAL_SERVER_ERROR,
516 Json(json!({"error": msg})),
517 )
518 .into_response()
519}
520fn svc_unavailable(msg: &str) -> Response {
521 (StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": msg}))).into_response()
522}
523
524#[cfg(test)]
525mod tests {
526 use super::*;
527
528 use std::collections::HashMap;
529 use std::sync::Mutex;
530
531 struct MemSessionStore(Mutex<HashMap<String, Session>>);
534
535 impl MemSessionStore {
536 fn new() -> Self {
537 Self(Mutex::new(HashMap::new()))
538 }
539 }
540
541 #[async_trait::async_trait]
542 impl SessionStore for MemSessionStore {
543 async fn create(&self, session: &Session) -> anyhow::Result<()> {
544 self.0
545 .lock()
546 .unwrap()
547 .insert(session.id.clone(), session.clone());
548 Ok(())
549 }
550
551 async fn get(&self, id: &str) -> anyhow::Result<Option<Session>> {
552 Ok(self.0.lock().unwrap().get(id).cloned())
553 }
554
555 async fn delete(&self, id: &str) -> anyhow::Result<bool> {
556 Ok(self.0.lock().unwrap().remove(id).is_some())
557 }
558
559 async fn list_for_user(&self, user_id: &str) -> anyhow::Result<Vec<Session>> {
560 Ok(self
561 .0
562 .lock()
563 .unwrap()
564 .values()
565 .filter(|s| s.user_id == user_id)
566 .cloned()
567 .collect())
568 }
569
570 async fn delete_for_user(&self, user_id: &str) -> anyhow::Result<u64> {
571 let mut guard = self.0.lock().unwrap();
572 let before = guard.len();
573 guard.retain(|_, s| s.user_id != user_id);
574 Ok((before - guard.len()) as u64)
575 }
576
577 async fn purge_expired(&self, now: f64) -> anyhow::Result<u64> {
578 let mut guard = self.0.lock().unwrap();
579 let before = guard.len();
580 guard.retain(|_, s| s.expires_at > now);
581 Ok((before - guard.len()) as u64)
582 }
583
584 async fn list_all(
585 &self,
586 limit: i64,
587 offset: i64,
588 user_filter: Option<&str>,
589 ) -> anyhow::Result<Vec<Session>> {
590 let guard = self.0.lock().unwrap();
591 let mut all: Vec<Session> = guard
592 .values()
593 .filter(|s| user_filter.is_none_or(|u| s.user_id == u))
594 .cloned()
595 .collect();
596 all.sort_by(|a, b| {
597 b.created_at
598 .partial_cmp(&a.created_at)
599 .unwrap_or(std::cmp::Ordering::Equal)
600 });
601 let off = offset.max(0) as usize;
602 let lim = limit.clamp(1, 500) as usize;
603 Ok(all.into_iter().skip(off).take(lim).collect())
604 }
605
606 async fn count_all(&self, user_filter: Option<&str>) -> anyhow::Result<i64> {
607 let guard = self.0.lock().unwrap();
608 Ok(guard
609 .values()
610 .filter(|s| user_filter.is_none_or(|u| s.user_id == u))
611 .count() as i64)
612 }
613 }
614
615 fn manager() -> SessionManager {
616 SessionManager::with_default_duration(Arc::new(MemSessionStore::new()))
617 }
618
619 #[tokio::test]
620 async fn create_then_resolve_returns_same_session() {
621 let mgr = manager();
622 let created = mgr.create("user_alice").await.unwrap();
623 let resolved = mgr.resolve(&created.id).await.unwrap().unwrap();
624 assert_eq!(resolved.id, created.id);
625 assert_eq!(resolved.user_id, "user_alice");
626 assert!(created.id.starts_with("sess_"));
627 assert!(created.csrf_token.starts_with("csrf_"));
628 }
629
630 #[tokio::test]
631 async fn resolve_returns_none_for_unknown_id() {
632 let mgr = manager();
633 assert!(mgr.resolve("sess_nope").await.unwrap().is_none());
634 }
635
636 #[tokio::test]
637 async fn resolve_returns_none_for_expired_session() {
638 let store = Arc::new(MemSessionStore::new()) as Arc<dyn SessionStore>;
641 let expired = Session {
642 id: "sess_expired".to_string(),
643 user_id: "user_x".to_string(),
644 csrf_token: "csrf_expired".to_string(),
645 created_at: now_secs() - 1000.0,
646 expires_at: now_secs() - 1.0,
647 ip_hash: None,
648 user_agent_hash: None,
649 };
650 store.create(&expired).await.unwrap();
651 let mgr = SessionManager::with_default_duration(store);
652 assert!(mgr.resolve("sess_expired").await.unwrap().is_none());
653 }
654
655 #[tokio::test]
656 async fn rotate_returns_new_id_with_same_user_and_expiry() {
657 let mgr = manager();
658 let original = mgr.create("user_bob").await.unwrap();
659 let rotated = mgr.rotate(&original.id).await.unwrap().unwrap();
660 assert_ne!(rotated.id, original.id);
661 assert_eq!(rotated.user_id, original.user_id);
662 assert!((rotated.expires_at - original.expires_at).abs() < f64::EPSILON);
663 assert!(mgr.resolve(&original.id).await.unwrap().is_none());
665 assert!(mgr.resolve(&rotated.id).await.unwrap().is_some());
667 }
668
669 #[tokio::test]
670 async fn revoke_drops_the_session() {
671 let mgr = manager();
672 let s = mgr.create("user_eve").await.unwrap();
673 assert!(mgr.revoke(&s.id).await.unwrap());
674 assert!(mgr.resolve(&s.id).await.unwrap().is_none());
675 assert!(!mgr.revoke(&s.id).await.unwrap());
677 }
678
679 #[tokio::test]
680 async fn revoke_for_user_drops_every_session_for_that_user() {
681 let mgr = manager();
682 let _s1 = mgr.create("user_multi").await.unwrap();
683 let _s2 = mgr.create("user_multi").await.unwrap();
684 let _other = mgr.create("user_other").await.unwrap();
685 let dropped = mgr.revoke_for_user("user_multi").await.unwrap();
686 assert_eq!(dropped, 2);
687 }
688
689 #[test]
690 fn cookie_for_https_url_is_secure_httponly_lax() {
691 let session = Session {
692 id: "sess_abc".to_string(),
693 user_id: "u".to_string(),
694 csrf_token: "csrf_abc".to_string(),
695 created_at: now_secs(),
696 expires_at: now_secs() + 3600.0,
697 ip_hash: None,
698 user_agent_hash: None,
699 };
700 let url = Url::parse("https://app.example.com").unwrap();
701 let cookie = cookie_for(&session, &url);
702 assert_eq!(cookie.name(), SESSION_COOKIE);
703 assert_eq!(cookie.value(), "sess_abc");
704 assert_eq!(cookie.http_only(), Some(true));
705 assert_eq!(cookie.secure(), Some(true));
706 assert_eq!(cookie.same_site(), Some(SameSite::Lax));
707 assert_eq!(cookie.path(), Some("/"));
708 }
709
710 #[test]
711 fn csrf_cookie_is_not_http_only() {
712 let session = Session {
713 id: "sess_abc".to_string(),
714 user_id: "u".to_string(),
715 csrf_token: "csrf_abc".to_string(),
716 created_at: now_secs(),
717 expires_at: now_secs() + 3600.0,
718 ip_hash: None,
719 user_agent_hash: None,
720 };
721 let cookie = csrf_cookie_for(&session);
722 assert_eq!(cookie.name(), CSRF_COOKIE);
723 assert_eq!(cookie.value(), "csrf_abc");
724 assert_eq!(cookie.http_only(), Some(false));
726 }
727
728 #[test]
729 fn cookie_for_http_url_is_not_secure() {
730 let session = Session {
731 id: "sess_abc".to_string(),
732 user_id: "u".to_string(),
733 csrf_token: "csrf_abc".to_string(),
734 created_at: now_secs(),
735 expires_at: now_secs() + 3600.0,
736 ip_hash: None,
737 user_agent_hash: None,
738 };
739 let url = Url::parse("http://localhost:3000").unwrap();
740 let cookie = cookie_for(&session, &url);
741 assert_eq!(cookie.secure(), Some(false));
743 }
744}