diff --git a/lib/foundation/fabro-db/migrations/2026072501_auth_sessions.sql b/lib/foundation/fabro-db/migrations/2026072501_auth_sessions.sql new file mode 100644 index 000000000..dae515083 --- /dev/null +++ b/lib/foundation/fabro-db/migrations/2026072501_auth_sessions.sql @@ -0,0 +1,46 @@ +-- A CLI auth session is a rotation chain: the identity and profile are facts +-- about the chain, not about any single token in it. Keeping them here means a +-- chain has exactly one owner, and `created_at_ms` is the real start of the +-- session rather than the newest token's issue time. +CREATE TABLE auth_sessions ( + id TEXT PRIMARY KEY NOT NULL, + identity_issuer TEXT NOT NULL, + identity_subject TEXT NOT NULL, + login TEXT NOT NULL, + name TEXT NOT NULL, + email TEXT NOT NULL, + avatar_url TEXT NOT NULL DEFAULT '', + user_agent TEXT NOT NULL DEFAULT '', + created_at_ms INTEGER NOT NULL, + last_used_at_ms INTEGER NOT NULL, + CHECK (length(id) = 36), + CHECK (length(identity_issuer) > 0), + CHECK (length(identity_subject) > 0), + CHECK (last_used_at_ms >= created_at_ms) +); + +CREATE INDEX auth_sessions_by_identity + ON auth_sessions (identity_issuer, identity_subject, last_used_at_ms DESC); + +-- Rotated tokens are retained until they expire so a replayed token is still +-- recognisable as one that existed, rather than indistinguishable from a +-- forgery. `used_at_ms IS NULL` marks the one token that can still be spent. +CREATE TABLE refresh_tokens ( + token_hash BLOB PRIMARY KEY NOT NULL, + session_id TEXT NOT NULL REFERENCES auth_sessions(id) ON DELETE CASCADE, + issued_at_ms INTEGER NOT NULL, + expires_at_ms INTEGER NOT NULL, + used_at_ms INTEGER, + CHECK (length(token_hash) = 32), + CHECK (expires_at_ms > issued_at_ms), + CHECK (used_at_ms IS NULL OR used_at_ms >= issued_at_ms) +); + +-- Rotation marks the presented token used before issuing its successor, so a +-- chain can only ever hold one live token. Enforcing it here turns an implicit +-- code convention into a constraint, and lets the session listing find the +-- live token by index instead of grouping candidates in memory. +CREATE UNIQUE INDEX refresh_tokens_one_live_per_session + ON refresh_tokens (session_id) WHERE used_at_ms IS NULL; + +CREATE INDEX refresh_tokens_by_expiry ON refresh_tokens (expires_at_ms); diff --git a/lib/foundation/fabro-db/tests/sqlite.rs b/lib/foundation/fabro-db/tests/sqlite.rs index bc4981b06..cdc903d07 100644 --- a/lib/foundation/fabro-db/tests/sqlite.rs +++ b/lib/foundation/fabro-db/tests/sqlite.rs @@ -73,6 +73,16 @@ async fn connect_creates_parent_directory_and_migrate_is_idempotent() -> anyhow: .await?; assert_eq!(runs_table_count, 1); + for table in ["auth_sessions", "refresh_tokens"] { + let count: i64 = sqlx::query_scalar( + "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .bind(table) + .fetch_one(database.pool()) + .await?; + assert_eq!(count, 1, "{table} table should exist"); + } + let legacy_import_table_count: i64 = sqlx::query_scalar( "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'legacy_imports'", ) @@ -390,6 +400,141 @@ INSERT INTO runs ( Ok(()) } +#[tokio::test] +async fn auth_sessions_schema_enforces_one_live_token_and_cascade() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?; + database.migrate().await?; + + let session = "11111111-1111-4111-8111-111111111111"; + insert_auth_session(database.pool(), session, "https://github.com", "12345").await?; + insert_refresh_token(database.pool(), &[1_u8; 32], session, 1_000, None).await?; + + // Rotation marks the old token used before issuing the new one, so a + // second live token in the same chain must be impossible. + assert!( + insert_refresh_token(database.pool(), &[2_u8; 32], session, 1_000, None) + .await + .is_err(), + "a session must not hold two live refresh tokens" + ); + // A used token alongside the live one is the normal post-rotation state. + insert_refresh_token(database.pool(), &[2_u8; 32], session, 1_000, Some(1_500)).await?; + + assert!( + insert_refresh_token( + database.pool(), + &[3_u8; 32], + "22222222-2222-4222-8222-222222222222", + 1_000, + None + ) + .await + .is_err(), + "a refresh token must reference an existing session" + ); + + sqlx::query("DELETE FROM auth_sessions WHERE id = ?") + .bind(session) + .execute(database.pool()) + .await?; + let orphaned: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM refresh_tokens") + .fetch_one(database.pool()) + .await?; + assert_eq!( + orphaned, 0, + "deleting a session should cascade to its tokens" + ); + + Ok(()) +} + +#[tokio::test] +async fn auth_sessions_schema_rejects_invalid_rows() -> anyhow::Result<()> { + let dir = tempfile::tempdir()?; + let database = fabro_db::Database::connect(dir.path().join("fabro.sqlite3")).await?; + database.migrate().await?; + + for (id, issuer, subject) in [ + ("too-short", "https://github.com", "12345"), + ("33333333-3333-4333-8333-333333333333", "", "12345"), + ( + "44444444-4444-4444-8444-444444444444", + "https://github.com", + "", + ), + ] { + assert!( + insert_auth_session(database.pool(), id, issuer, subject) + .await + .is_err(), + "auth session row should be rejected: id={id}, issuer={issuer}, subject={subject}" + ); + } + + let session = "55555555-5555-4555-8555-555555555555"; + insert_auth_session(database.pool(), session, "https://github.com", "12345").await?; + for (hash, expires_at_ms, used_at_ms) in [ + (vec![9_u8; 31], 1_000, None), + (vec![9_u8; 32], 0, None), + (vec![9_u8; 32], 1_000, Some(-1)), + ] { + assert!( + insert_refresh_token(database.pool(), &hash, session, expires_at_ms, used_at_ms) + .await + .is_err(), + "refresh token row should be rejected: len={}, expires_at_ms={expires_at_ms}", + hash.len() + ); + } + + Ok(()) +} + +async fn insert_auth_session( + pool: &fabro_db::DbPool, + id: &str, + identity_issuer: &str, + identity_subject: &str, +) -> Result<(), sqlx::Error> { + sqlx::query( + r" +INSERT INTO auth_sessions ( + id, identity_issuer, identity_subject, login, name, email, + created_at_ms, last_used_at_ms +) VALUES (?, ?, ?, 'octocat', 'The Octocat', 'octocat@example.com', 0, 0) +", + ) + .bind(id) + .bind(identity_issuer) + .bind(identity_subject) + .execute(pool) + .await?; + Ok(()) +} + +async fn insert_refresh_token( + pool: &fabro_db::DbPool, + token_hash: &[u8], + session_id: &str, + expires_at_ms: i64, + used_at_ms: Option, +) -> Result<(), sqlx::Error> { + sqlx::query( + r" +INSERT INTO refresh_tokens (token_hash, session_id, issued_at_ms, expires_at_ms, used_at_ms) +VALUES (?, ?, 0, ?, ?) +", + ) + .bind(token_hash) + .bind(session_id) + .bind(expires_at_ms) + .bind(used_at_ms) + .execute(pool) + .await?; + Ok(()) +} + #[tokio::test] async fn environments_schema_rejects_invalid_rows() -> anyhow::Result<()> { let dir = tempfile::tempdir()?;