//! SyncKit authentication: JWT issuance and app validation. use axum::{ extract::State, response::IntoResponse, Json, }; use crate::{ auth::verify_password, db, error::{AppError, Result}, synckit_auth, validation, AppState, }; /// Pre-computed dummy Argon2 hash used to equalize timing when a user is not found, /// preventing email enumeration via response time differences. static DUMMY_HASH: std::sync::LazyLock = std::sync::LazyLock::new(|| { crate::auth::hash_password("anti-timing-dummy").expect("dummy hash") }); use super::{SyncAuthRequest, SyncAuthResponse, ValidateAppQuery, ValidateAppResponse}; /// Authenticate a user and return a JWT for subsequent sync API calls. /// /// Verifies the app API key, then validates user email/password credentials. /// Returns a short-lived JWT containing the user ID and app ID, which the /// client SDK includes as a Bearer token on all other sync endpoints. #[utoipa::path( post, path = "/api/v1/sync/auth", tag = "SyncKit", request_body = SyncAuthRequest, responses( (status = 200, description = "JWT token for sync API access", body = SyncAuthResponse), (status = 401, description = "Invalid credentials or API key"), ), )] #[tracing::instrument(skip_all, name = "synckit::sync_auth")] pub(super) async fn sync_auth( State(state): State, Json(req): Json, ) -> Result { let secret = state .config .synckit_jwt_secret .as_deref() .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?; validation::validate_synckit_key(&req.key)?; // Verify app exists and is active let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key) .await? .ok_or(AppError::Unauthorized)?; // Reject oversized passwords early (before user lookup — no timing leak // since this branch doesn't touch the DB or run Argon2). if req.password.len() > 128 { let _ = verify_password("dummy", &DUMMY_HASH); return Err(AppError::Unauthorized); } // Verify user credentials — always run Argon2 before checking account // status to prevent timing oracles that leak suspension/lockout/2FA state. let email = match db::Email::new(&req.email) { Ok(e) => e, Err(_) => { // Equalize timing on malformed input too — same enumeration concern. let _ = verify_password("dummy", &DUMMY_HASH); return Err(AppError::Unauthorized); } }; let user = match db::users::get_user_by_email(&state.db, &email).await? { Some(u) => u, None => { // Equalize timing to prevent email enumeration let _ = verify_password("dummy", &DUMMY_HASH); return Err(AppError::Unauthorized); } }; let valid = crate::auth::verify_password(&req.password, &user.password_hash)?; if !valid { // Track failed attempts for lockout (atomic increment + lock) db::auth::increment_failed_login( &state.db, user.id, crate::constants::MAX_LOGIN_ATTEMPTS, crate::constants::LOCKOUT_MINUTES, ).await?; return Err(AppError::Unauthorized); } // Password is correct — now check account status. // These checks happen after verify_password to avoid timing oracles. if user.is_suspended() { return Err(AppError::Unauthorized); } if let Some(locked_until) = user.locked_until && locked_until > chrono::Utc::now() { return Err(AppError::Unauthorized); } // Reject accounts with 2FA enabled — they must use the OAuth flow. // Returns 401 (not 400) to avoid leaking 2FA status to attackers who // guessed the password. if user.totp_enabled { return Err(AppError::Unauthorized); } // Successful auth — reset failed login counter db::auth::reset_failed_login(&state.db, user.id).await?; let token = synckit_auth::create_sync_token(secret, user.id, app.id, &req.key)?; Ok(Json(SyncAuthResponse { token, user_id: user.id, app_id: app.id, })) } /// Validate an API key without authentication. Returns the app name on success. /// /// API key is sent in the JSON body (not query string) to avoid log exposure. #[utoipa::path( post, path = "/api/v1/sync/validate-app", tag = "SyncKit", request_body = ValidateAppQuery, responses( (status = 200, description = "App name", body = ValidateAppResponse), (status = 401, description = "Invalid API key"), ), )] #[tracing::instrument(skip_all, name = "synckit::validate_app")] pub(super) async fn validate_app( State(state): State, Json(params): Json, ) -> Result { let app = db::synckit::get_sync_app_by_api_key(&state.db, ¶ms.api_key) .await? .ok_or(AppError::Unauthorized)?; Ok(Json(ValidateAppResponse { app_name: app.name, })) }