//! SyncKit JWT authentication //! //! Separate from session-based auth. Sync clients use `Authorization: Bearer `. use axum::{extract::FromRequestParts, http::request::Parts}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use serde::{Deserialize, Serialize}; use crate::AppState; use crate::constants::{OAUTH_ACCESS_TOKEN_EXPIRY_SECS, SYNCKIT_JWT_EXPIRY_SECS}; use crate::db::{SyncAppId, UserId}; use crate::error::{AppError, ResultExt}; use crate::oauth_scope::GrantedScopes; /// Issuer claim value for all SyncKit JWTs. const SYNCKIT_JWT_ISSUER: &str = "makenotwork-synckit"; /// Audience claim value for all SyncKit JWTs. Pinning `aud` (in addition to /// `iss`) means a token signed with this secret for any other purpose can never /// be replayed against the sync API, even if the secret were ever shared. const SYNCKIT_JWT_AUDIENCE: &str = "makenotwork-synckit-clients"; /// Audience for OAuth userinfo-scoped access tokens. The decisive S13 boundary: /// these are minted for an RP's perk-refresh flow and accepted only at /// `/oauth/userinfo`. Because `decode_sync_token` pins the *sync* audience, a /// userinfo-aud token can never authenticate the sync API, same secret, but a /// different, non-overlapping audience. const OAUTH_USERINFO_AUDIENCE: &str = "makenotwork-oauth-userinfo"; /// 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, /// Audience pub aud: 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(), aud: SYNCKIT_JWT_AUDIENCE.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]); validation.set_audience(&[SYNCKIT_JWT_AUDIENCE]); let data = decode::( token, &DecodingKey::from_secret(secret.as_bytes()), &validation, ) .map_err(|e| { // Uniform 401 to the client, but log the specific failure kind (expired // vs invalid-signature vs malformed) so a spike is triageable (audit Run // 17 Observability). tracing::warn!(kind = ?e.kind(), "sync token decode failed"); 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) } /// Liveness gate for every SyncKit-derived credential: the sync JWT, the OAuth /// userinfo access token, and the OAuth refresh lineage. This is the single /// place that knows the revocation columns, so no caller can check a subset of /// them: a caller that checks `jwt_invalidated_at` and skips the device-removal /// column `sync_jwt_invalidated_at` accepts a credential a device removal was /// meant to kill. It enforces, in order: /// - the app is still active, /// - the user is neither suspended nor deactivated, /// - the credential was issued AFTER a password change (`jwt_invalidated_at`), /// - the credential was issued AFTER a sync-device removal /// (`sync_jwt_invalidated_at`). /// /// `issued_at <= invalidated_at` rejects (both have second resolution; `<=` /// closes the same-wall-second collision window). Returns [`AppError::Unauthorized`] /// for a liveness failure and a propagated error for an infrastructure failure, /// so callers can tell "revoke / deny" apart from "5xx, try again". pub async fn assert_token_live( db: &sqlx::PgPool, app_id: SyncAppId, user_id: UserId, issued_at: i64, ) -> Result<(), AppError> { let app = crate::db::synckit::get_sync_app_by_id(db, app_id) .await? .ok_or(AppError::Unauthorized)?; if !app.is_active { return Err(AppError::Unauthorized); } let user = crate::db::users::get_user_by_id(db, user_id) .await? .ok_or(AppError::Unauthorized)?; if user.is_suspended() || user.is_deactivated() { return Err(AppError::Unauthorized); } // Password-change revocation (kills web sessions and all derived tokens). if let Some(invalidated_at) = user.jwt_invalidated_at && issued_at <= invalidated_at.timestamp() { return Err(AppError::Unauthorized); } // Sync-device-removal revocation (bumped on device removal; deliberately // separate from web sessions). Checked here for EVERY credential type so a // removed device's OAuth refresh lineage dies with its sync token. if let Some(invalidated_at) = user.sync_jwt_invalidated_at && issued_at <= invalidated_at.timestamp() { return Err(AppError::Unauthorized); } Ok(()) } /// Authenticated sync user extracted from JWT Bearer token. pub struct SyncUser { /// The authenticated user. pub user_id: UserId, /// The SyncKit app the token was minted for. 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)?; // App-active + user-live + revocation (password change AND device // removal), all in one sealed gate so no check can drift out of sync. assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?; if claims.key.is_empty() { return Err(AppError::Unauthorized); } Ok(SyncUser { user_id: claims.sub, app_id: claims.app, key: claims.key, }) } } // ── OAuth userinfo access tokens ── /// Claims for a short-lived, scoped OAuth access token. Distinct struct (and /// audience) from [`SyncClaims`] so the sync API and userinfo can never accept /// each other's tokens. `scope` is the space-delimited granted scope. #[derive(Debug, Serialize, Deserialize)] pub struct OAuthAccessClaims { /// User ID pub sub: UserId, /// App ID pub app: SyncAppId, /// SDK key this session belongs to (per-key storage attribution). pub key: String, /// Space-delimited granted scope string. pub scope: String, /// Issuer pub iss: String, /// Audience pub aud: String, /// Expiration (Unix timestamp) pub exp: i64, /// Issued at (Unix timestamp) pub iat: i64, } /// Mint a short-lived OAuth userinfo access token carrying `scopes`. pub fn create_oauth_access_token( secret: &str, user_id: UserId, app_id: SyncAppId, key: &str, scopes: &GrantedScopes, ) -> Result { let now = chrono::Utc::now().timestamp(); let claims = OAuthAccessClaims { sub: user_id, app: app_id, key: key.to_string(), scope: scopes.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), aud: OAUTH_USERINFO_AUDIENCE.to_string(), exp: now + OAUTH_ACCESS_TOKEN_EXPIRY_SECS, iat: now, }; encode( &Header::default(), &claims, &EncodingKey::from_secret(secret.as_bytes()), ) .context("oauth access token encode") } /// Decode and validate an OAuth userinfo access token. Pins the userinfo /// audience and rejects future-`iat` (same defense-in-depth as /// [`decode_sync_token`]). pub fn decode_oauth_access_token(secret: &str, token: &str) -> Result { let mut validation = Validation::new(Algorithm::HS256); validation.set_issuer(&[SYNCKIT_JWT_ISSUER]); validation.set_audience(&[OAUTH_USERINFO_AUDIENCE]); let data = decode::( token, &DecodingKey::from_secret(secret.as_bytes()), &validation, ) .map_err(|e| { // Uniform 401 to the client; log the failure kind for triage (audit Run // 17 Observability). tracing::warn!(kind = ?e.kind(), "userinfo token decode failed"); AppError::Unauthorized })?; let now = chrono::Utc::now().timestamp(); if data.claims.iat > now + 60 { return Err(AppError::Unauthorized); } Ok(data.claims) } /// Authenticated user extracted from an OAuth userinfo access token. Carries the /// granted scopes so the userinfo handler can gate fields per scope. Runs the /// shared [`assert_token_live`] gate, so a password change AND a sync-device /// removal both kill userinfo tokens (the same gate `SyncUser` applies). pub struct OAuthUser { /// The authenticated user. pub user_id: UserId, /// Scopes granted to this userinfo access token. pub scopes: GrantedScopes, } impl FromRequestParts for OAuthUser { 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 token = parts .headers .get("authorization") .and_then(|v| v.to_str().ok()) .and_then(|v| v.strip_prefix("Bearer ")) .ok_or(AppError::Unauthorized)?; let claims = decode_oauth_access_token(secret, token)?; // Same sealed liveness gate as SyncUser. Critically this now also // enforces `sync_jwt_invalidated_at`, so removing a sync device kills the // userinfo token too, the gate previously skipped the device-removal // column here (and on the refresh path), the M-Sec1 parity gap. assert_token_live(&state.db, claims.app, claims.sub, claims.iat).await?; Ok(OAuthUser { user_id: claims.sub, scopes: GrantedScopes::parse(&claims.scope), }) } } #[cfg(test)] mod tests { use super::*; use crate::oauth_scope::OAuthScope; const TEST_SECRET: &str = "test-secret-key-for-synckit-jwt"; const TEST_KEY: &str = "test-key"; /// A sync JWT minted before the jsonwebtoken 10 upgrade must keep /// validating, or every client in the field is logged out on deploy. The /// token is built independently (raw HMAC-SHA256 over the base64url /// header/payload), so it pins the wire format rather than whatever the /// crate happens to emit today. `exp` is year-2100 so the vector does not /// rot; `iat` is in the past, as the future-iat guard requires. #[test] fn pre_upgrade_token_still_validates() { const SECRET: &str = "known-answer-sync-secret"; const TOKEN: &str = "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMTExMTExMS0xMTExLTQxMTEtODExMS0xMTExMTExMTExMTEiLCJhcHAiOiIyMjIyMjIyMi0yMjIyLTQyMjItODIyMi0yMjIyMjIyMjIyMjIiLCJrZXkiOiJzZGsta2V5LTEiLCJpc3MiOiJtYWtlbm90d29yay1zeW5ja2l0IiwiYXVkIjoibWFrZW5vdHdvcmstc3luY2tpdC1jbGllbnRzIiwiZXhwIjo0MTAyNDQ0ODAwLCJpYXQiOjE3MDAwMDAwMDB9.V5Iu9mkok7ryyPo_T2rQNo3jNi-i2Pq-xtuIIfVSttg"; let claims = decode_sync_token(SECRET, TOKEN).expect("pre-upgrade token must validate"); assert_eq!(claims.key, "sdk-key-1"); assert_eq!(claims.iss, SYNCKIT_JWT_ISSUER); assert_eq!(claims.aud, SYNCKIT_JWT_AUDIENCE); // The vector must also prove the checks still bite, not just that // decoding succeeds: a wrong secret is rejected. assert!(decode_sync_token("not-the-secret", TOKEN).is_err()); } #[test] fn oauth_access_token_round_trips_scope() { let scopes = GrantedScopes::parse("profile:read perks:read"); let token = create_oauth_access_token( TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY, &scopes, ) .unwrap(); let claims = decode_oauth_access_token(TEST_SECRET, &token).unwrap(); let got = GrantedScopes::parse(&claims.scope); assert!(got.contains(OAuthScope::ProfileRead)); assert!(got.contains(OAuthScope::PerksRead)); } #[test] fn oauth_access_token_rejected_by_sync_decode() { // The S13 boundary at the unit level: a userinfo-aud token must NOT // decode as a sync token, so it can never authenticate the sync API. let scopes = GrantedScopes::parse("perks:read"); let token = create_oauth_access_token( TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY, &scopes, ) .unwrap(); assert!(decode_sync_token(TEST_SECRET, &token).is_err()); } #[test] fn sync_token_rejected_by_oauth_decode() { // And the reverse: a full sync token isn't a userinfo-aud token. let token = create_sync_token(TEST_SECRET, UserId::new(), SyncAppId::new(), TEST_KEY).unwrap(); assert!(decode_oauth_access_token(TEST_SECRET, &token).is_err()); } #[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(), aud: SYNCKIT_JWT_AUDIENCE.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(), aud: SYNCKIT_JWT_AUDIENCE.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 wrong_audience_rejected() { // A token correctly signed and issued but minted for a different // audience must not authenticate against the sync API. let now = chrono::Utc::now().timestamp(); let claims = SyncClaims { sub: UserId::new(), app: SyncAppId::new(), key: TEST_KEY.to_string(), iss: SYNCKIT_JWT_ISSUER.to_string(), aud: "some-other-audience".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(), aud: SYNCKIT_JWT_AUDIENCE.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(), aud: SYNCKIT_JWT_AUDIENCE.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()); } }