use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use cookie::{Cookie, SameSite, time::Duration as CookieDuration};
use rand::RngCore;
use url::Url;
use crate::error::Result;
use crate::store::{Session, SessionStore};
pub const SESSION_COOKIE: &str = "assay_session";
pub const CSRF_COOKIE: &str = "assay_csrf";
pub const DEFAULT_SESSION_DURATION: Duration = Duration::from_secs(60 * 60 * 24 * 30);
#[derive(Clone)]
pub struct SessionManager {
store: Arc<dyn SessionStore>,
default_duration: Duration,
}
impl SessionManager {
pub fn new(store: Arc<dyn SessionStore>, default_duration: Duration) -> Self {
Self {
store,
default_duration,
}
}
pub fn with_default_duration(store: Arc<dyn SessionStore>) -> Self {
Self::new(store, DEFAULT_SESSION_DURATION)
}
pub async fn create(&self, user_id: &str) -> Result<Session> {
let id = format!("sess_{}", random_token());
let csrf_token = format!("csrf_{}", random_token());
let created_at = now_secs();
let expires_at = created_at + self.default_duration.as_secs_f64();
let session = Session {
id,
user_id: user_id.to_string(),
csrf_token,
created_at,
expires_at,
ip_hash: None,
user_agent_hash: None,
};
self.store.create(&session).await?;
Ok(session)
}
pub async fn resolve(&self, id: &str) -> Result<Option<Session>> {
let Some(session) = self.store.get(id).await? else {
return Ok(None);
};
if session.expires_at <= now_secs() {
return Ok(None);
}
Ok(Some(session))
}
pub async fn rotate(&self, old_id: &str) -> Result<Option<Session>> {
let Some(old) = self.store.get(old_id).await? else {
return Ok(None);
};
self.store.delete(old_id).await?;
let new_id = format!("sess_{}", random_token());
let csrf_token = format!("csrf_{}", random_token());
let session = Session {
id: new_id,
user_id: old.user_id,
csrf_token,
created_at: now_secs(),
expires_at: old.expires_at,
ip_hash: old.ip_hash,
user_agent_hash: old.user_agent_hash,
};
self.store.create(&session).await?;
Ok(Some(session))
}
pub async fn revoke(&self, id: &str) -> Result<bool> {
Ok(self.store.delete(id).await?)
}
pub async fn revoke_for_user(&self, user_id: &str) -> Result<u64> {
Ok(self.store.delete_for_user(user_id).await?)
}
pub fn store(&self) -> &Arc<dyn SessionStore> {
&self.store
}
}
pub fn cookie_for(session: &Session, public_url: &Url) -> Cookie<'static> {
let max_age = max_age_for(session);
let secure = is_secure(public_url);
Cookie::build((SESSION_COOKIE, session.id.clone()))
.path("/")
.secure(secure)
.http_only(true)
.same_site(SameSite::Lax)
.max_age(max_age)
.build()
.into_owned()
}
pub fn csrf_cookie_for(session: &Session) -> Cookie<'static> {
let max_age = max_age_for(session);
Cookie::build((CSRF_COOKIE, session.csrf_token.clone()))
.path("/")
.secure(true)
.http_only(false)
.same_site(SameSite::Lax)
.max_age(max_age)
.build()
.into_owned()
}
fn max_age_for(session: &Session) -> CookieDuration {
let secs = (session.expires_at - now_secs()).max(0.0) as i64;
CookieDuration::seconds(secs)
}
fn is_secure(public_url: &Url) -> bool {
public_url.scheme().eq_ignore_ascii_case("https")
}
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs_f64()
}
fn random_token() -> String {
let mut buf = [0u8; 32];
rand::rng().fill_bytes(&mut buf);
data_encoding::BASE64URL_NOPAD.encode(&buf)
}
use axum::Router;
use axum::extract::{FromRef, State};
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Json, Response};
use axum::routing::{delete, get, post};
use serde::Deserialize;
use serde_json::json;
use crate::ctx::AuthCtx;
pub fn router<S>() -> Router<S>
where
S: Clone + Send + Sync + 'static,
AuthCtx: FromRef<S>,
{
Router::new()
.route("/login", post(login_post))
.route("/session", delete(logout_delete))
.route("/whoami", get(whoami_get))
.route("/passkey/register/start", post(passkey_register_start))
.route("/passkey/register/finish", post(passkey_register_finish))
.route("/passkey/auth/start", post(passkey_auth_start))
.route("/passkey/auth/finish", post(passkey_auth_finish))
}
#[derive(Deserialize)]
struct LoginBody {
email: String,
password: String,
}
async fn login_post(State(ctx): State<AuthCtx>, Json(body): Json<LoginBody>) -> Response {
let user = match ctx.users.get_user_by_email(&body.email).await {
Ok(Some(u)) => u,
_ => return unauthorized("invalid credentials"),
};
let stored = match ctx.users.get_password_hash(&user.id).await {
Ok(Some(h)) => h,
_ => return unauthorized("invalid credentials"),
};
let hasher = crate::password::PasswordHasher::default();
let ok = match hasher.verify(&body.password, &stored) {
Ok(b) => b,
Err(_) => return unauthorized("invalid credentials"),
};
if !ok {
return unauthorized("invalid credentials");
}
let mgr = SessionManager::with_default_duration(ctx.sessions.clone());
let session = match mgr.create(&user.id).await {
Ok(s) => s,
Err(e) => return server_error(&format!("create session: {e}")),
};
let public_url = ctx
.oidc_provider
.as_ref()
.map(|p| p.public_url.clone())
.unwrap_or_else(|| url::Url::parse("http://localhost").unwrap());
let cookie = cookie_for(&session, &public_url);
let csrf = csrf_cookie_for(&session);
let mut response = (
StatusCode::OK,
Json(json!({
"user_id": user.id,
"email": user.email,
"csrf_token": session.csrf_token,
})),
)
.into_response();
if let Ok(value) = cookie.to_string().parse() {
response.headers_mut().append(header::SET_COOKIE, value);
}
if let Ok(value) = csrf.to_string().parse() {
response.headers_mut().append(header::SET_COOKIE, value);
}
response
}
async fn logout_delete(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
if let Some(sid) = parse_cookie(&headers, SESSION_COOKIE) {
let _ = ctx.sessions.delete(&sid).await;
}
let mut response = (StatusCode::NO_CONTENT, "").into_response();
let clear = format!(
"{}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
SESSION_COOKIE
);
if let Ok(v) = clear.parse() {
response.headers_mut().append(header::SET_COOKIE, v);
}
response
}
async fn whoami_get(State(ctx): State<AuthCtx>, headers: HeaderMap) -> Response {
let sid = match parse_cookie(&headers, SESSION_COOKIE) {
Some(s) => s,
None => return unauthorized("no session"),
};
let mgr = SessionManager::with_default_duration(ctx.sessions.clone());
let session = match mgr.resolve(&sid).await {
Ok(Some(s)) => s,
_ => return unauthorized("session unknown"),
};
let user = match ctx.users.get_user_by_id(&session.user_id).await {
Ok(Some(u)) => u,
_ => return unauthorized("user unknown"),
};
(
StatusCode::OK,
Json(json!({
"user_id": user.id,
"email": user.email,
"email_verified": user.email_verified,
"display_name": user.display_name,
})),
)
.into_response()
}
#[derive(Deserialize)]
struct PasskeyRegisterStartBody {
user_id: String,
user_name: String,
display_name: String,
}
async fn passkey_register_start(
State(ctx): State<AuthCtx>,
Json(body): Json<PasskeyRegisterStartBody>,
) -> Response {
let Some(mgr) = ctx.passkeys.as_ref() else {
return svc_unavailable("passkey manager not configured");
};
let uuid = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, body.user_id.as_bytes());
match mgr
.start_registration(
uuid,
&body.user_name,
&body.display_name,
Some(&body.user_id),
)
.await
{
Ok((challenge, state)) => {
let state_blob = serde_json::to_string(&state).unwrap_or_default();
(
StatusCode::OK,
Json(json!({
"challenge": challenge,
"state": state_blob,
})),
)
.into_response()
}
Err(e) => bad_request(&format!("start_registration: {e}")),
}
}
#[derive(Deserialize)]
struct PasskeyRegisterFinishBody {
user_id: String,
state: String,
response: serde_json::Value,
}
async fn passkey_register_finish(
State(ctx): State<AuthCtx>,
Json(body): Json<PasskeyRegisterFinishBody>,
) -> Response {
let Some(mgr) = ctx.passkeys.as_ref() else {
return svc_unavailable("passkey manager not configured");
};
let state: webauthn_rs::prelude::PasskeyRegistration = match serde_json::from_str(&body.state) {
Ok(s) => s,
Err(e) => return bad_request(&format!("decode state: {e}")),
};
let response: webauthn_rs::prelude::RegisterPublicKeyCredential =
match serde_json::from_value(body.response) {
Ok(r) => r,
Err(e) => return bad_request(&format!("decode response: {e}")),
};
let passkey = match mgr.finish_registration(&state, &response) {
Ok(p) => p,
Err(e) => return bad_request(&format!("finish_registration: {e}")),
};
let cred = crate::passkey::passkey_to_cred(&passkey, now_secs());
if let Err(e) = ctx.users.add_passkey(&body.user_id, &cred).await {
return server_error(&format!("persist passkey: {e}"));
}
(
StatusCode::OK,
Json(json!({"credential_id": data_encoding::BASE64URL_NOPAD.encode(&cred.credential_id)})),
)
.into_response()
}
#[derive(Deserialize)]
struct PasskeyAuthStartBody {
user_id: String,
#[serde(default)]
passkeys: Vec<serde_json::Value>,
}
async fn passkey_auth_start(
State(ctx): State<AuthCtx>,
Json(body): Json<PasskeyAuthStartBody>,
) -> Response {
let Some(mgr) = ctx.passkeys.as_ref() else {
return svc_unavailable("passkey manager not configured");
};
let _ = body.user_id;
let mut creds: Vec<webauthn_rs::prelude::Passkey> = Vec::with_capacity(body.passkeys.len());
for v in body.passkeys {
match serde_json::from_value(v) {
Ok(p) => creds.push(p),
Err(e) => return bad_request(&format!("decode passkey: {e}")),
}
}
if creds.is_empty() {
return bad_request("passkeys list is empty");
}
match mgr.start_authentication_with(&creds) {
Ok((challenge, state)) => (
StatusCode::OK,
Json(json!({
"challenge": challenge,
"state": serde_json::to_string(&state).unwrap_or_default(),
})),
)
.into_response(),
Err(e) => bad_request(&format!("start_authentication: {e}")),
}
}
#[derive(Deserialize)]
struct PasskeyAuthFinishBody {
state: String,
response: serde_json::Value,
}
async fn passkey_auth_finish(
State(ctx): State<AuthCtx>,
Json(body): Json<PasskeyAuthFinishBody>,
) -> Response {
let Some(mgr) = ctx.passkeys.as_ref() else {
return svc_unavailable("passkey manager not configured");
};
let state: webauthn_rs::prelude::PasskeyAuthentication = match serde_json::from_str(&body.state)
{
Ok(s) => s,
Err(e) => return bad_request(&format!("decode state: {e}")),
};
let response: webauthn_rs::prelude::PublicKeyCredential =
match serde_json::from_value(body.response) {
Ok(r) => r,
Err(e) => return bad_request(&format!("decode response: {e}")),
};
match mgr.finish_authentication(&state, &response) {
Ok(result) => (
StatusCode::OK,
Json(json!({
"credential_id": data_encoding::BASE64URL_NOPAD.encode(&result.credential_id),
"sign_count": result.sign_count,
"user_verified": result.user_verified,
})),
)
.into_response(),
Err(e) => unauthorized(&format!("finish_authentication: {e}")),
}
}
fn parse_cookie(headers: &HeaderMap, name: &str) -> Option<String> {
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
for kv in raw.split(';') {
let kv = kv.trim();
if let Some((k, v)) = kv.split_once('=')
&& k == name
{
return Some(v.to_string());
}
}
None
}
fn unauthorized(msg: &str) -> Response {
(StatusCode::UNAUTHORIZED, Json(json!({"error": msg}))).into_response()
}
fn bad_request(msg: &str) -> Response {
(StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response()
}
fn server_error(msg: &str) -> Response {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(json!({"error": msg})),
)
.into_response()
}
fn svc_unavailable(msg: &str) -> Response {
(StatusCode::SERVICE_UNAVAILABLE, Json(json!({"error": msg}))).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::Mutex;
struct MemSessionStore(Mutex<HashMap<String, Session>>);
impl MemSessionStore {
fn new() -> Self {
Self(Mutex::new(HashMap::new()))
}
}
#[async_trait::async_trait]
impl SessionStore for MemSessionStore {
async fn create(&self, session: &Session) -> anyhow::Result<()> {
self.0
.lock()
.unwrap()
.insert(session.id.clone(), session.clone());
Ok(())
}
async fn get(&self, id: &str) -> anyhow::Result<Option<Session>> {
Ok(self.0.lock().unwrap().get(id).cloned())
}
async fn delete(&self, id: &str) -> anyhow::Result<bool> {
Ok(self.0.lock().unwrap().remove(id).is_some())
}
async fn list_for_user(&self, user_id: &str) -> anyhow::Result<Vec<Session>> {
Ok(self
.0
.lock()
.unwrap()
.values()
.filter(|s| s.user_id == user_id)
.cloned()
.collect())
}
async fn delete_for_user(&self, user_id: &str) -> anyhow::Result<u64> {
let mut guard = self.0.lock().unwrap();
let before = guard.len();
guard.retain(|_, s| s.user_id != user_id);
Ok((before - guard.len()) as u64)
}
async fn purge_expired(&self, now: f64) -> anyhow::Result<u64> {
let mut guard = self.0.lock().unwrap();
let before = guard.len();
guard.retain(|_, s| s.expires_at > now);
Ok((before - guard.len()) as u64)
}
async fn list_all(
&self,
limit: i64,
offset: i64,
user_filter: Option<&str>,
) -> anyhow::Result<Vec<Session>> {
let guard = self.0.lock().unwrap();
let mut all: Vec<Session> = guard
.values()
.filter(|s| user_filter.is_none_or(|u| s.user_id == u))
.cloned()
.collect();
all.sort_by(|a, b| {
b.created_at
.partial_cmp(&a.created_at)
.unwrap_or(std::cmp::Ordering::Equal)
});
let off = offset.max(0) as usize;
let lim = limit.clamp(1, 500) as usize;
Ok(all.into_iter().skip(off).take(lim).collect())
}
async fn count_all(&self, user_filter: Option<&str>) -> anyhow::Result<i64> {
let guard = self.0.lock().unwrap();
Ok(guard
.values()
.filter(|s| user_filter.is_none_or(|u| s.user_id == u))
.count() as i64)
}
}
fn manager() -> SessionManager {
SessionManager::with_default_duration(Arc::new(MemSessionStore::new()))
}
#[tokio::test]
async fn create_then_resolve_returns_same_session() {
let mgr = manager();
let created = mgr.create("user_alice").await.unwrap();
let resolved = mgr.resolve(&created.id).await.unwrap().unwrap();
assert_eq!(resolved.id, created.id);
assert_eq!(resolved.user_id, "user_alice");
assert!(created.id.starts_with("sess_"));
assert!(created.csrf_token.starts_with("csrf_"));
}
#[tokio::test]
async fn resolve_returns_none_for_unknown_id() {
let mgr = manager();
assert!(mgr.resolve("sess_nope").await.unwrap().is_none());
}
#[tokio::test]
async fn resolve_returns_none_for_expired_session() {
let store = Arc::new(MemSessionStore::new()) as Arc<dyn SessionStore>;
let expired = Session {
id: "sess_expired".to_string(),
user_id: "user_x".to_string(),
csrf_token: "csrf_expired".to_string(),
created_at: now_secs() - 1000.0,
expires_at: now_secs() - 1.0,
ip_hash: None,
user_agent_hash: None,
};
store.create(&expired).await.unwrap();
let mgr = SessionManager::with_default_duration(store);
assert!(mgr.resolve("sess_expired").await.unwrap().is_none());
}
#[tokio::test]
async fn rotate_returns_new_id_with_same_user_and_expiry() {
let mgr = manager();
let original = mgr.create("user_bob").await.unwrap();
let rotated = mgr.rotate(&original.id).await.unwrap().unwrap();
assert_ne!(rotated.id, original.id);
assert_eq!(rotated.user_id, original.user_id);
assert!((rotated.expires_at - original.expires_at).abs() < f64::EPSILON);
assert!(mgr.resolve(&original.id).await.unwrap().is_none());
assert!(mgr.resolve(&rotated.id).await.unwrap().is_some());
}
#[tokio::test]
async fn revoke_drops_the_session() {
let mgr = manager();
let s = mgr.create("user_eve").await.unwrap();
assert!(mgr.revoke(&s.id).await.unwrap());
assert!(mgr.resolve(&s.id).await.unwrap().is_none());
assert!(!mgr.revoke(&s.id).await.unwrap());
}
#[tokio::test]
async fn revoke_for_user_drops_every_session_for_that_user() {
let mgr = manager();
let _s1 = mgr.create("user_multi").await.unwrap();
let _s2 = mgr.create("user_multi").await.unwrap();
let _other = mgr.create("user_other").await.unwrap();
let dropped = mgr.revoke_for_user("user_multi").await.unwrap();
assert_eq!(dropped, 2);
}
#[test]
fn cookie_for_https_url_is_secure_httponly_lax() {
let session = Session {
id: "sess_abc".to_string(),
user_id: "u".to_string(),
csrf_token: "csrf_abc".to_string(),
created_at: now_secs(),
expires_at: now_secs() + 3600.0,
ip_hash: None,
user_agent_hash: None,
};
let url = Url::parse("https://app.example.com").unwrap();
let cookie = cookie_for(&session, &url);
assert_eq!(cookie.name(), SESSION_COOKIE);
assert_eq!(cookie.value(), "sess_abc");
assert_eq!(cookie.http_only(), Some(true));
assert_eq!(cookie.secure(), Some(true));
assert_eq!(cookie.same_site(), Some(SameSite::Lax));
assert_eq!(cookie.path(), Some("/"));
}
#[test]
fn csrf_cookie_is_not_http_only() {
let session = Session {
id: "sess_abc".to_string(),
user_id: "u".to_string(),
csrf_token: "csrf_abc".to_string(),
created_at: now_secs(),
expires_at: now_secs() + 3600.0,
ip_hash: None,
user_agent_hash: None,
};
let cookie = csrf_cookie_for(&session);
assert_eq!(cookie.name(), CSRF_COOKIE);
assert_eq!(cookie.value(), "csrf_abc");
assert_eq!(cookie.http_only(), Some(false));
}
#[test]
fn cookie_for_http_url_is_not_secure() {
let session = Session {
id: "sess_abc".to_string(),
user_id: "u".to_string(),
csrf_token: "csrf_abc".to_string(),
created_at: now_secs(),
expires_at: now_secs() + 3600.0,
ip_hash: None,
user_agent_hash: None,
};
let url = Url::parse("http://localhost:3000").unwrap();
let cookie = cookie_for(&session, &url);
assert_eq!(cookie.secure(), Some(false));
}
}