Add auth_sessions and refresh_tokens schema

A CLI auth session is a rotation chain, but the SlateDB records that back
it today store identity and profile per token, so a chain has no owner and
nothing stops its rows from disagreeing. These two tables give the chain a
home: `auth_sessions` holds the identity and profile once, `refresh_tokens`
holds only per-token facts.

Two invariants the current code relies on but never states become
constraints. The partial unique index on `(session_id) WHERE used_at_ms IS
NULL` enforces that rotation leaves exactly one live token per chain --
which is what makes the session listing an indexed lookup instead of a
scan-and-group. The foreign key with `ON DELETE CASCADE` makes revoking a
session remove its tokens without a second statement.

Tokens are retained after rotation until they expire so a replayed token
stays distinguishable from a forgery.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-25 15:15:58 -04:00
parent 7b77011896
commit c69a47c4e6
No known key found for this signature in database
2 changed files with 191 additions and 0 deletions

View file

@ -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);

View file

@ -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<i64>,
) -> 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()?;