Skip to main content

max / makenotwork

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