diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 3b448d409..cf8da3c28 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -225,6 +225,10 @@ impl_openapi_schema!( PasskeyLoginOptionsResponse, PasskeyLoginCompleteRequest, PasskeyAdditionOptionsRequest, + PasskeySummary, + PasskeyListResponse, + PasskeyAdditionResultRequest, + PasskeyAdditionResultResponse, PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsResponse, PasskeyRegistrationCompleteRequest, @@ -648,6 +652,33 @@ pub trait ServerApi { #[body] request: PasskeyRegistrationCompleteRequest, ) -> Result; + #[get( + "/api/auth/passkeys", + status = 200, + error_status = 400, + additional_error_statuses = [403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn auth_passkey_list( + &self, + #[extension] context: ServerRequestContext, + ) -> Result; + + #[post( + "/api/auth/passkeys/addition/result", + status = 200, + error_status = 400, + additional_error_statuses = [403, 500], + bearer_auth = true, + browser_auth = true + )] + async fn auth_passkey_addition_result( + &self, + #[extension] context: ServerRequestContext, + #[body] request: PasskeyAdditionResultRequest, + ) -> Result; + #[post( "/api/auth/passkeys/addition/options", status = 200, @@ -4115,6 +4146,8 @@ impl RequestActor { pub struct WhoamiResponse { pub actor: Option, pub session: BrowserSessionState, + /// Non-secret comparison evidence, never an authentication credential. + pub authentication_reference: Option, } /// Browser navigation evidence, not an authorization grant or a generic 401 classification. @@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest { #[serde(deny_unknown_fields)] pub struct AuthUserResponse { pub user: AuthenticatedUser, + /// Comparison evidence for the exact newly issued session, not a credential. + pub authentication_reference: String, } /// Starts new-account registration only; an existing handle is never interpreted as key addition. @@ -4158,16 +4193,64 @@ pub struct PasskeyRegistrationOptionsRequest { pub browser_origin: Option, } -/// Adds a credential to the current authenticated actor; target identity is never public input. +/// Adds to the current actor. Expected identity/binding fields only fence stale UI; +/// they never select an owner or authorize an addition. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct PasskeyAdditionOptionsRequest { + pub expected_user_id: String, + pub expected_account_id: String, + pub expected_authentication_reference: String, #[serde(default, skip_serializing_if = "Option::is_none")] #[cfg_attr(feature = "typescript", ts(optional = nullable))] pub browser_origin: Option, } +/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed. +/// Normal-login verification is observed verifier success, not inferred discoverability. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeySummary { + pub key_reference: String, + pub created_at: String, + pub last_used_at: Option, + pub normal_login_verified_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyListResponse { + pub user: AuthenticatedUser, + pub keys: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct PasskeyAdditionResultRequest { + pub challenge_id: String, + pub expected_user_id: String, + pub expected_account_id: String, + /// Original options authentication reference; the current same-owner session may differ. + pub expected_authentication_reference: String, +} + +/// Unknown is not proof that registration failed: completion may still be in flight. +/// Saved proves the credential commit, not session issuance or current session health. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum PasskeyAdditionResultResponse { + Unknown {}, + Saved { + user: AuthenticatedUser, + key: PasskeySummary, + }, +} + #[derive(Debug, Serialize, Deserialize, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String { AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), PasskeyAdditionOptionsRequest::decl(&config), + PasskeySummary::decl(&config), + PasskeyListResponse::decl(&config), + PasskeyAdditionResultRequest::decl(&config), + PasskeyAdditionResultResponse::decl(&config), PasskeyRegistrationOptionsRequest::decl(&config), PasskeyRegistrationOptionsResponse::decl(&config), PasskeyRegistrationCompleteRequest::decl(&config), @@ -14069,7 +14156,8 @@ mod tests { "display_name": "Hare", "auth_method": "browser_session" }, - "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } + "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }, + "authentication_reference": "synthetic-session-reference" }); let decoded = serde_json::from_value::(whoami.clone()) .expect("server whoami fixture should match shared DTO"); @@ -14091,7 +14179,8 @@ mod tests { "account_id": "account-1", "handle": "hare", "display_name": "Hare" - } + }, + "authentication_reference": "synthetic-session-reference" }); let decoded = serde_json::from_value::(auth_user.clone()) .expect("server auth-user fixture should match shared DTO"); @@ -14264,7 +14353,7 @@ mod tests { serde_json::json!({"kind":"expired"}), serde_json::json!({"kind":"authenticated", "expires_at":null}), ] { - let value = serde_json::json!({"actor":null, "session":session}); + let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null}); let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); assert_eq!(serde_json::to_value(response).unwrap(), value); } @@ -14802,10 +14891,28 @@ mod openapi_artifact_tests { ); } assert!( - serde_json::from_value::(serde_json::json!({})).is_ok() + serde_json::from_value::(serde_json::json!({})).is_err() ); } + #[test] + fn passkey_addition_requires_expected_owner_and_binding() { + let expected = serde_json::json!({ + "expected_user_id": "user-a", "expected_account_id": "account-a", + "expected_authentication_reference": "reference" + }); + assert!(serde_json::from_value::(expected.clone()).is_ok()); + for field in [ + "expected_user_id", + "expected_account_id", + "expected_authentication_reference", + ] { + let mut missing = expected.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!(serde_json::from_value::(missing).is_err()); + } + } + #[test] fn auth_workspace_catalog_and_typed_header_contract_is_strict() { let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 2e9085438..68af0424b 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip (99, "Explicit hosting administrators and self-registration policy".into()), (100, "Provisional users and scoped passkey activation".into()), (101, "Durable explicit Subject body release".into()), - (102, "Durable failed Subject creation recovery".into()) + (102, "Durable failed Subject creation recovery".into()), + (103, "Passkey metadata and atomic addition results".into()) ] ); for (table, rows) in retained.iter().zip(original.iter()) { diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index c46c54926..be0198975 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -228,13 +228,21 @@ fn resolve_whoami_sync( use server_api::{BrowserSessionState, WhoamiResponse}; let anonymous = || WhoamiResponse { actor: None, + authentication_reference: None, session: BrowserSessionState::Anonymous {}, }; // Preserve the existing API-token precedence and fallback rules, including expired tokens. if let Some((actor, expires_at)) = - resolve_credentials_sync(store, bearer, session_token.clone())? + resolve_credentials_sync(store, bearer.clone(), session_token.clone())? { + let credential = match actor.auth_method { + ActorAuthMethod::ApiToken => bearer.as_deref(), + ActorAuthMethod::BrowserSession => session_token.as_deref(), + } + .ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?; + let authentication_reference = Some(authentication_reference(&token_hash(credential))); return Ok(WhoamiResponse { + authentication_reference, actor: Some(actor), session: BrowserSessionState::Authenticated { expires_at }, }); @@ -255,6 +263,7 @@ fn resolve_whoami_sync( if within_session_expiry_grace(&session.expires_at, Utc::now()) { return Ok(WhoamiResponse { actor: None, + authentication_reference: None, session: BrowserSessionState::Expired {}, }); } @@ -262,6 +271,13 @@ fn resolve_whoami_sync( Ok(anonymous()) } +/// Domain-separated public comparison value; never accepted as a credential. +pub(crate) fn authentication_reference(credential_hash: &str) -> String { + token_hash(&format!( + "passkey-authentication-reference:{credential_hash}" + )) +} + fn within_session_expiry_grace(expires_at: &str, now: DateTime) -> bool { DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) diff --git a/crates/workspace-server/src/implementation_result_tests.rs b/crates/workspace-server/src/implementation_result_tests.rs index c5a7cd7d2..a669573e0 100644 --- a/crates/workspace-server/src/implementation_result_tests.rs +++ b/crates/workspace-server/src/implementation_result_tests.rs @@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TRIGGER passkey_metadata_insert; + DROP TABLE passkey_addition_receipts; + DROP TABLE passkey_metadata; + UPDATE __yoi_schema_migrations SET version=102 WHERE version=103; DROP TABLE subject_create_recoveries; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; DROP TABLE subject_worker_releases; @@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow // Reconstruct the prior composition before its new business capture existed. conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; + DROP TRIGGER passkey_metadata_insert; + DROP TABLE passkey_addition_receipts; + DROP TABLE passkey_metadata; + UPDATE __yoi_schema_migrations SET version=102 WHERE version=103; DROP TABLE subject_create_recoveries; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; DROP TABLE subject_worker_releases; @@ -679,7 +687,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() { if historical { conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture; DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations; - DROP TABLE subject_create_recoveries; + DROP TRIGGER passkey_metadata_insert; + DROP TABLE passkey_addition_receipts; + DROP TABLE passkey_metadata; + UPDATE __yoi_schema_migrations SET version=102 WHERE version=103; + DROP TABLE subject_create_recoveries; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; DROP TABLE subject_worker_releases; UPDATE __yoi_schema_migrations SET version=100 WHERE version=101; diff --git a/crates/workspace-server/src/passkey_results_schema.sql b/crates/workspace-server/src/passkey_results_schema.sql new file mode 100644 index 000000000..6a670a615 --- /dev/null +++ b/crates/workspace-server/src/passkey_results_schema.sql @@ -0,0 +1,18 @@ +CREATE TABLE passkey_metadata ( + credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE, + key_reference TEXT NOT NULL UNIQUE, + normal_login_verified_at TEXT +); +INSERT INTO passkey_metadata(credential_id, key_reference) + SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials; +CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials +BEGIN + INSERT INTO passkey_metadata(credential_id, key_reference) + VALUES (NEW.credential_id, lower(hex(randomblob(16)))); +END; +CREATE TABLE passkey_addition_receipts ( + challenge_id TEXT PRIMARY KEY, + credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE, + user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, + authentication_reference TEXT NOT NULL +); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index c006bb28c..8ce931a21 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::hosting_settings_update( service.clone(), )) + .merge(server_api::server_api_axum::auth_passkey_list( + service.clone(), + )) + .merge(server_api::server_api_axum::auth_passkey_addition_result( + service.clone(), + )) .merge(server_api::server_api_axum::auth_passkey_addition_options( service.clone(), )) @@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService { ) } + async fn auth_passkey_list( + &self, + context: server_api::ServerRequestContext, + ) -> std::result::Result { + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + api.store + .db_executor() + .run(move || { + let (user, _) = passkey_owner_authority(&api, &headers, false)?; + Ok::<_, ApiError>(server_api::PasskeyListResponse { + keys: api.store.list_passkey_summaries(&user.user_id)?, + user: user_response(user), + }) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn auth_passkey_addition_result( + &self, + context: server_api::ServerRequestContext, + request: server_api::PasskeyAdditionResultRequest, + ) -> std::result::Result< + server_api::PasskeyAdditionResultResponse, + server_api::RepositoryApiError, + > { + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + api.store + .db_executor() + .run(move || { + let (user, _) = passkey_addition_authority(&api, &headers)?; + if user.user_id != request.expected_user_id + || user.account_id != request.expected_account_id + { + return Err(auth_error( + "passkey_authentication_changed", + "current authenticated owner does not match the operation owner", + ) + .into()); + } + let key = api.store.read_passkey_addition_result( + &user.user_id, + &request.expected_authentication_reference, + &request.challenge_id, + )?; + Ok::<_, ApiError>(match key { + Some(key) => server_api::PasskeyAdditionResultResponse::Saved { + user: user_response(user), + key, + }, + None => server_api::PasskeyAdditionResultResponse::Unknown {}, + }) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + async fn auth_passkey_addition_options( &self, context: server_api::ServerRequestContext, @@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService { .db_executor() .run(move || { let (user, authentication) = passkey_addition_authority(&api, &headers)?; + require_expected_passkey_owner( + &user, + &authentication, + &request.expected_user_id, + &request.expected_account_id, + &request.expected_authentication_reference, + )?; start_passkey_registration( &api, &headers, @@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options( fn passkey_addition_authority( api: &ServerAuthApi, headers: &HeaderMap, +) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> { + passkey_owner_authority(api, headers, true) +} + +fn passkey_owner_authority( + api: &ServerAuthApi, + headers: &HeaderMap, + require_origin: bool, ) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> { let auth = auth_public_config(&api.config); let actor = crate::auth::resolve_request_actor_sync( @@ -30176,7 +30256,7 @@ fn passkey_addition_authority( })?; let binding = match actor.auth_method { ActorAuthMethod::BrowserSession => { - if request_origin(headers).as_deref() != Some(auth.origin.as_str()) { + if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) { return Err(Error::WorkspacePermissionDenied( "cookie-authenticated passkey addition requires the configured Browser origin" .into(), @@ -30208,6 +30288,26 @@ fn passkey_addition_authority( Ok((user, binding)) } +fn require_expected_passkey_owner( + user: &UserRecord, + binding: &PasskeyAuthenticationBinding, + expected_user_id: &str, + expected_account_id: &str, + expected_authentication_reference: &str, +) -> ApiResult<()> { + if user.user_id != expected_user_id + || user.account_id != expected_account_id + || binding.reference() != expected_authentication_reference + { + return Err(auth_error( + "passkey_authentication_changed", + "authentication changed; refresh account before starting another operation", + ) + .into()); + } + Ok(()) +} + fn start_passkey_registration( api: &ServerAuthApi, headers: &HeaderMap, @@ -30289,7 +30389,11 @@ fn start_passkey_registration( /// 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 +/// Own additions save a receipt atomically with the credential: a currently +/// authenticated same owner can inspect it using the original operation binding +/// reference even if the response body was lost after Set-Cookie took effect. +/// This read does not authorize a completion or prove current session health. +/// For unknown new-account outcomes, 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. @@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment( request: PasskeyRegistrationCompleteRequest, completion: EnrollmentCompletion<'_>, ) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { + let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_)); let authority = match completion { EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?), _ => None, @@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment( &error.to_string(), ) })?; - api.store.register_verified_passkey( + api.store.register_verified_passkey_with_result( &envelope.target, &PasskeyCredentialRecord { credential_id: passkey_credential_id(&passkey)?, @@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment( created_at: crate::auth::now_rfc3339(), last_used_at: None, }, + is_addition.then_some(request.challenge_id.as_str()), )?; issue_browser_session(api, user) } @@ -30580,6 +30686,7 @@ fn complete_passkey_login( .into()); } let webauthn = webauthn_for_challenge(&api.config, &challenge)?; + let normal_login = matches!(state, PasskeyLoginState::Discoverable(_)); let auth_result = match state { PasskeyLoginState::Discoverable(state) => { // userHandle is unsigned routing data, never identity authority. The @@ -30635,7 +30742,7 @@ fn complete_passkey_login( .map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?; api.store .upsert_passkey_credential(&PasskeyCredentialRecord { - credential_id, + credential_id: credential_id.clone(), user_id: user.user_id.clone(), public_key_cose, transports_json: stored.transports_json, @@ -30643,6 +30750,10 @@ fn complete_passkey_login( created_at: stored.created_at, last_used_at: Some(crate::auth::now_rfc3339()), })?; + if normal_login { + api.store + .mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?; + } issue_browser_session(api, user) } @@ -30668,6 +30779,9 @@ fn issue_browser_session( Ok(( AuthUserResponse { user: user_response(user), + authentication_reference: crate::auth::authentication_reference(&token_hash( + &session_token, + )), }, server_api::SetCookieHeader::new(cookie), )) @@ -52401,6 +52515,7 @@ mod tests { include!("server_activation_tests.rs"); include!("server_discoverable_tests.rs"); + include!("server_passkey_results_tests.rs"); const ORIGIN_URL: &str = "https://workspace.test"; type Authenticator = ResidentAuthenticator; @@ -52454,9 +52569,61 @@ mod tests { origin: Option<&str>, body: Value, ) -> (StatusCode, HeaderMap, Value) { + let mut body = body; + if path == "/api/auth/passkeys/addition/options" + && body.get("expected_user_id").is_none() + { + let expected = self.expected_owner(cookie, bearer).await; + for (field, value) in expected.as_object().unwrap() { + body[field] = value.clone(); + } + } Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await } + async fn get_authenticated( + &self, + path: &str, + cookie: Option<&str>, + bearer: Option<&str>, + ) -> (StatusCode, Value) { + let mut request = Request::builder().uri(path); + if let Some(cookie) = cookie { + request = request.header(axum::http::header::COOKIE, cookie); + } + if let Some(bearer) = bearer { + request = request.header( + axum::http::header::AUTHORIZATION, + format!("Bearer {bearer}"), + ); + } + let response = self + .app + .clone() + .oneshot(request.body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + let status = response.status(); + let body = serde_json::from_slice( + &to_bytes(response.into_body(), usize::MAX).await.unwrap(), + ) + .unwrap(); + (status, body) + } + + async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value { + let (status, whoami) = self + .get_authenticated("/api/auth/whoami", cookie, bearer) + .await; + assert_eq!(status, StatusCode::OK); + serde_json::json!({ + "expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"), + "expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"), + "expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"), + }) + } + async fn post_to( app: Router, path: &str, @@ -52612,6 +52779,14 @@ mod tests { .next() .unwrap() .to_string(); + let (status, whoami) = self + .get_authenticated("/api/auth/whoami", Some(&cookie), None) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + body["authentication_reference"] == whoami["authentication_reference"], + "completion must identify its exact issued session" + ); ( cookie, serde_json::from_value::(body) @@ -52699,6 +52874,14 @@ mod tests { .next() .unwrap() .to_string(); + let (status, whoami) = self + .get_authenticated("/api/auth/whoami", Some(&cookie), None) + .await; + assert_eq!(status, StatusCode::OK); + assert!( + body["authentication_reference"] == whoami["authentication_reference"], + "login completion must identify its exact issued session" + ); ( cookie, serde_json::from_value::(body) @@ -53222,6 +53405,9 @@ mod tests { .auth_passkey_addition_options( context, server_api::PasskeyAdditionOptionsRequest { + expected_user_id: user.user_id.clone(), + expected_account_id: user.account_id.clone(), + expected_authentication_reference: "not-authority".into(), browser_origin: Some(ORIGIN_URL.into()), }, ) @@ -53236,6 +53422,9 @@ mod tests { .auth_passkey_addition_options( context, server_api::PasskeyAdditionOptionsRequest { + expected_user_id: user.user_id.clone(), + expected_account_id: user.account_id.clone(), + expected_authentication_reference: "not-authority".into(), browser_origin: Some(ORIGIN_URL.into()), }, ) @@ -54241,7 +54430,7 @@ mod tests { .header(axum::http::header::COOKIE, &marker) .header(ORIGIN, &auth.origin) .header(CONTENT_TYPE, "application/json") - .body(Body::from("{}")) + .body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#)) .unwrap(), ) .await diff --git a/crates/workspace-server/src/server_passkey_results_tests.rs b/crates/workspace-server/src/server_passkey_results_tests.rs new file mode 100644 index 000000000..9562e5af2 --- /dev/null +++ b/crates/workspace-server/src/server_passkey_results_tests.rs @@ -0,0 +1,399 @@ +// Integration boundary: generated HTTP admission -> real WebAuthn verifier -> +// SQLite credential/receipt transaction -> real session issuance. Only the +// authenticator is synthetic; failures are SQLite triggers, not success fakes. + +impl Fixture { + async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) { + let mut request = self.expected_owner(Some(cookie), None).await; + request["challenge_id"] = challenge_id.into(); + let (status, _, body) = self + .post( + "/api/auth/passkeys/addition/result", + Some(cookie), + Some(ORIGIN_URL), + request, + ) + .await; + (status, body) + } +} + +#[tokio::test] +async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() { + for failure in [ + "before_credential", + "receipt_insert", + "session_insert", + "lost_response", + "lost_body_accepted_cookie", + ] { + let f = Fixture::new().await; + let mut original = authenticator(); + let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await; + let original_expectation = f.expected_owner(Some(&cookie), None).await; + let options = f.options("addition", Some(&cookie), "").await; + let challenge_id = options.challenge_id.clone(); + let mut added = authenticator(); + let request = Fixture::credential(options, &mut added); + let (status, result) = f.addition_result(&cookie, &challenge_id).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["status"], "unknown"); + let table = match failure { + "before_credential" => Some("passkey_credentials"), + "receipt_insert" => Some("passkey_addition_receipts"), + "session_insert" => Some("browser_sessions"), + _ => None, + }; + if let Some(table) = table { + f.api.config_store.with_conn(|conn| { + conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?; + Ok(()) + }).unwrap(); + } + // Discard the completion body; one variant still accepts Set-Cookie. + let (status, headers, _) = f + .post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + request.clone(), + ) + .await; + assert_eq!( + status, + if matches!(failure, "lost_response" | "lost_body_accepted_cookie") { + StatusCode::OK + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + ); + if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") { + assert!(!headers.contains_key(axum::http::header::SET_COOKIE)); + } + let saved = matches!( + failure, + "session_insert" | "lost_response" | "lost_body_accepted_cookie" + ); + if failure == "lost_body_accepted_cookie" { + let accepted_cookie = headers[axum::http::header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap(); + let current = f.expected_owner(Some(accepted_cookie), None).await; + assert!( + current["expected_authentication_reference"] + != original_expectation["expected_authentication_reference"], + "addition issued a distinct session" + ); + let mut recovery = original_expectation.clone(); + recovery["challenge_id"] = challenge_id.clone().into(); + let (status, _, result) = f + .post( + "/api/auth/passkeys/addition/result", + Some(accepted_cookie), + Some(ORIGIN_URL), + recovery, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["status"], "saved"); + assert_eq!(result["user"]["user_id"], owner.user_id); + } + let (status, result) = f.addition_result(&cookie, &challenge_id).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["status"], if saved { "saved" } else { "unknown" }); + assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 }); + let receipt_count = f + .api + .config_store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM passkey_addition_receipts", + [], + |row| row.get::<_, u64>(0), + )?) + }) + .unwrap(); + assert_eq!(receipt_count, u64::from(saved)); + if saved { + assert_eq!(result["user"]["user_id"], owner.user_id); + let (_, listed) = f + .get_authenticated("/api/auth/passkeys", Some(&cookie), None) + .await; + let reference = result["key"]["key_reference"].as_str().unwrap(); + assert!( + listed["keys"] + .as_array() + .unwrap() + .iter() + .any(|key| key["key_reference"] == reference) + ); + assert!(result["key"]["normal_login_verified_at"].is_null()); + } + if table.is_some() { + f.api + .config_store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER inject_passkey_failure")?; + Ok(()) + }) + .unwrap(); + } + // Consumed completion is not retry authority, even after a transaction failure. + let (status, headers, _) = f + .post( + "/api/auth/passkeys/addition/complete", + Some(&cookie), + Some(ORIGIN_URL), + request, + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!(!headers.contains_key(axum::http::header::SET_COOKIE)); + assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 }); + if saved { + assert_eq!(f.discoverable_login(&mut added).await, owner); + } + } +} + +#[tokio::test] +async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() { + let f = Fixture::new().await; + let mut alice_key = authenticator(); + let (alice_cookie, alice) = f + .enroll("registration", None, "alice", &mut alice_key) + .await; + let (bob_cookie, _) = f + .enroll("registration", None, "bob", &mut authenticator()) + .await; + let expected = f.expected_owner(Some(&alice_cookie), None).await; + let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await; + let count_challenges = || { + f.api + .config_store + .with_conn(|conn| { + Ok( + conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| { + row.get::<_, u64>(0) + })?, + ) + }) + .unwrap() + }; + let before = count_challenges(); + for cookie in [&bob_cookie, &replacement_cookie] { + let (status, _, _) = f + .post( + "/api/auth/passkeys/addition/options", + Some(cookie), + Some(ORIGIN_URL), + expected.clone(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + count_challenges(), + before, + "stale options must not persist a ceremony" + ); + } + let options = f.options("addition", Some(&alice_cookie), "").await; + let challenge_id = options.challenge_id.clone(); + let request = Fixture::credential(options, &mut authenticator()); + assert_eq!( + f.post( + "/api/auth/passkeys/addition/complete", + Some(&alice_cookie), + Some(ORIGIN_URL), + request + ) + .await + .0, + StatusCode::OK + ); + assert_eq!( + f.addition_result(&alice_cookie, &challenge_id).await.1["status"], + "saved" + ); + // Current owner authorization is separate from original operation binding. + let mut recovery = expected.clone(); + recovery["challenge_id"] = challenge_id.clone().into(); + let (status, _, result) = f + .post( + "/api/auth/passkeys/addition/result", + Some(&replacement_cookie), + Some(ORIGIN_URL), + recovery.clone(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result["status"], "saved"); + assert_eq!( + f.post( + "/api/auth/passkeys/addition/result", + Some(&bob_cookie), + Some(ORIGIN_URL), + recovery.clone() + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert_eq!( + f.post( + "/api/auth/passkeys/addition/result", + None, + Some(ORIGIN_URL), + recovery.clone() + ) + .await + .0, + StatusCode::FORBIDDEN + ); + recovery["expected_authentication_reference"] = "changed-original-reference".into(); + assert_eq!( + f.post( + "/api/auth/passkeys/addition/result", + Some(&replacement_cookie), + Some(ORIGIN_URL), + recovery + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + // Bob's own expected fields cannot discover Alice's operation either. + let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(result, serde_json::json!({"status":"unknown"})); + assert_eq!(f.credentials(&alice).len(), 2); + assert_eq!( + f.post( + "/api/auth/logout", + Some(&alice_cookie), + Some(ORIGIN_URL), + serde_json::json!({}) + ) + .await + .0, + StatusCode::OK + ); + let mut request = expected; + request["challenge_id"] = challenge_id.into(); + assert_eq!( + f.post( + "/api/auth/passkeys/addition/result", + Some(&alice_cookie), + Some(ORIGIN_URL), + request + ) + .await + .0, + StatusCode::FORBIDDEN + ); +} + +#[tokio::test] +async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() { + let f = Fixture::new().await; + let mut key = authenticator(); + let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await; + let (other_cookie, _) = f + .enroll("registration", None, "other", &mut authenticator()) + .await; + assert_eq!( + f.get_authenticated("/api/auth/passkeys", None, None) + .await + .0, + StatusCode::FORBIDDEN + ); + let (status, list) = f + .get_authenticated("/api/auth/passkeys", Some(&cookie), None) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(list["user"]["user_id"], owner.user_id); + assert_eq!(list["keys"].as_array().unwrap().len(), 1); + let summary = &list["keys"][0]; + assert!(summary["normal_login_verified_at"].is_null()); + assert!(summary["last_used_at"].is_null()); + let reference = summary["key_reference"].as_str().unwrap(); + assert_eq!(reference.len(), 32); + assert_ne!(reference, f.credentials(&owner)[0].credential_id); + assert_eq!( + summary + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect::>(), + [ + "key_reference", + "created_at", + "last_used_at", + "normal_login_verified_at" + ] + .into_iter() + .collect() + ); + let (_, other) = f + .get_authenticated("/api/auth/passkeys", Some(&other_cookie), None) + .await; + assert_ne!(other["keys"][0]["key_reference"], reference); + assert_eq!(f.login("owner", &mut key).await, owner); + let (_, legacy) = f + .get_authenticated("/api/auth/passkeys", Some(&cookie), None) + .await; + assert!(legacy["keys"][0]["last_used_at"].is_string()); + assert!(legacy["keys"][0]["normal_login_verified_at"].is_null()); + // Rejected normal assertion does not produce confirmation metadata. + let mut invalid = f.discoverable_request(&mut key).await; + invalid.credential.response.signature = vec![0u8; 64].into(); + assert_eq!( + f.post( + "/api/auth/passkeys/login/complete", + None, + Some(ORIGIN_URL), + serde_json::to_value(invalid).unwrap() + ) + .await + .0, + StatusCode::BAD_REQUEST + ); + assert!( + f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0] + .normal_login_verified_at + .is_none() + ); + assert_eq!(f.discoverable_login(&mut key).await, owner); + let (_, confirmed) = f + .get_authenticated("/api/auth/passkeys", Some(&cookie), None) + .await; + assert_eq!(confirmed["keys"][0]["key_reference"], reference); + assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string()); +} + +#[tokio::test] +async fn unknown_transport_metadata_does_not_discard_verified_registration() { + let f = Fixture::new().await; + let options = f.options("registration", None, "future-transport").await; + let mut key = authenticator(); + let mut request = Fixture::credential(options, &mut key); + request["credential"]["response"]["transports"] = + serde_json::json!(["internal", "future-transport", "test"]); + let (status, _, body) = f + .post( + "/api/auth/passkeys/registration/complete", + None, + Some(ORIGIN_URL), + request, + ) + .await; + assert_eq!(status, StatusCode::OK); + let user = serde_json::from_value::(body) + .unwrap() + .user; + assert_eq!(f.discoverable_login(&mut key).await, user); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index f12c54334..adc833d00 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -119,7 +119,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 102; +const LATEST_SCHEMA_VERSION: i64 = 103; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Durable failed Subject creation recovery", apply: migrate_subject_create_recovery_v101_to_v102, }, + Migration { + version: 103, + name: "Passkey metadata and atomic addition results", + apply: migrate_passkey_results_v102_to_v103, + }, ]; #[derive(Clone, Copy)] @@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding { ApiToken(String), } +impl PasskeyAuthenticationBinding { + pub(crate) fn reference(&self) -> String { + let hash = match self { + Self::BrowserSession(hash) | Self::ApiToken(hash) => hash, + }; + crate::auth::authentication_reference(hash) + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct PasskeyCredentialRecord { pub credential_id: String, @@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { &self, target: &PasskeyRegistrationTarget, credential: &PasskeyCredentialRecord, + ) -> Result<()> { + self.register_verified_passkey_with_result(target, credential, None) + } + /// Receipt and credential commit together; no receipt claims session issuance. + fn register_verified_passkey_with_result( + &self, + target: &PasskeyRegistrationTarget, + credential: &PasskeyCredentialRecord, + addition_challenge_id: Option<&str>, + ) -> Result<()>; + fn list_passkey_summaries(&self, user_id: &str) -> Result>; + fn read_passkey_addition_result( + &self, + user_id: &str, + original_authentication_reference: &str, + challenge_id: &str, + ) -> Result>; + fn mark_passkey_normal_login_verified( + &self, + credential_id: &str, + verified_at: &str, ) -> Result<()>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; fn get_passkey_credential( @@ -6972,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } - fn register_verified_passkey( + fn register_verified_passkey_with_result( &self, target: &PasskeyRegistrationTarget, credential: &PasskeyCredentialRecord, + addition_challenge_id: Option<&str>, ) -> Result<()> { self.with_immediate_transaction(|tx| { let user = match target { @@ -7042,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at], )?; + if let Some(challenge_id) = addition_challenge_id { + let PasskeyRegistrationTarget::Addition { user, authentication } = target else { + return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition")); + }; + tx.execute( + "INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)", + params![challenge_id, credential.credential_id, user.user_id, authentication.reference()], + )?; + } if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target { if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 { return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists")); @@ -7051,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn list_passkey_summaries(&self, user_id: &str) -> Result> { + self.with_conn(|conn| { + let mut stmt = conn.prepare( + "SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at + FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id) + WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference", + )?; + let rows = stmt.query_map([user_id], read_passkey_summary)?; + rows.collect::, _>>() + .map_err(Error::from) + }) + } + + fn read_passkey_addition_result( + &self, + user_id: &str, + original_authentication_reference: &str, + challenge_id: &str, + ) -> Result> { + self.with_conn(|conn| { + // Current authenticated ownership authorizes reading. The original + // binding reference identifies the saved operation, not the current + // session: Set-Cookie may have arrived even if its body was lost. + let result = conn.query_row( + "SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference + FROM passkey_addition_receipts r + JOIN passkey_credentials c USING(credential_id) + JOIN passkey_metadata m USING(credential_id) + WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2", + params![challenge_id, user_id], + |row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)), + ).optional()?; + match result { + Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)), + Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")), + None => Ok(None), + } + }) + } + + fn mark_passkey_normal_login_verified( + &self, + credential_id: &str, + verified_at: &str, + ) -> Result<()> { + self.with_conn(|conn| { + conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?; + Ok(()) + }) + } + fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> { self.with_immediate_transaction(|conn| { activation::require_active(conn, &record.user_id)?; @@ -11911,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String { ) } +fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(server_api::PasskeySummary { + key_reference: row.get(0)?, + created_at: row.get(1)?, + last_used_at: row.get(2)?, + normal_login_verified_at: row.get(3)?, + }) +} + fn read_passkey_credential_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -15164,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 102 { + return Err(Error::Store( + "Passkey results migration requires schema 102".into(), + )); + } + let tx = conn.unchecked_transaction()?; + tx.execute_batch(include_str!("passkey_results_schema.sql"))?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)", + ["Passkey metadata and atomic addition results"], + )?; + tx.commit()?; + Ok(()) +} + fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 101 { return Err(Error::Store( @@ -15255,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; conn.execute_batch(include_str!("activation_schema.sql"))?; + conn.execute_batch(include_str!("passkey_results_schema.sql"))?; Ok(()) } @@ -18948,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()}, WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()}, WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()}, + WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()}, ] ); @@ -19060,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (100, "Provisional users and scoped passkey activation".to_string()), (101, "Durable explicit Subject body release".to_string()), (102, "Durable failed Subject creation recovery".to_string()), +(103, "Passkey metadata and atomic addition results".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -21288,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } + #[test] + fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() { + for baseline in [50, 102] { + let dir = tempfile::tempdir().unwrap(); + let database = dir.path().join("server.db"); + if baseline == 50 { + prepare_schema_v50(&database, None); + } else { + prepare_retained_schema(&database, baseline); + } + let conn = Connection::open(&database).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at) + VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated'); + INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at) + VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated'); + INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) + VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#) + .unwrap(); + if baseline == 102 { + conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations + WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap(); + assert!(migrate_passkey_results_v102_to_v103(&conn).is_err()); + assert_eq!(current_schema_version(&conn).unwrap(), 102); + assert!(!table_exists(&conn, "passkey_metadata").unwrap()); + assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap()); + conn.execute_batch("DROP TRIGGER interrupt_passkey_migration") + .unwrap(); + } + drop(conn); + let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap(); + assert!(preflight.migration_required()); + // Preflight must not install metadata in the source database. + let conn = Connection::open(&database).unwrap(); + assert!(!table_exists(&conn, "passkey_metadata").unwrap()); + drop(conn); + let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap(); + let record = migrated.get_passkey_credential("old-key").unwrap().unwrap(); + assert_eq!(record.public_key_cose, "unchanged-key-material"); + assert_eq!(record.sign_count, 7); + assert_eq!(record.last_used_at.as_deref(), Some("used-key")); + let summary = migrated + .list_passkey_summaries("user-old") + .unwrap() + .remove(0); + assert_eq!(summary.created_at, "created-key"); + assert_eq!(summary.last_used_at.as_deref(), Some("used-key")); + assert!(summary.normal_login_verified_at.is_none()); + assert_eq!(summary.key_reference.len(), 32); + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + let schema = |conn: &Connection| -> Result> { + let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN + ('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?; + Ok(stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))? + .collect::, _>>()?) + }; + assert_eq!( + migrated.with_conn(schema).unwrap(), + fresh.with_conn(schema).unwrap() + ); + migrated.with_conn(|conn| { + assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err()); + assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference) + VALUES('operation','missing-key','user-old','{}')", []).is_err()); + assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0); + Ok(()) + }).unwrap(); + drop(migrated); + let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap(); + assert_eq!( + reopened.list_passkey_summaries("user-old").unwrap(), + vec![summary] + ); + } + } + #[test] fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() { let dir = tempfile::tempdir().unwrap(); @@ -21327,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx drop(conn); let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap(); reopened - .with_conn(|c| verify_schema_history(c, 102)) + .with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION)) .unwrap(); } @@ -22266,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (99, "Explicit hosting administrators and self-registration policy".into()), (100, "Provisional users and scoped passkey activation".into()), (101, "Durable explicit Subject body release".into()), - (102, "Durable failed Subject creation recovery".into())]); + (102, "Durable failed Subject creation recovery".into()), + (103, "Passkey metadata and atomic addition results".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } diff --git a/openapi/server-api.json b/openapi/server-api.json index 720c13a72..3965becc3 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -298,12 +298,17 @@ "AuthUserResponse": { "additionalProperties": false, "properties": { + "authentication_reference": { + "description": "Comparison evidence for the exact newly issued session, not a credential.", + "type": "string" + }, "user": { "$ref": "#/components/schemas/AuthenticatedUser" } }, "required": [ - "user" + "user", + "authentication_reference" ], "type": "object" }, @@ -6709,15 +6714,112 @@ }, "PasskeyAdditionOptionsRequest": { "additionalProperties": false, - "description": "Adds a credential to the current authenticated actor; target identity is never public input.", + "description": "Adds to the current actor. Expected identity/binding fields only fence stale UI;\nthey never select an owner or authorize an addition.", "properties": { "browser_origin": { "type": [ "string", "null" ] + }, + "expected_account_id": { + "type": "string" + }, + "expected_authentication_reference": { + "type": "string" + }, + "expected_user_id": { + "type": "string" } }, + "required": [ + "expected_user_id", + "expected_account_id", + "expected_authentication_reference" + ], + "type": "object" + }, + "PasskeyAdditionResultRequest": { + "additionalProperties": false, + "properties": { + "challenge_id": { + "type": "string" + }, + "expected_account_id": { + "type": "string" + }, + "expected_authentication_reference": { + "description": "Original options authentication reference; the current same-owner session may differ.", + "type": "string" + }, + "expected_user_id": { + "type": "string" + } + }, + "required": [ + "challenge_id", + "expected_user_id", + "expected_account_id", + "expected_authentication_reference" + ], + "type": "object" + }, + "PasskeyAdditionResultResponse": { + "description": "Unknown is not proof that registration failed: completion may still be in flight.\nSaved proves the credential commit, not session issuance or current session health.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "status": { + "const": "unknown", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "key": { + "$ref": "#/components/schemas/PasskeySummary" + }, + "status": { + "const": "saved", + "type": "string" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "status", + "user", + "key" + ], + "type": "object" + } + ] + }, + "PasskeyListResponse": { + "additionalProperties": false, + "properties": { + "keys": { + "items": { + "$ref": "#/components/schemas/PasskeySummary" + }, + "type": "array" + }, + "user": { + "$ref": "#/components/schemas/AuthenticatedUser" + } + }, + "required": [ + "user", + "keys" + ], "type": "object" }, "PasskeyLoginCompleteRequest": { @@ -6820,6 +6922,35 @@ ], "type": "object" }, + "PasskeySummary": { + "additionalProperties": false, + "description": "Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.\nNormal-login verification is observed verifier success, not inferred discoverability.", + "properties": { + "created_at": { + "type": "string" + }, + "key_reference": { + "type": "string" + }, + "last_used_at": { + "type": [ + "string", + "null" + ] + }, + "normal_login_verified_at": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "key_reference", + "created_at" + ], + "type": "object" + }, "PasteArtifactAvailability": { "enum": [ "available", @@ -15143,6 +15274,13 @@ } ] }, + "authentication_reference": { + "description": "Non-secret comparison evidence, never an authentication credential.", + "type": [ + "string", + "null" + ] + }, "session": { "$ref": "#/components/schemas/BrowserSessionState" } @@ -18155,7 +18293,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7" + "x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23" }, "openapi": "3.1.0", "paths": { @@ -18589,6 +18727,61 @@ } } }, + "/api/auth/passkeys": { + "get": { + "operationId": "auth_passkey_list", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyListResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/auth/passkeys/addition/complete": { "post": { "operationId": "auth_passkey_addition_complete", @@ -18736,6 +18929,71 @@ ] } }, + "/api/auth/passkeys/addition/result": { + "post": { + "operationId": "auth_passkey_addition_result", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyAdditionResultRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasskeyAdditionResultResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/auth/passkeys/login/complete": { "post": { "operationId": "auth_passkey_login_complete", diff --git a/tests/e2e/README.md b/tests/e2e/README.md index f2a9414ae..24bbc5823 100644 --- a/tests/e2e/README.md +++ b/tests/e2e/README.md @@ -9,6 +9,7 @@ - [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。 - [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。 - [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。 +- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。 ## 固定Workerの段階起動 diff --git a/tests/e2e/browser/auth_sessions.ts b/tests/e2e/browser/auth_sessions.ts index 226ae9e76..4990cbbb0 100644 --- a/tests/e2e/browser/auth_sessions.ts +++ b/tests/e2e/browser/auth_sessions.ts @@ -4,7 +4,7 @@ // 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 // navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 import { assert, assertEquals } from "@std/assert"; -import { extname, join, normalize } from "@std/path"; +import { serveProductionShell } from "./static_shell.ts"; import { type Browser, type BrowserContext, chromium } from "playwright"; const [backend, origin, build, handle] = Deno.args; @@ -13,82 +13,43 @@ if (!backend || !origin || !build || !handle) { "Expected isolated Backend URL, browser origin, production build, handle", ); } -await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 -const mime: Record = { - ".html": "text/html; charset=utf-8", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json", - ".svg": "image/svg+xml", - ".png": "image/png", - ".woff2": "font/woff2", - ".ico": "image/x-icon", -}; -const server = Deno.serve({ - hostname: "127.0.0.1", - port: Number(new URL(origin).port), - onListen() {}, -}, async (request) => { - const url = new URL(request.url); - if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { - if (request.headers.get("origin") !== origin) { - return new Response(null, { status: 403 }); +const server = await serveProductionShell( + backend, + origin, + build, + async (request) => { + const url = new URL(request.url); + if ( + url.pathname === "/__e2e/expired-session" && request.method === "POST" + ) { + if (request.headers.get("origin") !== origin) { + return new Response(null, { status: 403 }); + } + // Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。 + const fixture = await bridge("expired-session", {}) as { + set_cookies: string[]; + }; + const headers = new Headers({ "cache-control": "no-store" }); + for (const cookie of fixture.set_cookies) { + headers.append("set-cookie", cookie); + } + return new Response(null, { status: 204, headers }); } - // Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。 - const fixture = await bridge("expired-session", {}) as { - set_cookies: string[]; - }; - const headers = new Headers({ "cache-control": "no-store" }); - for (const cookie of fixture.set_cookies) { - headers.append("set-cookie", cookie); + if ( + url.pathname === "/__e2e/primary-cookie-present" && + request.method === "GET" + ) { + // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 + const present = request.headers.get("cookie")?.split(";").some((pair) => + pair.trim().split("=", 1)[0] === "yoi_workspace_session" + ) ?? false; + return Response.json(present, { + headers: { "cache-control": "no-store" }, + }); } - return new Response(null, { status: 204, headers }); - } - if ( - url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" - ) { - // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 - const present = request.headers.get("cookie")?.split(";").some((pair) => - pair.trim().split("=", 1)[0] === "yoi_workspace_session" - ) ?? false; - return Response.json(present, { headers: { "cache-control": "no-store" } }); - } - if (url.pathname.startsWith("/api/")) { - const headers = new Headers(request.headers); - headers.delete("host"); - headers.delete("connection"); - // OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。 - return await fetch(new URL(url.pathname + url.search, backend), { - method: request.method, - headers, - body: ["GET", "HEAD"].includes(request.method) - ? undefined - : await request.arrayBuffer(), - redirect: "manual", - }); - } - const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); - if (relative.startsWith("..")) { - return new Response("not found", { status: 404 }); - } - let file = join(build, relative); - try { - if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); - return new Response(await Deno.readFile(file), { - headers: { - "content-type": mime[extname(file)] ?? "application/octet-stream", - }, - }); - } catch (error) { - if (!(error instanceof Deno.errors.NotFound)) throw error; - if (url.pathname.startsWith("/_app/")) { - return new Response("not found", { status: 404 }); - } - return new Response(await Deno.readFile(join(build, "index.html")), { - headers: { "content-type": mime[".html"] }, - }); - } -}); + return undefined; + }, +); // 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) @@ -313,7 +274,8 @@ try { // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 // 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 - const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; + const expiredTarget = + "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25"; const catalogResponse = () => page.waitForResponse((response) => new URL(response.url()).pathname === "/api/workspaces" && @@ -360,6 +322,7 @@ try { assert(expiredCookieNames.includes(marker.name)); assertEquals(await whoami(), { actor: null, + authentication_reference: null, session: { kind: "expired" }, }); privateRequests.length = 0; @@ -383,7 +346,11 @@ try { [], "expired reload must gate the real private catalog loader", ); - assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); + assertEquals(await whoami(), { + actor: null, + authentication_reference: null, + session: { kind: "expired" }, + }); await page.reload(); await at(expiredLogin); await page.getByRole("button", { name: "Log in with passkey", exact: true }) @@ -416,7 +383,11 @@ try { // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 await context.clearCookies({ name: session.name }); const partial = await whoami(); - assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); + assertEquals(partial, { + actor: null, + authentication_reference: null, + session: { kind: "anonymous" }, + }); await page.reload(); await at("/"); await context.clearCookies(); diff --git a/tests/e2e/browser/native_passkey_migration.ts b/tests/e2e/browser/native_passkey_migration.ts new file mode 100644 index 000000000..79c2d52b7 --- /dev/null +++ b/tests/e2e/browser/native_passkey_migration.ts @@ -0,0 +1,611 @@ +/// +// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。 +// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の +// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。 +// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。 +// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。 +// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。 +// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。 +import { join } from "@std/path"; +import { type Browser, type CDPSession, chromium, type Page } from "playwright"; +import { serveProductionShell } from "./static_shell.ts"; + +const [backend, origin, build, handlePrefix, artifacts] = Deno.args; +if (!backend || !origin || !build || !handlePrefix || !artifacts) { + throw new Error( + "Expected owned Backend, origin, production build, synthetic handle prefix, artifacts", + ); +} +const started = performance.now(); +const server = await serveProductionShell(backend, origin, build); +let browser: Browser | undefined; +let phase = "launch"; +const rows: { + resident: boolean; + authenticator: string; + outcome: string; + elapsed_ms: number; +}[] = []; +class NativeCheckError extends Error {} +function check(condition: unknown, diagnostic: string): asserts condition { + // 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。 + if (!condition) throw new NativeCheckError(diagnostic); +} +type User = { user_id: string; account_id: string; handle: string }; +type Key = { + key_reference: string; + normal_login_verified_at: string | null; + last_used_at: string | null; +}; +async function whoami(page: Page) { + const response = await page.request.get(`${origin}/api/auth/whoami`); + check(response.status() === 200, "whoami status"); + return await response.json() as { + actor: User | null; + authentication_reference: string | null; + session: { kind: string }; + }; +} +async function owner(page: Page, user: User, reference?: string) { + const current = await whoami(page); + check( + current.session.kind === "authenticated" && + current.actor?.user_id === user.user_id && + current.actor.account_id === user.account_id && + current.actor.handle === user.handle, + "authenticated owner mismatch", + ); + check( + typeof current.authentication_reference === "string", + "missing authentication reference", + ); + if (reference) { + check( + current.authentication_reference === reference, + "issued authentication mismatch", + ); + } + return current; +} +async function keys(page: Page, user: User): Promise { + const response = await page.request.get(`${origin}/api/auth/passkeys`); + check(response.status() === 200, "key list status"); + const result = await response.json(); + check( + result.user.user_id === user.user_id && + result.user.account_id === user.account_id, + "key list owner", + ); + return result.keys; +} +async function presence( + cdp: CDPSession, + authenticatorId: string, + enabled: boolean, +) { + await cdp.send("WebAuthn.setAutomaticPresenceSimulation", { + authenticatorId, + enabled, + }); +} +async function authenticator( + cdp: CDPSession, + resident: boolean, + automaticPresenceSimulation = true, +) { + return (await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "usb", + hasResidentKey: resident, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation, + }, + })).authenticatorId; +} +async function account(page: Page) { + await page.goto(origin + "/account"); + await page.getByRole("heading", { name: "Account", exact: true }).waitFor(); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); +} +async function logout(page: Page) { + await account(page); + await page.getByRole("button", { name: "Log out", exact: true }).click(); + await page.waitForURL((url) => url.origin === origin && url.pathname === "/"); + check( + (await whoami(page)).session.kind === "anonymous", + "logout must clear authentication", + ); +} +async function login(page: Page, user: User, legacy: boolean) { + await page.goto(origin + "/login?returnTo=%2Faccount"); + const button = page.getByRole("button", { + name: legacy ? "Log in with older key" : "Log in with passkey", + exact: true, + }); + if (legacy) { + await page.getByText("Use an older key with account ID", { exact: true }) + .click(); + await page.getByLabel("Account ID (handle)", { exact: true }).fill( + user.handle, + ); + } else { + // 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。 + check( + !await page.getByLabel("Account ID (handle)", { exact: true }) + .isVisible(), + "normal login asks for ID", + ); + } + const options = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/login/options" + ); + const complete = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await button.click(); + const optionResponse = await options; + check(optionResponse.status() === 200, "login options status"); + const input = optionResponse.request().postDataJSON(); + if (!legacy) { + check( + Object.keys(input).every((key) => key === "browser_origin"), + "normal login selected an account", + ); + const wire = await optionResponse.json(); + check( + !wire.public_key.publicKey.allowCredentials?.length, + "normal login sends an allow-list", + ); + } + const response = await complete; + check(response.status() === 200, "native login complete status"); + const issued = await response.json(); + check( + issued.user.user_id === user.user_id && + issued.user.account_id === user.account_id, + "login returned wrong owner", + ); + await page.waitForURL((url) => url.pathname === "/account"); + await owner(page, user, issued.authentication_reference); +} + +const terminate = () => { + void (async () => { + await browser?.close(); + await server.shutdown(); + Deno.exit(143); + })(); +}; +Deno.addSignalListener("SIGTERM", terminate); +try { + browser = await chromium.launch({ headless: true, timeout: 15_000 }); + for (const resident of [false, true]) { + const context = await browser.newContext({ + viewport: { width: 1280, height: 900 }, + serviceWorkers: "block", + }); + context.setDefaultTimeout(12_000); + // 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。 + await context.route( + "**/*", + (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + let page = await context.newPage(); + let pageErrors = 0; + page.on("pageerror", () => ++pageErrors); + let cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable", { enableUI: false }); + // 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、 + // resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。 + const old = await authenticator(cdp, true); + let completionPosts = 0; + const completions = () => completionPosts; + page.on("request", (r) => { + if ( + new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete" + ) ++completionPosts; + }); + try { + phase = `legacy-fixture-resident-${resident}`; + await page.goto(origin + "/register"); + // 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。 + const fixture = await page.evaluate(async ({ handle, resident }) => { + const post = async (path: string, body: unknown) => { + const response = await fetch(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (!response.ok) { + throw new Error("synthetic legacy fixture HTTP rejected"); + } + return await response.json(); + }; + const wire = await post("/api/auth/passkeys/registration/options", { + handle, + display_name: "Synthetic native migration", + browser_origin: location.origin, + }); + const bytes = (value: string) => + Uint8Array.from( + atob(value.replaceAll("-", "+").replaceAll("_", "/")), + (c) => c.charCodeAt(0), + ); + const options = wire.public_key.publicKey; + const random = crypto.getRandomValues(new Uint8Array(32)); + const stable = bytes(options.user.id); + const differs = stable.length !== random.length || + stable.some((byte: number, index: number) => byte !== random[index]); + options.challenge = bytes(options.challenge); + options.user.id = random; + options.excludeCredentials = options.excludeCredentials?.map(( + item: { id: string }, + ) => ({ ...item, id: bytes(item.id) })); + options.authenticatorSelection = { + ...options.authenticatorSelection, + residentKey: resident ? "required" : "discouraged", + requireResidentKey: resident, + }; + const credential = await navigator.credentials.create({ + publicKey: options, + }) as PublicKeyCredential | null; + if ( + !credential || + !(credential.response instanceof AuthenticatorAttestationResponse) + ) throw new Error("native attestation missing"); + const encode = (value: ArrayBuffer) => + btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll( + "+", + "-", + ).replaceAll("/", "_").replaceAll("=", ""); + const issued = await post("/api/auth/passkeys/registration/complete", { + challenge_id: wire.challenge_id, + credential: { + id: credential.id, + rawId: encode(credential.rawId), + type: credential.type, + response: { + attestationObject: encode(credential.response.attestationObject), + clientDataJSON: encode(credential.response.clientDataJSON), + transports: credential.response.getTransports(), + }, + extensions: credential.getClientExtensionResults(), + }, + }); + return { + user: issued.user, + authentication_reference: issued.authentication_reference, + randomDiffersFromStable: differs, + }; + }, { + handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`, + resident, + }); + check( + fixture.randomDiffersFromStable, + "fixture must use a legacy random handle", + ); + const user: User = fixture.user; + await owner(page, user, fixture.authentication_reference); + const initial = await keys(page, user); + check( + initial.length === 1 && initial[0].normal_login_verified_at === null, + "legacy key initially unverified", + ); + const oldReference = initial[0].key_reference; + await logout(page); + phase = `older-key-login-before-resident-${resident}`; + await login(page, user, true); + + phase = `same-authenticator-excluded-resident-${resident}`; + const sameStarted = performance.now(); + const add = () => + page.getByRole("button", { + name: `Add passkey to ${user.handle}`, + exact: true, + }); + const optionsResponse = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/addition/options" + ); + await add().click(); + check( + (await optionsResponse).status() === 200, + "addition options status", + ); + const excluded = page.getByRole("alert").filter({ + hasText: "[authenticator/excluded]", + }); + await excluded.waitFor({ state: "visible" }); + check( + (await excluded.innerText()).includes( + "different device, security key, or passkey provider", + ), + "exclusion needs a concrete alternative", + ); + check( + (await excluded.innerText()).includes("do not delete"), + "exclusion must preserve working access", + ); + check( + completions() === 0, + "native exclusion must not submit completion", + ); + const afterExcluded = await keys(page, user); + check( + afterExcluded.length === 1 && + afterExcluded[0].key_reference === oldReference, + "exclusion changed old key", + ); + await owner(page, user); + rows.push({ + resident, + authenticator: "same", + outcome: "native-exclusion-no-completion-old-key-preserved", + elapsed_ms: Math.round(performance.now() - sameStarted), + }); + + phase = `other-authenticator-pending-resident-${resident}`; + const otherStarted = performance.now(); + // CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、 + // 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。 + const legacyPage = page; + const legacyCdp = cdp; + page = await context.newPage(); + page.on("pageerror", () => ++pageErrors); + page.on("request", (r) => { + if ( + new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete" + ) ++completionPosts; + }); + cdp = await context.newCDPSession(page); + await cdp.send("WebAuthn.enable", { enableUI: false }); + const newer = await authenticator(cdp, true, false); + await account(page); + // options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。 + const beforeAddition = await owner(page, user); + const additionOptions = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/addition/options" + ); + const additionComplete = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete" + ); + await add().click(); + const optionResponse = await additionOptions; + check( + optionResponse.status() === 200, + "other authenticator options status", + ); + let optionsInput = optionResponse.request().postDataJSON(); + check( + optionsInput.expected_user_id === user.user_id && + optionsInput.expected_account_id === user.account_id && + optionsInput.expected_authentication_reference === + beforeAddition.authentication_reference, + "addition must fence displayed owner and session", + ); + let wire = await optionResponse.json(); + check( + wire.public_key.publicKey.excludeCredentials.length === 1, + "old credential must remain excluded", + ); + check( + wire.public_key.publicKey.authenticatorSelection.userVerification === + "required", + "addition must require UV", + ); + check( + wire.public_key.publicKey.authenticatorSelection.residentKey === + "required", + "product addition must require residency", + ); + const pending = page.getByRole("status").filter({ + hasText: "Waiting for passkey addition", + }); + await pending.waitFor({ state: "visible" }); + check( + await page.getByRole("heading", { name: "Account", exact: true }) + .isVisible(), + "account hidden while native pending", + ); + check( + await add().isDisabled() && completions() === 0, + "pending must prevent duplicate/early completion", + ); + for ( + const viewport of [{ width: 1280, height: 900 }, { + width: 390, + height: 844, + }] + ) { + await page.setViewportSize(viewport); + await pending.scrollIntoViewIfNeeded(); + check( + await pending.isVisible(), + "pending must remain visible at viewport", + ); + await page.screenshot({ + path: join( + artifacts, + `native-addition-pending-${resident}-${viewport.width}.png`, + ), + fullPage: false, + }); + } + await page.setViewportSize({ width: 1280, height: 900 }); + // presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。 + // pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。 + await page.getByRole("button", { name: "Stop waiting", exact: true }) + .click(); + await page.getByRole("alert").filter({ + hasText: "Completion was not sent.", + }).waitFor(); + check( + completions() === 0 && (await keys(page, user)).length === 1, + "cancel before touch must not save or complete", + ); + await presence(cdp, newer, true); + const freshOptions = page.waitForResponse((r) => + new URL(r.url()).pathname === "/api/auth/passkeys/addition/options" + ); + await add().click(); + const freshResponse = await freshOptions; + check(freshResponse.status() === 200, "fresh addition options status"); + optionsInput = freshResponse.request().postDataJSON(); + wire = await freshResponse.json(); + phase = `other-authenticator-completion-resident-${resident}`; + const complete = await additionComplete; + check(complete.status() === 200, "native addition complete status"); + const issued = await complete.json(); + check( + issued.user.user_id === user.user_id && + issued.user.account_id === user.account_id && + typeof issued.authentication_reference === "string", + "addition complete owner/reference", + ); + await page.getByRole("status").filter({ + hasText: "Passkey added to your account", + }).waitFor({ state: "visible" }); + await owner(page, user, issued.authentication_reference); + check(completions() === 1, "addition must submit exactly once"); + // 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。 + const receipt = await page.request.post( + `${origin}/api/auth/passkeys/addition/result`, + { + headers: { Origin: origin }, + data: { + challenge_id: wire.challenge_id, + expected_user_id: user.user_id, + expected_account_id: user.account_id, + expected_authentication_reference: + optionsInput.expected_authentication_reference, + }, + }, + ); + check( + receipt.status() === 200, + "saved addition receipt after Cookie issuance", + ); + const saved = await receipt.json(); + check( + saved.status === "saved" && saved.user.user_id === user.user_id && + saved.user.account_id === user.account_id, + "receipt must identify saved owner", + ); + const added = await keys(page, user); + check( + added.length === 2 && + added.some((key) => key.key_reference === oldReference), + "addition must retain older key", + ); + const newKey = added.find((key) => key.key_reference !== oldReference); + check( + newKey && saved.key.key_reference === newKey.key_reference && + newKey.normal_login_verified_at === null, + "storage must not assert normal login", + ); + await logout(page); + phase = `handle-free-login-resident-${resident}`; + await login(page, user, false); + const verified = await keys(page, user); + check( + verified.length === 2 && + verified.find((key) => key.key_reference === newKey.key_reference) + ?.normal_login_verified_at != null, + "actual handle-free verifier success must mark the new key", + ); + check( + verified.find((key) => key.key_reference === oldReference) + ?.normal_login_verified_at === null, + "normal login must not mark the unused legacy key", + ); + await logout(page); + phase = `older-key-login-after-resident-${resident}`; + await presence(cdp, newer, false); + page = legacyPage; + cdp = legacyCdp; + await presence(cdp, old, true); + await login(page, user, true); + const retained = await keys(page, user); + check( + retained.length === 2 && + retained.find((key) => key.key_reference === oldReference) + ?.last_used_at != null, + "older key must remain usable after migration", + ); + check( + retained.find((key) => key.key_reference === newKey.key_reference) + ?.normal_login_verified_at != null, + "older login must preserve new key verification", + ); + check(pageErrors === 0, "product page emitted an uncaught error"); + rows.push({ + resident, + authenticator: "other", + outcome: + "saved-correct-owner-handle-free-verified-older-login-preserved", + elapsed_ms: Math.round(performance.now() - otherStarted), + }); + } finally { + await context.close(); + } + } + await Deno.writeTextFile( + join(artifacts, "native-migration-result.json"), + JSON.stringify( + { + status: "passed", + boundary: + "real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only", + chromium: browser.version(), + rows, + elapsed_ms: Math.round(performance.now() - started), + viewports: ["1280x900", "390x844"], + physical_smartphone: "unconfirmed", + physical_authenticator: "unconfirmed", + }, + null, + 2, + ), + ); + console.error( + `native-migration: PASS ${rows.length} matrix rows; ${ + Math.round(performance.now() - started) + }ms`, + ); +} catch (error) { + const diagnostic = error instanceof NativeCheckError + ? error.message + : "browser-or-tool-operation-failed"; + await Deno.writeTextFile( + join(artifacts, "native-migration-result.json"), + JSON.stringify( + { + status: "failed", + phase, + diagnostic, + rows, + elapsed_ms: Math.round(performance.now() - started), + physical_smartphone: "unconfirmed", + physical_authenticator: "unconfirmed", + }, + null, + 2, + ), + ); + // Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。 + console.error( + `native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${ + Math.round(performance.now() - started) + }`, + ); + Deno.exitCode = 1; +} finally { + Deno.removeSignalListener("SIGTERM", terminate); + await browser?.close(); + await server.shutdown(); +} diff --git a/tests/e2e/browser/static_shell.ts b/tests/e2e/browser/static_shell.ts new file mode 100644 index 000000000..c603bc533 --- /dev/null +++ b/tests/e2e/browser/static_shell.ts @@ -0,0 +1,76 @@ +// 実Backendへの透過proxyと本番SPA配信をbrowser suite間で共有する。 +// Cookie/Origin/署名検証/認可は置換しない。fixture入口は呼出側が明示的に渡す。 +import { extname, join, normalize } from "@std/path"; + +export async function serveProductionShell( + backend: string, + origin: string, + build: string, + fixture?: (request: Request) => Promise, +) { + for (const address of [backend, origin]) { + const url = new URL(address); + if ( + url.protocol !== "http:" || + !["localhost", "127.0.0.1"].includes(url.hostname) || !url.port + ) { + throw new Error("Browser E2E requires explicit owned loopback endpoints"); + } + } + await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 + const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript", + ".css": "text/css", + ".json": "application/json", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", + ".ico": "image/x-icon", + }; + return Deno.serve({ + hostname: "127.0.0.1", + port: Number(new URL(origin).port), + onListen() {}, + }, async (request) => { + const url = new URL(request.url); + const special = await fixture?.(request); + if (special) return special; + if (url.pathname.startsWith("/api/")) { + const headers = new Headers(request.headers); + headers.delete("host"); + headers.delete("connection"); + return await fetch(new URL(url.pathname + url.search, backend), { + method: request.method, + headers, + body: ["GET", "HEAD"].includes(request.method) + ? undefined + : await request.arrayBuffer(), + redirect: "manual", + }); + } + const relative = normalize( + url.pathname.replace(/^\/+/, "") || "index.html", + ); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } + let file = join(build, relative); + try { + if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); + return new Response(await Deno.readFile(file), { + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, + }); + } catch (error) { + if (!(error instanceof Deno.errors.NotFound)) throw error; + if (url.pathname.startsWith("/_app/")) { + return new Response("not found", { status: 404 }); + } + return new Response(await Deno.readFile(join(build, "index.html")), { + headers: { "content-type": mime[".html"] }, + }); + } + }); +} diff --git a/tests/e2e/src/worker_environment.rs b/tests/e2e/src/worker_environment.rs index 14e01d73d..4ec279c14 100644 --- a/tests/e2e/src/worker_environment.rs +++ b/tests/e2e/src/worker_environment.rs @@ -1074,6 +1074,27 @@ impl WorkerE2eEnvironment { Ok(output) } + /// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は + /// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの + /// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。 + pub fn browser_command(&self, binary: &Path) -> Result { + let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect(); + let mut command = self.isolated_command(binary); + for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] { + if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) { + command.env(name, value); + } + } + let temporary = self.root.join("browser-tmp"); + let cache = self.root.join("browser-cache"); + fs::create_dir_all(&temporary)?; + fs::create_dir_all(&cache)?; + command + .env("TMPDIR", temporary) + .env("XDG_CACHE_HOME", cache); + Ok(command) + } + fn isolated_command(&self, binary: &Path) -> Command { let mut command = Command::new(binary); command diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 7195bb3bb..a6ccaa3db 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -4,8 +4,9 @@ //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 -//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが -//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを +//! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。 //! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 use std::error::Error; @@ -21,8 +22,8 @@ use serde::Deserialize; use serde::de::DeserializeOwned; use serde_json::{Value, json}; use server_api::{ - ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, - PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, + ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest, + PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyRegistrationOptionsResponse, WhoamiResponse, }; #[path = "../src/resident_authenticator.rs"] @@ -30,8 +31,12 @@ mod resident_model; use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; +#[path = "auth_passkeys/browser_process.rs"] +mod browser_process; #[path = "auth_passkeys/browser_sessions.rs"] mod browser_sessions; +#[path = "auth_passkeys/native_migration.rs"] +mod native_migration; type Result = std::result::Result>; const BROWSER_ORIGIN: &str = "http://localhost:5173"; @@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result Ok(()) } +// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。 +fn addition_expectation( + request: RequestBuilder, + artifacts: &Path, +) -> Result { + let identity: WhoamiResponse = decode(send( + request, + "read addition owner and authentication reference", + artifacts, + )?)?; + let actor = identity + .actor + .ok_or("addition fixture requires an authenticated actor")?; + Ok(PasskeyAdditionOptionsRequest { + expected_user_id: actor.user_id, + expected_account_id: actor.account_id, + expected_authentication_reference: identity + .authentication_reference + .ok_or("missing addition authentication reference")?, + browser_origin: Some(BROWSER_ORIGIN.to_owned()), + }) +} + fn host_admin_login( environment: &WorkerE2eEnvironment, client: &Client, @@ -291,11 +319,17 @@ fn host_admin_login( let bearer = setup["access_token"] .as_str() .ok_or("host setup omitted initial bearer")?; + let addition_body = addition_expectation( + client + .get(format!("{base}/api/auth/whoami")) + .bearer_auth(bearer), + artifacts, + )?; let options: PasskeyRegistrationOptionsResponse = decode(send( client .post(format!("{base}/api/auth/passkeys/addition/options")) .bearer_auth(bearer) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&addition_body), "host-created administrator bearer starts actor-derived passkey addition", artifacts, )?)?; @@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow policy(&client, base, artifacts, false)?; let mut admin_key = ResidentAuthenticator::new(true); let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + let admin_addition = addition_expectation( + client + .get(format!("{base}/api/auth/whoami")) + .header(COOKIE, &admin_cookie), + artifacts, + )?; policy(&client, base, artifacts, false)?; set_policy(&client, base, artifacts, &admin_cookie, true)?; let whoami: Value = decode(send( @@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow client .post(format!("{base}/api/auth/passkeys/addition/options")) .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&admin_addition), "unauthenticated addition options", artifacts, )?, @@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow )?)?; assert!(options.public_key.public_key.allow_credentials.is_empty()); + let addition_body = addition_expectation( + client + .get(format!("{base}/api/auth/whoami")) + .header(COOKIE, &cookie), + artifacts, + )?; 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}); + let mut body = serde_json::to_value(&addition_body)?; body[field] = json!(value); rejected( send( @@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow let mut request = client .post(format!("{base}/api/auth/passkeys/addition/options")) .header(COOKIE, &cookie) - .json(&json!({"browser_origin": BROWSER_ORIGIN})); + .json(&addition_body); if let Some(origin) = origin { request = request.header(ORIGIN, origin); } @@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow .post(format!("{base}/api/auth/passkeys/addition/options")) .header(COOKIE, &cookie) .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&addition_body), "authenticated self addition options", artifacts, )?)?; @@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow .post(format!("{base}/api/auth/passkeys/addition/options")) .header(COOKIE, revoked_cookie) .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&addition_body), "logout prevents further self addition with the old cookie", artifacts, )?, @@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN) .json(&json!({"user_code": flow["user_code"]})), client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id, + "expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})), ]; for mut request in requests { if let Some(bearer) = bearer { @@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp .post(format!("{base}/api/auth/passkeys/addition/options")) .header(COOKIE, &cookie) .header(ORIGIN, BROWSER_ORIGIN) - .json(&json!({"browser_origin": BROWSER_ORIGIN})), + .json(&addition_expectation( + client + .get(format!("{base}/api/auth/whoami")) + .header(COOKIE, &cookie), + artifacts, + )?), "start distinct authenticated self-addition ceremony", artifacts, )?)?; diff --git a/tests/e2e/tests/auth_passkeys/browser_process.rs b/tests/e2e/tests/auth_passkeys/browser_process.rs new file mode 100644 index 000000000..5a624b4c0 --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/browser_process.rs @@ -0,0 +1,58 @@ +//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。 +use super::*; +use std::process::{Child, ExitStatus}; +use std::sync::mpsc; + +pub(super) struct BrowserProcess(pub(super) Option); +impl BrowserProcess { + pub(super) fn child(&mut self) -> &mut Child { + self.0.as_mut().expect("browser process retained") + } + + pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result { + let mut child = self.0.take().expect("browser process retained"); + let pid = child.id() as libc::pid_t; + let (send, receive) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ = send.send(child.wait()); + }); + let result = receive.recv_timeout(timeout); + if result.is_err() { + unsafe { + libc::kill(pid, libc::SIGTERM); + } + if receive.recv_timeout(Duration::from_secs(3)).is_err() { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + } + let _ = waiter.join(); + Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??) + } +} +impl Drop for BrowserProcess { + fn drop(&mut self) { + let Some(mut child) = self.0.take() else { + return; + }; + if matches!(child.try_wait(), Ok(Some(_))) { + return; + } + let pid = child.id() as libc::pid_t; + // Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。 + unsafe { + libc::kill(pid, libc::SIGTERM); + } + let (send, receive) = mpsc::channel(); + let waiter = std::thread::spawn(move || { + let _ = send.send(child.wait()); + }); + if receive.recv_timeout(Duration::from_secs(3)).is_err() { + unsafe { + libc::kill(pid, libc::SIGKILL); + } + } + let _ = waiter.join(); + } +} diff --git a/tests/e2e/tests/auth_passkeys/browser_sessions.rs b/tests/e2e/tests/auth_passkeys/browser_sessions.rs index 7207e4124..86cdb92b2 100644 --- a/tests/e2e/tests/auth_passkeys/browser_sessions.rs +++ b/tests/e2e/tests/auth_passkeys/browser_sessions.rs @@ -8,10 +8,11 @@ //! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite: //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` //! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 +use super::browser_process::BrowserProcess; use super::*; use std::io::{BufRead, BufReader, Read}; use std::net::TcpListener; -use std::process::{Child, Command, Stdio}; +use std::process::Stdio; use std::sync::mpsc; use std::time::Instant; use yoi_workspace_server::auth::{ @@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result ]})) } -struct BrowserProcess(Option); -impl BrowserProcess { - fn child(&mut self) -> &mut Child { - self.0.as_mut().expect("browser process retained") - } -} -impl Drop for BrowserProcess { - fn drop(&mut self) { - let Some(mut child) = self.0.take() else { - return; - }; - if matches!(child.try_wait(), Ok(Some(_))) { - return; - } - let pid = child.id() as libc::pid_t; - // Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。 - unsafe { - libc::kill(pid, libc::SIGTERM); - } - let (send, receive) = mpsc::channel(); - let waiter = std::thread::spawn(move || { - let _ = send.send(child.wait()); - }); - if receive.recv_timeout(Duration::from_secs(3)).is_err() { - unsafe { - libc::kill(pid, libc::SIGKILL); - } - } - let _ = waiter.join(); - } -} - #[test] #[ignore = "requires production Web assets, Deno and Playwright Chromium"] fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result @@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit drop(reservation); let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; let mut process = BrowserProcess(Some( - Command::new("deno") + environment + .browser_command(Path::new("deno"))? .current_dir(&root) .args([ "run", diff --git a/tests/e2e/tests/auth_passkeys/native_migration.rs b/tests/e2e/tests/auth_passkeys/native_migration.rs new file mode 100644 index 000000000..6b774b4db --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/native_migration.rs @@ -0,0 +1,110 @@ +//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。 +//! random user handle × resident=false/true × 同じ/別の認証器を検査する。 +//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。 +//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。 +//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。 +//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。 +//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored` +//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。 +//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。 +//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。 +use super::browser_process::BrowserProcess; +use super::*; +use std::net::TcpListener; +use std::process::Stdio; + +#[test] +#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"] +fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration() +-> Result { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize()?; + let build = root.join("web/workspace/build"); + if !build.join("index.html").is_file() { + return Err("native browser E2E requires an existing production Web build".into()); + } + let mut environment = WorkerE2eEnvironment::new()?; + let reservation = TcpListener::bind("127.0.0.1:0")?; + let origin = format!("http://localhost:{}", reservation.local_addr()?.port()); + std::fs::write( + &environment.server_config, + format!("[browser]\npublic_url = \"{origin}\"\n"), + )?; + environment.start_empty_backend()?; + let admin = + environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?; + let bearer = admin["access_token"] + .as_str() + .ok_or("missing setup token")?; + let base = environment + .server_url + .as_deref() + .ok_or("missing Backend URL")?; + let response = http_client()? + .put(format!("{base}/api/hosting/settings")) + .bearer_auth(bearer) + .json(&json!({"self_registration_enabled": true})) + .send()?; + if !response.status().is_success() { + return Err("native fixture could not enable synthetic self-registration".into()); + } + // Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。 + drop(reservation); + let stderr = + std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?; + let mut command = environment.browser_command(Path::new("deno"))?; + command + .current_dir(&root) + .args([ + "run", + "--config", + "tools/web-ux/deno.json", + "--no-lock", + "--cached-only", + "--allow-env", + "--allow-net=127.0.0.1,localhost", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-sys", + "tests/e2e/browser/native_passkey_migration.ts", + base, + &origin, + build.to_str().ok_or("non-UTF8 build path")?, + &environment.account_handle, + environment + .artifacts_dir + .to_str() + .ok_or("non-UTF8 artifacts path")?, + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(stderr); + let process = BrowserProcess(Some(command.spawn()?)); + let status = process.wait_bounded(Duration::from_secs(180))?; + if !status.success() { + return Err(format!( + "native CTAP2 matrix failed; sanitized diagnostics: {}", + environment + .artifacts_dir + .join("native-browser.stderr.log") + .display() + ) + .into()); + } + let report: Value = serde_json::from_slice(&std::fs::read( + environment + .artifacts_dir + .join("native-migration-result.json"), + )?)?; + if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) { + return Err("native browser did not confirm all four matrix rows".into()); + } + let cleanup = environment.cleanup()?; + assert!( + cleanup.cleanup_success, + "owned native browser environment cleanup failed" + ); + Ok(()) +} diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 6b4622ebe..7384240af 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -21,7 +21,7 @@ "check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check", "generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types", "check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check", - "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts", + "test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts", "test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts" }, "imports": { diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 788ded8eb..cdd06d005 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; -export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; +export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, +/** + * Non-secret comparison evidence, never an authentication credential. + */ +authentication_reference: string | null, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; -export type AuthUserResponse = { user: AuthenticatedUser, }; +export type AuthUserResponse = { user: AuthenticatedUser, +/** + * Comparison evidence for the exact newly issued session, not a credential. + */ +authentication_reference: string, }; -export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, }; +export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, }; + +export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, }; + +export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array, }; + +export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string, +/** + * Original options authentication reference; the current same-owner session may differ. + */ +expected_authentication_reference: string, }; + +export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, }; export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index ecd2b3f5d..88701f292 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) +// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23) // 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 bc61338f5..7e62713af 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) +// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23) // 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 72647613a..5f678586c 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) +// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23) // 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 18b0a6804..063ddd5ba 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) +// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8dd4a964b..2497421ab 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:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) +// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23) // 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/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 46e41c345..7023137e9 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -108,7 +108,7 @@