use axum::{ extract::{OriginalUri, Request, State}, http::{HeaderValue, Method, header}, middleware::Next, response::Response, }; use axum_extra::extract::cookie::{Cookie, CookieJar}; use crank_community_auth::{ cleared_session_cookie as build_cleared_session_cookie, create_csrf_token as build_csrf_token, create_session_cookie as build_session_cookie, hash_password as community_hash_password, session_cookie as build_session_cookie_header, }; use crank_core::{MembershipRole, User, UserSessionId, WorkspaceId}; use crank_registry::WorkspaceMembershipRecord; use serde::Serialize; use crate::{error::ApiError, state::AppState}; pub use crank_community_auth::{ SESSION_COOKIE_NAME, SessionCookie, create_csrf_token, extract_session_token, hash_csrf_token, hash_session_secret, verify_password, }; #[derive(Clone)] pub struct BootstrapAdminConfig { pub email: String, pub password: String, pub display_name: String, } #[derive(Clone)] pub struct AuthSettings { pub session_secret: String, pub password_pepper: String, pub session_ttl_hours: i64, pub cookie_secure: bool, pub bootstrap_admin: BootstrapAdminConfig, } #[derive(Clone, Debug, Serialize)] pub struct AuthenticatedSession { pub session_id: UserSessionId, pub user: User, pub memberships: Vec, pub current_workspace_id: Option, } pub fn hash_password(password: &str, pepper: &str) -> Result { community_hash_password(password, pepper) .map_err(|error| ApiError::internal(format!("failed to hash password: {error}"))) } pub fn create_session_cookie(settings: &AuthSettings) -> Result { build_session_cookie(settings.session_ttl_hours) .map_err(|error| ApiError::internal(format!("failed to create session cookie: {error}"))) } pub fn create_csrf_token_value() -> String { build_csrf_token() } pub fn session_cookie(settings: &AuthSettings, token: &str) -> Cookie<'static> { build_session_cookie_header(token, settings.cookie_secure, settings.session_ttl_hours) } pub fn cleared_session_cookie(settings: &AuthSettings) -> Cookie<'static> { build_cleared_session_cookie(settings.cookie_secure) } pub async fn require_session( State(state): State, jar: CookieJar, mut request: Request, next: Next, ) -> Result { let session = resolve_authenticated_session(state.clone(), &jar).await?; request.extensions_mut().insert(session); Ok(next.run(request).await) } pub async fn require_workspace_session( State(state): State, jar: CookieJar, original_uri: OriginalUri, mut request: Request, next: Next, ) -> Result { let session = resolve_authenticated_session(state.clone(), &jar).await?; let workspace_id = workspace_id_from_path(original_uri.path()) .ok_or_else(|| ApiError::internal("workspace middleware was applied to an invalid path"))?; let has_access = state .service .user_has_workspace_access(&session.user.id, &workspace_id) .await?; if !has_access { return Err(ApiError::forbidden("workspace access denied")); } if !matches!( request.method(), &axum::http::Method::GET | &axum::http::Method::HEAD ) && !session.memberships.iter().any(|membership| { membership.workspace.id == workspace_id && membership.role == MembershipRole::Owner }) { return Err(ApiError::forbidden("workspace owner access required")); } request.extensions_mut().insert(session); Ok(next.run(request).await) } pub async fn require_csrf_for_browser_mutations( State(state): State, jar: CookieJar, request: Request, next: Next, ) -> Result { if !requires_csrf(request.method(), request.uri().path()) { return Ok(next.run(request).await); } let (session_id, _session_value) = extract_session_token(&jar) .ok_or_else(|| ApiError::unauthorized("authentication required"))?; let token = request .headers() .get("x-csrf-token") .and_then(|value| value.to_str().ok()) .filter(|value| valid_csrf_token(value)) .ok_or_else(|| ApiError::forbidden("csrf validation failed"))?; let csrf_hash = hash_csrf_token( &session_id, token, &state.service.auth_settings().session_secret, ); if !state .service .verify_session_csrf(&session_id, &csrf_hash) .await? { return Err(ApiError::forbidden("csrf validation failed")); } Ok(next.run(request).await) } pub async fn enforce_browser_security( State(state): State, request: Request, next: Next, ) -> Result { if let Some(origin) = request.headers().get(header::ORIGIN) && is_cross_origin( origin, request.headers().get(header::HOST), if state.service.auth_settings().cookie_secure { "https" } else { "http" }, ) && request.uri().path().starts_with("/api/") { return Err(ApiError::forbidden("cross-origin admin request denied")); } let mut response = next.run(request).await; let headers = response.headers_mut(); headers.insert( "x-content-type-options", HeaderValue::from_static("nosniff"), ); headers.insert("x-frame-options", HeaderValue::from_static("DENY")); headers.insert("referrer-policy", HeaderValue::from_static("no-referrer")); Ok(response) } async fn resolve_authenticated_session( state: AppState, jar: &CookieJar, ) -> Result { let (session_id, session_value) = extract_session_token(jar) .ok_or_else(|| ApiError::unauthorized("authentication required"))?; let session = state .service .get_session(&session_id, &session_value) .await? .ok_or_else(|| ApiError::unauthorized("session is invalid or expired"))?; state.service.touch_session(&session_id).await?; Ok(session) } fn workspace_id_from_path(path: &str) -> Option { let marker = "/api/admin/workspaces/"; let (_, suffix) = path.split_once(marker)?; let workspace_id = suffix.split('/').next()?; if workspace_id.is_empty() { return None; } Some(WorkspaceId::new(workspace_id)) } fn requires_csrf(method: &Method, path: &str) -> bool { if matches!(method, &Method::GET | &Method::HEAD | &Method::OPTIONS) { return false; } if matches!( path, "/api/auth/login" | "/api/auth/bootstrap/complete" | "/api/auth/session/csrf" ) { return false; } path.starts_with("/api/auth/") || path.starts_with("/api/admin/") } fn valid_csrf_token(value: &str) -> bool { (32..=256).contains(&value.len()) && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.')) } fn is_cross_origin( origin: &HeaderValue, host: Option<&HeaderValue>, expected_scheme: &str, ) -> bool { let Some(host) = host.and_then(|value| value.to_str().ok()) else { return true; }; let Some(origin) = origin.to_str().ok() else { return true; }; let Ok(url) = url::Url::parse(origin) else { return true; }; url.host_str() .zip(url.port_or_known_default()) .map(|(origin_host, origin_port)| { let origin_authority = format!("{}://{origin_host}:{origin_port}", url.scheme()); let request_authority = if host.contains(':') { format!("{expected_scheme}://{}", host.to_ascii_lowercase()) } else { let default_port = if expected_scheme == "https" { 443 } else { 80 }; format!( "{expected_scheme}://{}:{default_port}", host.to_ascii_lowercase() ) }; origin_authority.to_ascii_lowercase() != request_authority }) .unwrap_or(true) }