backend: verify passkey ceremonies

This commit is contained in:
2026-07-23 02:01:28 +09:00
parent 026e4cf90b
commit 554a784639
8 changed files with 512 additions and 213 deletions
+60 -11
View File
@@ -57,6 +57,11 @@ const MIGRATIONS: &[Migration] = &[
name: "account identity and login flow schema",
apply: create_account_identity_tables,
},
Migration {
version: 9,
name: "webauthn challenge state",
apply: add_webauthn_challenge_state,
},
];
struct Migration {
@@ -116,6 +121,7 @@ pub struct AuthChallengeRecord {
pub user_id: Option<String>,
pub rp_id: String,
pub origin: String,
pub state_json: Option<String>,
pub expires_at: String,
pub created_at: String,
pub consumed_at: Option<String>,
@@ -229,6 +235,12 @@ pub trait ControlPlaneStore: Send + Sync {
ceremony: &str,
consumed_at: &str,
) -> Result<Option<AuthChallengeRecord>>;
fn consume_auth_challenge_by_id(
&self,
challenge_id: &str,
ceremony: &str,
consumed_at: &str,
) -> Result<Option<AuthChallengeRecord>>;
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()>;
fn resolve_browser_session(&self, token_hash: &str) -> Result<Option<BrowserSessionRecord>>;
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()>;
@@ -524,9 +536,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn put_auth_challenge(&self, record: &AuthChallengeRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
r#"INSERT INTO auth_challenges (challenge_id, ceremony, challenge, user_id, rp_id, origin, expires_at, created_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)"#,
params![record.challenge_id, record.ceremony, record.challenge, record.user_id, record.rp_id, record.origin, record.expires_at, record.created_at, record.consumed_at],
r#"INSERT INTO auth_challenges (challenge_id, ceremony, challenge, user_id, rp_id, origin, state_json, expires_at, created_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)"#,
params![record.challenge_id, record.ceremony, record.challenge, record.user_id, record.rp_id, record.origin, record.state_json, record.expires_at, record.created_at, record.consumed_at],
)?;
Ok(())
})
@@ -559,6 +571,33 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn consume_auth_challenge_by_id(
&self,
challenge_id: &str,
ceremony: &str,
consumed_at: &str,
) -> Result<Option<AuthChallengeRecord>> {
self.with_conn(|conn| {
let record = conn
.query_row(
auth_challenge_select_sql(
"WHERE challenge_id = ?1 AND ceremony = ?2 AND consumed_at IS NULL",
)
.as_str(),
params![challenge_id, ceremony],
read_auth_challenge_record,
)
.optional()?;
if let Some(record) = record.as_ref() {
conn.execute(
"UPDATE auth_challenges SET consumed_at = ?2 WHERE challenge_id = ?1",
params![record.challenge_id, consumed_at],
)?;
}
Ok(record)
})
}
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
self.with_conn(|conn| {
conn.execute(
@@ -1025,7 +1064,7 @@ fn read_passkey_credential_record(
fn auth_challenge_select_sql(where_clause: &str) -> String {
format!(
"SELECT challenge_id, ceremony, challenge, user_id, rp_id, origin, expires_at, created_at, consumed_at FROM auth_challenges {where_clause}"
"SELECT challenge_id, ceremony, challenge, user_id, rp_id, origin, state_json, expires_at, created_at, consumed_at FROM auth_challenges {where_clause}"
)
}
@@ -1037,9 +1076,10 @@ fn read_auth_challenge_record(row: &rusqlite::Row<'_>) -> rusqlite::Result<AuthC
user_id: row.get(3)?,
rp_id: row.get(4)?,
origin: row.get(5)?,
expires_at: row.get(6)?,
created_at: row.get(7)?,
consumed_at: row.get(8)?,
state_json: row.get(6)?,
expires_at: row.get(7)?,
created_at: row.get(8)?,
consumed_at: row.get(9)?,
})
}
@@ -1226,6 +1266,13 @@ CREATE INDEX IF NOT EXISTS idx_worker_workdir_links_worker
Ok(())
}
fn add_webauthn_challenge_state(conn: &Connection) -> Result<()> {
if !column_exists(conn, "auth_challenges", "state_json")? {
conn.execute_batch("ALTER TABLE auth_challenges ADD COLUMN state_json TEXT;")?;
}
Ok(())
}
fn create_account_identity_tables(conn: &Connection) -> Result<()> {
conn.execute_batch(
r#"
@@ -1265,6 +1312,7 @@ CREATE TABLE IF NOT EXISTS auth_challenges (
user_id TEXT REFERENCES users(user_id) ON DELETE CASCADE,
rp_id TEXT NOT NULL,
origin TEXT NOT NULL,
state_json TEXT,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL,
consumed_at TEXT
@@ -1897,7 +1945,7 @@ mod tests {
let db = dir.path().join("control-plane.sqlite");
let store = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 8);
assert_eq!(store.schema_version().await.unwrap(), 9);
let record = WorkspaceRecord {
workspace_id: "local-dev".to_string(),
@@ -1910,7 +1958,7 @@ mod tests {
store.upsert_workspace(&record).await.unwrap();
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
assert_eq!(reopened.schema_version().await.unwrap(), 8);
assert_eq!(reopened.schema_version().await.unwrap(), 9);
assert_eq!(
reopened.get_workspace("local-dev").await.unwrap(),
Some(record)
@@ -2127,7 +2175,7 @@ mod tests {
.unwrap();
let store = SqliteWorkspaceStore::from_connection(conn).unwrap();
assert_eq!(store.schema_version().await.unwrap(), 8);
assert_eq!(store.schema_version().await.unwrap(), 9);
store
.with_conn(|conn| {
@@ -2317,7 +2365,7 @@ mod tests {
#[tokio::test]
async fn account_and_login_records_round_trip() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
assert_eq!(store.schema_version().await.unwrap(), 8);
assert_eq!(store.schema_version().await.unwrap(), 9);
let now = "2026-07-22T00:00:00Z".to_string();
let account = AccountRecord {
account_id: "acct-user-alice".to_string(),
@@ -2388,6 +2436,7 @@ mod tests {
user_id: Some(user.user_id.clone()),
rp_id: "127.0.0.1".to_string(),
origin: "http://127.0.0.1:8787".to_string(),
state_json: None,
expires_at: "2026-07-22T00:05:00Z".to_string(),
created_at: now.clone(),
consumed_at: None,