use jiff::{SignedDuration, Timestamp}; use rusqlite::{OptionalExtension, params}; use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use subtle::ConstantTimeEq; use url::Url; use crate::clients::ClientId; use crate::database::{Database, DatabaseError, digest, random_token}; use crate::identity::{Did, Handle, Identity}; use crate::oidc::{AuthorizationTarget, EmailClaim, PkceChallenge, Scope}; pub const FLOW_LIFETIME: SignedDuration = SignedDuration::from_mins(10); pub const CODE_LIFETIME: SignedDuration = SignedDuration::from_mins(5); pub const TOKEN_LIFETIME: SignedDuration = SignedDuration::from_hours(1); const PENDING: i64 = 0; const PROCESSING: i64 = 1; #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub enum FlowTarget { Authorization(Box), } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct LoginFlow { pub target: FlowTarget, pub handle: Handle, pub browser_token: String, pub created_at: Timestamp, #[serde(default)] pub remember: bool, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct AuthorizationCode { pub client_id: ClientId, pub redirect_uri: Url, pub nonce: Option, pub challenge: Option, pub identity: Identity, pub auth_time: Timestamp, pub expires_at: Timestamp, #[serde(default)] pub email: Option, #[serde(default)] pub scopes: Vec, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct BrowserSession { pub did: Did, pub handle: Handle, pub auth_time: Timestamp, pub expires_at: Timestamp, #[serde(default)] pub email: Option, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct AccessToken { pub identity: Identity, pub expires_at: Timestamp, #[serde(default)] pub email: Option, #[serde(default)] pub scopes: Vec, } #[derive(Clone)] pub struct FlowStore { db: Database, } impl FlowStore { pub fn new(db: Database) -> Result { db.lock().execute( "UPDATE flows SET status = ?1 WHERE status = ?2", params![PENDING, PROCESSING], )?; Ok(Self { db }) } pub fn in_memory() -> Result { Self::new(Database::in_memory()?) } pub fn schema_version(&self) -> Result { let connection = self.db.lock(); connection.query_row("SELECT EXISTS (SELECT 1 FROM flows)", [], |row| { row.get::<_, bool>(0) })?; Ok(connection.query_row("PRAGMA user_version", [], |row| row.get(0))?) } pub fn begin(&self, flow: LoginFlow) -> Result { self.insert(flow, PENDING) } fn insert(&self, flow: LoginFlow, status: i64) -> Result { let state = random_token(); let stored = LoginFlow { browser_token: digest(&flow.browser_token), ..flow }; self.db.lock().execute( "INSERT INTO flows (state, status, created_at, data) VALUES (?1, ?2, ?3, ?4)", params![ state, status, stored.created_at.as_second(), serde_json::to_string(&stored)? ], )?; Ok(state) } fn issue( &self, table: &str, record: &T, expires_at: Timestamp, ) -> Result { let value = random_token(); self.db.lock().execute( &format!("INSERT INTO {table} (digest, expires_at, data) VALUES (?1, ?2, ?3)"), params![ digest(&value), expires_at.as_second(), serde_json::to_string(record)? ], )?; Ok(value) } fn record( &self, sql: &str, key: &str, ) -> Result, DatabaseError> { let data: Option = self .db .lock() .query_row(sql, params![key], |row| row.get(0)) .optional()?; data.map(|json| Ok(serde_json::from_str(&json)?)) .transpose() } pub fn flow(&self, state: &str) -> Result, DatabaseError> { self.record("SELECT data FROM flows WHERE state = ?1", state) } pub fn claim( &self, state: &str, browser_token: &str, now: Timestamp, ) -> Result, DatabaseError> { let connection = self.db.lock(); let row: Option<(i64, String)> = connection .query_row( "SELECT status, data FROM flows WHERE state = ?1", params![state], |row| Ok((row.get(0)?, row.get(1)?)), ) .optional()?; let Some((status, json)) = row else { return Ok(None); }; let flow: LoginFlow = serde_json::from_str(&json)?; let fresh = now.duration_since(flow.created_at) <= FLOW_LIFETIME; let bound: bool = flow .browser_token .as_bytes() .ct_eq(digest(browser_token).as_bytes()) .into(); if !fresh || !bound || status != PENDING { return Ok(None); } let changed = connection.execute( "UPDATE flows SET status = ?1 WHERE state = ?2 AND status = ?3", params![PROCESSING, state, PENDING], )?; Ok((changed == 1).then_some(flow)) } pub fn release(&self, state: &str) -> Result<(), DatabaseError> { self.db.lock().execute( "UPDATE flows SET status = ?1 WHERE state = ?2", params![PENDING, state], )?; Ok(()) } pub fn abandon(&self, state: &str) -> Result<(), DatabaseError> { self.db .lock() .execute("DELETE FROM flows WHERE state = ?1", params![state])?; Ok(()) } pub fn issue_code(&self, code: AuthorizationCode) -> Result { self.issue("codes", &code, code.expires_at) } pub fn consume_code( &self, value: &str, now: Timestamp, ) -> Result, DatabaseError> { Ok(self .record::( "DELETE FROM codes WHERE digest = ?1 RETURNING data", &digest(value), )? .filter(|code| now < code.expires_at)) } pub fn issue_token(&self, token: AccessToken) -> Result { self.issue("tokens", &token, token.expires_at) } pub fn token(&self, value: &str, now: Timestamp) -> Result, DatabaseError> { Ok(self .record::("SELECT data FROM tokens WHERE digest = ?1", &digest(value))? .filter(|token| now < token.expires_at)) } pub fn create_session(&self, session: BrowserSession) -> Result { self.issue("sessions", &session, session.expires_at) } pub fn session( &self, value: &str, now: Timestamp, ) -> Result, DatabaseError> { Ok(self .record::( "SELECT data FROM sessions WHERE digest = ?1 AND revoked_at IS NULL", &digest(value), )? .filter(|session| now < session.expires_at)) } pub fn revoke_session(&self, value: &str, now: Timestamp) -> Result<(), DatabaseError> { self.db.lock().execute( "UPDATE sessions SET revoked_at = ?1 WHERE digest = ?2 AND revoked_at IS NULL", params![now.as_second(), digest(value)], )?; Ok(()) } pub fn revoke_sessions_for(&self, did: &Did, now: Timestamp) -> Result { Ok(self.db.lock().execute( "UPDATE sessions SET revoked_at = ?1 WHERE revoked_at IS NULL AND json_extract(data, '$.did') = ?2", params![now.as_second(), did.as_str()], )?) } pub fn sweep(&self, now: Timestamp) -> Result<(), DatabaseError> { let connection = self.db.lock(); connection.execute( "DELETE FROM sessions WHERE expires_at <= ?1 OR revoked_at IS NOT NULL", params![now.as_second()], )?; let stale = (now - FLOW_LIFETIME).as_second(); connection.execute("DELETE FROM flows WHERE created_at < ?1", params![stale])?; connection.execute( "DELETE FROM oauth_requests WHERE created_at < ?1", params![stale], )?; connection.execute( "DELETE FROM oauth_sessions WHERE created_at < ?1", params![stale], )?; connection.execute( "DELETE FROM codes WHERE expires_at <= ?1", params![now.as_second()], )?; connection.execute( "DELETE FROM tokens WHERE expires_at <= ?1", params![now.as_second()], )?; Ok(()) } } #[cfg(test)] mod tests { use super::*; fn identity() -> Identity { Identity { did: Did::parse("did:web:alice.example.com").expect("did"), handle: Handle::parse("alice.example.com").expect("handle"), } } fn flow() -> LoginFlow { LoginFlow { target: FlowTarget::Authorization(Box::new(AuthorizationTarget { client_id: ClientId::new("client"), redirect_uri: Url::parse("https://client.example/cb").expect("url"), client_state: None, nonce: None, challenge: None, scopes: Vec::new(), })), handle: Handle::parse("alice.example.com").expect("handle"), browser_token: "right".to_owned(), created_at: Timestamp::now(), remember: false, } } fn code(expires_at: Timestamp) -> AuthorizationCode { AuthorizationCode { client_id: ClientId::new("client"), redirect_uri: Url::parse("https://client.example/cb").expect("url"), nonce: None, challenge: None, identity: identity(), auth_time: Timestamp::now(), expires_at, email: None, scopes: Vec::new(), } } fn reopened(path: &std::path::Path) -> FlowStore { FlowStore::new(Database::open(path).expect("open")).expect("store") } #[test] fn sessions_expire_and_are_revoked() { let store = FlowStore::in_memory().expect("store"); let now = Timestamp::now(); let session = BrowserSession { did: identity().did, handle: identity().handle, auth_time: now, expires_at: now + SignedDuration::from_hours(1), email: None, }; let value = store.create_session(session.clone()).expect("create"); assert_eq!(store.session(&value, now).expect("lookup"), Some(session)); assert!(store.session("other", now).expect("lookup").is_none()); assert!( store .session(&value, now + SignedDuration::from_hours(2)) .expect("lookup") .is_none() ); store.revoke_session(&value, now).expect("revoke"); assert!(store.session(&value, now).expect("lookup").is_none()); store.sweep(now).expect("sweep"); } #[test] fn codes_and_tokens_survive_reopen_and_expire_and_codes_are_single_use() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("test.sqlite3"); let now = Timestamp::now(); let store = reopened(&path); let fresh = store.issue_code(code(now + CODE_LIFETIME)).expect("issue"); let expired = store .issue_code(code(now - SignedDuration::from_secs(1))) .expect("issue"); let token = store .issue_token(AccessToken { identity: identity(), expires_at: now + TOKEN_LIFETIME, email: None, scopes: Vec::new(), }) .expect("issue"); let store = reopened(&path); let redeemed = store.consume_code(&fresh, now).expect("consume"); assert_eq!(redeemed.map(|c| c.identity), Some(identity())); assert_eq!(store.consume_code(&fresh, now).expect("again"), None); assert_eq!(store.consume_code(&expired, now).expect("consume"), None); let earlier = now - SignedDuration::from_mins(5); assert_eq!( store.consume_code(&expired, earlier).expect("consume"), None, "an expired code is removed by the attempt" ); assert!(store.token(&token, now).expect("lookup").is_some()); let later = now + TOKEN_LIFETIME; assert!(store.token(&token, later).expect("lookup").is_none()); store.sweep(later).expect("sweep"); assert!(store.token(&token, now).expect("lookup").is_none()); store .db .lock() .execute_batch("DROP TABLE codes") .expect("drop"); assert!(matches!( store.issue_code(code(now + CODE_LIFETIME)), Err(DatabaseError::Sqlite(_)) )); } #[test] fn flows_bind_to_a_browser_are_claimed_once_and_released_on_reopen() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("test.sqlite3"); let now = Timestamp::now(); let store = reopened(&path); let state = store.begin(flow()).expect("begin"); let stored = store.flow(&state).expect("lookup").expect("present"); assert_ne!(stored.browser_token, "right"); let stale = now + FLOW_LIFETIME + SignedDuration::from_secs(1); assert!(store.claim(&state, "wrong", now).expect("claim").is_none()); assert!( store .claim(&state, "right", stale) .expect("claim") .is_none() ); assert!(store.claim(&state, "right", now).expect("claim").is_some()); assert!(store.claim(&state, "right", now).expect("claim").is_none()); let store = reopened(&path); assert!(store.claim(&state, "right", now).expect("claim").is_some()); store .db .lock() .execute("UPDATE flows SET data = '{'", []) .expect("corrupt"); assert!(matches!( store.flow(&state), Err(DatabaseError::Encoding(_)) )); assert!(matches!( store.claim(&state, "right", now), Err(DatabaseError::Encoding(_)) )); store.abandon(&state).expect("abandon"); assert!(store.flow(&state).expect("lookup").is_none()); assert!(store.claim(&state, "right", now).expect("claim").is_none()); } #[test] fn stale_pds_sessions_are_swept() { let store = FlowStore::in_memory().expect("store"); let now = Timestamp::now(); store .db .lock() .execute( "INSERT INTO oauth_sessions (did, session_id, data, created_at) VALUES (?1, ?2, ?3, ?4)", params!["did:web:alice.example.com", "s1", "{}", now.as_second()], ) .expect("insert"); let count = |store: &FlowStore| -> i64 { store .db .lock() .query_row("SELECT count(*) FROM oauth_sessions", [], |row| row.get(0)) .expect("count") }; store.sweep(now).expect("sweep"); assert_eq!(count(&store), 1); store .sweep(now + FLOW_LIFETIME + SignedDuration::from_secs(1)) .expect("sweep"); assert_eq!(count(&store), 0); } }