From 4cbf7a93d2c8dd2398fd27f5c49318cfec05d5fd Mon Sep 17 00:00:00 2001 From: kipavy Date: Sun, 11 Oct 2026 13:30:15 +0000 Subject: [PATCH] feat(auth): revoke refresh tokens with a per-user token version (#104) Refresh tokens were stateless for their whole 90 days. Changing the master password returned new tokens but left every earlier refresh token working, and nothing could sign a device out from the server. users.token_version is written into the refresh token as `ver` and compared on every refresh. Changing the password bumps it, and POST /v1/auth/logout-all bumps it on request. Both hand the caller a pair minted at the new version and send token_invalidated so other devices find out at once. Tokens issued before this decode as version 0 and stay valid until the first bump. /v1/auth/refresh moves to its own per-IP budget. Released clients retry a refused refresh every five seconds from the sync loop, which on the shared auth budget would have locked the same address out of signing in. The three copies of "access token + refresh token + map_err" in register, login and password change are now one issue_session. --- migrations/058_user_token_version.sql | 1 + src/auth/jwt.rs | 63 ++++--- src/auth/mod.rs | 1 + src/main.rs | 12 +- src/routes/auth.rs | 235 ++++++++++++++++++-------- src/routes/terminal.rs | 1 + 6 files changed, 224 insertions(+), 89 deletions(-) create mode 100644 migrations/058_user_token_version.sql diff --git a/migrations/058_user_token_version.sql b/migrations/058_user_token_version.sql new file mode 100644 index 0000000..d7735e0 --- /dev/null +++ b/migrations/058_user_token_version.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN token_version INTEGER NOT NULL DEFAULT 0; diff --git a/src/auth/jwt.rs b/src/auth/jwt.rs index 0860f82..ae25aa5 100644 --- a/src/auth/jwt.rs +++ b/src/auth/jwt.rs @@ -16,6 +16,8 @@ pub struct Claims { pub is_banned: bool, #[serde(default)] pub email_verified: bool, + #[serde(default)] + pub ver: i32, } impl Claims { @@ -55,6 +57,7 @@ pub fn create_access_token( is_admin, is_banned, email_verified, + ver: 0, }; encode( &Header::default(), @@ -63,7 +66,10 @@ pub fn create_access_token( ) } -pub fn create_refresh_token(user_id: Uuid) -> Result { +pub fn create_refresh_token( + user_id: Uuid, + token_version: i32, +) -> Result { let now = Utc::now(); // Refresh tokens carry no tier — tier is re-read from DB on each refresh. let claims = Claims { @@ -77,6 +83,7 @@ pub fn create_refresh_token(user_id: Uuid) -> Result String { + let now = Utc::now(); + encode( + &Header::default(), + &LegacyClaims { + sub: user_id, + iat: now.timestamp(), + exp: (now + Duration::hours(1)).timestamp(), + kind: kind.to_string(), + tier: "pro".to_string(), + trial_ends_at: None, + trial_used: false, + is_admin: false, + is_banned: false, + }, + &EncodingKey::from_secret(secret.as_bytes()), + ) + .unwrap() + } + #[test] fn access_token_includes_email_verified_claim() { // Serialize JWT_SECRET mutation against other env-touching tests (e.g. the @@ -136,31 +163,25 @@ mod tests { } #[test] - fn legacy_token_without_email_verified_decodes_as_unverified() { + fn legacy_token_decodes_with_defaults_for_later_claims() { let _guard = crate::test_support::env_lock(); std::env::set_var("JWT_SECRET", "test-secret"); - let now = Utc::now(); let user_id = Uuid::new_v4(); - let token = encode( - &Header::default(), - &LegacyClaims { - sub: user_id, - iat: now.timestamp(), - exp: (now + Duration::hours(1)).timestamp(), - kind: "access".to_string(), - tier: "pro".to_string(), - trial_ends_at: None, - trial_used: false, - is_admin: false, - is_banned: false, - }, - &EncodingKey::from_secret(b"test-secret"), - ) - .unwrap(); - let claims = validate_token(&token, "access").unwrap(); + let claims = validate_token(&legacy_token(user_id, "access", "test-secret"), "access").unwrap(); assert!(!claims.email_verified); + assert_eq!(claims.ver, 0); assert_eq!(claims.sub, user_id); } + + #[test] + fn refresh_token_carries_the_token_version() { + let _guard = crate::test_support::env_lock(); + std::env::set_var("JWT_SECRET", "test-secret"); + + let token = create_refresh_token(Uuid::new_v4(), 7).unwrap(); + + assert_eq!(validate_token(&token, "refresh").unwrap().ver, 7); + } } diff --git a/src/auth/mod.rs b/src/auth/mod.rs index 86c9d41..ef45b0e 100644 --- a/src/auth/mod.rs +++ b/src/auth/mod.rs @@ -165,6 +165,7 @@ mod authz_tests { is_admin: false, is_banned, email_verified: true, + ver: 0, } } diff --git a/src/main.rs b/src/main.rs index 0fec9ef..b92699f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -286,7 +286,6 @@ async fn main() { let public = Router::new() .route("/v1/auth/challenge", get(routes::auth::challenge)) .route("/v1/auth/login", post(routes::auth::login)) - .route("/v1/auth/refresh", post(routes::auth::refresh)) .route("/v1/auth/verify-email", post(routes::auth::verify_email)) .route( "/v1/invitations/:token", @@ -295,6 +294,15 @@ async fn main() { .layer(middleware::from_fn(rate_limit::auth_rate_limit)) .layer(Extension(auth_limiter)); + // A revoked session retries refresh every few seconds; it must not spend the sign-in budget. + let refresh_route = Router::new() + .route("/v1/auth/refresh", post(routes::auth::refresh)) + .layer(middleware::from_fn(rate_limit::auth_rate_limit)) + .layer(Extension(RateLimiter::::new( + auth_per_minute * 3, + Duration::from_secs(60), + ))); + let waitlist = Router::new() .route( "/v1/mobile-waitlist", @@ -360,6 +368,7 @@ async fn main() { .route("/v1/auth/me", get(routes::auth::get_me)) .route("/v1/auth/email", put(routes::auth::update_email)) .route("/v1/auth/password", put(routes::auth::update_password)) + .route("/v1/auth/logout-all", post(routes::auth::logout_all)) .route( "/v1/auth/wrapped-user-secrets", put(routes::auth::upload_wrapped_user_secrets), @@ -760,6 +769,7 @@ async fn main() { let app = Router::new() .merge(public) + .merge(refresh_route) .merge(waitlist) .merge(register_route) .merge(webhooks) diff --git a/src/routes/auth.rs b/src/routes/auth.rs index b276e67..67b3e28 100644 --- a/src/routes/auth.rs +++ b/src/routes/auth.rs @@ -30,11 +30,48 @@ struct TierInfo { is_admin: bool, is_banned: bool, email_verified: bool, + token_version: i32, +} + +fn access_token_for(user_id: Uuid, tier: &TierInfo) -> Result { + create_access_token( + user_id, + &tier.tier, + tier.trial_ends_at, + tier.trial_used, + tier.is_admin, + tier.is_banned, + tier.email_verified, + ) + .map_err(|err| { + error!(error = %err, user_id = %user_id, "Failed to create access token"); + StatusCode::INTERNAL_SERVER_ERROR + }) +} + +fn issue_session(user_id: Uuid, tier: &TierInfo) -> Result<(String, String), StatusCode> { + let refresh_token = create_refresh_token(user_id, tier.token_version).map_err(|err| { + error!(error = %err, user_id = %user_id, "Failed to create refresh token"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok((access_token_for(user_id, tier)?, refresh_token)) +} + +async fn bump_token_version(pool: &PgPool, user_id: Uuid) -> Result<(), StatusCode> { + sqlx::query("UPDATE users SET token_version = token_version + 1 WHERE id = $1") + .bind(user_id) + .execute(pool) + .await + .map_err(|e| { + error!(error = %e, user_id = %user_id, "Failed to bump token version"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + Ok(()) } async fn fetch_tier(pool: &PgPool, user_id: Uuid) -> Result { - let row = sqlx::query_as::<_, (String, Option>, bool, bool, bool, bool, bool, Option, Option>)>( - "SELECT subscription_tier, trial_ends_at, trial_used, is_admin, is_banned, email_verified, admin_override, ls_subscription_id, deleted_at FROM users WHERE id = $1", + let row = sqlx::query_as::<_, (String, Option>, bool, bool, bool, bool, bool, Option, Option>, i32)>( + "SELECT subscription_tier, trial_ends_at, trial_used, is_admin, is_banned, email_verified, admin_override, ls_subscription_id, deleted_at, token_version FROM users WHERE id = $1", ) .bind(user_id) .fetch_one(pool) @@ -44,7 +81,7 @@ async fn fetch_tier(pool: &PgPool, user_id: Uuid) -> Result Result, axum::Extension(auth): axum::Extension, + axum::Extension(notifier): axum::Extension, Json(body): Json, ) -> Result, StatusCode> { let auth_hash = sqlx::query_scalar::<_, String>("SELECT auth_hash FROM users WHERE id = $1") @@ -821,7 +831,7 @@ pub async fn update_password( })?; sqlx::query( - "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, updated_at = now() WHERE id = $3", + "UPDATE users SET auth_hash = $1, wrapped_user_secrets = $2, token_version = token_version + 1, updated_at = now() WHERE id = $3", ) .bind(&new_auth_hash) .bind(&body.new_wrapped_user_secrets) @@ -834,23 +844,8 @@ pub async fn update_password( })?; let tier = fetch_tier(&pool, auth.0).await?; - let jwt_token = create_access_token( - auth.0, - &tier.tier, - tier.trial_ends_at, - tier.trial_used, - tier.is_admin, - tier.is_banned, - tier.email_verified, - ) - .map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to create access token after password update"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let refresh_token = create_refresh_token(auth.0).map_err(|e| { - error!(error = %e, user_id = %auth.0, "Failed to create refresh token after password update"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; + notifier.notify(auth.0, "token_invalidated".to_string()); info!(user_id = %auth.0, "User password updated"); @@ -864,6 +859,29 @@ pub async fn update_password( })) } +// ─── Log out everywhere ─────────────────────────────────────────────────────── + +#[derive(Serialize)] +pub struct SessionTokens { + pub jwt_token: String, + pub refresh_token: String, +} + +pub async fn logout_all( + State(pool): State, + axum::Extension(auth): axum::Extension, + axum::Extension(notifier): axum::Extension, +) -> Result, StatusCode> { + bump_token_version(&pool, auth.0).await?; + let tier = fetch_tier(&pool, auth.0).await?; + let (jwt_token, refresh_token) = issue_session(auth.0, &tier)?; + notifier.notify(auth.0, "token_invalidated".to_string()); + + info!(user_id = %auth.0, "User signed out every other session"); + + Ok(Json(SessionTokens { jwt_token, refresh_token })) +} + // ─── Upload wrapped user secrets (migration) ────────────────────────────────── #[derive(Deserialize)] @@ -1454,6 +1472,89 @@ mod handler_tests { } } + // ── revocation: a refresh token dies when token_version moves ──────────── + + async fn refresh_with(pool: &PgPool, refresh_token: &str) -> Result { + refresh( + State(pool.clone()), + Json(RefreshRequest { + refresh_token: refresh_token.to_string(), + }), + ) + .await + .map(|Json(r)| r.jwt_token) + } + + async fn signed_in_twice(pool: &PgPool) -> (Uuid, AuthResponse, AuthResponse) { + let account_id = Uuid::new_v4(); + let uid = seed_user_with_credentials(pool, account_id, "auth-key-secret").await; + let here = login_of(pool, account_id).await.expect("login ok"); + let elsewhere = login_of(pool, account_id).await.expect("login ok"); + (uid, here, elsewhere) + } + + #[tokio::test] + async fn password_change_revokes_earlier_refresh_tokens() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + let (uid, _, elsewhere) = signed_in_twice(&pool).await; + + let Json(changed) = update_password( + State(pool.clone()), + Extension(AuthUser(uid)), + Extension(SyncNotifier::new()), + Json(UpdatePasswordRequest { + old_auth_key: "auth-key-secret".to_string(), + new_auth_key: "new-auth-key".to_string(), + new_wrapped_user_secrets: "wrapped".to_string(), + }), + ) + .await + .expect("password change ok"); + + assert_eq!( + refresh_with(&pool, &elsewhere.refresh_token).await, + Err(StatusCode::UNAUTHORIZED) + ); + assert!(refresh_with(&pool, &changed.refresh_token).await.is_ok()); + } + + #[tokio::test] + async fn logout_all_revokes_other_sessions_and_keeps_the_caller() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + use crate::sync_notifier::SyncEvent; + let (uid, here, elsewhere) = signed_in_twice(&pool).await; + let notifier = SyncNotifier::new(); + let mut events = notifier.subscribe(); + + let Json(fresh) = logout_all(State(pool.clone()), Extension(AuthUser(uid)), Extension(notifier)) + .await + .expect("logout-all ok"); + + for revoked in [&here.refresh_token, &elsewhere.refresh_token] { + assert_eq!(refresh_with(&pool, revoked).await, Err(StatusCode::UNAUTHORIZED)); + } + assert!(refresh_with(&pool, &fresh.refresh_token).await.is_ok()); + assert!(validate_token(&fresh.jwt_token, "access").is_ok()); + assert!(matches!( + events.try_recv(), + Ok(SyncEvent::BlobPushed { user_id, device_id }) if user_id == uid && device_id == "token_invalidated" + )); + } + + #[tokio::test] + async fn refresh_token_from_before_the_version_claim_works_until_a_bump() { + let _env = EnvGuard::new(true); + let pool = test_pool_or_skip!(); + let uid = seed_user_with_credentials(&pool, Uuid::new_v4(), "auth-key-secret").await; + let legacy = crate::auth::jwt::tests::legacy_token(uid, "refresh", "ci-test-secret"); + + assert!(refresh_with(&pool, &legacy).await.is_ok()); + bump_token_version(&pool, uid).await.expect("bump"); + assert_eq!(refresh_with(&pool, &legacy).await, Err(StatusCode::UNAUTHORIZED)); + } + // ── soft delete: a deleted account is locked out of every auth path ─────── async fn soft_delete(pool: &PgPool, user_id: Uuid) { diff --git a/src/routes/terminal.rs b/src/routes/terminal.rs index fbaa468..678655b 100644 --- a/src/routes/terminal.rs +++ b/src/routes/terminal.rs @@ -1926,6 +1926,7 @@ mod authz_tests { is_admin: false, is_banned: false, email_verified: true, + ver: 0, }) }