diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b80b10af4..5ba53348e 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -172,6 +172,9 @@ impl_openapi_schema!( AuthPublicConfig, HostingSettings, HostingAccess, + ProvisionalAccountRequest, + ActivationCodeResponse, + ActivationOptionsRequest, AuthUserResponse, DeviceLoginApproveRequest, DeviceLoginApproveResponse, @@ -539,6 +542,38 @@ pub trait ServerApi { #[body] request: HostingSettings, ) -> Result; + #[post("/api/hosting/accounts", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_account_create( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ProvisionalAccountRequest, + ) -> Result; + + #[post("/api/hosting/accounts/activation-code", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)] + async fn hosting_activation_code_reissue( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ProvisionalAccountRequest, + ) -> Result; + + #[post("/api/auth/activation/options", status = 200, error_status = 400, additional_error_statuses = [500])] + async fn auth_activation_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: ActivationOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/activation/complete", + responses = [(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])], + error_status = 400, + additional_error_statuses = [500] + )] + async fn auth_activation_complete( + &self, + #[body] request: PasskeyRegistrationCompleteRequest, + ) -> Result; + #[post( "/api/auth/bootstrap-user", status = 200, @@ -3886,6 +3921,60 @@ pub struct HostingAccess { pub is_admin: bool, } +/// A hosting administrator reserves the same normalized ID used by passkey login. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ProvisionalAccountRequest { + pub handle: String, +} + +/// The only response allowed to disclose an activation code. Clients must keep it +/// out of URLs, persistent browser storage and diagnostics. A lost response is +/// recovered by reissuing, which invalidates the previous code and its ceremonies. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ActivationCodeResponse { + pub user: AuthenticatedUser, + pub code: String, + pub expires_at: String, +} + +impl std::fmt::Debug for ActivationCodeResponse { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivationCodeResponse") + .field("user", &self.user) + .field("code", &"[REDACTED]") + .field("expires_at", &self.expires_at) + .finish() + } +} + +/// Proof for a pending account's initial passkey ceremony, never a bearer token +/// or browser session. No caller-selected account ID or enrollment purpose is accepted. +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ActivationOptionsRequest { + pub handle: String, + pub code: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub browser_origin: Option, +} + +impl std::fmt::Debug for ActivationOptionsRequest { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ActivationOptionsRequest") + .field("handle", &self.handle) + .field("code", &"[REDACTED]") + .field("browser_origin", &self.browser_origin) + .finish() + } +} + /// Authentication method that established the current request actor. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] @@ -11437,6 +11526,9 @@ pub fn auth_api_typescript() -> String { AuthPublicConfig::decl(&config), HostingSettings::decl(&config), HostingAccess::decl(&config), + ProvisionalAccountRequest::decl(&config), + ActivationCodeResponse::decl(&config), + ActivationOptionsRequest::decl(&config), ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), @@ -14423,6 +14515,8 @@ mod openapi_artifact_tests { "health", "auth_config", "auth_registration_policy", + "auth_activation_options", + "auth_activation_complete", "auth_bootstrap_user", "auth_passkey_registration_options", "auth_passkey_registration_complete", @@ -14845,3 +14939,47 @@ mod workdir_error_diagnostic_tests { assert!(schema["properties"].get("denial_reason").is_none()); } } + +#[cfg(test)] +mod activation_contract_tests { + use super::*; + + #[test] + fn activation_inputs_reject_caller_selected_authority_and_debug_redacts_codes() { + for field in [ + "account_id", + "user_id", + "purpose", + "is_admin", + "authentication", + ] { + let mut body = serde_json::json!({"handle":"new-user","code":"activation-secret"}); + body[field] = serde_json::json!("caller-controlled"); + assert!(serde_json::from_value::(body).is_err()); + let mut body = serde_json::json!({"handle":"new-user"}); + body[field] = serde_json::json!("caller-controlled"); + assert!(serde_json::from_value::(body).is_err()); + } + let request = ActivationOptionsRequest { + handle: "new-user".into(), + code: "activation-secret".into(), + browser_origin: None, + }; + assert!(!format!("{request:?}").contains(&request.code)); + let response = ActivationCodeResponse { + user: AuthenticatedUser { + user_id: "u".into(), + account_id: "a".into(), + handle: "new-user".into(), + display_name: "new-user".into(), + }, + code: "activation-secret".into(), + expires_at: "2030-01-01T00:00:00Z".into(), + }; + assert!(!format!("{response:?}").contains(&response.code)); + assert_eq!( + serde_json::to_value(&response).unwrap()["code"], + "activation-secret" + ); + } +} diff --git a/crates/workspace-server/src/activation.rs b/crates/workspace-server/src/activation.rs new file mode 100644 index 000000000..6c7227f8a --- /dev/null +++ b/crates/workspace-server/src/activation.rs @@ -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 { + 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 { + 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 { + 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 { + 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 = 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 { + 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(()) +} diff --git a/crates/workspace-server/src/activation_schema.sql b/crates/workspace-server/src/activation_schema.sql new file mode 100644 index 000000000..17b3e99ef --- /dev/null +++ b/crates/workspace-server/src/activation_schema.sql @@ -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) +); diff --git a/crates/workspace-server/src/activation_tests.rs b/crates/workspace-server/src/activation_tests.rs new file mode 100644 index 000000000..faa769661 --- /dev/null +++ b/crates/workspace-server/src/activation_tests.rs @@ -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::>(); + let results = jobs + .into_iter() + .map(|job| job.join().unwrap()) + .collect::>(); + 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::>(); + let results = jobs + .into_iter() + .map(|job| job.join().unwrap()) + .collect::>(); + 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()); +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index a280700e9..81b3ceab1 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -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()) { diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index 8c3210c84..33660ecb1 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -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;")?; } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 954bafb61..9c337d680 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -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 + { + 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 + { + 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 + { + 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 { 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; diff --git a/crates/workspace-server/src/server_activation_tests.rs b/crates/workspace-server/src/server_activation_tests.rs new file mode 100644 index 000000000..5a50da553 --- /dev/null +++ b/crates/workspace-server/src/server_activation_tests.rs @@ -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"); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 443d23df3..75d1b2e7b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -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>; fn get_user_by_handle(&self, handle: &str) -> Result>; fn any_user(&self) -> Result>; + fn create_provisional_user( + &self, + actor: &server_api::RequestActor, + handle: &str, + ) -> Result; + fn reissue_activation_code( + &self, + actor: &server_api::RequestActor, + handle: &str, + ) -> Result; + fn verify_activation_code(&self, handle: &str, code: &str) + -> Result; + fn is_user_pending_activation(&self, user_id: &str) -> Result; /// Checks an explicit grant for a currently existing user, not Workspace ownership. fn is_hosting_admin(&self, user_id: &str) -> Result; fn hosting_self_registration_enabled(&self) -> Result; @@ -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 { + activation::create(self, actor, handle) + } + + fn reissue_activation_code( + &self, + actor: &server_api::RequestActor, + handle: &str, + ) -> Result { + activation::reissue(self, actor, handle) + } + + fn verify_activation_code( + &self, + handle: &str, + code: &str, + ) -> Result { + activation::verify(self, handle, code) + } + + fn is_user_pending_activation(&self, user_id: &str) -> Result { + self.with_conn(|conn| activation::pending(conn, user_id)) + } + fn is_hosting_admin(&self, user_id: &str) -> Result { 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> { 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> { 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 { - 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 { - 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> { - 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}"); } diff --git a/openapi/server-api.json b/openapi/server-api.json index 113c09c04..5ed145eaa 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -31,6 +31,50 @@ ], "type": "object" }, + "ActivationCodeResponse": { + "additionalProperties": false, + "description": "The only response allowed to disclose an activation code. Clients must keep it\nout of URLs, persistent browser storage and diagnostics. A lost response is\nrecovered by reissuing, which invalidates the previous code and its ceremonies.", + "properties": { + "code": { + "type": "string" + }, + "expires_at": { + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "user", + "code", + "expires_at" + ], + "type": "object" + }, + "ActivationOptionsRequest": { + "additionalProperties": false, + "description": "Proof for a pending account's initial passkey ceremony, never a bearer token\nor browser session. No caller-selected account ID or enrollment purpose is accepted.", + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + }, + "code": { + "type": "string" + }, + "handle": { + "type": "string" + } + }, + "required": [ + "handle", + "code" + ], + "type": "object" + }, "ActorAuthMethod": { "description": "Authentication method that established the current request actor.", "enum": [ @@ -6825,6 +6869,19 @@ ], "type": "object" }, + "ProvisionalAccountRequest": { + "additionalProperties": false, + "description": "A hosting administrator reserves the same normalized ID used by passkey login.", + "properties": { + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, "PublicMergeRequest": { "additionalProperties": false, "properties": { @@ -17774,10 +17831,111 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a" + "x-yoi-source-digest": "sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8" }, "openapi": "3.1.0", "paths": { + "/api/auth/activation/complete": { + "post": { + "operationId": "auth_activation_complete", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AuthUserResponse" + } + } + }, + "description": "Successful response", + "headers": { + "set-cookie": { + "schema": { + "$ref": "#/components/schemas/SetCookieHeader" + } + } + } + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, + "/api/auth/activation/options": { + "post": { + "operationId": "auth_activation_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationOptionsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + } + } + }, "/api/auth/bootstrap-user": { "post": { "operationId": "auth_bootstrap_user", @@ -18877,6 +19035,136 @@ ] } }, + "/api/hosting/accounts": { + "post": { + "operationId": "hosting_account_create", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionalAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationCodeResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/hosting/accounts/activation-code": { + "post": { + "operationId": "hosting_activation_code_reissue", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProvisionalAccountRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ActivationCodeResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/hosting/settings": { "get": { "operationId": "hosting_settings", diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index b98fba12f..0482caa6a 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,5 +1,5 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 -//! 明示handle login、本人の鍵追加、Workspace所有境界を検証する。DB・認証・routerは本番実装を使い、 +//! 明示handle login、本人の鍵追加、限定activation、Workspace所有境界を検証する。DB・認証・routerは本番実装を使い、 //! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 @@ -14,10 +14,11 @@ use std::time::Duration; use reqwest::StatusCode; use reqwest::blocking::{Client, RequestBuilder, Response}; use reqwest::header::{COOKIE, ORIGIN, SET_COOKIE}; +use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use server_api::{ - AuthUserResponse, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, + AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse, }; use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; @@ -1007,6 +1008,825 @@ fn hosting_policy_rejects_spoofing_inflight_registration_and_survives_restart() Ok(()) } +// code発行とその拒否はsuccess/rejectedの本文診断へ渡さない。 +// 型にもDebugを付けず、失敗時にcode入りresponseをassertの値として出力しない。 +#[derive(Deserialize)] +struct ActivationIssue { + user: AuthenticatedUser, + code: String, + expires_at: String, +} + +fn activation_success(response: Response) -> Result { + let status = response.status(); + assert!(status.is_success(), "activation request failed: {status}"); + Ok(response) +} + +#[track_caller] +fn activation_denied(response: Response) -> Result { + let status = response.status(); + assert!( + matches!( + status, + StatusCode::BAD_REQUEST + | StatusCode::UNAUTHORIZED + | StatusCode::FORBIDDEN + | StatusCode::CONFLICT + | StatusCode::UNPROCESSABLE_ENTITY + ), + "activation boundary returned {status}" + ); + assert!(response.headers().get(SET_COOKIE).is_none()); + let text = response.text()?; + // axumのJSON extractorはunknown-field拒否をtext/plainで返す。 + if status == StatusCode::UNPROCESSABLE_ENTITY { + assert!( + text.contains("unknown field"), + "unexpected schema rejection" + ); + return Ok(()); + } + let body: Value = serde_json::from_str(&text)?; + let object = body + .as_object() + .ok_or("rejection must be a structured API error")?; + let keys: Vec<_> = object.keys().map(String::as_str).collect(); + let error_is_string = body + .get("error") + .and_then(Value::as_str) + .is_some_and(|error| !error.is_empty()); + // cookie Origin middlewareは403のerror-only、typed handlerはmessage/diagnostics付き。 + // 想定外の形でも診断はstatusとJSON keysだけに限定し、errorやcodeの値を表示しない。 + let middleware_error = status == StatusCode::FORBIDDEN && object.len() == 1 && error_is_string; + let typed_error = error_is_string + && body + .get("message") + .and_then(Value::as_str) + .is_some_and(|message| !message.is_empty()) + && body.get("diagnostics").is_some_and(Value::is_array); + assert!( + middleware_error || typed_error, + "unexpected rejection shape: {status}, JSON keys={keys:?}" + ); + for field in ["code", "user", "access_token", "token", "session"] { + assert!( + body.get(field).is_none_or(Value::is_null), + "rejection leaked {field}" + ); + } + Ok(()) +} + +fn issue_activation(request: RequestBuilder, artifacts: &Path) -> Result { + let response = activation_success(send( + request, + "administrator issues activation code", + artifacts, + )?)?; + assert!(response.headers().get(SET_COOKIE).is_none()); + let body: Value = response.json()?; + for field in ["access_token", "token", "session"] { + assert!( + body.get(field).is_none_or(Value::is_null), + "issuance granted {field}" + ); + } + let issue: ActivationIssue = serde_json::from_value(body)?; + assert!(!issue.code.is_empty()); + assert!(!issue.expires_at.is_empty()); + Ok(issue) +} + +fn create_pending( + client: &Client, + base: &str, + artifacts: &Path, + cookie: &str, + handle: &str, +) -> Result { + let issue = issue_activation( + client + .post(format!("{base}/api/hosting/accounts")) + .header(COOKIE, cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": handle})), + artifacts, + )?; + assert_eq!(issue.user.handle, handle); + Ok(issue) +} + +fn activation_options_request( + client: &Client, + base: &str, + handle: &str, + code: &str, +) -> RequestBuilder { + client + .post(format!("{base}/api/auth/activation/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": handle, "code": code, "browser_origin": BROWSER_ORIGIN})) +} + +fn activation_options( + client: &Client, + base: &str, + artifacts: &Path, + issue: &ActivationIssue, +) -> Result { + let response = activation_success(send( + activation_options_request(client, base, &issue.user.handle, &issue.code), + "code verification creates only limited registration options", + artifacts, + )?)?; + assert!(response.headers().get(SET_COOKIE).is_none()); + let body: Value = response.json()?; + for field in ["code", "user", "access_token", "token", "session"] { + assert!( + body.get(field).is_none_or(Value::is_null), + "options granted {field}" + ); + } + Ok(serde_json::from_value(body)?) +} + +fn activation_credential( + options: PasskeyRegistrationOptionsResponse, + key: &mut WebauthnAuthenticator, +) -> Result { + Ok(PasskeyRegistrationCompleteRequest { + challenge_id: options.challenge_id, + credential: key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?, + }) +} + +fn activation_complete_request( + client: &Client, + base: &str, + complete: &PasskeyRegistrationCompleteRequest, +) -> RequestBuilder { + client + .post(format!("{base}/api/auth/activation/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(complete) +} + +// host CLIのFULL USER bearerを再利用せず、実sessionによるdevice承認からbearerを得る。 +fn device_bearer(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result { + let flow: Value = decode(send( + client + .post(format!("{base}/api/auth/device-login/start")) + .json(&json!({"client_name": "isolated activation E2E"})), + "start device login for live authority checks", + artifacts, + )?)?; + let approved: Value = decode(send( + client + .post(format!("{base}/api/auth/device-login/approve")) + .header(COOKIE, cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"user_code": flow["user_code"]})), + "real passkey session approves device bearer", + artifacts, + )?)?; + assert_eq!(approved["status"], "approved"); + let response = activation_success(send( + client + .post(format!("{base}/api/auth/device-login/poll")) + .json(&json!({"device_code": flow["device_code"]})), + "deliver device bearer without body diagnostics", + artifacts, + )?)?; + let issued: Value = response.json()?; + assert_eq!(issued["status"], "approved"); + Ok(issued["access_token"] + .as_str() + .ok_or("device bearer missing")? + .to_owned()) +} + +#[test] +fn activation_with_disabled_self_registration_survives_reopen_and_enables_only_passkey_login() +-> Result { + let mut environment = WorkerE2eEnvironment::new()?; + eprintln!( + "activation E2E artifacts: {}", + environment.artifacts_dir.display() + ); + environment.start_empty_backend()?; + let client = http_client()?; + let artifacts = environment.artifacts_dir.clone(); + let mut base = environment.server_url.clone().ok_or("missing server URL")?; + let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + policy(&client, &base, &artifacts, false)?; + let issue = create_pending( + &client, + &base, + &artifacts, + &admin_cookie, + &environment.account_handle, + )?; + + // optionsだけで取消し/reloadしてもcodeを消費せず、鍵なしaccountに通常権限を与えない。 + let _cancelled = activation_options(&client, &base, &artifacts, &issue)?; + let _reloaded = activation_options(&client, &base, &artifacts, &issue)?; + let whoami: Value = decode(send( + client.get(format!("{base}/api/auth/whoami")), + "activation options leave browser anonymous", + &artifacts, + )?)?; + assert!(whoami["actor"].is_null()); + activation_denied(send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": issue.user.handle, "browser_origin": BROWSER_ORIGIN})), + "cancelled activation has no usable passkey", + &artifacts, + )?)?; + environment.restart_backend()?; + base = environment + .server_url + .clone() + .ok_or("missing restarted server URL")?; + policy(&client, &base, &artifacts, false)?; + let options = activation_options(&client, &base, &artifacts, &issue)?; + let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let complete = activation_credential(options, &mut key)?; + let (activated, cookie) = session(activation_success(send( + activation_complete_request(&client, &base, &complete), + "verified activation after reopened pending state", + &artifacts, + )?)?)?; + assert_eq!(activated.user, issue.user); + access(&client, &base, &artifacts, &cookie, false)?; + let workspaces: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &cookie), + "activation grants ordinary login without creating Workspace", + &artifacts, + )?)?; + assert_eq!(workspaces, json!([])); + activation_denied(send( + activation_options_request(&client, &base, &issue.user.handle, &issue.code), + "used activation code cannot log in again", + &artifacts, + )?)?; + activation_denied(send( + activation_complete_request(&client, &base, &complete), + "completed activation challenge cannot be reused", + &artifacts, + )?)?; + logout(&client, &base, &artifacts, &cookie)?; + let (logged_in, cookie) = login(&client, &base, &artifacts, &issue.user.handle, &mut key)?; + assert_eq!(logged_in.user, issue.user); + access(&client, &base, &artifacts, &cookie, false)?; + + environment.restart_backend()?; + base = environment + .server_url + .clone() + .ok_or("missing reopened server URL")?; + let (persisted, cookie) = login(&client, &base, &artifacts, &issue.user.handle, &mut key)?; + assert_eq!(persisted.user, issue.user); + access(&client, &base, &artifacts, &cookie, false)?; + policy(&client, &base, &artifacts, false)?; + activation_denied(send( + activation_options_request(&client, &base, &issue.user.handle, &issue.code), + "consumed code remains unusable after DB reopen", + &artifacts, + )?)?; + let report = environment.cleanup()?; + assert!(report.cleanup_success); + assert!(!report.fixture_root_exists_after); + Ok(()) +} + +#[test] +fn activation_issuance_requires_live_admin_and_cookie_origin_not_identity_claims() -> Result { + let mut environment = WorkerE2eEnvironment::new()?; + environment.start_empty_backend()?; + let client = http_client()?; + let artifacts = &environment.artifacts_dir; + let base = environment + .server_url + .as_deref() + .ok_or("missing server URL")?; + let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + let ordinary = create_pending( + &client, + base, + artifacts, + &admin_cookie, + &environment.account_handle, + )?; + let mut ordinary_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let complete = activation_credential( + activation_options(&client, base, artifacts, &ordinary)?, + &mut ordinary_key, + )?; + let (_, ordinary_cookie) = session(activation_success(send( + activation_complete_request(&client, base, &complete), + "activate ordinary user for permission checks", + artifacts, + )?)?)?; + let ordinary_bearer = device_bearer(&client, base, artifacts, &ordinary_cookie)?; + let admin_bearer = device_bearer(&client, base, artifacts, &admin_cookie)?; + let pending_handle = format!("{}-pending", environment.account_handle); + let pending = create_pending(&client, base, artifacts, &admin_cookie, &pending_handle)?; + + for path in [ + "/api/hosting/accounts", + "/api/hosting/accounts/activation-code", + ] { + for authorization in [None, Some(&ordinary_cookie), Some(&ordinary_bearer)] { + let mut request = client + .post(format!("{base}{path}")) + .header(ORIGIN, BROWSER_ORIGIN) + .header("x-yoi-user-id", &admin.user.user_id) + .header("x-yoi-account-id", &admin.user.account_id) + .header("x-hosting-admin", "true") + .json(&json!({"handle": pending_handle})); + if let Some(authorization) = authorization { + request = if authorization == &ordinary_cookie { + request.header(COOKIE, authorization) + } else { + request.bearer_auth(authorization) + }; + } + activation_denied(send( + request, + "anonymous or ordinary identity spoof cannot issue code", + artifacts, + )?)?; + } + for origin in [None, Some("http://localhost:5174")] { + let mut request = client + .post(format!("{base}{path}")) + .header(COOKIE, &admin_cookie) + .json(&json!({"handle": pending_handle})); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + activation_denied(send( + request, + "administrator cookie requires actual configured Origin", + artifacts, + )?)?; + } + activation_denied(send( + client + .post(format!("{base}{path}")) + .bearer_auth(&admin_bearer) + .header(ORIGIN, "http://localhost:5174") + .json(&json!({"handle": pending_handle})), + "bearer cannot bypass supplied untrusted browser Origin", + artifacts, + )?)?; + for field in ["account_id", "user_id", "is_admin"] { + let mut body = json!({"handle": pending_handle}); + body[field] = json!("forged"); + activation_denied(send( + client + .post(format!("{base}{path}")) + .header(COOKIE, &admin_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&body), + "issuance body cannot select identity or grant administrator", + artifacts, + )?)?; + } + } + // 拒否の後でも同じcodeで進める。拒否要求が暗黙に再発行していない。 + let _ = activation_options(&client, base, artifacts, &pending)?; + let replacement = issue_activation( + client + .post(format!("{base}/api/hosting/accounts/activation-code")) + .bearer_auth(&admin_bearer) + .json(&json!({"handle": pending_handle})), + artifacts, + )?; + assert_eq!(replacement.user, pending.user); + activation_denied(send( + activation_options_request(&client, base, &pending_handle, &pending.code), + "legitimate bearer reissue invalidates old code", + artifacts, + )?)?; + let _ = activation_options(&client, base, artifacts, &replacement)?; + let bearer_created = issue_activation( + client + .post(format!("{base}/api/hosting/accounts")) + .bearer_auth(&admin_bearer) + .json(&json!({"handle": format!("{}-bearer", environment.account_handle)})), + artifacts, + )?; + assert_ne!(bearer_created.user.account_id, pending.user.account_id); + // 有効accountに対してcreateもcode再発行もcredential回復へ転用できない。 + for path in [ + "/api/hosting/accounts", + "/api/hosting/accounts/activation-code", + ] { + activation_denied(send( + client + .post(format!("{base}{path}")) + .header(COOKIE, &admin_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": ordinary.user.handle})), + "administrator cannot replace active account or issue recovery code", + artifacts, + )?)?; + } + logout(&client, base, artifacts, &admin_cookie)?; + for path in [ + "/api/hosting/accounts", + "/api/hosting/accounts/activation-code", + ] { + activation_denied(send( + client + .post(format!("{base}{path}")) + .header(COOKIE, &admin_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": pending_handle})), + "revoked administrator cookie cannot issue code", + artifacts, + )?)?; + } + let (_, cookie) = login( + &client, + base, + artifacts, + &ordinary.user.handle, + &mut ordinary_key, + )?; + access(&client, base, artifacts, &cookie, false)?; + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} + +#[test] +fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purpose() -> Result { + let mut environment = WorkerE2eEnvironment::new()?; + environment.start_empty_backend()?; + let client = http_client()?; + let base = environment + .server_url + .as_deref() + .ok_or("missing server URL")?; + let artifacts = &environment.artifacts_dir; + let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + let issue = create_pending( + &client, + base, + artifacts, + &admin_cookie, + &environment.account_handle, + )?; + let other = create_pending( + &client, + base, + artifacts, + &admin_cookie, + &format!("{}-other", environment.account_handle), + )?; + for (handle, code) in [ + (issue.user.handle.as_str(), "invalid-code"), + (other.user.handle.as_str(), issue.code.as_str()), + ("unknown-activation-handle", issue.code.as_str()), + ] { + activation_denied(send( + activation_options_request(&client, base, handle, code), + "invalid code or account binding cannot start activation", + artifacts, + )?)?; + } + activation_denied(send( + client + .post(format!("{base}/api/auth/activation/options")) + .header(ORIGIN, "https://example.invalid") + .json(&json!({"handle": issue.user.handle, "code": issue.code, + "browser_origin": "https://example.invalid"})), + "untrusted browser origin cannot start activation", + artifacts, + )?)?; + let options = activation_options(&client, base, artifacts, &issue)?; + let challenge_id = options.challenge_id.clone(); + let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let complete = activation_credential(options, &mut key)?; + + // code/限定challenge IDのどちらも通常tokenではない。通常API、Workspace、device approvalに流用しない。 + let flow: Value = decode(send( + client + .post(format!("{base}/api/auth/device-login/start")) + .json(&json!({"client_name": "pending activation must not approve"})), + "start device flow before passkey verification", + artifacts, + )?)?; + for bearer in [None, Some(issue.code.as_str()), Some(challenge_id.as_str())] { + let requests = [ + client.get(format!("{base}/api/hosting/settings")), + client.post(format!("{base}/api/workspaces")).header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"operation_key": format!("pending-{}", environment.runtime_id), + "display_name": "must not exist", "repository": {"repository_key": "fixture", + "uri": "https://example.invalid/activation.git", "default_ref": "develop"}})), + client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"user_code": flow["user_code"]})), + client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + ]; + for mut request in requests { + if let Some(bearer) = bearer { + request = request.bearer_auth(bearer); + } + activation_denied(send( + request, + "limited activation state grants no ordinary authority", + artifacts, + )?)?; + } + } + let polled: Value = decode(send( + client + .post(format!("{base}/api/auth/device-login/poll")) + .json(&json!({"device_code": flow["device_code"]})), + "failed pending-user approvals did not authorize device", + artifacts, + )?)?; + assert_eq!(polled["status"], "pending"); + assert!(polled.get("access_token").is_none_or(Value::is_null)); + + // completionはchallengeのtargetを使い、本文で別accountへ切り替えられない。 + for field in ["handle", "account_id", "user_id"] { + let mut body = serde_json::to_value(&complete)?; + body[field] = json!(&other.user.account_id); + activation_denied(send( + client + .post(format!("{base}/api/auth/activation/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&body), + "activation completion rejects injected target identity", + artifacts, + )?)?; + } + for path in [ + "/api/auth/passkeys/registration/complete", + "/api/auth/passkeys/addition/complete", + ] { + let mut request = client + .post(format!("{base}{path}")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&complete); + // additionでは実admin sessionを持っていてもactivation challengeの用途は変えられない。 + if path.contains("addition") { + request = request.header(COOKIE, &admin_cookie); + } + activation_denied(send( + request, + "activation challenge cannot become public registration or self addition", + artifacts, + )?)?; + } + // 再発行はcodeだけでなく、それ以前に発行したchallengeも失効させる。 + let replacement = issue_activation( + client + .post(format!("{base}/api/hosting/accounts/activation-code")) + .header(COOKIE, &admin_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": issue.user.handle})), + artifacts, + )?; + assert_eq!(replacement.user, issue.user); + activation_denied(send( + activation_options_request(&client, base, &issue.user.handle, &issue.code), + "old code cannot resume after reissue", + artifacts, + )?)?; + activation_denied(send( + activation_complete_request(&client, base, &complete), + "old challenge cannot activate after reissue", + artifacts, + )?)?; + // 別IDに対する誤ったcode入力が元のaccountを消費していない。 + let _ = activation_options(&client, base, artifacts, &other)?; + let mut replacement_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let replacement_complete = activation_credential( + activation_options(&client, base, artifacts, &replacement)?, + &mut replacement_key, + )?; + let (user, cookie) = session(activation_success(send( + activation_complete_request(&client, base, &replacement_complete), + "replacement code activates only its bound account", + artifacts, + )?)?)?; + assert_eq!(user.user, issue.user); + access(&client, base, artifacts, &cookie, false)?; + + // 逆向きも拒否する。通常登録や本人鍵追加のchallengeでpending accountを有効化できない。 + set_policy(&client, base, artifacts, &admin_cookie, true)?; + let public_handle = format!("{}-public", environment.account_handle); + let public_options: PasskeyRegistrationOptionsResponse = decode(send( + client + .post(format!("{base}/api/auth/passkeys/registration/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": public_handle, "browser_origin": BROWSER_ORIGIN})), + "start distinct public registration ceremony", + artifacts, + )?)?; + let mut public_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let public_complete = activation_credential(public_options, &mut public_key)?; + activation_denied(send( + activation_complete_request(&client, base, &public_complete), + "public registration challenge cannot finish activation", + artifacts, + )?)?; + let addition_options: PasskeyRegistrationOptionsResponse = decode(send( + client + .post(format!("{base}/api/auth/passkeys/addition/options")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "start distinct authenticated self-addition ceremony", + artifacts, + )?)?; + let mut addition_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let addition_complete = activation_credential(addition_options, &mut addition_key)?; + activation_denied(send( + activation_complete_request(&client, base, &addition_complete), + "self-addition challenge cannot finish activation", + artifacts, + )?)?; + set_policy(&client, base, artifacts, &admin_cookie, false)?; + let (_, cookie) = login( + &client, + base, + artifacts, + &issue.user.handle, + &mut replacement_key, + )?; + access(&client, base, artifacts, &cookie, false)?; + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} + +#[test] +fn activation_bad_attestation_stays_pending_and_concurrent_ceremonies_store_only_one_key() -> Result +{ + let mut environment = WorkerE2eEnvironment::new()?; + environment.start_empty_backend()?; + let client = http_client()?; + let artifacts = &environment.artifacts_dir; + let base = environment + .server_url + .as_deref() + .ok_or("missing server URL")?; + let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + let issue = create_pending( + &client, + base, + artifacts, + &admin_cookie, + &environment.account_handle, + )?; + let mut bad_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let bad_complete = activation_credential( + activation_options(&client, base, artifacts, &issue)?, + &mut bad_key, + )?; + let mut bad_attestation = serde_json::to_value(&bad_complete)?; + // 外部JSONとして受理されるcredentialのclientDataだけを壊し、本番verifierで拒否させる。 + bad_attestation["credential"]["response"]["clientDataJSON"] = json!("e30"); + activation_denied(send( + client + .post(format!("{base}/api/auth/activation/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&bad_attestation), + "real WebAuthn verifier rejects malformed attestation", + artifacts, + )?)?; + activation_denied(send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": issue.user.handle, "browser_origin": BROWSER_ORIGIN})), + "failed verifier did not activate or save a credential", + artifacts, + )?)?; + let mut first_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let first = activation_credential( + activation_options(&client, base, artifacts, &issue)?, + &mut first_key, + )?; + let mut second_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let second = activation_credential( + activation_options(&client, base, artifacts, &issue)?, + &mut second_key, + )?; + let gate = std::sync::Barrier::new(3); + let first_request = activation_complete_request(&client, base, &first); + let second_request = activation_complete_request(&client, base, &second); + // 独立した有効ceremonyを同時送信する。HTTP timeoutとRAII Server回収は既存harnessを使う。 + let (first_response, second_response) = + std::thread::scope(|scope| -> Result<(Response, Response)> { + let first_thread = scope.spawn(|| { + gate.wait(); + first_request.send() + }); + let second_thread = scope.spawn(|| { + gate.wait(); + second_request.send() + }); + gate.wait(); + Ok(( + first_thread + .join() + .map_err(|_| "first completion thread panicked")??, + second_thread + .join() + .map_err(|_| "second completion thread panicked")??, + )) + })?; + let first_won = first_response.status().is_success(); + let second_won = second_response.status().is_success(); + assert_ne!( + first_won, second_won, + "exactly one concurrent activation may succeed" + ); + let (winner_response, loser_response, winner, winner_key, loser_key) = if first_won { + ( + first_response, + second_response, + &first, + &mut first_key, + &mut second_key, + ) + } else { + ( + second_response, + first_response, + &second, + &mut second_key, + &mut first_key, + ) + }; + activation_denied(loser_response)?; + let (user, cookie) = session(activation_success(winner_response)?)?; + assert_eq!(user.user, issue.user); + access(&client, base, artifacts, &cookie, false)?; + for complete in [&first, &second] { + activation_denied(send( + activation_complete_request(&client, base, complete), + "neither successful nor losing ceremony can add a second activation key", + artifacts, + )?)?; + } + activation_denied(send( + activation_options_request(&client, base, &issue.user.handle, &issue.code), + "parallel activation consumed code once", + artifacts, + )?)?; + let options: PasskeyLoginOptionsResponse = decode(send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": issue.user.handle, "browser_origin": BROWSER_ORIGIN})), + "reread only persisted credential after concurrent activation", + artifacts, + )?)?; + let wire = serde_json::to_value(&options.public_key)?; + let allowed = wire["publicKey"]["allowCredentials"] + .as_array() + .ok_or("login credentials missing")?; + assert_eq!( + allowed.len(), + 1, + "failed verifier and losing ceremony must not store credentials" + ); + assert_eq!( + allowed[0]["id"], + serde_json::to_value(winner)?["credential"]["id"] + ); + assert!( + loser_key + .do_authentication(BROWSER_ORIGIN.parse()?, options.public_key) + .is_err(), + "losing authenticator must have no usable persisted credential" + ); + let (logged_in, cookie) = login(&client, base, artifacts, &issue.user.handle, winner_key)?; + assert_eq!(logged_in.user, issue.user); + access(&client, base, artifacts, &cookie, false)?; + let report = environment.cleanup()?; + assert!(report.cleanup_success); + assert!(!report.fixture_root_exists_after); + Ok(()) +} + #[test] fn legacy_host_bootstrap_is_ordinary_until_explicit_host_selection() -> Result { let mut environment = WorkerE2eEnvironment::new()?; diff --git a/web/workspace/deno.json b/web/workspace/deno.json index e3c678adf..04e381b19 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,8 +6,9 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", + "test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts", "test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 58d91ed88..774a2852b 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -7,6 +7,12 @@ export type HostingSettings = { self_registration_enabled: boolean, }; export type HostingAccess = { is_admin: boolean, }; +export type ProvisionalAccountRequest = { handle: string, }; + +export type ActivationCodeResponse = { user: AuthenticatedUser, code: string, expires_at: string, }; + +export type ActivationOptionsRequest = { handle: string, code: string, browser_origin?: string | null, }; + export type ActorAuthMethod = "browser_session" | "api_token"; export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 0d5297502..39564d222 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 6ff62fb74..5e965ec0b 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 50de70063..1613cbbab 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index cd3941512..988151e8f 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index e2032c3f1..ccb0791a6 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a) +// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/auth/activation.test.ts b/web/workspace/src/lib/workspace/auth/activation.test.ts new file mode 100644 index 000000000..acb1d0781 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/activation.test.ts @@ -0,0 +1,175 @@ +declare const Deno: { test(name: string, fn: () => Promise): void }; +import { activateWithPasskey } from "./activation.ts"; +import { + authUser, + credentialFixture, + registrationOptions, +} from "./api.test-fixtures.ts"; +function equal(a: unknown, b: unknown) { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ"); +} +async function rejects(run: Promise) { + try { + await run; + } catch (error) { + equal((error as Error).message, "Activation could not be confirmed."); + return; + } + throw new Error("Expected rejection"); +} +async function boundary( + fetcher: typeof fetch, + create: (options: CredentialCreationOptions) => Promise, + run: () => Promise, +) { + const saved = ["fetch", "window", "navigator"].map((key) => + [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const + ); + Object.defineProperties(globalThis, { + fetch: { configurable: true, value: fetcher }, + window: { + configurable: true, + value: { + isSecureContext: true, + location: { origin: "https://example.test" }, + }, + }, + navigator: { configurable: true, value: { credentials: { create } } }, + }); + try { + await run(); + } finally { + for (const [key, descriptor] of saved) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + } +} +// HTTP/WebAuthn境界の代替。本物のServerによるcode検証やcredential保存の試験ではない。 +Deno.test("activation uses only dedicated endpoints and confirms normalized target after completion", async () => { + const requests: { path: string; init: RequestInit }[] = []; + const signal = new AbortController().signal; + await boundary(async (input, init) => { + requests.push({ path: String(input), init: init! }); + return Response.json( + String(input).endsWith("/options") + ? registrationOptions + : { user: authUser }, + ); + }, async (options) => { + equal(options.signal === signal, true); + return credentialFixture(); + }, async () => { + equal( + await activateWithPasskey(" ALIce ", "synthetic-input-only", signal), + authUser, + ); + equal(requests.map((r) => r.path), [ + "/api/auth/activation/options", + "/api/auth/activation/complete", + ]); + for (const { init } of requests) { + equal(init.method, "POST"); + equal(init.credentials, "same-origin"); + equal(init.redirect, "error"); + equal(init.mode, "same-origin"); + equal(init.cache, "no-store"); + equal(init.referrerPolicy, "no-referrer"); + equal(new Headers(init.headers).has("authorization"), false); + } + equal(JSON.parse(String(requests[0].init.body)), { + handle: "alice", + code: "synthetic-input-only", + browser_origin: "https://example.test", + }); + equal(Object.keys(JSON.parse(String(requests[1].init.body))).sort(), [ + "challenge_id", + "credential", + ]); + equal( + String(requests[1].init.body).includes("synthetic-input-only"), + false, + ); + }); +}); +Deno.test("activation cancellation and abort never send complete or return active", async () => { + for (const mode of ["cancel", "abort", "null"] as const) { + let calls = 0; + const controller = new AbortController(); + await boundary(async () => { + calls++; + return Response.json(registrationOptions); + }, async () => { + if (mode === "cancel") throw new Error("synthetic-input-only"); + if (mode === "abort") controller.abort(); + return mode === "null" ? null : credentialFixture(); + }, async () => { + await rejects( + activateWithPasskey("alice", "synthetic-input-only", controller.signal), + ); + equal(calls, 1); + }); + } +}); +Deno.test("activation sanitizes HTTP transport malformed and mismatched completion without retry", async () => { + for (const mode of ["http", "throw", "malformed", "mismatch"] as const) { + let calls = 0; + await boundary( + async (input) => { + calls++; + if (mode === "throw") throw new Error("synthetic-input-only"); + if (mode === "http") { + return new Response("synthetic-input-only", { status: 403 }); + } + if (mode === "malformed") { + return Response.json({ unexpected: "synthetic-input-only" }); + } + return Response.json( + String(input).endsWith("/options") + ? registrationOptions + : { user: { ...authUser, handle: "other" } }, + ); + }, + async () => credentialFixture(), + async () => { + await rejects( + activateWithPasskey( + "alice", + "synthetic-input-only", + new AbortController().signal, + ), + ); + equal(calls, mode === "mismatch" ? 2 : 1); + }, + ); + } +}); +Deno.test("activation invalid IDs and oversized code are rejected before network", async () => { + let calls = 0; + await boundary( + async () => { + calls++; + return Response.json({}); + }, + async () => credentialFixture(), + async () => { + for (const handle of ["", "a/b", "A", "i̇", "a".repeat(65)]) { + await rejects( + activateWithPasskey( + handle, + "synthetic-input-only", + new AbortController().signal, + ), + ); + } + await rejects( + activateWithPasskey( + "alice", + "x".repeat(4097), + new AbortController().signal, + ), + ); + equal(calls, 0); + }, + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/activation.ts b/web/workspace/src/lib/workspace/auth/activation.ts new file mode 100644 index 000000000..53a66b1c6 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/activation.ts @@ -0,0 +1,79 @@ +import type { + ActivationOptionsRequest, + PasskeyRegistrationCompleteRequest, +} from "#lib/generated/auth-api.ts"; +import { readBoundedAuthResponseJson } from "./api.ts"; +import { normalizeActivationHandle } from "./hosting-accounts.ts"; +import { + type AuthUser, + parseAuthUserResponse, + prepareRegistrationOptions, + registrationCredentialToJson, +} from "./model.ts"; + +// 一時codeは通常auth clientや本人の鍵追加APIへ渡さない。options取得はsessionではない。 +// 再開はID+codeの再入力から行い、ceremony/codeをURL・storageへ保存しない。 +// Cookieが存在しても対象はServerがcodeから決め、complete成功時だけ新sessionを受け取る。 +export async function activateWithPasskey( + handle: string, + code: string, + signal: AbortSignal, +): Promise { + try { + handle = normalizeActivationHandle(handle); + if (window.isSecureContext !== true || !code || code.length > 4096) { + throw new Error("Activation unavailable"); + } + const request = async ( + path: "/api/auth/activation/options" | "/api/auth/activation/complete", + body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest, + ): Promise => { + signal.throwIfAborted(); + const response = await fetch(path, { + method: "POST", + credentials: "same-origin", + mode: "same-origin", + redirect: "error", + cache: "no-store", + referrerPolicy: "no-referrer", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error("Activation rejected"); + } + return await readBoundedAuthResponseJson(response); + }; + const options = await request("/api/auth/activation/options", { + handle, + code, + browser_origin: window.location.origin, + }); + code = ""; + const publicKey = prepareRegistrationOptions(options); + const challengeId = (options as { challenge_id: string }).challenge_id; + signal.throwIfAborted(); + const credential = await navigator.credentials.create({ + publicKey, + signal, + }); + if (!credential) throw new Error("Registration cancelled"); + signal.throwIfAborted(); + const result = parseAuthUserResponse( + await request("/api/auth/activation/complete", { + challenge_id: challengeId, + credential: registrationCredentialToJson(credential), + }), + ).user; + signal.throwIfAborted(); + if (result.handle !== handle) throw new Error("Activation target mismatch"); + return result; + } catch { + // HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。 + throw new Error("Activation could not be confirmed."); + } finally { + code = ""; + } +} diff --git a/web/workspace/src/lib/workspace/auth/hosting-accounts.test.ts b/web/workspace/src/lib/workspace/auth/hosting-accounts.test.ts new file mode 100644 index 000000000..2b357674a --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/hosting-accounts.test.ts @@ -0,0 +1,120 @@ +declare const Deno: { test(name: string, fn: () => Promise): void }; +import { + issueActivationCode, + normalizeActivationHandle, +} from "./hosting-accounts.ts"; +import { HostingRequestError } from "./hosting.ts"; +import { authUser } from "./api.test-fixtures.ts"; +function equal(a: unknown, b: unknown) { + if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ"); +} +async function boundary(fetcher: typeof fetch, run: () => Promise) { + const saved = globalThis.fetch; + globalThis.fetch = fetcher; + try { + await run(); + } finally { + globalThis.fetch = saved; + } +} +const response = () => ({ + user: authUser, + code: "synthetic-output-only", + expires_at: new Date(Date.now() + 900000).toISOString(), +}); +Deno.test("hosting issuance normalizes ASCII handle and selects create or explicit reissue using cookie only", async () => { + for (const reissue of [false, true]) { + let calls = 0; + await boundary(async (input, requestInit) => { + const init = requestInit as RequestInit; + calls++; + equal( + input, + reissue + ? "/api/hosting/accounts/activation-code" + : "/api/hosting/accounts", + ); + equal(JSON.parse(String(init?.body)), { handle: "alice" }); + equal(init?.method, "POST"); + equal(init?.credentials, "same-origin"); + equal(init?.redirect, "error"); + equal(init?.cache, "no-store"); + equal(init?.referrerPolicy, "no-referrer"); + equal(new Headers(init?.headers).has("authorization"), false); + return Response.json(response()); + }, async () => { + const issued = await issueActivationCode( + " ALIce ", + reissue, + new AbortController().signal, + ); + equal(issued.user, authUser); + equal(calls, 1); + }); + } +}); +Deno.test("hosting issuance rejects malformed mismatched secret-bearing responses and transport diagnostics", async () => { + for ( + const mode of ["throw", "malformed", "mismatch", "extra", "expiry"] as const + ) { + await boundary(async () => { + if (mode === "throw") throw new Error("synthetic-output-only"); + const value = response(); + return Response.json( + mode === "malformed" + ? { code: value.code } + : mode === "extra" + ? { ...value, extra: true } + : mode === "expiry" + ? { ...value, expires_at: "never" } + : { ...value, user: { ...authUser, handle: "other" } }, + ); + }, async () => { + try { + await issueActivationCode("alice", false, new AbortController().signal); + } catch (error) { + equal( + (error as Error).message, + "Code issuance could not be confirmed.", + ); + return; + } + throw new Error("Expected rejection"); + }); + } +}); +Deno.test("hosting issuance exposes only HTTP status for denial and never retries", async () => { + for (const status of [401, 403, 409, 503]) { + let calls = 0; + await boundary(async () => { + calls++; + return new Response("synthetic-output-only", { status }); + }, async () => { + try { + await issueActivationCode("alice", true, new AbortController().signal); + } catch (error) { + equal(error instanceof HostingRequestError, true); + equal((error as HostingRequestError).status, status); + equal( + (error as Error).message.includes("synthetic-output-only"), + false, + ); + equal(calls, 1); + return; + } + throw new Error("Expected rejection"); + }); + } +}); +Deno.test("hosting ID normalization rejects Unicode and invalid length instead of changing account identity", async () => { + equal(normalizeActivationHandle(" AbC-_09 "), "abc-_09"); + for (const value of ["", "a".repeat(65), "A", "a.b", "a b", "İ"]) { + let rejected = false; + try { + normalizeActivationHandle(value); + } catch { + rejected = true; + } + equal(rejected, true); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/hosting-accounts.ts b/web/workspace/src/lib/workspace/auth/hosting-accounts.ts new file mode 100644 index 000000000..ac8cb6713 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/hosting-accounts.ts @@ -0,0 +1,69 @@ +import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts"; +import { readBoundedAuthResponseJson } from "./api.ts"; +import { HostingRequestError } from "./hosting.ts"; +import { parseAuthUserResponse } from "./model.ts"; + +// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。 +export function normalizeActivationHandle(value: string): string { + const handle = value.trim().replace( + /[A-Z]/g, + (letter) => letter.toLowerCase(), + ); + if (!/^[a-z0-9_-]{1,64}$/.test(handle)) { + throw new Error("Invalid account ID."); + } + return handle; +} + +// 管理者は新規IDを作成して、発行結果のcodeと/activateの場所を本人へ別々に渡す。 +// 再発行は仮accountにだけ行い、旧code/ceremonyは失効する。応答喪失時は自動再送せず、 +// 本人のPasskey login確認後に必要なら明示再発行する。codeは発行結果のmemoryだけで扱う。 +export async function issueActivationCode( + handle: string, + reissue: boolean, + signal: AbortSignal, +): Promise { + try { + handle = normalizeActivationHandle(handle); + const response = await fetch( + reissue + ? "/api/hosting/accounts/activation-code" + : "/api/hosting/accounts", + { + method: "POST", + credentials: "same-origin", + mode: "same-origin", + redirect: "error", + cache: "no-store", + referrerPolicy: "no-referrer", + signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ handle }), + }, + ); + if (!response.ok) { + await response.body?.cancel(); + throw new HostingRequestError(response.status); + } + const value = await readBoundedAuthResponseJson(response); + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error(); + } + const record = value as Record; + if ( + Object.keys(record).sort().join(",") !== "code,expires_at,user" || + typeof record.code !== "string" || !record.code || + record.code.length > 4096 || + typeof record.expires_at !== "string" || + !Number.isFinite(Date.parse(record.expires_at)) + ) throw new Error(); + const user = parseAuthUserResponse({ user: record.user }).user; + if (user.handle !== handle) throw new Error(); + signal.throwIfAborted(); + return { user, code: record.code, expires_at: record.expires_at }; + } catch (error) { + if (error instanceof HostingRequestError) throw error; + // HTTP本文、JSON/transport元例外には秘密が含まれ得る。 + throw new Error("Code issuance could not be confirmed."); + } +} diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 00e888184..6009c9f3e 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -26,6 +26,8 @@ import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts'; let { children, data }: LayoutProps = $props(); + // Hostingと初回登録はWorkspaceに依存しない。login全体の分離はここで扱わない。 + const standalone = $derived(page.url.pathname === '/activate' || page.url.pathname.startsWith('/hosting/')); const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog'); let sidebar = $state(null); const sidebarOverrides = createOverrideStack((activeSidebar) => { @@ -55,7 +57,7 @@ const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen); $effect(() => { - const error = catalog.data?.workspaceCatalogError; + const error = !standalone && catalog.data?.workspaceCatalogError; if (error) { pushWorkspaceAlert('error', error, { id: 'workspace-catalog-load', @@ -95,6 +97,11 @@ +{#if standalone} +
+ {@render children()} +
+{:else}
+{/if} diff --git a/web/workspace/src/routes/activate/activation.browser.spec.ts b/web/workspace/src/routes/activate/activation.browser.spec.ts new file mode 100644 index 000000000..61dd10376 --- /dev/null +++ b/web/workspace/src/routes/activate/activation.browser.spec.ts @@ -0,0 +1,101 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import Activate from "./+page.svelte"; +import { + authUser, + credentialFixture, + registrationOptions, +} from "#lib/workspace/auth/api.test-fixtures.ts"; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function boundary(mode: "success" | "pending" | "cancel" | "unknown") { + Object.defineProperty(window, "isSecureContext", { + configurable: true, + value: true, + }); + const create = vi.fn(async () => { + if (mode === "pending") return await new Promise(() => {}); + if (mode === "cancel") throw new Error("private diagnostic"); + return credentialFixture(); + }); + vi.stubGlobal("navigator", { credentials: { create } }); + const fetcher = vi.fn(async (input) => { + if (String(input) === "/api/auth/activation/options") { + return Response.json(registrationOptions); + } + if (String(input) === "/api/auth/activation/complete") { + if (mode === "unknown") throw new Error("private diagnostic"); + return Response.json({ user: authUser }); + } + throw new Error("Unexpected endpoint"); + }); + vi.stubGlobal("fetch", fetcher); + return { fetcher, create }; +} +async function submit() { + await fireEvent.input(screen.getByLabelText("Account ID (handle)"), { + target: { value: " ALICE " }, + }); + await fireEvent.input(screen.getByLabelText("Activation code"), { + target: { value: "synthetic-input-only" }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Register Passkey and activate" }), + ); +} +test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => { + const { fetcher } = boundary("success"); + render(Activate); + await submit(); + await screen.findByText(/registration and activation confirmed/); + expect(fetcher.mock.calls.map(([input]) => input)).toEqual([ + "/api/auth/activation/options", + "/api/auth/activation/complete", + ]); + expect(screen.queryByLabelText("Activation code")).toBeNull(); + expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe( + "alice", + ); +}); +test("pending ceremony never exposes activation or workspace actions and explicit cancel clears the input", async () => { + const { fetcher } = boundary("pending"); + render(Activate); + await submit(); + await screen.findByText(/activation has not been confirmed/); + expect((screen.getByLabelText("Activation code") as HTMLInputElement).value) + .toBe(""); + expect(screen.queryByText(/You are signed in/)).toBeNull(); + await fireEvent.click( + screen.getByRole("button", { name: "Cancel registration" }), + ); + await screen.findByRole("alert"); + expect(fetcher.mock.calls.length).toBe(1); + expect( + (screen.getByLabelText("Activation code") as HTMLInputElement).disabled, + ).toBe(false); +}); +for (const mode of ["cancel", "unknown"] as const) { + test(`${mode} does not claim active or echo diagnostics and offers login-first recovery`, async () => { + boundary(mode); + render(Activate); + await submit(); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain("Passkey login") + ); + expect(screen.getByRole("alert").textContent).not.toContain( + "private diagnostic", + ); + expect(screen.queryByText(/You are signed in/)).toBeNull(); + expect((screen.getByLabelText("Activation code") as HTMLInputElement).value) + .toBe(""); + }); +} diff --git a/web/workspace/src/routes/hosting/accounts/+page.svelte b/web/workspace/src/routes/hosting/accounts/+page.svelte new file mode 100644 index 000000000..19f13164c --- /dev/null +++ b/web/workspace/src/routes/hosting/accounts/+page.svelte @@ -0,0 +1,127 @@ + + +Hosting accounts · Yoi +
+

Hosting accounts

+ {#if viewState === 'loading'} +

Checking hosting access…

+ {:else if viewState === 'anonymous'} +

Sign in with a hosting administrator account to issue activation codes.

+ Open Account + {:else if viewState === 'denied'} +

Hosting administrator access is required.

+ {:else if viewState === 'error'} + + + {:else} +

Create a pending, ordinary user with an ID only. No email or existing Passkey is needed, even when self-registration is disabled.

+ {#if issued} +
+

Activation code issued

+
Account ID (handle)
{issued.user.handle}
Expires
{new Date(issued.expires_at).toLocaleString()}
One-time code
{issued.code}
+

This code is shown only here and disappears when hidden, expired or you leave. Privately give it and the ID to the recipient. Do not put it in a URL, screenshot, log or support message.

+

Ask the recipient to open /activate on this Server and register a Passkey within 15 minutes. The pending account is not active until registration completes.

+ +
+ {:else} +
{ event.preventDefault(); void issue(); }} autocomplete="off"> + + + + +

1–64 ASCII letters, numbers, hyphens or underscores. Surrounding spaces are removed and letters become lowercase.

+ {#if operation === 'reissue'}

Reissuing invalidates the old code and any in-progress registration. It cannot replace a Passkey or recover an active account.

{/if} + +
+ {/if} + {#if error}{/if} +
Issuance and recovery steps

Create a new ID, deliver the code privately, then ask the recipient to register at /activate. Codes expire after 15 minutes or five incorrect attempts. Options alone do not consume a correct code. After a cancelled prompt or reload, re-enter ID and code. If expired, locked or lost, choose reissue for the same pending ID and deliver only the new code. Ask the recipient to check Passkey login first if completion was uncertain. After activation use Passkey login, not the code.

+ Hosting settings + {/if} +
+ + diff --git a/web/workspace/src/routes/hosting/accounts/accounts.browser.spec.ts b/web/workspace/src/routes/hosting/accounts/accounts.browser.spec.ts new file mode 100644 index 000000000..36a492ff4 --- /dev/null +++ b/web/workspace/src/routes/hosting/accounts/accounts.browser.spec.ts @@ -0,0 +1,115 @@ +// @vitest-environment happy-dom +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import Accounts from "./+page.svelte"; +import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); +function boundary( + mode: "admin" | "anonymous" | "ordinary" | "revoked" | "unknown" | "loading", +) { + let accesses = 0; + const fetcher = vi.fn(async (input, init) => { + if (input === "/api/auth/whoami") { + return Response.json({ + actor: mode === "anonymous" + ? null + : { ...authUser, auth_method: "browser_session" }, + }); + } + if (input === "/api/hosting/access") { + accesses++; + if (mode === "loading") return await new Promise(() => {}); + return Response.json({ + is_admin: mode !== "ordinary" && !(mode === "revoked" && accesses > 1), + }); + } + if ( + init?.method === "POST" && + String(input).startsWith("/api/hosting/accounts") + ) { + if (mode === "unknown") throw new Error("private diagnostic"); + return Response.json({ + user: authUser, + code: "synthetic-output-only", + expires_at: new Date(Date.now() + 900000).toISOString(), + }); + } + throw new Error("Unexpected endpoint"); + }); + vi.stubGlobal("fetch", fetcher); + return fetcher; +} +async function issue() { + await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), { + target: { value: " ALICE " }, + }); + await fireEvent.click( + screen.getByRole("button", { name: "Create account and issue code" }), + ); +} +for (const mode of ["anonymous", "ordinary", "loading"] as const) { + test(`${mode} hides account mutations before current administrator access is confirmed`, async () => { + const fetcher = boundary(mode); + render(Accounts); + await screen.findByText( + mode === "anonymous" + ? /Sign in with a hosting administrator/ + : mode === "ordinary" + ? "Hosting administrator access is required." + : "Checking hosting access…", + ); + expect(screen.queryByLabelText("Account ID (handle)")).toBeNull(); + expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe( + false, + ); + }); +} +test("creation shows issuance only in page memory and hide clears the code", async () => { + const fetcher = boundary("admin"); + render(Accounts); + await issue(); + await screen.findByRole("heading", { name: "Activation code issued" }); + const post = fetcher.mock.calls.find(([, init]) => init?.method === "POST")!; + expect(JSON.parse(String(post[1]?.body))).toEqual({ handle: "alice" }); + expect(post[0]).toBe("/api/hosting/accounts"); + await fireEvent.click(screen.getByRole("button", { name: "Hide code" })); + expect(screen.queryByText("synthetic-output-only")).toBeNull(); + expect(screen.getByLabelText("Account ID (handle)")).toBeTruthy(); + // 実際のselect操作と再発行はproduction-shell browser suiteが確認する。 +}); +test("access revoked after page load prevents issuance", async () => { + const fetcher = boundary("revoked"); + render(Accounts); + await issue(); + await screen.findByText("Hosting administrator access is required."); + expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe( + false, + ); +}); +test("lost issuance response never retries or echoes private diagnostics", async () => { + const fetcher = boundary("unknown"); + render(Accounts); + await issue(); + await waitFor(() => + expect(screen.getByRole("alert").textContent).toContain( + "could not be confirmed", + ) + ); + expect(screen.getByRole("alert").textContent).not.toContain( + "private diagnostic", + ); + expect(screen.queryByRole("heading", { name: "Activation code issued" })) + .toBeNull(); + expect( + fetcher.mock.calls.filter(([, init]) => init?.method === "POST").length, + ).toBe(1); +}); diff --git a/web/workspace/src/routes/hosting/settings/+page.svelte b/web/workspace/src/routes/hosting/settings/+page.svelte index a6024a595..c7bd56c4c 100644 --- a/web/workspace/src/routes/hosting/settings/+page.svelte +++ b/web/workspace/src/routes/hosting/settings/+page.svelte @@ -128,6 +128,7 @@ {:else if settings} + Create accounts or reissue activation codes

Self-registration

Applies to this hosting, across all Workspaces.

diff --git a/web/workspace/test/activation-browser/activation-shell.browser.test.ts b/web/workspace/test/activation-browser/activation-shell.browser.test.ts new file mode 100644 index 000000000..2b2cdf1a5 --- /dev/null +++ b/web/workspace/test/activation-browser/activation-shell.browser.test.ts @@ -0,0 +1,256 @@ +/// +import { assert, assertEquals } from "@std/assert"; +import { resolve } from "@std/path"; +import { chromium, type Locator } from "playwright"; +import { + startOwnedProcesses, + stopOwnedProcesses, +} from "../../../../tools/web-ux/src/processes.ts"; +const root = resolve(import.meta.dirname!, "../../../.."); +// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。 +Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => { + const processes = await startOwnedProcesses( + [{ + id: "activation-browser", + command: "deno", + cwd: root, + args: [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/activation-browser/fixture-server.ts", + "15381", + "web/workspace/build", + ], + readyUrl: "http://127.0.0.1:15381/health", + }], + `${root}/tools/web-ux/scenarios/hosting-settings-light.json`, + `${root}/target/web-ux/activation-interactions/processes`, + [], + ); + const browser = await chromium.launch(); + try { + for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const context = await browser.newContext({ + viewport: { width, height: 900 }, + colorScheme, + reducedMotion: "reduce", + }); + try { + const page = await context.newPage(); + const requested: string[] = []; + const errors: string[] = []; + page.on("request", (request) => { + if (new URL(request.url()).pathname.startsWith("/api/")) { + requested.push( + `${request.method()} ${new URL(request.url()).pathname}`, + ); + } + }); + page.on("pageerror", () => errors.push("pageerror")); + const go = async (path: string, mode: string) => { + await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`); + }; + const noOverflow = async () => + assertEquals( + await page.evaluate(() => + document.documentElement.scrollWidth > innerWidth + ), + false, + ); + const linkStyle = (link: Locator) => + link.evaluate((element) => { + const style = getComputedStyle(element); + const probe = document.createElement("span"); + probe.hidden = true; + element.append(probe); + const token = (name: string) => { + probe.style.color = `var(${name})`; + return getComputedStyle(probe).color; + }; + const result = { + color: style.color, + outline: style.outlineColor, + width: style.outlineWidth, + kind: style.outlineStyle, + underline: style.textDecorationLine, + strong: token("--text-strong"), + text: token("--text"), + accent: token("--accent"), + }; + probe.remove(); + return result; + }); + const checkLink = async (link: Locator) => { + await page.mouse.move(0, 0); + const normal = await linkStyle(link); + assertEquals(normal.color, normal.strong); + assert(normal.underline.includes("underline")); + await link.hover(); + const hovered = await linkStyle(link); + assertEquals(hovered.color, hovered.text); + await page.mouse.move(0, 0); + await page.keyboard.press("Tab"); + await link.focus(); + const focused = await linkStyle(link); + assertEquals(focused.kind, "solid"); + assertEquals(focused.width, "2px"); + assertEquals(focused.outline, focused.accent); + }; + await go("/hosting/settings", "admin"); + await page.getByRole("link", { + name: "Create accounts or reissue activation codes", + }).click(); + await page.getByLabel("Account ID (handle)").waitFor(); + await checkLink( + page.getByRole("link", { name: "Hosting settings", exact: true }), + ); + const handle = page.getByLabel("Account ID (handle)"); + await handle.fill(" ALICE "); + const issue = page.getByRole("button", { + name: "Create account and issue code", + }); + await handle.press("Tab"); + assert( + await issue.evaluate((el) => + el === document.activeElement && + getComputedStyle(el).outlineStyle !== "none" + ), + ); + await page.keyboard.press("Enter"); + await page.getByRole("heading", { name: "Activation code issued" }) + .waitFor(); + await checkLink( + page.getByRole("link", { name: "/activate", exact: true }), + ); + assertEquals(await page.locator(".secret").count(), 1); + await noOverflow(); + await page.getByRole("button", { name: "Hide code" }).click(); + assertEquals(await page.locator(".secret").count(), 0); + await page.getByLabel("Operation").selectOption("reissue"); + await page.getByRole("button", { name: "Reissue activation code" }) + .click(); + await page.getByRole("heading", { name: "Activation code issued" }) + .waitFor(); + assert( + requested.includes("POST /api/hosting/accounts/activation-code"), + ); + for (const mode of ["anonymous", "ordinary"]) { + const before = requested.filter((path) => + path.startsWith("POST ") + ).length; + await go("/hosting/accounts", mode); + await page.getByText( + mode === "anonymous" + ? "Sign in with a hosting administrator account to issue activation codes." + : "Hosting administrator access is required.", + ).waitFor(); + assertEquals( + await page.getByLabel("Account ID (handle)").count(), + 0, + ); + assertEquals( + requested.filter((path) => path.startsWith("POST ")).length, + before, + ); + await noOverflow(); + } + const fillActivation = async () => { + await page.getByLabel("Account ID (handle)").fill("alice"); + await page.getByLabel("Activation code", { exact: true }).fill( + "synthetic-input-only", + ); + await page.getByRole("button", { + name: "Register Passkey and activate", + }).click(); + }; + await go("/activate", "activation-pending"); + await page.getByLabel("Activation code", { exact: true }).waitFor(); + await checkLink( + page.getByRole("link", { + name: + "Already registered or completion uncertain? Check Passkey login from Account", + }), + ); + await fillActivation(); + await page.getByText( + "Passkey registration pending. Account activation has not been confirmed.", + ).waitFor(); + assertEquals( + await page.getByLabel("Activation code", { exact: true }) + .inputValue(), + "", + ); + assertEquals( + requested.filter((path) => + path === "POST /api/auth/activation/complete" + ).length, + 0, + ); + await page.getByRole("button", { name: "Cancel registration" }) + .click(); + await page.getByRole("alert").waitFor(); + assertEquals(await page.getByText(/You are signed in/).count(), 0); + await noOverflow(); + await page.reload(); + await page.getByRole("button", { + name: "Register Passkey and activate", + }).waitFor(); + assertEquals( + await page.getByLabel("Activation code", { exact: true }) + .inputValue(), + "", + ); + for ( + const mode of [ + "activation-cancel", + "activation-unknown", + "activation-reject", + ] + ) { + await go("/activate", mode); + await fillActivation(); + await page.getByRole("alert").waitFor(); + assertEquals(await page.getByText(/You are signed in/).count(), 0); + await noOverflow(); + } + await go("/activate", "activation-success"); + await fillActivation(); + await page.getByText( + "Passkey registration and activation confirmed for alice. You are signed in.", + ).waitFor(); + assertEquals(await page.locator(".app-shell").count(), 0); + await noOverflow(); + assertEquals( + requested.some((path) => path.includes("/api/workspaces")), + false, + ); + assertEquals( + requested.some((path) => + path.includes("/addition/") || path.includes("/registration/") || + path.includes("/device") + ), + false, + ); + assertEquals( + await page.evaluate(() => + [...Object.values(localStorage), ...Object.values(sessionStorage)] + .some((value) => value.includes("synthetic-")) + ), + false, + ); + assertEquals(new URL(page.url()).searchParams.has("code"), false); + assertEquals(errors, []); + } finally { + await context.close(); + } + } + } + } finally { + await browser.close(); + await stopOwnedProcesses(processes); + } +}); diff --git a/web/workspace/test/activation-browser/capture.ts b/web/workspace/test/activation-browser/capture.ts new file mode 100644 index 000000000..e586d586d --- /dev/null +++ b/web/workspace/test/activation-browser/capture.ts @@ -0,0 +1,221 @@ +// 合成HTTPとWebAuthn境界だけを使い、本番build/shellをshared web-uxで採取する。 +// ServerE2Eとは別物。secret fieldは撮影前に隠し、snapshot/診断もredactする。 +import { resolve } from "@std/path"; +import { capture } from "../../../../tools/web-ux/src/capture.ts"; +import type { + Interaction, + RouteScenario, + Scenario, +} from "../../../../tools/web-ux/src/types.ts"; +const root = resolve(import.meta.dirname!, "../../../.."); +Deno.env.set("WEB_UX_SYNTHETIC_ACTIVATION_INPUT", "synthetic-input-only"); +const ready = (selector: string) => ({ kind: "selector" as const, selector }); +const click = (selector: string): Interaction => ({ + action: "click", + selector, +}); +const fill = (selector: string, value: string): Interaction => ({ + action: "fill", + selector, + value, +}); +const activation = [ + fill("#activation-handle", "alice"), + fill("#activation-code", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"), + click('button:has-text("Register Passkey and activate")'), +]; +const route = ( + id: string, + path: string, + selector: string, + capturePoints: RouteScenario["capturePoints"] = [{ + id: "initial", + label: id, + fullPage: true, + }], +): RouteScenario => ({ + id, + label: id, + path, + goal: "Check production shell and state/copy; not Server authorization.", + dataState: `Synthetic ${id}. No real code, account, credential or session.`, + ready: ready(selector), + capturePoints, +}); +const routes: RouteScenario[] = [ + route( + "settings", + "/hosting/settings?fixture=admin", + 'a[href="/hosting/accounts"]', + ), + route("admin", "/hosting/accounts?fixture=admin", "#account-handle:enabled", [ + { id: "initial", label: "ID-only creation", fullPage: true }, + { + id: "link-focus", + label: "Hosting settings link keyboard focus", + interaction: [{ action: "press", selector: "summary", key: "Tab" }], + ready: ready('a[href="/hosting/settings"]:focus-visible'), + fullPage: true, + }, + { + id: "issued", + label: "Issued; code redacted", + interaction: [ + fill("#account-handle", "alice"), + click('button:has-text("Create account and issue code")'), + ], + ready: ready("#issued-heading"), + fullPage: true, + }, + { + id: "reissue", + label: "Explicit reissue and invalidation warning", + interaction: [click('button:has-text("Hide code")'), { + action: "press", + selector: "#account-operation", + key: "ArrowDown", + }], + ready: ready('button:has-text("Reissue activation code")'), + fullPage: true, + }, + ]), + route( + "anonymous", + "/hosting/accounts?fixture=anonymous", + 'a[href="/account"]', + ), + route( + "ordinary", + "/hosting/accounts?fixture=ordinary", + 'p:has-text("Hosting administrator access is required.")', + ), + route("loading", "/hosting/accounts?fixture=loading", '[role="status"]'), + route( + "access-error", + "/hosting/accounts?fixture=access-error", + '[role="alert"]', + ), + route( + "issue-error", + "/hosting/accounts?fixture=issue-error", + "#account-handle:enabled", + [{ + id: "unconfirmed", + label: "Intentional HTTP409; unconfirmed issuance", + interaction: [ + fill("#account-handle", "alice"), + click('button:has-text("Create account and issue code")'), + ], + ready: ready('[role="alert"]'), + fullPage: true, + }], + ), + route( + "activation-pending", + "/activate?fixture=activation-pending", + "#activation-code:enabled", + [ + { + id: "initial", + label: "ID and code before mandatory Passkey", + fullPage: true, + }, + { + id: "link-focus", + label: "Account recovery link keyboard focus", + interaction: [{ + action: "press", + selector: "summary", + key: "Shift+Tab", + }], + ready: ready('a[href="/account"]:focus-visible'), + fullPage: true, + }, + { + id: "pending", + label: "Pending prompt, no activation claim", + interaction: activation, + ready: ready('button:has-text("Cancel registration")'), + fullPage: true, + }, + { + id: "cancelled", + label: "Cancelled; code cleared and restart guidance", + interaction: [click('button:has-text("Cancel registration")')], + ready: ready('[role="alert"]'), + fullPage: true, + }, + ], + ), + ...[ + "activation-cancel", + "activation-unknown", + "activation-reject", + "activation-success", + ].map((mode) => + route(mode, `/activate?fixture=${mode}`, "#activation-code:enabled", [{ + id: "result", + label: mode, + interaction: activation, + ready: ready( + mode === "activation-success" ? '[role="status"]' : '[role="alert"]', + ), + fullPage: true, + }]) + ), +]; +for (const colorScheme of ["light", "dark"] as const) { + for (const width of [1440, 768, 390]) { + const scenario: Scenario = { + schemaVersion: 1, + id: `activation-${colorScheme}-${width}`, + title: "Activation synthetic projection on production build", + baseUrl: "http://127.0.0.1:15382", + colorScheme, + reducedMotion: "reduce", + personas: [{ + id: "fixture", + label: "Synthetic HTTP/WebAuthn only", + auth: { kind: "anonymous" }, + }], + viewports: [{ width, height: 900, label: `${width}` }], + routes, + redact: { + selectors: [".secret"], + text: ["synthetic-output-only", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"], + }, + processes: [{ + id: "activation-projection", + command: "deno", + cwd: root, + args: [ + "run", + "--config", + "tools/web-ux/deno.json", + "--allow-read", + "--allow-net", + "web/workspace/test/activation-browser/fixture-server.ts", + "15382", + "web/workspace/build", + ], + readyUrl: "http://127.0.0.1:15382/health", + }], + }; + const scenarioPath = + `${root}/target/web-ux/activation-${colorScheme}-${width}.json`; + await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2)); + const result = await capture({ + scenarioPath, + outputDirectory: `${root}/target/web-ux`, + runId: `activation-after-v3-${colorScheme}-${width}`, + }); + console.log( + JSON.stringify({ + colorScheme, + width, + status: result.status, + captures: result.captures.length, + }), + ); + } +} diff --git a/web/workspace/test/activation-browser/fixture-server.ts b/web/workspace/test/activation-browser/fixture-server.ts new file mode 100644 index 000000000..5abfa28e8 --- /dev/null +++ b/web/workspace/test/activation-browser/fixture-server.ts @@ -0,0 +1,125 @@ +// 本番buildを配信するUI投影fixture。実際のcode検証、認証、DB、cookie発行ではない。 +// request本文/credential/secretを記録しない。fixture cookieは公開状態名だけ。 +import { extname, join, resolve } from "@std/path"; +import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts"; +const [port, build] = Deno.args; +if (!port || !build) throw new Error("Expected port and build root"); +const root = resolve(build); +const mime: Record = { + ".html": "text/html", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".woff2": "font/woff2", + ".png": "image/png", +}; +const user = { + user_id: "fixture-user", + account_id: "fixture-account", + handle: "alice", + display_name: "Alice", +}; +Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + const selected = url.searchParams.get("fixture"); + const mode = selected ?? + request.headers.get("cookie")?.match(/activation-fixture=([\w-]+)/)?.[1] ?? + "admin"; + const path = url.pathname; + if (path === "/__ui_fixture/webauthn.js") { + const source = mode === "activation-pending" + ? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>new Promise(()=>{})});` + : mode === "activation-cancel" + ? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>{throw new DOMException('Cancelled','NotAllowedError')}});` + : setupWebAuthnProjection; + return new Response(source, { + headers: { "content-type": "text/javascript" }, + }); + } + if (path === "/api/workspaces") return new Response(null, { status: 503 }); + if (path === "/api/auth/whoami") { + return Response.json({ + actor: mode === "anonymous" + ? null + : { ...user, auth_method: "browser_session" }, + }); + } + if (path === "/api/hosting/access") { + if (mode === "loading") return await new Promise(() => {}); + if (mode === "access-error") return new Response(null, { status: 503 }); + return Response.json({ + is_admin: mode !== "ordinary" && mode !== "anonymous", + }); + } + if (path === "/api/hosting/settings") { + return Response.json({ self_registration_enabled: false }); + } + if ( + path === "/api/hosting/accounts" || + path === "/api/hosting/accounts/activation-code" + ) { + if (mode === "issue-error") return new Response(null, { status: 409 }); + // codeは暗号素材でない固定の投影値。captureは.secretを隠しsnapshotからも除外する。 + return Response.json({ + user, + code: "synthetic-output-only", + expires_at: new Date(Date.now() + 900000).toISOString(), + }); + } + if (path === "/api/auth/activation/options") { + if (mode === "activation-reject") { + return new Response(null, { status: 403 }); + } + return Response.json({ + challenge_id: "ui-projection-only", + public_key: { + challenge: "dWk", + rp: { name: "UI fixture" }, + user: { id: "dWk", name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + }, + }); + } + if (path === "/api/auth/activation/complete") { + if (mode === "activation-unknown") { + return new Response(null, { status: 503 }); + } + return Response.json({ user }); + } + if (path.startsWith("/api/")) return new Response(null, { status: 404 }); + const file = join(root, decodeURIComponent(path)); + if (!file.startsWith(root + "/")) return new Response(null, { status: 400 }); + let bytes: Uint8Array; + let extension = extname(file); + try { + bytes = await Deno.readFile(file); + } catch (error) { + if ( + !(error instanceof Deno.errors.NotFound) && + !(error instanceof Deno.errors.IsADirectory) + ) throw error; + bytes = await Deno.readFile(join(root, "index.html")); + extension = ".html"; + } + if (extension === ".html") { + bytes = new TextEncoder().encode( + new TextDecoder().decode(bytes).replace( + "", + '', + ), + ); + } + const headers = new Headers({ + "content-type": mime[extension] ?? "application/octet-stream", + "cache-control": "no-store", + }); + if (selected) { + headers.set( + "set-cookie", + `activation-fixture=${selected}; Path=/; SameSite=Strict`, + ); + } + return new Response(bytes as Uint8Array, { headers }); +}); diff --git a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts index 0af4d5938..0fa32136f 100644 --- a/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts +++ b/web/workspace/test/hosting-browser/hosting-shell.browser.test.ts @@ -101,10 +101,10 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa await page.keyboard.press("Enter"); const edit = page.getByRole("button", { name: "Edit registration policy" }); await edit.waitFor(); - assertEquals( - await page.locator('a[href="/hosting/settings"]').getAttribute("aria-current"), - "page", - ); + // Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。 + // Workspace未作成でも管理できることと整合させる。 + assertEquals(await nav.count(), 0); + await page.getByRole("heading", { name: "Hosting settings", exact: true }).waitFor(); if ( await page.getByRole("button", { name: "Hide sidebar", exact: true }).isVisible() && width < 768 diff --git a/web/workspace/test/spa-shell.test.ts b/web/workspace/test/spa-shell.test.ts index 9ea6417d2..6d84bb9f0 100644 --- a/web/workspace/test/spa-shell.test.ts +++ b/web/workspace/test/spa-shell.test.ts @@ -1,5 +1,30 @@ import { assertEquals } from "jsr:@std/assert"; -import { ssr } from "../src/routes/+layout.ts"; +import { load, ssr } from "../src/routes/+layout.ts"; + +Deno.test("activation and hosting shells never require Workspace catalog authority", async () => { + for ( + const path of [ + "/activate", + "/hosting/accounts", + "/hosting/settings", + "/hosting/setup", + ] + ) { + let calls = 0; + const event = { + url: new URL(path, "https://workspace.test"), + fetch: () => { + calls++; + throw new Error("Workspace API must not be called"); + }, + } as unknown as Parameters[0]; + assertEquals(await load(event).deferredCatalog, { + ok: true, + data: { accessibleWorkspaces: [], workspaceCatalogError: null }, + }); + assertEquals(calls, 0); + } +}); Deno.test("workspace frontend serves deep links through the client SPA", () => { assertEquals(ssr, false);