Skip to main content

max / makenotwork

24.0 KB · 691 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::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
7 use serde::{Deserialize, Serialize};
8
9 use crate::AppState;
10 use crate::constants::{OAUTH_ACCESS_TOKEN_EXPIRY_SECS, SYNCKIT_JWT_EXPIRY_SECS};
11 use crate::db::{SyncAppId, UserId};
12 use crate::error::{AppError, ResultExt};
13 use crate::oauth_scope::GrantedScopes;
14
15 /// Issuer claim value for all SyncKit JWTs.
16 const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit";
17
18 /// Audience claim value for all SyncKit JWTs. Pinning `aud` (in addition to
19 /// `iss`) means a token signed with this secret for any other purpose can never
20 /// be replayed against the sync API, even if the secret were ever shared.
21 const SYNCKIT_JWT_AUDIENCE: &str = "makenotwork-synckit-clients";
22
23 /// Audience for OAuth userinfo-scoped access tokens. The decisive S13 boundary:
24 /// these are minted for an RP's perk-refresh flow and accepted only at
25 /// `/oauth/userinfo`. Because `decode_sync_token` pins the *sync* audience, a
26 /// userinfo-aud token can never authenticate the sync API, same secret, but a
27 /// different, non-overlapping audience.
28 const OAUTH_USERINFO_AUDIENCE: &str = "makenotwork-oauth-userinfo";
29
30 /// JWT claims for SyncKit tokens.
31 #[derive(Debug, Serialize, Deserialize)]
32 pub struct SyncClaims {
33 /// User ID
34 pub sub: UserId,
35 /// App ID
36 pub app: SyncAppId,
37 /// Developer-defined SDK key this session belongs to. Required for
38 /// per-key storage attribution. The dev's backend picks the key when
39 /// minting the session, typically one key per workspace/org/end-user.
40 pub key: String,
41 /// Issuer
42 pub iss: String,
43 /// Audience
44 pub aud: String,
45 /// Expiration (Unix timestamp)
46 pub exp: i64,
47 /// Issued at (Unix timestamp)
48 pub iat: i64,
49 }
50
51 /// Create a signed JWT for a sync user.
52 pub fn create_sync_token(
53 secret: &str,
54 user_id: UserId,
55 app_id: SyncAppId,
56 key: &str,
57 ) -> Result<String, AppError> {
58 let now = chrono::Utc::now().timestamp();
59 let claims = SyncClaims {
60 sub: user_id,
61 app: app_id,
62 key: key.to_string(),
63 iss: SYNCKIT_JWT_ISSUER.to_string(),
64 aud: SYNCKIT_JWT_AUDIENCE.to_string(),
65 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
66 iat: now,
67 };
68
69 let token = encode(
70 &Header::default(),
71 &claims,
72 &EncodingKey::from_secret(secret.as_bytes()),
73 )
74 .context("jwt encode")?;
75
76 Ok(token)
77 }
78
79 /// Decode and validate a sync JWT.
80 ///
81 /// Validates signature (HS256), expiry, issuer claim, and rejects future-`iat`
82 /// tokens. The future-`iat` check is the defense-in-depth match for our
83 /// `jwt_invalidated_at` revocation strategy: if a stolen secret were used
84 /// to mint a token with `iat = now + 1 year`, the iat-based revocation
85 /// check in `SyncUser::from_request_parts` would always see
86 /// `claims.iat >= invalidated_at` and let the token survive any password
87 /// change or admin suspend. Rejecting future-dated tokens here closes that.
88 pub fn decode_sync_token(secret: &str, token: &str) -> Result<SyncClaims, AppError> {
89 let mut validation = Validation::new(Algorithm::HS256);
90 validation.set_issuer(&[SYNCKIT_JWT_ISSUER]);
91 validation.set_audience(&[SYNCKIT_JWT_AUDIENCE]);
92
93 let data = decode::<SyncClaims>(
94 token,
95 &DecodingKey::from_secret(secret.as_bytes()),
96 &validation,
97 )
98 .map_err(|e| {
99 // Uniform 401 to the client, but log the specific failure kind (expired
100 // vs invalid-signature vs malformed) so a spike is triageable (audit Run
101 // 17 Observability).
102 tracing::warn!(kind = ?e.kind(), "sync token decode failed");
103 AppError::Unauthorized
104 })?;
105
106 // Reject `iat > now + clock_skew`. 60s skew matches the jsonwebtoken
107 // crate's default `leeway` and absorbs typical NTP drift without
108 // letting a deliberately future-dated token through.
109 let now = chrono::Utc::now().timestamp();
110 if data.claims.iat > now + 60 {
111 return Err(AppError::Unauthorized);
112 }
113
114 Ok(data.claims)
115 }
116
117 /// Liveness gate for every SyncKit-derived credential: the sync JWT, the OAuth
118 /// userinfo access token, and the OAuth refresh lineage. This is the single
119 /// place that knows the revocation columns, so no caller can check a subset of
120 /// them: a caller that checks `jwt_invalidated_at` and skips the device-removal
121 /// column `sync_jwt_invalidated_at` accepts a credential a device removal was
122 /// meant to kill. It enforces, in order:
123 /// - the app is still active,
124 /// - the user is neither suspended nor deactivated,
125 /// - the credential was issued AFTER a password change (`jwt_invalidated_at`),
126 /// - the credential was issued AFTER a sync-device removal
127 /// (`sync_jwt_invalidated_at`).
128 ///
129 /// `issued_at <= invalidated_at` rejects (both have second resolution; `<=`
130 /// closes the same-wall-second collision window). Returns [`AppError::Unauthorized`]
131 /// for a liveness failure and a propagated error for an infrastructure failure,
132 /// so callers can tell "revoke / deny" apart from "5xx, try again".
133 pub async fn assert_token_live(
134 db: &sqlx::PgPool,
135 app_id: SyncAppId,
136 user_id: UserId,
137 issued_at: i64,
138 ) -> Result<(), AppError> {
139 let app = crate::db::synckit::get_sync_app_by_id(db, app_id)
140 .await?
141 .ok_or(AppError::Unauthorized)?;
142 if !app.is_active {
143 return Err(AppError::Unauthorized);
144 }
145
146 let user = crate::db::users::get_user_by_id(db, user_id)
147 .await?
148 .ok_or(AppError::Unauthorized)?;
149 if user.is_suspended() || user.is_deactivated() {
150 return Err(AppError::Unauthorized);
151 }
152
153 // Password-change revocation (kills web sessions and all derived tokens).
154 if let Some(invalidated_at) = user.jwt_invalidated_at
155 && issued_at <= invalidated_at.timestamp()
156 {
157 return Err(AppError::Unauthorized);
158 }
159
160 // Sync-device-removal revocation (bumped on device removal; deliberately
161 // separate from web sessions). Checked here for EVERY credential type so a
162 // removed device's OAuth refresh lineage dies with its sync token.
163 if let Some(invalidated_at) = user.sync_jwt_invalidated_at
164 && issued_at <= invalidated_at.timestamp()
165 {
166 return Err(AppError::Unauthorized);
167 }
168
169 Ok(())
170 }
171
172 /// Authenticated sync user extracted from JWT Bearer token.
173 pub struct SyncUser {
174 /// The authenticated user.
175 pub user_id: UserId,
176 /// The SyncKit app the token was minted for.
177 pub app_id: SyncAppId,
178 /// SDK key this session was minted under. All writes attributed here.
179 pub key: String,
180 }
181
182 impl FromRequestParts<AppState> for SyncUser {
183 type Rejection = AppError;
184
185 async fn from_request_parts(
186 parts: &mut Parts,
187 state: &AppState,
188 ) -> Result<Self, Self::Rejection> {
189 let secret =
190 state.config.synckit_jwt_secret.as_deref().ok_or_else(|| {
191 AppError::ServiceUnavailable("SyncKit is not configured".to_string())
192 })?;
193
194 let auth_header = parts
195 .headers
196 .get("authorization")
197 .and_then(|v| v.to_str().ok())
198 .ok_or(AppError::Unauthorized)?;
199
200 let token = auth_header
201 .strip_prefix("Bearer ")
202 .ok_or(AppError::Unauthorized)?;
203
204 let claims = decode_sync_token(secret, token)?;
205
206 // App-active + user-live + revocation (password change AND device
207 // removal), all in one sealed gate so no check can drift out of sync.
208 assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?;
209
210 if claims.key.is_empty() {
211 return Err(AppError::Unauthorized);
212 }
213
214 Ok(SyncUser {
215 user_id: claims.sub,
216 app_id: claims.app,
217 key: claims.key,
218 })
219 }
220 }
221
222 // ── OAuth userinfo access tokens ──
223
224 /// Claims for a short-lived, scoped OAuth access token. Distinct struct (and
225 /// audience) from [`SyncClaims`] so the sync API and userinfo can never accept
226 /// each other's tokens. `scope` is the space-delimited granted scope.
227 #[derive(Debug, Serialize, Deserialize)]
228 pub struct OAuthAccessClaims {
229 /// User ID
230 pub sub: UserId,
231 /// App ID
232 pub app: SyncAppId,
233 /// SDK key this session belongs to (per-key storage attribution).
234 pub key: String,
235 /// Space-delimited granted scope string.
236 pub scope: String,
237 /// Issuer
238 pub iss: String,
239 /// Audience
240 pub aud: String,
241 /// Expiration (Unix timestamp)
242 pub exp: i64,
243 /// Issued at (Unix timestamp)
244 pub iat: i64,
245 }
246
247 /// Mint a short-lived OAuth userinfo access token carrying `scopes`.
248 pub fn create_oauth_access_token(
249 secret: &str,
250 user_id: UserId,
251 app_id: SyncAppId,
252 key: &str,
253 scopes: &GrantedScopes,
254 ) -> Result<String, AppError> {
255 let now = chrono::Utc::now().timestamp();
256 let claims = OAuthAccessClaims {
257 sub: user_id,
258 app: app_id,
259 key: key.to_string(),
260 scope: scopes.to_string(),
261 iss: SYNCKIT_JWT_ISSUER.to_string(),
262 aud: OAUTH_USERINFO_AUDIENCE.to_string(),
263 exp: now + OAUTH_ACCESS_TOKEN_EXPIRY_SECS,
264 iat: now,
265 };
266 encode(
267 &Header::default(),
268 &claims,
269 &EncodingKey::from_secret(secret.as_bytes()),
270 )
271 .context("oauth access token encode")
272 }
273
274 /// Decode and validate an OAuth userinfo access token. Pins the userinfo
275 /// audience and rejects future-`iat` (same defense-in-depth as
276 /// [`decode_sync_token`]).
277 pub fn decode_oauth_access_token(secret: &str, token: &str) -> Result<OAuthAccessClaims, AppError> {
278 let mut validation = Validation::new(Algorithm::HS256);
279 validation.set_issuer(&[SYNCKIT_JWT_ISSUER]);
280 validation.set_audience(&[OAUTH_USERINFO_AUDIENCE]);
281
282 let data = decode::<OAuthAccessClaims>(
283 token,
284 &DecodingKey::from_secret(secret.as_bytes()),
285 &validation,
286 )
287 .map_err(|e| {
288 // Uniform 401 to the client; log the failure kind for triage (audit Run
289 // 17 Observability).
290 tracing::warn!(kind = ?e.kind(), "userinfo token decode failed");
291 AppError::Unauthorized
292 })?;
293
294 let now = chrono::Utc::now().timestamp();
295 if data.claims.iat > now + 60 {
296 return Err(AppError::Unauthorized);
297 }
298
299 Ok(data.claims)
300 }
301
302 /// Authenticated user extracted from an OAuth userinfo access token. Carries the
303 /// granted scopes so the userinfo handler can gate fields per scope. Runs the
304 /// shared [`assert_token_live`] gate, so a password change AND a sync-device
305 /// removal both kill userinfo tokens (the same gate `SyncUser` applies).
306 pub struct OAuthUser {
307 /// The authenticated user.
308 pub user_id: UserId,
309 /// Scopes granted to this userinfo access token.
310 pub scopes: GrantedScopes,
311 }
312
313 impl FromRequestParts<AppState> for OAuthUser {
314 type Rejection = AppError;
315
316 async fn from_request_parts(
317 parts: &mut Parts,
318 state: &AppState,
319 ) -> Result<Self, Self::Rejection> {
320 let secret =
321 state.config.synckit_jwt_secret.as_deref().ok_or_else(|| {
322 AppError::ServiceUnavailable("SyncKit is not configured".to_string())
323 })?;
324
325 let token = parts
326 .headers
327 .get("authorization")
328 .and_then(|v| v.to_str().ok())
329 .and_then(|v| v.strip_prefix("Bearer "))
330 .ok_or(AppError::Unauthorized)?;
331
332 let claims = decode_oauth_access_token(secret, token)?;
333
334 // Same sealed liveness gate as SyncUser. Critically this now also
335 // enforces `sync_jwt_invalidated_at`, so removing a sync device kills the
336 // userinfo token too, the gate previously skipped the device-removal
337 // column here (and on the refresh path), the M-Sec1 parity gap.
338 assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?;
339
340 Ok(OAuthUser {
341 user_id: claims.sub,
342 scopes: GrantedScopes::parse(&claims.scope),
343 })
344 }
345 }
346
347 #[cfg(test)]
348 mod tests {
349 use super::*;
350 use crate::oauth_scope::OAuthScope;
351
352 const TEST_SECRET: &str = "test-secret-key-for-synckit-jwt";
353 const TEST_KEY: &str = "test-key";
354
355 /// A sync JWT minted before the jsonwebtoken 10 upgrade must keep
356 /// validating, or every client in the field is logged out on deploy. The
357 /// token is built independently (raw HMAC-SHA256 over the base64url
358 /// header/payload), so it pins the wire format rather than whatever the
359 /// crate happens to emit today. `exp` is year-2100 so the vector does not
360 /// rot; `iat` is in the past, as the future-iat guard requires.
361 #[test]
362 fn pre_upgrade_token_still_validates() {
363 const SECRET: &str = "known-answer-sync-secret";
364 const TOKEN: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhcHAiOiIyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJrZXkiOiJzZGsta2V5LTEiLCJpc3MiOiJtYWtlbm90d29yay1zeW5ja2l0IiwiYXVkIjoibWFrZW5vdHdvcmstc3luY2tpdC1jbGllbnRzIiwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDAwMDAwMDB9.V5Iu9mkok7ryyPo_T2rQNo3jNi-i2Pq-xtuIIfVSttg";
365
366 let claims = decode_sync_token(SECRET, TOKEN).expect("pre-upgrade token must validate");
367 assert_eq!(claims.key, "sdk-key-1");
368 assert_eq!(claims.iss, SYNCKIT_JWT_ISSUER);
369 assert_eq!(claims.aud, SYNCKIT_JWT_AUDIENCE);
370
371 // The vector must also prove the checks still bite, not just that
372 // decoding succeeds: a wrong secret is rejected.
373 assert!(decode_sync_token("not-the-secret", TOKEN).is_err());
374 }
375
376 #[test]
377 fn oauth_access_token_round_trips_scope() {
378 let scopes = GrantedScopes::parse("profile:read perks:read");
379 let token = create_oauth_access_token(
380 TEST_SECRET,
381 UserId::new(),
382 SyncAppId::new(),
383 TEST_KEY,
384 &scopes,
385 )
386 .unwrap();
387 let claims = decode_oauth_access_token(TEST_SECRET, &token).unwrap();
388 let got = GrantedScopes::parse(&claims.scope);
389 assert!(got.contains(OAuthScope::ProfileRead));
390 assert!(got.contains(OAuthScope::PerksRead));
391 }
392
393 #[test]
394 fn oauth_access_token_rejected_by_sync_decode() {
395 // The S13 boundary at the unit level: a userinfo-aud token must NOT
396 // decode as a sync token, so it can never authenticate the sync API.
397 let scopes = GrantedScopes::parse("perks:read");
398 let token = create_oauth_access_token(
399 TEST_SECRET,
400 UserId::new(),
401 SyncAppId::new(),
402 TEST_KEY,
403 &scopes,
404 )
405 .unwrap();
406 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
407 }
408
409 #[test]
410 fn sync_token_rejected_by_oauth_decode() {
411 // And the reverse: a full sync token isn't a userinfo-aud token.
412 let token =
413 create_sync_token(TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY).unwrap();
414 assert!(decode_oauth_access_token(TEST_SECRET, &token).is_err());
415 }
416
417 #[test]
418 fn jwt_round_trip() {
419 let user_id = UserId::new();
420 let app_id = SyncAppId::new();
421
422 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
423 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
424
425 assert_eq!(claims.sub, user_id);
426 assert_eq!(claims.app, app_id);
427 assert_eq!(claims.key, TEST_KEY);
428 }
429
430 #[test]
431 fn expired_token_rejected() {
432 let user_id = UserId::new();
433 let app_id = SyncAppId::new();
434 let now = chrono::Utc::now().timestamp();
435
436 let claims = SyncClaims {
437 sub: user_id,
438 app: app_id,
439 key: TEST_KEY.to_string(),
440 iss: SYNCKIT_JWT_ISSUER.to_string(),
441 aud: SYNCKIT_JWT_AUDIENCE.to_string(),
442 exp: now - 3600, // expired 1 hour ago
443 iat: now - 7200,
444 };
445
446 let token = encode(
447 &Header::default(),
448 &claims,
449 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
450 )
451 .unwrap();
452
453 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
454 }
455
456 #[test]
457 fn invalid_token_rejected() {
458 assert!(decode_sync_token(TEST_SECRET, "not.a.valid.token").is_err());
459 }
460
461 #[test]
462 fn wrong_secret_rejected() {
463 let user_id = UserId::new();
464 let app_id = SyncAppId::new();
465
466 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
467 assert!(decode_sync_token("wrong-secret", &token).is_err());
468 }
469
470 #[test]
471 fn malformed_token_no_dots() {
472 assert!(decode_sync_token(TEST_SECRET, "notavalidtoken").is_err());
473 }
474
475 #[test]
476 fn malformed_token_one_dot() {
477 assert!(decode_sync_token(TEST_SECRET, "part1.part2").is_err());
478 }
479
480 #[test]
481 fn malformed_token_invalid_base64() {
482 // Three dot-separated segments but with invalid base64 content
483 assert!(decode_sync_token(TEST_SECRET, "aaa.@@@invalid@@@.bbb").is_err());
484 }
485
486 #[test]
487 fn wrong_issuer_rejected() {
488 let user_id = UserId::new();
489 let app_id = SyncAppId::new();
490 let now = chrono::Utc::now().timestamp();
491
492 // Build claims with wrong issuer
493 let claims = SyncClaims {
494 sub: user_id,
495 app: app_id,
496 key: TEST_KEY.to_string(),
497 iss: "wrong-issuer".to_string(),
498 aud: SYNCKIT_JWT_AUDIENCE.to_string(),
499 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
500 iat: now,
501 };
502
503 let token = encode(
504 &Header::default(),
505 &claims,
506 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
507 )
508 .unwrap();
509
510 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
511 }
512
513 #[test]
514 fn wrong_audience_rejected() {
515 // A token correctly signed and issued but minted for a different
516 // audience must not authenticate against the sync API.
517 let now = chrono::Utc::now().timestamp();
518 let claims = SyncClaims {
519 sub: UserId::new(),
520 app: SyncAppId::new(),
521 key: TEST_KEY.to_string(),
522 iss: SYNCKIT_JWT_ISSUER.to_string(),
523 aud: "some-other-audience".to_string(),
524 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
525 iat: now,
526 };
527 let token = encode(
528 &Header::default(),
529 &claims,
530 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
531 )
532 .unwrap();
533 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
534 }
535
536 #[test]
537 fn missing_claims_rejected() {
538 use serde::Serialize;
539
540 // Minimal claims with no sub or app fields
541 #[derive(Serialize)]
542 struct MinimalClaims {
543 exp: i64,
544 iss: String,
545 }
546
547 let now = chrono::Utc::now().timestamp();
548 let claims = MinimalClaims {
549 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
550 iss: "makenotwork-synckit".to_string(),
551 };
552
553 let token = encode(
554 &Header::default(),
555 &claims,
556 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
557 )
558 .unwrap();
559
560 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
561 }
562
563 #[test]
564 fn tampered_payload_rejected() {
565 use base64::Engine;
566
567 let user_id = UserId::new();
568 let app_id = SyncAppId::new();
569
570 let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap();
571 let parts: Vec<&str> = token.split('.').collect();
572 assert_eq!(parts.len(), 3);
573
574 // Decode the payload, modify it, re-encode (signature will no longer match)
575 let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD;
576 let payload_bytes = b64.decode(parts[1]).unwrap();
577 let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
578 payload["sub"] = serde_json::Value::String("00000000-0000-0000-0000-000000000000".into());
579 let new_payload = b64.encode(serde_json::to_vec(&payload).unwrap());
580
581 let tampered = format!("{}.{}.{}", parts[0], new_payload, parts[2]);
582 assert!(decode_sync_token(TEST_SECRET, &tampered).is_err());
583 }
584
585 #[test]
586 fn empty_token_rejected() {
587 assert!(decode_sync_token(TEST_SECRET, "").is_err());
588 }
589
590 #[test]
591 fn empty_key_decodes_but_extractor_must_reject() {
592 // `decode_sync_token` does NOT enforce non-empty `key`, the only line
593 // of defense is `SyncUser::from_request_parts`. This test pins the
594 // decode-layer contract; if you ever add empty-key rejection here,
595 // also remove the extractor check (or this test).
596 let user_id = UserId::new();
597 let app_id = SyncAppId::new();
598 let token = create_sync_token(TEST_SECRET, user_id, app_id, "").unwrap();
599 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
600 assert!(
601 claims.key.is_empty(),
602 "decode must preserve empty key for extractor to filter"
603 );
604 }
605
606 #[test]
607 fn very_long_key_round_trips_through_jwt() {
608 // No length cap inside the JWT layer, the SDK key field is opaque
609 // here. Caller (sync_auth route) validates via validate_synckit_key,
610 // but a directly-minted token can carry an arbitrary string. This test
611 // documents that: the decode layer does NOT bound key length.
612 let user_id = UserId::new();
613 let app_id = SyncAppId::new();
614 let huge = "x".repeat(10_000);
615 let token = create_sync_token(TEST_SECRET, user_id, app_id, &huge).unwrap();
616 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
617 assert_eq!(claims.key.len(), 10_000);
618 }
619
620 #[test]
621 fn key_with_null_bytes_round_trips_through_jwt() {
622 // Same: null bytes survive the JWT round-trip. The /api/sync/auth
623 // route blocks via validate_synckit_key; the extractor does not.
624 let user_id = UserId::new();
625 let app_id = SyncAppId::new();
626 let bad = "abc\0def";
627 let token = create_sync_token(TEST_SECRET, user_id, app_id, bad).unwrap();
628 let claims = decode_sync_token(TEST_SECRET, &token).unwrap();
629 assert_eq!(claims.key, bad);
630 }
631
632 #[test]
633 fn token_with_future_iat_rejected() {
634 // Defense-in-depth: future-dated iat would defeat the
635 // jwt_invalidated_at revocation strategy in SyncUser, since the
636 // iat-based comparison would always see iat >= invalidated_at.
637 // decode_sync_token rejects iat > now + 60s clock skew.
638 let user_id = UserId::new();
639 let app_id = SyncAppId::new();
640 let now = chrono::Utc::now().timestamp();
641
642 let claims = SyncClaims {
643 sub: user_id,
644 app: app_id,
645 key: TEST_KEY.to_string(),
646 iss: SYNCKIT_JWT_ISSUER.to_string(),
647 aud: SYNCKIT_JWT_AUDIENCE.to_string(),
648 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
649 iat: now + 86400 * 365, // 1 year in the future
650 };
651
652 let token = encode(
653 &Header::default(),
654 &claims,
655 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
656 )
657 .unwrap();
658
659 assert!(decode_sync_token(TEST_SECRET, &token).is_err());
660 }
661
662 #[test]
663 fn token_with_iat_within_skew_accepted() {
664 // A small clock-skew window (60s default) must still pass so two
665 // servers with mildly out-of-sync clocks don't reject each other's
666 // freshly-minted tokens.
667 let user_id = UserId::new();
668 let app_id = SyncAppId::new();
669 let now = chrono::Utc::now().timestamp();
670
671 let claims = SyncClaims {
672 sub: user_id,
673 app: app_id,
674 key: TEST_KEY.to_string(),
675 iss: SYNCKIT_JWT_ISSUER.to_string(),
676 aud: SYNCKIT_JWT_AUDIENCE.to_string(),
677 exp: now + SYNCKIT_JWT_EXPIRY_SECS,
678 iat: now + 30, // within the 60s skew window
679 };
680
681 let token = encode(
682 &Header::default(),
683 &claims,
684 &EncodingKey::from_secret(TEST_SECRET.as_bytes()),
685 )
686 .unwrap();
687
688 assert!(decode_sync_token(TEST_SECRET, &token).is_ok());
689 }
690 }
691