Something went wrong. Try again.
Hangar is your Bluesky home base—built natively for Linux. ✈️ hangar.blue
libadwaita gtk4 bluesky flatpak gnome atprotocol linux atproto appimage rust
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482// SPDX-License-Identifier: MPL-2.0
//! Persistent SessionStore for OAuth sessions.//!//! Stores OAuth session data (DPoP keys + token set) as JSON in the//! app's config directory so sessions survive app restarts.
use atrium_api::types::string::Did;use atrium_common::store::Store;use atrium_oauth::Scope;use atrium_oauth::store::session::{Session, SessionStore};use serde::{Deserialize, Serialize};use std::collections::HashMap;use std::io::Write;use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};use std::path::PathBuf;use std::sync::Mutex;
/// One row of `oauth-sessions.json`: atrium's session plus the redirect URI and/// scopes it derives `client_id` from.////// A loopback OAuth client has no registered `client_id`; atrium rebuilds one/// from those two on every token request, refreshes included, and the server/// rejects a refresh whose `client_id` does not match byte for byte. atrium's/// `Session` stores neither.////// The keys sit beside atrium's own rather than nested, so a file written by a/// build that predates them still loads (they default to `None`), and one/// written here still loads in that build.#[derive(Clone, Debug, Serialize, Deserialize)]struct StoredSession { #[serde(flatten)] session: Session, /// Absent on rows written before Hangar recorded this. Those sessions /// cannot be refreshed and need one final re-authorization. #[serde(default, skip_serializing_if = "Option::is_none")] redirect_uri: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] scopes: Option<Vec<Scope>>,}
/// File-backed session store that persists OAuth sessions to disk.////// Sessions are stored as a JSON map of DID → Session in the app's/// config directory. An in-memory cache avoids repeated disk reads.#[derive(Clone)]pub struct FileSessionStore { inner: std::sync::Arc<Mutex<HashMap<Did, StoredSession>>>, path: PathBuf, /// Set only on the handle driving a live authorization. atrium writes the /// row itself once it exchanges the code, so the binding has to ride along /// on the store until that write happens. pending_redirect_uri: Option<String>, pending_scopes: Option<Vec<Scope>>,}
#[derive(Debug, thiserror::Error)]pub enum StoreError { #[error("I/O error: {0}")] Io(#[from] std::io::Error), #[error("JSON error: {0}")] Json(#[from] serde_json::Error), #[error("lock poisoned")] Lock,}
impl FileSessionStore { /// Create a new store, loading any existing sessions from disk. pub fn new() -> Self { let path = Self::store_path(); let sessions = Self::load_from_disk(&path).unwrap_or_default(); Self { inner: std::sync::Arc::new(Mutex::new(sessions)), path, pending_redirect_uri: None, pending_scopes: None, } }
/// Mark this handle as belonging to an in-flight authorization, so the row /// atrium writes when it exchanges the code records what `client_id` was /// built from. pub fn with_client_binding(mut self, redirect_uri: &str, scopes: &[Scope]) -> Self { self.pending_redirect_uri = Some(redirect_uri.to_string()); self.pending_scopes = Some(scopes.to_vec()); self }
/// The redirect URI and scopes this DID's session was authorized under. /// /// `Ok(None)` means the session cannot be refreshed at all: either there is /// no row, or it was written before Hangar recorded the binding and the /// original `client_id` is gone for good. Callers must treat that as /// "sign in again"; retrying will never succeed. pub fn client_binding(&self, did: &Did) -> Result<Option<(String, Vec<Scope>)>, StoreError> { let guard = self.inner.lock().map_err(|_| StoreError::Lock)?; let Some(row) = guard.get(did) else { return Ok(None); }; // Both or neither; a half binding rebuilds the wrong client_id. Ok(row.redirect_uri.clone().zip(row.scopes.clone())) }
fn store_path() -> PathBuf { let config_dir = dirs::config_dir() .unwrap_or_else(|| PathBuf::from(".")) .join(crate::config::APP_ID); // 0700: this directory holds the DPoP private key and the refresh // token, so nothing else running as this user should be able to // list it, let alone read it. let _ = std::fs::DirBuilder::new() .recursive(true) .mode(0o700) .create(&config_dir); config_dir.join("oauth-sessions.json") }
fn load_from_disk(path: &PathBuf) -> Result<HashMap<Did, StoredSession>, StoreError> { match std::fs::read_to_string(path) { Ok(data) => { let sessions: HashMap<String, StoredSession> = serde_json::from_str(&data)?; // Convert String keys back to Did let mut map = HashMap::new(); for (key, value) in sessions { if let Ok(did) = key.parse::<Did>() { map.insert(did, value); } } Ok(map) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(HashMap::new()), Err(e) => Err(StoreError::Io(e)), } }
/// Every DID this file still holds a credential for. /// /// Hangar signs into one account at a time, so anything here that is /// not the current account is a leftover from a sign-out that /// predates the app deleting them, and it is still live. pub fn stored_dids(&self) -> Result<Vec<Did>, StoreError> { let guard = self.inner.lock().map_err(|_| StoreError::Lock)?; Ok(guard.keys().cloned().collect()) }
/// Forget one DID's row, whatever else is going on. /// /// Sign out used to clear the keyring and stop, but for an OAuth login /// the keyring holds only the handle and DID; the credential itself /// lives here. Leaving it meant a live refresh token and DPoP key sat /// on disk after the one action a user takes to end their session. pub fn forget(&self, did: &Did) -> Result<(), StoreError> { let mut guard = self.inner.lock().map_err(|_| StoreError::Lock)?; guard.remove(did); self.save_to_disk(&guard) }
/// Backdate a stored session's expiry so the next refresh actually runs. /// /// atrium-oauth refreshes lazily: on a DPoP `invalid_token` it returns the /// cached session untouched while `expires_at` is in the future. A token /// the server has revoked or rotated is therefore replayed forever, and a /// restart does not help because the session is restored from this file /// unrefreshed. pub fn invalidate_cached_expiry(&self, did: &Did) -> Result<(), StoreError> { let mut guard = self.inner.lock().map_err(|_| StoreError::Lock)?; let Some(row) = guard.get_mut(did) else { return Ok(()); }; let past = chrono::Utc::now() - chrono::Duration::hours(1); row.session.token_set.expires_at = Some(atrium_api::types::string::Datetime::new( past.fixed_offset(), )); self.save_to_disk(&guard) }
/// Attach the client binding to a session on its way to disk. /// /// atrium writes back through `set` after every refresh, from a handle that /// knows nothing about the original authorization. Take the existing row's /// binding when this handle has none, or that write erases it. fn bind(&self, session: Session, existing: Option<&StoredSession>) -> StoredSession { StoredSession { session, redirect_uri: self .pending_redirect_uri .clone() .or_else(|| existing.and_then(|row| row.redirect_uri.clone())), scopes: self .pending_scopes .clone() .or_else(|| existing.and_then(|row| row.scopes.clone())), } }
fn save_to_disk(&self, sessions: &HashMap<Did, StoredSession>) -> Result<(), StoreError> { // Convert Did keys to String for JSON serialization let string_map: HashMap<String, &StoredSession> = sessions.iter().map(|(k, v)| (k.to_string(), v)).collect(); let json = serde_json::to_string_pretty(&string_map)?;
// The directory exists when the store is built, but a data wipe // (GNOME Software offers one) can remove it while the app runs, // and the very next thing a user does then is sign in. Recreate // it on every write. if let Some(parent) = self.path.parent() { std::fs::create_dir_all(parent)?; }
// Write atomically via temp file, created 0600 before a byte of // key material reaches it. The rename replaces the target inode, // so the mode has to be set here on every write or a hardened // file loosens itself on the next token refresh. let tmp_path = self.path.with_extension("json.tmp"); { let mut file = std::fs::OpenOptions::new() .write(true) .create(true) .truncate(true) .mode(0o600) .open(&tmp_path)?; file.write_all(json.as_bytes())?; // The credential surviving a crash matters more than the // microseconds; a torn file costs a re-auth. file.sync_all()?; } std::fs::rename(&tmp_path, &self.path)?; Ok(()) }}
impl Store<Did, Session> for FileSessionStore { type Error = StoreError;
async fn get(&self, key: &Did) -> Result<Option<Session>, Self::Error> { let guard = self.inner.lock().map_err(|_| StoreError::Lock)?; Ok(guard.get(key).map(|row| row.session.clone())) }
async fn set(&self, key: Did, value: Session) -> Result<(), Self::Error> { let mut guard = self.inner.lock().map_err(|_| StoreError::Lock)?; let row = self.bind(value, guard.get(&key)); guard.insert(key, row); self.save_to_disk(&guard)?; Ok(()) }
async fn del(&self, key: &Did) -> Result<(), Self::Error> { let mut guard = self.inner.lock().map_err(|_| StoreError::Lock)?; guard.remove(key); self.save_to_disk(&guard)?; Ok(()) }
async fn clear(&self) -> Result<(), Self::Error> { let mut guard = self.inner.lock().map_err(|_| StoreError::Lock)?; guard.clear(); self.save_to_disk(&guard)?; Ok(()) }}
impl SessionStore for FileSessionStore {}
#[cfg(test)]mod tests { use super::*; use atrium_oauth::KnownScope;
/// A row as written by a build that predates the client binding. The key /// material is synthetic; only its shape matters. const LEGACY_ROW: &str = r#"{ "dpop_key": { "kty": "EC", "crv": "P-256", "x": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "y": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "d": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" }, "token_set": { "iss": "https://bsky.social", "sub": "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", "aud": "https://pds.example.com", "scope": "atproto transition:generic", "refresh_token": "refresh", "access_token": "access", "token_type": "DPoP", "expires_at": "2026-01-01T00:00:00.000Z" } }"#;
fn store_with_pending(binding: Option<(&str, Vec<Scope>)>) -> FileSessionStore { let store = FileSessionStore { inner: std::sync::Arc::new(Mutex::new(HashMap::new())), path: PathBuf::from("/nonexistent/oauth-sessions.json"), pending_redirect_uri: None, pending_scopes: None, }; match binding { Some((uri, scopes)) => store.with_client_binding(uri, &scopes), None => store, } }
/// The flattened layout must stay readable both ways. #[test] fn legacy_row_loads_without_a_binding() { let row: StoredSession = serde_json::from_str(LEGACY_ROW).expect("legacy row parses"); assert!(row.redirect_uri.is_none()); assert!(row.scopes.is_none()); assert_eq!(row.session.token_set.access_token, "access"); }
#[test] fn bound_row_round_trips_and_stays_readable_as_a_bare_session() { let legacy: StoredSession = serde_json::from_str(LEGACY_ROW).unwrap(); let bound = store_with_pending(Some(( "http://127.0.0.1:41234/callback", vec![Scope::Known(KnownScope::Atproto)], ))) .bind(legacy.session.clone(), None);
let json = serde_json::to_string(&bound).unwrap(); let reparsed: StoredSession = serde_json::from_str(&json).unwrap(); assert_eq!( reparsed.redirect_uri.as_deref(), Some("http://127.0.0.1:41234/callback") ); assert_eq!( reparsed.scopes, Some(vec![Scope::Known(KnownScope::Atproto)]) );
// A build without these fields must still load what we wrote. let bare: Session = serde_json::from_str(&json).expect("still a valid Session"); assert_eq!(bare.token_set.access_token, "access"); }
/// A data wipe can delete the config directory out from under a /// running app; the next save must rebuild the path, not error. #[test] fn a_save_survives_its_directory_being_deleted() { let dir = std::env::temp_dir().join(format!("hangar-store-test-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap();
let store = FileSessionStore { inner: std::sync::Arc::new(Mutex::new(HashMap::new())), path: dir.join("oauth-sessions.json"), pending_redirect_uri: None, pending_scopes: None, }; let row: StoredSession = serde_json::from_str(LEGACY_ROW).unwrap(); let did: Did = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap(); store.inner.lock().unwrap().insert(did, row);
// The wipe: the whole directory goes, mid-run. std::fs::remove_dir_all(&dir).unwrap();
let guard = store.inner.lock().unwrap(); store .save_to_disk(&guard) .expect("the save rebuilds the missing directory"); assert!(store.path.exists(), "the session row landed on disk");
drop(guard); let _ = std::fs::remove_dir_all(&dir); }
/// The credential file is 0600 and its directory 0700, on the first /// write and on every rewrite, since the rename replaces the inode. #[test] fn the_credential_file_is_not_readable_by_anyone_else() { use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("hangar-perms-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let store = FileSessionStore { inner: std::sync::Arc::new(Mutex::new(HashMap::new())), path: dir.join("oauth-sessions.json"), pending_redirect_uri: None, pending_scopes: None, };
let row: StoredSession = serde_json::from_str(LEGACY_ROW).unwrap(); let did: Did = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap(); store.inner.lock().unwrap().insert(did.clone(), row);
for pass in ["first write", "rewrite"] { let guard = store.inner.lock().unwrap(); store.save_to_disk(&guard).expect("saves"); drop(guard); let mode = std::fs::metadata(&store.path).unwrap().permissions().mode() & 0o777; assert_eq!(mode, 0o600, "{pass}: mode is {mode:o}, not 0600"); }
// Sign out drops the row rather than leaving a live credential. store.forget(&did).expect("forgets"); assert!(store.inner.lock().unwrap().is_empty()); let on_disk = std::fs::read_to_string(&store.path).unwrap(); assert!( !on_disk.contains("did:plc:aaaaaaaaaaaaaaaaaaaaaaaa"), "the credential survived sign out: {on_disk}" );
let _ = std::fs::remove_dir_all(&dir); }
/// The startup sweep must drop leftovers and never the account in /// use. Getting this backwards would sign the user out of their own /// session and revoke a grant they are still holding, so the /// selection is pinned here rather than trusted to a filter. #[test] fn the_sweep_keeps_the_account_in_use() { let dir = std::env::temp_dir().join(format!("hangar-sweep-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let store = FileSessionStore { inner: std::sync::Arc::new(Mutex::new(HashMap::new())), path: dir.join("oauth-sessions.json"), pending_redirect_uri: None, pending_scopes: None, };
let row: StoredSession = serde_json::from_str(LEGACY_ROW).unwrap(); let in_use: Did = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa".parse().unwrap(); let leftover: Did = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb".parse().unwrap(); { let mut guard = store.inner.lock().unwrap(); guard.insert(in_use.clone(), row.clone()); guard.insert(leftover.clone(), row); }
// What the app computes at startup: keep the restored account. let keep = Some(in_use.to_string()); let stale: Vec<Did> = store .stored_dids() .unwrap() .into_iter() .filter(|did| keep.as_deref() != Some(did.as_str())) .collect(); assert_eq!(stale, vec![leftover.clone()], "swept the wrong account");
for did in stale { store.forget(&did).unwrap(); } assert!( store.stored_dids().unwrap() == vec![in_use], "the account in use lost its credential" );
// Signed out entirely: every row is a leftover. let none: Option<String> = None; let all_stale: Vec<Did> = store .stored_dids() .unwrap() .into_iter() .filter(|did| none.as_deref() != Some(did.as_str())) .collect(); assert_eq!(all_stale.len(), 1);
let _ = std::fs::remove_dir_all(&dir); }
/// atrium calls `set` after a refresh with no pending binding; it must not /// drop the stored one. #[test] fn refresh_write_keeps_the_existing_binding() { let legacy: StoredSession = serde_json::from_str(LEGACY_ROW).unwrap(); let existing = store_with_pending(Some(( "http://127.0.0.1:41234/callback", vec![Scope::Known(KnownScope::Atproto)], ))) .bind(legacy.session.clone(), None);
let refreshed = store_with_pending(None).bind(legacy.session, Some(&existing)); assert_eq!(refreshed.redirect_uri, existing.redirect_uri); assert_eq!(refreshed.scopes, existing.scopes); }}