oauth: forget sign-in devices of deleted accounts and idle ones
oauth_device keeps the IP address and user agent of every browser that signed in, but nothing ever deleted a row: account deletion cascaded only the oauth_account_device link, so the device row outlived the account, and the tranquil-store backend kept the link too. Every account deletion path (delete_account_complete, the admin one and delete_account_with_firehose; tranquil-store's shared delete path) now deletes, in the same transaction or write batch, the devices only that account is linked to. Devices another account also signs in on keep their row and just lose the link. A sweep on the scheduled delete-check tick removes devices unused for scheduled.oauth_device_retention_secs (OAUTH_DEVICE_RETENTION_SECS, default 90 days, 0 disables), and unlinked devices after a day: they are created when a sign-in starts, before any account is linked. Use is the newest of the device's last_seen_at, its account links' updated_at (bumped on every sign-in on it) and, on postgres, the updated_at of tokens issued through it (bumped on refresh); tokens in tranquil-store don't record their device. Devices still trusted for 2FA are kept until the trust ends. last_seen_at was never refreshed before; the authorize endpoints now bump it whenever they read the device cookie, and a cookie naming a swept device is treated as no cookie, so a sign-in can't hit the oauth_device foreign keys with a dead id. oauth_token.device_id and oauth_authorization_request.device_id are ON DELETE SET NULL, so deleting a device never ends a session. Tests: store_parity covers all three deletion paths and the sweep on both backends, plus postgres-only trust and token-refresh cases; a tranquil-store unit test covers the sweep with backdated links and a trusted device.
854 unmodified linesExpand all855856857858859860861862863864865866867868869854 unmodified linesExpand all# Can also be specified via environment variable `EVENT_RETENTION_INTERVAL_SECS`.## Default value: 3600#event_retention_interval_secs = 3600
# How long a sign-in device (its IP address and user agent) is kept after# its last use: being seen at the authorize page, an account signing in on# it, or a token issued on it being refreshed. Devices still trusted for 2FA# are kept until the trust ends. Checked every delete_check_interval_secs.# Set to 0 to disable. Default: 90 days.## Can also be specified via environment variable `OAUTH_DEVICE_RETENTION_SECS`.## Default value: 7776000#oauth_device_retention_secs = 7776000123456789101112131415{ "db_name": "PostgreSQL", "query": "\n WITH activity AS (\n SELECT od.id,\n GREATEST(od.last_seen_at, links.last_used_at, tokens.last_used_at)\n AS last_active_at,\n links.device_id IS NOT NULL AS linked\n FROM oauth_device od\n LEFT JOIN (\n SELECT device_id, MAX(updated_at) AS last_used_at\n FROM oauth_account_device\n GROUP BY device_id\n ) links ON links.device_id = od.id\n LEFT JOIN (\n SELECT device_id, MAX(updated_at) AS last_used_at\n FROM oauth_token\n WHERE device_id IS NOT NULL\n GROUP BY device_id\n ) tokens ON tokens.device_id = od.id\n WHERE od.trusted_until IS NULL OR od.trusted_until <= NOW()\n )\n DELETE FROM oauth_device od\n USING activity a\n WHERE od.id = a.id\n AND a.last_active_at\n < CASE WHEN a.linked THEN $1::timestamptz ELSE $2::timestamptz END\n AND od.last_seen_at\n < CASE WHEN a.linked THEN $1::timestamptz ELSE $2::timestamptz END\n ", "describe": { "columns": [], "parameters": { "Left": [ "Timestamptz", "Timestamptz" ] }, "nullable": [] }, "hash": "405b1ae2f9ea04e078422ddf41fb105bf06f2019a3bd5909d90a4a2b8c7c2c93"}1234567891011121314{ "db_name": "PostgreSQL", "query": "\n DELETE FROM oauth_device od\n WHERE od.id IN (SELECT device_id FROM oauth_account_device WHERE did = $1)\n AND NOT EXISTS (\n SELECT 1 FROM oauth_account_device other\n WHERE other.device_id = od.id AND other.did <> $1\n )\n ", "describe": { "columns": [], "parameters": { "Left": [ "Text" ] }, "nullable": [] }, "hash": "424c5a1b4fa9fa37e10b0c843d086c3799cd04f578a81f91ec8777e6209ff129"}325 unmodified linesExpand all3263273283293303313323333343353363373383393401331 unmodified linesExpand all1672167316741675167616771678167916801681168216831684168516861687506 unmodified linesExpand all325 unmodified linesExpand all must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}", self.scheduled.event_retention_max_age_secs )); } if self.scheduled.oauth_device_retention_secs > MAX_RETENTION_SECS { errors.push(format!( "scheduled.oauth_device_retention_secs (OAUTH_DEVICE_RETENTION_SECS) \ must be at most {MAX_RETENTION_SECS} (chrono::Duration limit); got {}", self.scheduled.oauth_device_retention_secs )); } if self.scheduled.event_retention_interval_secs > 0 { let backfill_secs = u64::try_from(self.firehose.backfill_hours.max(0)) .unwrap_or(0) .saturating_mul(3600);1331 unmodified linesExpand all /// Interval in seconds between event retention prune passes. /// Set to 0 to disable. #[config(env = "EVENT_RETENTION_INTERVAL_SECS", default = 3600)] pub event_retention_interval_secs: u64,
/// How long a sign-in device (its IP address and user agent) is kept after /// its last use: being seen at the authorize page, an account signing in on /// it, or a token issued on it being refreshed. Devices still trusted for 2FA /// are kept until the trust ends. Checked every delete_check_interval_secs. /// Set to 0 to disable. Default: 90 days. #[config(env = "OAUTH_DEVICE_RETENTION_SECS", default = 7776000)] pub oauth_device_retention_secs: u64,}
#[derive(Debug, Config)]#[config(layer_attr(serde(deny_unknown_fields)))]506 unmodified linesExpand all215 unmodified linesExpand all216217218219220220221222223224225226227228229230231232233234235107 unmodified linesExpand all215 unmodified linesExpand all async fn set_request_did(&self, request_id: &RequestId, did: &Did) -> Result<(), DbError>;
async fn create_device(&self, device_id: &DeviceId, data: &DeviceData) -> Result<(), DbError>; async fn get_device(&self, device_id: &DeviceId) -> Result<Option<DeviceData>, DbError>; /// Marks the device as seen now. Returns false when the device no longer exists. async fn update_device_last_seen(&self, device_id: &DeviceId) -> Result<(), DbError>; async fn update_device_last_seen(&self, device_id: &DeviceId) -> Result<bool, DbError>; async fn delete_device(&self, device_id: &DeviceId) -> Result<(), DbError>; /// Deletes devices whose last activity (device seen, account signed in on it, or a /// token issued through it refreshed) is older than `linked_cutoff`, or older than /// `unlinked_cutoff` for devices no account is linked to. Devices still trusted for /// 2FA are kept. Returns how many devices were deleted. async fn delete_stale_devices( &self, linked_cutoff: DateTime<Utc>, unlinked_cutoff: DateTime<Utc>, ) -> Result<u64, DbError>; async fn upsert_account_device(&self, did: &Did, device_id: &DeviceId) -> Result<(), DbError>; async fn get_device_accounts( &self, device_id: &DeviceId,107 unmodified linesExpand all9 unmodified lines1011121314141516171819502 unmodified linesExpand all52252352452552652752852953010 unmodified lines54154254354454554654754854955038 unmodified lines58959059159259359459559659759864 unmodified lines66366466566666766866967067167267367467567667767867968068168273 unmodified lines756757758759760761762763764765766767768769770771772773774775776777778779780781602 unmodified linesExpand all9 unmodified linesuse tokio::time::interval;use tokio_util::sync::CancellationToken;use tracing::{debug, error, info, warn};use tranquil_db_traits::{ BlobRepository, InfraRepository, RepoRepository, SsoRepository, UserRepository, BlobRepository, DbError, InfraRepository, OAuthRepository, RepoRepository, SsoRepository, UserRepository,};use tranquil_store::blockstore::CidBytes;use tranquil_store::bloom::BloomFilter;use tranquil_types::{AtUri, CidLink, Did};502 unmodified linesExpand all user_repo: Arc<dyn UserRepository>, blob_repo: Arc<dyn BlobRepository>, blob_store: Arc<dyn BlobStorage>, sso_repo: Arc<dyn SsoRepository>, oauth_repo: Arc<dyn OAuthRepository>, repo_repo: Arc<dyn RepoRepository>, block_store: AnyBlockStore, eventlog_segments_dir: Option<std::path::PathBuf>, shutdown: CancellationToken,10 unmodified lines let archival_interval = Duration::from_secs(cfg.scheduled.archival_interval_secs.max(60)); let event_retention_interval = Duration::from_secs(cfg.scheduled.event_retention_interval_secs.max(60)); let event_retention_max_age = Duration::from_secs(cfg.scheduled.event_retention_max_age_secs); let device_retention_enabled = cfg.scheduled.oauth_device_retention_secs > 0; let device_retention = Duration::from_secs(cfg.scheduled.oauth_device_retention_secs);
let archiver: Option<Arc<tranquil_store::archival::ContinuousArchiver>> = match (&eventlog_segments_dir, &cfg.scheduled.archival_dest_dir) { (Some(segments_dir), Some(dest_dir)) if archival_enabled_secs => {38 unmodified lines archival_enabled = archiver.is_some(), event_retention_enabled, event_retention_interval_secs = cfg.scheduled.event_retention_interval_secs, event_retention_max_age_secs = cfg.scheduled.event_retention_max_age_secs, device_retention_enabled, oauth_device_retention_secs = cfg.scheduled.oauth_device_retention_secs, "Starting scheduled tasks service" );
let mut ticker = interval(check_interval);64 unmodified lines Err(e) => { error!("Error cleaning up handle reservations: {:?}", e); } }
if device_retention_enabled { match delete_stale_devices(oauth_repo.as_ref(), device_retention).await { Ok(count) if count > 0 => { info!(count = count, "Deleted stale OAuth devices"); } Ok(_) => {} Err(e) => { error!("Error deleting stale OAuth devices: {:?}", e); } } } } _ = compaction_ticker.tick(), if compaction_enabled => { if let Some(store) = block_store.as_tranquil_store() { let store = store.clone();73 unmodified lines } }}
/// Deletes sign-in devices unused for `retention`. Devices are created when a sign-in/// starts, before an account is linked to them, so ones no account is linked to are only/// deleted after a day, long enough for any sign-in to have finished.async fn delete_stale_devices( oauth_repo: &dyn OAuthRepository, retention: Duration,) -> Result<u64, DbError> { let retention = chrono::Duration::from_std(retention) .expect("oauth_device_retention fits chrono::Duration: validated at config load"); let now = chrono::Utc::now(); oauth_repo .delete_stale_devices( now - retention, now - retention.min(chrono::Duration::days(1)), ) .await}
pub struct CompactionBlocklist { entries: std::collections::HashMap<tranquil_store::blockstore::DataFileId, std::time::Instant>, cool_off: Duration,}602 unmodified linesExpand all2 unmodified lines3456778910111835 unmodified linesExpand all184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122 unmodified lines
use std::sync::Arc;use tranquil_db::PostgresRepositories;use tranquil_db_traits::{Backlink, BacklinkPath, CommsChannel, CommsType};use tranquil_types::{AtUri, CidLink, Did, Handle, Nsid, Rkey, Tid};use tranquil_types::{AtUri, CidLink, DeviceId, Did, Handle, Nsid, Rkey, Tid};use uuid::Uuid;
async fn create_store_repos() -> Arc<PostgresRepositories> { let temp_dir = std::env::temp_dir().join(format!("tranquil-parity-{}", uuid::Uuid::new_v4()));1835 unmodified linesExpand all store_max_after, store_max_before, "store retention must not regress max_seq" );}
fn seed_device_data(last_seen_at: chrono::DateTime<chrono::Utc>) -> tranquil_oauth::DeviceData { tranquil_oauth::DeviceData { session_id: tranquil_oauth::SessionId::generate(), user_agent: Some("parity-agent".to_string()), ip_address: "192.0.2.1".to_string(), last_seen_at, }}
async fn seed_device_both( f: &ParityFixture, last_seen_at: chrono::DateTime<chrono::Utc>, linked_to: &[&Did],) -> DeviceId { let device_id = DeviceId::generate(); let data = seed_device_data(last_seen_at); for repos in [&f.pg, &f.store] { repos.oauth.create_device(&device_id, &data).await.unwrap(); for did in linked_to { repos .oauth .upsert_account_device(did, &device_id) .await .unwrap(); } } device_id}
#[derive(Clone, Copy)]enum AccountDeletion { Complete, Admin, WithFirehose,}
async fn delete_account(repos: &PostgresRepositories, how: AccountDeletion, uid: Uuid, did: &Did) { match how { AccountDeletion::Complete => repos.user.delete_account_complete(uid, did).await, AccountDeletion::Admin => repos.user.admin_delete_account_complete(uid, did).await, AccountDeletion::WithFirehose => repos.user.delete_account_with_firehose(uid, did).await, } .unwrap();}
async fn check_account_deletion_drops_unshared_devices(suffix: &str, how: AccountDeletion) { let f = ParityFixture::new().await; let leaving = test_did(&format!("{suffix}a")); let staying = test_did(&format!("{suffix}b")); let (pg_uid, store_uid) = seed_repos(&f, &leaving, &test_handle(&format!("{suffix}a"))).await; seed_repos(&f, &staying, &test_handle(&format!("{suffix}b"))).await;
let now = chrono::Utc::now(); let own = seed_device_both(&f, now, &[&leaving]).await; let shared = seed_device_both(&f, now, &[&leaving, &staying]).await; let other = seed_device_both(&f, now, &[&staying]).await;
delete_account(&f.pg, how, pg_uid, &leaving).await; delete_account(&f.store, how, store_uid, &leaving).await;
for (backend, repos) in [("pg", &f.pg), ("store", &f.store)] { assert!( repos.oauth.get_device(&own).await.unwrap().is_none(), "{backend}: device only the deleted account used must go" ); assert!(repos.oauth.get_device(&shared).await.unwrap().is_some()); assert!(repos.oauth.get_device(&other).await.unwrap().is_some()); let shared_accounts: Vec<Did> = repos .oauth .get_device_accounts(&shared) .await .unwrap() .into_iter() .map(|a| a.did) .collect(); assert_eq!(shared_accounts, vec![staying.clone()], "{backend}"); assert!( !repos .oauth .verify_account_on_device(&shared, &leaving) .await .unwrap() ); }}
#[tokio::test]async fn parity_account_deletion_drops_unshared_devices() { check_account_deletion_drops_unshared_devices("devdelc", AccountDeletion::Complete).await;}
#[tokio::test]async fn parity_admin_account_deletion_drops_unshared_devices() { check_account_deletion_drops_unshared_devices("devdeladm", AccountDeletion::Admin).await;}
#[tokio::test]async fn parity_scheduled_account_deletion_drops_unshared_devices() { check_account_deletion_drops_unshared_devices("devdelfh", AccountDeletion::WithFirehose).await;}
#[tokio::test]async fn parity_stale_device_sweep() { let f = ParityFixture::new().await; let did = test_did("devsweep"); seed_repos(&f, &did, &test_handle("devsweep")).await;
let now = chrono::Utc::now(); let long_ago = now - chrono::Duration::days(200); let signed_in_recently = seed_device_both(&f, long_ago, &[&did]).await; let abandoned_login = seed_device_both(&f, now - chrono::Duration::days(2), &[]).await; let login_in_progress = seed_device_both(&f, now - chrono::Duration::hours(1), &[]).await;
let linked_cutoff = now - chrono::Duration::days(90); let unlinked_cutoff = now - chrono::Duration::days(1); for (backend, repos) in [("pg", &f.pg), ("store", &f.store)] { repos .oauth .delete_stale_devices(linked_cutoff, unlinked_cutoff) .await .unwrap(); assert!( repos .oauth .get_device(&abandoned_login) .await .unwrap() .is_none(), "{backend}: unlinked device past the grace period must go" ); assert!( repos .oauth .get_device(&login_in_progress) .await .unwrap() .is_some(), "{backend}: fresh unlinked device is a sign-in in progress" ); assert!( repos .oauth .get_device(&signed_in_recently) .await .unwrap() .is_some(), "{backend}: a recent sign-in on the device counts as use" ); assert!( !repos .oauth .update_device_last_seen(&abandoned_login) .await .unwrap() ); assert!( repos .oauth .update_device_last_seen(&signed_in_recently) .await .unwrap() ); }}
#[tokio::test]async fn pg_stale_device_sweep_keeps_trusted_and_token_devices() { let f = ParityFixture::new().await; let pool = f.pg.pool.clone().expect("pg repos have a pool"); let did = test_did("devsweeppg"); seed_user(&f.pg, &did, &test_handle("devsweeppg")).await;
let now = chrono::Utc::now(); let long_ago = now - chrono::Duration::days(200); let seed = |device_id: DeviceId| { let pg = f.pg.clone(); let did = did.clone(); let pool = pool.clone(); async move { pg.oauth .create_device(&device_id, &seed_device_data(long_ago)) .await .unwrap(); pg.oauth .upsert_account_device(&did, &device_id) .await .unwrap(); sqlx::query("UPDATE oauth_account_device SET updated_at = $1 WHERE device_id = $2") .bind(long_ago) .bind(device_id.as_str()) .execute(&pool) .await .unwrap(); device_id } }; let idle = seed(DeviceId::generate()).await; let trusted = seed(DeviceId::generate()).await; let token_refreshed = seed(DeviceId::generate()).await;
f.pg.oauth .trust_device(&trusted, &did, now, now + chrono::Duration::days(10)) .await .unwrap(); let client_id = tranquil_types::ClientId::from("https://parity.test/client".to_string()); let token = tranquil_oauth::TokenData { did: did.clone(), token_id: tranquil_oauth::TokenId::from(format!("parity-{}", Uuid::new_v4())), created_at: long_ago, updated_at: now - chrono::Duration::days(3), expires_at: now + chrono::Duration::days(30), client_id: client_id.clone(), client_auth: tranquil_oauth::ClientAuth::None, device_id: Some(token_refreshed.clone()), parameters: tranquil_oauth::AuthorizationRequestParameters { response_type: tranquil_oauth::ResponseType::Code, client_id, redirect_uri: "https://parity.test/cb".to_string(), scope: None, state: None, code_challenge: "x".to_string(), code_challenge_method: tranquil_oauth::CodeChallengeMethod::S256, response_mode: None, login_hint: None, dpop_jkt: None, prompt: None, extra: None, }, details: None, code: None, current_refresh_token: None, scope: None, controller_did: None, }; f.pg.oauth.create_token(&token).await.unwrap();
f.pg.oauth .delete_stale_devices( now - chrono::Duration::days(90), now - chrono::Duration::days(1), ) .await .unwrap();
assert!(f.pg.oauth.get_device(&idle).await.unwrap().is_none()); assert!( !f.pg .oauth .verify_account_on_device(&idle, &did) .await .unwrap() ); assert!(f.pg.oauth.get_device(&trusted).await.unwrap().is_some()); assert!( f.pg.oauth .get_device(&token_refreshed) .await .unwrap() .is_some() );}328 unmodified linesExpand all329330331332333334335336337154 unmodified linesExpand all328 unmodified linesExpand all state.repos.user.clone(), state.repos.blob.clone(), state.blob_store.clone(), state.repos.sso.clone(), state.repos.oauth.clone(), state.repos.repo.clone(), state.block_store.clone(), state.eventlog_segments_dir.clone(), shutdown.clone(),154 unmodified linesExpand all