feat(auth): separate anonymous entry from expired session recovery

This commit is contained in:
2026-10-20 03:52:48 +09:00
parent e5749b9be0
commit bfeee487fd
66 changed files with 3672 additions and 429 deletions
Generated
+2
View File
@@ -6705,6 +6705,7 @@ dependencies = [
name = "yoi-e2e" name = "yoi-e2e"
version = "0.0.0" version = "0.0.0"
dependencies = [ dependencies = [
"chrono",
"libc", "libc",
"reqwest", "reqwest",
"serde", "serde",
@@ -6714,6 +6715,7 @@ dependencies = [
"tempfile", "tempfile",
"webauthn-authenticator-rs", "webauthn-authenticator-rs",
"webauthn-rs-proto", "webauthn-rs-proto",
"yoi-workspace-server",
] ]
[[package]] [[package]]
+39 -1
View File
@@ -4025,6 +4025,17 @@ impl RequestActor {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct WhoamiResponse { pub struct WhoamiResponse {
pub actor: Option<RequestActor>, pub actor: Option<RequestActor>,
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<String> },
Expired {},
} }
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -11533,6 +11544,7 @@ pub fn auth_api_typescript() -> String {
ActorAuthMethod::decl(&config), ActorAuthMethod::decl(&config),
AuthenticatedUser::decl(&config), AuthenticatedUser::decl(&config),
RequestActor::decl(&config), RequestActor::decl(&config),
BrowserSessionState::decl(&config),
WhoamiResponse::decl(&config), WhoamiResponse::decl(&config),
AuthBootstrapUserRequest::decl(&config), AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config), AuthUserResponse::decl(&config),
@@ -13961,7 +13973,8 @@ mod tests {
"handle": "hare", "handle": "hare",
"display_name": "Hare", "display_name": "Hare",
"auth_method": "browser_session" "auth_method": "browser_session"
} },
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
}); });
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone()) let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO"); .expect("server whoami fixture should match shared DTO");
@@ -14143,11 +14156,36 @@ mod tests {
let unexpected_field = serde_json::json!({ let unexpected_field = serde_json::json!({
"actor": null, "actor": null,
"session": { "kind": "anonymous" },
"access_token": "must-not-be-accepted" "access_token": "must-not-be-accepted"
}); });
assert!(serde_json::from_value::<WhoamiResponse>(unexpected_field).is_err()); assert!(serde_json::from_value::<WhoamiResponse>(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::<WhoamiResponse>(
serde_json::json!({"actor":null, "session":session})
)
.is_err()
);
}
}
#[test] #[test]
fn device_login_debug_output_redacts_secret_material() { fn device_login_debug_output_redacts_secret_material() {
let start = DeviceLoginStartResponse { let start = DeviceLoginStartResponse {
+366 -8
View File
@@ -91,11 +91,38 @@ pub fn parse_bearer(headers: &HeaderMap) -> Option<String> {
} }
pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> { pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> {
let cookie_header = headers.get(header::COOKIE)?.to_str().ok()?; unique_cookie(headers, cookie_name)
cookie_header.split(';').find_map(|pair| { .ok()
let (name, value) = pair.trim().split_once('=')?; .flatten()
(name == cookie_name && !value.is_empty()).then(|| value.to_string()) .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<Option<String>, ()> {
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( pub async fn resolve_request_actor(
@@ -121,6 +148,14 @@ pub(crate) fn resolve_request_actor_sync(
bearer: Option<String>, bearer: Option<String>,
session: Option<String>, session: Option<String>,
) -> Result<Option<RequestActor>> { ) -> Result<Option<RequestActor>> {
Ok(resolve_credentials_sync(store, bearer, session)?.map(|(actor, _)| actor))
}
fn resolve_credentials_sync(
store: &dyn ControlPlaneStore,
bearer: Option<String>,
session: Option<String>,
) -> Result<Option<(RequestActor, Option<String>)>> {
if let Some(token) = bearer { if let Some(token) = bearer {
let token_hash = token_hash(&token); let token_hash = token_hash(&token);
if let Some(api_token) = store.resolve_api_token(&token_hash)? { 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); return Ok(None);
} }
store.mark_api_token_used(&token_hash, &now_rfc3339())?; 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) { if is_expired(&session.expires_at) {
return Ok(None); 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) 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<dyn ControlPlaneStore>,
headers: &HeaderMap,
cookie_name: &str,
) -> Result<server_api::WhoamiResponse> {
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<String>,
session_token: Option<String>,
marker: Option<String>,
) -> Result<server_api::WhoamiResponse> {
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<Utc>) -> 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<S: ControlPlaneStore + ?Sized>( fn actor_for_user<S: ControlPlaneStore + ?Sized>(
store: &S, store: &S,
user_id: &str, user_id: &str,
@@ -274,6 +394,13 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(actor.user_id, "user-auth"); assert_eq!(actor.user_id, "user-auth");
assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); 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!( assert!(
store store
.resolve_api_token(&hash) .resolve_api_token(&hash)
@@ -363,20 +490,119 @@ mod tests {
assert!(futures::poll!(queued.as_mut()).is_pending()); assert!(futures::poll!(queued.as_mut()).is_pending());
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); 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( let result = tokio::time::timeout(
std::time::Duration::from_secs(1), std::time::Duration::from_secs(1),
resolve_request_actor(store, &headers, "session"), resolve_whoami(store, &headers, "session"),
) )
.await; .await;
release.send(()).unwrap(); release.send(()).unwrap();
blocker.await.unwrap().unwrap(); blocker.await.unwrap().unwrap();
queued.await.unwrap(); queued.await.unwrap();
assert!(matches!(
actor_result.expect("actor lookup rejects capacity"),
Err(Error::DbCapacityExceeded)
));
assert!(matches!( assert!(matches!(
result.expect("capacity rejection must not wait for queued SQL"), result.expect("capacity rejection must not wait for queued SQL"),
Err(Error::DbCapacityExceeded) 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] #[test]
fn user_code_uses_the_uuid_random_tail() { fn user_code_uses_the_uuid_random_tail() {
let id = Uuid::parse_str("01234567-89ab-7cde-8123-456789abcdef").expect("UUID"); 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()); 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()
);
}
} }
+257 -6
View File
@@ -6856,6 +6856,7 @@ async fn attach_server_request_context(
} }
fn generated_auth_contract_router(service: ServerApiContractService) -> Router { fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
let auth = auth_public_config(&service.auth_api().config);
let service = Arc::new(service); let service = Arc::new(service);
let origin_context = Router::new() let origin_context = Router::new()
.merge(server_api::server_api_axum::auth_activation_options( .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( .merge(server_api::server_api_axum::auth_device_login_approve(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::auth_whoami(service.clone()))
.layer(middleware::from_fn_with_state( .layer(middleware::from_fn_with_state(
service.as_ref().clone(), service.as_ref().clone(),
attach_server_request_context, attach_server_request_context,
@@ -6908,12 +6908,20 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::auth_logout(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( .merge(server_api::server_api_axum::auth_device_login_start(
service.clone(), service.clone(),
)) ))
.merge(server_api::server_api_axum::auth_device_login_poll(service)) .merge(server_api::server_api_axum::auth_device_login_poll(service))
.merge(origin_context) .merge(origin_context)
.merge(actor_context) .merge(actor_context)
.layer(middleware::from_fn_with_state(
auth,
append_session_marker_cookie,
))
.layer(middleware::map_response( .layer(middleware::map_response(
|mut response: Response| async move { |mut response: Response| async move {
// Current role/policy and authentication responses must not be cached // 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<AuthPublicConfig>,
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::<i64>().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::<Vec<_>>()
.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 { fn generated_workspace_catalog_contract_router(service: ServerApiContractService) -> Router {
let service = Arc::new(service); let service = Arc::new(service);
Router::new() Router::new()
@@ -8151,9 +8215,15 @@ impl server_api::ServerApi for ServerApiContractService {
&self, &self,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
) -> std::result::Result<WhoamiResponse, server_api::RepositoryApiError> { ) -> std::result::Result<WhoamiResponse, server_api::RepositoryApiError> {
Ok(WhoamiResponse { let headers = contract_request_headers(&context)?;
actor: context.actor, 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( async fn auth_logout(
@@ -30307,14 +30377,14 @@ fn issue_browser_session(
token_hash: token_hash(&session_token), token_hash: token_hash(&session_token),
user_id: user.user_id.clone(), user_id: user.user_id.clone(),
created_at: crate::auth::now_rfc3339(), 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, revoked_at: None,
})?; })?;
let auth = auth_public_config(&api.config); let auth = auth_public_config(&api.config);
let cookie = session_set_cookie( let cookie = session_set_cookie(
session_cookie_policy(&auth), session_cookie_policy(&auth),
&session_token, &session_token,
14 * 24 * 60 * 60, crate::auth::BROWSER_SESSION_SECONDS,
); );
Ok(( Ok((
AuthUserResponse { AuthUserResponse {
@@ -30510,6 +30580,16 @@ fn logout_browser_session(
.store .store
.revoke_browser_session(&token_hash(&session_token), &crate::auth::now_rfc3339())?; .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(( Ok((
LogoutResponse { LogoutResponse {
status: LogoutStatus::LoggedOut, status: LogoutStatus::LoggedOut,
@@ -53509,6 +53589,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(registration_response.status(), StatusCode::OK); assert_eq!(registration_response.status(), StatusCode::OK);
assert_auth_response_has_expiry_marker(registration_response.headers(), &cookie_name);
let registration_cookie = registration_response let registration_cookie = registration_response
.headers() .headers()
.get(axum::http::header::SET_COOKIE) .get(axum::http::header::SET_COOKIE)
@@ -53546,6 +53627,12 @@ mod tests {
.unwrap(), .unwrap(),
) )
.unwrap(); .unwrap();
assert_eq!(registered_whoami["session"]["kind"], "authenticated");
assert!(
registered_whoami["session"]["expires_at"]
.as_str()
.is_some()
);
assert_eq!( assert_eq!(
registered_whoami["actor"]["handle"], registered_whoami["actor"]["handle"],
"generated-passkey-user" "generated-passkey-user"
@@ -53600,6 +53687,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
assert_eq!(login_response.status(), StatusCode::OK); assert_eq!(login_response.status(), StatusCode::OK);
assert_auth_response_has_expiry_marker(login_response.headers(), &cookie_name);
let login_cookie = login_response let login_cookie = login_response
.headers() .headers()
.get(axum::http::header::SET_COOKIE) .get(axum::http::header::SET_COOKIE)
@@ -53684,6 +53772,169 @@ mod tests {
assert!(logged_out_whoami["actor"].is_null()); 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] #[tokio::test]
async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() { async fn browser_session_set_and_clear_cookies_follow_public_https_scheme() {
for (scheme, secure) in [("http", false), ("https", true)] { for (scheme, secure) in [("http", false), ("https", true)] {
+10
View File
@@ -7131,6 +7131,16 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> { fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
self.with_immediate_transaction(|conn| { self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?; 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( conn.execute(
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at) r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
+57 -1
View File
@@ -443,6 +443,56 @@
"additionalProperties": false, "additionalProperties": false,
"type": "object" "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": { "BrowserTicketState": {
"enum": [ "enum": [
"open", "open",
@@ -14846,8 +14896,14 @@
"type": "null" "type": "null"
} }
] ]
},
"session": {
"$ref": "#/components/schemas/BrowserSessionState"
} }
}, },
"required": [
"session"
],
"type": "object" "type": "object"
}, },
"WorkerBusyState": { "WorkerBusyState": {
@@ -17831,7 +17887,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:4e01d7dd0dab015ba3204045da33d7b87c9edc166b7dad464a8ab0d61e0f0e6d" "x-yoi-source-digest": "sha256:3cd508b4b2c8e82c8f185092bada8e8ec3e1d8f8f503a12775f6014765e5b45f"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
+2
View File
@@ -19,6 +19,8 @@ sha2.workspace = true
tempfile.workspace = true tempfile.workspace = true
[dev-dependencies] [dev-dependencies]
chrono = { version = "0.4", default-features = false, features = ["clock"] }
yoi-workspace-server.workspace = true
server-api.workspace = true server-api.workspace = true
webauthn-rs-proto = "0.5.5" webauthn-rs-proto = "0.5.5"
webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] }
+435
View File
@@ -0,0 +1,435 @@
/// <reference lib="dom" />
// 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<string, string> = {
".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<unknown> {
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<string, string | string[] | null>;
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);
+8 -4
View File
@@ -1,11 +1,12 @@
//! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、
//! ID不要login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 //! 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`で明示選択する。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
//! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 //! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 //! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 //! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
use std::error::Error; use std::error::Error;
use std::fs::OpenOptions; use std::fs::OpenOptions;
@@ -29,6 +30,9 @@ mod resident_model;
use resident_model::ResidentAuthenticator; use resident_model::ResidentAuthenticator;
use yoi_e2e::WorkerE2eEnvironment; use yoi_e2e::WorkerE2eEnvironment;
#[path = "auth_passkeys/browser_sessions.rs"]
mod browser_sessions;
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>; type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
const BROWSER_ORIGIN: &str = "http://localhost:5173"; const BROWSER_ORIGIN: &str = "http://localhost:5173";
@@ -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<Value> {
// 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<Child>);
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(())
}
+3 -1
View File
@@ -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 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, }; export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -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 { SKILL_API_LIMITS } from "#lib/generated/skill-api.ts";
import type { import type {
SkillCatalogResponse, SkillCatalogResponse,
@@ -102,7 +106,11 @@ export async function loadJson<T>(
): Promise<ApiResult<T>> { ): Promise<ApiResult<T>> {
let stage: "request" | "decode" | "validate" = "request"; let stage: "request" | "decode" | "validate" = "request";
try { try {
const authenticationGeneration = browserSession.authenticationGeneration;
const response = await fetchFn(path, init); const response = await fetchFn(path, init);
if (response.status === 401) {
recheckAfterUnauthorized(authenticationGeneration);
}
if (!response.ok) { if (!response.ok) {
if (policy) { if (policy) {
await response.body?.cancel(); await response.body?.cancel();
@@ -2,7 +2,8 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/state'; 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 { BrowserAuth } from './browser-session.svelte.ts';
import { loadRegistrationPolicy } from './hosting.ts'; import { loadRegistrationPolicy } from './hosting.ts';
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.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(() => { onMount(() => {
const clearReturn = () => { void goto(page.url.pathname, { replaceState: true }); };
window.addEventListener(CLEAR_RETURN_EVENT, clearReturn);
void auth.refresh(); void auth.refresh();
void refreshPolicy(); void refreshPolicy();
window.addEventListener('focus', 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); };
}); });
</script> </script>
@@ -74,11 +80,16 @@
{:else if auth.session.kind === 'error'} {:else if auth.session.kind === 'error'}
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p> <p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button> <button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
<button type="button" disabled={auth.busy} onclick={forget}>Forget this session</button>
{:else if auth.actor} {:else if auth.actor}
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p> <p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
<a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a> <a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
<a href="/account">Open Account</a> <a href="/account">Open Account</a>
{:else} {:else}
{#if auth.session.session.kind === 'expired'}
<p role="status">Your browser session expired. Log in to return to the requested page.</p>
<button type="button" disabled={auth.busy} onclick={forget}>Forget this session</button>
{/if}
{#if mode === 'register'} {#if mode === 'register'}
{#if policyLoading} {#if policyLoading}
<p role="status" class="muted">Checking registration availability…</p> <p role="status" class="muted">Checking registration availability…</p>
@@ -0,0 +1,71 @@
<script lang="ts">
import { onMount, type Snippet } from 'svelte';
import { goto, invalidate } from '$app/navigation';
import { page } from '$app/state';
import { browserSession, observeBrowserSession, type SessionSnapshot } from './session-state.ts';
import { isPublicPage, loginUrl } from './routes.ts';
import './auth-page.css';
let { children }: { children: Snippet } = $props();
let session = $state<SessionSnapshot>(browserSession.snapshot);
let admitted = $state(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.session.kind === 'authenticated');
const actorKey = $derived(session.kind === 'ready' && session.value.actor
? `${session.value.actor.user_id}:${session.value.actor.account_id}` : null);
let admittedActor = $state<string | null>(browserSession.snapshot.kind === 'ready' && browserSession.snapshot.value.actor
? `${browserSession.snapshot.value.actor.user_id}:${browserSession.snapshot.value.actor.account_id}` : null);
const sameActor = $derived(admittedActor === null || admittedActor === actorKey);
const protectedPage = $derived(!isPublicPage(page.url.pathname));
let navigating = false;
onMount(() => {
observeBrowserSession();
return browserSession.subscribe((snapshot) => {
// 子onMountの再確認より前に入場を記録する。effect後回しだとloadingの度に
// childを再mountし、onMount→whoami→unmountの反復を作ってしまう。
if (snapshot.kind === 'ready' && snapshot.value.session.kind === 'authenticated') admitted = true;
session = snapshot;
});
});
$effect(() => {
if (!protectedPage || session.kind !== 'ready') return;
const state = session.value.session.kind;
if (state === 'authenticated') {
if (!sameActor) {
// 別tabで主体が変わった場合、旧主体で取得したloader/subscriptionを再利用しない。
if (!navigating) { navigating = true; location.reload(); }
return;
}
admittedActor = actorKey;
admitted = true;
return;
}
admitted = false;
if (navigating) return;
navigating = true;
const target = state === 'expired'
? loginUrl(location.pathname + location.search + location.hash) : '/';
void goto(target, { replaceState: true }).finally(() => { navigating = false; });
});
async function retry() {
await browserSession.refresh();
await invalidate('auth:session');
}
</script>
{#if !protectedPage}
{@render children()}
{:else}
{#if session.kind === 'loading'}
<div class="auth-page"><p role="status">Loading session…</p></div>
{:else if session.kind === 'error'}
<div class="auth-page">
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
<button type="button" onclick={retry}>Retry session</button>
<a href="/">Yoi home</a>
</div>
{/if}
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
{/if}
{/if}
@@ -63,3 +63,12 @@ export function credentialFixture(authentication = false): Credential {
}, },
} as unknown as 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" },
};
}
@@ -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 { notifySessionChanged } from "./hosting.ts";
import type { RequestActor } from "./model.ts"; import type { RequestActor, WhoamiResponse } from "./model.ts";
export type BrowserSession = export type BrowserSession =
| { kind: "loading" } | { kind: "loading" }
| { kind: "error" } | { kind: "error" }
| { kind: "ready"; actor: RequestActor | null }; | ({ kind: "ready" } & WhoamiResponse);
export function authErrorMessage(error: unknown): string { export function authErrorMessage(error: unknown): string {
if (error instanceof AuthRequestError) { if (error instanceof AuthRequestError) {
if (error.status === 401) { 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) { if (error.status === 400 || error.status === 410) {
return "The request or authentication challenge is invalid or expired. Restart the operation."; return "The request or authentication challenge is invalid or expired. Restart the operation.";
@@ -35,61 +40,77 @@ export class BrowserAuth {
busy = $state(false); busy = $state(false);
error = $state(""); error = $state("");
status = $state(""); status = $state("");
private request = 0; private unsubscribe: () => void;
private disposed = false; private disposed = false;
private controller: AbortController | null = null; 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 { get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null; return this.session.kind === "ready" ? this.session.actor : null;
} }
async refresh(): Promise<void> { async refresh(): Promise<void> {
if (this.busy || this.disposed) return; if (this.busy || this.disposed) return;
await this.readSession(); await browserSession.refresh();
}
private async readSession(): Promise<void> {
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" };
}
} }
async run( async run(
pending: string, pending: string,
success: string, success: string,
operation: (signal: AbortSignal) => Promise<unknown>, operation: (signal: AbortSignal) => Promise<unknown>,
intent: "authentication" | "logout" = "authentication",
): Promise<boolean> { ): Promise<boolean> {
if (this.busy || this.disposed || this.session.kind !== "ready") { if (
this.busy || this.disposed ||
(intent !== "logout" && this.session.kind !== "ready")
) {
return false; return false;
} }
this.busy = true; this.busy = true;
++this.request; sessionMutation(intent);
const authenticationGeneration = browserSession.authenticationGeneration;
const controller = new AbortController(); const controller = new AbortController();
this.controller = controller; this.controller = controller;
this.error = ""; this.error = "";
this.status = pending; this.status = pending;
try { try {
await operation(controller.signal); 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(); notifySessionChanged();
await this.readSession();
if (this.disposed) return false;
const confirmed = this.session.kind === "ready"; const confirmed = this.session.kind === "ready";
this.status = confirmed ? success : ""; this.status = confirmed ? success : "";
return confirmed; return confirmed;
} catch (error) { } catch (error) {
if (this.disposed) return false; if (!this.disposed) {
this.error = authErrorMessage(error); this.error = authErrorMessage(error);
this.status = ""; this.status = "";
}
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 // 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
await this.readSession(); sessionMutation(intent);
await browserSession.refresh();
return false; return false;
} finally { } finally {
if (!this.disposed) { if (!this.disposed) {
@@ -101,7 +122,7 @@ export class BrowserAuth {
dispose(): void { dispose(): void {
this.disposed = true; this.disposed = true;
++this.request; this.unsubscribe();
this.controller?.abort(); this.controller?.abort();
} }
} }
@@ -118,10 +118,14 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
}); });
Deno.test("whoami rejects unknown auth methods and extra fields", () => { 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( assertThrows(
() => () =>
parseWhoamiResponse({ parseWhoamiResponse({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
user_id: "user-1", user_id: "user-1",
account_id: "account-1", account_id: "account-1",
@@ -233,3 +237,34 @@ Deno.test("passkey credential conversion fails closed on malformed payloads", ()
"unknown credential type", "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" } },
);
});
+33 -5
View File
@@ -463,12 +463,40 @@ function parseActor(value: unknown, path: string): RequestActor {
export function parseWhoamiResponse(value: unknown): WhoamiResponse { export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami"); const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor"]); requireExactKeys(response, "whoami", ["actor", "session"]);
return { const actor = response.actor === null
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 ? 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 { export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
@@ -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<void> {
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));
}
@@ -12,16 +12,16 @@ function assertEquals(
} }
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts"; 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 ( for (
const [input, expected] of [ const [input, expected] of [
["/", "/dashboard"], ["/", "/dashboard"],
["/dashboard?query=discard#fragment", "/dashboard"], ["/dashboard?query=keep#fragment", "/dashboard?query=keep#fragment"],
["/account", "/account"], ["/account", "/account"],
["/account?returnTo=//evil.invalid#secret", "/account"], ["/account?returnTo=//evil.invalid#secret", "/account"],
[ [
"/w/workspace-id/tickets/T-123?filter=all#entry", "/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", "/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?code=abcd-1234&returnTo=//evil.invalid",
"/login/device?user_code=abcd-1234", "/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); ) assertEquals(safeReturnTo(input), expected, input);
}); });
@@ -64,6 +64,11 @@ Deno.test("return destination rejects external, ambiguous encoding and authentic
"/account\t", "/account\t",
"/account\u0000", "/account\u0000",
"/account%00", "/account%00",
"/account?q=%255c",
"/account?q=%0A",
"/account#%5c",
"/account?q=%ZZ",
"/account?q=%252f%252fevil.invalid",
"/api/auth/logout", "/api/auth/logout",
"/hosting/setup", "/hosting/setup",
"/not-an-app-route", "/not-an-app-route",
@@ -86,3 +91,38 @@ Deno.test("login links encode one validated destination and discard nested auth
"/login?returnTo=%2Fdashboard", "/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");
}
});
+40 -3
View File
@@ -11,13 +11,25 @@ export function safeReturnTo(value: string | null): string {
} }
const rawPath = value.split(/[?#]/, 1)[0]; const rawPath = value.split(/[?#]/, 1)[0];
if ( if (
rawPath.includes("%") || rawPath.includes("//") || /%[0-7][0-9a-f]/i.test(rawPath) || rawPath.includes("//") ||
rawPath.split("/").some((part) => part === "." || part === "..") rawPath.split("/").some((part) => part === "." || part === "..")
) { ) {
return AUTHENTICATED_HOME; return AUTHENTICATED_HOME;
} }
let url: URL; let url: URL;
let decodedPath: string;
try { 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"); url = new URL(value, "https://app.invalid");
} catch { } catch {
return AUTHENTICATED_HOME; return AUTHENTICATED_HOME;
@@ -33,9 +45,12 @@ export function safeReturnTo(value: string | null): string {
} }
if ( if (
url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" || 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; 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 { export function isAuthPage(path: string): boolean {
return ["/login", "/register", "/account", "/activate", "/login/device"] return ["/login", "/register", "/account", "/activate", "/login/device"]
.includes(path); .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;
}
@@ -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: () => "<p>Private fragment</p>",
}));
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<Response>((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: () => "<p>Mounted session reader</p>",
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);
});
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function state(read: () => Promise<WhoamiResponse>) {
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<WhoamiResponse>();
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<WhoamiResponse>();
const read = vi.fn<() => Promise<WhoamiResponse>>().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<WhoamiResponse>();
const read = vi.fn<() => Promise<WhoamiResponse>>().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<WhoamiResponse>>().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 });
}
});
@@ -0,0 +1,153 @@
/// <reference lib="dom" />
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<SessionSnapshot> | null = null;
private listeners = new Set<(state: SessionSnapshot) => void>();
private timer: ReturnType<typeof setTimeout> | 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<SessionSnapshot> {
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();
});
}
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom // @vitest-environment happy-dom
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { browserSession, sessionMutation } from "./session-state.ts";
import { BrowserAuth } from "./browser-session.svelte.ts"; import { BrowserAuth } from "./browser-session.svelte.ts";
import { addPasskey, loginWithPasskey, logout } from "./api.ts"; import { addPasskey, loginWithPasskey, logout } from "./api.ts";
import { SESSION_CHANGED_EVENT } from "./hosting.ts"; import { SESSION_CHANGED_EVENT } from "./hosting.ts";
@@ -13,6 +14,7 @@ import {
const sessions: BrowserAuth[] = []; const sessions: BrowserAuth[] = [];
afterEach(() => { afterEach(() => {
for (const auth of sessions.splice(0)) auth.dispose(); for (const auth of sessions.splice(0)) auth.dispose();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -33,6 +35,9 @@ function session() {
function whoami(authenticated: boolean) { function whoami(authenticated: boolean) {
return Response.json({ return Response.json({
session: authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: authenticated actor: authenticated
? { ...authUser, auth_method: "browser_session" } ? { ...authUser, auth_method: "browser_session" }
: null, : 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(await auth.run("pending", "done", operation)).toBe(false);
expect(operation).not.toHaveBeenCalled(); expect(operation).not.toHaveBeenCalled();
await auth.refresh(); await auth.refresh();
expect(auth.session).toEqual({ kind: "ready", actor: null }); expect(auth.session).toEqual({
kind: "ready",
actor: null,
session: { kind: "anonymous" },
});
}); });
for ( for (
@@ -117,6 +126,7 @@ for (
); );
const auth = session(); const auth = session();
const first = auth.refresh(); const first = auth.refresh();
browserSession.invalidate();
const second = auth.refresh(); const second = auth.refresh();
latest.resolve( latest.resolve(
latestStatus === 200 latestStatus === 200
@@ -146,13 +156,21 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
const oldResponse = gate<Response>(); const oldResponse = gate<Response>();
fetcher.mockReturnValueOnce(oldResponse.promise); fetcher.mockReturnValueOnce(oldResponse.promise);
const oldRefresh = auth.refresh(); const oldRefresh = auth.refresh();
await auth.refresh(); expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
expect(auth.actor?.handle).toBe("alice"); true,
expect(await auth.run("Logging out", "Logged out", logout)).toBe(true); );
expect(auth.session).toEqual({ kind: "ready", actor: null }); expect(auth.session).toEqual({
kind: "ready",
actor: null,
session: { kind: "anonymous" },
});
oldResponse.resolve(whoami(true)); oldResponse.resolve(whoami(true));
await oldRefresh; 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"); 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)); options.resolve(Response.json(loginOptions));
await confirmingStarted.promise; await confirmingStarted.promise;
expect(auth.busy).toBe(true); 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); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh(); await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) 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.status).toBe("");
expect(auth.error).toMatch( expect(auth.error).toMatch(
status === 401 status === 401
? /Your session or authentication challenge expired/ ? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/, : /The request or authentication challenge is invalid or expired/,
); );
expect(auth.session.kind).toBe("ready"); expect(auth.session.kind).toBe("ready");
@@ -270,7 +288,11 @@ for (
expect(auth.error).not.toContain("secret credential detail"); expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe(""); expect(auth.status).toBe("");
expect(auth.busy).toBe(false); 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([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/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 { fetcher, get } = boundary();
const auth = session(); const auth = session();
await auth.refresh(); await auth.refresh();
@@ -360,12 +382,17 @@ test("dispose aborts WebAuthn and ignores a late credential without completion,
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
credential.resolve(credentialFixture(true)); credential.resolve(credentialFixture(true));
expect(await run).toBe(false); 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.status).toBe("Waiting");
expect(auth.error).toBe(""); expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/whoami",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => 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/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete", "/api/auth/passkeys/login/complete",
"/api/auth/whoami",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => 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), (signal) => loginWithPasskey(signal),
); );
await started.promise; await started.promise;
expect(auth.session.kind).toBe("loading"); expect(auth.session.kind).toBe("ready");
expect(auth.busy).toBe(true); expect(auth.busy).toBe(true);
auth.dispose(); auth.dispose();
response.resolve(whoami(true)); response.resolve(whoami(true));
expect(await run).toBe(false); expect(await run).toBe(false);
expect(auth.session.kind).toBe("loading"); expect(auth.session.kind).toBe("ready");
expect(auth.actor).toBeNull(); 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<Response>();
const started = gate<void>();
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) { for (const purpose of ["login", "addition"] as const) {
test(`${purpose} null credential reports cancellation and never sends completion`, async () => { test(`${purpose} null credential reports cancellation and never sends completion`, async () => {
const { fetcher, get, create } = boundary(purpose === "addition"); const { fetcher, get, create } = boundary(purpose === "addition");
@@ -21,6 +21,7 @@ Deno.test("deferred load returns before API completion and snapshots navigation
const api = gate<string>(); const api = gate<string>();
const event = { const event = {
params: { id: "first" }, params: { id: "first" },
parent: async () => ({}),
url: new URL("https://example.test/first?q=old"), url: new URL("https://example.test/first?q=old"),
}; };
const load = deferPage(async ({ params, url }: typeof event) => ({ 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 ] as const
) { ) {
const load = deferPage((_event: { params: {}; url: URL }) => run()); const load = deferPage((
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
) => run());
const result = await load({ const result = await load({
params: {}, params: {},
parent: async () => ({}),
url: new URL("https://example.test/"), url: new URL("https://example.test/"),
}).deferredPage; }).deferredPage;
assert( assert(
@@ -94,3 +98,39 @@ Deno.test("resolved data preserves page overrides without waiting for the global
result.data.workspaceId === "team", 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<void>();
let calls = 0;
const load = deferPage(
async (
_event: { params: {}; url: URL; parent: () => Promise<unknown> },
) => {
++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"));
}
});
@@ -39,7 +39,11 @@ export async function settle<T>(
} }
export function deferPage< export function deferPage<
E extends { params: Record<string, string | undefined>; url: URL }, E extends {
params: Record<string, string | undefined>;
url: URL;
parent: () => Promise<unknown>;
},
T extends object, T extends object,
>( >(
loader: (event: E) => T | Promise<T>, loader: (event: E) => T | Promise<T>,
@@ -51,11 +55,13 @@ export function deferPage<
params: { ...event.params }, params: { ...event.params },
url: new URL(event.url.href), url: new URL(event.url.href),
}; };
try { // SvelteKitはparent/childを並列開始する。deferしても認証前fetchを開始しない。
return { deferredPage: settle(loader(snapshot)) }; return {
} catch (error) { deferredPage: settle((async () => {
return { deferredPage: settle(Promise.reject(error)) }; await snapshot.parent();
} return await loader(snapshot);
})()),
};
}; };
} }
@@ -18,6 +18,9 @@ for (
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") { if (String(input) === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null, actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
}); });
} }
+3
View File
@@ -4,6 +4,7 @@
// navigationの構成はWorkspace identityやpermissionの根拠にはしない。 // navigationの構成はWorkspace identityやpermissionの根拠にはしない。
// Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。 // Header下辺とSidebar右辺だけで領域境界を示し、viewport外周に線を重ねない。
// MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。 // MobileはSidebarがHeader下を占有する間mainをinertにし、隠れた内容へfocusを入れない。
import SessionBoundary from '#lib/workspace/auth/SessionBoundary.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { setContext } from 'svelte'; import { setContext } from 'svelte';
import { MediaQuery } from 'svelte/reactivity'; import { MediaQuery } from 'svelte/reactivity';
@@ -96,6 +97,7 @@
}); });
</script> </script>
<SessionBoundary>
{#if standalone} {#if standalone}
<main class="standalone-main"> <main class="standalone-main">
{@render children()} {@render children()}
@@ -147,6 +149,7 @@
</main> </main>
</div> </div>
{/if} {/if}
</SessionBoundary>
<style> <style>
.standalone-main { .standalone-main {
+31 -25
View File
@@ -1,3 +1,4 @@
import { requirePageSession } from "#lib/workspace/auth/page-session.ts";
import { isAuthPage } from "#lib/workspace/auth/routes.ts"; import { isAuthPage } from "#lib/workspace/auth/routes.ts";
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts"; import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
import { settle } from "#lib/workspace/navigation/deferred.ts"; import { settle } from "#lib/workspace/navigation/deferred.ts";
@@ -5,28 +6,33 @@ import type { Load } from "@sveltejs/kit";
export const ssr = false; export const ssr = false;
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({ export const load = async ({ fetch, url, depends }: Parameters<Load>[0]) => {
deferredCatalog: settle((async () => { depends("auth:session");
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。 // href依存でquery変更を含むSPA遷移でもrootの認証gateを再実行する。
// dashboardはactor確認後に自身の一覧を取得する。 await requirePageSession(new URL(url.href));
if ( return {
url.pathname === "/" || url.pathname === "/dashboard" || deferredCatalog: settle((async () => {
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/") // 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
) { // dashboardはactor確認後に自身の一覧を取得する。
return { accessibleWorkspaces: [], workspaceCatalogError: null }; if (
} url.pathname === "/" || url.pathname === "/dashboard" ||
try { isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
return { ) {
accessibleWorkspaces: await listWorkspaces(fetch), return { accessibleWorkspaces: [], workspaceCatalogError: null };
workspaceCatalogError: null, }
}; try {
} catch (error) { return {
return { accessibleWorkspaces: await listWorkspaces(fetch),
accessibleWorkspaces: [], workspaceCatalogError: null,
workspaceCatalogError: error instanceof Error };
? error.message } catch (error) {
: "Unable to load Workspaces", return {
}; accessibleWorkspaces: [],
} workspaceCatalogError: error instanceof Error
})()), ? error.message
}); : "Unable to load Workspaces",
};
}
})()),
};
};
@@ -9,9 +9,6 @@
const auth = new BrowserAuth(); const auth = new BrowserAuth();
async function refresh() { async function refresh() {
await auth.refresh(); await auth.refresh();
if (auth.session.kind === 'ready' && !auth.actor) {
await goto(loginUrl('/account'), { replaceState: true });
}
} }
async function add() { async function add() {
if (!auth.actor) return; if (!auth.actor) return;
@@ -19,8 +16,8 @@
} }
async function signOut() { async function signOut() {
if (!auth.actor) return; if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout); const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto(loginUrl('/account'), { replaceState: true }); if (confirmed && !auth.actor) await goto('/', { replaceState: true });
} }
onMount(() => { void refresh(); return () => auth.dispose(); }); onMount(() => { void refresh(); return () => auth.dispose(); });
</script> </script>
@@ -6,6 +6,7 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { tick } from "svelte"; import { tick } from "svelte";
import { goto } from "$app/navigation"; import { goto } from "$app/navigation";
@@ -26,6 +27,7 @@ vi.mock("$app/state", () => ({ page }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
page.url = new URL("https://example.test/login"); page.url = new URL("https://example.test/login");
@@ -61,6 +63,9 @@ function boundary({
return new Response(null, { status: state.whoamiStatus }); return new Response(null, { status: state.whoamiStatus });
} }
return Response.json({ return Response.json({
session: state.authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: state.authenticated actor: state.authenticated
? { ...authUser, auth_method: "browser_session" } ? { ...authUser, auth_method: "browser_session" }
: null, : null,
@@ -187,7 +192,10 @@ for (
const [returnTo, expected] of [ const [returnTo, expected] of [
[null, "/dashboard"], [null, "/dashboard"],
["/account", "/account"], ["/account", "/account"],
["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"], [
"/w/project/tickets?ignored=1#fragment",
"/w/project/tickets?ignored=1#fragment",
],
[ [
"/login/device?code=abcd-EFGH&untrusted=1", "/login/device?code=abcd-EFGH&untrusted=1",
"/login/device?user_code=abcd-EFGH", "/login/device?user_code=abcd-EFGH",
@@ -396,14 +404,11 @@ test("account displays the current identity without editable target fields and a
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
}); });
test("anonymous account redirects to login with an account return path and has no credential inputs", async () => { test("account fragment leaves anonymous navigation to the root gate and has no credential inputs", async () => {
const { fetcher } = boundary(); const { fetcher } = boundary();
render(Account); render(Account);
await waitFor(() => await screen.findByRole("link", { name: "Log in to your account" });
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", { expect(goto).not.toHaveBeenCalled();
replaceState: true,
})
);
expect( expect(
screen.getByRole("link", { name: "Log in to your account" }).getAttribute( screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
"href", "href",
@@ -468,12 +473,12 @@ test("unknown account session shows retry instead of treating errors as anonymou
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
}); });
test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => { test("logout confirms the server session is cleared and returns to the public home", async () => {
const { fetcher } = boundary({ authenticated: true }); const { fetcher } = boundary({ authenticated: true });
render(Account); render(Account);
await fireEvent.click(await screen.findByRole("button", { name: "Log out" })); await fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
await waitFor(() => await waitFor(() =>
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", { expect(goto).toHaveBeenCalledWith("/", {
replaceState: true, replaceState: true,
}) })
); );
@@ -494,7 +499,7 @@ test("expired addition revokes stale identity without silently retrying as regis
await fireEvent.click( await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }), await screen.findByRole("button", { name: "Add passkey to alice" }),
); );
await screen.findByText(/Your session or authentication challenge expired/); await screen.findByText(/Authentication request was rejected/);
await screen.findByRole("link", { name: "Log in to your account" }); await screen.findByRole("link", { name: "Log in to your account" });
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByText("Passkey added to your account.")).toBeNull(); expect(screen.queryByText("Passkey added to your account.")).toBeNull();
@@ -676,7 +681,7 @@ for (const status of [401, 410]) {
); );
await screen.findByText( await screen.findByText(
status === 401 status === 401
? /Your session or authentication challenge expired/ ? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/, : /The request or authentication challenge is invalid or expired/,
); );
expect( expect(
+13 -2
View File
@@ -8,6 +8,7 @@ import {
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import Home from "./+page.svelte"; import Home from "./+page.svelte";
import Dashboard from "./dashboard/+page.svelte"; import Dashboard from "./dashboard/+page.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
@@ -20,6 +21,7 @@ import { goto } from "$app/navigation";
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) })); vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.clearAllMocks(); vi.clearAllMocks();
}); });
@@ -51,6 +53,9 @@ function boundary() {
if (state.sessionGate) return state.sessionGate.promise; if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200 return state.whoamiStatus === 200
? Response.json({ ? Response.json({
session: state.signedIn
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: state.signedIn actor: state.signedIn
? { ? {
...authUser, ...authUser,
@@ -288,7 +293,10 @@ test("same actor focus retains creation disclosure, repository fields, errors an
await screen.findByText("Loading session…"); await screen.findByText("Loading session…");
expect(screen.queryByLabelText("Workspace display name")).toBeNull(); expect(screen.queryByLabelText("Workspace display name")).toBeNull();
state.sessionGate.resolve( state.sessionGate.resolve(
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }), Response.json({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" },
}),
); );
expect( expect(
(await screen.findByLabelText("Workspace display name") as HTMLInputElement) (await screen.findByLabelText("Workspace display name") as HTMLInputElement)
@@ -323,7 +331,10 @@ test("pending create remains single across focus and a success during unknown se
await waitFor(() => expect(response.bodyUsed).toBe(true)); await waitFor(() => expect(response.bodyUsed).toBe(true));
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
state.sessionGate.resolve( state.sessionGate.resolve(
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }), Response.json({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" },
}),
); );
expect( expect(
(await screen.findByRole("link", { name: "Open created Workspace" })) (await screen.findByRole("link", { name: "Open created Workspace" }))
@@ -6,11 +6,13 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import Accounts from "./+page.svelte"; import Accounts from "./+page.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
function boundary( function boundary(
@@ -20,6 +22,9 @@ function boundary(
const fetcher = vi.fn<typeof fetch>(async (input, init) => { const fetcher = vi.fn<typeof fetch>(async (input, init) => {
if (input === "/api/auth/whoami") { if (input === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: mode === "anonymous" actor: mode === "anonymous"
? null ? null
: { ...authUser, auth_method: "browser_session" }, : { ...authUser, auth_method: "browser_session" },
@@ -6,12 +6,14 @@ import {
screen, screen,
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import Settings from "./+page.svelte"; import Settings from "./+page.svelte";
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts"; import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
@@ -25,6 +27,9 @@ function boundary(
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: persona !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: persona === "anonymous" actor: persona === "anonymous"
? null ? null
: { ...authUser, auth_method: "browser_session" }, : { ...authUser, auth_method: "browser_session" },
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { onDestroy } from 'svelte'; import { onDestroy } from 'svelte';
import { browserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts'; import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts'; import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts'; import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
@@ -45,6 +46,7 @@
const expected = user; const expected = user;
busy = true; busy = true;
error = ''; error = '';
sessionMutation();
try { try {
const signedIn = await loginWithPasskey(controller.signal); const signedIn = await loginWithPasskey(controller.signal);
if (!active) return; if (!active) return;
@@ -57,6 +59,8 @@
} catch { } catch {
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.'; if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
} finally { } finally {
sessionMutation();
await browserSession.refresh();
if (active) busy = false; if (active) busy = false;
} }
} }
@@ -34,6 +34,9 @@ function boundary(
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null, actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
}); });
} }
@@ -121,7 +124,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
expect(settings.getAttribute("href")).toBe("/hosting/settings"); expect(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(1); .toHaveLength(2);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1); .toHaveLength(1);
expect( expect(
@@ -172,6 +175,7 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami"
? Response.json({ ? Response.json({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
...authUser, ...authUser,
user_id: "other-user", user_id: "other-user",
@@ -248,7 +252,10 @@ test("API token identity is not mistaken for a verified browser session", async
const original = fetcher.getMockImplementation()!; const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami"
? Response.json({ actor: { ...authUser, auth_method: "api_token" } }) ? Response.json({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "api_token" },
})
: original(input, init) : original(input, init)
); );
render(Setup); render(Setup);
@@ -4,34 +4,62 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { afterEach, beforeEach, expect, test, vi } from "vitest";
import { createRawSnippet, tick } from "svelte"; import { createRawSnippet, tick } from "svelte";
import { logout } from "#lib/workspace/auth/api.ts"; import { logout } from "#lib/workspace/auth/api.ts";
import { clearWorkspaceAlerts, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts'; import {
import { page } from '$app/state'; clearWorkspaceAlerts,
import { settle } from '#lib/workspace/navigation/deferred.ts'; pushWorkspaceAlert,
} from "#lib/workspace/alerts/store.ts";
import { page } from "$app/state";
import { settle } from "#lib/workspace/navigation/deferred.ts";
import { browserSession } from "#lib/workspace/auth/session-state.ts";
import {
authUser,
whoamiPayload,
} from "#lib/workspace/auth/api.test-fixtures.ts";
import Layout from "./+layout.svelte"; import Layout from "./+layout.svelte";
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } })); vi.mock(
"$app/state",
() => ({ page: { url: new URL("https://example.test/w/example") } }),
);
const storageKey = "yoi.sidebar.mode.v1"; const storageKey = "yoi.sidebar.mode.v1";
const legacyKey = "yoi.sidebar.folded.v1"; const legacyKey = "yoi.sidebar.folded.v1";
const props = { const props = {
params: {}, params: {},
children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })), children: createRawSnippet(() => ({ render: () => "<p>Page content</p>" })),
data: { deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: null }) }, data: {
deferredCatalog: settle({
accessibleWorkspaces: [],
workspaceCatalogError: null,
}),
},
}; };
const frame = () => screen.getByRole("complementary", { name: "Sidebar" }); const frame = () => screen.getByRole("complementary", { name: "Sidebar" });
const folded = () => frame().classList.contains("folded"); const folded = () => frame().classList.contains("folded");
const hoverRegion = () => frame().querySelector<HTMLElement>(".sidebar-hover-region")!; const hoverRegion = () =>
const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" }); frame().querySelector<HTMLElement>(".sidebar-hover-region")!;
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" }); const enter = () =>
fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse" });
const leave = () =>
fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
const delay = () => vi.advanceTimersByTimeAsync(200); const delay = () => vi.advanceTimersByTimeAsync(200);
beforeEach(() => { beforeEach(() => {
localStorage.clear(); localStorage.clear();
clearWorkspaceAlerts(); clearWorkspaceAlerts();
vi.stubGlobal('fetch', vi.fn(async (input) => { browserSession.snapshot = {
if (String(input) === '/api/auth/whoami') return Response.json({ actor: null }); kind: "ready",
throw new Error(`Unexpected request ${input}`); value: whoamiPayload({ ...authUser, auth_method: "browser_session" }),
})); } as typeof browserSession.snapshot;
page.url = new URL('https://example.test/w/example'); vi.stubGlobal(
"fetch",
vi.fn(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({ session: { kind: "anonymous" }, actor: null });
}
throw new Error(`Unexpected request ${input}`);
}),
);
page.url = new URL("https://example.test/w/example");
vi.useFakeTimers(); vi.useFakeTimers();
}); });
afterEach(() => { afterEach(() => {
@@ -42,22 +70,36 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
test.each(['/', '/dashboard', '/login', '/register', '/account', '/hosting/settings'])( test.each([
'standalone %s never displays retained private Workspace notifications', "/",
"/dashboard",
"/login",
"/register",
"/account",
"/hosting/settings",
])(
"standalone %s never displays retained private Workspace notifications",
(path) => { (path) => {
pushWorkspaceAlert('error', 'Private Workspace Orion: worker deletion failed'); pushWorkspaceAlert(
page.url = new URL(path, 'https://example.test'); "error",
"Private Workspace Orion: worker deletion failed",
);
page.url = new URL(path, "https://example.test");
render(Layout, { props }); render(Layout, { props });
expect(screen.getByText('Page content')).toBeTruthy(); expect(screen.getByText("Page content")).toBeTruthy();
expect(screen.queryByText('Private Workspace Orion: worker deletion failed')).toBeNull(); expect(
expect(screen.queryByRole('alert')).toBeNull(); screen.queryByText("Private Workspace Orion: worker deletion failed"),
).toBeNull();
expect(screen.queryByRole("alert")).toBeNull();
}, },
); );
test('Workspace shell continues displaying Workspace action notifications', () => { test("Workspace shell continues displaying Workspace action notifications", () => {
pushWorkspaceAlert('error', 'Worker deletion failed'); pushWorkspaceAlert("error", "Worker deletion failed");
render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole('alert').textContent).toContain('Worker deletion failed'); expect(screen.getByRole("alert").textContent).toContain(
"Worker deletion failed",
);
}); });
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => { test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
@@ -65,7 +107,10 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
props: { props: {
...props, ...props,
data: { data: {
deferredCatalog: settle({ accessibleWorkspaces: [], workspaceCatalogError: "Workspace catalog request failed" }), deferredCatalog: settle({
accessibleWorkspaces: [],
workspaceCatalogError: "Workspace catalog request failed",
}),
}, },
}, },
}); });
@@ -78,10 +123,16 @@ test("Workspace catalog failure uses the shared alert without rendering diagnost
test("unpinning changes mode, and pointer departure closes the preview", async () => { test("unpinning changes mode, and pointer departure closes the preview", async () => {
render(Layout, { props }); render(Layout, { props });
expect(screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute("aria-pressed")).toBe("true"); expect(
screen.getByRole("button", { name: "Unpin sidebar" }).getAttribute(
"aria-pressed",
),
).toBe("true");
await enter(); await enter();
screen.getByRole("button", { name: "Unpin sidebar" }).focus(); screen.getByRole("button", { name: "Unpin sidebar" }).focus();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
detail: 1,
});
expect(localStorage.getItem(storageKey)).toBe("hover"); expect(localStorage.getItem(storageKey)).toBe("hover");
expect(folded()).toBe(false); expect(folded()).toBe(false);
await leave(); await leave();
@@ -107,7 +158,9 @@ test("footer pointer clicks release previous sidebar link focus before unpinning
render(Layout, { props }); render(Layout, { props });
frame().querySelector<HTMLAnchorElement>("a")!.focus(); frame().querySelector<HTMLAnchorElement>("a")!.focus();
await tick(); await tick();
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), { detail: 1 }); await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }), {
detail: 1,
});
expect(folded()).toBe(true); expect(folded()).toBe(true);
}); });
@@ -121,12 +174,15 @@ test("keyboard mode switching preserves the focused preview", async () => {
expect(folded()).toBe(false); expect(folded()).toBe(false);
}); });
test.each(["pinned", "hover"])("restores %s mode without persisting a transient open state", (mode) => { test.each(["pinned", "hover"])(
localStorage.setItem(storageKey, mode); "restores %s mode without persisting a transient open state",
render(Layout, { props }); (mode) => {
expect(folded()).toBe(mode === "hover"); localStorage.setItem(storageKey, mode);
expect(localStorage.getItem(storageKey)).toBe(mode); render(Layout, { props });
}); expect(folded()).toBe(mode === "hover");
expect(localStorage.getItem(storageKey)).toBe(mode);
},
);
test.each([true, false])("migrates legacy folded=%s to a mode", (value) => { test.each([true, false])("migrates legacy folded=%s to a mode", (value) => {
localStorage.setItem(legacyKey, String(value)); localStorage.setItem(legacyKey, String(value));
@@ -171,7 +227,8 @@ test("footer hover does not open or hold the preview, but its button still chang
render(Layout, { props }); render(Layout, { props });
const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!; const footer = frame().querySelector<HTMLElement>(".sidebar-control-row")!;
const button = screen.getByRole("button", { name: "Pin sidebar" }); const button = screen.getByRole("button", { name: "Pin sidebar" });
expect(button.querySelector('[data-icon="panel-right-open"] rect')).not.toBeNull(); expect(button.querySelector('[data-icon="panel-right-open"] rect')).not
.toBeNull();
await fireEvent.pointerEnter(footer, { pointerType: "mouse" }); await fireEvent.pointerEnter(footer, { pointerType: "mouse" });
await fireEvent.pointerEnter(button, { pointerType: "mouse" }); await fireEvent.pointerEnter(button, { pointerType: "mouse" });
await delay(); await delay();
@@ -184,7 +241,8 @@ test("footer hover does not open or hold the preview, but its button still chang
expect(folded()).toBe(true); expect(folded()).toBe(true);
await fireEvent.click(button, { detail: 1 }); await fireEvent.click(button, { detail: 1 });
expect(folded()).toBe(false); expect(folded()).toBe(false);
expect(button.querySelector('[data-icon="panel-right-close"] rect')).not.toBeNull(); expect(button.querySelector('[data-icon="panel-right-close"] rect')).not
.toBeNull();
}); });
test("keyboard focus opens the preview and holds it until focus leaves", async () => { test("keyboard focus opens the preview and holds it until focus leaves", async () => {
@@ -206,7 +264,9 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
localStorage.setItem(storageKey, "hover"); localStorage.setItem(storageKey, "hover");
render(Layout, { props }); render(Layout, { props });
await enter(); await enter();
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), { detail: 1 }); await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }), {
detail: 1,
});
await leave(); await leave();
await delay(); await delay();
expect(folded()).toBe(false); expect(folded()).toBe(false);
@@ -214,7 +274,10 @@ test("pinning a preview keeps it open after pointer and focus leave", async () =
}); });
test("mode survives logout and remount, but an open preview does not", async () => { test("mode survives logout and remount, but an open preview does not", async () => {
vi.stubGlobal("fetch", vi.fn(async () => Response.json({ status: "logged_out" }))); vi.stubGlobal(
"fetch",
vi.fn(async () => Response.json({ status: "logged_out" })),
);
localStorage.setItem(storageKey, "hover"); localStorage.setItem(storageKey, "hover");
const view = render(Layout, { props }); const view = render(Layout, { props });
await enter(); await enter();
@@ -229,7 +292,9 @@ test("touch does not hover; mobile header opens temporarily without changing mod
const matchMedia = window.matchMedia.bind(window); const matchMedia = window.matchMedia.bind(window);
vi.spyOn(window, "matchMedia").mockImplementation((query) => { vi.spyOn(window, "matchMedia").mockImplementation((query) => {
const result = matchMedia(query); const result = matchMedia(query);
Object.defineProperty(result, "matches", { value: query === "(max-width: 760px)" }); Object.defineProperty(result, "matches", {
value: query === "(max-width: 760px)",
});
return result; return result;
}); });
localStorage.setItem(storageKey, "hover"); localStorage.setItem(storageKey, "hover");
@@ -255,7 +320,9 @@ test("invalid storage falls back to pinned", () => {
}); });
test("unavailable storage does not prevent mode switching", async () => { test("unavailable storage does not prevent mode switching", async () => {
vi.spyOn(window, "localStorage", "get").mockImplementation(() => { throw new DOMException("Blocked", "SecurityError"); }); vi.spyOn(window, "localStorage", "get").mockImplementation(() => {
throw new DOMException("Blocked", "SecurityError");
});
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Unpin sidebar" }));
await delay(); await delay();
@@ -266,7 +333,9 @@ test("unavailable storage does not prevent mode switching", async () => {
test("full storage still restores the mode and allows changes", async () => { test("full storage still restores the mode and allows changes", async () => {
localStorage.setItem(storageKey, "hover"); localStorage.setItem(storageKey, "hover");
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new DOMException("Full", "QuotaExceededError"); }); vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
throw new DOMException("Full", "QuotaExceededError");
});
render(Layout, { props }); render(Layout, { props });
await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" })); await fireEvent.click(screen.getByRole("button", { name: "Pin sidebar" }));
expect(folded()).toBe(false); expect(folded()).toBe(false);
@@ -5,12 +5,18 @@ import { parseWorkspaceResponse } from "#lib/workspace/api/workspace-model.ts";
import { settle } from "#lib/workspace/navigation/deferred.ts"; import { settle } from "#lib/workspace/navigation/deferred.ts";
import type { LayoutLoad } from "./$types"; import type { LayoutLoad } from "./$types";
export const load = ({ fetch, params }: Parameters<LayoutLoad>[0]) => { export const load = async (
{ fetch, params, parent }: Parameters<LayoutLoad>[0],
) => {
await parent();
const workspaceId = params.workspaceId; const workspaceId = params.workspaceId;
return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) }; return { deferredWorkspace: settle(loadWorkspace(fetch, workspaceId)) };
}; };
async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspaceId: string) { async function loadWorkspace(
fetch: Parameters<LayoutLoad>[0]["fetch"],
workspaceId: string,
) {
const [workspaceResult, repositoryResult] = await Promise.all([ const [workspaceResult, repositoryResult] = await Promise.all([
loadJson( loadJson(
fetch, fetch,
@@ -43,4 +49,4 @@ async function loadWorkspace(fetch: Parameters<LayoutLoad>[0]["fetch"], workspac
repositories, repositories,
repositoriesError, repositoriesError,
}; };
}; }
@@ -1,7 +1,8 @@
import { loadDashboard } from "#lib/workspace/home/dashboard.ts"; import { loadDashboard } from "#lib/workspace/home/dashboard.ts";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load: PageLoad = ({ fetch, params, depends }) => { export const load: PageLoad = async ({ fetch, params, depends, parent }) => {
await parent();
depends("workspace:home"); depends("workspace:home");
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
@@ -79,6 +79,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
const depends = vi.fn(); const depends = vi.fn();
const result = await load( const result = await load(
{ {
parent: async () => ({}),
fetch, fetch,
params: { workspaceId: "home-owner" }, params: { workspaceId: "home-owner" },
depends, depends,
@@ -1,3 +1,4 @@
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { import {
@@ -11,36 +12,39 @@ import {
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts"; import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => { export const load = deferPage(
const apiPath = (path: string) => workspaceApiPath(params.workspaceId, path); async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const objectiveId = resourceKey(params.objectiveId); const apiPath = (path: string) =>
const objective = await loadJson( workspaceApiPath(params.workspaceId, path);
fetch, const objectiveId = resourceKey(params.objectiveId);
apiPath(`/objectives/${encodeURIComponent(objectiveId)}`), const objective = await loadJson(
undefined, fetch,
parseObjectiveDetail, apiPath(`/objectives/${encodeURIComponent(objectiveId)}`),
TICKET_BROWSER_API_LOAD_POLICY, undefined,
); parseObjectiveDetail,
TICKET_BROWSER_API_LOAD_POLICY,
if (objective.data) {
const canonical = canonicalResourceReference(
objective.data.resource_key,
objective.data.title,
); );
if (params.objectiveId !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
encodeURIComponent(canonical)
}`,
);
}
}
return { if (objective.data) {
workspaceId: params.workspaceId, const canonical = canonicalResourceReference(
objectiveId, objective.data.resource_key,
objective: objective.data, objective.data.title,
objectiveError: objective.error, );
}; if (params.objectiveId !== canonical) {
}); redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/objectives/${
encodeURIComponent(canonical)
}${browserUrlSuffix(url)}`,
);
}
}
return {
workspaceId: params.workspaceId,
objectiveId,
objective: objective.data,
objectiveError: objective.error,
};
},
);
@@ -1,3 +1,4 @@
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { error, redirect } from "@sveltejs/kit"; import { error, redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts"; import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts";
@@ -14,57 +15,62 @@ import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts"; import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => { export const load = deferPage(
const reference = resourceKey(params.ticketId); async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const ticketPath = workspaceApiPath( const reference = resourceKey(params.ticketId);
params.workspaceId, const ticketPath = workspaceApiPath(
`/tickets/${encodeURIComponent(reference)}`, params.workspaceId,
); `/tickets/${encodeURIComponent(reference)}`,
const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
loadJson(
fetch,
ticketPath,
undefined,
parseTicketDetail,
TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (!ticket.data) {
const message = ticket.error ?? "Ticket details could not be loaded";
if (ticket.status !== undefined && ticket.status >= 400 && ticket.status <= 599) {
error(ticket.status, message);
}
// Expected read failures belong to the deferred loader, not component construction.
throw new Error(message);
}
if (ticket.data) {
const canonical = canonicalResourceReference(
ticket.data.resource_key,
ticket.data.title,
); );
if (params.ticketId !== canonical) { const [ticket, repositoriesRaw, orchestrator] = await Promise.all([
redirect( loadJson(
308, fetch,
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${ ticketPath,
encodeURIComponent(canonical) undefined,
}`, parseTicketDetail,
); TICKET_BROWSER_API_LOAD_POLICY,
),
loadWorkspaceRepositoryList(fetch, params.workspaceId),
loadJson<WorkspaceOrchestratorStatus>(
fetch,
workspaceApiPath(params.workspaceId, "/orchestrator"),
undefined,
parseBrowserWorkspaceOrchestratorResponse,
),
]);
if (!ticket.data) {
const message = ticket.error ?? "Ticket details could not be loaded";
if (
ticket.status !== undefined && ticket.status >= 400 &&
ticket.status <= 599
) {
error(ticket.status, message);
}
// Expected read failures belong to the deferred loader, not component construction.
throw new Error(message);
} }
} if (ticket.data) {
const repositories = repositoriesRaw; const canonical = canonicalResourceReference(
ticket.data.resource_key,
ticket.data.title,
);
if (params.ticketId !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/tickets/${
encodeURIComponent(canonical)
}${browserUrlSuffix(url)}`,
);
}
}
const repositories = repositoriesRaw;
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
ticketId: ticket.data?.id ?? reference, ticketId: ticket.data?.id ?? reference,
ticket, ticket,
repositories, repositories,
orchestrator, orchestrator,
}; };
}); },
);
@@ -108,6 +108,7 @@ async function loadPage(
) { ) {
const result = await load( const result = await load(
{ {
parent: async () => ({}),
fetch: fetchFn, fetch: fetchFn,
params: { workspaceId }, params: { workspaceId },
url: new URL( url: new URL(
@@ -141,7 +142,7 @@ test("actual loader posts browse only; rows render while orchestrator is unresol
"/api/w/workspace-a/repositories": () => repos.promise, "/api/w/workspace-a/repositories": () => repos.promise,
}); });
const pending = loadPage(request); const pending = loadPage(request);
expect(request).toHaveBeenCalledTimes(1); await waitFor(() => expect(request).toHaveBeenCalledTimes(1));
expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse"); expect(request.mock.calls[0][0]).toBe("/api/w/workspace-a/tickets/browse");
expect(request.mock.calls[0][1]?.method).toBe("POST"); expect(request.mock.calls[0][1]?.method).toBe("POST");
expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual( expect(JSON.parse(String(request.mock.calls[0][1]?.body))).toEqual(
@@ -1,3 +1,4 @@
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts"; import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
@@ -8,33 +9,37 @@ import {
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
import { deferPage } from "#lib/workspace/navigation/deferred.ts"; import { deferPage } from "#lib/workspace/navigation/deferred.ts";
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => { export const load = deferPage(
const reference = resourceKey(params.workerRef); async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const result = await loadJson( const reference = resourceKey(params.workerRef);
fetch, const result = await loadJson(
workspaceApiPath( fetch,
params.workspaceId, workspaceApiPath(
`/workers/${encodeURIComponent(reference)}`, params.workspaceId,
), `/workers/${encodeURIComponent(reference)}`,
undefined, ),
parseWorkerSummary, undefined,
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 }, parseWorkerSummary,
); { diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
if (result.data?.resource_key) {
const canonical = canonicalResourceReference(
result.data.resource_key,
result.data.display_name,
); );
if (params.workerRef !== canonical) { if (result.data?.resource_key) {
redirect( const canonical = canonicalResourceReference(
308, result.data.resource_key,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}`, result.data.display_name,
); );
if (params.workerRef !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
encodeURIComponent(canonical)
}${browserUrlSuffix(url)}`,
);
}
} }
} return {
return { workspaceId: params.workspaceId,
workspaceId: params.workspaceId, worker: result.data,
worker: result.data, workerError: result.error,
workerError: result.error, };
}; },
}); );
@@ -1,3 +1,4 @@
import { browserUrlSuffix } from "#lib/workspace/auth/routes.ts";
import { redirect } from "@sveltejs/kit"; import { redirect } from "@sveltejs/kit";
import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts";
import { parseWorkerSummary } from "#lib/workspace/api/workers.ts"; import { parseWorkerSummary } from "#lib/workspace/api/workers.ts";
@@ -8,38 +9,40 @@ import {
import { deferPage } from "#lib/workspace/navigation/deferred.ts"; import { deferPage } from "#lib/workspace/navigation/deferred.ts";
import type { PageLoad } from "./$types"; import type { PageLoad } from "./$types";
export const load = deferPage(async ({ fetch, params }: Parameters<PageLoad>[0]) => { export const load = deferPage(
const reference = resourceKey(params.workerRef); async ({ fetch, params, url }: Parameters<PageLoad>[0]) => {
const result = await loadJson( const reference = resourceKey(params.workerRef);
fetch, const result = await loadJson(
workspaceApiPath( fetch,
params.workspaceId, workspaceApiPath(
`/workers/${encodeURIComponent(reference)}`, params.workspaceId,
), `/workers/${encodeURIComponent(reference)}`,
undefined, ),
parseWorkerSummary, undefined,
{ diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 }, parseWorkerSummary,
); { diagnosticLabel: "Worker API", maxResponseBytes: 8 * 1024 * 1024 },
if (result.data?.resource_key) {
const canonical = canonicalResourceReference(
result.data.resource_key,
result.data.display_name,
); );
if (params.workerRef !== canonical) { if (result.data?.resource_key) {
redirect( const canonical = canonicalResourceReference(
308, result.data.resource_key,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${ result.data.display_name,
encodeURIComponent(canonical)
}/console`,
); );
if (params.workerRef !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
encodeURIComponent(canonical)
}/console${browserUrlSuffix(url)}`,
);
}
} }
}
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimeId: result.data?.runtime_id ?? null, runtimeId: result.data?.runtime_id ?? null,
workerId: result.data?.worker_id ?? null, workerId: result.data?.worker_id ?? null,
worker: result.data, worker: result.data,
workerError: result.error, workerError: result.error,
}; };
}); },
);
@@ -5,17 +5,29 @@ import WorkspaceError from "./+error.svelte";
import { load } from "./+layout.ts"; import { load } from "./+layout.ts";
const state = vi.hoisted(() => ({ const state = vi.hoisted(() => ({
page: { params: { workspaceId: "workspace-a" }, status: 500, error: { message: "Ticket Browser API response is invalid" } }, page: {
params: { workspaceId: "workspace-a" },
status: 500,
error: { message: "Ticket Browser API response is invalid" },
},
})); }));
vi.mock("$app/state", () => state); vi.mock("$app/state", () => state);
afterEach(() => { cleanup(); vi.restoreAllMocks(); }); afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
test("a child page error preserves its message and never claims Workspace deletion", async () => { test("a child page error preserves its message and never claims Workspace deletion", async () => {
const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {}); const reload = vi.spyOn(window.location, "reload").mockImplementation(
() => {},
);
const view = render(WorkspaceError); const view = render(WorkspaceError);
expect(view.getByRole("alert").textContent).toBe("Ticket Browser API response is invalid"); expect(view.getByRole("alert").textContent).toBe(
"Ticket Browser API response is invalid",
);
expect(view.getByText("Error 500")).toBeTruthy(); expect(view.getByText("Error 500")).toBeTruthy();
expect(view.queryByText("The selected Workspace cannot be opened")).toBeNull(); expect(view.queryByText("The selected Workspace cannot be opened"))
.toBeNull();
expect(view.queryByText("Workspace unavailable")).toBeNull(); expect(view.queryByText("Workspace unavailable")).toBeNull();
expect(view.queryByText("Create Workspace")).toBeNull(); expect(view.queryByText("Create Workspace")).toBeNull();
expect(view.queryByText("Choose another Workspace")).toBeNull(); expect(view.queryByText("Choose another Workspace")).toBeNull();
@@ -28,31 +40,49 @@ async function loadFailure(workspaceFetch: () => Promise<Response>) {
if (String(input).endsWith("/workspace")) return await workspaceFetch(); if (String(input).endsWith("/workspace")) return await workspaceFetch();
return new Response("repository lookup unavailable", { status: 503 }); return new Response("repository lookup unavailable", { status: 503 });
}); });
return await load({ fetch: fetcher, params: { workspaceId: "workspace-a" } } as unknown as Parameters<typeof load>[0]).deferredWorkspace; return await (await load(
{
fetch: fetcher,
parent: async () => ({}),
params: { workspaceId: "workspace-a" },
} as unknown as Parameters<typeof load>[0],
)).deferredWorkspace;
} }
test.each([401, 403, 404, 500, 503])("Workspace HTTP %i is not rewritten as 404", async (status) => { test.each([401, 403, 404, 500, 503])(
const result = await loadFailure(async () => new Response("private backend body", { status })); "Workspace HTTP %i is not rewritten as 404",
expect(result.ok).toBe(false); async (status) => {
if (result.ok) throw new Error("expected a failed Workspace load"); const result = await loadFailure(async () =>
expect(result.failure.status).toBe(status); new Response("private backend body", { status })
expect(result.failure.message).toBe(`Workspace API request failed with HTTP ${status}`); );
expect(result.failure.message).not.toContain("private backend body"); expect(result.ok).toBe(false);
}); if (result.ok) throw new Error("expected a failed Workspace load");
expect(result.failure.status).toBe(status);
expect(result.failure.message).toBe(
`Workspace API request failed with HTTP ${status}`,
);
expect(result.failure.message).not.toContain("private backend body");
},
);
test.each(["network", "json", "contract"] as const)("Workspace %s failures do not imply a missing Workspace", async (kind) => { test.each(["network", "json", "contract"] as const)(
const result = await loadFailure(async () => { "Workspace %s failures do not imply a missing Workspace",
if (kind === "network") throw new TypeError("Failed to fetch"); async (kind) => {
return kind === "json" ? new Response("not json") : Response.json({}); const result = await loadFailure(async () => {
}); if (kind === "network") throw new TypeError("Failed to fetch");
expect(result.ok).toBe(false); return kind === "json" ? new Response("not json") : Response.json({});
if (result.ok) throw new Error("expected a failed Workspace load"); });
expect(result.failure.status).toBeNull(); expect(result.ok).toBe(false);
expect(result.failure.message).toBe({ if (result.ok) throw new Error("expected a failed Workspace load");
network: "Workspace API request could not be completed", expect(result.failure.status).toBeNull();
json: "Workspace API response could not be decoded as JSON", expect(result.failure.message).toBe(
contract: "Workspace API response is invalid", {
}[kind]); network: "Workspace API request could not be completed",
expect(result.failure.message).not.toContain("removed"); json: "Workspace API response could not be decoded as JSON",
expect(result.failure.message).not.toContain("404"); contract: "Workspace API response is invalid",
}); }[kind],
);
expect(result.failure.message).not.toContain("removed");
expect(result.failure.message).not.toContain("404");
},
);
@@ -143,11 +143,13 @@ Deno.test("activation standalone shell supports issuance, reissue, denial, resta
path.startsWith("POST ") path.startsWith("POST ")
).length; ).length;
await go("/hosting/accounts", mode); await go("/hosting/accounts", mode);
await page.getByText( if (mode === "anonymous") {
mode === "anonymous" await page.waitForURL("http://127.0.0.1:15381/");
? "Sign in with a hosting administrator account to issue activation codes." await page.getByRole("heading", { name: "Yoi", exact: true })
: "Hosting administrator access is required.", .waitFor();
).waitFor(); } else {await page.getByText(
"Hosting administrator access is required.",
).waitFor();}
assertEquals( assertEquals(
await page.getByLabel("Account ID (handle)").count(), await page.getByLabel("Account ID (handle)").count(),
0, 0,
@@ -41,6 +41,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (path === "/api/workspaces") return new Response(null, { status: 503 }); if (path === "/api/workspaces") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: mode === "anonymous" actor: mode === "anonymous"
? null ? null
: { ...user, auth_method: "browser_session" }, : { ...user, auth_method: "browser_session" },
@@ -3,6 +3,7 @@
import { assert, assertEquals } from "@std/assert"; import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path"; import { resolve } from "@std/path";
import { type Browser, chromium, type Page } from "playwright"; import { type Browser, chromium, type Page } from "playwright";
import { sessionNavigation } from "./session-navigation.ts";
import { import {
startOwnedProcesses, startOwnedProcesses,
stopOwnedProcesses, stopOwnedProcesses,
@@ -14,7 +15,7 @@ type Mutation = { path: string; keys: string[] };
type Harness = { type Harness = {
page: Page; page: Page;
mutations: Mutation[]; mutations: Mutation[];
go: (path: string, mode?: string) => Promise<void>; go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
}; };
const login = async (page: Page) => { const login = async (page: Page) => {
await page.getByRole("button", { name: "Log in with passkey", exact: true }) await page.getByRole("button", { name: "Log in with passkey", exact: true })
@@ -67,6 +68,19 @@ async function isolated(
const page = await context.newPage(); const page = await context.newPage();
const mutations: Mutation[] = []; const mutations: Mutation[] = [];
const errors: string[] = []; const errors: string[] = [];
const rejectedLoads: unknown[] = [];
let expectsUnavailableRoot = false;
await page.exposeFunction(
"__authFixtureRejectedLoad",
(reason: unknown) => rejectedLoads.push(reason),
);
await page.addInitScript(() => {
window.addEventListener("unhandledrejection", (event) => {
void (window as unknown as {
__authFixtureRejectedLoad: (reason: unknown) => Promise<void>;
}).__authFixtureRejectedLoad(event.reason);
});
});
page.on("pageerror", (error) => errors.push(error.message)); page.on("pageerror", (error) => errors.push(error.message));
page.on("request", (request) => { page.on("request", (request) => {
const path = new URL(request.url()).pathname; const path = new URL(request.url()).pathname;
@@ -81,14 +95,38 @@ async function isolated(
await body({ await body({
page, page,
mutations, mutations,
go: async (path, mode = "anonymous") => { go: async (path, mode = "anonymous", unavailableRoot = false) => {
const url = new URL(path, origin); // fixture選択を実際の戻り先queryへ混入させない。同じcontextのcookieをHTTPで設定する。
url.searchParams.set("fixture", mode); expectsUnavailableRoot ||= unavailableRoot ||
await page.goto(url.href); (path.startsWith("/account") &&
[
"session-error",
"session-malformed",
"session-retry",
"network-error",
].includes(mode));
const setup = new URL("/", origin);
setup.searchParams.set("fixture", mode);
await page.request.get(setup.href);
await page.goto(new URL(path, origin).href);
}, },
}); });
await noOverflow(page); await noOverflow(page);
assertEquals(errors, []); // root load自身の503ではKitのstatic fallback再実行も同じHttpErrorをrejectする。
// minifyされたclass名で除外せず、意図した障害fixtureのstatus/bodyと件数だけを許す。
if (expectsUnavailableRoot && rejectedLoads.length > 0) {
for (const reason of rejectedLoads) {
assertEquals(reason, {
status: 503,
body: {
status: 503,
message:
"Your session could not be loaded. Login status is unknown. Retry this page.",
},
});
}
assertEquals(errors.length, rejectedLoads.length);
} else assertEquals(errors, []);
} finally { } finally {
await context.close(); await context.close();
} }
@@ -113,12 +151,13 @@ Deno.test("standalone auth separates login, registration, own account and explic
readyUrl: `${origin}/health`, readyUrl: `${origin}/health`,
}], }],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`, `${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/auth-interactions/processes`, `${root}/target/web-ux/t767-validation/auth-interactions/processes`,
[], [],
); );
let browser: Browser | undefined; let browser: Browser | undefined;
try { try {
browser = await chromium.launch(); browser = await chromium.launch();
await sessionNavigation(t, browser, isolated, origin);
for (const theme of ["light", "dark"] as const) { for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) { for (const width of [1440, 768, 390]) {
const step = ( const step = (
@@ -130,11 +169,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
() => isolated(browser!, body, theme, width), () => isolated(browser!, body, theme, width),
); );
await step( await step(
"anonymous account replaces history with login then returns to own account", "expired account replaces history with login then returns to own account",
async ({ page, go, mutations }) => { async ({ page, go, mutations }) => {
await page.goto(`${origin}/health`); await page.goto(`${origin}/health`);
const historyBefore = await page.evaluate(() => history.length); const historyBefore = await page.evaluate(() => history.length);
await go("/account"); await go("/account", "session-expired");
await at(page, "/login?returnTo=%2Faccount"); await at(page, "/login?returnTo=%2Faccount");
assertEquals( assertEquals(
await page.evaluate(() => history.length), await page.evaluate(() => history.length),
@@ -208,16 +247,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
); );
await page.getByRole("button", { name: "Log out", exact: true }) await page.getByRole("button", { name: "Log out", exact: true })
.click(); .click();
await page.getByRole("button", { await page.getByRole("heading", { name: "Yoi", exact: true })
name: "Log in with passkey", .waitFor();
exact: true, assertEquals(new URL(page.url()).pathname, "/");
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login");
await page.reload(); await page.reload();
await page.getByRole("button", { await page.getByRole("heading", { name: "Yoi", exact: true })
name: "Log in with passkey", .waitFor();
exact: true,
}).waitFor();
assertEquals(count(mutations, "/api/auth/logout"), 1); assertEquals(count(mutations, "/api/auth/logout"), 1);
}, },
); );
@@ -410,10 +445,15 @@ Deno.test("standalone auth separates login, registration, own account and explic
() => () =>
isolated(browser!, async ({ page, go, mutations }) => { isolated(browser!, async ({ page, go, mutations }) => {
await go(path, "session-error"); await go(path, "session-error");
await page.getByRole("button", { if (path === "/account") {
name: "Retry session", // 初回protected loadの確認不能はroot loaderの503。画面内再確認とは分ける。
exact: true, await page.getByText(/Login status is unknown/i).waitFor();
}).waitFor(); } else {
await page.getByRole("button", {
name: "Retry session",
exact: true,
}).waitFor();
}
assertEquals( assertEquals(
await page.getByRole("link", { await page.getByRole("link", {
name: "Log in to continue", name: "Log in to continue",
@@ -627,6 +667,9 @@ Deno.test("standalone auth separates login, registration, own account and explic
"/register?returnTo=%2Flogin", "/register?returnTo=%2Flogin",
"/activate", "/activate",
"/login/../login", "/login/../login",
"/%2f%2fexample.invalid",
"/account?filter=%252F%252Fexample.invalid",
"/account#%5Cexample.invalid",
] ]
) { ) {
await t.step( await t.step(
@@ -713,7 +756,11 @@ Deno.test("standalone auth separates login, registration, own account and explic
count(mutations, `/api/auth/passkeys/${purpose}/complete`), count(mutations, `/api/auth/passkeys/${purpose}/complete`),
0, 0,
); );
await page.getByRole("button", { name: label, exact: true }) await page.getByRole("button", {
name: label,
exact: true,
includeHidden: true,
})
.evaluate((button) => { .evaluate((button) => {
button.dispatchEvent( button.dispatchEvent(
new MouseEvent("click", { bubbles: true }), new MouseEvent("click", { bubbles: true }),
+51 -5
View File
@@ -10,6 +10,10 @@ import type {
const root = resolve(import.meta.dirname!, "../../../.."); const root = resolve(import.meta.dirname!, "../../../..");
const phase = Deno.args[0] ?? "after"; const phase = Deno.args[0] ?? "after";
const coverage = Deno.args[1] ?? "all";
if (coverage !== "all" && coverage !== "session") {
throw new Error("Expected capture coverage: all or session");
}
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) { if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
throw new Error("Expected safe capture label"); throw new Error("Expected safe capture label");
} }
@@ -86,7 +90,39 @@ const routes: RouteScenario[] = [
route( route(
"account-redirect", "account-redirect",
"/account?fixture=anonymous", "/account?fixture=anonymous",
'button:text-is("Log in with passkey")', 'h1:text-is("Yoi")',
),
route(
"expired-account",
"/account?fixture=session-expired&tab=keys#passkeys",
'[role="status"]:has-text("Your browser session expired")',
),
route(
"expired-login",
"/login?fixture=session-expired-cancel-once&returnTo=%2Faccount%3Ftab%3Dkeys%23passkeys",
'[role="status"]:has-text("Your browser session expired")',
[
{
id: "initial",
label: "Explicit expiry and login continuation, not anonymous entry",
fullPage: true,
},
{
id: "cancelled",
label: "Cancelled passkey keeps expiry context and retry destination",
fullPage: true,
interaction: login,
ready: ready('[role="alert"]:has-text("cancelled")'),
},
{
id: "returned",
label:
"Retry confirms authentication before returning to the requested account",
fullPage: true,
interaction: login,
ready: ready('button:text-is("Add passkey to alice")'),
},
],
), ),
route("register", "/register?fixture=anonymous", `${id}:enabled`, [ route("register", "/register?fixture=anonymous", `${id}:enabled`, [
{ {
@@ -129,10 +165,10 @@ const routes: RouteScenario[] = [
}, },
{ {
id: "logout", id: "logout",
label: "Log out returns to login", label: "Log out forgets expiry and returns to public home",
fullPage: true, fullPage: true,
interaction: [click('button:text-is("Log out")')], interaction: [click('button:text-is("Log out")')],
ready: ready('button:text-is("Log in with passkey")'), ready: ready('h1:text-is("Yoi")'),
}, },
], ],
), ),
@@ -184,7 +220,7 @@ const routes: RouteScenario[] = [
route( route(
"account-session-error", "account-session-error",
"/account?fixture=session-error", "/account?fixture=session-error",
'button:text-is("Retry session")', "text=/Login status is unknown/",
), ),
route( route(
"register-session-error", "register-session-error",
@@ -297,6 +333,14 @@ const routes: RouteScenario[] = [
], ],
), ),
]; ];
const sessionCaptureIds = new Set([
"login",
"account-redirect",
"expired-account",
"expired-login",
"session-error",
"account-session-error",
]);
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true }); await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
for (const colorScheme of ["light", "dark"] as const) { for (const colorScheme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) { for (const width of [1440, 768, 390]) {
@@ -314,7 +358,9 @@ for (const colorScheme of ["light", "dark"] as const) {
auth: { kind: "anonymous" }, auth: { kind: "anonymous" },
}], }],
viewports: [{ width, height: 900, label: `${width}` }], viewports: [{ width, height: 900, label: `${width}` }],
routes, routes: coverage === "session"
? routes.filter((route) => sessionCaptureIds.has(route.id))
: routes,
redact: { redact: {
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。 // 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。 // 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
@@ -48,7 +48,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
}, },
], ],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`, `${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/entry-browser/processes`, `${root}/target/web-ux/t767-validation/entry-browser/processes`,
[], [],
); );
try { try {
@@ -230,16 +230,8 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
]; ];
await page.getByRole("button", { name: "Log out", exact: true }) await page.getByRole("button", { name: "Log out", exact: true })
.click(); .click();
await page.getByRole("button", { name: "Log in with passkey" })
.waitFor();
retainedCounts.push(
await page.locator(".workspace-alert").count(),
);
await page.getByRole("link", {
name: "Dashboard / Workspaces",
exact: true,
}).click();
await ready(page, "Log in"); await ready(page, "Log in");
assertEquals(new URL(page.url()).pathname, "/");
retainedCounts.push( retainedCounts.push(
await page.locator(".workspace-alert").count(), await page.locator(".workspace-alert").count(),
); );
@@ -253,8 +245,11 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await page.getByRole("link", { name: "Yoi home", exact: true }) await page.getByRole("link", { name: "Yoi home", exact: true })
.click(); .click();
await ready(page, "Log in"); await ready(page, "Log in");
retainedCounts.push(
await page.locator(".workspace-alert").count(),
);
const folder = const folder =
`${root}/target/web-ux/entry-browser/private-alerts-${colorScheme}-${width}`; `${root}/target/web-ux/t767-validation/entry-browser/private-alerts-${colorScheme}-${width}`;
await Deno.mkdir(folder, { recursive: true }); await Deno.mkdir(folder, { recursive: true });
const bytes = await page.screenshot({ const bytes = await page.screenshot({
path: folder + "/viewport.png", path: folder + "/viewport.png",
@@ -281,7 +276,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
JSON.stringify( JSON.stringify(
{ {
scenario: scenario:
"Workspace Stop notification → Account logout → dashboard/login → public home (SPA)", "Workspace Stop notification → Account logout → public home → login → public home (SPA)",
boundary: boundary:
"synthetic HTTP and Worker subscription; production UI/router/store", "synthetic HTTP and Worker subscription; production UI/router/store",
path: new URL(page.url()).pathname, path: new URL(page.url()).pathname,
@@ -590,13 +585,19 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
display_name: "Alice", display_name: "Alice",
auth_method: "browser_session", auth_method: "browser_session",
}, },
session: {
kind: "authenticated",
expires_at: "2099-01-01T00:00:00Z",
},
}, },
}); });
}); });
await page.evaluate(() => await page.evaluate(() =>
window.dispatchEvent(new Event("focus")) window.dispatchEvent(new Event("focus"))
); );
await page.getByText("Loading session…", { exact: true }) await page.getByRole("status").filter({
hasText: "Loading session…",
})
.waitFor(); .waitFor();
const completed = page.waitForResponse((response) => const completed = page.waitForResponse((response) =>
response.request().method() === "POST" && response.request().method() === "POST" &&
@@ -611,9 +612,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
) )
); );
assertEquals(new URL(page.url()).pathname, "/dashboard"); assertEquals(new URL(page.url()).pathname, "/dashboard");
// 確認中のsubtreeは副作用の結果を保持したままhidden/inertになる。
assertEquals( assertEquals(
await page.getByLabel("Workspace display name").count(), await page.getByLabel("Workspace display name").isVisible(),
0, false,
); );
releaseSession(); releaseSession();
const link = page.getByRole("link", { const link = page.getByRole("link", {
@@ -630,7 +632,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
0, 0,
); );
const folder = const folder =
`${root}/target/web-ux/entry-browser/confirmed-${colorScheme}-${width}`; `${root}/target/web-ux/t767-validation/entry-browser/confirmed-${colorScheme}-${width}`;
await Deno.mkdir(folder, { recursive: true }); await Deno.mkdir(folder, { recursive: true });
const bytes = await page.screenshot({ const bytes = await page.screenshot({
path: folder + "/viewport.png", path: folder + "/viewport.png",
@@ -1,5 +1,6 @@
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。 // 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。 // 実認証・署名・製品cookie設計・policyの永続化は検証しない。本文/credentialは記録しない。
// cookie-sessionは実HTTPのMax-AgeをChromiumが失効させる境界だけを検証する。Backendの判定は合成。
import { extname, join, resolve } from "@std/path"; import { extname, join, resolve } from "@std/path";
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts"; import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
@@ -22,6 +23,9 @@ type State = {
signedIn: boolean; signedIn: boolean;
whoamiCalls: number; whoamiCalls: number;
selfRegistrationEnabled: boolean; selfRegistrationEnabled: boolean;
expired: boolean;
expiresAt: string | null;
cookieSession: boolean;
}; };
const sessions = new Map<string, State>(); const sessions = new Map<string, State>();
const mime: Record<string, string> = { const mime: Record<string, string> = {
@@ -62,7 +66,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const selected = url.searchParams.get("fixture"); const selected = url.searchParams.get("fixture");
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1]; let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。 // routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
if (selected || !id || !sessions.has(id)) { if (
(selected && sessions.get(id ?? "")?.mode !== selected) || !id ||
!sessions.has(id)
) {
id = crypto.randomUUID(); id = crypto.randomUUID();
sessions.set(id, { sessions.set(id, {
mode: selected ?? "anonymous", mode: selected ?? "anonymous",
@@ -72,13 +79,54 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
selected?.startsWith("addition-") === true, selected?.startsWith("addition-") === true,
whoamiCalls: 0, whoamiCalls: 0,
selfRegistrationEnabled: selected !== "policy-disabled", selfRegistrationEnabled: selected !== "policy-disabled",
expired: selected?.startsWith("session-expired") === true,
expiresAt: null,
cookieSession: false,
}); });
} }
const state = sessions.get(id)!; const state = sessions.get(id)!;
const { mode } = state; const { mode } = state;
const sessionCookiePresent = request.headers.get("cookie")?.split(/;\s*/)
.includes("auth-fixture-session=active") === true;
if (state.cookieSession && !sessionCookiePresent) {
state.signedIn = false;
state.expired = true;
}
// 制御は同一browser contextの合成fixtureに限定。認証・cookieをJSで偽造しない。
if (path === "/__ui_fixture/session") {
if (request.method === "POST") {
const { operation } = await request.json();
if (operation === "arm-cookie") {
state.signedIn = true;
state.expired = false;
state.cookieSession = true;
state.expiresAt = new Date(Date.now() + 3000).toISOString();
return Response.json({ expires_at: state.expiresAt }, {
headers: {
"set-cookie":
"auth-fixture-session=active; Max-Age=3; Path=/; HttpOnly; SameSite=Strict",
},
});
}
if (operation === "expire" || operation === "anonymous") {
state.signedIn = false;
state.expired = operation === "expire";
state.cookieSession = false;
state.expiresAt = null;
} else return new Response(null, { status: 400 });
}
return Response.json({
session_cookie_present: sessionCookiePresent,
whoami_calls: state.whoamiCalls,
expired: state.expired,
});
}
if (path === "/__ui_fixture/webauthn.js") { if (path === "/__ui_fixture/webauthn.js") {
let extra = ""; let extra = "";
if (mode.endsWith("-cancel")) { if (mode.endsWith("-cancel-once")) {
extra =
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); let first=true; Object.defineProperty(navigator.credentials,method,{configurable:true,value:async(...args)=>{if(first){first=false;throw new DOMException('Passkey cancelled','NotAllowedError')}return original(...args)}}); }`;
} else if (mode.endsWith("-cancel")) {
extra = extra =
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`; `for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
} else if (mode.endsWith("-unsupported")) { } else if (mode.endsWith("-unsupported")) {
@@ -116,6 +164,13 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
actor: state.signedIn actor: state.signedIn
? { ...user, auth_method: "browser_session" } ? { ...user, auth_method: "browser_session" }
: null, : null,
session: state.signedIn
? {
kind: "authenticated",
expires_at: state.expiresAt ??
new Date(Date.now() + 86_400_000).toISOString(),
}
: { kind: state.expired ? "expired" : "anonymous" },
}); });
} }
if (path === "/api/auth/registration-policy") { if (path === "/api/auth/registration-policy") {
@@ -160,7 +215,15 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
} }
if (path === "/api/auth/logout") { if (path === "/api/auth/logout") {
state.signedIn = false; state.signedIn = false;
return Response.json({ status: "logged_out" }); state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ status: "logged_out" }, {
headers: {
"set-cookie":
"auth-fixture-session=; Max-Age=0; Path=/; HttpOnly; SameSite=Strict",
},
});
} }
if (path === "/api/auth/device-login/approve") { if (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 }); if (!state.signedIn) return new Response(null, { status: 401 });
@@ -187,7 +250,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response(null, { status: 503 }); return new Response(null, { status: 503 });
} }
if (mode === `${purpose}-expired` && stage === "complete") { if (mode === `${purpose}-expired` && stage === "complete") {
if (purpose === "addition") state.signedIn = false; // challengeの401だけではbrowser sessionの失効を主張しない。
return new Response(null, { status: 401 }); return new Response(null, { status: 401 });
} }
if (stage === "options") { if (stage === "options") {
@@ -197,6 +260,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
); );
} }
state.signedIn = true; state.signedIn = true;
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user }); return Response.json({ user });
} }
if (path === "/api/auth/activation/options") { if (path === "/api/auth/activation/options") {
@@ -204,6 +270,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
} }
if (path === "/api/auth/activation/complete") { if (path === "/api/auth/activation/complete") {
state.signedIn = true; state.signedIn = true;
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user }); return Response.json({ user });
} }
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。 // Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
@@ -0,0 +1,589 @@
/// <reference lib="dom" />
// auth-shellの本番build/HTTP/WebAuthn fixtureと隔離contextを再利用する。
// Max-Ageは本物のHTTP cookieをbrowserが消すことを観測するが、whoami分類は合成。
// 実Serverのsession/marker/revoke判定の証明ではない。
import { assert, assertEquals } from "@std/assert";
import type { Browser, Page } from "playwright";
type Harness = {
page: Page;
mutations: { path: string; keys: string[] }[];
go: (path: string, mode?: string, unavailableRoot?: boolean) => Promise<void>;
};
type Isolated = (
browser: Browser,
body: (harness: Harness) => Promise<void>,
) => Promise<void>;
export async function sessionNavigation(
t: Deno.TestContext,
browser: Browser,
isolated: Isolated,
origin: string,
) {
const step = (name: string, body: (harness: Harness) => Promise<void>) =>
t.step(name, () => isolated(browser, body));
const login = (page: Page) =>
page.getByRole("button", { name: "Log in with passkey", exact: true })
.click();
const at = (page: Page, target: string) =>
page.waitForURL((url) =>
url.origin === origin && url.pathname + url.search + url.hash === target
);
const loginTarget = (target: string) =>
`/login?${new URLSearchParams({ returnTo: target })}`;
const control = async (page: Page, operation: string) => {
const response = await page.request.post(`${origin}/__ui_fixture/session`, {
data: { operation },
});
assertEquals(response.status(), 200);
return response;
};
const focus = (page: Page) =>
page.evaluate(() => window.dispatchEvent(new Event("focus")));
const noPrivateRequests = (page: Page) => {
const paths: string[] = [];
page.on("request", (request) => {
const path = new URL(request.url()).pathname;
if (/^\/api\/(workspaces|w\/|hosting\/)/.test(path)) paths.push(path);
});
return paths;
};
for (
const target of [
"/w/team/tickets?filter=100%25#entry",
"/w/team/tickets/%e6%9c%aa?filter=100%25#part%25",
]
) {
await step(
`expiry retains encoded URL data ${target}`,
async ({ page, go }) => {
const privateRequests = noPrivateRequests(page);
await go(target, "session-expired");
await at(page, loginTarget(target));
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
await page.reload();
await at(page, loginTarget(target));
assertEquals(privateRequests, []);
},
);
}
await step(
"successful login retains percent query and fragment data",
async ({ page, go }) => {
const target = "/account?filter=100%25#part%25";
await go(target, "session-expired");
await at(page, loginTarget(target));
await login(page);
await at(page, target);
await page.getByRole("button", { name: "Log out", exact: true })
.waitFor();
},
);
const protectedRoutes = [
"/dashboard",
"/account",
"/hosting/settings",
"/w/private/tickets",
];
for (const path of protectedRoutes) {
await step(
`fresh anonymous ${path} goes to public root before private loaders`,
async ({ page, go, mutations }) => {
const privateRequests = noPrivateRequests(page);
await go(`${path}?view=private#selected`);
await at(page, "/");
await page.getByRole("heading", { name: "Yoi", exact: true }).waitFor();
assertEquals(await page.locator(".app-shell").count(), 0);
assertEquals(privateRequests, []);
assertEquals(mutations, []);
},
);
await step(
`expired ${path} preserves pathname query and fragment before private loaders`,
async ({ page, go }) => {
const target = `${path}?filter=a%2Fb&view=thread#selected`;
const privateRequests = noPrivateRequests(page);
await go(target, "session-expired");
await at(page, loginTarget(target));
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(privateRequests, []);
},
);
}
for (
const [path, selector] of [
["/", "h1"],
["/login", "button[type=submit]"],
["/register", "#registration-handle"],
["/activate", "#activation-handle"],
["/login/device?user_code=ABCD-EFGH", 'a[href^="/login?"]'],
["/hosting/setup", "#host-setup-token"],
]
) {
await step(
`anonymous public entry ${path} does not redirect or load private data`,
async ({ page, go }) => {
const privateRequests = noPrivateRequests(page);
await go(path);
// 固有controlのrenderまで待ち、単なるdocument load成功で済ませない。
if (path === "/register") {
await page.getByLabel("Display name", { exact: true }).waitFor();
} else await page.locator(selector).first().waitFor();
await at(page, path);
assertEquals(privateRequests, []);
},
);
}
await step(
"expired login reload cancellation and retry keep the complete original URL",
async ({ page, go, mutations }) => {
const target = "/account?tab=keys&filter=a%2Fb#passkeys";
await go(target, "session-expired-cancel-once");
await at(page, loginTarget(target));
await page.reload();
await login(page);
await page.getByRole("alert").filter({ hasText: /cancelled|timed out/i })
.waitFor();
await at(page, loginTarget(target));
assertEquals(
mutations.filter((m) => m.path.endsWith("/login/complete")).length,
0,
);
await login(page);
await at(page, target);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
await page.reload();
await at(page, target);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
},
);
for (
const mode of [
"session-error",
"session-malformed",
"session-loading",
"network-error",
]
) {
await step(
`protected ${mode} stays unknown without redirect or private loaders`,
async ({ page, go }) => {
if (mode === "network-error") {
await page.route(
"**/api/auth/whoami",
(route) => route.abort("failed"),
);
}
const privateRequests = noPrivateRequests(page);
const target = "/account?tab=keys#passkeys";
await go(target, mode);
if (mode === "session-loading") {
// 実要求がpendingであることを観測する。loadingの文言はloaderと画面で異なる。
await page.waitForLoadState("domcontentloaded");
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).count(),
0,
);
} else await page.getByText(/Login status is unknown/i).waitFor();
await at(page, target);
assertEquals(privateRequests, []);
},
);
}
await step(
"protected unknown can reload and retry without losing query or fragment",
async ({ page, go }) => {
const target = "/account?tab=keys#passkeys";
let failed = true;
await page.route(
"**/api/auth/whoami",
(route) => failed ? route.fulfill({ status: 503 }) : route.continue(),
);
await go(target, "account", true);
await page.getByText(/Login status is unknown/i).waitFor();
await at(page, target);
failed = false;
await page.reload();
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
await at(page, target);
},
);
for (const active of [false, true]) {
await step(
`real HTTP Max-Age cookie disappears and ${
active ? "active expiry" : "reload"
} returns through login`,
async ({ page, go }) => {
const target = "/account?tab=keys#after-cookie-expiry";
await go(target, "account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
const armed = await control(page, "arm-cookie");
assert(armed.headers()["set-cookie"].includes("Max-Age=3"));
const cookie = (await page.context().cookies()).find((c) =>
c.name === "auth-fixture-session"
);
assert(cookie?.httpOnly);
assert(cookie.expires > Date.now() / 1000);
if (active) {
// whoamiのexpires_atを製品timerへ渡す。テスト側clockの前進やcookie削除はしない。
const refreshed = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/whoami"
);
await focus(page);
const wire = await (await refreshed).json();
assertEquals(wire.session.kind, "authenticated");
assertEquals(
wire.session.expires_at,
(await armed.json()).expires_at,
);
}
// 固定sleepではなくHTTP要求にcookieが付かなくなる事実で同期する。
await page.evaluate(async () => {
const deadline = performance.now() + 10_000;
do {
const observation = await (await fetch("/__ui_fixture/session", {
cache: "no-store",
})).json();
if (observation.session_cookie_present === false) return;
await new Promise((resolve) => setTimeout(resolve, 100));
} while (performance.now() < deadline);
throw new Error("Primary session cookie did not naturally expire");
});
assertEquals(
(await page.context().cookies()).some((c) =>
c.name === "auth-fixture-session"
),
false,
);
// 長寿命の識別補助は残る。全消去とは異なる条件を作ったことも確認する。
assert(
(await page.context().cookies()).some((c) =>
c.name === "auth-fixture"
),
);
if (!active) await page.reload();
await at(page, loginTarget(target));
await page.reload();
await login(page);
await at(page, target);
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
},
);
}
await step(
"concurrent API 401 and repeated session checks cannot overwrite the original expired return URL",
async ({ page, go }) => {
const target = "/w/private/tickets?view=thread#concurrent-expiry";
let release!: () => void;
const held = new Promise<void>((resolve) => release = resolve);
const statuses: number[] = [];
// 本番Workspace loaderの並列requestを止める。window.fetchの呼出しだけで
// 製品API clientの401再照会を通したことにはしない。
await page.route("**/api/w/private/**", async (route) => {
await held;
statuses.push(401);
await route.fulfill({ status: 401 });
});
const requests = Promise.all([
page.waitForRequest((r) =>
new URL(r.url()).pathname === "/api/w/private/workspace"
),
page.waitForRequest((r) =>
new URL(r.url()).pathname === "/api/w/private/repositories"
),
]);
try {
await go(target, "account");
await requests;
await control(page, "expire");
release();
// focus等の外部契機なしに、同時401がwhoami再照会へ接続すること。
await at(page, loginTarget(target));
assert(statuses.length >= 2);
for (let i = 0; i < 2; i++) {
const response = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/whoami"
);
await focus(page);
await (await response).finished();
}
await at(page, loginTarget(target));
await page.unroute("**/api/w/private/**");
await login(page);
await at(page, target);
} finally {
release();
}
},
);
await step(
"delayed pre-logout authenticated response cannot restore private UI or an old return URL",
async ({ page, go }) => {
await go("/account?tab=keys#stale", "account");
await page.getByRole("button", { name: "Log out", exact: true })
.waitFor();
let started!: () => void;
let failed!: (error: unknown) => void;
let release!: () => void;
const requested = new Promise<void>((resolve, reject) => {
started = resolve;
failed = reject;
});
const held = new Promise<void>((resolve) => release = resolve);
let first = true;
await page.route("**/api/auth/whoami", async (route) => {
if (!first) return route.continue();
first = false;
try {
const old = await route.fetch({ timeout: 5000 });
started();
await held;
await route.fulfill({
response: old,
headers: { ...old.headers(), "x-auth-fixture-held": "true" },
});
} catch (error) {
failed(error);
}
});
try {
const observed = page.waitForRequest((r) =>
new URL(r.url()).pathname === "/api/auth/whoami"
);
await focus(page);
await observed;
await requested;
// 他tabの本物の製品logoutでgeneration/storage eventも通す。
const other = await page.context().newPage();
try {
await other.goto(`${origin}/account`);
await other.getByRole("button", { name: "Log out", exact: true })
.click();
await at(other, "/");
const delivered = page.waitForResponse((r) =>
r.headers()["x-auth-fixture-held"] === "true"
);
release();
await (await delivered).finished();
// 古い応答を実際に製品へ届け、readSession/renderのtask境界まで進める。
await page.evaluate(() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() =>
requestAnimationFrame(() => resolve())
)
)
);
await at(page, "/");
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).count(),
0,
);
await page.goto(`${origin}/account`);
await at(page, "/");
} finally {
await other.close();
}
} finally {
release();
}
},
);
await step(
"challenge 401 leaves an authenticated account in place and permits retry",
async ({ page, go }) => {
const target = "/account?tab=keys#challenge";
await go(target, "addition-expired");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("alert").waitFor();
await at(page, target);
assertEquals(
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
.session.kind,
"authenticated",
);
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
true,
);
},
);
await step(
"explicit logout and complete browser storage removal classify as anonymous, not expired",
async ({ page, go }) => {
await go("/account", "account");
await control(page, "arm-cookie");
await page.getByRole("button", { name: "Log out", exact: true }).click();
await at(page, "/");
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
.json();
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
});
await page.context().clearCookies();
await page.goto(`${origin}/account?tab=keys#cleared`);
await at(page, "/");
assertEquals(new URL(page.url()).searchParams.has("returnTo"), false);
},
);
for (const event of ["pageshow", "visibilitychange"] as const) {
await step(
`${event} rechecks active session and preserves expired return URL`,
async ({ page, go }) => {
const target = "/account?tab=keys#lifecycle";
await go(target, "account");
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).waitFor();
await control(page, "expire");
await page.evaluate((event) => {
if (event === "visibilitychange") {
if (document.hidden) {
throw new Error("Expected a visible test page");
}
document.dispatchEvent(new Event(event));
} else window.dispatchEvent(new Event(event));
}, event);
await at(page, loginTarget(target));
},
);
}
await step(
"forget expired session clears returnTo and fragment before a later login",
async ({ page, go, mutations }) => {
await go("/account?tab=keys#forgotten", "session-expired");
await page.getByRole("button", {
name: "Forget this session",
exact: true,
}).click();
await at(page, "/login");
await page.getByRole("button", {
name: "Log in with passkey",
exact: true,
}).waitFor();
assertEquals(
(await (await page.request.get(`${origin}/api/auth/whoami`)).json())
.session.kind,
"anonymous",
);
assertEquals(
mutations.filter((m) => m.path === "/api/auth/logout").length,
1,
);
await login(page);
await at(page, "/dashboard");
},
);
await step(
"forget session is allowed on unknown login and never claims authenticated success",
async ({ page, go, mutations }) => {
await go(
"/login?returnTo=%2Faccount%3Ftab%3Dkeys%23forgotten#entry",
"session-error",
);
await page.getByText(/Login status is unknown/i).waitFor();
await page.getByRole("button", {
name: "Forget this session",
exact: true,
}).click();
await at(page, "/login");
await page.getByText(/Login status is unknown/i).waitFor();
assertEquals(
mutations.filter((m) => m.path === "/api/auth/logout").length,
1,
);
assertEquals(
await page.getByRole("link", { name: /^Continue to/ }).count(),
0,
);
},
);
await step(
"other-tab logout clears a pending expired login return URL",
async ({ page, go }) => {
const target = "/account?tab=keys#other-tab-logout";
await go(target, "session-expired");
await at(page, loginTarget(target));
const other = await page.context().newPage();
try {
await other.goto(`${origin}${loginTarget(target)}`);
await other.getByRole("button", {
name: "Forget this session",
exact: true,
}).click();
await at(other, "/login");
await at(page, "/login");
await login(page);
await at(page, "/dashboard");
} finally {
await other.close();
}
},
);
for (const status of [403, 404]) {
await step(
`post-login resource ${status} remains a resource failure, never a relogin loop`,
async ({ page, go }) => {
const target = "/dashboard?selected=missing#resource";
let whoamiCalls = 0;
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/auth/whoami") {
whoamiCalls++;
}
});
await page.route(
"**/api/workspaces?*",
(route) => route.fulfill({ status }),
);
await go(target, "session-expired");
await at(page, loginTarget(target));
await login(page);
await at(page, target);
await page.getByText(/Workspaces could not be loaded/).waitFor();
const before = whoamiCalls;
await page.reload();
await page.getByText(/Workspaces could not be loaded/).waitFor();
await at(page, target);
// reloadごとの有限なreadで、403/404をexpiredへ変換してloopしない。
assert(whoamiCalls - before <= 4);
},
);
}
}
@@ -4,7 +4,9 @@
import { extname, join, resolve } from "@std/path"; import { extname, join, resolve } from "@std/path";
import { setupProjectionResponse } from "./setup-projection.ts"; import { setupProjectionResponse } from "./setup-projection.ts";
const [port, build] = Deno.args; const [port, build] = Deno.args;
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>"); if (!port || !build) {
throw new Error("usage: fixture-server.ts <port> <build-root>");
}
const buildRoot = resolve(build); const buildRoot = resolve(build);
const mime: Record<string, string> = { const mime: Record<string, string> = {
".html": "text/html", ".html": "text/html",
@@ -23,9 +25,15 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === "/health") return new Response("ok"); if (url.pathname === "/health") return new Response("ok");
const selected = url.searchParams.get("fixture"); const selected = url.searchParams.get("fixture");
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected; if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ?? setupMode = selected;
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on"; }
const mode = selected ??
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/")
? setupMode
: null) ??
"admin-on";
if (selected) policies.delete(mode); if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null); const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
if (setup) return setup; if (setup) return setup;
@@ -33,6 +41,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const ordinary = mode.startsWith("ordinary"); const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") { if (url.pathname === "/api/auth/whoami") {
return Response.json({ return Response.json({
session: !anonymous
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: anonymous ? null : { actor: anonymous ? null : {
user_id: "fixture-user", user_id: "fixture-user",
account_id: "fixture-account", account_id: "fixture-account",
@@ -46,7 +57,9 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (url.pathname === "/api/hosting/access") { if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous }); return Response.json({ is_admin: !ordinary && !anonymous });
} }
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") }); const policy = () => ({
self_registration_enabled: policies.get(mode) ?? !mode.includes("off"),
});
if (url.pathname === "/api/auth/registration-policy") { if (url.pathname === "/api/auth/registration-policy") {
if (mode === "account-error") { if (mode === "account-error") {
return new Response("synthetic availability failure", { status: 503 }); return new Response("synthetic availability failure", { status: 503 });
@@ -56,12 +69,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
} }
if (url.pathname === "/api/hosting/settings") { if (url.pathname === "/api/hosting/settings") {
if (anonymous || ordinary) { if (anonymous || ordinary) {
return new Response("synthetic denied", { status: anonymous ? 401 : 403 }); return new Response("synthetic denied", {
status: anonymous ? 401 : 403,
});
} }
if (mode === "admin-load-error") { if (mode === "admin-load-error") {
return new Response("synthetic settings failure", { status: 503 }); return new Response("synthetic settings failure", { status: 503 });
} }
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) { if (
mode === "admin-loading" ||
(mode === "admin-saving" && request.method === "PUT")
) {
return new Promise<Response>(() => {}); return new Promise<Response>(() => {});
} }
if (request.method === "PUT") { if (request.method === "PUT") {
@@ -80,13 +98,18 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response("unexpected fixture API", { status: 404 }); return new Response("unexpected fixture API", { status: 404 });
} }
const path = join(buildRoot, decodeURIComponent(url.pathname)); const path = join(buildRoot, decodeURIComponent(url.pathname));
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 }); if (!path.startsWith(buildRoot + "/")) {
return new Response("invalid path", { status: 400 });
}
let bytes: Uint8Array; let bytes: Uint8Array;
let extension = extname(path); let extension = extname(path);
try { try {
bytes = await Deno.readFile(path); bytes = await Deno.readFile(path);
} catch (error) { } catch (error) {
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) { if (
!(error instanceof Deno.errors.NotFound) &&
!(error instanceof Deno.errors.IsADirectory)
) {
throw error; throw error;
} }
bytes = await Deno.readFile(join(buildRoot, "index.html")); bytes = await Deno.readFile(join(buildRoot, "index.html"));
@@ -106,6 +129,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
"content-type": mime[extension] ?? "application/octet-stream", "content-type": mime[extension] ?? "application/octet-stream",
"cache-control": "no-store", "cache-control": "no-store",
}); });
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`); if (selected) {
headers.set(
"set-cookie",
`ui-fixture=${selected}; Path=/; SameSite=Strict`,
);
}
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers }); return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
}); });
@@ -189,12 +189,14 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
await page.goto( await page.goto(
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`, `http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
); );
await page.getByText( if (mode === "anonymous") {
mode === "ordinary" await page.waitForURL("http://127.0.0.1:15379/");
? "Hosting administrator access is required." await page.getByRole("heading", { name: "Yoi", exact: true })
: "Sign in with a hosting administrator account to view these settings.", .waitFor();
{ exact: true }, } else {await page.getByText(
).waitFor(); "Hosting administrator access is required.",
{ exact: true },
).waitFor();}
assertEquals(await edit.count(), 0); assertEquals(await edit.count(), 0);
assertEquals(await checkbox.count(), 0); assertEquals(await checkbox.count(), 0);
assertEquals( assertEquals(
@@ -285,16 +287,19 @@ Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown sa
await page.locator(ready).waitFor(); await page.locator(ready).waitFor();
await noOverflow(page); await noOverflow(page);
} }
// Navigation may also cancel the shell's concurrent identity/access // 遷移でidentity/settingsの読み取りや遅延font取得がbrowserに取消されることはある。
// reads. Only an explicit browser cancellation of these reads is expected. // 通信失敗一般を許すのではなく、これらの明示的な取消だけを区別する。
assert( assert(
failedRequests.every(({ url, error }) => failedRequests.every(({ url, error }) =>
error === "net::ERR_ABORTED" && [ error === "net::ERR_ABORTED" && ([
"/api/hosting/settings", "/api/hosting/settings",
"/api/auth/registration-policy", "/api/auth/registration-policy",
"/api/auth/whoami", "/api/auth/whoami",
"/api/hosting/access", "/api/hosting/access",
].some((path) => url.endsWith(path)) ].some((path) => url.endsWith(path)) ||
/^\/_app\/immutable\/assets\/[^/]+\.woff2$/.test(
new URL(url).pathname,
))
), ),
JSON.stringify(failedRequests), JSON.stringify(failedRequests),
); );
@@ -29,7 +29,12 @@ export function setupProjectionResponse(
} }
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null, session: stages.get(mode) === "login"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: stages.get(mode) === "login"
? { ...user, auth_method: "browser_session" }
: null,
}); });
} }
if (path === "/api/hosting/access") { if (path === "/api/hosting/access") {
@@ -54,7 +59,10 @@ export function setupProjectionResponse(
return Response.json({ user }); return Response.json({ user });
} }
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } }); return Response.json({
challenge_id: "fixture-login",
public_key: { challenge: "dWk" },
});
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login"); stages.set(mode, "login");
@@ -68,6 +68,7 @@ async function loadPageWithFailure(failedSuffix: string): Promise<LoadedPage> {
}; };
const envelope = loadRepositoryAccessPage( const envelope = loadRepositoryAccessPage(
{ {
parent: async () => ({}),
fetch: fetcher as typeof fetch, fetch: fetcher as typeof fetch,
params: { workspaceId: "workspace-1" }, params: { workspaceId: "workspace-1" },
url: new URL( url: new URL(
+68 -1
View File
@@ -1,4 +1,5 @@
import { assertEquals } from "jsr:@std/assert"; import { assertEquals } from "jsr:@std/assert";
import { browserSession } from "../src/lib/workspace/auth/session-state.ts";
import { load, ssr } from "../src/routes/+layout.ts"; import { load, ssr } from "../src/routes/+layout.ts";
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => { Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
@@ -16,22 +17,88 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
"/hosting/setup", "/hosting/setup",
] ]
) { ) {
const original = globalThis.fetch;
globalThis.fetch = async () =>
Response.json({
actor: {
user_id: "u",
account_id: "a",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
},
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
let calls = 0; let calls = 0;
const event = { const event = {
depends: () => {},
url: new URL(path, "https://workspace.test"), url: new URL(path, "https://workspace.test"),
fetch: () => { fetch: () => {
calls++; calls++;
throw new Error("Workspace API must not be called"); throw new Error("Workspace API must not be called");
}, },
} as unknown as Parameters<typeof load>[0]; } as unknown as Parameters<typeof load>[0];
assertEquals(await load(event).deferredCatalog, { assertEquals(await (await load(event)).deferredCatalog, {
ok: true, ok: true,
data: { accessibleWorkspaces: [], workspaceCatalogError: null }, data: { accessibleWorkspaces: [], workspaceCatalogError: null },
}); });
assertEquals(calls, 0); assertEquals(calls, 0);
browserSession.dispose();
globalThis.fetch = original;
} }
}); });
Deno.test("workspace frontend serves deep links through the client SPA", () => { Deno.test("workspace frontend serves deep links through the client SPA", () => {
assertEquals(ssr, false); assertEquals(ssr, false);
}); });
Deno.test("root admission never starts private catalog while anonymous, expired, loading or unknown", async () => {
const original = globalThis.fetch;
try {
for (
const condition of ["anonymous", "expired", "error", "loading"] as const
) {
browserSession.invalidate();
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
globalThis.fetch = async () => {
if (condition === "loading") await pending;
return condition === "error" || condition === "loading"
? new Response(null, { status: 503 })
: Response.json({ actor: null, session: { kind: condition } });
};
let privateCalls = 0;
const event = {
url: new URL("https://workspace.test/w/team/tickets?q=mine#entry"),
depends: () => {},
fetch: () => {
++privateCalls;
throw new Error("private fetch before admission");
},
} as unknown as Parameters<typeof load>[0];
const result = load(event).then(() => null, (rejection) => rejection);
await Promise.resolve();
assertEquals(privateCalls, 0);
release();
const rejected = await result;
assertEquals(
rejected.status,
condition === "anonymous" || condition === "expired" ? 307 : 503,
);
if (condition === "anonymous") assertEquals(rejected.location, "/");
if (condition === "expired") {
assertEquals(
rejected.location,
"/login?returnTo=%2Fw%2Fteam%2Ftickets%3Fq%3Dmine%23entry",
);
}
assertEquals(privateCalls, 0);
}
} finally {
browserSession.dispose();
globalThis.fetch = original;
}
});
@@ -10,6 +10,10 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
if (url.pathname === "/health") return new Response("ok"); if (url.pathname === "/health") return new Response("ok");
if (url.pathname === "/api/auth/whoami") { if (url.pathname === "/api/auth/whoami") {
return Response.json({ return Response.json({
session:
request.headers.get("cookie")?.includes("fixture-actor=authenticated")
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor:
request.headers.get("cookie")?.includes("fixture-actor=authenticated") request.headers.get("cookie")?.includes("fixture-actor=authenticated")
? { ? {
@@ -11,8 +11,21 @@ import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fi
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
const url = new URL(request.url); const url = new URL(request.url);
if (url.pathname === "/api/auth/whoami") return Response.json({ actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" } }); if (url.pathname === "/api/auth/whoami") {
if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false }); return Response.json({
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "fixture-user",
account_id: "fixture-owner",
handle: "alice",
display_name: "Alice",
auth_method: "browser_session",
},
});
}
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: false });
}
if (url.pathname === "/api/workspaces" && request.method === "POST") { if (url.pathname === "/api/workspaces" && request.method === "POST") {
return Response.json({ return Response.json({
message: message: