Skip to main content

max / makenotwork

7.0 KB · 167 lines History Blame Raw
1 //! SyncKit authentication: JWT issuance and app validation.
2
3 use axum::{Json, extract::State, response::IntoResponse};
4
5 use sqlx::PgPool;
6
7 use crate::{
8 auth::verify_password_async,
9 config::Config,
10 db,
11 error::{AppError, Result},
12 synckit_auth, validation,
13 };
14
15 /// Pre-computed dummy Argon2 hash used to equalize timing when a user is not found,
16 /// preventing email enumeration via response time differences.
17 static DUMMY_HASH: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
18 crate::auth::hash_password("anti-timing-dummy").expect("dummy hash")
19 });
20
21 use super::{SyncAuthRequest, SyncAuthResponse, ValidateAppQuery, ValidateAppResponse};
22
23 /// Authenticate a user and return a JWT for subsequent sync API calls.
24 ///
25 /// Verifies the app API key, then validates user email/password credentials.
26 /// Returns a short-lived JWT containing the user ID and app ID, which the
27 /// client SDK includes as a Bearer token on all other sync endpoints.
28 #[utoipa::path(
29 post,
30 path = "/api/v1/sync/auth",
31 tag = "SyncKit",
32 request_body = SyncAuthRequest,
33 responses(
34 (status = 200, description = "JWT token for sync API access", body = SyncAuthResponse),
35 (status = 401, description = "Invalid credentials or API key"),
36 ),
37 )]
38 #[tracing::instrument(skip_all, name = "synckit::sync_auth")]
39 pub(super) async fn sync_auth(
40 State(db): State<PgPool>,
41 State(config): State<Config>,
42 headers: axum::http::HeaderMap,
43 Json(req): Json<SyncAuthRequest>,
44 ) -> Result<impl IntoResponse> {
45 let secret = config
46 .synckit_jwt_secret
47 .as_deref()
48 .ok_or_else(|| AppError::ServiceUnavailable("SyncKit is not configured".to_string()))?;
49
50 validation::validate_synckit_key(&req.key)?;
51
52 // Verify app exists and is active
53 let app = db::synckit::get_sync_app_by_api_key(&db, &req.api_key)
54 .await?
55 .ok_or(AppError::Unauthorized)?;
56
57 // Reject oversized passwords early (before user lookup, no timing leak
58 // since this branch doesn't touch the DB or run Argon2). Same char-count
59 // metric as signup so a valid multibyte password isn't rejected here.
60 if crate::validation::password_too_long(&req.password) {
61 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
62 return Err(AppError::Unauthorized);
63 }
64
65 // Verify user credentials, always run Argon2 before checking account
66 // status to prevent timing oracles that leak suspension/lockout/2FA state.
67 let Ok(email) = db::Email::new(&req.email) else {
68 // Equalize timing on malformed input too, same enumeration concern.
69 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
70 return Err(AppError::Unauthorized);
71 };
72 let Some(user) = db::users::get_user_by_email(&db, &email).await? else {
73 // Equalize timing to prevent email enumeration
74 let _ = verify_password_async("dummy".to_string(), DUMMY_HASH.clone()).await;
75 return Err(AppError::Unauthorized);
76 };
77
78 // Account-status checks run after verify_password to avoid timing oracles.
79 // A correct password that still can't complete login here, suspended,
80 // deactivated, locked, or 2FA-gated (2FA users must use the OAuth flow), is
81 // accounted and answered EXACTLY like a wrong password: increment the
82 // failed-login counter and return 401. Otherwise the counter is an oracle
83 // that confirms the password of a 2FA/locked/suspended account (wrong guesses
84 // increment, a correct-but-blocked guess would not). Returning 401 (not 400)
85 // also avoids leaking 2FA status. (ultra-fuzz Run 3 SECURITY #4.) The folding
86 // and counter ordering live in the shared relying-party gate so this flow and
87 // OAuth can't drift apart (Run 11 Sec M1).
88 match crate::auth::relying_party_login_gate(&db, &user, &req.password).await? {
89 crate::auth::LoginGate::Deny { .. } => {
90 // Audit the failed sync auth (best-effort). Recorded only for a real
91 // account; the email-enumeration equalization paths above (unknown
92 // user, malformed input) deliberately don't log, both to avoid noise
93 // and because they carry no user_id.
94 let ip = crate::helpers::extract_client_ip(&headers);
95 if let Err(e) = db::synckit::record_security_event(
96 &db,
97 app.id,
98 Some(user.id),
99 db::synckit::sync_security_event::AUTH_FAILURE,
100 None,
101 ip.as_deref(),
102 )
103 .await
104 {
105 tracing::error!(error = ?e, "failed to record auth_failure security event");
106 }
107 return Err(AppError::Unauthorized);
108 }
109 crate::auth::LoginGate::Allow => {}
110 }
111
112 // Register the session's billing key under the per-key cap AT MINT TIME, so a
113 // token can never be issued for a key beyond the developer's paid allowance.
114 // Previously the key was claimed lazily on first write and the JWT `key`
115 // claim was accepted on `!is_empty()` alone, letting a token minter spread
116 // storage across unlimited synthetic keys and evade the per-key fairness cap
117 // (ultra-fuzz Run 4 M-Sec2). Only `per_key` developer apps are capped;
118 // internal and `bulk`/`app_wide` apps are uncapped and skip the claim.
119 // `claim_key` is idempotent for an already-claimed key and enforces the cap
120 // atomically under the usage-row lock.
121 let billing = db::synckit_billing::get_app_with_billing(&db, app.id)
122 .await?
123 .ok_or(AppError::Unauthorized)?;
124 if !billing.is_internal && billing.enforcement_mode == "per_key" {
125 let key_cap = billing.key_cap.unwrap_or(0);
126 let claim = db::synckit_billing::claim_key(&db, app.id, &req.key, Some(key_cap)).await?;
127 if claim.cap_reached {
128 return Err(AppError::PaymentRequired(format!(
129 "key limit reached ({} of {key_cap} keys claimed); release an unused key or raise the cap",
130 claim.total_claimed
131 )));
132 }
133 }
134
135 let token = synckit_auth::create_sync_token(secret, user.id, app.id, &req.key)?;
136
137 Ok(Json(SyncAuthResponse {
138 token,
139 user_id: user.id,
140 app_id: app.id,
141 }))
142 }
143
144 /// Validate an API key without authentication. Returns the app name on success.
145 ///
146 /// API key is sent in the JSON body (not query string) to avoid log exposure.
147 #[utoipa::path(
148 post,
149 path = "/api/v1/sync/validate-app",
150 tag = "SyncKit",
151 request_body = ValidateAppQuery,
152 responses(
153 (status = 200, description = "App name", body = ValidateAppResponse),
154 (status = 401, description = "Invalid API key"),
155 ),
156 )]
157 #[tracing::instrument(skip_all, name = "synckit::validate_app")]
158 pub(super) async fn validate_app(
159 State(db): State<PgPool>,
160 Json(params): Json<ValidateAppQuery>,
161 ) -> Result<impl IntoResponse> {
162 let app = db::synckit::get_sync_app_by_api_key(&db, &params.api_key)
163 .await?
164 .ok_or(AppError::Unauthorized)?;
165 Ok(Json(ValidateAppResponse { app_name: app.name }))
166 }
167