Skip to main content

assay_auth/
session.rs

1//! Session management — opaque server-side sessions backed by
2//! [`crate::store::SessionStore`].
3//!
4//! Plan 11 reference: "auth.session" — an opaque session id (random 32
5//! bytes, base64url) plus a parallel CSRF token. The cookie value is the
6//! session id; the server resolves it against the store on every
7//! request. Revocation is a single DELETE — no JWT-style "wait for
8//! expiry" footgun.
9//!
10//! `SessionManager` is the entry point. Cookie helpers ([`cookie_for`],
11//! [`csrf_cookie_for`]) build the standard cookie pair (HttpOnly +
12//! Secure session cookie, JS-readable CSRF cookie) used by the
13//! double-submit pattern.
14//!
15//! Phase 8 adds a HTTP router under [`router`] that mounts the
16//! session-facing endpoints (`/login`, `/logout`, `/whoami`, passkey
17//! ceremony). The auth top-level router merges this in.
18
19use 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
29/// Cookie name carrying the opaque session id. HttpOnly — never read by
30/// browser JS.
31pub const SESSION_COOKIE: &str = "assay_session";
32
33/// Cookie name carrying the CSRF token. NOT HttpOnly — client JS reads
34/// this and echoes it in a request header (double-submit pattern).
35pub const CSRF_COOKIE: &str = "assay_csrf";
36
37/// Default session lifetime — 30 days. Matches typical "remember me"
38/// expectations; per-deployment configuration overrides via
39/// [`SessionManager::new`].
40pub const DEFAULT_SESSION_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 30);
41
42/// Owns the [`SessionStore`] and mints / resolves / revokes sessions.
43///
44/// Cheap to clone — the underlying store is reference-counted.
45#[derive(Clone)]
46pub struct SessionManager {
47    store: Arc<dyn SessionStore>,
48    default_duration: Duration,
49}
50
51impl SessionManager {
52    /// Construct a manager with an explicit default session duration.
53    /// Callers wanting the standard 30-day lifetime should use
54    /// [`SessionManager::with_default_duration`].
55    pub fn new(store: Arc<dyn SessionStore>, default_duration: Duration) -> Self {
56        Self {
57            store,
58            default_duration,
59        }
60    }
61
62    /// Construct with the [`DEFAULT_SESSION_DURATION`] (30 days).
63    pub fn with_default_duration(store: Arc<dyn SessionStore>) -> Self {
64        Self::new(store, DEFAULT_SESSION_DURATION)
65    }
66
67    /// Mint a fresh session for `user_id` and persist it via the store.
68    /// The returned [`Session`] carries both the opaque cookie value
69    /// (`id`) and the parallel CSRF token. Call sites set both cookies
70    /// on the response (see [`cookie_for`] / [`csrf_cookie_for`]).
71    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    /// Resolve a presented session id. Returns `Ok(None)` for a
90    /// missing-or-expired session so callers can treat
91    /// "not authenticated" uniformly. Expired rows are left in the table
92    /// — the periodic [`SessionStore::purge_expired`] sweep removes them.
93    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    /// Rotate a session id while preserving the user binding and the
104    /// original `expires_at`. Used on privilege escalation
105    /// (e.g. completing a step-up authentication) to defeat session
106    /// fixation. Returns `Ok(None)` if the old id is unknown — caller
107    /// can decide whether to surface as 401.
108    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        // Delete old before creating new so a crash in between can't
113        // leave both sessions live for the same user with the same
114        // expiry stamp.
115        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    /// Revoke a single session — typically called from `/logout`.
132    pub async fn revoke(&self, id: &str) -> Result<bool> {
133        Ok(self.store.delete(id).await?)
134    }
135
136    /// Revoke every session for a user — typically called from
137    /// "log out of all devices" or after a password change.
138    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    /// Borrow the underlying store. Phase 5/6 may want direct access
143    /// (e.g. for the periodic purge sweep) without going through the
144    /// manager's API surface.
145    pub fn store(&self) -> &Arc<dyn SessionStore> {
146        &self.store
147    }
148}
149
150/// Build the HttpOnly session cookie that carries the opaque id.
151///
152/// `Secure; HttpOnly; SameSite=Lax; Path=/` matches the assumed
153/// deployment shape — `public_url` only contributes its scheme today
154/// (HTTPS in production), but is taken as a `Url` so the future
155/// "domain= when running under a sub-domain" extension lands without an
156/// API change.
157pub 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
170/// Build the parallel CSRF cookie. Same path / max-age as the session
171/// cookie but NOT HttpOnly so client JS can echo the value in a request
172/// header on state-changing requests (double-submit pattern).
173pub 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
185/// Translate the session's wall-clock `expires_at` into a cookie
186/// `Max-Age`. Clamped to `>= 0` so an already-expired session yields a
187/// "delete me now" cookie instead of a negative age (some browsers
188/// reject negative max-age outright).
189fn 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
194/// HTTPS deployments (production) → Secure cookies. HTTP deployments
195/// (local dev) → not Secure, otherwise the browser drops them. The
196/// `public_url` is the operator-supplied canonical URL so this honours
197/// the actual deployment, not the bind address (which may be 0.0.0.0
198/// behind a TLS reverse proxy).
199fn 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
216// HTTP router — see `router()` below for the canonical route list.
217
218use 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
228/// Build the session router. Generic over a parent state `S` from
229/// which `AuthCtx` is extractable via `axum::extract::FromRef`.
230pub 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    /// Optional pre-decoded passkeys (for tests + advanced clients).
420    /// In production we'd load these out of `auth.passkeys`;
421    /// has no `passkey_json` column so we accept them as a body field.
422    #[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    /// In-memory store for fast unit tests. Mirrors the trait surface
532    /// without touching sqlx.
533    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        // Build a session manually with expires_at in the past, then
639        // probe through the manager.
640        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        // Old id is gone after rotation.
664        assert!(mgr.resolve(&original.id).await.unwrap().is_none());
665        // New id resolves.
666        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        // Revoking again returns false (idempotent path).
676        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        // CSRF token must be readable by client JS.
725        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        // Local-dev HTTP must not set Secure or the browser drops the cookie.
742        assert_eq!(cookie.secure(), Some(false));
743    }
744}