//! SyncKit authentication: JWT issuance and app validation. use axum::{Json, extract::State, response::IntoResponse}; use sqlx::PgPool; use crate::{ auth::verify_password_async, config::Config, db, error::{AppError, Result}, synckit_auth, validation, }; /// 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(db): State, State(config): State, headers: axum::http::HeaderMap, Json(req): Json, ) -> Result { let secret = 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(&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). Same char-count // metric as signup so a valid multibyte password isn't rejected here. if crate::validation::password_too_long(&req.password) { let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; 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 Ok(email) = db::Email::new(&req.email) else { // Equalize timing on malformed input too, same enumeration concern. let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; return Err(AppError::Unauthorized); }; let Some(user) = db::users::get_user_by_email(&db, &email).await? else { // Equalize timing to prevent email enumeration let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await; return Err(AppError::Unauthorized); }; // Account-status checks run after verify_password to avoid timing oracles. // A correct password that still can't complete login here, suspended, // deactivated, locked, or 2FA-gated (2FA users must use the OAuth flow), is // accounted and answered EXACTLY like a wrong password: increment the // failed-login counter and return 401. Otherwise the counter is an oracle // that confirms the password of a 2FA/locked/suspended account (wrong guesses // increment, a correct-but-blocked guess would not). Returning 401 (not 400) // also avoids leaking 2FA status. (ultra-fuzz Run 3 SECURITY #4.) The folding // and counter ordering live in the shared relying-party gate so this flow and // OAuth can't drift apart (Run 11 Sec M1). match crate::auth::relying_party_login_gate(&db, &user, &req.password).await? { crate::auth::LoginGate::Deny { .. } => { // Audit the failed sync auth (best-effort). Recorded only for a real // account; the email-enumeration equalization paths above (unknown // user, malformed input) deliberately don't log, both to avoid noise // and because they carry no user_id. let ip = crate::helpers::extract_client_ip(&headers); if let Err(e) = db::synckit::record_security_event( &db, app.id, Some(user.id), db::synckit::sync_security_event::AUTH_FAILURE, None, ip.as_deref(), ) .await { tracing::error!(error = ?e, "failed to record auth_failure security event"); } return Err(AppError::Unauthorized); } crate::auth::LoginGate::Allow => {} } // Register the session's billing key under the per-key cap AT MINT TIME, so a // token can never be issued for a key beyond the developer's paid allowance. // Previously the key was claimed lazily on first write and the JWT `key` // claim was accepted on `!is_empty()` alone, letting a token minter spread // storage across unlimited synthetic keys and evade the per-key fairness cap // (ultra-fuzz Run 4 M-Sec2). Only `per_key` developer apps are capped; // internal and `bulk`/`app_wide` apps are uncapped and skip the claim. // `claim_key` is idempotent for an already-claimed key and enforces the cap // atomically under the usage-row lock. let billing = db::synckit_billing::get_app_with_billing(&db, app.id) .await? .ok_or(AppError::Unauthorized)?; if !billing.is_internal && billing.enforcement_mode == "per_key" { let key_cap = billing.key_cap.unwrap_or(0); let claim = db::synckit_billing::claim_key(&db, app.id, &req.key, Some(key_cap)).await?; if claim.cap_reached { return Err(AppError::PaymentRequired(format!( "key limit reached ({} of {key_cap} keys claimed); release an unused key or raise the cap", claim.total_claimed ))); } } 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(db): State, Json(params): Json, ) -> Result { let app = db::synckit::get_sync_app_by_api_key(&db, ¶ms.api_key) .await? .ok_or(AppError::Unauthorized)?; Ok(Json(ValidateAppResponse { app_name: app.name })) }