use axum::{ extract::State, http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, Json, }; use axum_extra::{ headers::{authorization::Bearer, Authorization, Cookie}, typed_header::TypedHeader, }; use chrono::{Duration as ChronoDuration, Utc}; use diesel::{pg::PgConnection, prelude::*}; use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use utoipa::{OpenApi, ToSchema}; use uuid::Uuid; use crate::{ auth::{ api_tokens::{find_active_token_by_secret, touch_api_token}, passkeys::{ AuthenticationChallengeResponse, PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload, PasskeySummary, RegistrationChallengeResponse, }, AuthenticatedUser, }, error::{AppError, AppResult}, models::{ ApiTokenCapability, MagicToken, MagicTokenKind, NewUser, NewUserSession, TenantStatus, User, UserSession, }, schema::{ magic_tokens::dsl as magic_dsl, tenants::dsl as tenant_dsl, user_memberships::dsl as memberships_dsl, user_passkeys::dsl as passkey_dsl, user_sessions, users::dsl, }, state::AppState, tenants::{ apply_tenant_guc, apply_user_guc, apply_user_session_hash, clear_user_guc, clear_user_session_hash, }, }; use crate::schema::user_sessions::dsl as session_dsl; use webauthn_rs::prelude::RegisterPublicKeyCredential; const SESSION_COOKIE_NAME: &str = "refresh_token"; #[derive(Deserialize, ToSchema)] pub struct LoginRequest { #[serde(default)] pub username: String, #[serde(default)] #[schema(nullable)] pub password: Option, #[serde(default)] #[schema(nullable)] pub magic_token: Option, #[serde(default)] #[schema(nullable)] pub preferred_tenant_id: Option, } #[derive(Debug, Deserialize, ToSchema)] pub struct ApiTokenExchangeRequest { pub api_token: String, } #[derive(Deserialize, Serialize, ToSchema)] pub struct LoginResponse { pub access_token: String, pub token_type: String, pub expires_in: i64, pub tenant: TenantSnippet, } #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSnippet { pub id: Uuid, pub name: String, } #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantSelectionResponse { pub access_token: String, pub tenants: Vec, } #[derive(Serialize, Deserialize, ToSchema)] pub struct TenantListResponse { pub tenants: Vec, } #[derive(Deserialize, ToSchema)] pub struct TenantSelectionRequest { pub tenant_id: Uuid, } #[derive(Deserialize, ToSchema)] pub struct SignupStartRequest { pub username: String, } #[derive(Serialize, Deserialize, ToSchema)] pub struct SignupStartResponse { pub signup_token: String, pub challenge: RegistrationChallengeResponse, } #[derive(Deserialize, ToSchema)] pub struct SignupFinishRequest { pub signup_token: String, #[schema(value_type = Object)] pub credential: RegisterPublicKeyCredential, #[schema(nullable)] pub nickname: Option, } #[derive(Serialize, Deserialize, ToSchema)] #[serde(untagged)] pub enum LoginResponseVariants { Token(LoginResponse), Selection(TenantSelectionResponse), } #[derive(OpenApi)] #[openapi( paths( login, api_token_exchange, signup_start, signup_finish, refresh, logout, me, list_tenants, select_tenant, passkey_register_start, passkey_register_finish, passkey_login_start, passkey_login_finish, ), components(schemas( LoginRequest, ApiTokenExchangeRequest, SignupStartRequest, SignupStartResponse, SignupFinishRequest, LoginResponse, LoginResponseVariants, TenantSnippet, TenantSelectionResponse, TenantSelectionRequest, TenantListResponse, crate::auth::AuthenticatedUser, crate::auth::passkeys::RegistrationChallengeResponse, crate::auth::passkeys::AuthenticationChallengeResponse, crate::auth::passkeys::PasskeySummary, crate::auth::passkeys::PasskeyRegistrationFinishPayload, crate::auth::passkeys::PasskeyLoginStartPayload, crate::auth::passkeys::PasskeyLoginFinishPayload, crate::models::ApiTokenCapability, )) )] pub struct AuthApiDoc; #[utoipa::path( post, path = "/api/auth/login", request_body = LoginRequest, responses( (status = 200, description = "Login succeeded", body = LoginResponseVariants), (status = 401, description = "Invalid credentials") ), tag = "Auth" )] pub async fn login( State(state): State, Json(payload): Json, ) -> AppResult { let magic_token = payload .magic_token .as_ref() .map(|value| value.trim()) .filter(|value| !value.is_empty()); if magic_token.is_none() { if payload.password.is_some() { return Err(AppError::bad_request( "password authentication is no longer supported", )); } return Err(AppError::bad_request( "magic_token is required for passwordless login", )); } let token_value = magic_token.unwrap(); let mut conn = state.db_unscoped()?; let username_hint = payload.username.trim(); let preferred_tenant_id = payload.preferred_tenant_id; magic_token_login( &state, &mut conn, token_value, (!username_hint.is_empty()).then_some(username_hint), preferred_tenant_id, ) } #[utoipa::path( post, path = "/api/auth/exchange-api-token", request_body = ApiTokenExchangeRequest, responses((status = 200, description = "Access token issued", body = LoginResponse)), tag = "Auth" )] pub async fn api_token_exchange( State(state): State, Json(payload): Json, ) -> AppResult> { let secret = payload.api_token.trim(); if secret.is_empty() { return Err(AppError::bad_request("api_token must not be empty")); } let mut conn = state.db_unscoped()?; let token = find_active_token_by_secret(&mut conn, None, secret, ApiTokenCapability::Api)? .ok_or_else(AppError::unauthorized)?; let user: User = dsl::users.find(token.user_id).first(&mut conn)?; apply_user_guc(&mut conn, user.id)?; let membership = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user.id)) .filter(memberships_dsl::tenant_id.eq(token.tenant_id)) .select(memberships_dsl::tenant_id) .first::(&mut conn) .optional()?; clear_user_guc(&mut conn)?; if membership.is_none() { return Err(AppError::unauthorized()); } apply_tenant_guc(&mut conn, token.tenant_id)?; touch_api_token(&mut conn, token.id)?; let access_token = state .jwt .generate_token(user.id, token.tenant_id, &user.username) .map_err(AppError::from)?; let tenant_name: String = tenant_dsl::tenants .find(token.tenant_id) .select(tenant_dsl::name) .first(&mut conn) .map_err(AppError::from)?; let response = LoginResponse { access_token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_minutes * 60, tenant: TenantSnippet { id: token.tenant_id, name: tenant_name, }, }; Ok(Json(response)) } #[utoipa::path( post, path = "/api/auth/signup/start", request_body = SignupStartRequest, responses( (status = 200, description = "Signup challenge created", body = SignupStartResponse), (status = 400, description = "Invalid signup request"), (status = 409, description = "Username already exists") ), tag = "Auth" )] pub async fn signup_start( State(state): State, Json(payload): Json, ) -> AppResult> { let username = payload.username.trim(); if username.is_empty() { return Err(AppError::bad_request("username must not be empty")); } let mut conn = state.db_unscoped()?; let exists: bool = dsl::users .filter(dsl::username.eq(username)) .first::(&mut conn) .optional()? .is_some(); if exists { return Err(AppError::conflict("username already exists")); } let user_id = Uuid::new_v4(); let challenge = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))? .start_signup_registration(&mut conn, user_id, username)?; let signup_token = state .jwt .generate_signup_token(user_id, challenge.challenge_id, username.to_owned()) .map_err(AppError::from)?; Ok(Json(SignupStartResponse { signup_token, challenge, })) } #[utoipa::path( post, path = "/api/auth/signup/finish", request_body = SignupFinishRequest, responses( (status = 200, description = "Signup completed", body = LoginResponseVariants), (status = 400, description = "Invalid signup completion"), (status = 409, description = "Username already exists") ), tag = "Auth" )] pub async fn signup_finish( State(state): State, Json(payload): Json, ) -> AppResult { let claims = state .jwt .verify_signup_token(&payload.signup_token) .map_err(|_| AppError::unauthorized())?; let mut conn = state.db_unscoped()?; let exists: bool = dsl::users .filter(dsl::username.eq(&claims.username)) .first::(&mut conn) .optional()? .is_some(); if exists { return Err(AppError::conflict("username already exists")); } let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let prepared_passkey = service.consume_signup_challenge(&mut conn, claims.challenge_id, &payload.credential)?; let state_clone = state.clone(); let response = conn.transaction::(|conn| { insert_user(conn, claims.sub, &claims.username)?; let tenant = state_clone.tenants.create_tenant_with_conn( conn, &claims.username, None, None, TenantStatus::Creating, &[claims.sub], Some(claims.sub), )?; let passkey_insert = prepared_passkey.into_new_user_passkey(claims.sub, payload.nickname.clone()); diesel::insert_into(passkey_dsl::user_passkeys) .values(&passkey_insert) .execute(conn) .map_err(AppError::from)?; let user: User = dsl::users.find(claims.sub).first(conn)?; issue_session(&state_clone, conn, &user, tenant.id) })?; Ok(response) } #[utoipa::path( post, path = "/api/auth/refresh", responses( (status = 200, description = "Refreshed access token", body = LoginResponse), (status = 401, description = "Missing or invalid refresh token") ), tag = "Auth" )] pub async fn refresh( State(state): State, jar: Option>, ) -> AppResult { let cookies = jar.ok_or_else(AppError::unauthorized)?; let refresh_value = cookies .get(SESSION_COOKIE_NAME) .ok_or_else(AppError::unauthorized)?; let hashed = hash_session_token(refresh_value); let mut conn = state.db_unscoped()?; let now = Utc::now(); let now_naive = now.naive_utc(); apply_user_session_hash(&mut conn, &hashed)?; let token = match session_dsl::user_sessions .filter(session_dsl::token_hash.eq(&hashed)) .filter(session_dsl::revoked_at.is_null()) .filter(session_dsl::expires_at.gt(now_naive)) .first::(&mut conn) { Ok(token) => token, Err(diesel::result::Error::NotFound) => return Err(AppError::unauthorized()), Err(err) => return Err(AppError::from(err)), }; clear_user_session_hash(&mut conn)?; apply_tenant_guc(&mut conn, token.tenant_id)?; clear_user_guc(&mut conn)?; diesel::update(session_dsl::user_sessions.filter(session_dsl::id.eq(token.id))) .set(( session_dsl::revoked_at.eq(now_naive), session_dsl::updated_at.eq(now_naive), )) .execute(&mut conn)?; let user: User = dsl::users .find(token.user_id) .first(&mut conn) .map_err(AppError::from)?; issue_session(&state, &mut conn, &user, token.tenant_id) } fn insert_user(conn: &mut PgConnection, id: Uuid, username: &str) -> AppResult<()> { let new_user = NewUser { id, username: username.to_string(), }; diesel::insert_into(dsl::users) .values(&new_user) .execute(conn) .map(|_| ()) .map_err(AppError::from) } #[utoipa::path( post, path = "/api/auth/select-tenant", request_body = TenantSelectionRequest, responses((status = 200, description = "Tenant selected", body = LoginResponse)), tag = "Auth" )] pub async fn select_tenant( State(state): State, TypedHeader(Authorization(bearer)): TypedHeader>, Json(payload): Json, ) -> AppResult { let user_id = match state.jwt.verify_tenant_selector_token(bearer.token()) { Ok(claims) => claims.sub, Err(_) => state .jwt .verify_token(bearer.token()) .map(|claims| claims.sub) .map_err(|_| AppError::unauthorized())?, }; let mut conn = state.db_unscoped()?; apply_user_guc(&mut conn, user_id)?; let membership_exists = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user_id)) .filter(memberships_dsl::tenant_id.eq(payload.tenant_id)) .select(memberships_dsl::tenant_id) .first::(&mut conn) .optional()?; clear_user_guc(&mut conn)?; if membership_exists.is_none() { return Err(AppError::unauthorized()); } let user: User = dsl::users .find(user_id) .first(&mut conn) .map_err(AppError::from)?; issue_session(&state, &mut conn, &user, payload.tenant_id) } #[utoipa::path( post, path = "/api/auth/logout", responses((status = 204, description = "Session revoked")), tag = "Auth" )] pub async fn logout( State(state): State, user: AuthenticatedUser, jar: Option>, ) -> AppResult<(HeaderMap, StatusCode)> { let mut conn = state.db_for_tenant(user.tenant_id)?; let now = Utc::now().naive_utc(); let mut rows_affected = 0; if let Some(cookies) = jar { if let Some(value) = cookies.get(SESSION_COOKIE_NAME) { let hashed = hash_session_token(value); rows_affected = diesel::update( session_dsl::user_sessions .filter(session_dsl::token_hash.eq(hashed)) .filter(session_dsl::user_id.eq(user.user_id)) .filter(session_dsl::revoked_at.is_null()), ) .set(( session_dsl::revoked_at.eq(now), session_dsl::updated_at.eq(now), )) .execute(&mut conn) .unwrap_or(0); } } if rows_affected == 0 { let _ = diesel::update( session_dsl::user_sessions .filter(session_dsl::user_id.eq(user.user_id)) .filter(session_dsl::revoked_at.is_null()), ) .set(( session_dsl::revoked_at.eq(now), session_dsl::updated_at.eq(now), )) .execute(&mut conn); } let mut headers = HeaderMap::new(); headers.insert(SET_COOKIE, build_clear_session_cookie(&state)); Ok((headers, StatusCode::NO_CONTENT)) } #[utoipa::path( get, path = "/api/auth/me", responses((status = 200, description = "Authenticated principal", body = AuthenticatedUser)), tag = "Auth" )] pub async fn me(user: AuthenticatedUser) -> Json { Json(user) } #[utoipa::path( get, path = "/api/auth/tenants", responses((status = 200, description = "Available tenants", body = TenantListResponse)), tag = "Auth" )] pub async fn list_tenants( State(state): State, auth: Option>>, ) -> AppResult> { let bearer = auth.ok_or_else(AppError::unauthorized)?; let token = bearer.token(); let user_id = match state.jwt.verify_token(token) { Ok(claims) => claims.sub, Err(_) => { let claims = state .jwt .verify_tenant_selector_token(token) .map_err(|_| AppError::unauthorized())?; claims.sub } }; let mut conn = state.db_unscoped()?; apply_user_guc(&mut conn, user_id)?; let tenant_ids: Vec = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user_id)) .select(memberships_dsl::tenant_id) .load(&mut conn)?; clear_user_guc(&mut conn)?; drop(conn); let mut tenants = Vec::with_capacity(tenant_ids.len()); for tenant_id in tenant_ids { let mut tenant_conn = state.db_for_tenant(tenant_id)?; let name: String = tenant_dsl::tenants .find(tenant_id) .select(tenant_dsl::name) .first(&mut tenant_conn) .map_err(AppError::from)?; tenants.push(TenantSnippet { id: tenant_id, name, }); } Ok(Json(TenantListResponse { tenants })) } #[utoipa::path( post, path = "/api/auth/passkeys/register/start", responses((status = 200, description = "Passkey registration challenge", body = RegistrationChallengeResponse)), tag = "Auth" )] pub async fn passkey_register_start( State(state): State, user: AuthenticatedUser, ) -> AppResult> { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let mut conn = state.db_unscoped()?; let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; let challenge = service.start_registration(&mut conn, ¤t_user)?; Ok(Json(challenge)) } #[utoipa::path( post, path = "/api/auth/passkeys/register/finish", request_body = PasskeyRegistrationFinishPayload, responses((status = 200, description = "Passkey registered", body = PasskeySummary)), tag = "Auth" )] pub async fn passkey_register_finish( State(state): State, user: AuthenticatedUser, Json(payload): Json, ) -> AppResult> { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let mut conn = state.db_unscoped()?; let current_user: User = dsl::users.find(user.user_id).first(&mut conn)?; let PasskeyRegistrationFinishPayload { challenge_id, credential, nickname, } = payload; let passkey = service.finish_registration( &mut conn, ¤t_user, challenge_id, credential, nickname, )?; Ok(Json(PasskeySummary::from(passkey))) } #[utoipa::path( post, path = "/api/auth/passkeys/login/start", request_body = PasskeyLoginStartPayload, responses((status = 200, description = "Passkey authentication challenge", body = AuthenticationChallengeResponse)), tag = "Auth" )] pub async fn passkey_login_start( State(state): State, Json(payload): Json, ) -> AppResult> { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let username = payload.username.trim(); if username.is_empty() { return Err(AppError::bad_request("username must not be empty")); } let mut conn = state.db_unscoped()?; let user: User = dsl::users .filter(dsl::username.eq(username)) .first(&mut conn)?; let challenge = service.start_authentication(&mut conn, &user)?; Ok(Json(challenge)) } #[utoipa::path( post, path = "/api/auth/passkeys/login/finish", request_body = PasskeyLoginFinishPayload, responses( (status = 200, description = "Passkey login successful", body = LoginResponseVariants), (status = 401, description = "Authentication failed") ), tag = "Auth" )] pub async fn passkey_login_finish( State(state): State, Json(payload): Json, ) -> AppResult { let service = state .passkeys .as_ref() .ok_or_else(|| AppError::bad_request("passkey support is disabled"))?; let mut conn = state.db_unscoped()?; let (user, _passkey, auth_result) = service.finish_authentication(&mut conn, payload.challenge_id, payload.credential)?; if !auth_result.user_verified() { return Err(AppError::unauthorized()); } complete_login(&state, &mut conn, &user, None) } fn complete_login( state: &AppState, conn: &mut PgConnection, user: &User, preferred_tenant_id: Option, ) -> AppResult { apply_user_guc(conn, user.id)?; let tenant_ids: Vec = memberships_dsl::user_memberships .filter(memberships_dsl::user_id.eq(user.id)) .select(memberships_dsl::tenant_id) .load(conn)?; clear_user_guc(conn)?; tracing::debug!(user_id = %user.id, tenants = tenant_ids.len(), "passkey login memberships"); if tenant_ids.is_empty() { return Err(AppError::unauthorized()); } if let Some(preferred_id) = preferred_tenant_id { if tenant_ids.iter().any(|id| *id == preferred_id) { return issue_session(state, conn, user, preferred_id); } } if tenant_ids.len() == 1 { return issue_session(state, conn, user, tenant_ids[0]); } let selection_token = state .jwt .generate_tenant_selector_token(user.id) .map_err(AppError::from)?; let mut tenants = Vec::with_capacity(tenant_ids.len()); for tenant_id in tenant_ids { let mut tenant_conn = state.db_for_tenant(tenant_id)?; let name: String = tenant_dsl::tenants .find(tenant_id) .select(tenant_dsl::name) .first(&mut tenant_conn) .map_err(AppError::from)?; tenants.push(TenantSnippet { id: tenant_id, name, }); } Ok(Json(TenantSelectionResponse { access_token: selection_token, tenants, }) .into_response()) } fn magic_token_login( state: &AppState, conn: &mut PgConnection, token_value: &str, username_hint: Option<&str>, preferred_tenant_id: Option, ) -> AppResult { if token_value.is_empty() { return Err(AppError::bad_request("magic_token must not be empty")); } let token_hash = hash_magic_token(token_value); let now = Utc::now(); let now_naive = now.naive_utc(); conn.transaction::(|conn| { let magic = magic_dsl::magic_tokens .filter(magic_dsl::token_hash.eq(&token_hash)) .filter(magic_dsl::expires_at.gt(now_naive)) .first::(conn) .map_err(|err| match err { diesel::result::Error::NotFound => AppError::unauthorized(), _ => AppError::from(err), })?; if let Some(limit) = magic.max_uses { if magic.used_count >= limit { return Err(AppError::unauthorized()); } } match magic.kind { MagicTokenKind::EmailLogin | MagicTokenKind::DemoLogin => {} } let user: User = dsl::users .find(magic.user_id) .first(conn) .map_err(AppError::from)?; if let Some(expected) = username_hint { if expected != user.username { return Err(AppError::unauthorized()); } } diesel::update(magic_dsl::magic_tokens.filter(magic_dsl::id.eq(magic.id))) .set(( magic_dsl::used_count.eq(magic.used_count + 1), magic_dsl::last_used_at.eq(Some(now_naive)), )) .execute(conn)?; complete_login(state, conn, &user, preferred_tenant_id) }) } fn issue_session( state: &AppState, conn: &mut PgConnection, user: &User, tenant_id: Uuid, ) -> AppResult { apply_tenant_guc(conn, tenant_id)?; clear_user_guc(conn)?; clear_user_session_hash(conn)?; let now = Utc::now(); let access_token = state .jwt .generate_token(user.id, tenant_id, &user.username) .map_err(AppError::from)?; let tenant_name: String = tenant_dsl::tenants .find(tenant_id) .select(tenant_dsl::name) .first(conn) .map_err(AppError::from)?; let session_value = generate_session_token(); let session_hash = hash_session_token(&session_value); let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days); let new_session = NewUserSession { id: Uuid::new_v4(), user_id: user.id, token_hash: session_hash, issued_at: now.naive_utc(), expires_at: refresh_expires_at.naive_utc(), tenant_id, }; diesel::insert_into(user_sessions::table) .values(&new_session) .execute(conn)?; let mut response = Json(LoginResponse { access_token, token_type: "Bearer".to_string(), expires_in: state.config.jwt_expiry_minutes * 60, tenant: TenantSnippet { id: tenant_id, name: tenant_name, }, }) .into_response(); response.headers_mut().insert( SET_COOKIE, build_session_cookie(state, &session_value, refresh_expires_at), ); Ok(response) } fn hash_session_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); hex::encode(hasher.finalize()) } fn hash_magic_token(token: &str) -> String { hash_session_token(token) } fn generate_session_token() -> String { let mut bytes = [0u8; 32]; OsRng.fill_bytes(&mut bytes); hex::encode(bytes) } fn build_session_cookie( state: &AppState, token: &str, expires_at: chrono::DateTime, ) -> HeaderValue { let max_age = ChronoDuration::days(state.config.refresh_token_expiry_days).num_seconds(); let mut parts = vec![format!("{}={}", SESSION_COOKIE_NAME, token)]; parts.push("Path=/".into()); parts.push("HttpOnly".into()); parts.push("SameSite=Strict".into()); parts.push(format!("Max-Age={}", max_age)); parts.push(format!("Expires={}", expires_at.to_rfc2822())); if state.config.refresh_cookie_secure { parts.push("Secure".into()); } if let Some(domain) = &state.config.refresh_cookie_domain { parts.push(format!("Domain={}", domain)); } HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") } fn build_clear_session_cookie(state: &AppState) -> HeaderValue { let mut parts = vec![format!("{}=", SESSION_COOKIE_NAME)]; parts.push("Path=/".into()); parts.push("HttpOnly".into()); parts.push("SameSite=Strict".into()); parts.push("Max-Age=0".into()); parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT".into()); if state.config.refresh_cookie_secure { parts.push("Secure".into()); } if let Some(domain) = &state.config.refresh_cookie_domain { parts.push(format!("Domain={}", domain)); } HeaderValue::from_str(&parts.join("; ")).expect("valid session cookie") }