Skip to main content

max / synckit-client

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