feat: activate provisional users with scoped passkey enrollment

This commit is contained in:
2026-10-16 08:48:35 +09:00
parent af008f802d
commit d22899d39e
34 changed files with 4278 additions and 49 deletions
+203
View File
@@ -0,0 +1,203 @@
//! Activation codes authorize only a first passkey enrollment, never a normal actor.
//!
//! Options do not consume the code: cancellation/reload may resume with ID and code.
//! Each reissue replaces the issuance identity, invalidating every previous ceremony.
//! Credential insertion and removal of the pending row share the store's write transaction.
//! Only hashes are persisted; losing the one issuance response requires explicit reissue.
use super::*;
use ring::rand::{SecureRandom, SystemRandom};
/// Plaintext is returned only to the issuing administrator, never through user records.
#[derive(Clone)]
pub struct ActivationCodeIssue {
pub user: UserRecord,
pub code: String,
pub expires_at: String,
}
impl std::fmt::Debug for ActivationCodeIssue {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ActivationCodeIssue")
.field("user", &self.user)
.field("code", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.finish()
}
}
fn denied() -> Error {
crate::auth::auth_error(
"activation_code_invalid",
"activation code is invalid, expired or locked",
)
}
pub(super) fn pending(conn: &Connection, user_id: &str) -> Result<bool> {
Ok(conn.query_row(
"SELECT EXISTS(SELECT 1 FROM user_activations WHERE user_id=?1)",
[user_id],
|r| r.get(0),
)?)
}
pub(super) fn require_active(conn: &Connection, user_id: &str) -> Result<()> {
if pending(conn, user_id)? {
return Err(crate::auth::auth_error(
"activation_required",
"a first passkey must be registered before account use",
));
}
Ok(())
}
pub(super) fn require_active_account(conn: &Connection, account_id: &str) -> Result<()> {
let pending: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN user_activations p ON p.user_id=u.user_id WHERE u.account_id=?1)", [account_id], |r| r.get(0))?;
if pending {
return Err(crate::auth::auth_error(
"activation_required",
"a pending account cannot own a Workspace",
));
}
Ok(())
}
fn require_admin(tx: &Connection, actor: &server_api::RequestActor) -> Result<()> {
let allowed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id=h.user_id JOIN accounts a ON a.account_id=u.account_id WHERE u.user_id=?1 AND u.account_id=?2 AND a.kind='user' AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=u.user_id))",
params![actor.user_id, actor.account_id], |r| r.get(0),
)?;
if !allowed {
return Err(Error::WorkspacePermissionDenied(
"hosting_admin_required: a live hosting administrator is required".into(),
));
}
Ok(())
}
fn issue(tx: &Connection, user: UserRecord) -> Result<ActivationCodeIssue> {
let mut bytes = [0u8; 32];
SystemRandom::new()
.fill(&mut bytes)
.map_err(|_| Error::Store("activation code entropy unavailable".into()))?;
let code: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
let expires_at = crate::auth::rfc3339_after(chrono::Duration::minutes(15));
let issuance_id = crate::auth::new_id("activation");
tx.execute("INSERT INTO user_activations(user_id,issuance_id,code_hash,expires_at,failed_attempts) VALUES(?1,?2,?3,?4,0) ON CONFLICT(user_id) DO UPDATE SET issuance_id=excluded.issuance_id,code_hash=excluded.code_hash,expires_at=excluded.expires_at,failed_attempts=0",
params![user.user_id, issuance_id, crate::auth::token_hash(&code), expires_at])?;
Ok(ActivationCodeIssue {
user,
code,
expires_at,
})
}
pub(super) fn create(
store: &SqliteWorkspaceStore,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
let handle = crate::auth::normalize_handle(handle)?;
store.with_immediate_transaction(|tx| {
require_admin(tx, actor)?;
let occupied: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM users WHERE handle=?1) OR EXISTS(SELECT 1 FROM accounts WHERE kind='user' AND handle=?1)", [&handle], |r| r.get(0))?;
if occupied { return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered")); }
let now = crate::auth::now_rfc3339();
let user = UserRecord { user_id: crate::auth::new_id("user"), account_id: crate::auth::new_id("acct-user"), handle: handle.clone(), display_name: handle.clone(), created_at: now.clone(), updated_at: now.clone() };
tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?2,?3,?3)", params![user.account_id, handle, now])?;
tx.execute("INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?3,?4,?4)", params![user.user_id, user.account_id, handle, now])?;
issue(tx, user)
})
}
pub(super) fn reissue(
store: &SqliteWorkspaceStore,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
let handle = crate::auth::normalize_handle(handle)?;
store.with_immediate_transaction(|tx| {
require_admin(tx, actor)?;
let user = tx
.query_row(
user_select_sql("WHERE handle=?1").as_str(),
[&handle],
read_user_record,
)
.optional()?
.ok_or_else(denied)?;
if !pending(tx, &user.user_id)? {
return Err(denied());
}
require_pending_target(tx, &user, None)?;
issue(tx, user)
})
}
/// A server-retained target is still checked against current account and issuance authority.
/// Reissue may replace an expired/locked issuance, but completion never may use one.
pub(super) fn require_pending_target(
conn: &Connection,
user: &UserRecord,
issuance: Option<&str>,
) -> Result<()> {
let valid: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN accounts a ON a.account_id=u.account_id JOIN user_activations p ON p.user_id=u.user_id WHERE u.user_id=?1 AND u.account_id=?2 AND u.handle=?3 AND a.kind='user' AND a.handle=u.handle AND NOT EXISTS(SELECT 1 FROM passkey_credentials c WHERE c.user_id=u.user_id))", params![user.user_id,user.account_id,user.handle], |r| r.get(0))?;
if !valid {
return Err(denied());
}
if let Some(issuance) = issuance {
let expiry: Option<String> = conn.query_row("SELECT expires_at FROM user_activations WHERE user_id=?1 AND issuance_id=?2 AND failed_attempts<5", params![user.user_id,issuance], |r| r.get(0)).optional()?;
if !expiry.is_some_and(|expiry| !crate::auth::is_expired(&expiry)) {
return Err(denied());
}
}
Ok(())
}
pub(super) fn verify(
store: &SqliteWorkspaceStore,
handle: &str,
code: &str,
) -> Result<PasskeyRegistrationTarget> {
let handle = crate::auth::normalize_handle(handle).map_err(|_| denied())?;
// Inner result is deliberate: rejected attempts must commit before the public error.
store.with_immediate_transaction(|tx| {
let user = tx.query_row(user_select_sql("WHERE handle=?1").as_str(), [&handle], read_user_record).optional()?;
let Some(user) = user else { return Ok(Err(denied())); };
let row: Option<(String,String,String,i64)> = tx.query_row("SELECT issuance_id,code_hash,expires_at,failed_attempts FROM user_activations WHERE user_id=?1", [&user.user_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
let Some((issuance_id,hash,expiry,attempts)) = row else { return Ok(Err(denied())); };
if attempts >= 5 || crate::auth::is_expired(&expiry) { return Ok(Err(denied())); }
if crate::auth::token_hash(code) != hash {
tx.execute("UPDATE user_activations SET failed_attempts=failed_attempts+1 WHERE user_id=?1", [&user.user_id])?;
return Ok(Err(denied()));
}
require_pending_target(tx, &user, Some(&issuance_id))?;
Ok(Ok(PasskeyRegistrationTarget::Activation { user, issuance_id }))
})?
}
pub(super) fn migrate(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 99 {
return Err(Error::Store(
"Activation migration requires schema 99".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(include_str!("activation_schema.sql"))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(100,?1)",
["Provisional users and scoped passkey activation"],
)?;
verify_workspace_resource_constraints(&tx)?;
let violations: i64 =
tx.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| {
r.get(0)
})?;
if violations != 0 {
return Err(Error::Store(
"Activation migration foreign-key violation".into(),
));
}
tx.commit()?;
Ok(())
}
@@ -0,0 +1,7 @@
CREATE TABLE user_activations (
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
issuance_id TEXT NOT NULL UNIQUE,
code_hash TEXT NOT NULL,
expires_at TEXT NOT NULL,
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts BETWEEN 0 AND 5)
);
@@ -0,0 +1,684 @@
// Component tests use production store methods and real isolated SQLite files/connections.
// WebAuthn verification is outside this suite: credentials enter at the verified-store boundary.
// Included in store::tests to reuse genuine retained migration fixtures. Run with `activation_`.
fn activation_fixture() -> (SqliteWorkspaceStore, server_api::RequestActor) {
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let admin = store.setup_hosting_admin("admin", true).unwrap();
let actor = hosting_actor(&admin.user);
(store, actor)
}
#[test]
fn activation_options_resume_without_consuming_and_completion_is_one_time() {
let (store, admin) = activation_fixture();
assert!(!store.hosting_self_registration_enabled().unwrap());
let issue = store
.create_provisional_user(&admin, " New-Person ")
.unwrap();
assert_eq!(issue.user.handle, "new-person");
assert_eq!(issue.user.display_name, "new-person");
assert!(
store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert_eq!(
store.get_user(&issue.user.user_id).unwrap(),
Some(issue.user.clone())
);
let target = store
.verify_activation_code("NEW-PERSON", &issue.code)
.unwrap();
assert_eq!(
store
.verify_activation_code("new-person", &issue.code)
.unwrap(),
target
);
assert!(store.verify_activation_code("admin", &issue.code).is_err());
assert!(
store
.verify_activation_code("other-person", &issue.code)
.is_err()
);
assert!(
store
.list_passkey_credentials_for_user(&issue.user.user_id)
.unwrap()
.is_empty()
);
assert!(
store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "first-key"))
.unwrap();
assert!(
!store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert!(
store
.verify_activation_code("new-person", &issue.code)
.is_err()
);
assert!(store.reissue_activation_code(&admin, "new-person").is_err());
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "second-key"))
.is_err()
);
assert_eq!(
store
.list_passkey_credentials_for_user(&issue.user.user_id)
.unwrap()
.len(),
1
);
assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap());
}
#[test]
fn activation_invalid_attempts_commit_lockout_across_reopen_and_reissue_resets_it() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user);
let issue = store.create_provisional_user(&admin, "person").unwrap();
let target = store.verify_activation_code("person", &issue.code).unwrap();
for expected in 1..=5 {
assert!(store.verify_activation_code("person", "wrong").is_err());
store
.with_conn(|conn| {
let attempts: i64 = conn.query_row(
"SELECT failed_attempts FROM user_activations WHERE user_id=?1",
[&issue.user.user_id],
|r| r.get(0),
)?;
assert_eq!(attempts, expected);
Ok(())
})
.unwrap();
}
drop(store);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(store.verify_activation_code("person", &issue.code).is_err());
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "locked-key"))
.is_err()
);
let next = store.reissue_activation_code(&admin, "PERSON").unwrap();
assert!(issue.code != next.code, "reissue must replace the secret");
assert!(store.verify_activation_code("person", &issue.code).is_err());
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "old-key"))
.is_err()
);
let target = store.verify_activation_code("person", &next.code).unwrap();
store
.register_verified_passkey(&target, &hosting_credential(&next.user, "fresh-key"))
.unwrap();
}
#[test]
fn activation_expiry_and_reissued_ceremonies_are_rechecked_at_completion() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let old = store.verify_activation_code("person", &issue.code).unwrap();
let next = store.reissue_activation_code(&admin, "person").unwrap();
assert!(
store
.register_verified_passkey(&old, &hosting_credential(&issue.user, "old-key"))
.is_err()
);
let target = store.verify_activation_code("person", &next.code).unwrap();
store
.with_conn(|conn| {
conn.execute(
"UPDATE user_activations SET expires_at='2000-01-01T00:00:00Z'",
[],
)?;
Ok(())
})
.unwrap();
assert!(store.verify_activation_code("person", &next.code).is_err());
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "expired-key"))
.is_err()
);
assert!(
store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert!(
store
.list_passkey_credentials_for_user(&issue.user.user_id)
.unwrap()
.is_empty()
);
}
#[test]
fn activation_credential_failure_rolls_back_pending_removal_and_allows_retry() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let target = store.verify_activation_code("person", &issue.code).unwrap();
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_activation_credential BEFORE INSERT ON passkey_credentials BEGIN SELECT RAISE(ABORT,'injected credential failure'); END;")?; Ok(()) }).unwrap();
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.is_err()
);
assert!(
store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert!(
store
.list_passkey_credentials_for_user(&issue.user.user_id)
.unwrap()
.is_empty()
);
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER reject_activation_credential")?;
Ok(())
})
.unwrap();
let target = store.verify_activation_code("person", &issue.code).unwrap();
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.unwrap();
assert!(
!store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
}
#[test]
fn activation_completion_checks_account_issuance_user_and_absence_of_credentials() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let original = store.verify_activation_code("person", &issue.code).unwrap();
let PasskeyRegistrationTarget::Activation { user, issuance_id } = &original else {
panic!("wrong purpose")
};
let mut wrong_user = user.clone();
wrong_user.account_id = admin.account_id;
let wrong = PasskeyRegistrationTarget::Activation {
user: wrong_user,
issuance_id: issuance_id.clone(),
};
assert!(
store
.register_verified_passkey(&wrong, &hosting_credential(user, "wrong-account"))
.is_err()
);
let mut credential = hosting_credential(user, "wrong-user");
credential.user_id = admin.user_id;
assert!(
store
.register_verified_passkey(&original, &credential)
.is_err()
);
// Existing credentials, even if injected outside public admission, may not become recovery.
store.with_conn(|conn| { conn.execute("INSERT INTO passkey_credentials(credential_id,user_id,public_key_cose,sign_count,created_at) VALUES('existing',?1,'key',0,'1')", [&user.user_id])?; Ok(()) }).unwrap();
assert!(
store
.register_verified_passkey(&original, &hosting_credential(user, "second"))
.is_err()
);
assert!(
store
.reissue_activation_code(
&hosting_actor(&store.get_user_by_handle("admin").unwrap().unwrap()),
"person"
)
.is_err()
);
assert!(store.is_user_pending_activation(&user.user_id).unwrap());
}
#[test]
fn activation_admin_authority_is_live_and_duplicate_handles_preserve_accounts() {
let (store, admin) = activation_fixture();
let existing = store.setup_hosting_admin("existing", true).unwrap().user;
let normal = hosting_actor(&existing);
store
.with_conn(|conn| {
conn.execute(
"DELETE FROM hosting_administrators WHERE user_id=?1",
[&existing.user_id],
)?;
Ok(())
})
.unwrap();
let issue = store.create_provisional_user(&admin, "person").unwrap();
assert!(store.create_provisional_user(&normal, "forbidden").is_err());
assert!(store.reissue_activation_code(&normal, "person").is_err());
let mut fake = admin.clone();
fake.account_id = existing.account_id.clone();
assert!(store.create_provisional_user(&fake, "fake").is_err());
assert!(store.reissue_activation_code(&fake, "person").is_err());
for handle in ["existing", "EXISTING", "person"] {
assert!(store.create_provisional_user(&admin, handle).is_err());
}
assert_eq!(store.get_user(&existing.user_id).unwrap(), Some(existing));
assert!(store.reissue_activation_code(&admin, "existing").is_err());
store
.with_conn(|conn| {
conn.execute(
"DELETE FROM hosting_administrators WHERE user_id=?1",
[&admin.user_id],
)?;
Ok(())
})
.unwrap();
assert!(store.create_provisional_user(&admin, "revoked").is_err());
assert!(store.reissue_activation_code(&admin, "person").is_err());
assert_eq!(
store.verify_activation_code("person", &issue.code).unwrap(),
store.verify_activation_code("person", &issue.code).unwrap()
);
}
#[test]
fn activation_pending_cannot_mint_or_resolve_normal_auth_or_gain_hosting_setup() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let session = BrowserSessionRecord {
session_id: "session".into(),
token_hash: "session-hash".into(),
user_id: issue.user.user_id.clone(),
created_at: "1".into(),
expires_at: crate::auth::rfc3339_after(chrono::Duration::hours(1)),
revoked_at: None,
};
let token = ApiTokenRecord {
token_id: "token".into(),
token_hash: "token-hash".into(),
user_id: issue.user.user_id.clone(),
label: "test".into(),
created_at: "1".into(),
expires_at: None,
revoked_at: None,
last_used_at: None,
};
assert!(store.create_browser_session(&session).is_err());
assert!(store.create_api_token(&token).is_err());
assert!(
store
.upsert_passkey_credential(&hosting_credential(&issue.user, "bypass"))
.is_err()
);
assert!(store.setup_hosting_admin("person", false).is_err());
// Corrupt/legacy credential rows cannot bypass resolution or authorize Addition.
store.with_conn(|conn| {
conn.execute("INSERT INTO browser_sessions(session_id,token_hash,user_id,created_at,expires_at) VALUES(?1,?2,?3,?4,?5)", params![session.session_id,session.token_hash,session.user_id,session.created_at,session.expires_at])?;
conn.execute("INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at) VALUES(?1,?2,?3,?4,?5)", params![token.token_id,token.token_hash,token.user_id,token.label,token.created_at])?;
Ok(())
}).unwrap();
assert!(
store
.resolve_browser_session(&session.token_hash)
.unwrap()
.is_none()
);
assert!(
store
.resolve_api_token(&token.token_hash)
.unwrap()
.is_none()
);
assert!(
store
.register_verified_passkey(
&PasskeyRegistrationTarget::Addition {
user: issue.user.clone(),
authentication: PasskeyAuthenticationBinding::ApiToken(
token.token_hash.clone()
)
},
&hosting_credential(&issue.user, "addition")
)
.is_err()
);
assert!(
store
.approve_device_login_flow(
"unknown",
&issue.user.user_id,
&token.token_id,
"secret",
"1"
)
.is_err()
);
store
.with_conn(|conn| {
assert!(activation::require_active_account(conn, &issue.user.account_id).is_err());
Ok(())
})
.unwrap();
let target = store.verify_activation_code("person", &issue.code).unwrap();
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.unwrap();
store
.create_browser_session(&BrowserSessionRecord {
session_id: "active".into(),
token_hash: "active-hash".into(),
..session
})
.unwrap();
store
.create_api_token(&ApiTokenRecord {
token_id: "active".into(),
token_hash: "active-hash".into(),
..token
})
.unwrap();
}
#[test]
fn activation_plaintext_never_persists_and_pending_survives_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user);
let issue = store.create_provisional_user(&admin, "person").unwrap();
assert!(!format!("{issue:?}").contains(&issue.code));
let expiry = chrono::DateTime::parse_from_rfc3339(&issue.expires_at).unwrap();
let seconds = (expiry.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds();
assert!((890..=900).contains(&seconds));
store
.with_conn(|conn| {
let hash: String = conn.query_row(
"SELECT code_hash FROM user_activations WHERE user_id=?1",
[&issue.user.user_id],
|r| r.get(0),
)?;
assert_eq!(hash, crate::auth::token_hash(&issue.code));
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
Ok(())
})
.unwrap();
drop(store);
for entry in std::fs::read_dir(dir.path()).unwrap() {
let bytes = std::fs::read(entry.unwrap().path()).unwrap();
assert!(
!bytes
.windows(issue.code.len())
.any(|window| window == issue.code.as_bytes())
);
}
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert!(
reopened
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert!(
reopened
.verify_activation_code("person", &issue.code)
.is_ok()
);
}
#[test]
fn activation_parallel_options_complete_once_and_parallel_duplicate_create_has_one_winner() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let first = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
let admin = hosting_actor(&first.setup_hosting_admin("admin", true).unwrap().user);
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
let jobs = [first.clone(), second.clone()]
.into_iter()
.map(|store| {
let barrier = barrier.clone();
let admin = admin.clone();
std::thread::spawn(move || {
barrier.wait();
store.create_provisional_user(&admin, "person")
})
})
.collect::<Vec<_>>();
let results = jobs
.into_iter()
.map(|job| job.join().unwrap())
.collect::<Vec<_>>();
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
let issue = results.into_iter().find_map(|result| result.ok()).unwrap();
let targets = [
first.verify_activation_code("person", &issue.code).unwrap(),
second
.verify_activation_code("person", &issue.code)
.unwrap(),
];
let jobs = [first.clone(), second]
.into_iter()
.zip(targets)
.enumerate()
.map(|(index, (store, target))| {
let barrier = barrier.clone();
let credential = hosting_credential(&issue.user, &format!("key-{index}"));
std::thread::spawn(move || {
barrier.wait();
store.register_verified_passkey(&target, &credential)
})
})
.collect::<Vec<_>>();
let results = jobs
.into_iter()
.map(|job| job.join().unwrap())
.collect::<Vec<_>>();
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
assert!(
!first
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert_eq!(
first
.list_passkey_credentials_for_user(&issue.user.user_id)
.unwrap()
.len(),
1
);
}
#[test]
fn activation_oldest_and_previous_schema_migrate_with_same_constraints_and_preserve_users() {
fn schema(conn: &Connection) -> String {
conn.query_row(
"SELECT sql FROM sqlite_schema WHERE name='user_activations'",
[],
|r| r.get(0),
)
.unwrap()
}
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let expected = fresh.with_conn(|conn| Ok(schema(conn))).unwrap();
for baseline in [50, 99] {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
if baseline == 50 {
prepare_schema_v50(&path, None);
} else {
prepare_retained_schema(&path, baseline);
}
let conn = Connection::open(&path).unwrap();
conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old','Old','1','1'); INSERT INTO users VALUES('old-user','old-account','old','Old','1','1');").unwrap();
drop(conn);
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
assert_eq!(
store.get_user("old-user").unwrap().unwrap().display_name,
"Old"
);
assert!(!store.is_user_pending_activation("old-user").unwrap());
store.with_conn(|conn| {
assert_eq!(schema(conn), expected);
assert_eq!(current_schema_version(conn)?, 100);
assert!(conn.execute("INSERT INTO user_activations VALUES('missing','issue','hash','future',0)", []).is_err());
for attempts in [-1, 6] { assert!(conn.execute("INSERT INTO user_activations VALUES('old-user','issue','hash','future',?1)", [attempts]).is_err()); }
Ok(())
}).unwrap();
}
}
#[test]
fn activation_migration_failure_rolls_back_table_and_history() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
prepare_retained_schema(&path, 99);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch("CREATE TRIGGER reject_activation_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=100 BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;").unwrap();
assert!(activation::migrate(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 99);
assert!(!table_exists(&conn, "user_activations").unwrap());
conn.execute_batch("DROP TRIGGER reject_activation_migration")
.unwrap();
activation::migrate(&conn).unwrap();
assert!(table_exists(&conn, "user_activations").unwrap());
assert_eq!(current_schema_version(&conn).unwrap(), 100);
}
#[tokio::test]
async fn activation_pending_workspace_owner_is_rejected_by_both_store_creation_paths() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let workspace = WorkspaceRecord {
workspace_id: "pending-workspace".into(),
owner_account_id: issue.user.account_id.clone(),
display_name: "Pending".into(),
state: "active".into(),
created_at: "1".into(),
updated_at: "1".into(),
};
assert!(
store
.upsert_workspace(&workspace)
.await
.unwrap_err()
.to_string()
.contains("activation_required")
);
let signing = WorkspaceSigningIdentityActivation {
workspace_id: workspace.workspace_id.clone(),
key_id: "key".into(),
public_key: "public".into(),
public_key_fingerprint: "sha256:key".into(),
private_material_ref: "private-ref".into(),
provisioned_at: "1".into(),
};
let record = WorkspaceBootstrapRecord {
operation_key: "operation".into(),
request_fingerprint: "sha256:request".into(),
workspace: workspace.clone(),
repository: None,
};
assert!(
store
.create_workspace_bootstrap(&record, &signing, "provision")
.unwrap_err()
.to_string()
.contains("activation_required")
);
assert!(
store
.get_workspace(&workspace.workspace_id)
.await
.unwrap()
.is_none()
);
let target = store.verify_activation_code("person", &issue.code).unwrap();
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.unwrap();
store.upsert_workspace(&workspace).await.unwrap();
assert_eq!(
store.get_workspace(&workspace.workspace_id).await.unwrap(),
Some(workspace)
);
}
#[test]
fn activation_pending_removal_failure_rolls_back_inserted_credential() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let target = store.verify_activation_code("person", &issue.code).unwrap();
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_pending_removal BEFORE DELETE ON user_activations BEGIN SELECT RAISE(ABORT,'injected activation failure'); END;")?; Ok(()) }).unwrap();
assert!(
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.is_err()
);
assert!(
store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
assert!(store.get_passkey_credential("key").unwrap().is_none());
store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER reject_pending_removal")?;
Ok(())
})
.unwrap();
store
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
.unwrap();
assert!(
!store
.is_user_pending_activation(&issue.user.user_id)
.unwrap()
);
}
#[test]
fn activation_pending_device_token_is_not_delivered_even_with_injected_approval() {
let (store, admin) = activation_fixture();
let issue = store.create_provisional_user(&admin, "person").unwrap();
let flow = DeviceLoginFlowRecord {
device_code: "device".into(),
user_code: "CODE".into(),
verification_uri: "/device".into(),
client_name: None,
user_id: Some(issue.user.user_id.clone()),
api_token_id: None,
issued_access_token: Some("not-deliverable".into()),
created_at: "1".into(),
expires_at: crate::auth::rfc3339_after(chrono::Duration::minutes(5)),
approved_at: Some("1".into()),
consumed_at: None,
};
assert!(store.try_create_device_login_flow(&flow).is_err());
store.with_conn(|conn| { conn.execute("INSERT INTO device_login_flows(device_code,user_code,verification_uri,user_id,issued_access_token,created_at,expires_at,approved_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![flow.device_code,flow.user_code,flow.verification_uri,flow.user_id,flow.issued_access_token,flow.created_at,flow.expires_at,flow.approved_at])?; Ok(()) }).unwrap();
assert!(
store
.consume_device_login_token("device", "2")
.unwrap()
.is_none()
);
assert!(
store
.get_device_login_flow_by_device_code("device")
.unwrap()
.unwrap()
.consumed_at
.is_none()
);
assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap());
}
@@ -473,7 +473,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(96,"Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into()),
(99, "Explicit hosting administrators and self-registration policy".into())
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
@@ -381,6 +381,8 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations;
DROP TABLE user_activations;
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
@@ -511,6 +513,8 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
// Reconstruct the prior composition before its new business capture existed.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TABLE user_activations;
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
@@ -667,7 +671,9 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
DROP TABLE user_activations;
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
}
+165 -17
View File
@@ -3389,7 +3389,7 @@ async fn dispatch_workspace_request(
let path = request.uri().path().to_owned();
// Hosting setup/settings belong to the Server, including zero or multiple
// Workspaces. Serving their public shell does not grant their API authority.
if path.starts_with("/hosting/") {
if path.starts_with("/hosting/") || path == "/activate" {
return serve_server_static_shell(&api, &path).await;
}
let workspace_id = scoped_workspace_id(&path);
@@ -6729,12 +6729,19 @@ async fn attach_server_request_context(
fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
let service = Arc::new(service);
let origin_context = Router::new()
.merge(server_api::server_api_axum::auth_activation_options(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_registration_options(service.clone()))
.merge(server_api::server_api_axum::auth_passkey_login_options(
service.clone(),
))
.layer(middleware::from_fn(attach_server_origin_context));
let actor_context = Router::new()
.merge(server_api::server_api_axum::hosting_account_create(
service.clone(),
))
.merge(server_api::server_api_axum::hosting_activation_code_reissue(service.clone()))
.merge(server_api::server_api_axum::hosting_access(service.clone()))
.merge(server_api::server_api_axum::hosting_settings(
service.clone(),
@@ -6764,6 +6771,9 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
.merge(server_api::server_api_axum::auth_bootstrap_user(
service.clone(),
))
.merge(server_api::server_api_axum::auth_activation_complete(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_registration_complete(service.clone()))
.merge(server_api::server_api_axum::auth_passkey_login_complete(
service.clone(),
@@ -7738,6 +7748,100 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error)
}
async fn hosting_account_create(
&self,
context: server_api::ServerRequestContext,
request: server_api::ProvisionalAccountRequest,
) -> std::result::Result<server_api::ActivationCodeResponse, server_api::RepositoryApiError>
{
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let actor = live_hosting_actor(&api, &context, true)?;
let issued = api.store.create_provisional_user(&actor, &request.handle)?;
Ok::<_, ApiError>(server_api::ActivationCodeResponse {
user: user_response(issued.user),
code: issued.code,
expires_at: issued.expires_at,
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn hosting_activation_code_reissue(
&self,
context: server_api::ServerRequestContext,
request: server_api::ProvisionalAccountRequest,
) -> std::result::Result<server_api::ActivationCodeResponse, server_api::RepositoryApiError>
{
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let actor = live_hosting_actor(&api, &context, true)?;
let issued = api.store.reissue_activation_code(&actor, &request.handle)?;
Ok::<_, ApiError>(server_api::ActivationCodeResponse {
user: user_response(issued.user),
code: issued.code,
expires_at: issued.expires_at,
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_activation_options(
&self,
context: server_api::ServerRequestContext,
request: server_api::ActivationOptionsRequest,
) -> std::result::Result<PasskeyRegistrationOptionsResponse, server_api::RepositoryApiError>
{
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
// Validate the allowed WebAuthn origin before spending an attempt. Neither
// a browser cookie nor a bearer token can select this enrollment target.
let origin = request
.browser_origin
.as_deref()
.or(context.origin.as_deref());
auth_config_for_origin(&api.config, origin)?;
let target = api
.store
.verify_activation_code(&request.handle, &request.code)?;
start_passkey_registration(&api, &HeaderMap::new(), origin, target)
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_activation_complete(
&self,
request: PasskeyRegistrationCompleteRequest,
) -> std::result::Result<
server_api::server_api_responses::AuthActivationComplete,
server_api::RepositoryApiError,
> {
let api = self.auth_api();
let (body, header_set_cookie) = api
.store
.db_executor()
.run(move || {
complete_passkey_enrollment(&api, request, EnrollmentCompletion::Activation)
})
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(
server_api::server_api_responses::AuthActivationComplete::Status200 {
body,
header_set_cookie,
},
)
}
async fn auth_bootstrap_user(
&self,
request: AuthBootstrapUserRequest,
@@ -7824,7 +7928,9 @@ impl server_api::ServerApi for ServerApiContractService {
let (body, header_set_cookie) = api
.store
.db_executor()
.run(move || complete_passkey_enrollment(&api, request, Some(&headers)))
.run(move || {
complete_passkey_enrollment(&api, request, EnrollmentCompletion::Addition(&headers))
})
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(
@@ -29557,6 +29663,13 @@ fn passkey_addition_authority(
.get_user(&actor.user_id)?
.filter(|user| user.account_id == actor.account_id)
.ok_or_else(|| auth_error("auth_required", "authenticated user no longer exists"))?;
if api.store.is_user_pending_activation(&user.user_id)? {
return Err(auth_error(
"passkey_activation_required",
"initial passkey activation is required",
)
.into());
}
Ok((user, binding))
}
@@ -29567,7 +29680,8 @@ fn start_passkey_registration(
target: PasskeyRegistrationTarget,
) -> ApiResult<PasskeyRegistrationOptionsResponse> {
let (user, exclude_credentials) = match &target {
PasskeyRegistrationTarget::NewAccount { user } => (user, Vec::new()),
PasskeyRegistrationTarget::NewAccount { user }
| PasskeyRegistrationTarget::Activation { user, .. } => (user, Vec::new()),
PasskeyRegistrationTarget::Addition { user, .. } => (
user,
passkeys_for_user(api, &user.user_id)?
@@ -29592,7 +29706,8 @@ fn start_passkey_registration(
// target (including its allocated user ID) is inside the purpose-tagged state.
let user_id = match &target {
PasskeyRegistrationTarget::NewAccount { .. } => None,
PasskeyRegistrationTarget::Addition { user, .. } => Some(user.user_id.clone()),
PasskeyRegistrationTarget::Addition { user, .. }
| PasskeyRegistrationTarget::Activation { user, .. } => Some(user.user_id.clone()),
};
api.store.put_auth_challenge(&AuthChallengeRecord {
challenge_id: challenge_id.clone(),
@@ -29632,17 +29747,26 @@ fn complete_passkey_registration(
api: &ServerAuthApi,
request: PasskeyRegistrationCompleteRequest,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
complete_passkey_enrollment(api, request, None)
complete_passkey_enrollment(api, request, EnrollmentCompletion::NewAccount)
}
/// Completion endpoints select a purpose, not a caller-controlled body field.
/// Activation authority never satisfies the public registration or addition path.
enum EnrollmentCompletion<'a> {
NewAccount,
Addition(&'a HeaderMap),
Activation,
}
fn complete_passkey_enrollment(
api: &ServerAuthApi,
request: PasskeyRegistrationCompleteRequest,
addition_headers: Option<&HeaderMap>,
completion: EnrollmentCompletion<'_>,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
let authority = addition_headers
.map(|headers| passkey_addition_authority(api, headers))
.transpose()?;
let authority = match completion {
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
_ => None,
};
let challenge = api
.store
.consume_auth_challenge_by_id(
@@ -29676,10 +29800,12 @@ fn complete_passkey_enrollment(
"registration requires purpose-bound state",
)
})?;
let user = match (&envelope.target, authority) {
(PasskeyRegistrationTarget::NewAccount { user }, None)
if addition_headers.is_none() && challenge.user_id.is_none() =>
{
let user = match (&envelope.target, authority, completion) {
(
PasskeyRegistrationTarget::NewAccount { user },
None,
EnrollmentCompletion::NewAccount,
) if challenge.user_id.is_none() => {
require_self_registration(api)?;
user.clone()
}
@@ -29689,6 +29815,7 @@ fn complete_passkey_enrollment(
authentication,
},
Some((current, binding)),
EnrollmentCompletion::Addition(_),
) if user.user_id == current.user_id
&& user.account_id == current.account_id
&& *authentication == binding
@@ -29696,6 +29823,15 @@ fn complete_passkey_enrollment(
{
current
}
(
PasskeyRegistrationTarget::Activation { user, .. },
None,
EnrollmentCompletion::Activation,
) if challenge.user_id.as_deref() == Some(user.user_id.as_str()) => {
// The verified credential transaction rechecks the live pending row,
// issuance, code expiry and attempts before atomically activating it.
user.clone()
}
_ => {
return Err(auth_error(
"passkey_registration_binding_mismatch",
@@ -29748,6 +29884,13 @@ async fn post_passkey_login_options(
}
}
.ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?;
if api.store.is_user_pending_activation(&user.user_id)? {
return Err(auth_error(
"passkey_activation_required",
"initial passkey activation is required",
)
.into());
}
let passkeys = passkeys_for_user(&api, &user.user_id)?;
if passkeys.is_empty() {
return Err(auth_error(
@@ -30253,7 +30396,8 @@ async fn resolve_actor(
// Hosting endpoints re-resolve actual transport credentials, not actor-like headers,
// JSON or a stale middleware snapshot. The store rechecks live admin authority in
// the settings transaction; cookie writes also enforce Origin at this direct entry.
// the write transaction. Cookie writes require Origin; non-browser bearer calls
// may omit it, but a supplied foreign Origin never becomes trusted via a bearer.
fn live_hosting_actor(
api: &ServerAuthApi,
context: &server_api::ServerRequestContext,
@@ -30268,12 +30412,14 @@ fn live_hosting_actor(
parse_cookie(&headers, &auth.cookie_name),
)?
.ok_or_else(|| auth_error("auth_required", "current authentication required"))?;
let origin = request_origin(&headers);
if mutating
&& actor.auth_method == ActorAuthMethod::BrowserSession
&& request_origin(&headers).as_deref() != Some(auth.origin.as_str())
&& (actor.auth_method == ActorAuthMethod::BrowserSession || origin.is_some())
&& origin.as_deref() != Some(auth.origin.as_str())
{
return Err(Error::WorkspacePermissionDenied(
"hosting settings require configured Browser origin".into(),
"hosting mutations require configured Browser origin when supplied, and for cookies"
.into(),
)
.into());
}
@@ -51517,6 +51663,8 @@ mod tests {
use super::*;
use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey};
include!("server_activation_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = WebauthnAuthenticator<SoftPasskey>;
@@ -0,0 +1,318 @@
// Production generated routes, SQLite and WebAuthn verifier share the passkey
// security fixture. Only the authenticator is SoftPasskey, not a browser/OS key.
// The ordinary Server test target selects these isolated integration cases.
impl Fixture {
async fn activation_admin(&self) -> String {
let (cookie, user) = self
.enroll(
"registration",
None,
"activation-admin",
&mut authenticator(),
)
.await;
self.api
.config_store
.setup_hosting_admin(&user.handle, false)
.unwrap();
let actor = RequestActor {
user_id: user.user_id,
account_id: user.account_id,
handle: user.handle,
display_name: user.display_name,
auth_method: ActorAuthMethod::BrowserSession,
};
self.api
.store
.set_hosting_self_registration_enabled(&actor, false)
.unwrap();
cookie
}
async fn issue_activation(
&self,
admin: &str,
handle: &str,
) -> server_api::ActivationCodeResponse {
let (status, headers, body) = self
.post(
"/api/hosting/accounts",
Some(admin),
Some(ORIGIN_URL),
serde_json::json!({"handle":handle}),
)
.await;
assert_eq!(
status,
StatusCode::OK,
"code issuance must succeed without printing its response"
);
assert_eq!(headers[CACHE_CONTROL], "no-store");
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
serde_json::from_value(body).unwrap()
}
async fn activation_options(
&self,
issued: &server_api::ActivationCodeResponse,
) -> PasskeyRegistrationOptionsResponse {
let (status, headers, body) = self
.post(
"/api/auth/activation/options",
None,
Some(ORIGIN_URL),
serde_json::json!({"handle": issued.user.handle, "code": issued.code}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[CACHE_CONTROL], "no-store");
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert!(body.get("access_token").is_none());
serde_json::from_value(body).unwrap()
}
}
#[tokio::test]
async fn activation_requires_verified_passkey_and_never_grants_hosting_authority() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "pending-person").await;
assert!(!f.api.store.hosting_self_registration_enabled().unwrap());
let abandoned = f.activation_options(&issued).await;
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(f.credentials(&issued.user).is_empty());
rejected(
f.post(
"/api/auth/passkeys/login/options",
None,
None,
serde_json::json!({"handle":issued.user.handle}),
)
.await,
StatusCode::BAD_REQUEST,
"passkey",
);
// A valid attestation for a different challenge cannot activate this identity.
let options = f.activation_options(&issued).await;
let mut tampered = Fixture::credential(abandoned, &mut authenticator());
tampered["challenge_id"] = Value::String(options.challenge_id);
rejected(
f.post("/api/auth/activation/complete", None, None, tampered)
.await,
StatusCode::BAD_REQUEST,
"webauthn_registration_verification_failed",
);
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(f.credentials(&issued.user).is_empty());
// Cancellation/failure did not spend the activation code. Fresh options resume.
let options = f.activation_options(&issued).await;
let mut key = authenticator();
let complete = Fixture::credential(options, &mut key);
let (status, headers, body) = f
.post(
"/api/auth/activation/complete",
None,
None,
complete.clone(),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(body["user"]["user_id"], issued.user.user_id);
assert!(
!f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
assert!(!f.api.store.is_hosting_admin(&issued.user.user_id).unwrap());
assert_eq!(f.credentials(&issued.user).len(), 1);
rejected(
f.post("/api/auth/activation/complete", None, None, complete)
.await,
StatusCode::BAD_REQUEST,
"invalid_passkey_challenge",
);
let (status, headers, _) = f
.post(
"/api/auth/activation/options",
None,
None,
serde_json::json!({"handle":issued.user.handle,"code":issued.code}),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(f.login("pending-person", &mut key).await, issued.user);
}
#[tokio::test]
async fn activation_ceremonies_cannot_be_used_by_registration_or_addition_endpoints() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "purpose-bound").await;
for endpoint in [
"/api/auth/passkeys/registration/complete",
"/api/auth/passkeys/addition/complete",
] {
let options = f.activation_options(&issued).await;
let complete = Fixture::credential(options, &mut authenticator());
let (status, _, _) = f
.post(endpoint, Some(&admin), Some(ORIGIN_URL), complete)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(f.credentials(&issued.user).is_empty());
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
}
// The converse also fails: even a logged-in administrator's addition ceremony
// must not become an activation or create credentials for the provisional user.
let options = f.options("addition", Some(&admin), "ignored").await;
let complete = Fixture::credential(options, &mut authenticator());
rejected(
f.post(
"/api/auth/activation/complete",
Some(&admin),
Some(ORIGIN_URL),
complete,
)
.await,
StatusCode::BAD_REQUEST,
"passkey_registration_binding_mismatch",
);
assert!(f.credentials(&issued.user).is_empty());
}
#[tokio::test]
async fn activation_reissue_invalidates_even_a_previously_verified_code_ceremony() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "rotated").await;
let options = f.activation_options(&issued).await;
let complete = Fixture::credential(options, &mut authenticator());
let (status, headers, body) = f
.post(
"/api/hosting/accounts/activation-code",
Some(&admin),
Some(ORIGIN_URL),
serde_json::json!({"handle":"rotated"}),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(headers[CACHE_CONTROL], "no-store");
let replacement: server_api::ActivationCodeResponse = serde_json::from_value(body).unwrap();
assert!(
issued.code != replacement.code,
"reissue must replace the secret"
);
let (status, _, _) = f
.post("/api/auth/activation/complete", None, None, complete)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(f.credentials(&issued.user).is_empty());
assert!(
f.api
.store
.is_user_pending_activation(&issued.user.user_id)
.unwrap()
);
let _ = f.activation_options(&replacement).await;
}
#[tokio::test]
async fn activation_two_valid_ceremonies_complete_once_and_save_no_extra_credential() {
let f = Fixture::new().await;
let admin = f.activation_admin().await;
let issued = f.issue_activation(&admin, "one-winner").await;
let first = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
let second = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
let (a, b) = tokio::join!(
f.post("/api/auth/activation/complete", None, None, first),
f.post("/api/auth/activation/complete", None, None, second)
);
assert_eq!(
[a.0, b.0]
.iter()
.filter(|status| **status == StatusCode::OK)
.count(),
1
);
assert_eq!(
[a.0, b.0]
.iter()
.filter(|status| **status == StatusCode::BAD_REQUEST)
.count(),
1
);
assert_eq!(f.credentials(&issued.user).len(), 1);
}
#[tokio::test]
async fn activation_direct_admin_mutations_reject_foreign_origin_even_with_live_bearer() {
use api_macros::HttpError;
use server_api::ServerApi;
let f = Fixture::new().await;
let cookie = f.activation_admin().await;
let token = f.device_token(&cookie).await;
let service = ServerApiContractService::Workspace(f.api.clone());
let context = |origin: Option<&str>| server_api::ServerRequestContext {
actor: None,
worker_source: None,
runtime_source: None,
// A forged extension cannot override the byte-exact transport Origin.
origin: Some(ORIGIN_URL.into()),
transport_headers: std::iter::once((
"authorization".into(),
format!("Bearer {token}").into_bytes(),
))
.chain(origin.map(|value| ("origin".into(), value.as_bytes().to_vec())))
.collect(),
};
for handle in ["new-id", "existing-pending"] {
let request = || server_api::ProvisionalAccountRequest {
handle: handle.into(),
};
assert_eq!(
service
.hosting_account_create(context(Some("https://foreign.test")), request())
.await
.unwrap_err()
.status_code(),
403
);
assert_eq!(
service
.hosting_activation_code_reissue(context(Some("https://foreign.test")), request())
.await
.unwrap_err()
.status_code(),
403
);
assert!(f.api.store.get_user_by_handle(handle).unwrap().is_none());
}
let issued = service
.hosting_account_create(
context(None),
server_api::ProvisionalAccountRequest {
handle: "non-browser-bearer".into(),
},
)
.await
.unwrap();
assert_eq!(issued.user.handle, "non-browser-bearer");
}
+96 -13
View File
@@ -92,6 +92,9 @@ use crate::backend_job::{
use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
#[path = "activation.rs"]
mod activation;
pub use activation::ActivationCodeIssue;
#[path = "assignment_delivery.rs"]
mod assignment_delivery;
#[path = "notification_diagnostics.rs"]
@@ -116,7 +119,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 99;
const LATEST_SCHEMA_VERSION: i64 = 100;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -419,6 +422,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Explicit hosting administrators and self-registration policy",
apply: migrate_hosting_policy_v98_to_v99,
},
Migration {
version: 100,
name: "Provisional users and scoped passkey activation",
apply: activation::migrate,
},
];
#[derive(Clone, Copy)]
@@ -711,6 +719,10 @@ pub enum PasskeyRegistrationTarget {
NewAccount {
user: UserRecord,
},
Activation {
user: UserRecord,
issuance_id: String,
},
Addition {
user: UserRecord,
authentication: PasskeyAuthenticationBinding,
@@ -1663,6 +1675,19 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
fn any_user(&self) -> Result<Option<UserRecord>>;
fn create_provisional_user(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue>;
fn reissue_activation_code(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue>;
fn verify_activation_code(&self, handle: &str, code: &str)
-> Result<PasskeyRegistrationTarget>;
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool>;
/// Checks an explicit grant for a currently existing user, not Workspace ownership.
fn is_hosting_admin(&self, user_id: &str) -> Result<bool>;
fn hosting_self_registration_enabled(&self) -> Result<bool>;
@@ -2685,7 +2710,10 @@ impl SqliteWorkspaceStore {
let (user, initial_bearer) = match (existing, create_user) {
(Some(_), true) => return Err(Error::Store("hosting-admin --create-user requires a new handle; no credentials issued".into())),
(None, false) => return Err(Error::Store("hosting-admin requires an existing user; use --create-user explicitly for a new user".into())),
(Some(user), false) => (user, None),
(Some(user), false) => {
activation::require_active(tx, &user.user_id)?;
(user, None)
},
(None, true) => {
let user = UserRecord {
user_id: crate::auth::new_id("user"),
@@ -4426,6 +4454,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|row| row.get::<_, String>(0),
)
.optional()?;
activation::require_active_account(&tx, &record.owner_account_id)?;
if owner_kind.as_deref() != Some("user") {
return Err(Error::Store(format!(
"Workspace owner `{}` must reference an existing User Account",
@@ -4607,6 +4636,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|row| row.get::<_, String>(0),
)
.optional()?;
activation::require_active_account(&tx, &record.workspace.owner_account_id)?;
if owner_kind.as_deref() != Some("user") {
return Err(Error::Store(format!(
"Workspace owner `{}` must reference an existing User Account",
@@ -6559,8 +6589,39 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn create_provisional_user(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
activation::create(self, actor, handle)
}
fn reissue_activation_code(
&self,
actor: &server_api::RequestActor,
handle: &str,
) -> Result<ActivationCodeIssue> {
activation::reissue(self, actor, handle)
}
fn verify_activation_code(
&self,
handle: &str,
code: &str,
) -> Result<PasskeyRegistrationTarget> {
activation::verify(self, handle, code)
}
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool> {
self.with_conn(|conn| activation::pending(conn, user_id))
}
fn is_hosting_admin(&self, user_id: &str) -> Result<bool> {
self.with_conn(|conn| {
if activation::pending(conn, user_id)? {
return Ok(false);
}
conn.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1)",
[user_id], |row| row.get(0),
@@ -6585,6 +6646,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
enabled: bool,
) -> Result<()> {
self.with_immediate_transaction(|tx| {
activation::require_active(tx, &actor.user_id)?;
let allowed: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1 AND u.account_id = ?2)",
params![actor.user_id, actor.account_id], |row| row.get(0),
@@ -6638,7 +6700,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)?;
user
}
PasskeyRegistrationTarget::Activation { user, issuance_id } => {
activation::require_pending_target(tx, user, Some(issuance_id))?;
user
}
PasskeyRegistrationTarget::Addition { user, authentication } => {
activation::require_active(tx, &user.user_id)?;
let current = tx.query_row(
user_select_sql("WHERE user_id = ?1 AND account_id = ?2").as_str(),
params![user.user_id, user.account_id], read_user_record,
@@ -6669,12 +6736,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
)?;
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
}
}
Ok(())
})
}
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
@@ -6784,7 +6857,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
@@ -6797,7 +6871,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn resolve_browser_session(&self, token_hash: &str) -> Result<Option<BrowserSessionRecord>> {
self.with_conn(|conn| {
conn.query_row(
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=browser_sessions.user_id)").as_str(),
params![token_hash],
read_browser_session_record,
)
@@ -6817,7 +6891,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
conn.execute(
r#"INSERT INTO api_tokens (token_id, token_hash, user_id, label, created_at, expires_at, revoked_at, last_used_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
@@ -6830,7 +6905,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>> {
self.with_conn(|conn| {
conn.query_row(
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=api_tokens.user_id)").as_str(),
params![token_hash],
read_api_token_record,
)
@@ -6850,7 +6925,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
if let Some(user_id) = &record.user_id { activation::require_active(conn, user_id)?; }
let changed = conn.execute(
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
@@ -6899,7 +6975,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
issued_access_token: &str,
approved_at: &str,
) -> Result<bool> {
self.with_conn(|conn| {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, user_id)?;
let changed = conn.execute(
r#"UPDATE device_login_flows
SET user_id = ?2, api_token_id = ?3, issued_access_token = ?4, approved_at = ?5
@@ -6921,8 +6998,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
device_code: &str,
consumed_at: &str,
) -> Result<Option<DeviceLoginFlowRecord>> {
self.with_conn(|conn| {
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL").as_str(), params![device_code], read_device_login_flow_record).optional()?;
self.with_immediate_transaction(|conn| {
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=device_login_flows.user_id)").as_str(), params![device_code], read_device_login_flow_record).optional()?;
if record.is_some() {
conn.execute("UPDATE device_login_flows SET consumed_at = ?2, issued_access_token = NULL WHERE device_code = ?1", params![device_code, consumed_at])?;
}
@@ -14808,7 +14885,9 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
// and component DDL to the shared cross-domain constraint composition below.
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?;
Ok(())
}
fn create_workspace_schema_structure_from_sql(
@@ -18055,6 +18134,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
}
include!("activation_tests.rs");
include!("schema_equivalence_tests.rs");
include!("ticket_tag_upgrade_tests.rs");
include!("ticket_post_upgrade_tests.rs");
@@ -18396,6 +18476,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
WorkspaceSchemaMigrationStep {version:99, name:"Explicit hosting administrators and self-registration policy".into()},
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
]
);
@@ -18505,6 +18586,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(97, "MR notifications and authenticated implementation results".to_string()),
(98, "Assignment-independent implementation reports".to_string()),
(99, "Explicit hosting administrators and self-registration policy".to_string()),
(100, "Provisional users and scoped passkey activation".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -21128,7 +21210,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(96, "Backend Job common notification routing".into()),
(97, "MR notifications and authenticated implementation results".into()),
(98, "Assignment-independent implementation reports".into()),
(99, "Explicit hosting administrators and self-registration policy".into())]);
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}