diff --git a/Cargo.lock b/Cargo.lock index 122e578dc..0e669a4f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6705,6 +6705,7 @@ dependencies = [ name = "yoi-e2e" version = "0.0.0" dependencies = [ + "chrono", "libc", "reqwest", "serde", @@ -6714,6 +6715,7 @@ dependencies = [ "tempfile", "webauthn-authenticator-rs", "webauthn-rs-proto", + "yoi-workspace-server", ] [[package]] diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 1e30192d7..dc082acd2 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -4025,6 +4025,17 @@ impl RequestActor { #[serde(deny_unknown_fields)] pub struct WhoamiResponse { pub actor: Option, + pub session: BrowserSessionState, +} + +/// Browser navigation evidence, not an authorization grant or a generic 401 classification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum BrowserSessionState { + Anonymous {}, + Authenticated { expires_at: Option }, + Expired {}, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -11533,6 +11544,7 @@ pub fn auth_api_typescript() -> String { ActorAuthMethod::decl(&config), AuthenticatedUser::decl(&config), RequestActor::decl(&config), + BrowserSessionState::decl(&config), WhoamiResponse::decl(&config), AuthBootstrapUserRequest::decl(&config), AuthUserResponse::decl(&config), @@ -13961,7 +13973,8 @@ mod tests { "handle": "hare", "display_name": "Hare", "auth_method": "browser_session" - } + }, + "session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" } }); let decoded = serde_json::from_value::(whoami.clone()) .expect("server whoami fixture should match shared DTO"); @@ -14143,11 +14156,36 @@ mod tests { let unexpected_field = serde_json::json!({ "actor": null, + "session": { "kind": "anonymous" }, "access_token": "must-not-be-accepted" }); assert!(serde_json::from_value::(unexpected_field).is_err()); } + #[test] + fn whoami_session_json_distinguishes_anonymous_expired_and_token_auth() { + for session in [ + serde_json::json!({"kind":"anonymous"}), + serde_json::json!({"kind":"expired"}), + serde_json::json!({"kind":"authenticated", "expires_at":null}), + ] { + let value = serde_json::json!({"actor":null, "session":session}); + let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(response).unwrap(), value); + } + for session in [ + serde_json::json!({"kind":"unknown"}), + serde_json::json!({"kind":"expired", "expires_at":null}), + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"actor":null, "session":session}) + ) + .is_err() + ); + } + } + #[test] fn device_login_debug_output_redacts_secret_material() { let start = DeviceLoginStartResponse { diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index fc6a302fe..c46c54926 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option { } pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { - let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; - cookie_header.split(';').find_map(|pair| { - let (name, value) = pair.trim().split_once('=')?; - (name == cookie_name && !value.is_empty()).then(|| value.to_string()) - }) + unique_cookie(headers, cookie_name) + .ok() + .flatten() + .filter(|value| !value.is_empty()) +} + +// Cookie has no path/domain metadata on requests. Duplicates (including identical values +// and multiple Cookie headers) cannot be disambiguated safely after cookie tossing. +fn unique_cookie( + headers: &HeaderMap, + cookie_name: &str, +) -> std::result::Result, ()> { + let mut found = None; + for header in headers.get_all(header::COOKIE) { + let header = header.to_str().map_err(|_| ())?; + for pair in header.split(';') { + let pair = pair.trim(); + let Some((name, value)) = pair.split_once('=') else { + if pair == cookie_name { + return Err(()); + } + continue; + }; + if name == cookie_name { + if found.is_some() { + return Err(()); + } + found = Some(value.to_owned()); + } + } + } + Ok(found) } pub async fn resolve_request_actor( @@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync( bearer: Option, session: Option, ) -> Result> { + Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor)) +} + +fn resolve_credentials_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session: Option, +) -> Result)>> { if let Some(token) = bearer { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { @@ -132,7 +167,10 @@ pub(crate) fn resolve_request_actor_sync( return Ok(None); } store.mark_api_token_used(&token_hash, &now_rfc3339())?; - return actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken); + return Ok( + actor_for_user(store, &api_token.user_id, ActorAuthMethod::ApiToken)? + .map(|actor| (actor, None)), + ); } } @@ -141,13 +179,95 @@ pub(crate) fn resolve_request_actor_sync( if is_expired(&session.expires_at) { return Ok(None); } - return actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession); + return Ok( + actor_for_user(store, &session.user_id, ActorAuthMethod::BrowserSession)? + .map(|actor| (actor, Some(session.expires_at))), + ); } } Ok(None) } +// This marker is a non-authenticating reference to the existing non-revoked session row. +// It outlives the auth cookie so Max-Age expiry remains distinguishable from a new browser. +// No second session store is introduced; clearing all cookies necessarily loses this evidence. +// Keep the established 14-day authentication lifetime. The additional week is only a bounded +// return-navigation window (including a week away); it never extends authentication. The store +// reclaims expired rows past this same boundary on subsequent session issuance. Neither whoami +// nor other reads refresh the marker or the row's absolute expiry. +pub const BROWSER_SESSION_SECONDS: i64 = 14 * 24 * 60 * 60; +pub const SESSION_EXPIRY_GRACE_SECONDS: i64 = 7 * 24 * 60 * 60; + +pub fn session_marker_cookie_name(cookie_name: &str) -> String { + format!("{cookie_name}_expiry") +} + +pub async fn resolve_whoami( + store: std::sync::Arc, + headers: &HeaderMap, + cookie_name: &str, +) -> Result { + let bearer = parse_bearer(headers); + // Even an empty/invalid auth cookie takes precedence over an older marker. + // An ambiguous primary is invalid, not absent; never fall back to an older marker. + let session = unique_cookie(headers, cookie_name).unwrap_or_else(|()| Some(String::new())); + let marker = parse_cookie(headers, &session_marker_cookie_name(cookie_name)); + let executor = store.db_executor(); + executor + .run(move || resolve_whoami_sync(store.as_ref(), bearer, session, marker)) + .await +} + +fn resolve_whoami_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session_token: Option, + marker: Option, +) -> Result { + use server_api::{BrowserSessionState, WhoamiResponse}; + let anonymous = || WhoamiResponse { + actor: 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())? + { + return Ok(WhoamiResponse { + actor: Some(actor), + session: BrowserSessionState::Authenticated { expires_at }, + }); + } + let hash = match session_token { + Some(token) => token_hash(&token), + None => match marker { + Some(hash) => hash, + None => return Ok(anonymous()), + }, + }; + let Some(session) = store.resolve_browser_session(&hash)? else { + return Ok(anonymous()); + }; + if store.get_user(&session.user_id)?.is_none() { + return Ok(anonymous()); + } + if within_session_expiry_grace(&session.expires_at, Utc::now()) { + return Ok(WhoamiResponse { + actor: None, + session: BrowserSessionState::Expired {}, + }); + } + // A marker for a still-valid session cannot restore authentication after partial deletion. + Ok(anonymous()) +} + +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) + }) +} + fn actor_for_user( store: &S, user_id: &str, @@ -274,6 +394,13 @@ mod tests { .unwrap(); assert_eq!(actor.user_id, "user-auth"); assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); + let whoami = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!( + whoami.session, + server_api::BrowserSessionState::Authenticated { expires_at: None } + ); assert!( store .resolve_api_token(&hash) @@ -363,20 +490,119 @@ mod tests { assert!(futures::poll!(queued.as_mut()).is_pending()); let mut headers = HeaderMap::new(); headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor_result = tokio::time::timeout( + std::time::Duration::from_secs(1), + resolve_request_actor(store.clone(), &headers, "session"), + ) + .await; let result = tokio::time::timeout( std::time::Duration::from_secs(1), - resolve_request_actor(store, &headers, "session"), + resolve_whoami(store, &headers, "session"), ) .await; release.send(()).unwrap(); blocker.await.unwrap().unwrap(); queued.await.unwrap(); + assert!(matches!( + actor_result.expect("actor lookup rejects capacity"), + Err(Error::DbCapacityExceeded) + )); assert!(matches!( result.expect("capacity rejection must not wait for queued SQL"), Err(Error::DbCapacityExceeded) )); } + #[tokio::test] + async fn whoami_classifies_only_live_store_backed_session_expiry() { + use server_api::BrowserSessionState; + let store = seeded_store(); + for (secret, offset, revoked) in [ + ("recent", -60, false), + ("old", -SESSION_EXPIRY_GRACE_SECONDS - 60, false), + ("revoked", -60, true), + ] { + store + .create_browser_session(&BrowserSessionRecord { + session_id: format!("session-{secret}"), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: "1".into(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: revoked.then(|| now_rfc3339()), + }) + .unwrap(); + } + let cases = [ + (String::new(), BrowserSessionState::Anonymous {}), + ("session=unknown".into(), BrowserSessionState::Anonymous {}), + ("session=recent".into(), BrowserSessionState::Expired {}), + ( + format!("session_expiry={}", token_hash("recent")), + BrowserSessionState::Expired {}, + ), + ( + format!("session_expiry={}", token_hash("session-secret")), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=unknown".into(), + BrowserSessionState::Anonymous {}, + ), + ( + "session_expiry=recent".into(), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=unknown; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ( + format!("session=; session_expiry={}", token_hash("recent")), + BrowserSessionState::Anonymous {}, + ), + ("session=revoked".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("revoked")), + BrowserSessionState::Anonymous {}, + ), + ("session=old".into(), BrowserSessionState::Anonymous {}), + ( + format!("session_expiry={}", token_hash("old")), + BrowserSessionState::Anonymous {}, + ), + ( + format!( + "session=session-secret; session_expiry={}", + token_hash("recent") + ), + BrowserSessionState::Authenticated { + expires_at: Some("9999-01-01T00:00:00Z".into()), + }, + ), + ]; + for (cookie, expected) in cases { + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, cookie.parse().unwrap()); + let response = resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap(); + assert_eq!(response.session, expected, "cookie: {cookie}"); + assert_eq!( + response.actor.is_some(), + matches!(expected, BrowserSessionState::Authenticated { .. }) + ); + if !matches!(expected, BrowserSessionState::Authenticated { .. }) { + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + } + } + } + #[test] fn user_code_uses_the_uuid_random_tail() { let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID"); @@ -395,4 +621,136 @@ mod tests { ); assert_eq!(code, code.to_ascii_uppercase()); } + + #[test] + fn expiry_grace_includes_expiry_but_excludes_its_absolute_end() { + let expiry = DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&Utc); + for (offset, expected) in [ + (-1, false), + (0, true), + (SESSION_EXPIRY_GRACE_SECONDS - 1, true), + (SESSION_EXPIRY_GRACE_SECONDS, false), + ] { + assert_eq!( + within_session_expiry_grace( + &expiry.to_rfc3339(), + expiry + Duration::seconds(offset) + ), + expected + ); + } + assert!(!within_session_expiry_grace("invalid", expiry)); + } + + #[tokio::test] + async fn duplicate_cookies_never_select_an_actor_or_promote_an_ambiguous_marker() { + use server_api::BrowserSessionState; + let store = seeded_store(); + let expired_hash = token_hash("expired-duplicate"); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-duplicate".into(), + token_hash: expired_hash.clone(), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(-60)), + revoked_at: None, + }) + .unwrap(); + for cookies in [ + vec!["session=session-secret; session=unknown".to_owned()], + vec!["session=session-secret; session=session-secret".to_owned()], + vec![ + "session=session-secret".to_owned(), + "session=unknown".to_owned(), + ], + vec![format!( + "session=; session=session-secret; session_expiry={expired_hash}" + )], + vec![format!( + "session_expiry={expired_hash}; session_expiry={expired_hash}" + )], + vec![format!("session; session_expiry={expired_hash}")], + ] { + let mut headers = HeaderMap::new(); + for cookie in cookies { + headers.append(header::COOKIE, cookie.parse().unwrap()); + } + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + assert_eq!( + resolve_whoami(store.clone(), &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Anonymous {} + ); + } + let mut headers = HeaderMap::new(); + headers.insert( + header::COOKIE, + format!("session=session-secret; session_expiry={expired_hash}") + .parse() + .unwrap(), + ); + assert!(matches!( + resolve_whoami(store, &headers, "session") + .await + .unwrap() + .session, + BrowserSessionState::Authenticated { .. } + )); + } + + #[test] + fn session_issuance_reclaims_only_rows_beyond_recovery_grace() { + let store = seeded_store(); + let record = |secret: &str, offset| BrowserSessionRecord { + session_id: secret.into(), + token_hash: token_hash(secret), + user_id: "user-auth".into(), + created_at: now_rfc3339(), + expires_at: rfc3339_after(Duration::seconds(offset)), + revoked_at: None, + }; + store + .create_browser_session(&record("past-grace", -SESSION_EXPIRY_GRACE_SECONDS - 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_some() + ); + store + .create_browser_session(&record("within-grace", -SESSION_EXPIRY_GRACE_SECONDS + 60)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("past-grace")) + .unwrap() + .is_none() + ); + store + .create_browser_session(&record("new-session", BROWSER_SESSION_SECONDS)) + .unwrap(); + assert!( + store + .resolve_browser_session(&token_hash("within-grace")) + .unwrap() + .is_some() + ); + assert!( + store + .resolve_browser_session(&token_hash("new-session")) + .unwrap() + .is_some() + ); + } } diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index dd256fea9..30732afac 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6856,6 +6856,7 @@ async fn attach_server_request_context( } fn generated_auth_contract_router(service: ServerApiContractService) -> Router { + let auth = auth_public_config(&service.auth_api().config); let service = Arc::new(service); let origin_context = Router::new() .merge(server_api::server_api_axum::auth_activation_options( @@ -6887,7 +6888,6 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { .merge(server_api::server_api_axum::auth_device_login_approve( service.clone(), )) - .merge(server_api::server_api_axum::auth_whoami(service.clone())) .layer(middleware::from_fn_with_state( service.as_ref().clone(), attach_server_request_context, @@ -6908,12 +6908,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { service.clone(), )) .merge(server_api::server_api_axum::auth_logout(service.clone())) + .merge( + server_api::server_api_axum::auth_whoami(service.clone()) + .layer(middleware::from_fn(attach_server_origin_context)), + ) .merge(server_api::server_api_axum::auth_device_login_start( service.clone(), )) .merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(origin_context) .merge(actor_context) + .layer(middleware::from_fn_with_state( + auth, + append_session_marker_cookie, + )) .layer(middleware::map_response( |mut response: Response| async move { // Current role/policy and authentication responses must not be cached @@ -6926,6 +6934,62 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router { )) } +// The typed auth operations expose one Set-Cookie. At the HTTP boundary, pair only a real +// session Set-Cookie with a non-authenticating expiry marker, including login and logout. +// Mirroring its attributes keeps Secure/Path/Domain/SameSite aligned without a second policy. +async fn append_session_marker_cookie( + State(auth): State, + request: Request, + next: Next, +) -> Response { + let mut response = next.run(request).await; + let marker = response + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .find_map(|value| { + let value = value.to_str().ok()?; + let (pair, attributes) = value.split_once(';')?; + let (name, token) = pair.split_once('=')?; + if name != auth.cookie_name { + return None; + } + let max_age = attributes + .split(';') + .find_map(|attr| attr.trim().strip_prefix("Max-Age=")?.parse::().ok())?; + let (hash, marker_age) = if max_age <= 0 { + (String::new(), 0) + } else { + ( + token_hash(token), + max_age + crate::auth::SESSION_EXPIRY_GRACE_SECONDS, + ) + }; + let attributes = attributes + .split(';') + .map(|attr| { + if attr.trim().starts_with("Max-Age=") { + format!(" Max-Age={marker_age}") + } else { + attr.to_owned() + } + }) + .collect::>() + .join(";"); + HeaderValue::from_str(&format!( + "{}={hash};{attributes}", + crate::auth::session_marker_cookie_name(name) + )) + .ok() + }); + if let Some(marker) = marker { + response + .headers_mut() + .append(axum::http::header::SET_COOKIE, marker); + } + response +} + fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router { let service = Arc::new(service); Router::new() @@ -8151,9 +8215,15 @@ impl server_api::ServerApi for ServerApiContractService { &self, context: server_api::ServerRequestContext, ) -> std::result::Result { - Ok(WhoamiResponse { - actor: context.actor, - }) + let headers = contract_request_headers(&context)?; + let api = self.auth_api(); + crate::auth::resolve_whoami( + api.store.clone(), + &headers, + &auth_public_config(&api.config).cookie_name, + ) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error()) } async fn auth_logout( @@ -30307,14 +30377,14 @@ fn issue_browser_session( token_hash: token_hash(&session_token), user_id: user.user_id.clone(), created_at: crate::auth::now_rfc3339(), - expires_at: rfc3339_after(Duration::days(14)), + expires_at: rfc3339_after(Duration::seconds(crate::auth::BROWSER_SESSION_SECONDS)), revoked_at: None, })?; let auth = auth_public_config(&api.config); let cookie = session_set_cookie( session_cookie_policy(&auth), &session_token, - 14 * 24 * 60 * 60, + crate::auth::BROWSER_SESSION_SECONDS, ); Ok(( AuthUserResponse { @@ -30510,6 +30580,16 @@ fn logout_browser_session( .store .revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; } + // A missing auth cookie after Max-Age must not prevent explicit logout from revoking + // the recovery reference. This hash is never accepted as an authentication credential. + if let Some(hash) = parse_cookie( + &headers, + &crate::auth::session_marker_cookie_name(&auth.cookie_name), + ) { + let _ = api + .store + .revoke_browser_session(&hash, &crate::auth::now_rfc3339())?; + } Ok(( LogoutResponse { status: LogoutStatus::LoggedOut, @@ -53509,6 +53589,7 @@ mod tests { .await .unwrap(); assert_eq!(registration_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name); let registration_cookie = registration_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53546,6 +53627,12 @@ mod tests { .unwrap(), ) .unwrap(); + assert_eq!(registered_whoami["session"]["kind"], "authenticated"); + assert!( + registered_whoami["session"]["expires_at"] + .as_str() + .is_some() + ); assert_eq!( registered_whoami["actor"]["handle"], "generated-passkey-user" @@ -53600,6 +53687,7 @@ mod tests { .await .unwrap(); assert_eq!(login_response.status(), StatusCode::OK); + assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name); let login_cookie = login_response .headers() .get(axum::http::header::SET_COOKIE) @@ -53684,6 +53772,169 @@ mod tests { assert!(logged_out_whoami["actor"].is_null()); } + // Real generated auth routes, DB executor and SQLite; no auth resolution is mocked. + #[tokio::test] + async fn whoami_expiry_marker_survives_auth_cookie_loss_but_logout_revokes_it() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + seed_test_api_token(api.store.as_ref(), "expiry-router"); + let user = api.store.get_user("user-expiry-router").unwrap().unwrap(); + let auth = auth_public_config(&api.config); + let cookie_name = auth.cookie_name; + let hash = token_hash("expired-router-secret"); + api.store + .create_browser_session(&BrowserSessionRecord { + session_id: "expired-router-session".into(), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(-1)), + revoked_at: None, + }) + .unwrap(); + let marker = format!( + "{}={hash}", + crate::auth::session_marker_cookie_name(&cookie_name) + ); + let app = generated_auth_contract_router(ServerApiContractService::Workspace(api.clone())); + for (cookie, kind) in [ + (String::new(), "anonymous"), + (format!("{cookie_name}=expired-router-secret"), "expired"), + (marker.clone(), "expired"), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.headers()[CACHE_CONTROL], "no-store"); + assert!( + !response + .headers() + .contains_key(axum::http::header::SET_COOKIE) + ); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["actor"], Value::Null); + assert_eq!(value["session"]["kind"], kind); + } + let denied = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/passkeys/addition/options") + .header(axum::http::header::COOKIE, &marker) + .header(ORIGIN, &auth.origin) + .header(CONTENT_TYPE, "application/json") + .body(Body::from("{}")) + .unwrap(), + ) + .await + .unwrap(); + // Preserve the operation's existing auth_required/403 contract; no HTML redirect. + assert_eq!(denied.status(), StatusCode::FORBIDDEN); + assert!(!denied.headers().contains_key(axum::http::header::LOCATION)); + let logout = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/auth/logout") + .header(axum::http::header::COOKIE, &marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(logout.status(), StatusCode::OK); + let cleared: Vec<_> = logout + .headers() + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|v| v.to_str().unwrap()) + .collect(); + assert_eq!(cleared.len(), 2); + assert!( + cleared + .iter() + .all(|value| value.contains("Max-Age=0") && value.contains("HttpOnly")) + ); + assert!(cleared.iter().any(|value| value.starts_with(&format!( + "{}=;", + crate::auth::session_marker_cookie_name(&cookie_name) + )))); + assert!(api.store.resolve_browser_session(&hash).unwrap().is_none()); + let response = app + .oneshot( + Request::builder() + .uri("/api/auth/whoami") + .header(axum::http::header::COOKIE, marker) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert_eq!(value["session"]["kind"], "anonymous"); + } + + fn assert_auth_response_has_expiry_marker(headers: &HeaderMap, cookie_name: &str) { + let cookies: Vec<_> = headers + .get_all(axum::http::header::SET_COOKIE) + .iter() + .map(|value| value.to_str().unwrap()) + .collect(); + let auth = cookies + .iter() + .find(|value| value.starts_with(&format!("{cookie_name}="))) + .unwrap(); + let (_, token) = auth.split(';').next().unwrap().split_once('=').unwrap(); + let marker = cookies + .iter() + .find(|value| { + value.starts_with(&format!( + "{}=", + crate::auth::session_marker_cookie_name(cookie_name) + )) + }) + .unwrap(); + let expected = token_hash(token); + assert_eq!( + parse_cookie( + &{ + let mut headers = HeaderMap::new(); + headers.insert(axum::http::header::COOKIE, marker.parse().unwrap()); + headers + }, + &crate::auth::session_marker_cookie_name(cookie_name) + ), + Some(expected) + ); + assert!(auth.contains(&format!("Max-Age={}", crate::auth::BROWSER_SESSION_SECONDS))); + assert!(marker.contains(&format!( + "Max-Age={}", + crate::auth::BROWSER_SESSION_SECONDS + crate::auth::SESSION_EXPIRY_GRACE_SECONDS + ))); + for attribute in ["Path=/", "HttpOnly", "SameSite=Lax", "Secure"] { + assert_eq!( + auth.contains(attribute), + marker.contains(attribute), + "{attribute}" + ); + } + } + #[tokio::test] async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { for (scheme, secure) in [("http", false), ("https", true)] { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b9dd24f8a..138b56fa6 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore { fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { self.with_immediate_transaction(|conn| { activation::require_active(conn, &record.user_id)?; + // Retain the real row throughout the non-authenticating recovery window, not + // forever. Issuance amortizes reclamation without extending marker lifetime or + // introducing another store/scheduler. Inactive stores do not grow between issues. + let cutoff = (chrono::Utc::now() + - chrono::Duration::seconds(crate::auth::SESSION_EXPIRY_GRACE_SECONDS)) + .to_rfc3339(); + conn.execute( + "DELETE FROM browser_sessions WHERE julianday(expires_at) <= julianday(?1) OR julianday(expires_at) IS NULL", + params![cutoff], + )?; conn.execute( r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, diff --git a/openapi/server-api.json b/openapi/server-api.json index e601f843e..138988240 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -443,6 +443,56 @@ "additionalProperties": false, "type": "object" }, + "BrowserSessionState": { + "description": "Browser navigation evidence, not an authorization grant or a generic 401 classification.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "anonymous", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expires_at": { + "type": [ + "string", + "null" + ] + }, + "kind": { + "const": "authenticated", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "expired", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + } + ] + }, "BrowserTicketState": { "enum": [ "open", @@ -14846,8 +14896,14 @@ "type": "null" } ] + }, + "session": { + "$ref": "#/components/schemas/BrowserSessionState" } }, + "required": [ + "session" + ], "type": "object" }, "WorkerBusyState": { @@ -17831,7 +17887,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" + "x-yoi-source-digest": "sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f" }, "openapi": "3.1.0", "paths": { diff --git a/tests/e2e/Cargo.toml b/tests/e2e/Cargo.toml index 9638ae45a..b308eeb49 100644 --- a/tests/e2e/Cargo.toml +++ b/tests/e2e/Cargo.toml @@ -19,6 +19,8 @@ sha2.workspace = true tempfile.workspace = true [dev-dependencies] +chrono = { version = "0.4", default-features = false, features = ["clock"] } +yoi-workspace-server.workspace = true server-api.workspace = true webauthn-rs-proto = "0.5.5" webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/tests/e2e/browser/auth_sessions.ts b/tests/e2e/browser/auth_sessions.ts new file mode 100644 index 000000000..226ae9e76 --- /dev/null +++ b/tests/e2e/browser/auth_sessions.ts @@ -0,0 +1,435 @@ +/// +// WorkerE2eEnvironment所有の実Backendへ全APIをproxyし、本番static shellを配信する。 +// fixture-serverと同じSPA配信方式。製品APIのwhoami、認証、認可、Set-Cookieは置換しない。 +// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 +// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 +import { assert, assertEquals } from "@std/assert"; +import { extname, join, normalize } from "@std/path"; +import { type Browser, type BrowserContext, chromium } from "playwright"; + +const [backend, origin, build, handle] = Deno.args; +if (!backend || !origin || !build || !handle) { + throw new Error( + "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 }); + } + // 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 }); + } + 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"] }, + }); + } +}); + +// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 +const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) + .getReader(); +let pending = ""; +async function bridge(operation: string, options: unknown): Promise { + await Deno.stdout.write( + new TextEncoder().encode(JSON.stringify({ operation, options }) + "\n"), + ); + while (!pending.includes("\n")) { + const { done, value } = await input.read(); + if (done) throw new Error("ResidentAuthenticator disconnected"); + pending += value; + if (pending.length > 1024 * 1024) throw new Error("Oversized credential"); + } + const newline = pending.indexOf("\n"); + const credential = JSON.parse(pending.slice(0, newline)); + pending = pending.slice(newline + 1); + return credential; +} +let browser: Browser | undefined; +let context: BrowserContext | undefined; +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 }); + context = await browser.newContext(); + context.setDefaultTimeout(10_000); + await context.route( + "**/*", + (route) => + new URL(route.request().url()).origin === origin + ? route.continue() + : route.abort(), + ); + await context.exposeBinding( + "residentCredential", + (_source, operation: string, options: unknown) => + bridge(operation, options), + ); + await context.addInitScript(() => { + const encode = (value: unknown): unknown => { + if (value instanceof ArrayBuffer) { + return btoa(String.fromCharCode(...new Uint8Array(value))) + .replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", ""); + } + if (Array.isArray(value)) return value.map(encode); + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, encode(item)]), + ); + } + return value; + }; + const bytes = (value: string) => { + const base64 = value.replaceAll("-", "+").replaceAll("_", "/"); + return Uint8Array.from( + atob(base64), + (character) => character.charCodeAt(0), + ).buffer; + }; + for (const operation of ["create", "get"] as const) { + Object.defineProperty(navigator.credentials, operation, { + configurable: true, + value: async ( + options: CredentialCreationOptions & CredentialRequestOptions, + ) => { + const bridge = (window as unknown as { + residentCredential: ( + operation: string, + options: unknown, + ) => Promise<{ + id: string; + rawId: string; + response: Record; + extensions?: unknown; + }>; + }).residentCredential; + const wire = await bridge(operation, { + publicKey: encode(options.publicKey), + }); + const response = Object.fromEntries( + Object.entries(wire.response).map(( + [key, value], + ) => [key, typeof value === "string" ? bytes(value) : value]), + ); + return { + id: wire.id, + rawId: bytes(wire.rawId), + type: "public-key", + getClientExtensionResults: () => wire.extensions ?? {}, + response: { + ...response, + getTransports: () => wire.response.transports ?? [], + }, + }; + }, + }); + } + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(error.message)); + const at = async (path: string) => { + await page.waitForURL((url) => + url.origin === origin && url.pathname + url.search + url.hash === path + ); + console.error(`navigation-ready: ${new URL(path, origin).pathname}`); + }; + const whoami = async () => { + const response = await page.request.get(`${origin}/api/auth/whoami`); + assertEquals(response.status(), 200); + return await response.json(); + }; + const account = "/account?view=passkeys&filter=a%2Fb#selected"; + const privateRequests: string[] = []; + const observePrivate = (request: { url(): string }) => { + const path = new URL(request.url()).pathname; + if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) { + privateRequests.push(path); + } + }; + page.on("request", observePrivate); + await page.goto(origin + account); + await at("/"); + await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor(); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals( + privateRequests, + [], + "anonymous gate must run before private loaders", + ); + page.off("request", observePrivate); + + await page.goto( + `${origin}/register?${new URLSearchParams({ returnTo: account })}`, + ); + await page.getByLabel("Account ID (handle)", { exact: true }).fill(handle); + await page.getByLabel("Display name", { exact: true }).fill( + "Real browser E2E", + ); + const registration = page.waitForResponse((response) => + new URL(response.url()).pathname === + "/api/auth/passkeys/registration/complete" + ); + await page.getByRole("button", { name: "Register new account", exact: true }) + .click(); + assertEquals((await registration).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + const identity = await whoami(); + assertEquals(identity.actor?.handle, handle); + assertEquals(identity.session.kind, "authenticated"); + assert(typeof identity.session.expires_at === "string"); + const cookies = await context.cookies(origin); + const session = cookies.find((cookie) => + cookie.name === "yoi_workspace_session" + ); + const marker = cookies.find((cookie) => + cookie.name === "yoi_workspace_session_expiry" + ); + assert(session && marker, "Backend must issue both cookies"); + for (const cookie of [session, marker]) { + assert(cookie.httpOnly); + assertEquals(cookie.sameSite, "Lax"); + } + const seconds = (Date.parse(identity.session.expires_at) / 1000) - + Date.now() / 1000; + assert(seconds > 14 * 86400 - 60 && seconds <= 14 * 86400); + assert( + Math.abs(session.expires - Date.parse(identity.session.expires_at) / 1000) < + 2, + ); + assert(Math.abs(marker.expires - session.expires - 7 * 86400) < 2); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + await page.reload(); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // 明示logoutは実Serverのrevoke + 両cookie削除を通る。 + await page.getByRole("button", { name: "Log out", exact: true }).click(); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + const afterLogout = await context.cookies(origin); + assert( + !afterLogout.some((cookie) => + [session.name, marker.name].includes(cookie.name) + ), + ); + const revoked = await page.request.get(`${origin}/api/auth/whoami`, { + headers: { + cookie: + `${session.name}=${session.value}; ${marker.name}=${marker.value}`, + }, + }); + assertEquals((await revoked.json()).session, { kind: "anonymous" }); + await page.goto(origin + account); + await at("/"); + + // 実discoverable loginで、確認済みの認証成功後のみpathname/query/fragmentへ復帰する。 + await page.goto( + `${origin}/login?${new URLSearchParams({ returnTo: account })}`, + ); + await page.reload(); + const login = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await login).status(), 200); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + assertEquals((await whoami()).actor?.handle, handle); + + // 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 catalogResponse = () => + page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/workspaces" && + response.request().method() === "GET" + ); + const initialCatalog = catalogResponse(); + await page.goto(origin + expiredTarget); + assertEquals((await initialCatalog).status(), 200); + await at(expiredTarget); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const issuedStatus = await page.evaluate(async () => { + const response = await fetch("/__e2e/expired-session", { + method: "POST", + cache: "no-store", + }); + return response.status; + }); + assertEquals(issuedStatus, 204); + const initiallyPresent = await page.evaluate(async () => + await (await fetch("/__e2e/primary-cookie-present", { cache: "no-store" })) + .json() + ); + assertEquals( + initiallyPresent, + true, + "short-lived primary must first reach Chromium", + ); + await page.evaluate(async () => { + const deadline = performance.now() + 10_000; + do { + const response = await fetch("/__e2e/primary-cookie-present", { + cache: "no-store", + }); + if (response.ok && await response.json() === false) return; + await new Promise((resolve) => setTimeout(resolve, 100)); + } while (performance.now() < deadline); + throw new Error("Primary session cookie did not naturally expire"); + }); + // 値をassertionへ渡さず、cookie jarにもprimaryが消えmarkerが残ることを確認する。 + const expiredCookieNames = (await context.cookies(origin)).map((cookie) => + cookie.name + ); + assert(!expiredCookieNames.includes(session.name)); + assert(expiredCookieNames.includes(marker.name)); + assertEquals(await whoami(), { + actor: null, + session: { kind: "expired" }, + }); + privateRequests.length = 0; + page.on("request", observePrivate); + const expiredWhoami = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/whoami" + ); + await page.reload(); + const expiredResponse = await expiredWhoami; + assertEquals(expiredResponse.status(), 200); + // SPAがloginへ移った後の旧document bodyをCDPから読み直さない。 + // 分類本文は直前/復帰待ち後にも同じ実Backendから取得し、browser要求はstatusを観測する。 + const expiredLogin = `/login?${new URLSearchParams({ + returnTo: expiredTarget, + })}`; + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "expired reload must gate the real private catalog loader", + ); + assertEquals(await whoami(), { actor: null, session: { kind: "expired" } }); + await page.reload(); + await at(expiredLogin); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .waitFor(); + assertEquals( + privateRequests, + [], + "login reload must not lose or privately load returnTo", + ); + page.off("request", observePrivate); + const recoveredCatalog = catalogResponse(); + const relogin = page.waitForResponse((response) => + new URL(response.url()).pathname === "/api/auth/passkeys/login/complete" + ); + await page.getByRole("button", { name: "Log in with passkey", exact: true }) + .click(); + assertEquals((await relogin).status(), 200); + await at(expiredTarget); + assertEquals((await recoveredCatalog).status(), 200); + await page.getByRole("heading", { name: "Dashboard", exact: true }).waitFor(); + const recovered = await whoami(); + assertEquals(recovered.session.kind, "authenticated"); + assertEquals(recovered.actor?.user_id, identity.actor.user_id); + assertEquals(recovered.actor?.handle, handle); + await page.goto(origin + account); + await at(account); + await page.getByRole("button", { name: "Log out", exact: true }).waitFor(); + + // fixtureでprimaryだけを除去。Server時計は進めていないため、markerのみでは + // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 + await context.clearCookies({ name: session.name }); + const partial = await whoami(); + assertEquals(partial, { actor: null, session: { kind: "anonymous" } }); + await page.reload(); + await at("/"); + await context.clearCookies(); + await page.goto(origin + account); + await at("/"); + assertEquals((await whoami()).session, { kind: "anonymous" }); + assertEquals(errors, []); +} finally { + await context?.close(); + await browser?.close(); + await server.shutdown(); + Deno.removeSignalListener("SIGTERM", terminate); +} +await Deno.stdout.write(new TextEncoder().encode('{"operation":"done"}\n')); +// 親はstdout EOFで完了を確定する。stdin streamのprefetch取消を待つと相互待ちになる。 +Deno.exit(0); diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index 95dce869b..7195bb3bb 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,11 +1,12 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 //! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 -//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(browser UI/OS認証器は対象外)。 +//! authenticatorはresident store/選択を持つsoftware model、署名エンジンのみSoftPasskeyとする(OS/物理認証器は対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 -//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 -//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 -//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 +//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 +//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが +//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 +//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 use std::error::Error; use std::fs::OpenOptions; @@ -29,6 +30,9 @@ mod resident_model; use resident_model::ResidentAuthenticator; use yoi_e2e::WorkerE2eEnvironment; +#[path = "auth_passkeys/browser_sessions.rs"] +mod browser_sessions; + type Result = std::result::Result>; const BROWSER_ORIGIN: &str = "http://localhost:5173"; diff --git a/tests/e2e/tests/auth_passkeys/browser_sessions.rs b/tests/e2e/tests/auth_passkeys/browser_sessions.rs new file mode 100644 index 000000000..7207e4124 --- /dev/null +++ b/tests/e2e/tests/auth_passkeys/browser_sessions.rs @@ -0,0 +1,233 @@ +//! 実Backend・本番static shell・Chromium cookie jarの接続を検証する。 +//! navigator.credentialsだけを共有ResidentAuthenticatorの署名へ接続する。 +//! 期限切れケースは実登録userへ公開store APIで固定expiry sessionを作り、 +//! 本番cookie生成関数の短命primary/猶予markerをfixture HTTPで配信する。 +//! SQL直書き・resolver/APIの置換・製品clockの追加はしない。 +//! 実発行14日cookieの属性/DTOと短命fixtureの自然消失/復帰を分けて検証する。 +//! OS/物理認証器と14日経過したServer時計は検証しない。 +//! 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::*; +use std::io::{BufRead, BufReader, Read}; +use std::net::TcpListener; +use std::process::{Child, Command, Stdio}; +use std::sync::mpsc; +use std::time::Instant; +use yoi_workspace_server::auth::{ + SESSION_EXPIRY_GRACE_SECONDS, SessionCookiePolicy, mint_secret, new_id, + session_marker_cookie_name, session_set_cookie, token_hash, +}; +use yoi_workspace_server::store::{BrowserSessionRecord, ControlPlaneStore, SqliteWorkspaceStore}; + +// 戻り値はprivate IPC→fixture HTTPのSet-Cookieだけに使い、assert/debug/artifactへ出さない。 +fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result { + // default環境や任意pathへ接続せず、このenvironmentが起動した既存DBだけを開く。 + let database = environment + .server_data_dir + .join("server.db") + .canonicalize()?; + if !database.starts_with(environment.root.canonicalize()?) { + return Err("expired-session fixture DB is outside its environment".into()); + } + let store = SqliteWorkspaceStore::open(database)?; + let user = store + .get_user_by_handle(&environment.account_handle)? + .ok_or("browser registration must create the fixture user first")?; + let token = mint_secret("yoi_sess"); + let hash = token_hash(&token); + let now = chrono::Utc::now(); + // Server時計を動かさず、既に期限切れだが実猶予内のrecordを公開store APIで保存する。 + store.create_browser_session(&BrowserSessionRecord { + session_id: new_id("session"), + token_hash: hash.clone(), + user_id: user.user_id, + created_at: (now - chrono::Duration::days(14) - chrono::Duration::seconds(60)).to_rfc3339(), + expires_at: (now - chrono::Duration::seconds(60)).to_rfc3339(), + revoked_at: None, + })?; + let policy = SessionCookiePolicy { + cookie_name: "yoi_workspace_session", + path: "/", + domain: None, + secure: false, + }; + let marker_name = session_marker_cookie_name(policy.cookie_name); + Ok(json!({"set_cookies": [ + session_set_cookie(policy, &token, 2), + session_set_cookie(SessionCookiePolicy { + cookie_name: &marker_name, + ..policy + }, &hash, SESSION_EXPIRY_GRACE_SECONDS - 60), + ]})) +} + +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 +{ + // Backend/process/DBの所有は既存harnessに限定する。Web buildは呼出元が用意する。 + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../.."); + let build = root.join("web/workspace/build"); + if !build.join("index.html").is_file() { + return Err("build web/workspace production assets before selecting browser E2E".into()); + } + let mut environment = WorkerE2eEnvironment::new()?; + let reservation = TcpListener::bind("127.0.0.1:0")?; + let port = reservation.local_addr()?.port(); + let origin = format!("http://localhost:{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")?; + success( + http_client()? + .put(format!("{base}/api/hosting/settings")) + .bearer_auth(bearer) + .json(&json!({"self_registration_enabled": true})) + .send()?, + )?; + drop(reservation); + let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; + let mut process = BrowserProcess(Some( + Command::new("deno") + .current_dir(&root) + .args([ + "run", + "--config", + "tools/web-ux/deno.json", + "--no-lock", + "--allow-env", + "--allow-net", + "--allow-read", + "--allow-write", + "--allow-run", + "--allow-sys", + "tests/e2e/browser/auth_sessions.ts", + base, + &origin, + build.to_str().ok_or("non-UTF8 build path")?, + &environment.account_handle, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(stderr) + .spawn()?, + )); + let stdout = process + .child() + .stdout + .take() + .ok_or("missing browser stdout")?; + let mut stdin = process + .child() + .stdin + .take() + .ok_or("missing browser stdin")?; + let (send, receive) = mpsc::sync_channel(4); + // bounded NDJSONは実WebAuthn options/署名とfixture cookieを運び、artifactには記録しない。 + let reader = std::thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.by_ref().take(1024 * 1024).read_line(&mut line) { + Ok(0) => break, + Ok(_) if line.ends_with('\n') => { + if send.send(Ok(line)).is_err() { + break; + } + } + _ => { + let _ = send.send(Err("invalid or oversized browser IPC")); + break; + } + } + } + }); + let deadline = Instant::now() + Duration::from_secs(120); + let mut authenticator = ResidentAuthenticator::new(true); + let mut completed = false; + loop { + let message = match receive.recv_timeout(deadline.saturating_duration_since(Instant::now())) + { + Ok(message) => message.map_err(std::io::Error::other)?, + Err(mpsc::RecvTimeoutError::Disconnected) => break, + Err(_) => return Err("browser E2E timed out; see browser.stderr.log".into()), + }; + let request: Value = serde_json::from_str(&message)?; + let credential = match request["operation"].as_str() { + Some("create") => serde_json::to_value(authenticator.do_registration( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("get") => serde_json::to_value(authenticator.do_authentication( + origin.parse()?, + serde_json::from_value(request["options"].clone())?, + )?)?, + Some("expired-session") => expired_session_fixture(&environment)?, + Some("done") => { + completed = true; + continue; + } + _ => return Err("unexpected browser IPC operation".into()), + }; + serde_json::to_writer(&mut stdin, &credential)?; + writeln!(stdin)?; + stdin.flush()?; + } + reader.join().map_err(|_| "browser reader panicked")?; + assert!( + completed, + "browser did not complete; see browser.stderr.log" + ); + assert!( + process.child().wait()?.success(), + "browser E2E failed; see browser.stderr.log" + ); + drop(process); + let report = environment.cleanup()?; + assert!(report.cleanup_success); + Ok(()) +} diff --git a/web/workspace/src/lib/generated/auth-api.ts b/web/workspace/src/lib/generated/auth-api.ts index 774a2852b..788ded8eb 100644 --- a/web/workspace/src/lib/generated/auth-api.ts +++ b/web/workspace/src/lib/generated/auth-api.ts @@ -19,7 +19,9 @@ export type AuthenticatedUser = { user_id: string, account_id: string, handle: s export type RequestActor = { user_id: string, account_id: string, handle: string, display_name: string, auth_method: ActorAuthMethod, }; -export type WhoamiResponse = { actor: RequestActor | null, }; +export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", }; + +export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, }; diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 146d0ce28..5c09ef0af 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:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // 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 74a8c9547..6fcb5b03d 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:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // 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 138ed0fd1..79c053a6d 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:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // 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 2dbcf6109..11391dbd3 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:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // 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 8fff5d2ea..63f2881c3 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:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d) +// Input contract: 0.1.0 (sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f) // 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/api/http.ts b/web/workspace/src/lib/workspace/api/http.ts index 27773b1bc..02dc0f39e 100644 --- a/web/workspace/src/lib/workspace/api/http.ts +++ b/web/workspace/src/lib/workspace/api/http.ts @@ -1,3 +1,7 @@ +import { + browserSession, + recheckAfterUnauthorized, +} from "#lib/workspace/auth/session-state.ts"; import { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts"; import type { SkillCatalogResponse, @@ -102,7 +106,11 @@ export async function loadJson( ): Promise> { let stage: "request" | "decode" | "validate" = "request"; try { + const authenticationGeneration = browserSession.authenticationGeneration; const response = await fetchFn(path, init); + if (response.status === 401) { + recheckAfterUnauthorized(authenticationGeneration); + } if (!response.ok) { if (policy) { await response.body?.cancel(); diff --git a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte index 2dafcddd9..46e41c345 100644 --- a/web/workspace/src/lib/workspace/auth/AuthEntry.svelte +++ b/web/workspace/src/lib/workspace/auth/AuthEntry.svelte @@ -2,7 +2,8 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import { page } from '$app/state'; - import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey } from './api.ts'; + import { loginWithLegacyPasskey, loginWithPasskey, registerPasskey, logout } from './api.ts'; + import { CLEAR_RETURN_EVENT } from './session-state.ts'; import { BrowserAuth } from './browser-session.svelte.ts'; import { loadRegistrationPolicy } from './hosting.ts'; import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts'; @@ -58,11 +59,16 @@ } } + async function forget() { + await auth.run('Logging out…', 'Logged out.', logout, 'logout'); + } onMount(() => { + const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); }; + window.addEventListener(CLEAR_RETURN_EVENT, clearReturn); void auth.refresh(); void refreshPolicy(); window.addEventListener('focus', refreshPolicy); - return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); }; + return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); window.removeEventListener(CLEAR_RETURN_EVENT, clearReturn); }; }); @@ -74,11 +80,16 @@ {:else if auth.session.kind === 'error'} + {:else if auth.actor}

Logged in as {auth.actor.display_name} @{auth.actor.handle}.

Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'} Open Account {:else} + {#if auth.session.session.kind === 'expired'} +

Your browser session expired. Log in to return to the requested page.

+ + {/if} {#if mode === 'register'} {#if policyLoading}

Checking registration availability…

diff --git a/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte new file mode 100644 index 000000000..e910913e6 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/SessionBoundary.svelte @@ -0,0 +1,71 @@ + + +{#if !protectedPage} + {@render children()} +{:else} + {#if session.kind === 'loading'} +

Loading session…

+ {:else if session.kind === 'error'} +
+ + + Yoi home +
+ {/if} + {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} + + + {/if} +{/if} diff --git a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts index ad24e6937..3cbac0ddf 100644 --- a/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/auth/api.test-fixtures.ts @@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential { }, } as unknown as Credential; } + +export function whoamiPayload(actor: unknown = null) { + return { + actor, + session: actor === null + ? { kind: "anonymous" } + : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }; +} diff --git a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts index f956e48fa..d69840eb2 100644 --- a/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts +++ b/web/workspace/src/lib/workspace/auth/browser-session.svelte.ts @@ -1,16 +1,21 @@ -import { AuthRequestError, loadWhoami } from "./api.ts"; +import { AuthRequestError } from "./api.ts"; +import { + browserSession, + observeBrowserSession, + sessionMutation, +} from "./session-state.ts"; import { notifySessionChanged } from "./hosting.ts"; -import type { RequestActor } from "./model.ts"; +import type { RequestActor, WhoamiResponse } from "./model.ts"; export type BrowserSession = | { kind: "loading" } | { kind: "error" } - | { kind: "ready"; actor: RequestActor | null }; + | ({ kind: "ready" } & WhoamiResponse); export function authErrorMessage(error: unknown): string { if (error instanceof AuthRequestError) { if (error.status === 401) { - return "Your session or authentication challenge expired. Log in again or restart the Passkey operation."; + return "Authentication request was rejected. Check your session or restart the Passkey operation."; } if (error.status === 400 || error.status === 410) { return "The request or authentication challenge is invalid or expired. Restart the operation."; @@ -35,61 +40,77 @@ export class BrowserAuth { busy = $state(false); error = $state(""); status = $state(""); - private request = 0; + private unsubscribe: () => void; private disposed = false; private controller: AbortController | null = null; + constructor() { + observeBrowserSession(); + this.unsubscribe = browserSession.subscribe((snapshot) => { + // 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。 + if (this.busy && snapshot.kind === "loading") return; + if (!this.disposed) { + this.session = snapshot.kind === "ready" + ? { kind: "ready", ...snapshot.value } + : snapshot; + } + }); + } + get actor(): RequestActor | null { return this.session.kind === "ready" ? this.session.actor : null; } async refresh(): Promise { if (this.busy || this.disposed) return; - await this.readSession(); - } - - private async readSession(): Promise { - const current = ++this.request; - this.session = { kind: "loading" }; - try { - const result = await loadWhoami(); - if (this.disposed || current !== this.request) return; - this.session = { kind: "ready", actor: result.actor }; - } catch { - if (this.disposed || current !== this.request) return; - this.session = { kind: "error" }; - } + await browserSession.refresh(); } async run( pending: string, success: string, operation: (signal: AbortSignal) => Promise, + intent: "authentication" | "logout" = "authentication", ): Promise { - if (this.busy || this.disposed || this.session.kind !== "ready") { + if ( + this.busy || this.disposed || + (intent !== "logout" && this.session.kind !== "ready") + ) { return false; } this.busy = true; - ++this.request; + sessionMutation(intent); + const authenticationGeneration = browserSession.authenticationGeneration; const controller = new AbortController(); this.controller = controller; this.error = ""; this.status = pending; try { await operation(controller.signal); - if (this.disposed) return false; + const superseded = + authenticationGeneration !== browserSession.authenticationGeneration; + sessionMutation(intent); + const confirmationGeneration = browserSession.authenticationGeneration; + await browserSession.refresh(); + if ( + this.disposed || superseded || + confirmationGeneration !== browserSession.authenticationGeneration + ) { + if (!this.disposed) this.status = ""; + return false; + } notifySessionChanged(); - await this.readSession(); - if (this.disposed) return false; const confirmed = this.session.kind === "ready"; this.status = confirmed ? success : ""; return confirmed; } catch (error) { - if (this.disposed) return false; - this.error = authErrorMessage(error); - this.status = ""; + if (!this.disposed) { + this.error = authErrorMessage(error); + this.status = ""; + } // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 - await this.readSession(); + sessionMutation(intent); + await browserSession.refresh(); return false; } finally { if (!this.disposed) { @@ -101,7 +122,7 @@ export class BrowserAuth { dispose(): void { this.disposed = true; - ++this.request; + this.unsubscribe(); this.controller?.abort(); } } diff --git a/web/workspace/src/lib/workspace/auth/model.test.ts b/web/workspace/src/lib/workspace/auth/model.test.ts index 177db4b8f..4c17c2df3 100644 --- a/web/workspace/src/lib/workspace/auth/model.test.ts +++ b/web/workspace/src/lib/workspace/auth/model.test.ts @@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", () }); Deno.test("whoami rejects unknown auth methods and extra fields", () => { - assertEquals(parseWhoamiResponse({ actor: null }), { actor: null }); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), + { actor: null, session: { kind: "anonymous" } }, + ); assertThrows( () => parseWhoamiResponse({ + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, actor: { user_id: "user-1", account_id: "account-1", @@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", () "unknown credential type", ); }); + +Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", () => { + const actor = { + user_id: "u", + account_id: "a", + handle: "alice", + display_name: "Alice", + auth_method: "browser_session", + }; + for ( + const payload of [ + { actor, session: { kind: "anonymous" } }, + { actor, session: { kind: "expired" } }, + { + actor: null, + session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, + }, + { actor, session: { kind: "authenticated", expires_at: null } }, + { actor, session: { kind: "authenticated", expires_at: "tomorrow" } }, + { actor: null, session: { kind: "future" } }, + { + actor: null, + session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, + }, + ] + ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); + assertEquals( + parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), + { actor: null, session: { kind: "expired" } }, + ); +}); diff --git a/web/workspace/src/lib/workspace/auth/model.ts b/web/workspace/src/lib/workspace/auth/model.ts index a52b2e5a4..5923f32b8 100644 --- a/web/workspace/src/lib/workspace/auth/model.ts +++ b/web/workspace/src/lib/workspace/auth/model.ts @@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor { export function parseWhoamiResponse(value: unknown): WhoamiResponse { const response = asRecord(value, "whoami"); - requireExactKeys(response, "whoami", ["actor"]); - return { - actor: response.actor === null + requireExactKeys(response, "whoami", ["actor", "session"]); + const actor = response.actor === null + ? null + : parseActor(response.actor, "whoami.actor"); + const session = asRecord(response.session, "whoami.session"); + if (session.kind === "authenticated") { + requireExactKeys(session, "whoami.session", ["kind", "expires_at"]); + if (actor === null) { + invalid("whoami", "authenticated session requires actor"); + } + const expiry = session.expires_at === null ? null - : parseActor(response.actor, "whoami.actor"), - }; + : boundedString(session.expires_at, "whoami.session.expires_at"); + if ( + (expiry !== null && + (!/^\d{4}-\d\d-\d\dT/.test(expiry) || + !Number.isFinite(Date.parse(expiry)))) || + (actor.auth_method === "browser_session" && expiry === null) + ) { + invalid( + "whoami.session.expires_at", + "must identify the server session expiry", + ); + } + return { actor, session: { kind: "authenticated", expires_at: expiry } }; + } + requireExactKeys(session, "whoami.session", ["kind"]); + if ( + actor !== null || + (session.kind !== "anonymous" && session.kind !== "expired") + ) { + invalid("whoami", "inconsistent actor or session state"); + } + return { actor: null, session: { kind: session.kind } }; } export function parseAuthPublicConfig(value: unknown): AuthPublicConfig { diff --git a/web/workspace/src/lib/workspace/auth/page-session.ts b/web/workspace/src/lib/workspace/auth/page-session.ts new file mode 100644 index 000000000..4ea824ddc --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/page-session.ts @@ -0,0 +1,28 @@ +import { error, redirect } from "@sveltejs/kit"; +import { browserSession, observeBrowserSession } from "./session-state.ts"; +import { isPublicPage, loginUrl } from "./routes.ts"; + +// root loadとそのparentを待つ各loaderが同じ確認を共有する。 +// SSRは無効。fragmentはHTTP/Serverに依存せず、browserの最終URLから回収する。 +export async function requirePageSession(url: URL): Promise { + observeBrowserSession(); + if (isPublicPage(url.pathname)) return; + const snapshot = await browserSession.refresh(); + if (snapshot !== browserSession.snapshot) return requirePageSession(url); + if (snapshot.kind !== "ready") { + error( + 503, + "Your session could not be loaded. Login status is unknown. Retry this page.", + ); + } + if (snapshot.value.session.kind === "authenticated") return; + if (snapshot.value.session.kind === "anonymous") redirect(307, "/"); + const target = new URL(url); + if ( + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ) { + target.hash = location.hash; + } + redirect(307, loginUrl(target.pathname + target.search + target.hash)); +} diff --git a/web/workspace/src/lib/workspace/auth/routes.test.ts b/web/workspace/src/lib/workspace/auth/routes.test.ts index c80b69926..feee08cd7 100644 --- a/web/workspace/src/lib/workspace/auth/routes.test.ts +++ b/web/workspace/src/lib/workspace/auth/routes.test.ts @@ -12,16 +12,16 @@ function assertEquals( } import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; -Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => { +Deno.test("return destination preserves query and fragment only on known app screens", () => { for ( const [input, expected] of [ ["/", "/dashboard"], - ["/dashboard?query=discard#fragment", "/dashboard"], + ["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"], ["/account", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"], [ "/w/workspace-id/tickets/T-123?filter=all#entry", - "/w/workspace-id/tickets/T-123", + "/w/workspace-id/tickets/T-123?filter=all#entry", ], [ "/login/device?user_code=ABCD-EFGH", @@ -31,7 +31,7 @@ Deno.test("return destination permits known app screens without forwarding arbit "/login/device?code=abcd-1234&returnTo=//evil.invalid", "/login/device?user_code=abcd-1234", ], - ["/login/device?user_code=%252f%252fevil.invalid", "/login/device"], + ["/login/device?user_code=%252f%252fevil.invalid", "/dashboard"], ] ) assertEquals(safeReturnTo(input), expected, input); }); @@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic "/account\t", "/account\u0000", "/account%00", + "/account?q=%255c", + "/account?q=%0A", + "/account#%5c", + "/account?q=%ZZ", + "/account?q=%252f%252fevil.invalid", "/api/auth/logout", "/hosting/setup", "/not-an-app-route", @@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth "/login?returnTo=%2Fdashboard", ); }); + +Deno.test("return destination accepts canonical UTF-8 resource slugs but not encoded ASCII or delimiters", () => { + const target = `/w/team/tickets/T-123-${ + encodeURIComponent("未ログイン") + }?view=comments#entry-2`; + assertEquals(safeReturnTo(target), target); + for ( + const [input, expected] of [ + [ + "/w/team/tickets?filter=100%25#entry", + "/w/team/tickets?filter=100%25#entry", + ], + [ + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + "/w/team/tickets/%e6%9c%aa?filter=100%25#part%25", + ], + [ + "/w/team/tickets/未?query=a%2fb#entry%2f2", + "/w/team/tickets/%E6%9C%AA?query=a%2fb#entry%2f2", + ], + ] + ) { + assertEquals(safeReturnTo(input), expected); + } + for ( + const input of [ + "/w/team/tickets/%54-123", + "/w/team/tickets/T-123%2fother", + "/w/team/tickets/%E0%80%AF", + "/w/team/tickets/%252f", + ] + ) { + assertEquals(safeReturnTo(input), "/dashboard"); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/routes.ts b/web/workspace/src/lib/workspace/auth/routes.ts index 595f8d44e..5324ee5a6 100644 --- a/web/workspace/src/lib/workspace/auth/routes.ts +++ b/web/workspace/src/lib/workspace/auth/routes.ts @@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string { } const rawPath = value.split(/[?#]/, 1)[0]; if ( - rawPath.includes("%") || rawPath.includes("//") || + /%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") || rawPath.split("/").some((part) => part === "." || part === "..") ) { return AUTHENTICATED_HOME; } let url: URL; + let decodedPath: string; try { + decodedPath = decodeURIComponent(rawPath); + // pathは一度だけdecodeして許可画面を判定する。UTF-8 escapeの大小や生Unicodeで + // origin/区切りは変わらないため、再encodeとの文字列一致は求めない。 + // query/fragmentはpathの権限を持たないdata。単独の%25は保持し、次のdecodeで + // 別文字へ変わる二重escapeとcontrol/backslashだけを拒否する。 + const decoded = decodeURIComponent(value); + if ( + /[\\\u0000-\u001f\u007f]/u.test(decoded) || /%[0-9a-f]{2}/i.test(decoded) + ) { + return AUTHENTICATED_HOME; + } url = new URL(value, "https://app.invalid"); } catch { return AUTHENTICATED_HOME; @@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string { } if ( url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || - /^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname) + ["/hosting/settings", "/hosting/accounts"].includes(url.pathname) || + /^\/w\/[a-zA-Z0-9_-]+(?:\/[\p{Letter}\p{Number}_/-]*)?$/u.test(decodedPath) ) { - return url.pathname; + // 認証導線の入れ子は復帰先へ持ち込まない。検索条件とfragmentはURLとして保持する。 + if (url.searchParams.has("returnTo")) return url.pathname; + return url.pathname + url.search + url.hash; } return AUTHENTICATED_HOME; } @@ -53,7 +68,29 @@ export function authEntryUrl( })}`; } +export function isPublicPage(path: string): boolean { + return [ + "/", + "/login", + "/register", + "/activate", + "/login/device", + "/hosting/setup", + ] + .includes(path); +} + export function isAuthPage(path: string): boolean { return ["/login", "/register", "/account", "/activate", "/login/device"] .includes(path); } + +// Universal loadのURLにfragmentがなくても同じ表示中URLならbrowserから補完する。 +export function browserUrlSuffix(url: URL): string { + const hash = + typeof location !== "undefined" && location.pathname === url.pathname && + location.search === url.search + ? location.hash + : url.hash; + return url.search + hash; +} diff --git a/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts new file mode 100644 index 000000000..19bf093ef --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-boundary.browser.spec.ts @@ -0,0 +1,121 @@ +// @vitest-environment happy-dom +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import { createRawSnippet } from "svelte"; +import SessionBoundary from "./SessionBoundary.svelte"; +import { browserSession } from "./session-state.ts"; +import { authUser, whoamiPayload } from "./api.test-fixtures.ts"; +import { goto } from "$app/navigation"; +const page = vi.hoisted(() => ({ + url: new URL("http://localhost:3000/w/team/tickets?q=mine#entry"), +})); +vi.mock("$app/state", () => ({ page })); +vi.mock( + "$app/navigation", + () => ({ goto: vi.fn(async () => {}), invalidate: vi.fn(async () => {}) }), +); +afterEach(() => { + cleanup(); + browserSession.invalidate(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + vi.clearAllMocks(); +}); +const actor = { ...authUser, auth_method: "browser_session" }; +const children = createRawSnippet(() => ({ + render: () => "

Private fragment

", +})); +async function admitted() { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + vi.stubGlobal( + "fetch", + vi.fn(async () => Response.json(whoamiPayload(actor))), + ); + await browserSession.refresh(); + render(SessionBoundary, { children }); + await screen.findByText("Private fragment"); +} + +test("active expired state removes private content and preserves browser query and fragment", async () => { + await admitted(); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json({ actor: null, session: { kind: "expired" } }) + ), + ); + await browserSession.refresh(); + await waitFor(() => + expect(screen.queryByText("Private fragment")).toBeNull() + ); + expect(goto).toHaveBeenCalledWith( + "/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry", + { replaceState: true }, + ); +}); + +test("unknown state hides and inerts admitted content instead of declaring anonymous", async () => { + await admitted(); + let release!: (response: Response) => void; + vi.stubGlobal( + "fetch", + vi.fn(() => + new Promise((resolve) => { + release = resolve; + }) + ), + ); + const checking = browserSession.refresh(); + await screen.findByText("Loading session…"); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + release(new Response(null, { status: 503 })); + await checking; + await screen.findByText( + "Your session could not be loaded. Login status is unknown.", + ); + expect(screen.getByText("Private fragment").closest("[hidden][inert]")) + .toBeTruthy(); + expect(goto).not.toHaveBeenCalled(); +}); + +test("another tab changing actor cannot reveal content loaded for the old actor", async () => { + await admitted(); + const reload = vi.spyOn(location, "reload").mockImplementation(() => {}); + vi.stubGlobal( + "fetch", + vi.fn(async () => + Response.json( + whoamiPayload({ + ...actor, + user_id: "different-user", + account_id: "different-account", + }), + ) + ), + ); + await browserSession.refresh(); + await waitFor(() => expect(reload).toHaveBeenCalledOnce()); + expect(screen.queryByText("Private fragment")).toBeNull(); +}); + +test("a child mount session recheck does not unmount and restart its own loader", async () => { + window.history.replaceState({}, "", "/w/team/tickets?q=mine#entry"); + const fetcher = vi.fn(async () => Response.json(whoamiPayload(actor))); + vi.stubGlobal("fetch", fetcher); + await browserSession.refresh(); + let mounts = 0; + const checkingChild = createRawSnippet(() => ({ + render: () => "

Mounted session reader

", + setup: () => { + ++mounts; + void browserSession.refresh(); + }, + })); + render(SessionBoundary, { children: checkingChild }); + await waitFor(() => expect(browserSession.snapshot.kind).toBe("ready")); + expect(screen.getByText("Mounted session reader").closest("[hidden]")) + .toBeNull(); + expect(mounts).toBe(1); + expect(fetcher).toHaveBeenCalledTimes(2); +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts new file mode 100644 index 000000000..5ef8ddf6a --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.browser.spec.ts @@ -0,0 +1,102 @@ +// @vitest-environment happy-dom +// whoami境界だけを代替し、epoch競合/再確認契機を決定的に検査する。 +// cookieと実storeの一致はServer/browser結合suiteが所有する。 +import { afterEach, expect, test, vi } from "vitest"; +import { SessionState } from "./session-state.ts"; +import type { WhoamiResponse } from "./model.ts"; +import { authUser } from "./api.test-fixtures.ts"; + +const states: SessionState[] = []; +afterEach(() => { + for (const state of states.splice(0)) state.dispose(); + vi.useRealTimers(); + localStorage.clear(); +}); +const anonymous: WhoamiResponse = { + actor: null, + session: { kind: "anonymous" }, +}; +const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; +const authenticated = ( + expires_at: string | null = "2099-01-01T00:00:00Z", +): WhoamiResponse => ({ + actor: { ...authUser, auth_method: "browser_session" }, + session: { kind: "authenticated", expires_at }, +}); +function gate() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function state(read: () => Promise) { + const result = new SessionState(read); + states.push(result); + return result; +} + +test("concurrent session checks share one authoritative read without classifying loading as anonymous", async () => { + const response = gate(); + const read = vi.fn(() => response.promise); + const session = state(read); + const first = session.refresh(); + const second = session.refresh(); + expect(session.snapshot.kind).toBe("loading"); + expect(read).toHaveBeenCalledTimes(1); + response.resolve(expired); + await Promise.all([first, second]); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("delayed expired response cannot replace a new login in the same tab", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + session.invalidate(); + await session.refresh(); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("storage generation fences responses even before the other tab storage event is delivered", async () => { + const older = gate(); + const read = vi.fn<() => Promise>().mockReturnValueOnce( + older.promise, + ).mockResolvedValue(authenticated()); + const session = state(read); + const first = session.refresh(); + localStorage.setItem( + "yoi.browser-auth-generation", + JSON.stringify({ id: "new-login", reason: "authentication" }), + ); + older.resolve(expired); + await first; + expect(session.snapshot).toEqual({ kind: "ready", value: authenticated() }); +}); + +test("active deadline rechecks the server and reports failures as unknown, never inferred expiry", async () => { + vi.useFakeTimers(); + const read = vi.fn<() => Promise>().mockResolvedValueOnce( + authenticated(new Date(Date.now() + 5_000).toISOString()), + ).mockRejectedValueOnce(new Error("network")).mockResolvedValue(expired); + const session = state(read); + await session.refresh(); + await vi.advanceTimersByTimeAsync(5_000); + expect(session.snapshot).toEqual({ kind: "error" }); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value: expired }); +}); + +test("a challenge failure is not session expiry; recheck may still be authenticated or anonymous", async () => { + for (const value of [authenticated(), anonymous]) { + const session = state(async () => value); + session.invalidate(); + await session.refresh(); + expect(session.snapshot).toEqual({ kind: "ready", value }); + } +}); diff --git a/web/workspace/src/lib/workspace/auth/session-state.ts b/web/workspace/src/lib/workspace/auth/session-state.ts new file mode 100644 index 000000000..2ff372940 --- /dev/null +++ b/web/workspace/src/lib/workspace/auth/session-state.ts @@ -0,0 +1,153 @@ +/// +import { loadWhoami } from "./api.ts"; +import type { WhoamiResponse } from "./model.ts"; + +export type SessionSnapshot = + | { kind: "loading" } + | { kind: "error" } + | { kind: "ready"; value: WhoamiResponse }; + +// 世代は権限ではなく、認証操作より前に開始した応答を捨てるためだけに使う。 +// Cookieの実状態は常にwhoamiが所有し、tab間通知や一般401からexpiredを推測しない。 +const GENERATION_KEY = "yoi.browser-auth-generation"; +export class SessionState { + snapshot: SessionSnapshot = { kind: "loading" }; + private generation = 0; + private pending: Promise | null = null; + private listeners = new Set<(state: SessionSnapshot) => void>(); + private timer: ReturnType | undefined; + + get authenticationGeneration(): number { + return this.generation; + } + + constructor(private readonly read = loadWhoami) {} + + subscribe(listener: (state: SessionSnapshot) => void): () => void { + this.listeners.add(listener); + listener(this.snapshot); + return () => this.listeners.delete(listener); + } + + private publish(snapshot: SessionSnapshot): void { + this.snapshot = snapshot; + clearTimeout(this.timer); + if ( + snapshot.kind === "ready" && + snapshot.value.session.kind === "authenticated" + ) { + const expiry = snapshot.value.session.expires_at; + if (expiry !== null) { + // Timerは再照会の契機。local clockだけで期限切れと断定しない。 + const remaining = Date.parse(expiry) - Date.now(); + this.timer = setTimeout(() => { + void this.refresh(); + }, Math.min(2_147_483_647, Math.max(1_000, remaining))); + } + } + for (const listener of this.listeners) listener(snapshot); + } + + invalidate(): void { + ++this.generation; + this.pending = null; + this.publish({ kind: "loading" }); + } + + async refresh(): Promise { + if (this.pending) return this.pending; + const generation = this.generation; + const stamp = storageStamp(); + this.publish({ kind: "loading" }); + const pending = (async () => { + let result: SessionSnapshot; + try { + result = { kind: "ready", value: await this.read() }; + } catch { + result = { kind: "error" }; + } + if (generation !== this.generation || stamp !== storageStamp()) { + // 新世代が既に確定していれば再取得すら不要。古い完了で確定結果を巻き戻さない。 + if (generation !== this.generation) { + if (this.pending) return await this.pending; + if (this.snapshot.kind !== "loading") return this.snapshot; + } + // 別tabの操作がstorage eventより先に完了していても古い応答を採用しない。 + if (generation === this.generation) this.invalidate(); + return await this.refresh(); + } + this.pending = null; + this.publish(result); + return result; + })(); + this.pending = pending; + return pending; + } + + dispose(): void { + clearTimeout(this.timer); + this.listeners.clear(); + ++this.generation; + } +} + +function storageStamp(): string | null { + try { + return globalThis.localStorage?.getItem(GENERATION_KEY) ?? null; + } catch { + return null; + } +} + +export const browserSession = new SessionState(); + +export function sessionMutation( + reason: "authentication" | "logout" = "authentication", +): void { + browserSession.invalidate(); + const message = JSON.stringify({ id: crypto.randomUUID(), reason }); + try { + localStorage.setItem(GENERATION_KEY, message); + } catch { /* 保存拒否でも同tab fenceは維持。 */ } + if (reason === "logout") clearReturnTo(); +} + +export const CLEAR_RETURN_EVENT = "yoi:clear-auth-return"; +function clearReturnTo(): void { + if (typeof window !== "undefined") { + window.dispatchEvent(new Event(CLEAR_RETURN_EVENT)); + } +} + +let installed = false; +export function recheckAfterUnauthorized( + authenticationGeneration: number, +): void { + if ( + installed && + authenticationGeneration === browserSession.authenticationGeneration + ) void browserSession.refresh(); +} +export function observeBrowserSession(): void { + if (installed || typeof window === "undefined") return; + installed = true; + const refresh = () => { + void browserSession.refresh(); + }; + window.addEventListener("focus", refresh); + window.addEventListener("pageshow", refresh); + document.addEventListener("visibilitychange", () => { + if (!document.hidden) refresh(); + }); + window.addEventListener("storage", (event) => { + if (event.key !== GENERATION_KEY && event.key !== null) return; + browserSession.invalidate(); + try { + if ( + event.key === null || + JSON.parse(event.newValue ?? "null")?.reason === "logout" + ) clearReturnTo(); + } catch { /* 不明な通知も権限ではなく再照会だけ。 */ } + refresh(); + }); +} diff --git a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts index b892e0086..559b97782 100644 --- a/web/workspace/src/lib/workspace/auth/session.browser.spec.ts +++ b/web/workspace/src/lib/workspace/auth/session.browser.spec.ts @@ -1,5 +1,6 @@ // @vitest-environment happy-dom import { afterEach, expect, test, vi } from "vitest"; +import { browserSession, sessionMutation } from "./session-state.ts"; import { BrowserAuth } from "./browser-session.svelte.ts"; import { addPasskey, loginWithPasskey, logout } from "./api.ts"; import { SESSION_CHANGED_EVENT } from "./hosting.ts"; @@ -13,6 +14,7 @@ import { const sessions: BrowserAuth[] = []; afterEach(() => { for (const auth of sessions.splice(0)) auth.dispose(); + browserSession.invalidate(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -33,6 +35,9 @@ function session() { function whoami(authenticated: boolean) { return Response.json({ + session: authenticated + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: authenticated ? { ...authUser, auth_method: "browser_session" } : null, @@ -98,7 +103,11 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut expect(await auth.run("pending", "done", operation)).toBe(false); expect(operation).not.toHaveBeenCalled(); await auth.refresh(); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); }); for ( @@ -117,6 +126,7 @@ for ( ); const auth = session(); const first = auth.refresh(); + browserSession.invalidate(); const second = auth.refresh(); latest.resolve( latestStatus === 200 @@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async () const oldResponse = gate(); fetcher.mockReturnValueOnce(oldResponse.promise); const oldRefresh = auth.refresh(); - await auth.refresh(); - expect(auth.actor?.handle).toBe("alice"); - expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe( + true, + ); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); oldResponse.resolve(whoami(true)); await oldRefresh; - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Logged out"); }); @@ -190,7 +208,7 @@ test("busy login rejects duplicate operations and refresh until its post-complet options.resolve(Response.json(loginOptions)); await confirmingStarted.promise; expect(auth.busy).toBe(true); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); await auth.refresh(); expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) @@ -220,7 +238,7 @@ for (const status of [400, 401, 410]) { expect(auth.status).toBe(""); expect(auth.error).toMatch( status === 401 - ? /Your session or authentication challenge expired/ + ? /Authentication request was rejected/ : /The request or authentication challenge is invalid or expired/, ); expect(auth.session.kind).toBe("ready"); @@ -270,7 +288,11 @@ for ( expect(auth.error).not.toContain("secret credential detail"); expect(auth.status).toBe(""); expect(auth.busy).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", @@ -338,7 +360,7 @@ for (const rejected of [false, true]) { }); } -test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => { +test("dispose aborts WebAuthn without accepting late credentials, while shared session is rechecked", async () => { const { fetcher, get } = boundary(); const auth = session(); await auth.refresh(); @@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion, expect(signal.aborted).toBe(true); credential.resolve(credentialFixture(true)); expect(await run).toBe(false); - expect(auth.session).toEqual({ kind: "ready", actor: null }); + expect(auth.session).toEqual({ + kind: "ready", + actor: null, + session: { kind: "anonymous" }, + }); expect(auth.status).toBe("Waiting"); expect(auth.error).toBe(""); expect(paths(fetcher)).toEqual([ "/api/auth/whoami", "/api/auth/passkeys/login/options", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -409,6 +436,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not "/api/auth/whoami", "/api/auth/passkeys/login/options", "/api/auth/passkeys/login/complete", + "/api/auth/whoami", ]); expect( dispatch.mock.calls.filter(([event]) => @@ -443,15 +471,73 @@ test("dispose during the post-operation whoami prevents its late actor from beco (signal) => loginWithPasskey(signal), ); await started.promise; - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.busy).toBe(true); auth.dispose(); response.resolve(whoami(true)); expect(await run).toBe(false); - expect(auth.session.kind).toBe("loading"); + expect(auth.session.kind).toBe("ready"); expect(auth.actor).toBeNull(); }); +for (const newer of ["logout", "another-actor"] as const) { + test(`confirmation superseded by ${newer} cannot report success or navigate using the old return URL`, async () => { + const { fetcher } = boundary(); + const auth = session(); + await auth.refresh(); + const confirming = gate(); + const started = gate(); + const request = fetcher.getMockImplementation()!; + fetcher.mockImplementation((input, init) => { + if (String(input) === "/api/auth/whoami") { + started.resolve(); + return confirming.promise; + } + return request(input, init); + }); + const changed = vi.fn(); + window.addEventListener(SESSION_CHANGED_EVENT, changed); + const navigate = vi.fn(); + try { + const run = auth.run("Waiting", "Logged in", loginWithPasskey).then( + (confirmed) => { + if (confirmed && auth.actor) navigate("/account?old=return#entry"); + return confirmed; + }, + ); + await started.promise; + sessionMutation(newer === "logout" ? "logout" : "authentication"); + fetcher.mockResolvedValueOnce( + newer === "logout" ? whoami(false) : Response.json({ + actor: { + ...authUser, + user_id: "other-user", + account_id: "other-account", + handle: "bob", + auth_method: "browser_session", + }, + session: { + kind: "authenticated", + expires_at: "2099-01-01T00:00:00Z", + }, + }), + ); + await browserSession.refresh(); + confirming.resolve(whoami(true)); + expect(await run).toBe(false); + expect(auth.actor?.handle ?? null).toBe( + newer === "logout" ? null : "bob", + ); + expect(auth.status).toBe(""); + expect(auth.busy).toBe(false); + expect(changed).not.toHaveBeenCalled(); + expect(navigate).not.toHaveBeenCalled(); + } finally { + window.removeEventListener(SESSION_CHANGED_EVENT, changed); + } + }); +} + for (const purpose of ["login", "addition"] as const) { test(`${purpose} null credential reports cancellation and never sends completion`, async () => { const { fetcher, get, create } = boundary(purpose === "addition"); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.test.ts b/web/workspace/src/lib/workspace/navigation/deferred.test.ts index 238567212..63b3c85cc 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.test.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.test.ts @@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation const api = gate(); const event = { params: { id: "first" }, + parent: async () => ({}), url: new URL("https://example.test/first?q=old"), }; const load = deferPage(async ({ params, url }: typeof event) => ({ @@ -54,9 +55,12 @@ Deno.test("deferred redirects and HTTP failures are handled results, not unhandl ], ] as const ) { - const load = deferPage((_event: { params: {}; url: URL }) => run()); + const load = deferPage(( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => run()); const result = await load({ params: {}, + parent: async () => ({}), url: new URL("https://example.test/"), }).deferredPage; assert( @@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global result.data.workspaceId === "team", ); }); + +Deno.test("private deferred loader starts only after parent admission and never on parent failure", async () => { + for (const outcome of ["admit", "anonymous", "expired", "error"] as const) { + const admission = gate(); + let calls = 0; + const load = deferPage( + async ( + _event: { params: {}; url: URL; parent: () => Promise }, + ) => { + ++calls; + return {}; + }, + ); + const result = load({ + params: {}, + url: new URL("https://app.invalid/w/a/tickets"), + parent: async () => { + await admission.promise; + if (outcome === "anonymous") redirect(307, "/"); + if (outcome === "expired") { + redirect(307, "/login?returnTo=%2Fw%2Fa%2Ftickets"); + } + if (outcome === "error") { + error(503, "Unknown session"); + } + return {}; + }, + }); + await Promise.resolve(); + assert(calls === 0); + admission.release(); + const settled = await result.deferredPage; + assert(calls === (outcome === "admit" ? 1 : 0)); + assert(settled.ok === (outcome === "admit")); + } +}); diff --git a/web/workspace/src/lib/workspace/navigation/deferred.ts b/web/workspace/src/lib/workspace/navigation/deferred.ts index c3baaf145..6e71ab849 100644 --- a/web/workspace/src/lib/workspace/navigation/deferred.ts +++ b/web/workspace/src/lib/workspace/navigation/deferred.ts @@ -39,7 +39,11 @@ export async function settle( } export function deferPage< - E extends { params: Record; url: URL }, + E extends { + params: Record; + url: URL; + parent: () => Promise; + }, T extends object, >( loader: (event: E) => T | Promise, @@ -51,11 +55,13 @@ export function deferPage< params: { ...event.params }, url: new URL(event.url.href), }; - try { - return { deferredPage: settle(loader(snapshot)) }; - } catch (error) { - return { deferredPage: settle(Promise.reject(error)) }; - } + // SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。 + return { + deferredPage: settle((async () => { + await snapshot.parent(); + return await loader(snapshot); + })()), + }; }; } diff --git a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts index 905d3fe88..695e5841f 100644 --- a/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts +++ b/web/workspace/src/lib/workspace/sidebar/hosting-navigation.browser.spec.ts @@ -18,6 +18,9 @@ for ( const fetcher = vi.fn().mockImplementation(async (input) => { if (String(input) === "/api/auth/whoami") { return Response.json({ + session: actor + ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } + : { kind: "anonymous" }, actor: actor ? { ...authUser, auth_method: "browser_session" } : null, }); } diff --git a/web/workspace/src/routes/+layout.svelte b/web/workspace/src/routes/+layout.svelte index 401c24047..bbaab7c93 100644 --- a/web/workspace/src/routes/+layout.svelte +++ b/web/workspace/src/routes/+layout.svelte @@ -4,6 +4,7 @@ // navigationの構成はWorkspace identityやpermissionの根拠にはしない。 // Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 + import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte'; import { page } from '$app/state'; import { setContext } from 'svelte'; import { MediaQuery } from 'svelte/reactivity'; @@ -96,6 +97,7 @@ }); + {#if standalone}
{@render children()} @@ -147,6 +149,7 @@
{/if} +