Skip to main content

max / synckit

15.7 KB · 455 lines History Blame Raw
1 //! Authentication and session lifecycle.
2 //!
3 //! Password and OAuth login, session restore from a stored token, and the
4 //! local JWT-expiry check the API methods use to fail fast before a request.
5
6 use bytes::Bytes;
7 use std::sync::Arc;
8 use tracing::instrument;
9 #[cfg(test)]
10 use uuid::Uuid;
11
12 use crate::{
13 error::Result,
14 ids::{AppId, UserId},
15 types::{AuthRequest, AuthResponse, OAuthTokenResponse},
16 };
17
18 use super::helpers::{Idempotency, check_response, jwt_exp};
19 use super::{SecretToken, Session, SyncKitClient, TOKEN_EXPIRY_BUFFER_SECS};
20
21 impl SyncKitClient {
22 /// Authenticate with the MNW server. Returns (user_id, app_id).
23 ///
24 /// `key` is the developer-defined SDK key this session's storage counts
25 /// against. The dev's backend chooses it, typically one key per
26 /// workspace/org/end-user.
27 ///
28 /// Stores the session internally. If called concurrently from multiple
29 /// threads, the last write wins, earlier sessions are silently overwritten.
30 ///
31 /// # Errors
32 ///
33 /// Returns `Server { status: 401, .. }` for wrong credentials.
34 #[instrument(skip(self, email, password, key))]
35 pub async fn authenticate(
36 &self,
37 email: &str,
38 password: &str,
39 key: &str,
40 ) -> Result<(UserId, AppId)> {
41 let body = Bytes::from(serde_json::to_vec(&AuthRequest {
42 email,
43 password,
44 api_key: &self.config.api_key,
45 key,
46 })?);
47
48 let resp = self
49 .retry_request(
50 Idempotency::IdempotentWrite {
51 on: "credentials; re-auth reissues an equivalent token",
52 },
53 || {
54 let req = self
55 .http
56 .post(&self.endpoints.auth)
57 .header("content-type", "application/json")
58 .body(body.clone());
59 async move { check_response(req.send().await?).await }
60 },
61 )
62 .await?;
63 let auth: AuthResponse = crate::client::helpers::read_json_capped(
64 resp,
65 crate::client::helpers::MAX_CONTROL_BODY_BYTES,
66 )
67 .await?;
68
69 let user_id = auth.user_id;
70 let app_id = auth.app_id;
71 let token_exp = jwt_exp(&auth.token);
72
73 *self.session.write() = Some(Session {
74 token: Arc::new(SecretToken::new(auth.token)),
75 token_exp,
76 user_id,
77 app_id,
78 });
79
80 tracing::info!("Authenticated as user {user_id} for app {app_id}");
81 Ok((user_id, app_id))
82 }
83
84 /// Restore a session from previously stored credentials (e.g. OS keychain).
85 ///
86 /// Sets the internal session state without making any HTTP calls.
87 /// Used on app startup to restore from stored credentials without re-authenticating.
88 pub fn restore_session(&self, token: &str, user_id: UserId, app_id: AppId) {
89 let token_exp = jwt_exp(token);
90 *self.session.write() = Some(Session {
91 token: Arc::new(SecretToken::new(token.to_string())),
92 token_exp,
93 user_id,
94 app_id,
95 });
96 tracing::info!("Session restored for user {user_id}, app {app_id}");
97 }
98
99 /// Clear the in-memory session and master key.
100 ///
101 /// After calling this, the client will need to re-authenticate and set up
102 /// encryption again. Does not affect OS keychain storage, call
103 /// `keystore::delete_master_key` separately if needed.
104 pub fn clear_session(&self) {
105 *self.session.write() = None;
106 *self.master_key.write() = None;
107 tracing::info!("Session and master key cleared");
108 }
109
110 /// Check whether the current session token has expired (or will expire
111 /// within a 30-second buffer). Returns `true` if there is no session or
112 /// if the token's `exp` claim is in the past. Returns `false` if the
113 /// token cannot be decoded (assumes not expired, the server will reject
114 /// it with a 401 if it actually is).
115 pub fn is_token_expired(&self) -> bool {
116 let guard = self.session.read();
117 let Some(session) = guard.as_ref() else {
118 return true;
119 };
120 match session.token_exp {
121 Some(exp) => {
122 let now = chrono::Utc::now().timestamp();
123 now >= exp - TOKEN_EXPIRY_BUFFER_SECS
124 }
125 None => false,
126 }
127 }
128
129 // ── OAuth ──
130
131 /// Build the authorization URL for the OAuth2 PKCE flow.
132 ///
133 /// The caller is responsible for generating the PKCE verifier/challenge,
134 /// starting the localhost callback server, and opening the browser.
135 ///
136 /// Sends `scope=sync` explicitly: this is the SyncKit pairing flow and the
137 /// full sync-API token is what it needs. The server treats an omitted scope
138 /// as least-privilege userinfo (not sync), so the scope must be sent for the
139 /// token exchange to yield a sync-capable token.
140 pub fn build_authorize_url(
141 &self,
142 redirect_port: u16,
143 state: &str,
144 code_challenge: &str,
145 ) -> String {
146 format!(
147 "{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&state={}&code_challenge={}&code_challenge_method=S256&scope=sync",
148 self.config.server_url,
149 urlencoding::encode(&self.config.api_key),
150 urlencoding::encode(&format!("http://127.0.0.1:{redirect_port}/")),
151 urlencoding::encode(state),
152 urlencoding::encode(code_challenge),
153 )
154 }
155
156 /// Exchange an OAuth2 authorization code for a SyncKit JWT.
157 ///
158 /// `key` is the developer-defined SDK key for billing attribution, see
159 /// [`authenticate`](Self::authenticate).
160 ///
161 /// Call this after receiving the code from the localhost callback server.
162 /// On success, stores the session internally (same as `authenticate()`).
163 #[instrument(skip(self, code, code_verifier, key))]
164 pub async fn authenticate_with_code(
165 &self,
166 code: &str,
167 code_verifier: &str,
168 redirect_port: u16,
169 key: &str,
170 ) -> Result<(UserId, AppId)> {
171 let redirect_uri = format!("http://127.0.0.1:{redirect_port}/");
172
173 let form_params = [
174 ("grant_type", "authorization_code"),
175 ("code", code),
176 ("redirect_uri", &redirect_uri),
177 ("code_verifier", code_verifier),
178 ("client_id", &self.config.api_key),
179 ("key", key),
180 ];
181
182 // OAuth authorization codes are single-use. Retrying after a
183 // network error would send an already-consumed code, producing a
184 // permanent 400. Send exactly once and let the caller restart the
185 // OAuth flow on failure.
186 let resp = check_response(
187 self.http
188 .post(&self.endpoints.oauth_token)
189 .form(&form_params)
190 .send()
191 .await?,
192 )
193 .await?;
194 let token_resp: OAuthTokenResponse = crate::client::helpers::read_json_capped(
195 resp,
196 crate::client::helpers::MAX_CONTROL_BODY_BYTES,
197 )
198 .await?;
199
200 let user_id = token_resp.user_id;
201 let app_id = token_resp.app_id;
202 let token_exp = jwt_exp(&token_resp.access_token);
203
204 *self.session.write() = Some(Session {
205 token: Arc::new(SecretToken::new(token_resp.access_token)),
206 token_exp,
207 user_id,
208 app_id,
209 });
210
211 tracing::info!("Authenticated via OAuth as user {user_id} for app {app_id}");
212 Ok((user_id, app_id))
213 }
214 }
215
216 #[cfg(test)]
217 mod tests {
218 use super::*;
219 use base64::Engine;
220 use chrono::Utc;
221
222 use crate::error::SyncKitError;
223
224 fn test_config() -> super::super::SyncKitConfig {
225 super::super::SyncKitConfig {
226 server_url: "https://example.com".to_string(),
227 api_key: "test-api-key-123".to_string(),
228 }
229 }
230
231 fn test_ids() -> (crate::ids::AppId, crate::ids::UserId) {
232 (
233 crate::ids::AppId::new(
234 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(),
235 ),
236 crate::ids::UserId::new(
237 Uuid::parse_str("6ba7b810-9dad-11d1-80b4-00c04fd430c8").unwrap(),
238 ),
239 )
240 }
241
242 fn fake_jwt(exp: i64) -> String {
243 let header = base64::engine::general_purpose::URL_SAFE_NO_PAD
244 .encode(r#"{"alg":"HS256","typ":"JWT"}"#);
245 let payload_json = serde_json::json!({
246 "sub": "550e8400-e29b-41d4-a716-446655440000",
247 "app": "6ba7b810-9dad-11d1-80b4-00c04fd430c8",
248 "exp": exp,
249 "iat": exp - 3600,
250 });
251 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
252 .encode(payload_json.to_string().as_bytes());
253 let signature = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"fake-signature");
254 format!("{header}.{payload}.{signature}")
255 }
256
257 // ── restore_session ──
258
259 #[test]
260 fn restore_session_makes_client_authenticated() {
261 let client = SyncKitClient::new(test_config());
262 let (app_id, user_id) = test_ids();
263
264 client.restore_session("fake-token", user_id, app_id);
265
266 let info = client.session_info().expect("session should exist");
267 assert_eq!(info.token.as_str(), "fake-token");
268 assert_eq!(info.user_id, user_id);
269 assert_eq!(info.app_id, app_id);
270 }
271
272 #[test]
273 fn restore_session_overwrites_previous_session() {
274 let client = SyncKitClient::new(test_config());
275 let (app_id, user_id) = test_ids();
276
277 client.restore_session("first-token", user_id, app_id);
278 client.restore_session("second-token", user_id, app_id);
279
280 let info = client.session_info().unwrap();
281 assert_eq!(info.token.as_str(), "second-token");
282 }
283
284 // ── build_authorize_url ──
285
286 #[test]
287 fn build_authorize_url_includes_all_params() {
288 let client = SyncKitClient::new(test_config());
289 let url = client.build_authorize_url(8080, "random-state", "challenge123");
290
291 assert!(url.starts_with("https://example.com/oauth/authorize?"));
292 assert!(url.contains("response_type=code"));
293 assert!(url.contains("client_id=test-api-key-123"));
294 assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A8080%2F"));
295 assert!(url.contains("state=random-state"));
296 assert!(url.contains("code_challenge=challenge123"));
297 assert!(url.contains("code_challenge_method=S256"));
298 // The pairing flow must request the sync scope explicitly, the server
299 // treats an omitted scope as least-privilege userinfo, not sync.
300 assert!(url.contains("scope=sync"));
301 }
302
303 #[test]
304 fn build_authorize_url_encodes_special_chars() {
305 let config = super::super::SyncKitConfig {
306 server_url: "https://example.com".to_string(),
307 api_key: "key with spaces&special=chars".to_string(),
308 };
309 let client = SyncKitClient::new(config);
310 let url = client.build_authorize_url(9090, "state/with/slashes", "ch+all=enge");
311
312 assert!(url.contains("key%20with%20spaces%26special%3Dchars"));
313 assert!(url.contains("state%2Fwith%2Fslashes"));
314 assert!(url.contains("ch%2Ball%3Denge"));
315 }
316
317 #[test]
318 fn build_authorize_url_different_ports() {
319 let client = SyncKitClient::new(test_config());
320
321 let url_low = client.build_authorize_url(1234, "s", "c");
322 assert!(url_low.contains("127.0.0.1%3A1234"));
323
324 let url_high = client.build_authorize_url(65535, "s", "c");
325 assert!(url_high.contains("127.0.0.1%3A65535"));
326 }
327
328 // ── is_token_expired ──
329
330 #[test]
331 fn is_token_expired_true_without_session() {
332 let client = SyncKitClient::new(test_config());
333 assert!(client.is_token_expired());
334 }
335
336 #[test]
337 fn is_token_expired_true_with_expired_token() {
338 let client = SyncKitClient::new(test_config());
339 let (app_id, user_id) = test_ids();
340 let token = fake_jwt(Utc::now().timestamp() - 3600);
341 client.restore_session(&token, user_id, app_id);
342 assert!(client.is_token_expired());
343 }
344
345 #[test]
346 fn is_token_expired_false_with_fresh_token() {
347 let client = SyncKitClient::new(test_config());
348 let (app_id, user_id) = test_ids();
349 let token = fake_jwt(Utc::now().timestamp() + 3600);
350 client.restore_session(&token, user_id, app_id);
351 assert!(!client.is_token_expired());
352 }
353
354 // ── require_token with expiry ──
355
356 #[test]
357 fn require_token_returns_token_expired_for_expired_token() {
358 let client = SyncKitClient::new(test_config());
359 let (app_id, user_id) = test_ids();
360 let token = fake_jwt(Utc::now().timestamp() - 3600);
361 client.restore_session(&token, user_id, app_id);
362
363 let err = client.require_token().unwrap_err();
364 assert!(matches!(err, SyncKitError::TokenExpired));
365 }
366
367 #[test]
368 fn require_token_succeeds_with_fresh_token() {
369 let client = SyncKitClient::new(test_config());
370 let (app_id, user_id) = test_ids();
371 let token = fake_jwt(Utc::now().timestamp() + 3600);
372 client.restore_session(&token, user_id, app_id);
373
374 assert!(client.require_token().is_ok());
375 }
376
377 // ── clear_session ──
378
379 #[test]
380 fn clear_session_clears_master_key() {
381 let client = SyncKitClient::new(test_config());
382 let (app_id, user_id) = test_ids();
383 client.restore_session("token", user_id, app_id);
384 client.set_master_key_raw([42u8; 32]);
385
386 assert!(client.session_info().is_some());
387 assert!(client.has_master_key());
388
389 client.clear_session();
390
391 assert!(client.session_info().is_none());
392 assert!(!client.has_master_key());
393 }
394
395 // ── OAuth types ──
396
397 #[test]
398 fn oauth_token_response_deserialization() {
399 let json = r#"{
400 "access_token": "jwt-access-token",
401 "token_type": "Bearer",
402 "expires_in": 3600,
403 "user_id": "550e8400-e29b-41d4-a716-446655440000",
404 "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
405 }"#;
406
407 let resp: OAuthTokenResponse = serde_json::from_str(json).unwrap();
408 assert_eq!(resp.access_token, "jwt-access-token");
409 assert_eq!(resp.token_type, "Bearer");
410 assert_eq!(resp.expires_in, 3600);
411 }
412
413 // ── Auth types ──
414
415 #[test]
416 fn auth_request_serialization() {
417 let req = AuthRequest {
418 email: "user@example.com",
419 password: "secret123",
420 api_key: "ak_test",
421 key: "session-key-abc",
422 };
423
424 let json = serde_json::to_string(&req).unwrap();
425 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
426 assert_eq!(parsed["email"], "user@example.com");
427 assert_eq!(parsed["password"], "secret123");
428 assert_eq!(parsed["api_key"], "ak_test");
429 assert_eq!(parsed["key"], "session-key-abc");
430 }
431
432 #[test]
433 fn auth_response_deserialization() {
434 let json = r#"{
435 "token": "jwt.token.here",
436 "user_id": "550e8400-e29b-41d4-a716-446655440000",
437 "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
438 }"#;
439
440 let resp: AuthResponse = serde_json::from_str(json).unwrap();
441 assert_eq!(resp.token, "jwt.token.here");
442 assert_eq!(
443 resp.user_id.as_uuid(),
444 Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap()
445 );
446 }
447
448 #[test]
449 fn auth_response_missing_token_fails() {
450 let json = r#"{"user_id": "550e8400-e29b-41d4-a716-446655440000", "app_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8"}"#;
451 let result = serde_json::from_str::<AuthResponse>(json);
452 assert!(result.is_err());
453 }
454 }
455