feat: activate provisional users with scoped passkey enrollment
This commit is contained in:
@@ -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;")?;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user