Skip to main content

max / makenotwork

5.0 KB · 153 lines History Blame Raw
1 //! SyncKit authentication: JWT issuance and app validation.
2
3 use axum::{
4 extract::State,
5 response::IntoResponse,
6 Json,
7 };
8
9 use crate::{
10 auth::verify_password,
11 db,
12 error::{AppError, Result},
13 synckit_auth,
14 validation,
15 AppState,
16 };
17
18 /// Pre-computed dummy Argon2 hash used to equalize timing when a user is not found,
19 /// preventing email enumeration via response time differences.
20 static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
21 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
22 });
23
24 use super::{SyncAuthRequest, SyncAuthResponse, ValidateAppQuery, ValidateAppResponse};
25
26 /// Authenticate a user and return a JWT for subsequent sync API calls.
27 ///
28 /// Verifies the app API key, then validates user email/password credentials.
29 /// Returns a short-lived JWT containing the user ID and app ID, which the
30 /// client SDK includes as a Bearer token on all other sync endpoints.
31 #[utoipa::path(
32 post,
33 path = "/api/v1/sync/auth",
34 tag = "SyncKit",
35 request_body = SyncAuthRequest,
36 responses(
37 (status = 200, description = "JWT token for sync API access", body = SyncAuthResponse),
38 (status = 401, description = "Invalid credentials or API key"),
39 ),
40 )]
41 #[tracing::instrument(skip_all, name = "synckit::sync_auth")]
42 pub(super) async fn sync_auth(
43 State(state): State<AppState>,
44 Json(req): Json<SyncAuthRequest>,
45 ) -> Result<impl IntoResponse> {
46 let secret = state
47 .config
48 .synckit_jwt_secret
49 .as_deref()
50 .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
51
52 validation::validate_synckit_key(&req.key)?;
53
54 // Verify app exists and is active
55 let app = db::synckit::get_sync_app_by_api_key(&state.db, &req.api_key)
56 .await?
57 .ok_or(AppError::Unauthorized)?;
58
59 // Reject oversized passwords early (before user lookup — no timing leak
60 // since this branch doesn't touch the DB or run Argon2).
61 if req.password.len() > 128 {
62 let _ = verify_password("dummy", &DUMMY_HASH);
63 return Err(AppError::Unauthorized);
64 }
65
66 // Verify user credentials — always run Argon2 before checking account
67 // status to prevent timing oracles that leak suspension/lockout/2FA state.
68 let email = match db::Email::new(&req.email) {
69 Ok(e) => e,
70 Err(_) => {
71 // Equalize timing on malformed input too — same enumeration concern.
72 let _ = verify_password("dummy", &DUMMY_HASH);
73 return Err(AppError::Unauthorized);
74 }
75 };
76 let user = match db::users::get_user_by_email(&state.db, &email).await? {
77 Some(u) => u,
78 None => {
79 // Equalize timing to prevent email enumeration
80 let _ = verify_password("dummy", &DUMMY_HASH);
81 return Err(AppError::Unauthorized);
82 }
83 };
84
85 let valid = crate::auth::verify_password(&req.password, &user.password_hash)?;
86 if !valid {
87 // Track failed attempts for lockout (atomic increment + lock)
88 db::auth::increment_failed_login(
89 &state.db, user.id,
90 crate::constants::MAX_LOGIN_ATTEMPTS,
91 crate::constants::LOCKOUT_MINUTES,
92 ).await?;
93 return Err(AppError::Unauthorized);
94 }
95
96 // Password is correct — now check account status.
97 // These checks happen after verify_password to avoid timing oracles.
98
99 if user.is_suspended() {
100 return Err(AppError::Unauthorized);
101 }
102
103 if let Some(locked_until) = user.locked_until
104 && locked_until > chrono::Utc::now()
105 {
106 return Err(AppError::Unauthorized);
107 }
108
109 // Reject accounts with 2FA enabled — they must use the OAuth flow.
110 // Returns 401 (not 400) to avoid leaking 2FA status to attackers who
111 // guessed the password.
112 if user.totp_enabled {
113 return Err(AppError::Unauthorized);
114 }
115
116 // Successful auth — reset failed login counter
117 db::auth::reset_failed_login(&state.db, user.id).await?;
118
119 let token = synckit_auth::create_sync_token(secret, user.id, app.id, &req.key)?;
120
121 Ok(Json(SyncAuthResponse {
122 token,
123 user_id: user.id,
124 app_id: app.id,
125 }))
126 }
127
128 /// Validate an API key without authentication. Returns the app name on success.
129 ///
130 /// API key is sent in the JSON body (not query string) to avoid log exposure.
131 #[utoipa::path(
132 post,
133 path = "/api/v1/sync/validate-app",
134 tag = "SyncKit",
135 request_body = ValidateAppQuery,
136 responses(
137 (status = 200, description = "App name", body = ValidateAppResponse),
138 (status = 401, description = "Invalid API key"),
139 ),
140 )]
141 #[tracing::instrument(skip_all, name = "synckit::validate_app")]
142 pub(super) async fn validate_app(
143 State(state): State<AppState>,
144 Json(params): Json<ValidateAppQuery>,
145 ) -> Result<impl IntoResponse> {
146 let app = db::synckit::get_sync_app_by_api_key(&state.db, &params.api_key)
147 .await?
148 .ok_or(AppError::Unauthorized)?;
149 Ok(Json(ValidateAppResponse {
150 app_name: app.name,
151 }))
152 }
153