use axum::{ extract::{OriginalUri, Request, State}, middleware::Next, response::Response, }; use axum_extra::extract::cookie::{Cookie, CookieJar}; use crank_community_auth::{ cleared_session_cookie as build_cleared_session_cookie, create_session_cookie as build_session_cookie, hash_password as community_hash_password, session_cookie as build_session_cookie_header, }; use crank_core::{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, extract_session_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 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")); } request.extensions_mut().insert(session); Ok(next.run(request).await) } 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)) }