//! SyncKit JWT authentication //! //! Separate from session-based auth. Sync clients use `Authorization: Bearer `. use axum::{extract::FromRequestParts, http::request::Parts}; use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation}; use serde::{Deserialize, Serialize}; use crate::constants::SYNCKIT_JWT_EXPIRY_SECS; use crate::db::{SyncAppId, UserId}; use crate::error::{AppError, ResultExt}; use crate::AppState; /// Issuer claim value for all SyncKit JWTs. const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit"; /// JWT claims for SyncKit tokens. #[derive(Debug, Serialize, Deserialize)] pub struct SyncClaims { /// User ID pub sub: UserId, /// App ID pub app: SyncAppId, /// Developer-defined SDK key this session belongs to. Required for /// per-key storage attribution. The dev's backend picks the key when /// minting the session — typically one key per workspace/org/end-user. pub key: String, /// Issuer pub iss: String, /// Expiration (Unix timestamp) pub exp: i64, /// Issued at (Unix timestamp) pub iat: i64, } /// Create a signed JWT for a sync user. pub fn create_sync_token( secret: &str, user_id: UserId, app_id: SyncAppId, key: &str, ) -> Result { let now = chrono::Utc::now().timestamp(); let claims = SyncClaims { sub: user_id, app: app_id, key: key.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), exp: now + SYNCKIT_JWT_EXPIRY_SECS, iat: now, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()), ) .context("jwt encode")?; Ok(token) } /// Decode and validate a sync JWT. /// /// Validates signature (HS256), expiry, issuer claim, and rejects future-`iat` /// tokens. The future-`iat` check is the defense-in-depth match for our /// `jwt_invalidated_at` revocation strategy: if a stolen secret were used /// to mint a token with `iat = now + 1 year`, the iat-based revocation /// check in `SyncUser::from_request_parts` would always see /// `claims.iat >= invalidated_at` and let the token survive any password /// change or admin suspend. Rejecting future-dated tokens here closes that. pub fn decode_sync_token(secret: &str, token: &str) -> Result { let mut validation = Validation::new(Algorithm::HS256); validation.set_issuer(&[SYNCKIT_JWT_ISSUER]); let data = decode::( token, &DecodingKey::from_secret(secret.as_bytes()), &validation, ) .map_err(|_| AppError::Unauthorized)?; // Reject `iat > now + clock_skew`. 60s skew matches the jsonwebtoken // crate's default `leeway` and absorbs typical NTP drift without // letting a deliberately future-dated token through. let now = chrono::Utc::now().timestamp(); if data.claims.iat > now + 60 { return Err(AppError::Unauthorized); } Ok(data.claims) } /// Authenticated sync user extracted from JWT Bearer token. pub struct SyncUser { pub user_id: UserId, pub app_id: SyncAppId, /// SDK key this session was minted under. All writes attributed here. pub key: String, } impl FromRequestParts for SyncUser { type Rejection = AppError; async fn from_request_parts( parts: &mut Parts, state: &AppState, ) -> Result { let secret = state .config .synckit_jwt_secret .as_deref() .ok_or_else(|| { AppError::ServiceUnavailable("SyncKit is not configured".to_string()) })?; let auth_header = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .ok_or(AppError::Unauthorized)?; let token = auth_header .strip_prefix("Bearer ") .ok_or(AppError::Unauthorized)?; let claims = decode_sync_token(secret, token)?; // Verify the app is still active (JWT may outlive app deactivation) let app = crate::db::synckit::get_sync_app_by_id(&state.db, claims.app) .await? .ok_or(AppError::Unauthorized)?; if !app.is_active { return Err(AppError::Unauthorized); } // Verify user is not suspended or deactivated (JWT may outlive suspension) let user = crate::db::users::get_user_by_id(&state.db, claims.sub) .await? .ok_or(AppError::Unauthorized)?; if user.is_suspended() || user.is_deactivated() { return Err(AppError::Unauthorized); } // Reject tokens issued before a password change (JWT revocation). // `<=` closes the 1-second collision window where a token minted in // the same wall-second as the password change would otherwise survive // revocation (`iat` and `invalidated_at` both have second resolution). if let Some(invalidated_at) = user.jwt_invalidated_at && claims.iat <= invalidated_at.timestamp() { return Err(AppError::Unauthorized); } if claims.key.is_empty() { return Err(AppError::Unauthorized); } Ok(SyncUser { user_id: claims.sub, app_id: claims.app, key: claims.key, }) } } #[cfg(test)] mod tests { use super::*; const TEST_SECRET: &str = "test-secret-key-for-synckit-jwt"; const TEST_KEY: &str = "test-key"; #[test] fn jwt_round_trip() { let user_id = UserId::new(); let app_id = SyncAppId::new(); let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap(); let claims = decode_sync_token(TEST_SECRET, &token).unwrap(); assert_eq!(claims.sub, user_id); assert_eq!(claims.app, app_id); assert_eq!(claims.key, TEST_KEY); } #[test] fn expired_token_rejected() { let user_id = UserId::new(); let app_id = SyncAppId::new(); let now = chrono::Utc::now().timestamp(); let claims = SyncClaims { sub: user_id, app: app_id, key: TEST_KEY.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), exp: now - 3600, // expired 1 hour ago iat: now - 7200, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(TEST_SECRET.as_bytes()), ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_err()); } #[test] fn invalid_token_rejected() { assert!(decode_sync_token(TEST_SECRET, "not.a.valid.token").is_err()); } #[test] fn wrong_secret_rejected() { let user_id = UserId::new(); let app_id = SyncAppId::new(); let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap(); assert!(decode_sync_token("wrong-secret", &token).is_err()); } #[test] fn malformed_token_no_dots() { assert!(decode_sync_token(TEST_SECRET, "notavalidtoken").is_err()); } #[test] fn malformed_token_one_dot() { assert!(decode_sync_token(TEST_SECRET, "part1.part2").is_err()); } #[test] fn malformed_token_invalid_base64() { // Three dot-separated segments but with invalid base64 content assert!(decode_sync_token(TEST_SECRET, "aaa.@@@invalid@@@.bbb").is_err()); } #[test] fn wrong_issuer_rejected() { let user_id = UserId::new(); let app_id = SyncAppId::new(); let now = chrono::Utc::now().timestamp(); // Build claims with wrong issuer let claims = SyncClaims { sub: user_id, app: app_id, key: TEST_KEY.to_string(), iss: "wrong-issuer".to_string(), exp: now + SYNCKIT_JWT_EXPIRY_SECS, iat: now, }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(TEST_SECRET.as_bytes()), ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_err()); } #[test] fn missing_claims_rejected() { use serde::Serialize; // Minimal claims with no sub or app fields #[derive(Serialize)] struct MinimalClaims { exp: i64, iss: String, } let now = chrono::Utc::now().timestamp(); let claims = MinimalClaims { exp: now + SYNCKIT_JWT_EXPIRY_SECS, iss: "makenotwork-synckit".to_string(), }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(TEST_SECRET.as_bytes()), ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_err()); } #[test] fn tampered_payload_rejected() { use base64::Engine; let user_id = UserId::new(); let app_id = SyncAppId::new(); let token = create_sync_token(TEST_SECRET, user_id, app_id, TEST_KEY).unwrap(); let parts: Vec<&str> = token.split('.').collect(); assert_eq!(parts.len(), 3); // Decode the payload, modify it, re-encode (signature will no longer match) let b64 = base64::engine::general_purpose::URL_SAFE_NO_PAD; let payload_bytes = b64.decode(parts[1]).unwrap(); let mut payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap(); payload["sub"] = serde_json::Value::String("00000000-0000-0000-0000-000000000000".into()); let new_payload = b64.encode(serde_json::to_vec(&payload).unwrap()); let tampered = format!("{}.{}.{}", parts[0], new_payload, parts[2]); assert!(decode_sync_token(TEST_SECRET, &tampered).is_err()); } #[test] fn empty_token_rejected() { assert!(decode_sync_token(TEST_SECRET, "").is_err()); } #[test] fn empty_key_decodes_but_extractor_must_reject() { // `decode_sync_token` does NOT enforce non-empty `key` — the only line // of defense is `SyncUser::from_request_parts`. This test pins the // decode-layer contract; if you ever add empty-key rejection here, // also remove the extractor check (or this test). let user_id = UserId::new(); let app_id = SyncAppId::new(); let token = create_sync_token(TEST_SECRET, user_id, app_id, "").unwrap(); let claims = decode_sync_token(TEST_SECRET, &token).unwrap(); assert!(claims.key.is_empty(), "decode must preserve empty key for extractor to filter"); } #[test] fn very_long_key_round_trips_through_jwt() { // No length cap inside the JWT layer — the SDK key field is opaque // here. Caller (sync_auth route) validates via validate_synckit_key, // but a directly-minted token can carry an arbitrary string. This test // documents that: the decode layer does NOT bound key length. let user_id = UserId::new(); let app_id = SyncAppId::new(); let huge = "x".repeat(10_000); let token = create_sync_token(TEST_SECRET, user_id, app_id, &huge).unwrap(); let claims = decode_sync_token(TEST_SECRET, &token).unwrap(); assert_eq!(claims.key.len(), 10_000); } #[test] fn key_with_null_bytes_round_trips_through_jwt() { // Same: null bytes survive the JWT round-trip. The /api/sync/auth // route blocks via validate_synckit_key; the extractor does not. let user_id = UserId::new(); let app_id = SyncAppId::new(); let bad = "abc\0def"; let token = create_sync_token(TEST_SECRET, user_id, app_id, bad).unwrap(); let claims = decode_sync_token(TEST_SECRET, &token).unwrap(); assert_eq!(claims.key, bad); } #[test] fn token_with_future_iat_rejected() { // Defense-in-depth: future-dated iat would defeat the // jwt_invalidated_at revocation strategy in SyncUser, since the // iat-based comparison would always see iat >= invalidated_at. // decode_sync_token rejects iat > now + 60s clock skew. let user_id = UserId::new(); let app_id = SyncAppId::new(); let now = chrono::Utc::now().timestamp(); let claims = SyncClaims { sub: user_id, app: app_id, key: TEST_KEY.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), exp: now + SYNCKIT_JWT_EXPIRY_SECS, iat: now + 86400 * 365, // 1 year in the future }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(TEST_SECRET.as_bytes()), ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_err()); } #[test] fn token_with_iat_within_skew_accepted() { // A small clock-skew window (60s default) must still pass so two // servers with mildly out-of-sync clocks don't reject each other's // freshly-minted tokens. let user_id = UserId::new(); let app_id = SyncAppId::new(); let now = chrono::Utc::now().timestamp(); let claims = SyncClaims { sub: user_id, app: app_id, key: TEST_KEY.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), exp: now + SYNCKIT_JWT_EXPIRY_SECS, iat: now + 30, // within the 60s skew window }; let token = encode( &Header::default(), &claims, &EncodingKey::from_secret(TEST_SECRET.as_bytes()), ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_ok()); } }