From b744efd1043de727cefcb986e57e529346b1238a Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 00:35:23 +0900 Subject: [PATCH 1/5] fix: align isolated runtime harness with current trust contract Keep Workspace trust enrollment and owner-authenticated registration, then verify each Runtime connection with a fresh signed request and match its binding ID. Remove obsolete workspace_trust_id input and cached connection_state assertions. Validation: cargo test -p yoi-e2e --features e2e --test worker_environment --test auth_passkeys -- --nocapture (2 Runtime harness tests and 1 Passkey product E2E passed); root cargo check. --- tests/e2e/src/worker_environment.rs | 32 ++++++++++++++++++----------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/tests/e2e/src/worker_environment.rs b/tests/e2e/src/worker_environment.rs index e0c4eafb2..18098a829 100644 --- a/tests/e2e/src/worker_environment.rs +++ b/tests/e2e/src/worker_environment.rs @@ -439,7 +439,14 @@ impl WorkerE2eEnvironment { false, )?; let enrollment: Value = serde_json::from_slice(&enrollment.stdout)?; - let workspace_trust_id = json_string(&enrollment, "/trust_id")?; + if json_string(&enrollment, "/trust_id")?.is_empty() + || enrollment.get("workspace_id").and_then(Value::as_str) != Some(workspace_id.as_str()) + || enrollment.get("state").and_then(Value::as_str) != Some("active") + { + return Err(HarnessError::Protocol( + "Runtime did not retain active trust for the fixture Workspace".to_string(), + )); + } let mut command = self.isolated_command(&runtime_binary); command.args([ @@ -468,12 +475,20 @@ impl WorkerE2eEnvironment { .post(format!("{server_url}/api/w/{workspace_id}/runtimes")) .json(&serde_json::json!({ "public_bundle": runtime_identity, - "workspace_trust_id": workspace_trust_id, "display_name": format!("E2E {}", self.runtime_id), "endpoint": runtime_url })), "register Runtime", )?; + self.assert_connected_and_empty()?; + self.write_metadata("running")?; + Ok(()) + } + + pub fn assert_connected_and_empty(&self) -> Result<()> { + let server_url = self.required_server_url()?; + let workspace_id = self.required_workspace_id()?; + // connection testは実署名要求。保存済みの接続flagを認証成功の代わりにしない。 let connection = self.request_json( self.client.post(format!( "{server_url}/api/w/{workspace_id}/runtimes/{}/connection-tests", @@ -486,14 +501,7 @@ impl WorkerE2eEnvironment { "Runtime connection was not compatible: {connection}" ))); } - self.assert_connected_and_empty()?; - self.write_metadata("running")?; - Ok(()) - } - - pub fn assert_connected_and_empty(&self) -> Result<()> { - let server_url = self.required_server_url()?; - let workspace_id = self.required_workspace_id()?; + let binding_id = json_string(&connection, "/binding_id")?; let runtimes = self.request_json( self.client .get(format!("{server_url}/api/w/{workspace_id}/runtimes")), @@ -506,9 +514,9 @@ impl WorkerE2eEnvironment { items.iter().any(|item| { item.get("runtime_id").and_then(Value::as_str) == Some(self.runtime_id.as_str()) && item - .pointer("/management/binding/connection_state") + .pointer("/management/binding/binding_id") .and_then(Value::as_str) - == Some("verified") + == Some(binding_id.as_str()) }) }); if !connected { From 6218c27fb65a4cfb8bca9598cb155460c0559587 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 00:55:16 +0900 Subject: [PATCH 2/5] fix: separate account registration from authenticated passkey addition Bind enrollment purpose and allocated/current account to verifier state. Reject existing-handle registration, derive additions from live transport authentication, recheck revocation/expiry in the insert-only transaction, and consume challenges atomically across connections. Derive stable account WebAuthn handles without rewriting legacy credentials; reject implicit-user login and unsafe legacy ceremonies. Align account callers, DTO/OpenAPI/generated artifacts and migration/recovery rationale. Preserve credential-less legacy identities rather than allowing anonymous reclaim. Validation: 14 actual-verifier passkey tests; full workspace-server 1086 library + 11 binary passed (Drive ignored case explicitly selected separately and passed); server-api default 99 and TypeScript feature 108 passed; isolated auth/Runtime E2E 1+2 passed; Web 645 + component 394 passed; root cargo check, docs/doctests, Server/Runtime builds, fmt, naming and generated consistency passed. Software authenticator only; hosted CI/hardware login not claimed. --- Cargo.lock | 2 + crates/server-api/src/lib.rs | 74 + crates/workspace-server/src/auth.rs | 2 +- crates/workspace-server/src/server.rs | 1898 ++++++++++++++++- crates/workspace-server/src/store.rs | 93 +- openapi/server-api.json | 164 +- tests/e2e/Cargo.toml | 9 + tests/e2e/src/worker_environment.rs | 10 +- tests/e2e/tests/auth_passkeys.rs | 465 ++++ web/workspace/src/lib/generated/auth-api.ts | 2 + .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/auth/api.test-fixtures.ts | 50 + web/workspace/src/lib/workspace/auth/api.ts | 29 +- .../console/worker-console.ui.test.ts | 11 - web/workspace/src/routes/account/+page.svelte | 28 +- .../routes/account/account.browser.spec.ts | 180 ++ .../account-passkeys-fixture-server.ts | 42 + .../test/visual-review/account-passkeys.json | 76 + web/workspace/tests/auth-api.test.ts | 177 ++ 24 files changed, 3184 insertions(+), 140 deletions(-) create mode 100644 tests/e2e/tests/auth_passkeys.rs create mode 100644 web/workspace/src/lib/workspace/auth/api.test-fixtures.ts create mode 100644 web/workspace/src/routes/account/account.browser.spec.ts create mode 100644 web/workspace/test/visual-review/account-passkeys-fixture-server.ts create mode 100644 web/workspace/test/visual-review/account-passkeys.json diff --git a/Cargo.lock b/Cargo.lock index 2e6537d58..aa62dec4c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6708,8 +6708,10 @@ dependencies = [ "reqwest", "serde", "serde_json", + "server-api", "sha2 0.11.0", "tempfile", + "webauthn-authenticator-rs", ] [[package]] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index da736fc93..93dfcc610 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -179,6 +179,7 @@ impl_openapi_schema!( PasskeyLoginOptionsRequest, PasskeyLoginOptionsResponse, PasskeyLoginCompleteRequest, + PasskeyAdditionOptionsRequest, PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsResponse, PasskeyRegistrationCompleteRequest, @@ -546,6 +547,36 @@ pub trait ServerApi { #[body] request: PasskeyRegistrationCompleteRequest, ) -> Result; + #[post( + "/api/auth/passkeys/addition/options", + status = 200, + error_status = 400, + additional_error_statuses = [403, 500, 502], + bearer_auth = true, + browser_auth = true + )] + async fn auth_passkey_addition_options( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyAdditionOptionsRequest, + ) -> Result; + + #[post( + "/api/auth/passkeys/addition/complete", + responses = [ + (status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)]) + ], + error_status = 400, + additional_error_statuses = [403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn auth_passkey_addition_complete( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyRegistrationCompleteRequest, + ) -> Result; + #[post( "/api/auth/passkeys/login/options", status = 200, @@ -3872,6 +3903,7 @@ pub struct AuthUserResponse { pub user: AuthenticatedUser, } +/// Starts new-account registration only; an existing handle is never interpreted as key addition. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -3885,6 +3917,16 @@ pub struct PasskeyRegistrationOptionsRequest { pub browser_origin: Option, } +/// Adds a credential to the current authenticated actor; target identity is never public input. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyAdditionOptionsRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[cfg_attr(feature = "typescript", ts(optional = nullable))] + pub browser_origin: Option, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -3905,6 +3947,10 @@ pub struct PasskeyRegistrationCompleteRequest { pub credential: RegisterPublicKeyCredential, } +/// Identified-account login. Missing handles are rejected, not resolved to an implicit user. +/// +/// The nullable wire field permits an explicit authentication error for old callers; it does not +/// advertise a discoverable-login ceremony. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -11344,6 +11390,7 @@ pub fn auth_api_typescript() -> String { WhoamiResponse::decl(&config), AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), + PasskeyAdditionOptionsRequest::decl(&config), PasskeyRegistrationOptionsRequest::decl(&config), PasskeyRegistrationOptionsResponse::decl(&config), PasskeyRegistrationCompleteRequest::decl(&config), @@ -12061,6 +12108,16 @@ mod tests { HttpMethod::Post, "/api/auth/passkeys/registration/options", ), + ( + "auth_passkey_addition_options", + HttpMethod::Post, + "/api/auth/passkeys/addition/options", + ), + ( + "auth_passkey_addition_complete", + HttpMethod::Post, + "/api/auth/passkeys/addition/complete", + ), ( "auth_passkey_login_options", HttpMethod::Post, @@ -14452,6 +14509,20 @@ mod openapi_artifact_tests { } } + #[test] + fn passkey_addition_rejects_caller_selected_target_fields() { + for field in ["handle", "user_id", "account_id"] { + let payload = serde_json::json!({ (field): "other-user" }); + assert!( + serde_json::from_value::(payload).is_err(), + "addition must reject caller-selected {field}" + ); + } + assert!( + serde_json::from_value::(serde_json::json!({})).is_ok() + ); + } + #[test] fn auth_workspace_catalog_and_typed_header_contract_is_strict() { let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); @@ -14465,6 +14536,8 @@ mod openapi_artifact_tests { ("/api/auth/bootstrap-user", "post"), ("/api/auth/passkeys/registration/options", "post"), ("/api/auth/passkeys/registration/complete", "post"), + ("/api/auth/passkeys/addition/options", "post"), + ("/api/auth/passkeys/addition/complete", "post"), ("/api/auth/passkeys/login/options", "post"), ("/api/auth/passkeys/login/complete", "post"), ("/api/auth/logout", "post"), @@ -14503,6 +14576,7 @@ mod openapi_artifact_tests { } for path in [ "/api/auth/passkeys/registration/complete", + "/api/auth/passkeys/addition/complete", "/api/auth/passkeys/login/complete", "/api/auth/logout", ] { diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index 6dd4770bc..3fc9eb325 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -116,7 +116,7 @@ pub async fn resolve_request_actor( // One owned synchronous stage includes token usage marking and the user read. // Do not call async store wrappers here: they use the same executor themselves. -fn resolve_request_actor_sync( +pub(crate) fn resolve_request_actor_sync( store: &dyn ControlPlaneStore, bearer: Option, session: Option, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index a51d25c03..b38d6b092 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -230,9 +230,12 @@ use crate::resource_broker::BackendResourceBroker; use crate::runtime_settings::RuntimeConfigSchemaProvider; use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::skills; +#[cfg(test)] +use crate::store::AccountRecord; use crate::store::{ - AccountRecord, ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, - DeviceLoginFlowRecord, ExternalWorkdirGrantRecord, FlowSourceRecord, PasskeyCredentialRecord, + ApiTokenRecord, AuthChallengeRecord, BrowserSessionRecord, ControlPlaneStore, + DeviceLoginFlowRecord, ExternalWorkdirGrantRecord, FlowSourceRecord, + PasskeyAuthenticationBinding, PasskeyCredentialRecord, PasskeyRegistrationTarget, RepositoryInsertOutcome, RepositoryRecord, RuntimeRemovalOperation, RuntimeRemovalOperationState, TicketAssignmentPrincipal, TicketAssignmentRole, TicketRoleAssignmentRecord, TicketWorkerAssignmentRecord, UserRecord, @@ -6394,6 +6397,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { )) .layer(middleware::from_fn(attach_server_origin_context)); let actor_context = Router::new() + .merge(server_api::server_api_axum::auth_passkey_addition_options( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_passkey_addition_complete( + service.clone(), + )) .merge(server_api::server_api_axum::auth_device_login_approve( service.clone(), )) @@ -7345,6 +7354,56 @@ impl server_api::ServerApi for ServerApiContractService { ) } + async fn auth_passkey_addition_options( + &self, + context: server_api::ServerRequestContext, + request: server_api::PasskeyAdditionOptionsRequest, + ) -> std::result::Result + { + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + api.store + .db_executor() + .run(move || { + let (user, authentication) = passkey_addition_authority(&api, &headers)?; + start_passkey_registration( + &api, + &headers, + request.browser_origin.as_deref(), + PasskeyRegistrationTarget::Addition { + user, + authentication, + }, + ) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_addition_complete( + &self, + context: server_api::ServerRequestContext, + request: PasskeyRegistrationCompleteRequest, + ) -> std::result::Result< + server_api::server_api_responses::AuthPasskeyAdditionComplete, + server_api::RepositoryApiError, + > { + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + let (body, header_set_cookie) = api + .store + .db_executor() + .run(move || complete_passkey_enrollment(&api, request, Some(&headers))) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok( + server_api::server_api_responses::AuthPasskeyAdditionComplete::Status200 { + body, + header_set_cookie, + }, + ) + } + async fn auth_passkey_login_options( &self, context: server_api::ServerRequestContext, @@ -28934,17 +28993,51 @@ async fn scoped_list_host_workers( list_host_workers(State(api), AxumPath(path.host_id)).await } +/// Account creation requires a verified new-account ceremony. The old bootstrap +/// endpoint must not reserve handles or make an existing identity enrollable. async fn post_auth_bootstrap_user( - State(api): State, - Json(request): Json, + State(_api): State, + Json(_request): Json, ) -> ApiResult> { - api.store - .db_executor() - .run(move || { - let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; - Ok(Json(AuthUserResponse { user })) - }) - .await + Err(auth_error( + "verified_registration_required", + "use new-account passkey registration", + ) + .into()) +} + +/// Tagged state deliberately rejects pre-separation bare WebAuthn states. They +/// carry no enrollment purpose or current-auth binding and cannot be upgraded safely. +/// A caller can obtain new options and repeat new-account enrollment only for an +/// unused, unpersisted handle. Existing credential/current authenticated authority +/// instead permits login and actor-derived addition. +/// +/// Old options could already have persisted an Account/User without credentials. +/// Credential absence is not ownership proof: that existing handle cannot be +/// claimed anonymously or restarted as a new account. Use a different unused +/// handle or a separately authorized future recovery flow. Preserve those rows; +/// no DB edits, automatic deletion, bare-state conversion or new recovery/admin +/// feature is part of this migration. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct PasskeyRegistrationEnvelope { + target: PasskeyRegistrationTarget, + verifier: PasskeyRegistration, +} + +/// An account gets one stable WebAuthn user handle, without storing a new schema +/// field. Migration leaves all existing credentials, their random registration +/// handles and key material unchanged. Credential-ID-scoped authentication must +/// accept signed assertions both without userHandle and with the original random +/// or newly registered stable userHandle; it is not discoverable-handle lookup. +/// The real-verifier compatibility tests exercise each representation explicitly. +fn passkey_user_handle(account_id: &str) -> Uuid { + let digest = Sha256::digest(account_id.as_bytes()); + Uuid::from_bytes( + digest[..16] + .try_into() + .expect("SHA256 has at least 16 bytes"), + ) } async fn post_passkey_registration_options( @@ -28955,62 +29048,147 @@ async fn post_passkey_registration_options( api.store .db_executor() .run(move || { - let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; - let header_origin = request_origin(&headers); - let requested_origin = request - .browser_origin - .as_deref() - .or(header_origin.as_deref()); - let auth = auth_config_for_origin(&api.config, requested_origin)?; - let webauthn = webauthn_for_auth(&auth)?; - let exclude_credentials = passkeys_for_user(&api, &user.user_id)? - .into_iter() - .map(|passkey| passkey.cred_id().clone()) - .collect(); - let user_unique_id = Uuid::now_v7(); - let (public_key, state) = webauthn - .start_passkey_registration( - user_unique_id, - &user.handle, - &user.display_name, - Some(exclude_credentials), - ) - .map_err(|error| { - auth_error("webauthn_registration_options_failed", &error.to_string()) - })?; - let challenge_id = new_id("webauthn-registration"); - api.store.put_auth_challenge(&AuthChallengeRecord { - challenge_id: challenge_id.clone(), - ceremony: "passkey_registration".to_string(), - challenge: challenge_id.clone(), - user_id: Some(user.user_id), - rp_id: auth.rp_id, - origin: auth.origin, - state_json: Some(serde_json::to_string(&state).map_err(|error| { - auth_error("webauthn_state_serialize_failed", &error.to_string()) - })?), - expires_at: rfc3339_after(Duration::minutes(5)), - created_at: crate::auth::now_rfc3339(), - consumed_at: None, - })?; - Ok(Json(PasskeyRegistrationOptionsResponse { - challenge_id, - public_key, - })) + let user = allocate_new_user(&api, &request.handle, request.display_name.as_deref())?; + start_passkey_registration( + &api, + &headers, + request.browser_origin.as_deref(), + PasskeyRegistrationTarget::NewAccount { user }, + ) + .map(Json) }) .await } -/// Consumes a stored WebAuthn ceremony and registers only verified credentials. +/// Re-resolves transport credentials instead of trusting a context actor snapshot. +/// Cookie additions require the exact configured Origin even at direct API entry; +/// browser_origin in a JSON body is not CSRF evidence. +fn passkey_addition_authority( + api: &ServerAuthApi, + headers: &HeaderMap, +) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> { + let auth = auth_public_config(&api.config); + let actor = crate::auth::resolve_request_actor_sync( + api.store.as_ref(), + crate::auth::parse_bearer(headers), + parse_cookie(headers, &auth.cookie_name), + )? + .ok_or_else(|| { + auth_error( + "auth_required", + "passkey addition requires current authentication", + ) + })?; + let binding = match actor.auth_method { + ActorAuthMethod::BrowserSession => { + if request_origin(headers).as_deref() != Some(auth.origin.as_str()) { + return Err(Error::WorkspacePermissionDenied( + "cookie-authenticated passkey addition requires the configured Browser origin" + .into(), + ) + .into()); + } + PasskeyAuthenticationBinding::BrowserSession(token_hash( + &parse_cookie(headers, &auth.cookie_name) + .ok_or_else(|| auth_error("auth_required", "missing browser credential"))?, + )) + } + ActorAuthMethod::ApiToken => PasskeyAuthenticationBinding::ApiToken(token_hash( + &crate::auth::parse_bearer(headers) + .ok_or_else(|| auth_error("auth_required", "missing API credential"))?, + )), + }; + let user = api + .store + .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"))?; + Ok((user, binding)) +} + +fn start_passkey_registration( + api: &ServerAuthApi, + headers: &HeaderMap, + browser_origin: Option<&str>, + target: PasskeyRegistrationTarget, +) -> ApiResult { + let (user, exclude_credentials) = match &target { + PasskeyRegistrationTarget::NewAccount { user } => (user, Vec::new()), + PasskeyRegistrationTarget::Addition { user, .. } => ( + user, + passkeys_for_user(api, &user.user_id)? + .into_iter() + .map(|passkey| passkey.cred_id().clone()) + .collect(), + ), + }; + let header_origin = request_origin(headers); + let auth = auth_config_for_origin(&api.config, browser_origin.or(header_origin.as_deref()))?; + let webauthn = webauthn_for_auth(&auth)?; + let (public_key, verifier) = webauthn + .start_passkey_registration( + passkey_user_handle(&user.account_id), + &user.handle, + &user.display_name, + Some(exclude_credentials), + ) + .map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?; + let challenge_id = new_id("webauthn-registration"); + // New users do not exist yet, so the nullable FK remains empty. The complete + // 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()), + }; + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_registration".into(), + challenge: challenge_id.clone(), + user_id, + rp_id: auth.rp_id, + origin: auth.origin, + state_json: Some( + serde_json::to_string(&PasskeyRegistrationEnvelope { target, verifier }).map_err( + |error| auth_error("webauthn_state_serialize_failed", &error.to_string()), + )?, + ), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(PasskeyRegistrationOptionsResponse { + challenge_id, + public_key, + }) +} + +/// One-use ceremonies bind purpose, target, RP, origin and verifier state. A +/// consumed challenge is never restored on failure. Only verified credentials +/// reach the insert-only transaction; a concurrent handle winner cannot become +/// an addition. /// -/// Binds the challenge to User, RP, Origin, and stored verifier state, rejecting consumed or -/// expired ceremonies. Browser assertions alone do not create keys or sessions: credentials -/// are saved and browser sessions issued only after webauthn-rs verification succeeds. -/// Login also verifies a stored ceremony. +/// Credential commit precedes session issuance. If issuing the session or +/// delivering the response fails, the credential can already exist: log in with +/// that key rather than overwriting it or replaying the consumed completion. +/// After an unknown commit outcome, try login first. Restart new-account +/// enrollment with fresh options only for an unused handle with no persisted +/// identity; legacy pre-created identities follow the migration policy on +/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits. fn complete_passkey_registration( api: &ServerAuthApi, request: PasskeyRegistrationCompleteRequest, ) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { + complete_passkey_enrollment(api, request, None) +} + +fn complete_passkey_enrollment( + api: &ServerAuthApi, + request: PasskeyRegistrationCompleteRequest, + addition_headers: Option<&HeaderMap>, +) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { + let authority = addition_headers + .map(|headers| passkey_addition_authority(api, headers)) + .transpose()?; let challenge = api .store .consume_auth_challenge_by_id( @@ -29031,39 +29209,58 @@ fn complete_passkey_registration( ) .into()); } - let user_id = challenge.user_id.clone().ok_or_else(|| { - auth_error( - "invalid_passkey_challenge", - "passkey registration challenge is not bound to a user", - ) - })?; - let user = api.store.get_user(&user_id)?.ok_or_else(|| { - auth_error( - "unknown_auth_user", - "passkey registration user does not exist", - ) - })?; - let state_json = challenge.state_json.clone().ok_or_else(|| { - auth_error( - "missing_webauthn_state", - "passkey registration state was not persisted", - ) - })?; - let state: PasskeyRegistration = serde_json::from_str(&state_json) - .map_err(|error| auth_error("webauthn_state_deserialize_failed", &error.to_string()))?; - let webauthn = webauthn_for_challenge(&api.config, &challenge)?; - let passkey = webauthn - .finish_passkey_registration(&request.credential, &state) + let envelope: PasskeyRegistrationEnvelope = + serde_json::from_str(challenge.state_json.as_deref().ok_or_else(|| { + auth_error( + "missing_webauthn_state", + "registration state was not persisted", + ) + })?) + .map_err(|_| { + auth_error( + "invalid_registration_state", + "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() => + { + user.clone() + } + ( + PasskeyRegistrationTarget::Addition { + user, + authentication, + }, + Some((current, binding)), + ) if user.user_id == current.user_id + && user.account_id == current.account_id + && *authentication == binding + && challenge.user_id.as_deref() == Some(user.user_id.as_str()) => + { + current + } + _ => { + return Err(auth_error( + "passkey_registration_binding_mismatch", + "registration purpose, target or authentication does not match", + ) + .into()); + } + }; + let passkey = webauthn_for_challenge(&api.config, &challenge)? + .finish_passkey_registration(&request.credential, &envelope.verifier) .map_err(|error| { auth_error( "webauthn_registration_verification_failed", &error.to_string(), ) })?; - let credential_id = passkey_credential_id(&passkey)?; - api.store - .upsert_passkey_credential(&PasskeyCredentialRecord { - credential_id, + api.store.register_verified_passkey( + &envelope.target, + &PasskeyCredentialRecord { + credential_id: passkey_credential_id(&passkey)?, user_id: user.user_id.clone(), public_key_cose: serde_json::to_string(&passkey).map_err(|error| { auth_error("webauthn_passkey_serialize_failed", &error.to_string()) @@ -29072,8 +29269,9 @@ fn complete_passkey_registration( sign_count: 0, created_at: crate::auth::now_rfc3339(), last_used_at: None, - })?; - issue_browser_session(&api, user) + }, + )?; + issue_browser_session(api, user) } async fn post_passkey_login_options( @@ -29086,7 +29284,13 @@ async fn post_passkey_login_options( .run(move || { let user = match request.handle.as_deref() { Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, - None => api.store.any_user()?, + None => { + return Err(auth_error( + "passkey_handle_required", + "login requires an explicit handle", + ) + .into()); + } } .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; let passkeys = passkeys_for_user(&api, &user.user_id)?; @@ -29548,14 +29752,16 @@ fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { } } -fn ensure_user_account( +fn allocate_new_user( api: &ServerAuthApi, handle: &str, display_name: Option<&str>, -) -> ApiResult { +) -> ApiResult { let handle = normalize_handle(handle)?; - if let Some(user) = api.store.get_user_by_handle(&handle)? { - return Ok(user_response(user)); + if api.store.get_user_by_handle(&handle)?.is_some() + || api.store.get_account_by_handle("user", &handle)?.is_some() + { + return Err(auth_error("auth_handle_exists", "handle is already registered").into()); } let now = crate::auth::now_rfc3339(); let display_name = display_name @@ -29563,25 +29769,14 @@ fn ensure_user_account( .filter(|value| !value.is_empty()) .unwrap_or(handle.as_str()) .to_string(); - let account = AccountRecord { - account_id: new_id("acct-user"), - kind: "user".to_string(), - handle: handle.clone(), - display_name: display_name.clone(), - created_at: now.clone(), - updated_at: now.clone(), - }; - api.store.upsert_account(&account)?; - let user = UserRecord { + Ok(UserRecord { user_id: new_id("user"), - account_id: account.account_id, + account_id: new_id("acct-user"), handle, display_name, created_at: now.clone(), updated_at: now, - }; - api.store.upsert_user(&user)?; - Ok(user_response(user)) + }) } fn user_response(user: UserRecord) -> AuthenticatedUser { @@ -35671,6 +35866,12 @@ impl ApiError { fn api_error_status(error: &Error) -> StatusCode { match error { + Error::RuntimeOperationFailed { + runtime_id, code, .. + } if runtime_id == "workspace-auth" && code == "auth_required" => StatusCode::FORBIDDEN, + Error::RuntimeOperationFailed { runtime_id, .. } if runtime_id == "workspace-auth" => { + StatusCode::BAD_REQUEST + } Error::BrowserReopenConfirmationRequired | Error::WorkspacePermissionDenied(_) => { StatusCode::FORBIDDEN } @@ -50271,6 +50472,1491 @@ mod tests { assert_ne!(same_origin.status(), StatusCode::FORBIDDEN); } + /// Generated HTTP admission, real SQLite and WebAuthn verification are connected + /// here. Only the physical authenticator is replaced with SoftPasskey; all + /// identities used for addition come from cookies/tokens issued by real login. + mod passkey_security { + use super::*; + use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; + + const ORIGIN_URL: &str = "https://workspace.test"; + type Authenticator = WebauthnAuthenticator; + + struct Fixture { + _root: tempfile::TempDir, + api: WorkspaceApi, + app: Router, + } + + impl Fixture { + async fn new() -> Self { + let root = tempfile::tempdir().unwrap(); + let mut api = test_api(root.path()).await; + let AuthConfig::Passkey { + rp_id, + origin, + public_base_url, + .. + } = &mut api.config.auth; + *rp_id = "workspace.test".into(); + *origin = ORIGIN_URL.into(); + *public_base_url = ORIGIN_URL.into(); + let app = generated_auth_contract_router(ServerApiContractService::Workspace( + api.clone(), + )); + Self { + _root: root, + api, + app, + } + } + + async fn post( + &self, + path: &str, + cookie: Option<&str>, + origin: Option<&str>, + body: Value, + ) -> (StatusCode, HeaderMap, Value) { + self.post_authenticated(path, cookie, None, origin, body) + .await + } + + async fn post_authenticated( + &self, + path: &str, + cookie: Option<&str>, + bearer: Option<&str>, + origin: Option<&str>, + body: Value, + ) -> (StatusCode, HeaderMap, Value) { + Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await + } + + async fn post_to( + app: Router, + path: &str, + cookie: Option<&str>, + bearer: Option<&str>, + origin: Option<&str>, + body: Value, + ) -> (StatusCode, HeaderMap, Value) { + let mut request = Request::builder() + .method(Method::POST) + .uri(path) + .header(CONTENT_TYPE, "application/json"); + if let Some(cookie) = cookie { + request = request.header(axum::http::header::COOKIE, cookie); + } + if let Some(token) = bearer { + request = request + .header(axum::http::header::AUTHORIZATION, format!("Bearer {token}")); + } + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + let response = app + .oneshot( + request + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into())); + (status, headers, body) + } + + async fn device_token(&self, cookie: &str) -> String { + let (status, _, start) = self + .post( + "/api/auth/device-login/start", + None, + None, + serde_json::json!({"client_name":"passkey-test-device"}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{start}"); + let (status, _, pending) = self + .post( + "/api/auth/device-login/poll", + None, + None, + serde_json::json!({"device_code":start["device_code"]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{pending}"); + assert_eq!(pending["status"], "pending"); + assert!(pending.get("access_token").is_none_or(Value::is_null)); + let (status, _, approval) = self + .post( + "/api/auth/device-login/approve", + Some(cookie), + Some(ORIGIN_URL), + serde_json::json!({"user_code":start["user_code"]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{approval}"); + assert_eq!(approval["status"], "approved"); + let (status, _, polled) = self + .post( + "/api/auth/device-login/poll", + None, + None, + serde_json::json!({"device_code":start["device_code"]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{polled}"); + assert_eq!(polled["status"], "approved"); + let (status, _, consumed) = self + .post( + "/api/auth/device-login/poll", + None, + None, + serde_json::json!({"device_code":start["device_code"]}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{consumed}"); + assert_eq!(consumed["status"], "consumed"); + assert!(consumed.get("access_token").is_none_or(Value::is_null)); + polled["access_token"].as_str().unwrap().to_string() + } + + async fn options( + &self, + purpose: &str, + cookie: Option<&str>, + handle: &str, + ) -> PasskeyRegistrationOptionsResponse { + let body = if purpose == "registration" { + serde_json::json!({"handle": handle}) + } else { + serde_json::json!({}) + }; + let (status, _, body) = self + .post( + &format!("/api/auth/passkeys/{purpose}/options"), + cookie, + Some(ORIGIN_URL), + body, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + serde_json::from_value(body).unwrap() + } + + fn credential( + options: PasskeyRegistrationOptionsResponse, + authenticator: &mut Authenticator, + ) -> Value { + let credential = authenticator + .do_registration(Url::parse(ORIGIN_URL).unwrap(), options.public_key) + .unwrap(); + serde_json::to_value(PasskeyRegistrationCompleteRequest { + challenge_id: options.challenge_id, + credential, + }) + .unwrap() + } + + async fn enroll( + &self, + purpose: &str, + cookie: Option<&str>, + handle: &str, + authenticator: &mut Authenticator, + ) -> (String, AuthenticatedUser) { + let options = self.options(purpose, cookie, handle).await; + let request = Self::credential(options, authenticator); + let (status, headers, body) = self + .post( + &format!("/api/auth/passkeys/{purpose}/complete"), + cookie, + Some(ORIGIN_URL), + request, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let cookie = headers[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + ( + cookie, + serde_json::from_value::(body) + .unwrap() + .user, + ) + } + + async fn login_session( + &self, + handle: &str, + authenticator: &mut Authenticator, + ) -> (String, AuthenticatedUser) { + self.login_session_with_user_handle(handle, authenticator, None) + .await + } + + /// SoftPasskey 0.5.5 does not retain/return a registration user handle: + /// its actual signed assertion has user_handle=None. Exercise that + /// output and the response a real authenticator can return by supplying + /// the exact registered bytes. userHandle is not signature-covered; + /// all signed fields remain intact and the real verifier still runs. + async fn login_session_with_user_handle( + &self, + handle: &str, + authenticator: &mut Authenticator, + user_handle: Option<&[u8]>, + ) -> (String, AuthenticatedUser) { + let (status, _, body) = self + .post( + "/api/auth/passkeys/login/options", + None, + Some(ORIGIN_URL), + serde_json::json!({"handle":handle}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let options: PasskeyLoginOptionsResponse = serde_json::from_value(body).unwrap(); + let mut credential = authenticator + .do_authentication(Url::parse(ORIGIN_URL).unwrap(), options.public_key) + .unwrap(); + assert!( + credential.response.user_handle.is_none(), + "inspect soft-authenticator output without logging assertion material" + ); + let signed_fields = ( + credential.response.authenticator_data.clone(), + credential.response.client_data_json.clone(), + credential.response.signature.clone(), + ); + credential.response.user_handle = user_handle.map(|bytes| bytes.to_vec().into()); + assert_eq!( + credential + .response + .user_handle + .as_ref() + .map(|bytes| bytes.as_slice()), + user_handle + ); + assert!( + ( + &credential.response.authenticator_data, + &credential.response.client_data_json, + &credential.response.signature + ) == (&signed_fields.0, &signed_fields.1, &signed_fields.2), + "userHandle adjustment must not change signed assertion fields", + ); + let (status, headers, body) = self + .post( + "/api/auth/passkeys/login/complete", + None, + None, + serde_json::to_value(PasskeyLoginCompleteRequest { + challenge_id: options.challenge_id, + credential, + }) + .unwrap(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let cookie = headers[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_string(); + ( + cookie, + serde_json::from_value::(body) + .unwrap() + .user, + ) + } + + async fn login( + &self, + handle: &str, + authenticator: &mut Authenticator, + ) -> AuthenticatedUser { + self.login_session(handle, authenticator).await.1 + } + + fn credentials(&self, user: &AuthenticatedUser) -> Vec { + self.api + .store + .list_passkey_credentials_for_user(&user.user_id) + .unwrap() + } + + fn expire_challenge(&self, id: &str) { + self.api.config_store.with_conn(|conn| { + conn.execute("UPDATE auth_challenges SET expires_at = '2000-01-01T00:00:00Z' WHERE challenge_id = ?1", [id])?; + Ok(()) + }).unwrap(); + } + } + + fn authenticator() -> Authenticator { + WebauthnAuthenticator::new(SoftPasskey::new(true)) + } + + fn rejected(result: (StatusCode, HeaderMap, Value), status: StatusCode, code: &str) { + assert_eq!(result.0, status, "{}", result.2); + assert!(result.2.to_string().contains(code), "{}", result.2); + assert!(!result.1.contains_key(axum::http::header::SET_COOKIE)); + } + + #[tokio::test] + async fn new_account_is_deferred_and_own_addition_preserves_old_keys_and_stable_user_handle() + { + let f = Fixture::new().await; + let mut first = authenticator(); + let options = f.options("registration", None, "alice").await; + assert!(f.api.store.get_user_by_handle("alice").unwrap().is_none()); + assert!( + f.api + .store + .get_account_by_handle("user", "alice") + .unwrap() + .is_none() + ); + let original_handle = + serde_json::to_value(&options.public_key).unwrap()["publicKey"]["user"]["id"] + .clone(); + assert!(!original_handle.is_null()); + let request = Fixture::credential(options, &mut first); + let (status, headers, body) = f + .post( + "/api/auth/passkeys/registration/complete", + None, + Some(ORIGIN_URL), + request.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let cookie = headers[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap(); + let user = serde_json::from_value::(body) + .unwrap() + .user; + let originals = f.credentials(&user); + rejected( + f.post( + "/api/auth/passkeys/registration/complete", + None, + None, + request, + ) + .await, + StatusCode::BAD_REQUEST, + "invalid_passkey_challenge", + ); + for cookie in [None, Some(cookie)] { + rejected( + f.post( + "/api/auth/passkeys/registration/options", + cookie, + Some(ORIGIN_URL), + serde_json::json!({"handle":"ALICE"}), + ) + .await, + StatusCode::BAD_REQUEST, + "auth_handle_exists", + ); + } + let options = f.options("addition", Some(cookie), "ignored").await; + assert_eq!( + serde_json::to_value(&options.public_key).unwrap()["publicKey"]["user"]["id"], + original_handle + ); + let mut second = authenticator(); + let request = Fixture::credential(options, &mut second); + let result = f + .post( + "/api/auth/passkeys/addition/complete", + Some(cookie), + Some(ORIGIN_URL), + request.clone(), + ) + .await; + assert_eq!(result.0, StatusCode::OK, "{}", result.2); + assert_eq!( + serde_json::from_value::(result.2) + .unwrap() + .user, + user + ); + assert_eq!(f.credentials(&user).len(), 2); + assert_eq!( + f.api + .store + .get_passkey_credential(&originals[0].credential_id) + .unwrap() + .as_ref(), + Some(&originals[0]) + ); + rejected( + f.post( + "/api/auth/passkeys/addition/complete", + Some(cookie), + Some(ORIGIN_URL), + request, + ) + .await, + StatusCode::BAD_REQUEST, + "invalid_passkey_challenge", + ); + assert_eq!(f.login("alice", &mut first).await, user); + assert_eq!(f.login("alice", &mut second).await, user); + } + + #[tokio::test] + async fn addition_rejects_unauthenticated_and_target_injection_and_legacy_bootstrap() { + let f = Fixture::new().await; + let (cookie, user) = f + .enroll("registration", None, "alice", &mut authenticator()) + .await; + let (_, other) = f + .enroll("registration", None, "bob", &mut authenticator()) + .await; + let originals = f.credentials(&user); + rejected( + f.post( + "/api/auth/passkeys/addition/options", + None, + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await, + StatusCode::FORBIDDEN, + "auth_required", + ); + for field in ["handle", "user_id", "account_id"] { + let value = match field { + "handle" => other.handle.clone(), + "user_id" => other.user_id.clone(), + _ => other.account_id.clone(), + }; + let body = serde_json::json!({field:value}); + let result = f + .post( + "/api/auth/passkeys/addition/options", + Some(&cookie), + Some(ORIGIN_URL), + body, + ) + .await; + assert_eq!(result.0, StatusCode::UNPROCESSABLE_ENTITY, "{}", result.2); + } + let options = f.options("addition", Some(&cookie), "").await; + let request = Fixture::credential(options, &mut authenticator()); + for field in ["handle", "user_id", "account_id"] { + let mut injected = request.clone(); + injected[field] = Value::String(other.user_id.clone()); + let result = f + .post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + injected, + ) + .await; + assert_eq!(result.0, StatusCode::UNPROCESSABLE_ENTITY, "{}", result.2); + } + for handle in ["alice", "unused"] { + rejected( + f.post( + "/api/auth/bootstrap-user", + None, + None, + serde_json::json!({"handle":handle}), + ) + .await, + StatusCode::BAD_REQUEST, + "verified_registration_required", + ); + } + assert!(f.api.store.get_user_by_handle("unused").unwrap().is_none()); + assert!( + f.api + .store + .get_account_by_handle("user", "unused") + .unwrap() + .is_none() + ); + rejected( + f.post( + "/api/auth/passkeys/login/options", + None, + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await, + StatusCode::BAD_REQUEST, + "passkey_handle_required", + ); + assert_eq!(f.credentials(&user), originals); + } + + #[tokio::test] + async fn addition_completion_rechecks_account_session_revocation_and_expiry() { + let f = Fixture::new().await; + let mut alice_authenticator = authenticator(); + let (alice_cookie, alice) = f + .enroll("registration", None, "alice", &mut alice_authenticator) + .await; + let (bob_cookie, bob) = f + .enroll("registration", None, "bob", &mut authenticator()) + .await; + let bob_originals = f.credentials(&bob); + for failure in [ + "unauthenticated", + "different_account", + "different_session", + "revoked", + "session_expired", + "challenge_expired", + ] { + // Each case starts with a live real session and actual verifier state. + let (cookie, current) = f.login_session("alice", &mut alice_authenticator).await; + assert_eq!(current, alice); + let originals = f.credentials(&alice); + let options = f.options("addition", Some(&cookie), "").await; + if failure == "challenge_expired" { + f.expire_challenge(&options.challenge_id); + } + let request = Fixture::credential(options, &mut authenticator()); + let current_cookie = match failure { + "unauthenticated" => None, + "different_account" => Some(bob_cookie.as_str()), + "different_session" => Some(alice_cookie.as_str()), + _ => Some(cookie.as_str()), + }; + if failure == "revoked" { + let result = f + .post( + "/api/auth/logout", + Some(&cookie), + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await; + assert_eq!(result.0, StatusCode::OK, "{}", result.2); + } + if failure == "session_expired" { + let token = cookie.split_once('=').unwrap().1; + f.api.config_store.with_conn(|conn| { + conn.execute("UPDATE browser_sessions SET expires_at='2000-01-01T00:00:00Z' WHERE token_hash=?1", [token_hash(token)])?; + Ok(()) + }).unwrap(); + } + let (status, code) = match failure { + "unauthenticated" | "revoked" | "session_expired" => { + (StatusCode::FORBIDDEN, "auth_required") + } + "challenge_expired" => (StatusCode::BAD_REQUEST, "expired_passkey_challenge"), + _ => ( + StatusCode::BAD_REQUEST, + "passkey_registration_binding_mismatch", + ), + }; + rejected( + f.post( + "/api/auth/passkeys/addition/complete", + current_cookie, + Some(ORIGIN_URL), + request, + ) + .await, + status, + code, + ); + assert_eq!(f.credentials(&alice), originals, "{failure}"); + assert_eq!(f.credentials(&bob), bob_originals, "{failure}"); + } + } + + #[tokio::test] + async fn enrollment_rejects_wrong_purpose_legacy_state_and_target_tampering() { + let f = Fixture::new().await; + let (cookie, user) = f + .enroll("registration", None, "alice", &mut authenticator()) + .await; + let originals = f.credentials(&user); + for (purpose, other) in [("registration", "addition"), ("addition", "registration")] { + let options = f.options(purpose, Some(&cookie), "unused").await; + let request = Fixture::credential(options, &mut authenticator()); + rejected( + f.post( + &format!("/api/auth/passkeys/{other}/complete"), + Some(&cookie), + Some(ORIGIN_URL), + request, + ) + .await, + StatusCode::BAD_REQUEST, + "passkey_registration_binding_mismatch", + ); + } + for failure in ["legacy", "target"] { + let options = f.options("addition", Some(&cookie), "").await; + f.api + .config_store + .with_conn(|conn| { + let json: String = conn.query_row( + "SELECT state_json FROM auth_challenges WHERE challenge_id=?1", + [&options.challenge_id], + |row| row.get(0), + )?; + let mut state: Value = serde_json::from_str(&json).unwrap(); + if failure == "legacy" { + state = state["verifier"].take(); + } else { + state["target"]["user"]["account_id"] = + Value::String("other-account".into()); + } + conn.execute( + "UPDATE auth_challenges SET state_json=?2 WHERE challenge_id=?1", + rusqlite::params![options.challenge_id, state.to_string()], + )?; + Ok(()) + }) + .unwrap(); + let request = Fixture::credential(options, &mut authenticator()); + let code = if failure == "legacy" { + "invalid_registration_state" + } else { + "passkey_registration_binding_mismatch" + }; + rejected( + f.post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + request, + ) + .await, + StatusCode::BAD_REQUEST, + code, + ); + } + assert_eq!(f.credentials(&user), originals); + assert!(f.api.store.get_user_by_handle("unused").unwrap().is_none()); + } + + #[tokio::test] + async fn parallel_new_account_handle_collision_has_one_identity_and_immutable_credential() { + let f = Fixture::new().await; + let one = f.options("registration", None, "racing").await; + let two = f.options("registration", None, "racing").await; + assert!(f.api.store.get_user_by_handle("racing").unwrap().is_none()); + let request_one = Fixture::credential(one, &mut authenticator()); + let request_two = Fixture::credential(two, &mut authenticator()); + let (one, two) = tokio::join!( + f.post( + "/api/auth/passkeys/registration/complete", + None, + None, + request_one + ), + f.post( + "/api/auth/passkeys/registration/complete", + None, + None, + request_two + ), + ); + assert_eq!( + [one.0, two.0] + .into_iter() + .filter(|status| *status == StatusCode::OK) + .count(), + 1 + ); + let (winner, loser) = if one.0 == StatusCode::OK { + (one, two) + } else { + (two, one) + }; + rejected(loser, StatusCode::BAD_REQUEST, "auth_handle_exists"); + let user = serde_json::from_value::(winner.2) + .unwrap() + .user; + let credentials = f.credentials(&user); + assert_eq!(credentials.len(), 1); + let account = f + .api + .store + .get_account_by_handle("user", "racing") + .unwrap() + .unwrap(); + assert_eq!(account.account_id, user.account_id); + let persisted_user = f.api.store.get_user_by_handle("racing").unwrap().unwrap(); + assert_eq!(persisted_user.user_id, user.user_id); + // A verified registration never uses the login-update upsert path. + // Duplicate credential IDs roll back the complete new identity too. + let new_user = + allocate_new_user(&ServerAuthApi::from(&f.api), "rollback", None).unwrap(); + let mut duplicate = credentials[0].clone(); + duplicate.user_id = new_user.user_id.clone(); + duplicate.public_key_cose = "replacement-not-allowed".into(); + assert!( + f.api + .store + .register_verified_passkey( + &PasskeyRegistrationTarget::NewAccount { user: new_user }, + &duplicate + ) + .is_err() + ); + assert!( + f.api + .store + .get_user_by_handle("rollback") + .unwrap() + .is_none() + ); + assert!( + f.api + .store + .get_account_by_handle("user", "rollback") + .unwrap() + .is_none() + ); + assert_eq!(f.credentials(&user), credentials); + } + + #[tokio::test] + async fn addition_requires_real_cookie_origin_even_at_direct_contract_entry() { + use server_api::ServerApi; + let f = Fixture::new().await; + let (cookie, user) = f + .enroll("registration", None, "alice", &mut authenticator()) + .await; + let originals = f.credentials(&user); + for origin in [ + None, + Some("https://evil.test"), + Some("https://workspace.test:444"), + ] { + rejected( + f.post( + "/api/auth/passkeys/addition/options", + Some(&cookie), + origin, + serde_json::json!({"browser_origin":ORIGIN_URL}), + ) + .await, + StatusCode::FORBIDDEN, + "origin", + ); + let options = f.options("addition", Some(&cookie), "").await; + let request = Fixture::credential(options, &mut authenticator()); + rejected( + f.post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + origin, + request, + ) + .await, + StatusCode::FORBIDDEN, + "origin", + ); + } + let service = ServerApiContractService::Workspace(f.api.clone()); + // Even a historical actor fixture with matching IDs is not authority. + let actor = RequestActor { + user_id: user.user_id.clone(), + account_id: user.account_id.clone(), + handle: user.handle.clone(), + display_name: user.display_name.clone(), + auth_method: ActorAuthMethod::BrowserSession, + }; + let context = server_request_context( + Some(actor.clone()), + None, + None, + Some(ORIGIN_URL.into()), + &HeaderMap::new(), + ); + let error = service + .auth_passkey_addition_options( + context, + server_api::PasskeyAdditionOptionsRequest { + browser_origin: Some(ORIGIN_URL.into()), + }, + ) + .await + .unwrap_err(); + assert_eq!(api_macros::HttpError::status_code(&error), 403); + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, cookie.parse().unwrap()); + let context = + server_request_context(Some(actor), None, None, Some(ORIGIN_URL.into()), &headers); + let error = service + .auth_passkey_addition_options( + context, + server_api::PasskeyAdditionOptionsRequest { + browser_origin: Some(ORIGIN_URL.into()), + }, + ) + .await + .unwrap_err(); + assert_eq!(api_macros::HttpError::status_code(&error), 403); + assert_eq!(f.credentials(&user), originals); + } + + #[tokio::test] + async fn device_token_addition_is_actor_derived_and_bound_to_current_unrevoked_token() { + let f = Fixture::new().await; + let (cookie, user) = f + .enroll("registration", None, "alice", &mut authenticator()) + .await; + let token = f.device_token(&cookie).await; + let second_token = f.device_token(&cookie).await; + let originals = f.credentials(&user); + let mut added = authenticator(); + for failure in ["replacement_token", "revoked", "expired", "success"] { + let current_token = if failure == "revoked" || failure == "expired" { + f.device_token(&cookie).await + } else { + token.clone() + }; + let (status, _, body) = f + .post_authenticated( + "/api/auth/passkeys/addition/options", + None, + Some(¤t_token), + None, + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let options: PasskeyRegistrationOptionsResponse = + serde_json::from_value(body).unwrap(); + let request = Fixture::credential(options, &mut added); + if failure == "revoked" || failure == "expired" { + f.api.config_store.with_conn(|conn| { + let sql = if failure == "revoked" { "UPDATE api_tokens SET revoked_at='2000-01-01T00:00:00Z' WHERE token_hash=?1" } + else { "UPDATE api_tokens SET expires_at='2000-01-01T00:00:00Z' WHERE token_hash=?1" }; + conn.execute(sql, [token_hash(¤t_token)])?; + Ok(()) + }).unwrap(); + } + let presented = if failure == "replacement_token" { + &second_token + } else { + ¤t_token + }; + let result = f + .post_authenticated( + "/api/auth/passkeys/addition/complete", + None, + Some(presented), + None, + request, + ) + .await; + if failure == "success" { + assert_eq!(result.0, StatusCode::OK, "{}", result.2); + assert_eq!( + serde_json::from_value::(result.2) + .unwrap() + .user, + user + ); + } else if failure == "replacement_token" { + rejected( + result, + StatusCode::BAD_REQUEST, + "passkey_registration_binding_mismatch", + ); + } else { + rejected(result, StatusCode::FORBIDDEN, "auth_required"); + } + } + assert_eq!(f.credentials(&user).len(), 2); + assert_eq!( + f.api + .store + .get_passkey_credential(&originals[0].credential_id) + .unwrap() + .as_ref(), + Some(&originals[0]) + ); + assert_eq!(f.login("alice", &mut added).await, user); + } + + #[tokio::test] + async fn failed_new_account_verification_and_expired_challenge_never_create_identity() { + let f = Fixture::new().await; + let options = f.options("registration", None, "expired").await; + f.expire_challenge(&options.challenge_id); + let request = Fixture::credential(options, &mut authenticator()); + rejected( + f.post( + "/api/auth/passkeys/registration/complete", + None, + None, + request, + ) + .await, + StatusCode::BAD_REQUEST, + "expired_passkey_challenge", + ); + let options = f.options("registration", None, "mismatch").await; + let other = f.options("registration", None, "other").await; + let mut request = Fixture::credential(options, &mut authenticator()); + request["challenge_id"] = Value::String(other.challenge_id); + rejected( + f.post( + "/api/auth/passkeys/registration/complete", + None, + None, + request, + ) + .await, + StatusCode::BAD_REQUEST, + "webauthn_registration_verification_failed", + ); + for handle in ["expired", "mismatch", "other"] { + assert!(f.api.store.get_user_by_handle(handle).unwrap().is_none()); + assert!( + f.api + .store + .get_account_by_handle("user", handle) + .unwrap() + .is_none() + ); + } + } + + #[tokio::test] + async fn verified_addition_rejects_credential_revocation_or_expiry_at_storage_boundary() { + let f = Fixture::new().await; + let mut owner_authenticator = authenticator(); + let (_, owner) = f + .enroll("registration", None, "alice", &mut owner_authenticator) + .await; + // A genuinely separate connection observes the same SQLite authority; + // it does not share the primary store's in-process connection mutex. + let other_store = + SqliteWorkspaceStore::open_for_test(&f.api.config.database_path).unwrap(); + let api = ServerAuthApi::from(&f.api); + for (method, failure) in [ + ("session", "revoked"), + ("session", "expired"), + ("token", "revoked"), + ("token", "expired"), + ] { + let (cookie, current) = f.login_session("alice", &mut owner_authenticator).await; + assert_eq!(current, owner); + let token = if method == "token" { + Some(f.device_token(&cookie).await) + } else { + None + }; + let presented_cookie = (method == "session").then_some(cookie.as_str()); + let originals = f.credentials(&owner); + let (status, _, body) = f + .post_authenticated( + "/api/auth/passkeys/addition/options", + presented_cookie, + token.as_deref(), + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let options: PasskeyRegistrationOptionsResponse = + serde_json::from_value(body).unwrap(); + let request: PasskeyRegistrationCompleteRequest = + serde_json::from_value(Fixture::credential(options, &mut authenticator())) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, ORIGIN_URL.parse().unwrap()); + if let Some(cookie) = presented_cookie { + headers.insert(axum::http::header::COOKIE, cookie.parse().unwrap()); + } + if let Some(token) = &token { + headers.insert( + axum::http::header::AUTHORIZATION, + format!("Bearer {token}").parse().unwrap(), + ); + } + // Resolve the real transport credential, consume persisted state and + // verify the actual SoftPasskey attestation before invalidating auth. + let (current, authentication) = passkey_addition_authority(&api, &headers).unwrap(); + let challenge = api + .store + .consume_auth_challenge_by_id( + &request.challenge_id, + "passkey_registration", + &crate::auth::now_rfc3339(), + ) + .unwrap() + .unwrap(); + assert!(!is_expired(&challenge.expires_at)); + assert_eq!(challenge.user_id.as_deref(), Some(owner.user_id.as_str())); + let envelope: PasskeyRegistrationEnvelope = + serde_json::from_str(challenge.state_json.as_deref().unwrap()).unwrap(); + assert!( + envelope.target + == PasskeyRegistrationTarget::Addition { + user: current, + authentication: authentication.clone() + } + ); + let passkey = webauthn_for_challenge(&api.config, &challenge) + .unwrap() + .finish_passkey_registration(&request.credential, &envelope.verifier) + .unwrap(); + let verified = PasskeyCredentialRecord { + credential_id: passkey_credential_id(&passkey).unwrap(), + user_id: owner.user_id.clone(), + public_key_cose: serde_json::to_string(&passkey).unwrap(), + transports_json: None, + sign_count: 0, + created_at: crate::auth::now_rfc3339(), + last_used_at: None, + }; + assert!( + api.store + .get_passkey_credential(&verified.credential_id) + .unwrap() + .is_none() + ); + // Deterministic interleaving: auth was live through verification; + // this distinct connection commits revocation/expiry before insert. + other_store.with_conn(|conn| { + let (table, hash) = match &authentication { + PasskeyAuthenticationBinding::BrowserSession(hash) => ("browser_sessions", hash), + PasskeyAuthenticationBinding::ApiToken(hash) => ("api_tokens", hash), + }; + let column = if failure == "revoked" { "revoked_at" } else { "expires_at" }; + assert_eq!(conn.execute(&format!("UPDATE {table} SET {column}='2000-01-01T00:00:00Z' WHERE token_hash=?1"), [hash])?, 1); + Ok(()) + }).unwrap(); + let error = api + .store + .register_verified_passkey(&envelope.target, &verified) + .unwrap_err(); + assert!( + matches!(error, Error::RuntimeOperationFailed { code, .. } if code == "auth_required") + ); + assert!( + api.store + .get_passkey_credential(&verified.credential_id) + .unwrap() + .is_none() + ); + assert!( + other_store + .get_passkey_credential(&verified.credential_id) + .unwrap() + .is_none() + ); + assert_eq!(f.credentials(&owner), originals, "{method}/{failure}"); + assert_eq!( + other_store + .list_passkey_credentials_for_user(&owner.user_id) + .unwrap(), + originals + ); + } + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn same_addition_challenge_concurrent_complete_across_connections_commits_once() { + let f = Fixture::new().await; + let (cookie, owner) = f + .enroll("registration", None, "alice", &mut authenticator()) + .await; + let originals = f.credentials(&owner); + let mut added = authenticator(); + let options = f.options("addition", Some(&cookie), "").await; + let challenge_id = options.challenge_id.clone(); + let request = Fixture::credential(options, &mut added); + let other_store = + Arc::new(SqliteWorkspaceStore::open_for_test(&f.api.config.database_path).unwrap()); + let mut other_api = f.api.clone(); + other_api.store = other_store.clone(); + other_api.config_store = other_store.clone(); + let other_app = + generated_auth_contract_router(ServerApiContractService::Workspace(other_api)); + let start = Arc::new(tokio::sync::Barrier::new(2)); + let attempt = |app: Router| { + let start = start.clone(); + let cookie = cookie.clone(); + let request = request.clone(); + tokio::spawn(async move { + start.wait().await; + Fixture::post_to( + app, + "/api/auth/passkeys/addition/complete", + Some(&cookie), + None, + Some(ORIGIN_URL), + request, + ) + .await + }) + }; + let (one, two) = tokio::join!(attempt(f.app.clone()), attempt(other_app)); + let (one, two) = (one.unwrap(), two.unwrap()); + assert_eq!( + [one.0, two.0] + .into_iter() + .filter(|status| *status == StatusCode::OK) + .count(), + 1 + ); + let (winner, loser) = if one.0 == StatusCode::OK { + (one, two) + } else { + (two, one) + }; + assert!(winner.1.contains_key(axum::http::header::SET_COOKIE)); + assert_eq!( + serde_json::from_value::(winner.2) + .unwrap() + .user, + owner + ); + rejected(loser, StatusCode::BAD_REQUEST, "invalid_passkey_challenge"); + assert_eq!(f.credentials(&owner).len(), 2); + assert_eq!( + other_store + .list_passkey_credentials_for_user(&owner.user_id) + .unwrap(), + f.credentials(&owner) + ); + assert_eq!( + f.api + .store + .get_passkey_credential(&originals[0].credential_id) + .unwrap() + .as_ref(), + Some(&originals[0]) + ); + other_store + .with_conn(|conn| { + let consumed: bool = conn.query_row( + "SELECT consumed_at IS NOT NULL FROM auth_challenges WHERE challenge_id=?1", + [&challenge_id], + |row| row.get(0), + )?; + assert!(consumed); + Ok(()) + }) + .unwrap(); + assert_eq!(f.login("alice", &mut added).await, owner); + } + + #[tokio::test] + async fn legacy_bare_registration_preserves_precreated_uncredentialed_identity_and_rejects_anonymous_reclaim() + { + let f = Fixture::new().await; + let mut known_key = authenticator(); + let (_, known_user) = f + .enroll("registration", None, "known-user", &mut known_key) + .await; + let known_credentials = f.credentials(&known_user); + let api = ServerAuthApi::from(&f.api); + let now = crate::auth::now_rfc3339(); + // Reproduce old options, not the new deferred-registration envelope: + // ensure_user_account persisted account/user before making a random- + // handle WebAuthn ceremony, even though no credential yet existed. + let account = AccountRecord { + account_id: new_id("acct-user"), + kind: "user".into(), + handle: "old-pending".into(), + display_name: "Old Pending Registration".into(), + created_at: now.clone(), + updated_at: now.clone(), + }; + api.store.upsert_account(&account).unwrap(); + let user = UserRecord { + user_id: new_id("user"), + account_id: account.account_id.clone(), + handle: account.handle.clone(), + display_name: account.display_name.clone(), + created_at: now.clone(), + updated_at: now.clone(), + }; + api.store.upsert_user(&user).unwrap(); + let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap(); + let (options, bare_state) = webauthn + .start_passkey_registration( + Uuid::now_v7(), + &user.handle, + &user.display_name, + Some(Vec::new()), + ) + .unwrap(); + let challenge_id = new_id("webauthn-registration"); + api.store + .put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_registration".into(), + challenge: challenge_id.clone(), + user_id: Some(user.user_id.clone()), + rp_id: "workspace.test".into(), + origin: ORIGIN_URL.into(), + state_json: Some(serde_json::to_string(&bare_state).unwrap()), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: now, + consumed_at: None, + }) + .unwrap(); + let mut old_authenticator = authenticator(); + let credential = old_authenticator + .do_registration(Url::parse(ORIGIN_URL).unwrap(), options) + .unwrap(); + // The input is a valid real attestation for the stored old verifier, + // not a malformed-credential fixture that fails for another reason. + let verified = webauthn + .finish_passkey_registration(&credential, &bare_state) + .unwrap(); + let old_credential_id = passkey_credential_id(&verified).unwrap(); + let request = serde_json::to_value(PasskeyRegistrationCompleteRequest { + challenge_id, + credential, + }) + .unwrap(); + let unchanged = || { + assert_eq!( + api.store.get_account(&account.account_id).unwrap().as_ref(), + Some(&account) + ); + assert_eq!( + api.store + .get_account_by_handle("user", &account.handle) + .unwrap() + .as_ref(), + Some(&account) + ); + assert_eq!( + api.store.get_user(&user.user_id).unwrap().as_ref(), + Some(&user) + ); + assert_eq!( + api.store.get_user_by_handle(&user.handle).unwrap().as_ref(), + Some(&user) + ); + assert!( + api.store + .list_passkey_credentials_for_user(&user.user_id) + .unwrap() + .is_empty() + ); + assert!( + api.store + .get_passkey_credential(&old_credential_id) + .unwrap() + .is_none() + ); + f.api + .config_store + .with_conn(|conn| { + let sessions: u64 = conn.query_row( + "SELECT COUNT(*) FROM browser_sessions WHERE user_id=?1", + [&user.user_id], + |row| row.get(0), + )?; + assert_eq!(sessions, 0); + Ok(()) + }) + .unwrap(); + }; + unchanged(); + rejected( + f.post( + "/api/auth/passkeys/registration/complete", + None, + Some(ORIGIN_URL), + request.clone(), + ) + .await, + StatusCode::BAD_REQUEST, + "invalid_registration_state", + ); + unchanged(); + rejected( + f.post( + "/api/auth/passkeys/registration/complete", + None, + Some(ORIGIN_URL), + request, + ) + .await, + StatusCode::BAD_REQUEST, + "invalid_passkey_challenge", + ); + unchanged(); + rejected( + f.post( + "/api/auth/passkeys/registration/options", + None, + Some(ORIGIN_URL), + serde_json::json!({"handle":"OLD-PENDING"}), + ) + .await, + StatusCode::BAD_REQUEST, + "auth_handle_exists", + ); + unchanged(); + rejected( + f.post( + "/api/auth/passkeys/addition/options", + None, + Some(ORIGIN_URL), + serde_json::json!({}), + ) + .await, + StatusCode::FORBIDDEN, + "auth_required", + ); + unchanged(); + assert_eq!(f.credentials(&known_user), known_credentials); + let (_, new_user) = f + .enroll( + "registration", + None, + "unused-alternative", + &mut authenticator(), + ) + .await; + assert_ne!(new_user.account_id, account.account_id); + assert_ne!(new_user.user_id, user.user_id); + assert_eq!(f.credentials(&new_user).len(), 1); + unchanged(); + assert_eq!(f.login("known-user", &mut known_key).await, known_user); + unchanged(); + } + + #[tokio::test] + async fn legacy_random_user_handle_credential_authenticates_before_and_after_stable_handle_addition() + { + let f = Fixture::new().await; + let api = ServerAuthApi::from(&f.api); + let user = allocate_new_user(&api, "legacy", None).unwrap(); + let random_handle = Uuid::now_v7(); + assert_ne!(random_handle, passkey_user_handle(&user.account_id)); + let webauthn = webauthn_for_auth(&auth_public_config(&api.config)).unwrap(); + let (options, state) = webauthn + .start_passkey_registration(random_handle, &user.handle, &user.display_name, None) + .unwrap(); + let legacy_user_handle = options.public_key.user.id.clone(); + assert_eq!(legacy_user_handle.as_slice(), random_handle.as_bytes()); + let mut legacy_authenticator = authenticator(); + let credential = legacy_authenticator + .do_registration(Url::parse(ORIGIN_URL).unwrap(), options) + .unwrap(); + let passkey = webauthn + .finish_passkey_registration(&credential, &state) + .unwrap(); + let record = PasskeyCredentialRecord { + credential_id: passkey_credential_id(&passkey).unwrap(), + user_id: user.user_id.clone(), + public_key_cose: serde_json::to_string(&passkey).unwrap(), + transports_json: None, + sign_count: 0, + created_at: crate::auth::now_rfc3339(), + last_used_at: None, + }; + f.api + .store + .register_verified_passkey( + &PasskeyRegistrationTarget::NewAccount { user: user.clone() }, + &record, + ) + .unwrap(); + let expected = user_response(user.clone()); + for returned_handle in [None, Some(legacy_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle( + "legacy", + &mut legacy_authenticator, + returned_handle, + ) + .await; + assert_eq!(logged_in, expected); + } + let (cookie, _) = f + .login_session_with_user_handle( + "legacy", + &mut legacy_authenticator, + Some(legacy_user_handle.as_slice()), + ) + .await; + let before_add = f.credentials(&expected); + let mut added = authenticator(); + let options = f.options("addition", Some(&cookie), "").await; + let stable_user_handle = options.public_key.public_key.user.id.clone(); + assert_eq!( + stable_user_handle.as_slice(), + passkey_user_handle(&user.account_id).as_bytes() + ); + assert_ne!(stable_user_handle, legacy_user_handle); + let request = Fixture::credential(options, &mut added); + let (status, _, body) = f + .post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + request, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + serde_json::from_value::(body) + .unwrap() + .user, + expected + ); + assert_eq!( + f.api + .store + .get_passkey_credential(&record.credential_id) + .unwrap() + .as_ref(), + Some(&before_add[0]) + ); + for returned_handle in [None, Some(legacy_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle( + "legacy", + &mut legacy_authenticator, + returned_handle, + ) + .await; + assert_eq!(logged_in, expected); + } + for returned_handle in [None, Some(stable_user_handle.as_slice())] { + let (_, logged_in) = f + .login_session_with_user_handle("legacy", &mut added, returned_handle) + .await; + assert_eq!(logged_in, expected); + } + } + } + #[tokio::test] async fn generated_passkey_routes_complete_registration_login_and_logout_cookie_lifecycle() { use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 21b53dfcf..ba362f9a9 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -661,6 +661,30 @@ pub struct UserRecord { pub updated_at: String, } +/// Enrollment authority persisted with the WebAuthn verifier, never supplied by a client. +/// New-account identities are only allocations until verified completion commits them. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "purpose", rename_all = "snake_case", deny_unknown_fields)] +pub enum PasskeyRegistrationTarget { + NewAccount { + user: UserRecord, + }, + Addition { + user: UserRecord, + authentication: PasskeyAuthenticationBinding, + }, +} + +/// Hash of the exact transport credential that authorized an addition. A second +/// session for the same user cannot complete its ceremony; revocation is checked +/// again in the credential insertion transaction. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "method", content = "token_hash", rename_all = "snake_case")] +pub enum PasskeyAuthenticationBinding { + BrowserSession(String), + ApiToken(String), +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PasskeyCredentialRecord { pub credential_id: String, @@ -1552,6 +1576,13 @@ 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>; + /// Atomically creates a new account/user/credential, or inserts a credential + /// for a still-authenticated existing target. Never overwrites a credential. + fn register_verified_passkey( + &self, + target: &PasskeyRegistrationTarget, + credential: &PasskeyCredentialRecord, + ) -> Result<()>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; fn get_passkey_credential( &self, @@ -6323,6 +6354,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn register_verified_passkey( + &self, + target: &PasskeyRegistrationTarget, + credential: &PasskeyCredentialRecord, + ) -> Result<()> { + self.with_immediate_transaction(|tx| { + let user = match target { + PasskeyRegistrationTarget::NewAccount { user } => { + let occupied: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)", + params![user.handle], |row| row.get(0), + )?; + if occupied { + return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered")); + } + tx.execute( + "INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) VALUES (?1, 'user', ?2, ?3, ?4, ?5)", + params![user.account_id, user.handle, user.display_name, user.created_at, user.updated_at], + )?; + tx.execute( + "INSERT INTO users (user_id, account_id, handle, display_name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![user.user_id, user.account_id, user.handle, user.display_name, user.created_at, user.updated_at], + )?; + user + } + PasskeyRegistrationTarget::Addition { user, authentication } => { + 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, + ).optional()?; + if current.is_none() { + return Err(crate::auth::auth_error("passkey_target_mismatch", "addition target no longer exists")); + } + let expires_at: Option> = match authentication { + PasskeyAuthenticationBinding::BrowserSession(hash) => tx.query_row( + "SELECT expires_at FROM browser_sessions WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL", + params![hash, user.user_id], |row| row.get(0), + ).optional()?, + PasskeyAuthenticationBinding::ApiToken(hash) => tx.query_row( + "SELECT expires_at FROM api_tokens WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL", + params![hash, user.user_id], |row| row.get(0), + ).optional()?, + }; + if !expires_at.is_some_and(|expiry| expiry.as_deref().is_none_or(|value| !crate::auth::is_expired(value))) { + return Err(crate::auth::auth_error("auth_required", "addition credential has expired or been revoked")); + } + user + } + }; + if user.user_id != credential.user_id { + return Err(crate::auth::auth_error("passkey_target_mismatch", "credential does not match enrollment target")); + } + tx.execute( + "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], + )?; + Ok(()) + }) + } + fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> { self.with_conn(|conn| { conn.execute( @@ -6412,7 +6503,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ceremony: &str, consumed_at: &str, ) -> Result> { - self.with_conn(|conn| { + self.with_immediate_transaction(|conn| { let record = conn .query_row( auth_challenge_select_sql( diff --git a/openapi/server-api.json b/openapi/server-api.json index 45b5d4eee..69c718440 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -6562,6 +6562,19 @@ ], "type": "object" }, + "PasskeyAdditionOptionsRequest": { + "additionalProperties": false, + "description": "Adds a credential to the current authenticated actor; target identity is never public input.", + "properties": { + "browser_origin": { + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, "PasskeyLoginCompleteRequest": { "additionalProperties": false, "properties": { @@ -6578,6 +6591,7 @@ }, "PasskeyLoginOptionsRequest": { "additionalProperties": false, + "description": "Identified-account login. Missing handles are rejected, not resolved to an implicit user.\n\nThe nullable wire field permits an explicit authentication error for old callers; it does not\nadvertise a discoverable-login ceremony.", "properties": { "browser_origin": { "type": [ @@ -6624,6 +6638,7 @@ }, "PasskeyRegistrationOptionsRequest": { "additionalProperties": false, + "description": "Starts new-account registration only; an existing handle is never interpreted as key addition.", "properties": { "browser_origin": { "type": [ @@ -17582,7 +17597,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c" + "x-yoi-source-digest": "sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f" }, "openapi": "3.1.0", "paths": { @@ -17915,6 +17930,153 @@ } } }, + "/api/auth/passkeys/addition/complete": { + "post": { + "operationId": "auth_passkey_addition_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" + }, + "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/auth/passkeys/addition/options": { + "post": { + "operationId": "auth_passkey_addition_options", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyAdditionOptionsRequest" + } + } + }, + "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" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "502": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/auth/passkeys/login/complete": { "post": { "operationId": "auth_passkey_login_complete", diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index cdba03914..beadf526b 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -18,6 +18,15 @@ serde_json.workspace = true sha2.workspace = true tempfile.workspace = true +[dev-dependencies] +server-api.workspace = true +webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } + +[[test]] +name = "auth_passkeys" +path = "tests/auth_passkeys.rs" +required-features = ["e2e"] + [[test]] name = "panel" path = "tests/panel.rs" diff --git a/tests/e2e/src/worker_environment.rs b/tests/e2e/src/worker_environment.rs index 18098a829..ac75c97b9 100644 --- a/tests/e2e/src/worker_environment.rs +++ b/tests/e2e/src/worker_environment.rs @@ -305,7 +305,8 @@ impl WorkerE2eEnvironment { self.start_runtime() } - pub fn start_backend(&mut self) -> Result<()> { + /// 空のDBでBackendだけを起動し、Account/Workspaceは作成しない。 + pub fn start_empty_backend(&mut self) -> Result<()> { assert_absent_or_empty(&self.server_data_dir, "Server data directory")?; assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?; let server_binary = self.binaries.binary("yoi-server")?; @@ -328,7 +329,14 @@ impl WorkerE2eEnvironment { self.server = Some(server); self.server_url = Some(server_url.clone()); self.wait_for_server_health(&server_url)?; + self.write_metadata("empty-backend-running")?; + Ok(()) + } + pub fn start_backend(&mut self) -> Result<()> { + self.start_empty_backend()?; + let server_binary = self.binaries.binary("yoi-server")?; + let server_url = self.required_server_url()?.to_string(); let bootstrap = self.run_command( &server_binary, &[ diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs new file mode 100644 index 000000000..9afc11d6f --- /dev/null +++ b/tests/e2e/tests/auth_passkeys.rs @@ -0,0 +1,465 @@ +//! 隔離した実yoi-serverのHTTP APIで、新規登録→明示handle login→本人の鍵追加→ +//! owner-first 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が所有する。 +//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 + +use std::error::Error; +use std::fs::OpenOptions; +use std::io::Write; +use std::path::Path; +use std::time::Duration; + +use reqwest::StatusCode; +use reqwest::blocking::{Client, RequestBuilder, Response}; +use reqwest::header::{COOKIE, ORIGIN, SET_COOKIE}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use server_api::{ + AuthUserResponse, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, + PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse, +}; +use webauthn_authenticator_rs::{WebauthnAuthenticator, softpasskey::SoftPasskey}; +use yoi_e2e::WorkerE2eEnvironment; + +type Result = std::result::Result>; +const BROWSER_ORIGIN: &str = "http://localhost:5173"; + +// 通信と診断だけを補助する。起動/DB/process管理は共有harnessに任せる。 +fn send(request: RequestBuilder, operation: &str, artifacts: &Path) -> Result { + let response = request.send()?; + let mut log = OpenOptions::new() + .create(true) + .append(true) + .open(artifacts.join("auth-operations.jsonl"))?; + writeln!( + log, + "{}", + json!({"operation": operation, "status": response.status().as_u16()}) + )?; + Ok(response) +} + +fn success(response: Response) -> Result { + let status = response.status(); + if !status.is_success() { + return Err(format!( + "unexpected {status}: {}", + response.text()?.chars().take(2048).collect::() + ) + .into()); + } + Ok(response) +} + +fn decode(response: Response) -> Result { + Ok(success(response)?.json()?) +} + +fn rejected(response: Response, expected: StatusCode, diagnostic: &str) -> Result { + let status = response.status(); + let body = response.text()?; + assert!( + status == expected, + "expected rejection {expected}, got {status}: {}", + body.chars().take(2048).collect::() + ); + assert!( + body.contains(diagnostic), + "missing diagnostic {diagnostic:?}: {}", + body.chars().take(2048).collect::() + ); + Ok(()) +} + +fn session(response: Response) -> Result<(AuthUserResponse, String)> { + let response = success(response)?; + let header = response + .headers() + .get(SET_COOKIE) + .ok_or("missing session cookie")? + .to_str()?; + assert!(header.contains("HttpOnly")); + assert!(header.contains("SameSite=Lax")); + let cookie = header + .split(';') + .next() + .ok_or("missing cookie pair")? + .to_owned(); + Ok((response.json()?, cookie)) +} + +fn register( + client: &Client, + base: &str, + artifacts: &Path, + handle: &str, + authenticator: &mut WebauthnAuthenticator, +) -> Result<(AuthUserResponse, String, Value)> { + let options: PasskeyRegistrationOptionsResponse = decode(send( + client.post(format!("{base}/api/auth/passkeys/registration/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": handle, "display_name": "Isolated passkey E2E", "browser_origin": BROWSER_ORIGIN})), + "new account registration options", artifacts, + )?)?; + let user_handle = serde_json::to_value(&options.public_key)?["publicKey"]["user"]["id"].clone(); + assert!(user_handle.is_string(), "WebAuthn user handle is absent"); + let credential = authenticator.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?; + let (user, cookie) = session(send( + client + .post(format!("{base}/api/auth/passkeys/registration/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&PasskeyRegistrationCompleteRequest { + challenge_id: options.challenge_id, + credential, + }), + "new account registration complete", + artifacts, + )?)?; + assert_eq!(user.user.handle, handle); + Ok((user, cookie, user_handle)) +} + +fn login( + client: &Client, + base: &str, + artifacts: &Path, + handle: &str, + authenticator: &mut WebauthnAuthenticator, +) -> Result<(AuthUserResponse, String)> { + let options: PasskeyLoginOptionsResponse = decode(send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": handle, "browser_origin": BROWSER_ORIGIN})), + "explicit handle login options", + artifacts, + )?)?; + let credential = + authenticator.do_authentication(BROWSER_ORIGIN.parse()?, options.public_key)?; + session(send( + client + .post(format!("{base}/api/auth/passkeys/login/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&PasskeyLoginCompleteRequest { + challenge_id: options.challenge_id, + credential, + }), + "verified login complete", + artifacts, + )?) +} + +#[test] +fn fresh_account_adds_own_passkey_and_creates_owned_workspace() -> Result { + let mut environment = WorkerE2eEnvironment::new()?; + eprintln!( + "passkey E2E artifacts: {}", + environment.artifacts_dir.display() + ); + assert!(!environment.server_data_dir.exists()); + environment.start_empty_backend()?; + assert!(environment.workspace_id.is_none()); + assert!(environment.runtime_pid().is_none()); + let base = environment + .server_url + .as_deref() + .ok_or("missing server URL")?; + let artifacts = &environment.artifacts_dir; + let client = Client::builder() + .no_proxy() + .redirect(reqwest::redirect::Policy::none()) + .connect_timeout(Duration::from_secs(1)) + .timeout(Duration::from_secs(5)) + .build()?; + let whoami: Value = decode(send( + client.get(format!("{base}/api/auth/whoami")), + "fresh whoami", + artifacts, + )?)?; + assert!(whoami["actor"].is_null()); + + let workspace_request = json!({ + "operation_key": format!("passkey-e2e-{}", environment.runtime_id), + "display_name": "Passkey owner-first E2E", + "repository": {"repository_key": "fixture", "uri": "https://example.invalid/passkey-e2e.git", "default_ref": "develop"} + }); + rejected( + send( + client + .post(format!("{base}/api/workspaces")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&workspace_request), + "unauthenticated loopback cannot bootstrap Workspace", + artifacts, + )?, + StatusCode::FORBIDDEN, + "authenticated owner", + )?; + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "unauthenticated addition options", + artifacts, + )?, + StatusCode::FORBIDDEN, + "auth_required", + )?; + + let mut original_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (registered, _, user_handle) = register( + &client, + base, + artifacts, + &environment.account_handle, + &mut original_key, + )?; + // registrationのcookieは使わず、実assertionで得たlogin cookieを次の変更操作に使う。 + let (logged_in, cookie) = login( + &client, + base, + artifacts, + &environment.account_handle, + &mut original_key, + )?; + assert_eq!(logged_in.user, registered.user); + let whoami: Value = decode(send( + client + .get(format!("{base}/api/auth/whoami")) + .header(COOKIE, &cookie), + "logged-in whoami", + artifacts, + )?)?; + assert_eq!(whoami["actor"]["account_id"], registered.user.account_id); + let workspaces: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &cookie), + "zero Workspaces after registration and login", + artifacts, + )?)?; + assert_eq!( + workspaces, + json!([]), + "startup/registration must not create a Workspace" + ); + rejected(send( + client.post(format!("{base}/api/auth/passkeys/registration/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"handle": environment.account_handle, "display_name": "Attempted replacement", "browser_origin": BROWSER_ORIGIN})), + "existing handle cannot use unauthenticated registration", artifacts, + )?, StatusCode::BAD_REQUEST, "auth_handle_exists")?; + + // 2 usersを実登録し、handle省略が最古userへfallbackしないことを検証する。 + let mut other_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let other_handle = format!("{}-other", environment.account_handle); + let (other, _, other_user_handle) = + register(&client, base, artifacts, &other_handle, &mut other_key)?; + assert_ne!(other.user.account_id, registered.user.account_id); + assert_ne!(other_user_handle, user_handle); + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/login/options")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&json!({"browser_origin": BROWSER_ORIGIN})), + "omitted handle cannot select first account", + artifacts, + )?, + StatusCode::BAD_REQUEST, + "passkey_handle_required", + )?; + + for (field, value) in [ + ("handle", &other.user.handle), + ("user_id", &other.user.user_id), + ("account_id", &other.user.account_id), + ] { + let mut body = json!({"browser_origin": BROWSER_ORIGIN}); + body[field] = json!(value); + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/options")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&body), + &format!("reject addition target injection: {field}"), + artifacts, + )?, + StatusCode::UNPROCESSABLE_ENTITY, + "unknown field", + )?; + } + for origin in [None, Some("http://localhost:5174")] { + let mut request = client + .post(format!("{base}/api/auth/passkeys/addition/options")) + .header(COOKIE, &cookie) + .json(&json!({"browser_origin": BROWSER_ORIGIN})); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + rejected( + send( + request, + "cookie addition requires actual matching Origin header", + artifacts, + )?, + StatusCode::FORBIDDEN, + "origin", + )?; + } + + let 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})), + "authenticated self addition options", + artifacts, + )?)?; + assert_eq!( + serde_json::to_value(&options.public_key)?["publicKey"]["user"]["id"], + user_handle, + "same account must retain its WebAuthn user handle" + ); + let mut added_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let credential = added_key.do_registration(BROWSER_ORIGIN.parse()?, options.public_key)?; + let complete = PasskeyRegistrationCompleteRequest { + challenge_id: options.challenge_id, + credential, + }; + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/complete")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&complete), + "unauthenticated addition complete", + artifacts, + )?, + StatusCode::FORBIDDEN, + "auth_required", + )?; + for origin in [None, Some("http://localhost:5174")] { + let mut request = client + .post(format!("{base}/api/auth/passkeys/addition/complete")) + .header(COOKIE, &cookie) + .json(&complete); + if let Some(origin) = origin { + request = request.header(ORIGIN, origin); + } + rejected( + send( + request, + "cookie addition complete requires matching Origin", + artifacts, + )?, + StatusCode::FORBIDDEN, + "origin", + )?; + } + for (field, value) in [ + ("handle", &other.user.handle), + ("user_id", &other.user.user_id), + ("account_id", &other.user.account_id), + ] { + let mut body = serde_json::to_value(&complete)?; + body[field] = json!(value); + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/complete")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&body), + &format!("reject addition complete target injection: {field}"), + artifacts, + )?, + StatusCode::UNPROCESSABLE_ENTITY, + "unknown field", + )?; + } + let (added, _) = session(send( + client + .post(format!("{base}/api/auth/passkeys/addition/complete")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&complete), + "authenticated self addition complete", + artifacts, + )?)?; + assert_eq!(added.user, registered.user); + rejected( + send( + client + .post(format!("{base}/api/auth/passkeys/addition/complete")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&complete), + "addition challenge cannot be replayed", + artifacts, + )?, + StatusCode::BAD_REQUEST, + "invalid_passkey_challenge", + )?; + + let (new_login, new_cookie) = login( + &client, + base, + artifacts, + &environment.account_handle, + &mut added_key, + )?; + assert_eq!(new_login.user, registered.user); + let (old_login, _) = login( + &client, + base, + artifacts, + &environment.account_handle, + &mut original_key, + )?; + assert_eq!( + old_login.user, registered.user, + "adding a key must preserve the original key" + ); + let created: Value = decode(send( + client + .post(format!("{base}/api/workspaces")) + .header(COOKIE, &new_cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&workspace_request), + "first Workspace from verified new-key login", + artifacts, + )?)?; + assert_eq!( + created["workspace"]["owner_account_id"], + registered.user.account_id + ); + let workspace_id = created["workspace"]["workspace_id"] + .as_str() + .ok_or("missing Workspace ID")?; + let listed: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &new_cookie), + "reread owned Workspace", + artifacts, + )?)?; + let items = listed + .as_array() + .ok_or("Workspace catalog is not an array")?; + assert_eq!(items.len(), 1); + assert_eq!(items[0]["workspace_id"], workspace_id); + assert_eq!(items[0]["owner_account_id"], registered.user.account_id); + + let report = environment.cleanup()?; + assert!(report.cleanup_success); + assert!(!report.fixture_root_exists_after); + Ok(()) +} diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 2233ccf1f..7b23a373b 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -15,6 +15,8 @@ export type AuthBootstrapUserRequest = { handle: string, display_name?: string | export type AuthUserResponse = { user: AuthenticatedUser, }; +export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, }; + export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, }; export type PasskeyRegistrationOptionsResponse = { challenge_id: string, public_key: unknown, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 7e0cb675f..b49da4c59 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // 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 6e4a3d456..aa8ed406d 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // 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 78bd99008..a6dab27d8 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // 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 de4aa0581..8e494cfe1 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // 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/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 535876288..903fda1a6 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_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 dd19cacfb..896b7b35b 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:230dd390bd140496b25b27e4ddf96cf07af1c48721654d0bcda8e5dc6733622c) +// Input contract: 0.1.0 (sha256:c13260867096583ac3c6822d2c6f47db51d8a53122768bf5a3bf3def67dd085f) // 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/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts new file mode 100644 index 000000000..383139d50 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -0,0 +1,50 @@ +// HTTP caller試験は本番adapterとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。 +// 認証・署名の検証はBackend suiteが所有し、このfixtureでは保証しない。 +export const authUser = { + user_id: "user-alice", + account_id: "account-alice", + handle: "alice", + display_name: "Alice", +}; +export const registrationOptions = { + challenge_id: "create-challenge", + public_key: { + publicKey: { + challenge: "AQID", + rp: { id: "localhost", name: "Yoi" }, + user: { id: "BAUG", name: "alice", displayName: "Alice" }, + pubKeyCredParams: [{ type: "public-key", alg: -7 }], + }, + }, +}; +export const loginOptions = { + challenge_id: "login-challenge", + public_key: { + publicKey: { + challenge: "AQID", + allowCredentials: [{ type: "public-key", id: "BwgJ" }], + }, + }, +}; +export function credentialFixture(authentication = false): Credential { + const bytes = new Uint8Array([1, 2, 3]).buffer; + return { + id: "AQID", + rawId: bytes, + type: "public-key", + authenticatorAttachment: "platform", + getClientExtensionResults: () => ({}), + response: authentication + ? { + clientDataJSON: bytes, + authenticatorData: bytes, + signature: bytes, + userHandle: null, + } + : { + clientDataJSON: bytes, + attestationObject: bytes, + getTransports: () => ["internal"], + }, + } as unknown as Credential; +} diff --git a/web/workspace/src/lib/workspace/auth/api.ts b/web/workspace/src/lib/workspace/auth/api.ts index 1d67b3622..8f09b944a 100644 --- a/web/workspace/src/lib/workspace/auth/api.ts +++ b/web/workspace/src/lib/workspace/auth/api.ts @@ -1,5 +1,6 @@ import type { DeviceLoginApproveRequest, + PasskeyAdditionOptionsRequest, PasskeyLoginCompleteRequest, PasskeyLoginOptionsRequest, PasskeyRegistrationCompleteRequest, @@ -114,7 +115,23 @@ export async function registerPasskey( display_name: displayName ?? null, browser_origin: window.location.origin, }; - const options = await requestJson("/api/auth/passkeys/registration/options", { + return await createPasskey("registration", optionsRequest); +} + +export async function addPasskey(): Promise { + const optionsRequest: PasskeyAdditionOptionsRequest = { + browser_origin: window.location.origin, + }; + return await createPasskey("addition", optionsRequest); +} + +async function createPasskey( + purpose: "registration" | "addition", + optionsRequest: + | PasskeyRegistrationOptionsRequest + | PasskeyAdditionOptionsRequest, +): Promise { + const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, { method: "POST", body: JSON.stringify(optionsRequest), }); @@ -137,7 +154,7 @@ export async function registerPasskey( credential: registrationCredentialToJson(credential), }; const result = parseAuthUserResponse( - await requestJson("/api/auth/passkeys/registration/complete", { + await requestJson(`/api/auth/passkeys/${purpose}/complete`, { method: "POST", body: JSON.stringify(completeRequest), }), @@ -145,9 +162,13 @@ export async function registerPasskey( return result.user; } -export async function loginWithPasskey(handle?: string): Promise { +export async function loginWithPasskey(handle: string): Promise { + const loginHandle = handle.trim(); + if (!loginHandle) { + throw new Error("User handle is required for passkey login."); + } const optionsRequest: PasskeyLoginOptionsRequest = { - handle: handle ?? null, + handle: loginHandle, browser_origin: window.location.origin, }; const options = await requestJson("/api/auth/passkeys/login/options", { diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 756b10be9..2acabf79b 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -1284,9 +1284,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho const devicePage = await Deno.readTextFile( new URL("./../../../routes/login/device/+page.svelte", import.meta.url), ); - const authApi = await Deno.readTextFile( - new URL("../auth/api.ts", import.meta.url), - ); const rootLayout = await Deno.readTextFile( new URL("./../../../routes/+layout.svelte", import.meta.url), ); @@ -1339,14 +1336,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho devicePage.includes("Approve device login"), "Device login page should approve CLI login without DevTools console", ); - assert( - authApi.includes("/api/auth/whoami") && - authApi.includes("/api/auth/passkeys/registration/options") && - authApi.includes("/api/auth/passkeys/login/complete") && - authApi.includes("/api/auth/logout") && - authApi.includes("/api/auth/device-login/approve"), - "Auth model should stay on Backend auth APIs rather than workspace authorization APIs", - ); assert( rootLayout.includes("SIDEBAR_CONTEXT") && rootLayout.includes("GlobalSidebar") && diff --git a/web/workspace/src/routes/account/+page.svelte b/web/workspace/src/routes/account/+page.svelte index f43a2944b..edc73057d 100644 --- a/web/workspace/src/routes/account/+page.svelte +++ b/web/workspace/src/routes/account/+page.svelte @@ -1,6 +1,7 @@