Something went wrong. Try again.
An OIDC provider for AT Protocol OAuth. Rust port of apenwarr/atlogin. forked from quilling.dev/ratlogin
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476use 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<AuthorizationTarget>),}
#[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<String>, pub challenge: Option<PkceChallenge>, pub identity: Identity, pub auth_time: Timestamp, pub expires_at: Timestamp, #[serde(default)] pub email: Option<EmailClaim>, #[serde(default)] pub scopes: Vec<Scope>,}
#[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<EmailClaim>,}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]pub struct AccessToken { pub identity: Identity, pub expires_at: Timestamp, #[serde(default)] pub email: Option<EmailClaim>, #[serde(default)] pub scopes: Vec<Scope>,}
#[derive(Clone)]pub struct FlowStore { db: Database,}
impl FlowStore { pub fn new(db: Database) -> Result<Self, DatabaseError> { db.lock().execute( "UPDATE flows SET status = ?1 WHERE status = ?2", params![PENDING, PROCESSING], )?; Ok(Self { db }) }
pub fn in_memory() -> Result<Self, DatabaseError> { Self::new(Database::in_memory()?) }
pub fn schema_version(&self) -> Result<i64, DatabaseError> { 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<String, DatabaseError> { self.insert(flow, PENDING) }
fn insert(&self, flow: LoginFlow, status: i64) -> Result<String, DatabaseError> { 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<T: Serialize>( &self, table: &str, record: &T, expires_at: Timestamp, ) -> Result<String, DatabaseError> { 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<T: DeserializeOwned>( &self, sql: &str, key: &str, ) -> Result<Option<T>, DatabaseError> { let data: Option<String> = 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<Option<LoginFlow>, DatabaseError> { self.record("SELECT data FROM flows WHERE state = ?1", state) }
pub fn claim( &self, state: &str, browser_token: &str, now: Timestamp, ) -> Result<Option<LoginFlow>, 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<String, DatabaseError> { self.issue("codes", &code, code.expires_at) }
pub fn consume_code( &self, value: &str, now: Timestamp, ) -> Result<Option<AuthorizationCode>, DatabaseError> { Ok(self .record::<AuthorizationCode>( "DELETE FROM codes WHERE digest = ?1 RETURNING data", &digest(value), )? .filter(|code| now < code.expires_at)) }
pub fn issue_token(&self, token: AccessToken) -> Result<String, DatabaseError> { self.issue("tokens", &token, token.expires_at) }
pub fn token(&self, value: &str, now: Timestamp) -> Result<Option<AccessToken>, DatabaseError> { Ok(self .record::<AccessToken>("SELECT data FROM tokens WHERE digest = ?1", &digest(value))? .filter(|token| now < token.expires_at)) }
pub fn create_session(&self, session: BrowserSession) -> Result<String, DatabaseError> { self.issue("sessions", &session, session.expires_at) }
pub fn session( &self, value: &str, now: Timestamp, ) -> Result<Option<BrowserSession>, DatabaseError> { Ok(self .record::<BrowserSession>( "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<usize, DatabaseError> { 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); }}