use axum::http::{HeaderMap, header}; use chrono::{DateTime, Duration, Utc}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use uuid::Uuid; use crate::{Error, Result, store::ControlPlaneStore}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AuthPublicConfig { pub rp_id: String, pub origin: String, pub public_base_url: String, pub cookie_name: String, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct RequestActor { pub user_id: String, pub account_id: String, pub handle: String, pub display_name: String, pub auth_method: ActorAuthMethod, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] pub enum ActorAuthMethod { BrowserSession, ApiToken, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct AuthenticatedUser { pub user_id: String, pub account_id: String, pub handle: String, pub display_name: String, } impl RequestActor { pub fn user(&self) -> AuthenticatedUser { AuthenticatedUser { user_id: self.user_id.clone(), account_id: self.account_id.clone(), handle: self.handle.clone(), display_name: self.display_name.clone(), } } } pub fn normalize_handle(handle: &str) -> Result { let normalized = handle.trim().to_ascii_lowercase(); let valid = !normalized.is_empty() && normalized.len() <= 64 && normalized .chars() .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); if !valid { return Err(auth_error( "invalid_handle", "handle must be 1-64 chars of ascii lowercase letters, digits, '-' or '_'", )); } Ok(normalized) } pub fn now_rfc3339() -> String { Utc::now().to_rfc3339() } pub fn rfc3339_after(duration: Duration) -> String { (Utc::now() + duration).to_rfc3339() } pub fn is_expired(expires_at: &str) -> bool { match DateTime::parse_from_rfc3339(expires_at) { Ok(expires_at) => expires_at.with_timezone(&Utc) <= Utc::now(), Err(_) => true, } } pub fn new_id(prefix: &str) -> String { format!("{prefix}-{}", Uuid::now_v7()) } pub fn new_challenge() -> String { format!("chal-{}-{}", Uuid::now_v7(), Uuid::now_v7()) } pub fn mint_secret(prefix: &str) -> String { format!("{prefix}_{}_{}", Uuid::now_v7(), Uuid::now_v7()) } pub fn token_hash(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); let digest = hasher.finalize(); let mut encoded = String::with_capacity(digest.len() * 2 + "sha256:".len()); encoded.push_str("sha256:"); for byte in digest { encoded.push_str(&format!("{byte:02x}")); } encoded } pub fn new_user_code() -> String { let hex = Uuid::now_v7().simple().to_string().to_ascii_uppercase(); format!("{}-{}", &hex[0..4], &hex[4..8]) } pub fn parse_bearer(headers: &HeaderMap) -> Option { headers .get(header::AUTHORIZATION) .and_then(|value| value.to_str().ok()) .and_then(|value| value.strip_prefix("Bearer ")) .map(str::trim) .filter(|value| !value.is_empty()) .map(ToOwned::to_owned) } pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; cookie_header.split(';').find_map(|pair| { let (name, value) = pair.trim().split_once('=')?; (name == cookie_name && !value.is_empty()).then(|| value.to_string()) }) } pub async fn resolve_request_actor( store: &S, headers: &HeaderMap, cookie_name: &str, ) -> Result> { if let Some(token) = parse_bearer(headers) { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { if api_token .expires_at .as_deref() .is_some_and(|expires_at| is_expired(expires_at)) { return Ok(None); } store.mark_api_token_used(&token_hash, &now_rfc3339())?; return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); } } if let Some(session_token) = parse_cookie(headers, cookie_name) { if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? { if is_expired(&session.expires_at) { return Ok(None); } return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); } } Ok(None) } fn actor_for_user( store: &S, user_id: &str, auth_method: ActorAuthMethod, ) -> Result> { let Some(user) = store.get_user(user_id)? else { return Ok(None); }; Ok(Some(RequestActor { user_id: user.user_id, account_id: user.account_id, handle: user.handle, display_name: user.display_name, auth_method, })) } pub fn session_set_cookie(cookie_name: &str, token: &str, max_age_seconds: i64) -> String { format!("{cookie_name}={token}; Max-Age={max_age_seconds}; Path=/; HttpOnly; SameSite=Lax") } pub fn auth_error(code: &str, message: &str) -> Error { Error::RuntimeOperationFailed { runtime_id: "workspace-auth".to_string(), code: code.to_string(), message: message.to_string(), } }