Skip to main content

max / makenotwork

13.5 KB · 418 lines History Blame Raw
1 //! SyncKit JWT authentication
2 //!
3 //! Separate from session-based auth. Sync clients use `Authorization: Bearer <token>`.
4
5 use axum::{extract::FromRequestParts, http::request::Parts};
6 use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
7 use serde::{Deserialize, Serialize};
8
9 use crate::constants::SYNCKIT_JWT_EXPIRY_SECS;
10 use crate::db::{SyncAppId, UserId};
11 use crate::error::{AppError, ResultExt};
12 use crate::AppState;
13
14 /// Issuer claim value for all SyncKit JWTs.
15 const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit";
16
17 /// JWT claims for SyncKit tokens.
18 #[derive(Debug, Serialize, Deserialize)]
19 pub struct SyncClaims {
20 /// User ID
21 pub sub: UserId,
22 /// App ID
23 pub app: SyncAppId,
24 /// Developer-defined SDK key this session belongs to. Required for
25 /// per-key storage attribution. The dev's backend picks the key when
26 /// minting the session — typically one key per workspace/org/end-user.
27 pub key: String,
28 /// Issuer
29 pub iss: String,
30 /// Expiration (Unix timestamp)
31 pub exp: i64,
32 /// Issued at (Unix timestamp)
33 pub iat: i64,
34 }
35
36 /// Create a signed JWT for a sync user.
37 pub fn create_sync_token(
38 secret: &str,
39 user_id: UserId,
40 app_id: SyncAppId,
41 key: &str,
42 ) -> Result<String, AppError> {
43 let now = chrono::Utc::now().timestamp();
44 let claims = SyncClaims {
45 sub: user_id,
46 app: app_id,
47 key: key.to_string(),
48 iss: SYNCKIT_JWT_ISSUER.to_string(),
49 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
50 iat: now,
51 };
52
53 let token = encode(
54 &Header::default(),
55 &claims,
56 &EncodingKey::from_secret(secret.as_bytes()),
57 )
58 .context("jwt encode")?;
59
60 Ok(token)
61 }
62
63 /// Decode and validate a sync JWT.
64 ///
65 /// Validates signature (HS256), expiry, issuer claim, and rejects future-`iat`
66 /// tokens. The future-`iat` check is the defense-in-depth match for our
67 /// `jwt_invalidated_at` revocation strategy: if a stolen secret were used
68 /// to mint a token with `iat = now + 1 year`, the iat-based revocation
69 /// check in `SyncUser::from_request_parts` would always see
70 /// `claims.iat >= invalidated_at` and let the token survive any password
71 /// change or admin suspend. Rejecting future-dated tokens here closes that.
72 pub fn decode_sync_token(secret: &str, token: &str) -> Result<SyncClaims, AppError> {
73 let mut validation = Validation::new(Algorithm::HS256);
74 validation.set_issuer(&[SYNCKIT_JWT_ISSUER]);
75
76 let data = decode::<SyncClaims>(
77 token,
78 &DecodingKey::from_secret(secret.as_bytes()),
79 &validation,
80 )
81 .map_err(|_| AppError::Unauthorized)?;
82
83 // Reject `iat > now + clock_skew`. 60s skew matches the jsonwebtoken
84 // crate's default `leeway` and absorbs typical NTP drift without
85 // letting a deliberately future-dated token through.
86 let now = chrono::Utc::now().timestamp();
87 if data.claims.iat > now + 60 {
88 return Err(AppError::Unauthorized);
89 }
90
91 Ok(data.claims)
92 }
93
94 /// Authenticated sync user extracted from JWT Bearer token.
95 pub struct SyncUser {
96 pub user_id: UserId,
97 pub app_id: SyncAppId,
98 /// SDK key this session was minted under. All writes attributed here.
99 pub key: String,
100 }
101
102 impl FromRequestParts<AppState> for SyncUser {
103 type Rejection = AppError;
104
105 async fn from_request_parts(
106 parts: &mut Parts,
107 state: &AppState,
108 ) -> Result<Self, Self::Rejection> {
109 let secret = state
110 .config
111 .synckit_jwt_secret
112 .as_deref()
113 .ok_or_else(|| {
114 AppError::ServiceUnavailable("SyncKit is not configured".to_string())
115 })?;
116
117 let auth_header = parts
118 .headers
119 .get("authorization")
120 .and_then(|v| v.to_str().ok())
121 .ok_or(AppError::Unauthorized)?;
122
123 let token = auth_header
124 .strip_prefix("Bearer ")
125 .ok_or(AppError::Unauthorized)?;
126
127 let claims = decode_sync_token(secret, token)?;
128
129 // Verify the app is still active (JWT may outlive app deactivation)
130 let app = crate::db::synckit::get_sync_app_by_id(&state.db, claims.app)
131 .await?
132 .ok_or(AppError::Unauthorized)?;
133 if !app.is_active {
134 return Err(AppError::Unauthorized);
135 }
136
137 // Verify user is not suspended or deactivated (JWT may outlive suspension)
138 let user = crate::db::users::get_user_by_id(&state.db, claims.sub)
139 .await?
140 .ok_or(AppError::Unauthorized)?;
141 if user.is_suspended() || user.is_deactivated() {
142 return Err(AppError::Unauthorized);
143 }
144
145 // Reject tokens issued before a password change (JWT revocation).
146 // `<=` closes the 1-second collision window where a token minted in
147 // the same wall-second as the password change would otherwise survive
148 // revocation (`iat` and `invalidated_at` both have second resolution).
149 if let Some(invalidated_at) = user.jwt_invalidated_at
150 && claims.iat <= invalidated_at.timestamp()
151 {
152 return Err(AppError::Unauthorized);
153 }
154
155 if claims.key.is_empty() {
156 return Err(AppError::Unauthorized);
157 }
158
159 Ok(SyncUser {
160 user_id: claims.sub,
161 app_id: claims.app,
162 key: claims.key,
163 })
164 }
165 }
166
167 #[cfg(test)]
168 mod tests {
169 use super::*;
170
171 const TEST_SECRET: &str = "test-secret-key-for-synckit-jwt";
172 const TEST_KEY: &str = "test-key";
173
174 #[test]
175 fn jwt_round_trip() {
176 let user_id = UserId::new();
177 let app_id = SyncAppId::new();
178
179 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
180 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
181
182 assert_eq!(claims.sub, user_id);
183 assert_eq!(claims.app, app_id);
184 assert_eq!(claims.key, TEST_KEY);
185 }
186
187 #[test]
188 fn expired_token_rejected() {
189 let user_id = UserId::new();
190 let app_id = SyncAppId::new();
191 let now = chrono::Utc::now().timestamp();
192
193 let claims = SyncClaims {
194 sub: user_id,
195 app: app_id,
196 key: TEST_KEY.to_string(),
197 iss: SYNCKIT_JWT_ISSUER.to_string(),
198 exp: now - 3600, // expired 1 hour ago
199 iat: now - 7200,
200 };
201
202 let token = encode(
203 &Header::default(),
204 &claims,
205 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
206 )
207 .unwrap();
208
209 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
210 }
211
212 #[test]
213 fn invalid_token_rejected() {
214 assert!(decode_sync_token(TEST_SECRET, "not.a.valid.token").is_err());
215 }
216
217 #[test]
218 fn wrong_secret_rejected() {
219 let user_id = UserId::new();
220 let app_id = SyncAppId::new();
221
222 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
223 assert!(decode_sync_token("wrong-secret", &token).is_err());
224 }
225
226 #[test]
227 fn malformed_token_no_dots() {
228 assert!(decode_sync_token(TEST_SECRET, "notavalidtoken").is_err());
229 }
230
231 #[test]
232 fn malformed_token_one_dot() {
233 assert!(decode_sync_token(TEST_SECRET, "part1.part2").is_err());
234 }
235
236 #[test]
237 fn malformed_token_invalid_base64() {
238 // Three dot-separated segments but with invalid base64 content
239 assert!(decode_sync_token(TEST_SECRET, "aaa.@@@invalid@@@.bbb").is_err());
240 }
241
242 #[test]
243 fn wrong_issuer_rejected() {
244 let user_id = UserId::new();
245 let app_id = SyncAppId::new();
246 let now = chrono::Utc::now().timestamp();
247
248 // Build claims with wrong issuer
249 let claims = SyncClaims {
250 sub: user_id,
251 app: app_id,
252 key: TEST_KEY.to_string(),
253 iss: "wrong-issuer".to_string(),
254 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
255 iat: now,
256 };
257
258 let token = encode(
259 &Header::default(),
260 &claims,
261 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
262 )
263 .unwrap();
264
265 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
266 }
267
268 #[test]
269 fn missing_claims_rejected() {
270 use serde::Serialize;
271
272 // Minimal claims with no sub or app fields
273 #[derive(Serialize)]
274 struct MinimalClaims {
275 exp: i64,
276 iss: String,
277 }
278
279 let now = chrono::Utc::now().timestamp();
280 let claims = MinimalClaims {
281 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
282 iss: "makenotwork-synckit".to_string(),
283 };
284
285 let token = encode(
286 &Header::default(),
287 &claims,
288 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
289 )
290 .unwrap();
291
292 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
293 }
294
295 #[test]
296 fn tampered_payload_rejected() {
297 use base64::Engine;
298
299 let user_id = UserId::new();
300 let app_id = SyncAppId::new();
301
302 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
303 let parts: Vec<&str> = token.split('.').collect();
304 assert_eq!(parts.len(), 3);
305
306 // Decode the payload, modify it, re-encode (signature will no longer match)
307 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
308 let payload_bytes = b64.decode(parts[1]).unwrap();
309 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
310 payload["sub"] = serde_json::Value::String("00000000-0000-0000-0000-000000000000".into());
311 let new_payload = b64.encode(serde_json::to_vec(&payload).unwrap());
312
313 let tampered = format!("{}.{}.{}", parts[0], new_payload, parts[2]);
314 assert!(decode_sync_token(TEST_SECRET, &tampered).is_err());
315 }
316
317 #[test]
318 fn empty_token_rejected() {
319 assert!(decode_sync_token(TEST_SECRET, "").is_err());
320 }
321
322 #[test]
323 fn empty_key_decodes_but_extractor_must_reject() {
324 // `decode_sync_token` does NOT enforce non-empty `key` — the only line
325 // of defense is `SyncUser::from_request_parts`. This test pins the
326 // decode-layer contract; if you ever add empty-key rejection here,
327 // also remove the extractor check (or this test).
328 let user_id = UserId::new();
329 let app_id = SyncAppId::new();
330 let token = create_sync_token(TEST_SECRET, user_id, app_id, "").unwrap();
331 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
332 assert!(claims.key.is_empty(), "decode must preserve empty key for extractor to filter");
333 }
334
335 #[test]
336 fn very_long_key_round_trips_through_jwt() {
337 // No length cap inside the JWT layer — the SDK key field is opaque
338 // here. Caller (sync_auth route) validates via validate_synckit_key,
339 // but a directly-minted token can carry an arbitrary string. This test
340 // documents that: the decode layer does NOT bound key length.
341 let user_id = UserId::new();
342 let app_id = SyncAppId::new();
343 let huge = "x".repeat(10_000);
344 let token = create_sync_token(TEST_SECRET, user_id, app_id, &huge).unwrap();
345 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
346 assert_eq!(claims.key.len(), 10_000);
347 }
348
349 #[test]
350 fn key_with_null_bytes_round_trips_through_jwt() {
351 // Same: null bytes survive the JWT round-trip. The /api/sync/auth
352 // route blocks via validate_synckit_key; the extractor does not.
353 let user_id = UserId::new();
354 let app_id = SyncAppId::new();
355 let bad = "abc\0def";
356 let token = create_sync_token(TEST_SECRET, user_id, app_id, bad).unwrap();
357 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
358 assert_eq!(claims.key, bad);
359 }
360
361 #[test]
362 fn token_with_future_iat_rejected() {
363 // Defense-in-depth: future-dated iat would defeat the
364 // jwt_invalidated_at revocation strategy in SyncUser, since the
365 // iat-based comparison would always see iat >= invalidated_at.
366 // decode_sync_token rejects iat > now + 60s clock skew.
367 let user_id = UserId::new();
368 let app_id = SyncAppId::new();
369 let now = chrono::Utc::now().timestamp();
370
371 let claims = SyncClaims {
372 sub: user_id,
373 app: app_id,
374 key: TEST_KEY.to_string(),
375 iss: SYNCKIT_JWT_ISSUER.to_string(),
376 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
377 iat: now + 86400 * 365, // 1 year in the future
378 };
379
380 let token = encode(
381 &Header::default(),
382 &claims,
383 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
384 )
385 .unwrap();
386
387 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
388 }
389
390 #[test]
391 fn token_with_iat_within_skew_accepted() {
392 // A small clock-skew window (60s default) must still pass so two
393 // servers with mildly out-of-sync clocks don't reject each other's
394 // freshly-minted tokens.
395 let user_id = UserId::new();
396 let app_id = SyncAppId::new();
397 let now = chrono::Utc::now().timestamp();
398
399 let claims = SyncClaims {
400 sub: user_id,
401 app: app_id,
402 key: TEST_KEY.to_string(),
403 iss: SYNCKIT_JWT_ISSUER.to_string(),
404 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
405 iat: now + 30, // within the 60s skew window
406 };
407
408 let token = encode(
409 &Header::default(),
410 &claims,
411 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
412 )
413 .unwrap();
414
415 assert!(decode_sync_token(TEST_SECRET, &token).is_ok());
416 }
417 }
418