diff --git a/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json b/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json deleted file mode 100644 index fbbcdd7..0000000 --- a/.sqlx/query-4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Text", - "Timestamptz", - "Timestamptz", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "4dcee809896ead3de8ca0433856ed424211d79df201d08bbea0e4c576931a234" -} diff --git a/.sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json b/.sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json deleted file mode 100644 index 9c246a3..0000000 --- a/.sqlx/query-b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "session_id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "b2e1736dbe2ab9114e373353bcc299176417f3c9220025f9521591ba62928bd7" -} diff --git a/.sqlx/query-c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6.json b/.sqlx/query-c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6.json new file mode 100644 index 0000000..1e714ed --- /dev/null +++ b/.sqlx/query-c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did,\n st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at,\n k.key_bytes, k.encryption_version\n FROM used_refresh_tokens u\n JOIN session_tokens st ON st.id = u.session_id\n JOIN users us ON st.did = us.did\n JOIN user_keys k ON us.id = k.user_id\n WHERE u.refresh_jti = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "used_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "session_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "did", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "scope", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "controller_did", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "access_jti", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "refresh_jti", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "access_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "refresh_expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "key_bytes", + "type_info": "Bytea" + }, + { + "ordinal": 10, + "name": "encryption_version", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "c08e3760be75071ac5e281cb3b05b3a02dccbf35b96c3d50c652878a38c9f9e6" +} diff --git a/.sqlx/query-c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379.json b/.sqlx/query-c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379.json deleted file mode 100644 index c70513a..0000000 --- a/.sqlx/query-c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO used_refresh_tokens (refresh_jti, session_id)\n VALUES ($1, $2)\n ON CONFLICT (refresh_jti) DO NOTHING\n ", - "describe": { - "columns": [], - "parameters": { - "Left": [ - "Text", - "Int4" - ] - }, - "nullable": [] - }, - "hash": "c0a7b73070e6c5b9baa9caddca3c5d24427b48ed02b5cac913a0c0af1e5c9379" -} diff --git a/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json b/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json deleted file mode 100644 index c39151a..0000000 --- a/.sqlx/query-fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "session_id", - "type_info": "Int4" - } - ], - "parameters": { - "Left": [ - "Text" - ] - }, - "nullable": [ - false - ] - }, - "hash": "fcd868a192d27fd4eccae92a884e881b8d6f09bf7ae08a9b431a44acbf2f91f3" -} diff --git a/crates/tranquil-api/src/server/session.rs b/crates/tranquil-api/src/server/session.rs index 42931d0..74ab9cd 100644 --- a/crates/tranquil-api/src/server/session.rs +++ b/crates/tranquil-api/src/server/session.rs @@ -505,16 +505,72 @@ pub async fn refresh_session( ))); } }; - if let Ok(Some(_)) = state - .repos - .session - .check_refresh_token_used(&refresh_jti) - .await - { - warn!("Refresh token reuse detected for jti: {}", refresh_jti); - return Err(ApiError::AuthenticationFailed(Some( - "Refresh token has been revoked due to suspected compromise".into(), - ))); + match state.repos.session.lookup_refresh_grace(&refresh_jti).await { + Ok(tranquil_db_traits::RefreshGraceLookup::NotUsed) => {} + Ok(tranquil_db_traits::RefreshGraceLookup::Replay(replay)) => { + // Verify the presented token's signature before issuing anything: a + // rotated jti is a public value, so we must never mint for a forged + // unsigned token carrying it. + let key = match tranquil_pds::config::decrypt_key( + &replay.key_bytes, + Some(replay.encryption_version), + ) { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key for grace replay: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + if tranquil_pds::auth::verify_refresh_token(&refresh_token, &key).is_err() { + return Err(ApiError::AuthenticationFailed(Some( + "Invalid refresh token".into(), + ))); + } + info!( + "Refresh token reuse within grace window for jti: {refresh_jti}; replaying tokens" + ); + let (access_jwt, refresh_jwt) = remint_grace_tokens(&replay, &key)?; + return build_refresh_session_output(&state, replay.did, access_jwt, refresh_jwt).await; + } + Ok(tranquil_db_traits::RefreshGraceLookup::Compromised { + session_id, + key_bytes, + encryption_version, + }) => { + // Never revoke a session for an unverified token: verify the + // signature first, so a forged unsigned token cannot force a logout. + let key = match tranquil_pds::config::decrypt_key(&key_bytes, Some(encryption_version)) + { + Ok(k) => k, + Err(e) => { + error!("Failed to decrypt user key for grace check: {:?}", e); + return Err(ApiError::InternalError(None)); + } + }; + if tranquil_pds::auth::verify_refresh_token(&refresh_token, &key).is_err() { + return Err(ApiError::AuthenticationFailed(Some( + "Invalid refresh token".into(), + ))); + } + warn!( + "Refresh token reuse outside grace window for jti: {refresh_jti}; revoking session" + ); + if let Err(e) = state.repos.session.delete_session_by_id(session_id).await { + error!( + "Failed to revoke session {} for refresh token reuse: {:?}", + session_id.as_i32(), + e + ); + return Err(ApiError::InternalError(None)); + } + return Err(ApiError::AuthenticationFailed(Some( + "Refresh token has been revoked due to suspected compromise".into(), + ))); + } + Err(e) => { + error!("Database error checking refresh token grace: {:?}", e); + return Err(ApiError::InternalError(None)); + } } let session_row = match state .repos @@ -579,22 +635,27 @@ pub async fn refresh_session( new_access_expires_at: new_access_meta.expires_at, new_refresh_expires_at: new_refresh_meta.expires_at, }; - match state + let (access_jwt, refresh_jwt) = match state .repos .session .refresh_session_atomic(&refresh_data) .await { - Ok(tranquil_db_traits::RefreshSessionResult::Success) => {} - Ok(tranquil_db_traits::RefreshSessionResult::TokenAlreadyUsed) => { - warn!("Refresh token reuse detected during atomic operation"); - return Err(ApiError::AuthenticationFailed(Some( - "Refresh token has been revoked due to suspected compromise".into(), - ))); + Ok(tranquil_db_traits::RefreshSessionResult::Success) => { + (new_access_meta.token, new_refresh_meta.token) } - Ok(tranquil_db_traits::RefreshSessionResult::ConcurrentRefresh) => { + Ok(tranquil_db_traits::RefreshSessionResult::GraceReplay(replay)) => { + // Lost a benign concurrent rotation; re-mint the winner's tokens + // using this session's signing key (same user, unchanged by rotation). + info!( + "Concurrent refresh within grace window for session_id: {}; replaying tokens", + session_row.id + ); + remint_grace_tokens(&replay, &key_bytes)? + } + Ok(tranquil_db_traits::RefreshSessionResult::Compromise) => { warn!( - "Concurrent refresh detected for session_id: {}", + "Refresh token reuse outside grace window or unreplayable rotation conflict for session_id: {}", session_row.id ); return Err(ApiError::AuthenticationFailed(Some( @@ -605,12 +666,55 @@ pub async fn refresh_session( error!("Database error during session refresh: {:?}", e); return Err(ApiError::InternalError(None)); } - } - let did_for_doc = session_row.did.clone(); + }; + build_refresh_session_output(&state, session_row.did, access_jwt, refresh_jwt).await +} + +/// Re-mint the access/refresh JWTs for a grace-window replay from the session's +/// current jtis and signing key. We never persist the signed JWTs; they are +/// reconstructed on demand so a benignly-racing client converges on the same +/// credentials the winning rotation produced. `key_bytes` is the owning user's +/// already-decrypted signing key. +fn remint_grace_tokens( + replay: &tranquil_db_traits::RefreshGraceReplay, + key_bytes: &[u8], +) -> Result<(String, String), ApiError> { + let access_jwt = tranquil_pds::auth::create_access_token_with_jti( + &replay.did, + key_bytes, + replay.scope.as_deref(), + replay.controller_did.as_deref(), + None, + &replay.access_jti, + replay.access_expires_at, + ) + .map_err(|e| { + error!("Failed to re-mint access token for grace replay: {:?}", e); + ApiError::InternalError(None) + })?; + let refresh_jwt = tranquil_pds::auth::create_refresh_token_with_jti( + &replay.did, + key_bytes, + &replay.refresh_jti, + replay.refresh_expires_at, + ) + .map_err(|e| { + error!("Failed to re-mint refresh token for grace replay: {:?}", e); + ApiError::InternalError(None) + })?; + Ok((access_jwt, refresh_jwt)) +} + +async fn build_refresh_session_output( + state: &AppState, + did: Did, + access_jwt: String, + refresh_jwt: String, +) -> Result, ApiError> { let did_resolver = state.did_resolver.clone(); let (db_result, did_doc) = tokio::join!( - state.repos.user.get_session_info_by_did(&session_row.did), - did_resolver.fetch_did_document(&did_for_doc) + state.repos.user.get_session_info_by_did(&did), + did_resolver.fetch_did_document(&did) ); match db_result { Ok(Some(u)) => { @@ -621,10 +725,10 @@ pub async fn refresh_session( let account_state = AccountState::from_db_fields(u.deactivated_at, u.takedown_ref.clone(), None, None); Ok(Json(RefreshSessionOutput { - access_jwt: new_access_meta.token, - refresh_jwt: new_refresh_meta.token, + access_jwt, + refresh_jwt, handle, - did: session_row.did, + did, email: u.email, email_confirmed: u.channel_verification.email, preferred_channel: u.preferred_comms_channel.as_str().to_string(), @@ -637,7 +741,7 @@ pub async fn refresh_session( })) } Ok(None) => { - error!("User not found for existing session: {}", session_row.did); + error!("User not found for existing session: {}", did); Err(ApiError::InternalError(None)) } Err(e) => { diff --git a/crates/tranquil-auth/src/lib.rs b/crates/tranquil-auth/src/lib.rs index cbfe0c8..d213dc9 100644 --- a/crates/tranquil-auth/src/lib.rs +++ b/crates/tranquil-auth/src/lib.rs @@ -5,10 +5,11 @@ mod verify; pub use token::{ create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata, - create_access_token_with_delegation, create_access_token_with_metadata, - create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256, - create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata, - create_service_token, create_service_token_hs256, + create_access_token_with_delegation, create_access_token_with_jti, + create_access_token_with_metadata, create_access_token_with_scope_metadata, + create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata, + create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token, + create_service_token_hs256, }; pub use totp::{ diff --git a/crates/tranquil-auth/src/token.rs b/crates/tranquil-auth/src/token.rs index fc45644..bb39846 100644 --- a/crates/tranquil-auth/src/token.rs +++ b/crates/tranquil-auth/src/token.rs @@ -4,7 +4,7 @@ use super::types::{ use anyhow::Result; use base64::Engine as _; use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use chrono::{Duration, Utc}; +use chrono::{DateTime, Duration, Utc}; use hmac::{Hmac, Mac}; use k256::ecdsa::{Signature, SigningKey, signature::Signer}; use sha2::Sha256; @@ -74,6 +74,54 @@ pub fn create_refresh_token_with_metadata( ) } +/// Re-mint an access token carrying a specific `jti` and expiry. Used by the +/// refresh grace window to reproduce a session's current access token without +/// persisting the signed JWT itself. +pub fn create_access_token_with_jti( + did: &str, + key_bytes: &[u8], + scopes: Option<&str>, + controller_did: Option<&str>, + hostname: Option<&str>, + jti: &str, + expires_at: DateTime, +) -> Result { + let scope = scopes.unwrap_or(TokenScope::Access.as_str()); + let act = controller_did.map(|c| ActClaim { sub: c.to_string() }); + Ok(create_signed_token_pinned( + did, + scope, + TokenType::Access, + key_bytes, + expires_at, + jti.to_string(), + act, + hostname, + )? + .token) +} + +/// Re-mint a refresh token carrying a specific `jti` and expiry. Counterpart to +/// [`create_access_token_with_jti`] for the refresh grace window. +pub fn create_refresh_token_with_jti( + did: &str, + key_bytes: &[u8], + jti: &str, + expires_at: DateTime, +) -> Result { + Ok(create_signed_token_pinned( + did, + TokenScope::Refresh.as_str(), + TokenType::Refresh, + key_bytes, + expires_at, + jti.to_string(), + None, + None, + )? + .token) +} + pub fn create_service_token( did: &str, aud: &str, @@ -122,14 +170,27 @@ fn create_signed_token_with_act( act: Option, hostname: Option<&str>, ) -> Result { - let signing_key = SigningKey::from_slice(key_bytes)?; - let expires_at = Utc::now() .checked_add_signed(duration) .expect("valid timestamp"); + let jti = uuid::Uuid::new_v4().to_string(); + create_signed_token_pinned(did, scope, typ, key_bytes, expires_at, jti, act, hostname) +} + +#[allow(clippy::too_many_arguments)] +fn create_signed_token_pinned( + did: &str, + scope: &str, + typ: TokenType, + key_bytes: &[u8], + expires_at: DateTime, + jti: String, + act: Option, + hostname: Option<&str>, +) -> Result { + let signing_key = SigningKey::from_slice(key_bytes)?; let expiration = expires_at.timestamp(); - let jti = uuid::Uuid::new_v4().to_string(); let aud_hostname = hostname.map(|h| h.to_string()).unwrap_or_else(|| { tranquil_config::try_get() diff --git a/crates/tranquil-db-traits/src/lib.rs b/crates/tranquil-db-traits/src/lib.rs index 9d5c53e..c628930 100644 --- a/crates/tranquil-db-traits/src/lib.rs +++ b/crates/tranquil-db-traits/src/lib.rs @@ -43,7 +43,8 @@ pub use repo::{ pub use scope::{DbScope, InvalidScopeError}; pub use sequence::{SequenceNumber, deserialize_optional_sequence}; pub use session::{ - AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, RefreshSessionResult, + AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, LoginType, + REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, }; diff --git a/crates/tranquil-db-traits/src/session.rs b/crates/tranquil-db-traits/src/session.rs index 69e6af3..ab589b6 100644 --- a/crates/tranquil-db-traits/src/session.rs +++ b/crates/tranquil-db-traits/src/session.rs @@ -158,11 +158,47 @@ pub struct SessionMfaStatus { pub last_reauth_at: Option>, } +/// Window during which a just-rotated refresh token may still be presented by a +/// benignly-racing or retrying client without being treated as a compromise. +/// The window is measured per rotated token from its own rotation time +/// (`used_refresh_tokens.used_at` / the metastore marker's `rotated_at_ms`), +/// matching the reference atproto PDS `REFRESH_GRACE_MS` +/// (`account-manager.ts::rotateRefreshToken`), which shortens a rotated refresh +/// token's lifetime to a 2-hour revocation grace period. +pub const REFRESH_GRACE_PERIOD_SECS: i64 = 2 * 60 * 60; + +/// The session's current token identity (plus its signing key) needed to re-mint +/// the access/refresh JWTs during a grace-window replay. We carry the key so the +/// caller can verify the presented token's signature before issuing anything. +#[derive(Debug, Clone)] +pub struct RefreshGraceReplay { + pub did: Did, + pub scope: Option, + pub controller_did: Option, + pub access_jti: String, + pub refresh_jti: String, + pub access_expires_at: DateTime, + pub refresh_expires_at: DateTime, + pub key_bytes: Vec, + pub encryption_version: i32, +} + #[derive(Debug, Clone)] pub enum RefreshSessionResult { Success, - TokenAlreadyUsed, - ConcurrentRefresh, + GraceReplay(RefreshGraceReplay), + Compromise, +} + +#[derive(Debug, Clone)] +pub enum RefreshGraceLookup { + NotUsed, + Replay(RefreshGraceReplay), + Compromised { + session_id: SessionId, + key_bytes: Vec, + encryption_version: i32, + }, } #[derive(Debug, Clone)] @@ -230,16 +266,7 @@ pub trait SessionRepository: Send + Sync { app_password_name: &str, ) -> Result, DbError>; - async fn check_refresh_token_used( - &self, - refresh_jti: &str, - ) -> Result, DbError>; - - async fn mark_refresh_token_used( - &self, - refresh_jti: &str, - session_id: SessionId, - ) -> Result; + async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result; async fn list_app_passwords(&self, user_id: Uuid) -> Result, DbError>; diff --git a/crates/tranquil-db/src/postgres/session.rs b/crates/tranquil-db/src/postgres/session.rs index 0f1702c..bbb24f2 100644 --- a/crates/tranquil-db/src/postgres/session.rs +++ b/crates/tranquil-db/src/postgres/session.rs @@ -1,10 +1,11 @@ use async_trait::async_trait; -use chrono::{DateTime, Utc}; +use chrono::{DateTime, Duration, Utc}; use sqlx::PgPool; use tranquil_db_traits::{ AppPasswordCreate, AppPasswordPrivilege, AppPasswordRecord, DbError, LoginType, - RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, - SessionRefreshData, SessionRepository, SessionToken, SessionTokenCreate, + REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshGraceReplay, RefreshSessionResult, + SessionForRefresh, SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, + SessionRepository, SessionToken, SessionTokenCreate, }; use tranquil_types::Did; use uuid::Uuid; @@ -267,40 +268,51 @@ impl SessionRepository for PostgresSessionRepository { Ok(rows) } - async fn check_refresh_token_used( - &self, - refresh_jti: &str, - ) -> Result, DbError> { - let row = sqlx::query_scalar!( - "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1", + async fn lookup_refresh_grace(&self, refresh_jti: &str) -> Result { + let row = sqlx::query!( + r#" + SELECT u.used_at, st.id AS session_id, st.did, st.scope, st.controller_did, + st.access_jti, st.refresh_jti, st.access_expires_at, st.refresh_expires_at, + k.key_bytes, k.encryption_version + FROM used_refresh_tokens u + JOIN session_tokens st ON st.id = u.session_id + JOIN users us ON st.did = us.did + JOIN user_keys k ON us.id = k.user_id + WHERE u.refresh_jti = $1 + "#, refresh_jti ) .fetch_optional(&self.pool) .await .map_err(map_sqlx_error)?; - Ok(row.map(SessionId::new)) - } + // No marker (or a missing users/user_keys join row) degrades to NotUsed. + // That is safe: the normal refresh path then fails closed with "Invalid + // refresh token" without mutating any state. + let Some(r) = row else { + return Ok(RefreshGraceLookup::NotUsed); + }; - async fn mark_refresh_token_used( - &self, - refresh_jti: &str, - session_id: SessionId, - ) -> Result { - let result = sqlx::query!( - r#" - INSERT INTO used_refresh_tokens (refresh_jti, session_id) - VALUES ($1, $2) - ON CONFLICT (refresh_jti) DO NOTHING - "#, - refresh_jti, - session_id.as_i32() - ) - .execute(&self.pool) - .await - .map_err(map_sqlx_error)?; - - Ok(result.rows_affected() > 0) + let grace_cutoff = Utc::now() - Duration::seconds(REFRESH_GRACE_PERIOD_SECS); + if r.used_at > grace_cutoff { + Ok(RefreshGraceLookup::Replay(RefreshGraceReplay { + did: Did::from(r.did), + scope: r.scope, + controller_did: r.controller_did.map(Did::from), + access_jti: r.access_jti, + refresh_jti: r.refresh_jti, + access_expires_at: r.access_expires_at, + refresh_expires_at: r.refresh_expires_at, + key_bytes: r.key_bytes, + encryption_version: r.encryption_version.unwrap_or(0), + })) + } else { + Ok(RefreshGraceLookup::Compromised { + session_id: SessionId::new(r.session_id), + key_bytes: r.key_bytes, + encryption_version: r.encryption_version.unwrap_or(0), + }) + } } async fn list_app_passwords(&self, user_id: Uuid) -> Result, DbError> { @@ -524,21 +536,10 @@ impl SessionRepository for PostgresSessionRepository { ) -> Result { let mut tx = self.pool.begin().await.map_err(map_sqlx_error)?; - if let Ok(Some(session_id)) = sqlx::query_scalar!( - "SELECT session_id FROM used_refresh_tokens WHERE refresh_jti = $1 FOR UPDATE", - data.old_refresh_jti - ) - .fetch_optional(&mut *tx) - .await - { - let _ = sqlx::query!("DELETE FROM session_tokens WHERE id = $1", session_id) - .execute(&mut *tx) - .await; - tx.commit().await.map_err(map_sqlx_error)?; - return Ok(RefreshSessionResult::TokenAlreadyUsed); - } - - let result = sqlx::query!( + // Atomically claim the old refresh jti. The INSERT serializes concurrent + // rotations of the same token: exactly one request inserts the row, the + // rest see `rows_affected == 0`. + let claimed = sqlx::query!( "INSERT INTO used_refresh_tokens (refresh_jti, session_id) VALUES ($1, $2) ON CONFLICT (refresh_jti) DO NOTHING", data.old_refresh_jti, data.session_id.as_i32() @@ -547,19 +548,42 @@ impl SessionRepository for PostgresSessionRepository { .await .map_err(map_sqlx_error)?; - if result.rows_affected() == 0 { - let _ = sqlx::query!( - "DELETE FROM session_tokens WHERE id = $1", - data.session_id.as_i32() - ) - .execute(&mut *tx) - .await; - tx.commit().await.map_err(map_sqlx_error)?; - return Ok(RefreshSessionResult::ConcurrentRefresh); + if claimed.rows_affected() == 0 { + // Another request already rotated this token. Nothing to write, so + // end our transaction before reading the winner's committed row. + tx.rollback().await.map_err(map_sqlx_error)?; + + // Within the grace window (measured from this token's own rotation + // time) we replay the session's current tokens so a benignly-racing + // client keeps a working session instead of being revoked. + match self.lookup_refresh_grace(&data.old_refresh_jti).await? { + RefreshGraceLookup::Replay(replay) => { + return Ok(RefreshSessionResult::GraceReplay(replay)); + } + RefreshGraceLookup::Compromised { .. } | RefreshGraceLookup::NotUsed => { + // Outside the grace window, or the marker/session vanished + // concurrently: genuine reuse. Revoke the session (delete is + // idempotent). + sqlx::query!( + "DELETE FROM session_tokens WHERE id = $1", + data.session_id.as_i32() + ) + .execute(&self.pool) + .await + .map_err(map_sqlx_error)?; + return Ok(RefreshSessionResult::Compromise); + } + } } + // We won the rotation. sqlx::query!( - "UPDATE session_tokens SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, refresh_expires_at = $4, updated_at = NOW() WHERE id = $5", + r#" + UPDATE session_tokens + SET access_jti = $1, refresh_jti = $2, access_expires_at = $3, + refresh_expires_at = $4, updated_at = NOW() + WHERE id = $5 + "#, data.new_access_jti, data.new_refresh_jti, data.new_access_expires_at, diff --git a/crates/tranquil-pds/src/auth/mod.rs b/crates/tranquil-pds/src/auth/mod.rs index 99fe5ad..51f98c0 100644 --- a/crates/tranquil-pds/src/auth/mod.rs +++ b/crates/tranquil-pds/src/auth/mod.rs @@ -46,14 +46,15 @@ pub use tranquil_auth::{ ActClaim, Claims, Header, SigningAlgorithm, TokenData, TokenDecodeError, TokenScope, TokenType, TokenVerifyError, TokenWithMetadata, TotpError, UnsafeClaims, create_access_token, create_access_token_hs256, create_access_token_hs256_with_metadata, - create_access_token_with_delegation, create_access_token_with_metadata, - create_access_token_with_scope_metadata, create_refresh_token, create_refresh_token_hs256, - create_refresh_token_hs256_with_metadata, create_refresh_token_with_metadata, - create_service_token, create_service_token_hs256, generate_backup_codes, - generate_qr_png_base64, generate_totp_secret, generate_totp_uri, get_algorithm_from_token, - get_did_from_token, get_jti_from_token, hash_backup_code, is_backup_code_format, - verify_access_token, verify_access_token_hs256, verify_access_token_typed, verify_backup_code, - verify_refresh_token, verify_refresh_token_hs256, verify_token, verify_totp_code, + create_access_token_with_delegation, create_access_token_with_jti, + create_access_token_with_metadata, create_access_token_with_scope_metadata, + create_refresh_token, create_refresh_token_hs256, create_refresh_token_hs256_with_metadata, + create_refresh_token_with_jti, create_refresh_token_with_metadata, create_service_token, + create_service_token_hs256, generate_backup_codes, generate_qr_png_base64, + generate_totp_secret, generate_totp_uri, get_algorithm_from_token, get_did_from_token, + get_jti_from_token, hash_backup_code, is_backup_code_format, verify_access_token, + verify_access_token_hs256, verify_access_token_typed, verify_backup_code, verify_refresh_token, + verify_refresh_token_hs256, verify_token, verify_totp_code, }; pub fn lxm_permits(lxm: &str, expected: &str) -> bool { diff --git a/crates/tranquil-pds/tests/jwt_security.rs b/crates/tranquil-pds/tests/jwt_security.rs index d873aa7..aaebb16 100644 --- a/crates/tranquil-pds/tests/jwt_security.rs +++ b/crates/tranquil-pds/tests/jwt_security.rs @@ -673,8 +673,12 @@ async fn test_deactivated_account_behavior() { assert_eq!(post_body["error"], "AccountDeactivated"); } +// End-to-end regression for the legacy refresh grace window and the +// verify-before-mutate fixes: a signed replay within the 2h grace window converges +// on the winner's current tokens, while a forged replay is rejected without +// destroying the session. #[tokio::test] -async fn test_refresh_token_replay_protection() { +async fn test_refresh_token_replay_grace_and_forgery() { let url = base_url().await; let http_client = client(); let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8]; @@ -728,6 +732,7 @@ async fn test_refresh_token_replay_protection() { let confirmed: Value = confirm.json().await.unwrap(); let refresh_jwt = confirmed["refreshJwt"].as_str().unwrap().to_string(); + // 1. Rotate refresh_jwt once; capture the winner's tokens. let first = http_client .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) .header("Authorization", format!("Bearer {}", refresh_jwt)) @@ -735,12 +740,74 @@ async fn test_refresh_token_replay_protection() { .await .unwrap(); assert_eq!(first.status(), StatusCode::OK); + let first_body: Value = first.json().await.unwrap(); + let winner_refresh = first_body["refreshJwt"].as_str().unwrap().to_string(); + let winner_access = first_body["accessJwt"].as_str().unwrap().to_string(); + // 2. Signed replay of the original (now-rotated) token within the grace + // window: 200, returning the session's current tokens. The re-minted JWTs are + // not byte-identical to the winner's because the pinned claims still carry a + // fresh `iat` (see create_signed_token_pinned), so assert they VERIFY and WORK + // instead: the returned refreshJwt drives a subsequent refresh. let replay = http_client .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) .header("Authorization", format!("Bearer {}", refresh_jwt)) .send() .await .unwrap(); - assert_eq!(replay.status(), StatusCode::UNAUTHORIZED); + assert_eq!(replay.status(), StatusCode::OK); + let replay_body: Value = replay.json().await.unwrap(); + let replayed_refresh = replay_body["refreshJwt"].as_str().unwrap().to_string(); + let replayed_access = replay_body["accessJwt"].as_str().unwrap().to_string(); + + // The replayed access token authenticates. + let who = http_client + .get(format!("{}/xrpc/com.atproto.server.getSession", url)) + .header("Authorization", format!("Bearer {}", replayed_access)) + .send() + .await + .unwrap(); + assert_eq!(who.status(), StatusCode::OK); + + // The replayed refresh token rotates the session forward (200), proving the + // grace replay handed back the live session's current credentials. + let from_replay = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + .header("Authorization", format!("Bearer {}", replayed_refresh)) + .send() + .await + .unwrap(); + assert_eq!(from_replay.status(), StatusCode::OK); + let from_replay_body: Value = from_replay.json().await.unwrap(); + let current_refresh = from_replay_body["refreshJwt"].as_str().unwrap().to_string(); + assert!(!winner_refresh.is_empty() && !winner_access.is_empty()); + + // 3. Forged replay: tamper the original token's signature segment with + // same-length valid base64url garbage. Must be rejected (401) WITHOUT + // destroying the session. + let parts: Vec<&str> = refresh_jwt.split('.').collect(); + assert_eq!(parts.len(), 3); + let garbage_sig: String = parts[2] + .chars() + .map(|c| if c == 'A' { 'B' } else { 'A' }) + .collect(); + assert_eq!(garbage_sig.len(), parts[2].len()); + let forged = format!("{}.{}.{}", parts[0], parts[1], garbage_sig); + + let forged_res = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + .header("Authorization", format!("Bearer {}", forged)) + .send() + .await + .unwrap(); + assert_eq!(forged_res.status(), StatusCode::UNAUTHORIZED); + + // The session survived the forgery: the current refresh token still works. + let after_forge = http_client + .post(format!("{}/xrpc/com.atproto.server.refreshSession", url)) + .header("Authorization", format!("Bearer {}", current_refresh)) + .send() + .await + .unwrap(); + assert_eq!(after_forge.status(), StatusCode::OK); } diff --git a/crates/tranquil-pds/tests/lifecycle_session.rs b/crates/tranquil-pds/tests/lifecycle_session.rs index 8285d82..ec46c7e 100644 --- a/crates/tranquil-pds/tests/lifecycle_session.rs +++ b/crates/tranquil-pds/tests/lifecycle_session.rs @@ -158,6 +158,9 @@ async fn test_session_lifecycle_refresh_invalidates_old() { let refresh_body: Value = refresh_res.json().await.unwrap(); let new_refresh_jwt = refresh_body["refreshJwt"].as_str().unwrap(); assert_ne!(refresh_jwt, new_refresh_jwt, "Refresh tokens should differ"); + // A signed reuse within the grace window is benign: it replays the session's + // current tokens rather than revoking. The deep assertions live in + // jwt_security.rs::test_refresh_token_replay_grace_and_forgery. let reuse_res = client .post(format!( "{}/xrpc/com.atproto.server.refreshSession", @@ -167,11 +170,24 @@ async fn test_session_lifecycle_refresh_invalidates_old() { .send() .await .expect("Failed reuse attempt"); - assert!( - reuse_res.status() == StatusCode::UNAUTHORIZED - || reuse_res.status() == StatusCode::BAD_REQUEST, - "Old refresh token should be invalid after use" + assert_eq!( + reuse_res.status(), + StatusCode::OK, + "Signed reuse within grace replays the session" ); + let reuse_body: Value = reuse_res.json().await.unwrap(); + let replayed_refresh = reuse_body["refreshJwt"].as_str().unwrap(); + // The replayed refresh token works for a subsequent refresh. + let followup = client + .post(format!( + "{}/xrpc/com.atproto.server.refreshSession", + base_url().await + )) + .bearer_auth(replayed_refresh) + .send() + .await + .expect("Failed followup refresh"); + assert_eq!(followup.status(), StatusCode::OK); } #[tokio::test] diff --git a/crates/tranquil-store/src/metastore/client.rs b/crates/tranquil-store/src/metastore/client.rs index c38e586..7e70c89 100644 --- a/crates/tranquil-store/src/metastore/client.rs +++ b/crates/tranquil-store/src/metastore/client.rs @@ -1596,13 +1596,13 @@ impl tranquil_db_traits::SessionRepository for Metastore recv(rx).await } - async fn check_refresh_token_used( + async fn lookup_refresh_grace( &self, refresh_jti: &str, - ) -> Result, DbError> { + ) -> Result { let (tx, rx) = oneshot::channel(); self.pool.send(MetastoreRequest::Session( - SessionRequest::CheckRefreshTokenUsed { + SessionRequest::LookupRefreshGrace { refresh_jti: refresh_jti.to_owned(), tx, }, @@ -1610,22 +1610,6 @@ impl tranquil_db_traits::SessionRepository for Metastore recv(rx).await } - async fn mark_refresh_token_used( - &self, - refresh_jti: &str, - session_id: tranquil_db_traits::SessionId, - ) -> Result { - let (tx, rx) = oneshot::channel(); - self.pool.send(MetastoreRequest::Session( - SessionRequest::MarkRefreshTokenUsed { - refresh_jti: refresh_jti.to_owned(), - session_id, - tx, - }, - ))?; - recv(rx).await - } - async fn list_app_passwords( &self, user_id: Uuid, diff --git a/crates/tranquil-store/src/metastore/handler.rs b/crates/tranquil-store/src/metastore/handler.rs index fea5b39..2edd004 100644 --- a/crates/tranquil-store/src/metastore/handler.rs +++ b/crates/tranquil-store/src/metastore/handler.rs @@ -892,14 +892,9 @@ pub enum SessionRequest { app_password_name: String, tx: Tx>, }, - CheckRefreshTokenUsed { + LookupRefreshGrace { refresh_jti: String, - tx: Tx>, - }, - MarkRefreshTokenUsed { - refresh_jti: String, - session_id: SessionId, - tx: Tx, + tx: Tx, }, ListAppPasswords { user_id: Uuid, @@ -960,8 +955,7 @@ impl SessionRequest { Self::CreateSession { .. } | Self::GetSessionByAccessJti { .. } | Self::GetSessionForRefresh { .. } - | Self::CheckRefreshTokenUsed { .. } - | Self::MarkRefreshTokenUsed { .. } + | Self::LookupRefreshGrace { .. } | Self::DeleteSessionByAccessJti { .. } | Self::DeleteSessionById { .. } => Routing::Global, Self::DeleteSessionsByDid { did, .. } @@ -3744,23 +3738,11 @@ fn dispatch_session(state: &HandlerState, req: SessionRequest) .map_err(metastore_to_db); let _ = tx.send(result); } - SessionRequest::CheckRefreshTokenUsed { refresh_jti, tx } => { + SessionRequest::LookupRefreshGrace { refresh_jti, tx } => { let result = state .metastore .session_ops() - .check_refresh_token_used(&refresh_jti) - .map_err(metastore_to_db); - let _ = tx.send(result); - } - SessionRequest::MarkRefreshTokenUsed { - refresh_jti, - session_id, - tx, - } => { - let result = state - .metastore - .session_ops() - .mark_refresh_token_used(&refresh_jti, session_id) + .lookup_refresh_grace(&refresh_jti) .map_err(metastore_to_db); let _ = tx.send(result); } diff --git a/crates/tranquil-store/src/metastore/mod.rs b/crates/tranquil-store/src/metastore/mod.rs index 9132f34..511261a 100644 --- a/crates/tranquil-store/src/metastore/mod.rs +++ b/crates/tranquil-store/src/metastore/mod.rs @@ -429,6 +429,245 @@ mod tests { }); } + fn legacy_session_create( + did: &str, + access_jti: &str, + refresh_jti: &str, + ) -> tranquil_db_traits::SessionTokenCreate { + let now = chrono::Utc::now(); + tranquil_db_traits::SessionTokenCreate { + did: tranquil_types::Did::new(did.to_string()).unwrap(), + access_jti: access_jti.to_string(), + refresh_jti: refresh_jti.to_string(), + access_expires_at: now + chrono::Duration::minutes(120), + refresh_expires_at: now + chrono::Duration::days(90), + login_type: tranquil_db_traits::LoginType::Legacy, + mfa_verified: false, + scope: None, + controller_did: None, + app_password_name: None, + } + } + + fn create_test_user(ms: &Metastore, did: &str, handle: &str) { + let input = tranquil_db_traits::CreatePasswordAccountInput { + handle: tranquil_types::Handle::new(handle.to_string()).unwrap(), + email: None, + did: tranquil_types::Did::new(did.to_string()).unwrap(), + password_hash: "test-hash".to_string(), + preferred_comms_channel: tranquil_db_traits::CommsChannel::Email, + discord_username: None, + telegram_username: None, + signal_username: None, + deactivated_at: None, + inbound_migration: false, + encrypted_key_bytes: vec![7u8; 32], + encryption_version: 0, + reserved_key_id: None, + commit_cid: "bafyreib2rxk3ryblouj3fxza5jvx6psmwewwessc4m6g6e7pqhhkwqomfi".to_string(), + repo_rev: "rev0".to_string(), + genesis_block_cids: vec![], + invite_code: None, + birthdate_pref: None, + }; + ms.user_ops().create_password_account(&input).unwrap(); + } + + fn legacy_refresh_data( + session_id: tranquil_db_traits::SessionId, + old_refresh_jti: &str, + new_access_jti: &str, + new_refresh_jti: &str, + ) -> tranquil_db_traits::SessionRefreshData { + let now = chrono::Utc::now(); + tranquil_db_traits::SessionRefreshData { + old_refresh_jti: old_refresh_jti.to_string(), + session_id, + new_access_jti: new_access_jti.to_string(), + new_refresh_jti: new_refresh_jti.to_string(), + new_access_expires_at: now + chrono::Duration::minutes(120), + new_refresh_expires_at: now + chrono::Duration::days(90), + } + } + + #[test] + fn legacy_refresh_grace_replays_within_window() { + use tranquil_db_traits::{RefreshGraceLookup, RefreshSessionResult}; + let (_dir, ms) = open_fresh(); + create_test_user(&ms, "did:plc:grace", "grace.test"); + let ops = ms.session_ops(); + + let session_id = ops + .create_session(&legacy_session_create("did:plc:grace", "acc0", "ref0")) + .unwrap(); + + // The winning request rotates ref0 -> ref1. + let win = legacy_refresh_data(session_id, "ref0", "acc1", "ref1"); + assert!(matches!( + ops.refresh_session_atomic(&win).unwrap(), + RefreshSessionResult::Success + )); + + // A racing client re-presents ref0; the up-front grace lookup points at + // the session's current tokens for re-minting (jtis, not signed JWTs), + // carrying the signing key so the caller can verify the presented token. + match ops.lookup_refresh_grace("ref0").unwrap() { + RefreshGraceLookup::Replay(replay) => { + assert_eq!(replay.did.as_str(), "did:plc:grace"); + assert_eq!(replay.access_jti, "acc1"); + assert_eq!(replay.refresh_jti, "ref1"); + assert_eq!(replay.key_bytes, vec![7u8; 32]); + } + other => panic!("expected Replay, got {other:?}"), + } + + // The atomic path (two requests both past the used-check) also yields + // the winner's current tokens rather than revoking. + let lose = legacy_refresh_data(session_id, "ref0", "accX", "refX"); + match ops.refresh_session_atomic(&lose).unwrap() { + RefreshSessionResult::GraceReplay(replay) => { + assert_eq!(replay.access_jti, "acc1"); + assert_eq!(replay.refresh_jti, "ref1"); + } + other => panic!("expected GraceReplay, got {other:?}"), + } + + // Crucially, the session is still alive on the winner's tokens — nobody + // got logged out. + let alive = ops.get_session_by_access_jti("acc1").unwrap(); + assert!( + alive.is_some(), + "session must survive a benign concurrent refresh" + ); + assert_eq!(alive.unwrap().refresh_jti, "ref1"); + } + + // Per-token grace: a token two rotations stale but rotated moments ago is + // still within its own grace window, so it must replay the session's CURRENT + // tokens (acc2/ref2) rather than revoking. Regression for the defect where + // only the immediate predecessor was replayable. + #[test] + fn legacy_refresh_superseded_token_within_grace_replays() { + use tranquil_db_traits::{RefreshGraceLookup, RefreshSessionResult}; + let (_dir, ms) = open_fresh(); + create_test_user(&ms, "did:plc:reuse", "reuse.test"); + let ops = ms.session_ops(); + + let session_id = ops + .create_session(&legacy_session_create("did:plc:reuse", "acc0", "ref0")) + .unwrap(); + ops.refresh_session_atomic(&legacy_refresh_data(session_id, "ref0", "acc1", "ref1")) + .unwrap(); + ops.refresh_session_atomic(&legacy_refresh_data(session_id, "ref1", "acc2", "ref2")) + .unwrap(); + + // ref0 is two rotations back but was rotated just now: still in window. + match ops.lookup_refresh_grace("ref0").unwrap() { + RefreshGraceLookup::Replay(replay) => { + assert_eq!(replay.access_jti, "acc2"); + assert_eq!(replay.refresh_jti, "ref2"); + } + other => panic!("expected Replay, got {other:?}"), + } + match ops + .refresh_session_atomic(&legacy_refresh_data(session_id, "ref0", "z", "z")) + .unwrap() + { + RefreshSessionResult::GraceReplay(replay) => { + assert_eq!(replay.access_jti, "acc2"); + assert_eq!(replay.refresh_jti, "ref2"); + } + other => panic!("expected GraceReplay, got {other:?}"), + } + // The session is still alive on the current tokens — nobody logged out. + assert!(ops.get_session_by_access_jti("acc2").unwrap().is_some()); + } + + // An old-format (12-byte, no rotated_at) used marker has unknown rotation + // time and must classify as Compromised, never Replay. + #[test] + fn legacy_old_format_marker_is_compromise() { + use tranquil_db_traits::RefreshGraceLookup; + let (_dir, ms) = open_fresh(); + create_test_user(&ms, "did:plc:oldfmt", "oldfmt.test"); + let ops = ms.session_ops(); + + let session_id = ops + .create_session(&legacy_session_create("did:plc:oldfmt", "acc0", "ref0")) + .unwrap(); + + // Write a pre-upgrade 12-byte marker (TTL prefix + session_id) directly. + let mut marker = 0u64.to_be_bytes().to_vec(); + marker.extend_from_slice(&session_id.as_i32().to_be_bytes()); + assert_eq!(marker.len(), 12); + ms.partition(Partition::Auth) + .insert( + super::sessions::session_used_refresh_key("ref0").as_slice(), + marker, + ) + .unwrap(); + + assert!(matches!( + ops.lookup_refresh_grace("ref0").unwrap(), + RefreshGraceLookup::Compromised { .. } + )); + } + + // A current-format marker whose rotation predates the grace window is genuine + // reuse: lookup classifies Compromised, the atomic path returns Compromise and + // revokes, and the session is gone afterwards. + #[test] + fn legacy_refresh_stale_rotation_outside_grace_is_compromise() { + use tranquil_db_traits::{ + REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, RefreshSessionResult, + }; + let (_dir, ms) = open_fresh(); + create_test_user(&ms, "did:plc:stale", "stale.test"); + let ops = ms.session_ops(); + + let session_id = ops + .create_session(&legacy_session_create("did:plc:stale", "acc0", "ref0")) + .unwrap(); + + // Overwrite ref0's used marker with a current 20-byte marker rotated 3h ago, + // well outside the 2h grace window. + let stale_rotated_at_ms = + (chrono::Utc::now() - chrono::Duration::hours(3)).timestamp_millis(); + let refresh_expires_at_ms = + (chrono::Utc::now() + chrono::Duration::days(90)).timestamp_millis(); + assert!( + stale_rotated_at_ms + < (chrono::Utc::now() - chrono::Duration::seconds(REFRESH_GRACE_PERIOD_SECS)) + .timestamp_millis() + ); + ms.partition(Partition::Auth) + .insert( + super::sessions::session_used_refresh_key("ref0").as_slice(), + super::sessions::serialize_used_refresh_value( + refresh_expires_at_ms, + session_id.as_i32(), + stale_rotated_at_ms, + ), + ) + .unwrap(); + + match ops.lookup_refresh_grace("ref0").unwrap() { + RefreshGraceLookup::Compromised { key_bytes, .. } => { + assert_eq!(key_bytes, vec![7u8; 32]); + } + other => panic!("expected Compromised, got {other:?}"), + } + + assert!(matches!( + ops.refresh_session_atomic(&legacy_refresh_data(session_id, "ref0", "z", "z")) + .unwrap(), + RefreshSessionResult::Compromise + )); + + // The session was actually revoked. + assert!(ops.get_session_by_access_jti("acc0").unwrap().is_none()); + } + #[test] fn reopen_preserves_partitions() { let dir = tempfile::TempDir::new().unwrap(); diff --git a/crates/tranquil-store/src/metastore/session_ops.rs b/crates/tranquil-store/src/metastore/session_ops.rs index 1138e6e..60cd655 100644 --- a/crates/tranquil-store/src/metastore/session_ops.rs +++ b/crates/tranquil-store/src/metastore/session_ops.rs @@ -20,9 +20,9 @@ use super::user_hash::UserHashMap; use super::users::UserValue; use tranquil_db_traits::{ - AppPasswordCreate, AppPasswordRecord, LoginType, RefreshSessionResult, SessionForRefresh, - SessionId, SessionListItem, SessionMfaStatus, SessionRefreshData, SessionToken, - SessionTokenCreate, + AppPasswordCreate, AppPasswordRecord, LoginType, REFRESH_GRACE_PERIOD_SECS, RefreshGraceLookup, + RefreshGraceReplay, RefreshSessionResult, SessionForRefresh, SessionId, SessionListItem, + SessionMfaStatus, SessionRefreshData, SessionToken, SessionTokenCreate, }; use tranquil_types::Did; @@ -171,6 +171,84 @@ impl SessionOps { ); } + pub fn lookup_refresh_grace( + &self, + refresh_jti: &str, + ) -> Result { + let used_key = session_used_refresh_key(refresh_jti); + let used = self + .auth + .get(used_key.as_slice()) + .map_err(MetastoreError::Fjall)?; + let Some(raw) = used else { + return Ok(RefreshGraceLookup::NotUsed); + }; + let (session_id, rotated_at_ms) = deserialize_used_refresh_value(&raw) + .ok_or(MetastoreError::CorruptData("corrupt used refresh value"))?; + + // Marker without a live session, or without a loadable user key, degrades + // to NotUsed: we cannot verify the presented token, so we never mutate. + let Some(session) = self.load_session_by_id(session_id)? else { + return Ok(RefreshGraceLookup::NotUsed); + }; + let user_hash = self.resolve_user_hash_from_did(&session.did); + let Some(user) = self.load_user_value(user_hash)? else { + return Ok(RefreshGraceLookup::NotUsed); + }; + + // Per-token grace measured from this token's own rotation time. A legacy + // marker (no rotated_at) is treated as outside the window. + let grace_cutoff_ms = + (Utc::now() - chrono::Duration::seconds(REFRESH_GRACE_PERIOD_SECS)).timestamp_millis(); + if let Some(rotated_at_ms) = rotated_at_ms + && rotated_at_ms > grace_cutoff_ms + { + return Ok(RefreshGraceLookup::Replay(self.build_grace_replay( + &session, + user.key_bytes, + user.encryption_version, + )?)); + } + + Ok(RefreshGraceLookup::Compromised { + session_id: SessionId::new(session_id), + key_bytes: user.key_bytes, + encryption_version: user.encryption_version, + }) + } + + /// Assemble the session's current token identity plus its signing key for a + /// grace-window replay. + fn build_grace_replay( + &self, + session: &SessionTokenValue, + key_bytes: Vec, + encryption_version: i32, + ) -> Result { + let did = Did::new(session.did.clone()) + .map_err(|_| MetastoreError::CorruptData("invalid session did"))?; + let access_expires_at = + DateTime::::from_timestamp_millis(session.access_expires_at_ms) + .ok_or(MetastoreError::CorruptData("invalid access expiry"))?; + let refresh_expires_at = + DateTime::::from_timestamp_millis(session.refresh_expires_at_ms) + .ok_or(MetastoreError::CorruptData("invalid refresh expiry"))?; + Ok(RefreshGraceReplay { + did, + scope: session.scope.clone(), + controller_did: session + .controller_did + .clone() + .and_then(|d| Did::new(d).ok()), + access_jti: session.access_jti.clone(), + refresh_jti: session.refresh_jti.clone(), + access_expires_at, + refresh_expires_at, + key_bytes, + encryption_version, + }) + } + fn collect_sessions_for_did( &self, user_hash: UserHash, @@ -540,51 +618,6 @@ impl SessionOps { .collect()) } - pub fn check_refresh_token_used( - &self, - refresh_jti: &str, - ) -> Result, MetastoreError> { - let key = session_used_refresh_key(refresh_jti); - match self - .auth - .get(key.as_slice()) - .map_err(MetastoreError::Fjall)? - { - Some(raw) => Ok(deserialize_used_refresh_value(&raw).map(SessionId::new)), - None => Ok(None), - } - } - - pub fn mark_refresh_token_used( - &self, - refresh_jti: &str, - session_id: SessionId, - ) -> Result { - let key = session_used_refresh_key(refresh_jti); - let existing = self - .auth - .get(key.as_slice()) - .map_err(MetastoreError::Fjall)?; - - match existing { - Some(_) => Ok(false), - None => { - let session = self.load_session_by_id(session_id.as_i32())?; - let expires_at_ms = session - .map(|s| s.refresh_expires_at_ms) - .unwrap_or(Utc::now().timestamp_millis().saturating_add(86_400_000)); - - self.auth - .insert( - key.as_slice(), - serialize_used_refresh_value(expires_at_ms, session_id.as_i32()), - ) - .map_err(MetastoreError::Fjall)?; - Ok(true) - } - } - } - pub fn list_app_passwords( &self, user_id: Uuid, @@ -837,49 +870,59 @@ impl SessionOps { .map_err(MetastoreError::Fjall)?; if already_used.is_some() { - let mut batch = self.db.batch(); - let session = self.load_session_by_id(data.session_id.as_i32())?; - if let Some(s) = session { - self.delete_session_indexes(&mut batch, &s); + // The old refresh token was already rotated. Within the per-token + // grace window, replay the session's current tokens so this + // benignly-racing client keeps a working session instead of being + // revoked. + match self.lookup_refresh_grace(&data.old_refresh_jti)? { + RefreshGraceLookup::Replay(replay) => { + return Ok(RefreshSessionResult::GraceReplay(replay)); + } + RefreshGraceLookup::Compromised { .. } | RefreshGraceLookup::NotUsed => { + // Outside the grace window: genuine reuse. Revoke the session. + let mut batch = self.db.batch(); + if let Some(s) = self.load_session_by_id(data.session_id.as_i32())? { + self.delete_session_indexes(&mut batch, &s); + } + batch.commit().map_err(MetastoreError::Fjall)?; + return Ok(RefreshSessionResult::Compromise); + } } - batch.commit().map_err(MetastoreError::Fjall)?; - return Ok(RefreshSessionResult::TokenAlreadyUsed); } let mut session = match self.load_session_by_id(data.session_id.as_i32())? { Some(s) => s, - None => return Ok(RefreshSessionResult::ConcurrentRefresh), + None => return Ok(RefreshSessionResult::Compromise), }; if session.refresh_jti != data.old_refresh_jti { - return Ok(RefreshSessionResult::ConcurrentRefresh); + return Ok(RefreshSessionResult::Compromise); } let user_hash = self.resolve_user_hash_from_did(&session.did); let old_access_jti = session.access_jti.clone(); let old_refresh_jti = session.refresh_jti.clone(); + let rotated_at_ms = Utc::now().timestamp_millis(); session.access_jti = data.new_access_jti.clone(); session.refresh_jti = data.new_refresh_jti.clone(); session.access_expires_at_ms = data.new_access_expires_at.timestamp_millis(); session.refresh_expires_at_ms = data.new_refresh_expires_at.timestamp_millis(); - session.updated_at_ms = Utc::now().timestamp_millis(); + session.updated_at_ms = rotated_at_ms; - let new_access_index = SessionIndexValue { - user_hash: user_hash.raw(), - session_id: session.id, - }; - let new_refresh_index = SessionIndexValue { + let index = SessionIndexValue { user_hash: user_hash.raw(), session_id: session.id, }; let mut batch = self.db.batch(); + // Record the rotation time so a benign replay of this token can be graced + // from its own rotation moment. batch.insert( &self.auth, used_key.as_slice(), - serialize_used_refresh_value(session.refresh_expires_at_ms, session.id), + serialize_used_refresh_value(session.refresh_expires_at_ms, session.id, rotated_at_ms), ); batch.remove( @@ -899,12 +942,12 @@ impl SessionOps { batch.insert( &self.auth, session_by_access_key(&data.new_access_jti).as_slice(), - new_access_index.serialize(session.refresh_expires_at_ms), + index.serialize(session.refresh_expires_at_ms), ); batch.insert( &self.auth, session_by_refresh_key(&data.new_refresh_jti).as_slice(), - new_refresh_index.serialize(session.refresh_expires_at_ms), + index.serialize(session.refresh_expires_at_ms), ); batch.insert( &self.auth, diff --git a/crates/tranquil-store/src/metastore/sessions.rs b/crates/tranquil-store/src/metastore/sessions.rs index c6ad998..10b6cd2 100644 --- a/crates/tranquil-store/src/metastore/sessions.rs +++ b/crates/tranquil-store/src/metastore/sessions.rs @@ -209,25 +209,34 @@ pub fn session_id_counter_key() -> SmallVec<[u8; 128]> { KeyBuilder::new().tag(KeyTag::SESSION_ID_COUNTER).build() } -fn serialize_ttl_i32(expires_at_ms: i64, session_id: i32) -> Vec { - let mut buf = Vec::with_capacity(12); - buf.extend_from_slice(&u64::try_from(expires_at_ms).unwrap_or(0).to_be_bytes()); - buf.extend_from_slice(&session_id.to_be_bytes()); - buf -} - fn deserialize_ttl_i32(bytes: &[u8]) -> Option { let rest = bytes.get(8..)?; let arr: [u8; 4] = rest.try_into().ok()?; Some(i32::from_be_bytes(arr)) } -pub fn serialize_used_refresh_value(expires_at_ms: i64, session_id: i32) -> Vec { - serialize_ttl_i32(expires_at_ms, session_id) +pub fn serialize_used_refresh_value( + expires_at_ms: i64, + session_id: i32, + rotated_at_ms: i64, +) -> Vec { + let mut buf = Vec::with_capacity(20); + buf.extend_from_slice(&u64::try_from(expires_at_ms).unwrap_or(0).to_be_bytes()); + buf.extend_from_slice(&session_id.to_be_bytes()); + buf.extend_from_slice(&rotated_at_ms.to_be_bytes()); + buf } -pub fn deserialize_used_refresh_value(bytes: &[u8]) -> Option { - deserialize_ttl_i32(bytes) +/// Decode a used-refresh marker. The 20-byte format carries the rotation time; +/// the legacy 12-byte format predates it (rotation time unknown), so callers +/// must treat its `None` as outside the grace window. +pub fn deserialize_used_refresh_value(bytes: &[u8]) -> Option<(i32, Option)> { + let session_id = deserialize_ttl_i32(&bytes[..12.min(bytes.len())])?; + let rotated_at_ms = bytes + .get(12..20) + .and_then(|s| <[u8; 8]>::try_from(s).ok()) + .map(i64::from_be_bytes); + Some((session_id, rotated_at_ms)) } fn serialize_ttl_i64(timestamp_ms: i64) -> Vec { @@ -336,10 +345,29 @@ mod tests { fn used_refresh_value_roundtrip() { let session_id = 7; let expires_at_ms = 1700000600000i64; - let bytes = serialize_used_refresh_value(expires_at_ms, session_id); + let rotated_at_ms = 1700000123000i64; + let bytes = serialize_used_refresh_value(expires_at_ms, session_id, rotated_at_ms); + assert_eq!(bytes.len(), 20); let ttl = u64::from_be_bytes(bytes[..8].try_into().unwrap()); assert_eq!(ttl, u64::try_from(expires_at_ms).unwrap_or(0)); - assert_eq!(deserialize_used_refresh_value(&bytes), Some(session_id)); + assert_eq!( + deserialize_used_refresh_value(&bytes), + Some((session_id, Some(rotated_at_ms))) + ); + } + + #[test] + fn used_refresh_value_legacy_12_byte_decodes_without_rotated_at() { + // Pre-upgrade markers carry no rotation time; rotated_at must be None. + let session_id = 9i32; + let expires_at_ms = 1700000600000i64; + let mut bytes = u64::try_from(expires_at_ms).unwrap().to_be_bytes().to_vec(); + bytes.extend_from_slice(&session_id.to_be_bytes()); + assert_eq!(bytes.len(), 12); + assert_eq!( + deserialize_used_refresh_value(&bytes), + Some((session_id, None)) + ); } #[test]