fix: integrate passkey results with current dogfooding shell

Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
This commit is contained in:
2026-10-26 09:50:13 +09:00
67 changed files with 5302 additions and 542 deletions
+112 -5
View File
@@ -225,6 +225,10 @@ impl_openapi_schema!(
PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest,
PasskeySummary,
PasskeyListResponse,
PasskeyAdditionResultRequest,
PasskeyAdditionResultResponse,
PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest,
@@ -648,6 +652,33 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
#[get(
"/api/auth/passkeys",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_list(
&self,
#[extension] context: ServerRequestContext,
) -> Result<PasskeyListResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/result",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_result(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyAdditionResultRequest,
) -> Result<PasskeyAdditionResultResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/options",
status = 200,
@@ -4115,6 +4146,8 @@ impl RequestActor {
pub struct WhoamiResponse {
pub actor: Option<RequestActor>,
pub session: BrowserSessionState,
/// Non-secret comparison evidence, never an authentication credential.
pub authentication_reference: Option<String>,
}
/// Browser navigation evidence, not an authorization grant or a generic 401 classification.
@@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest {
#[serde(deny_unknown_fields)]
pub struct AuthUserResponse {
pub user: AuthenticatedUser,
/// Comparison evidence for the exact newly issued session, not a credential.
pub authentication_reference: String,
}
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
@@ -4158,16 +4193,64 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>,
}
/// Adds a credential to the current authenticated actor; target identity is never public input.
/// Adds to the current actor. Expected identity/binding fields only fence stale UI;
/// they never select an owner or authorize an addition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest {
pub expected_user_id: String,
pub expected_account_id: String,
pub expected_authentication_reference: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>,
}
/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.
/// Normal-login verification is observed verifier success, not inferred discoverability.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeySummary {
pub key_reference: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub normal_login_verified_at: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyListResponse {
pub user: AuthenticatedUser,
pub keys: Vec<PasskeySummary>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionResultRequest {
pub challenge_id: String,
pub expected_user_id: String,
pub expected_account_id: String,
/// Original options authentication reference; the current same-owner session may differ.
pub expected_authentication_reference: String,
}
/// Unknown is not proof that registration failed: completion may still be in flight.
/// Saved proves the credential commit, not session issuance or current session health.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
pub enum PasskeyAdditionResultResponse {
Unknown {},
Saved {
user: AuthenticatedUser,
key: PasskeySummary,
},
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String {
AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config),
PasskeySummary::decl(&config),
PasskeyListResponse::decl(&config),
PasskeyAdditionResultRequest::decl(&config),
PasskeyAdditionResultResponse::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config),
@@ -14069,7 +14156,8 @@ mod tests {
"display_name": "Hare",
"auth_method": "browser_session"
},
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" },
"authentication_reference": "synthetic-session-reference"
});
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO");
@@ -14091,7 +14179,8 @@ mod tests {
"account_id": "account-1",
"handle": "hare",
"display_name": "Hare"
}
},
"authentication_reference": "synthetic-session-reference"
});
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
.expect("server auth-user fixture should match shared DTO");
@@ -14264,7 +14353,7 @@ mod tests {
serde_json::json!({"kind":"expired"}),
serde_json::json!({"kind":"authenticated", "expires_at":null}),
] {
let value = serde_json::json!({"actor":null, "session":session});
let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null});
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value);
}
@@ -14802,10 +14891,28 @@ mod openapi_artifact_tests {
);
}
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_err()
);
}
#[test]
fn passkey_addition_requires_expected_owner_and_binding() {
let expected = serde_json::json!({
"expected_user_id": "user-a", "expected_account_id": "account-a",
"expected_authentication_reference": "reference"
});
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(expected.clone()).is_ok());
for field in [
"expected_user_id",
"expected_account_id",
"expected_authentication_reference",
] {
let mut missing = expected.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(missing).is_err());
}
}
#[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into())
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())
]
);
for (table, rows) in retained.iter().zip(original.iter()) {
+17 -1
View File
@@ -228,13 +228,21 @@ fn resolve_whoami_sync(
use server_api::{BrowserSessionState, WhoamiResponse};
let anonymous = || WhoamiResponse {
actor: None,
authentication_reference: None,
session: BrowserSessionState::Anonymous {},
};
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
if let Some((actor, expires_at)) =
resolve_credentials_sync(store, bearer, session_token.clone())?
resolve_credentials_sync(store, bearer.clone(), session_token.clone())?
{
let credential = match actor.auth_method {
ActorAuthMethod::ApiToken => bearer.as_deref(),
ActorAuthMethod::BrowserSession => session_token.as_deref(),
}
.ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?;
let authentication_reference = Some(authentication_reference(&token_hash(credential)));
return Ok(WhoamiResponse {
authentication_reference,
actor: Some(actor),
session: BrowserSessionState::Authenticated { expires_at },
});
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
return Ok(WhoamiResponse {
actor: None,
authentication_reference: None,
session: BrowserSessionState::Expired {},
});
}
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
Ok(anonymous())
}
/// Domain-separated public comparison value; never accepted as a credential.
pub(crate) fn authentication_reference(credential_hash: &str) -> String {
token_hash(&format!(
"passkey-authentication-reference:{credential_hash}"
))
}
fn within_session_expiry_grace(expires_at: &str, now: DateTime<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)
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
DROP TABLE ticket_implementation_results;
DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
@@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
// Reconstruct the prior composition before its new business capture existed.
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
@@ -679,7 +687,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
if historical {
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
DROP TABLE subject_create_recoveries;
DROP TRIGGER passkey_metadata_insert;
DROP TABLE passkey_addition_receipts;
DROP TABLE passkey_metadata;
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases;
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
@@ -0,0 +1,18 @@
CREATE TABLE passkey_metadata (
credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
key_reference TEXT NOT NULL UNIQUE,
normal_login_verified_at TEXT
);
INSERT INTO passkey_metadata(credential_id, key_reference)
SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials;
CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials
BEGIN
INSERT INTO passkey_metadata(credential_id, key_reference)
VALUES (NEW.credential_id, lower(hex(randomblob(16))));
END;
CREATE TABLE passkey_addition_receipts (
challenge_id TEXT PRIMARY KEY,
credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
authentication_reference TEXT NOT NULL
);
+194 -5
View File
@@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
.merge(server_api::server_api_axum::hosting_settings_update(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_list(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_result(
service.clone(),
))
.merge(server_api::server_api_axum::auth_passkey_addition_options(
service.clone(),
))
@@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService {
)
}
async fn auth_passkey_list(
&self,
context: server_api::ServerRequestContext,
) -> std::result::Result<server_api::PasskeyListResponse, server_api::RepositoryApiError> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_owner_authority(&api, &headers, false)?;
Ok::<_, ApiError>(server_api::PasskeyListResponse {
keys: api.store.list_passkey_summaries(&user.user_id)?,
user: user_response(user),
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_result(
&self,
context: server_api::ServerRequestContext,
request: server_api::PasskeyAdditionResultRequest,
) -> std::result::Result<
server_api::PasskeyAdditionResultResponse,
server_api::RepositoryApiError,
> {
let headers = contract_request_headers(&context)?;
let api = self.auth_api();
api.store
.db_executor()
.run(move || {
let (user, _) = passkey_addition_authority(&api, &headers)?;
if user.user_id != request.expected_user_id
|| user.account_id != request.expected_account_id
{
return Err(auth_error(
"passkey_authentication_changed",
"current authenticated owner does not match the operation owner",
)
.into());
}
let key = api.store.read_passkey_addition_result(
&user.user_id,
&request.expected_authentication_reference,
&request.challenge_id,
)?;
Ok::<_, ApiError>(match key {
Some(key) => server_api::PasskeyAdditionResultResponse::Saved {
user: user_response(user),
key,
},
None => server_api::PasskeyAdditionResultResponse::Unknown {},
})
})
.await
.map_err(ApiError::into_repository_api_error)
}
async fn auth_passkey_addition_options(
&self,
context: server_api::ServerRequestContext,
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
.db_executor()
.run(move || {
let (user, authentication) = passkey_addition_authority(&api, &headers)?;
require_expected_passkey_owner(
&user,
&authentication,
&request.expected_user_id,
&request.expected_account_id,
&request.expected_authentication_reference,
)?;
start_passkey_registration(
&api,
&headers,
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
fn passkey_addition_authority(
api: &ServerAuthApi,
headers: &HeaderMap,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
passkey_owner_authority(api, headers, true)
}
fn passkey_owner_authority(
api: &ServerAuthApi,
headers: &HeaderMap,
require_origin: bool,
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
let auth = auth_public_config(&api.config);
let actor = crate::auth::resolve_request_actor_sync(
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
})?;
let binding = match actor.auth_method {
ActorAuthMethod::BrowserSession => {
if request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
return Err(Error::WorkspacePermissionDenied(
"cookie-authenticated passkey addition requires the configured Browser origin"
.into(),
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
Ok((user, binding))
}
fn require_expected_passkey_owner(
user: &UserRecord,
binding: &PasskeyAuthenticationBinding,
expected_user_id: &str,
expected_account_id: &str,
expected_authentication_reference: &str,
) -> ApiResult<()> {
if user.user_id != expected_user_id
|| user.account_id != expected_account_id
|| binding.reference() != expected_authentication_reference
{
return Err(auth_error(
"passkey_authentication_changed",
"authentication changed; refresh account before starting another operation",
)
.into());
}
Ok(())
}
fn start_passkey_registration(
api: &ServerAuthApi,
headers: &HeaderMap,
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
/// Credential commit precedes session issuance. If issuing the session or
/// delivering the response fails, the credential can already exist: log in with
/// that key rather than overwriting it or replaying the consumed completion.
/// After an unknown commit outcome, try login first. Restart new-account
/// Own additions save a receipt atomically with the credential: a currently
/// authenticated same owner can inspect it using the original operation binding
/// reference even if the response body was lost after Set-Cookie took effect.
/// This read does not authorize a completion or prove current session health.
/// For unknown new-account outcomes, try login first. Restart new-account
/// enrollment with fresh options only for an unused handle with no persisted
/// identity; legacy pre-created identities follow the migration policy on
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
request: PasskeyRegistrationCompleteRequest,
completion: EnrollmentCompletion<'_>,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
let authority = match completion {
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
_ => None,
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
&error.to_string(),
)
})?;
api.store.register_verified_passkey(
api.store.register_verified_passkey_with_result(
&envelope.target,
&PasskeyCredentialRecord {
credential_id: passkey_credential_id(&passkey)?,
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
created_at: crate::auth::now_rfc3339(),
last_used_at: None,
},
is_addition.then_some(request.challenge_id.as_str()),
)?;
issue_browser_session(api, user)
}
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
.into());
}
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
let auth_result = match state {
PasskeyLoginState::Discoverable(state) => {
// userHandle is unsigned routing data, never identity authority. The
@@ -30635,7 +30742,7 @@ fn complete_passkey_login(
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
api.store
.upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id,
credential_id: credential_id.clone(),
user_id: user.user_id.clone(),
public_key_cose,
transports_json: stored.transports_json,
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()),
})?;
if normal_login {
api.store
.mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?;
}
issue_browser_session(api, user)
}
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
Ok((
AuthUserResponse {
user: user_response(user),
authentication_reference: crate::auth::authentication_reference(&token_hash(
&session_token,
)),
},
server_api::SetCookieHeader::new(cookie),
))
@@ -52401,6 +52515,7 @@ mod tests {
include!("server_activation_tests.rs");
include!("server_discoverable_tests.rs");
include!("server_passkey_results_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = ResidentAuthenticator;
@@ -52454,9 +52569,61 @@ mod tests {
origin: Option<&str>,
body: Value,
) -> (StatusCode, HeaderMap, Value) {
let mut body = body;
if path == "/api/auth/passkeys/addition/options"
&& body.get("expected_user_id").is_none()
{
let expected = self.expected_owner(cookie, bearer).await;
for (field, value) in expected.as_object().unwrap() {
body[field] = value.clone();
}
}
Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await
}
async fn get_authenticated(
&self,
path: &str,
cookie: Option<&str>,
bearer: Option<&str>,
) -> (StatusCode, Value) {
let mut request = Request::builder().uri(path);
if let Some(cookie) = cookie {
request = request.header(axum::http::header::COOKIE, cookie);
}
if let Some(bearer) = bearer {
request = request.header(
axum::http::header::AUTHORIZATION,
format!("Bearer {bearer}"),
);
}
let response = self
.app
.clone()
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
let status = response.status();
let body = serde_json::from_slice(
&to_bytes(response.into_body(), usize::MAX).await.unwrap(),
)
.unwrap();
(status, body)
}
async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value {
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", cookie, bearer)
.await;
assert_eq!(status, StatusCode::OK);
serde_json::json!({
"expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"),
"expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"),
"expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"),
})
}
async fn post_to(
app: Router,
path: &str,
@@ -52612,6 +52779,14 @@ mod tests {
.next()
.unwrap()
.to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"completion must identify its exact issued session"
);
(
cookie,
serde_json::from_value::<AuthUserResponse>(body)
@@ -52699,6 +52874,14 @@ mod tests {
.next()
.unwrap()
.to_string();
let (status, whoami) = self
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body["authentication_reference"] == whoami["authentication_reference"],
"login completion must identify its exact issued session"
);
(
cookie,
serde_json::from_value::<AuthUserResponse>(body)
@@ -53222,6 +53405,9 @@ mod tests {
.auth_passkey_addition_options(
context,
server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()),
},
)
@@ -53236,6 +53422,9 @@ mod tests {
.auth_passkey_addition_options(
context,
server_api::PasskeyAdditionOptionsRequest {
expected_user_id: user.user_id.clone(),
expected_account_id: user.account_id.clone(),
expected_authentication_reference: "not-authority".into(),
browser_origin: Some(ORIGIN_URL.into()),
},
)
@@ -54241,7 +54430,7 @@ mod tests {
.header(axum::http::header::COOKIE, &marker)
.header(ORIGIN, &auth.origin)
.header(CONTENT_TYPE, "application/json")
.body(Body::from("{}"))
.body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#))
.unwrap(),
)
.await
@@ -0,0 +1,399 @@
// Integration boundary: generated HTTP admission -> real WebAuthn verifier ->
// SQLite credential/receipt transaction -> real session issuance. Only the
// authenticator is synthetic; failures are SQLite triggers, not success fakes.
impl Fixture {
async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) {
let mut request = self.expected_owner(Some(cookie), None).await;
request["challenge_id"] = challenge_id.into();
let (status, _, body) = self
.post(
"/api/auth/passkeys/addition/result",
Some(cookie),
Some(ORIGIN_URL),
request,
)
.await;
(status, body)
}
}
#[tokio::test]
async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() {
for failure in [
"before_credential",
"receipt_insert",
"session_insert",
"lost_response",
"lost_body_accepted_cookie",
] {
let f = Fixture::new().await;
let mut original = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await;
let original_expectation = f.expected_owner(Some(&cookie), None).await;
let options = f.options("addition", Some(&cookie), "").await;
let challenge_id = options.challenge_id.clone();
let mut added = authenticator();
let request = Fixture::credential(options, &mut added);
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "unknown");
let table = match failure {
"before_credential" => Some("passkey_credentials"),
"receipt_insert" => Some("passkey_addition_receipts"),
"session_insert" => Some("browser_sessions"),
_ => None,
};
if let Some(table) = table {
f.api.config_store.with_conn(|conn| {
conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?;
Ok(())
}).unwrap();
}
// Discard the completion body; one variant still accepts Set-Cookie.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request.clone(),
)
.await;
assert_eq!(
status,
if matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
StatusCode::OK
} else {
StatusCode::INTERNAL_SERVER_ERROR
}
);
if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
}
let saved = matches!(
failure,
"session_insert" | "lost_response" | "lost_body_accepted_cookie"
);
if failure == "lost_body_accepted_cookie" {
let accepted_cookie = headers[axum::http::header::SET_COOKIE]
.to_str()
.unwrap()
.split(';')
.next()
.unwrap();
let current = f.expected_owner(Some(accepted_cookie), None).await;
assert!(
current["expected_authentication_reference"]
!= original_expectation["expected_authentication_reference"],
"addition issued a distinct session"
);
let mut recovery = original_expectation.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(accepted_cookie),
Some(ORIGIN_URL),
recovery,
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(result["user"]["user_id"], owner.user_id);
}
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], if saved { "saved" } else { "unknown" });
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
let receipt_count = f
.api
.config_store
.with_conn(|conn| {
Ok(conn.query_row(
"SELECT COUNT(*) FROM passkey_addition_receipts",
[],
|row| row.get::<_, u64>(0),
)?)
})
.unwrap();
assert_eq!(receipt_count, u64::from(saved));
if saved {
assert_eq!(result["user"]["user_id"], owner.user_id);
let (_, listed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
let reference = result["key"]["key_reference"].as_str().unwrap();
assert!(
listed["keys"]
.as_array()
.unwrap()
.iter()
.any(|key| key["key_reference"] == reference)
);
assert!(result["key"]["normal_login_verified_at"].is_null());
}
if table.is_some() {
f.api
.config_store
.with_conn(|conn| {
conn.execute_batch("DROP TRIGGER inject_passkey_failure")?;
Ok(())
})
.unwrap();
}
// Consumed completion is not retry authority, even after a transaction failure.
let (status, headers, _) = f
.post(
"/api/auth/passkeys/addition/complete",
Some(&cookie),
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
if saved {
assert_eq!(f.discoverable_login(&mut added).await, owner);
}
}
}
#[tokio::test]
async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() {
let f = Fixture::new().await;
let mut alice_key = authenticator();
let (alice_cookie, alice) = f
.enroll("registration", None, "alice", &mut alice_key)
.await;
let (bob_cookie, _) = f
.enroll("registration", None, "bob", &mut authenticator())
.await;
let expected = f.expected_owner(Some(&alice_cookie), None).await;
let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await;
let count_challenges = || {
f.api
.config_store
.with_conn(|conn| {
Ok(
conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| {
row.get::<_, u64>(0)
})?,
)
})
.unwrap()
};
let before = count_challenges();
for cookie in [&bob_cookie, &replacement_cookie] {
let (status, _, _) = f
.post(
"/api/auth/passkeys/addition/options",
Some(cookie),
Some(ORIGIN_URL),
expected.clone(),
)
.await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(
count_challenges(),
before,
"stale options must not persist a ceremony"
);
}
let options = f.options("addition", Some(&alice_cookie), "").await;
let challenge_id = options.challenge_id.clone();
let request = Fixture::credential(options, &mut authenticator());
assert_eq!(
f.post(
"/api/auth/passkeys/addition/complete",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::OK
);
assert_eq!(
f.addition_result(&alice_cookie, &challenge_id).await.1["status"],
"saved"
);
// Current owner authorization is separate from original operation binding.
let mut recovery = expected.clone();
recovery["challenge_id"] = challenge_id.clone().into();
let (status, _, result) = f
.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery.clone(),
)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result["status"], "saved");
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&bob_cookie),
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
None,
Some(ORIGIN_URL),
recovery.clone()
)
.await
.0,
StatusCode::FORBIDDEN
);
recovery["expected_authentication_reference"] = "changed-original-reference".into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&replacement_cookie),
Some(ORIGIN_URL),
recovery
)
.await
.0,
StatusCode::BAD_REQUEST
);
// Bob's own expected fields cannot discover Alice's operation either.
let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await;
assert_eq!(status, StatusCode::OK);
assert_eq!(result, serde_json::json!({"status":"unknown"}));
assert_eq!(f.credentials(&alice).len(), 2);
assert_eq!(
f.post(
"/api/auth/logout",
Some(&alice_cookie),
Some(ORIGIN_URL),
serde_json::json!({})
)
.await
.0,
StatusCode::OK
);
let mut request = expected;
request["challenge_id"] = challenge_id.into();
assert_eq!(
f.post(
"/api/auth/passkeys/addition/result",
Some(&alice_cookie),
Some(ORIGIN_URL),
request
)
.await
.0,
StatusCode::FORBIDDEN
);
}
#[tokio::test]
async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() {
let f = Fixture::new().await;
let mut key = authenticator();
let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await;
let (other_cookie, _) = f
.enroll("registration", None, "other", &mut authenticator())
.await;
assert_eq!(
f.get_authenticated("/api/auth/passkeys", None, None)
.await
.0,
StatusCode::FORBIDDEN
);
let (status, list) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(status, StatusCode::OK);
assert_eq!(list["user"]["user_id"], owner.user_id);
assert_eq!(list["keys"].as_array().unwrap().len(), 1);
let summary = &list["keys"][0];
assert!(summary["normal_login_verified_at"].is_null());
assert!(summary["last_used_at"].is_null());
let reference = summary["key_reference"].as_str().unwrap();
assert_eq!(reference.len(), 32);
assert_ne!(reference, f.credentials(&owner)[0].credential_id);
assert_eq!(
summary
.as_object()
.unwrap()
.keys()
.map(String::as_str)
.collect::<std::collections::BTreeSet<_>>(),
[
"key_reference",
"created_at",
"last_used_at",
"normal_login_verified_at"
]
.into_iter()
.collect()
);
let (_, other) = f
.get_authenticated("/api/auth/passkeys", Some(&other_cookie), None)
.await;
assert_ne!(other["keys"][0]["key_reference"], reference);
assert_eq!(f.login("owner", &mut key).await, owner);
let (_, legacy) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert!(legacy["keys"][0]["last_used_at"].is_string());
assert!(legacy["keys"][0]["normal_login_verified_at"].is_null());
// Rejected normal assertion does not produce confirmation metadata.
let mut invalid = f.discoverable_request(&mut key).await;
invalid.credential.response.signature = vec![0u8; 64].into();
assert_eq!(
f.post(
"/api/auth/passkeys/login/complete",
None,
Some(ORIGIN_URL),
serde_json::to_value(invalid).unwrap()
)
.await
.0,
StatusCode::BAD_REQUEST
);
assert!(
f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0]
.normal_login_verified_at
.is_none()
);
assert_eq!(f.discoverable_login(&mut key).await, owner);
let (_, confirmed) = f
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
.await;
assert_eq!(confirmed["keys"][0]["key_reference"], reference);
assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string());
}
#[tokio::test]
async fn unknown_transport_metadata_does_not_discard_verified_registration() {
let f = Fixture::new().await;
let options = f.options("registration", None, "future-transport").await;
let mut key = authenticator();
let mut request = Fixture::credential(options, &mut key);
request["credential"]["response"]["transports"] =
serde_json::json!(["internal", "future-transport", "test"]);
let (status, _, body) = f
.post(
"/api/auth/passkeys/registration/complete",
None,
Some(ORIGIN_URL),
request,
)
.await;
assert_eq!(status, StatusCode::OK);
let user = serde_json::from_value::<AuthUserResponse>(body)
.unwrap()
.user;
assert_eq!(f.discoverable_login(&mut key).await, user);
}
+206 -4
View File
@@ -119,7 +119,7 @@ pub use ticket::{
};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 102;
const LATEST_SCHEMA_VERSION: i64 = 103;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable failed Subject creation recovery",
apply: migrate_subject_create_recovery_v101_to_v102,
},
Migration {
version: 103,
name: "Passkey metadata and atomic addition results",
apply: migrate_passkey_results_v102_to_v103,
},
];
#[derive(Clone, Copy)]
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
ApiToken(String),
}
impl PasskeyAuthenticationBinding {
pub(crate) fn reference(&self) -> String {
let hash = match self {
Self::BrowserSession(hash) | Self::ApiToken(hash) => hash,
};
crate::auth::authentication_reference(hash)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PasskeyCredentialRecord {
pub credential_id: String,
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
) -> Result<()> {
self.register_verified_passkey_with_result(target, credential, None)
}
/// Receipt and credential commit together; no receipt claims session issuance.
fn register_verified_passkey_with_result(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()>;
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>>;
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>>;
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()>;
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
fn get_passkey_credential(
@@ -6972,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn register_verified_passkey(
fn register_verified_passkey_with_result(
&self,
target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()> {
self.with_immediate_transaction(|tx| {
let user = match target {
@@ -7042,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
)?;
if let Some(challenge_id) = addition_challenge_id {
let PasskeyRegistrationTarget::Addition { user, authentication } = target else {
return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition"));
};
tx.execute(
"INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)",
params![challenge_id, credential.credential_id, user.user_id, authentication.reference()],
)?;
}
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
@@ -7051,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
})
}
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>> {
self.with_conn(|conn| {
let mut stmt = conn.prepare(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at
FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id)
WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference",
)?;
let rows = stmt.query_map([user_id], read_passkey_summary)?;
rows.collect::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
})
}
fn read_passkey_addition_result(
&self,
user_id: &str,
original_authentication_reference: &str,
challenge_id: &str,
) -> Result<Option<server_api::PasskeySummary>> {
self.with_conn(|conn| {
// Current authenticated ownership authorizes reading. The original
// binding reference identifies the saved operation, not the current
// session: Set-Cookie may have arrived even if its body was lost.
let result = conn.query_row(
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference
FROM passkey_addition_receipts r
JOIN passkey_credentials c USING(credential_id)
JOIN passkey_metadata m USING(credential_id)
WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2",
params![challenge_id, user_id],
|row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)),
).optional()?;
match result {
Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)),
Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")),
None => Ok(None),
}
})
}
fn mark_passkey_normal_login_verified(
&self,
credential_id: &str,
verified_at: &str,
) -> Result<()> {
self.with_conn(|conn| {
conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?;
Ok(())
})
}
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?;
@@ -11911,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String {
)
}
fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::PasskeySummary> {
Ok(server_api::PasskeySummary {
key_reference: row.get(0)?,
created_at: row.get(1)?,
last_used_at: row.get(2)?,
normal_login_verified_at: row.get(3)?,
})
}
fn read_passkey_credential_record(
row: &rusqlite::Row<'_>,
) -> rusqlite::Result<PasskeyCredentialRecord> {
@@ -15164,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(())
}
fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 102 {
return Err(Error::Store(
"Passkey results migration requires schema 102".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch(include_str!("passkey_results_schema.sql"))?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)",
["Passkey metadata and atomic addition results"],
)?;
tx.commit()?;
Ok(())
}
fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 101 {
return Err(Error::Store(
@@ -15255,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
conn.execute_batch(include_str!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
Ok(())
}
@@ -18948,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()},
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
]
);
@@ -19060,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(100, "Provisional users and scoped passkey activation".to_string()),
(101, "Durable explicit Subject body release".to_string()),
(102, "Durable failed Subject creation recovery".to_string()),
(103, "Passkey metadata and atomic addition results".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -21288,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
);
}
#[test]
fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() {
for baseline in [50, 102] {
let dir = tempfile::tempdir().unwrap();
let database = dir.path().join("server.db");
if baseline == 50 {
prepare_schema_v50(&database, None);
} else {
prepare_retained_schema(&database, baseline);
}
let conn = Connection::open(&database).unwrap();
configure_sqlite(&conn).unwrap();
conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated');
INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at)
VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated');
INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#)
.unwrap();
if baseline == 102 {
conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations
WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap();
assert!(migrate_passkey_results_v102_to_v103(&conn).is_err());
assert_eq!(current_schema_version(&conn).unwrap(), 102);
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap());
conn.execute_batch("DROP TRIGGER interrupt_passkey_migration")
.unwrap();
}
drop(conn);
let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap();
assert!(preflight.migration_required());
// Preflight must not install metadata in the source database.
let conn = Connection::open(&database).unwrap();
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
drop(conn);
let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap();
let record = migrated.get_passkey_credential("old-key").unwrap().unwrap();
assert_eq!(record.public_key_cose, "unchanged-key-material");
assert_eq!(record.sign_count, 7);
assert_eq!(record.last_used_at.as_deref(), Some("used-key"));
let summary = migrated
.list_passkey_summaries("user-old")
.unwrap()
.remove(0);
assert_eq!(summary.created_at, "created-key");
assert_eq!(summary.last_used_at.as_deref(), Some("used-key"));
assert!(summary.normal_login_verified_at.is_none());
assert_eq!(summary.key_reference.len(), 32);
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
let schema = |conn: &Connection| -> Result<Vec<(String, String)>> {
let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN
('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?;
Ok(stmt
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
.collect::<std::result::Result<Vec<_>, _>>()?)
};
assert_eq!(
migrated.with_conn(schema).unwrap(),
fresh.with_conn(schema).unwrap()
);
migrated.with_conn(|conn| {
assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err());
assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference)
VALUES('operation','missing-key','user-old','{}')", []).is_err());
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0);
Ok(())
}).unwrap();
drop(migrated);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
assert_eq!(
reopened.list_passkey_summaries("user-old").unwrap(),
vec![summary]
);
}
}
#[test]
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
let dir = tempfile::tempdir().unwrap();
@@ -21327,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
drop(conn);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
reopened
.with_conn(|c| verify_schema_history(c, 102))
.with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
.unwrap();
}
@@ -22266,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".into()),
(102, "Durable failed Subject creation recovery".into())]);
(102, "Durable failed Subject creation recovery".into()),
(103, "Passkey metadata and atomic addition results".into())]);
for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
}
+261 -3
View File
@@ -298,12 +298,17 @@
"AuthUserResponse": {
"additionalProperties": false,
"properties": {
"authentication_reference": {
"description": "Comparison evidence for the exact newly issued session, not a credential.",
"type": "string"
},
"user": {
"$ref": "#/components/schemas/AuthenticatedUser"
}
},
"required": [
"user"
"user",
"authentication_reference"
],
"type": "object"
},
@@ -6709,15 +6714,112 @@
},
"PasskeyAdditionOptionsRequest": {
"additionalProperties": false,
"description": "Adds a credential to the current authenticated actor; target identity is never public input.",
"description": "Adds to the current actor. Expected identity/binding fields only fence stale UI;\nthey never select an owner or authorize an addition.",
"properties": {
"browser_origin": {
"type": [
"string",
"null"
]
},
"expected_account_id": {
"type": "string"
},
"expected_authentication_reference": {
"type": "string"
},
"expected_user_id": {
"type": "string"
}
},
"required": [
"expected_user_id",
"expected_account_id",
"expected_authentication_reference"
],
"type": "object"
},
"PasskeyAdditionResultRequest": {
"additionalProperties": false,
"properties": {
"challenge_id": {
"type": "string"
},
"expected_account_id": {
"type": "string"
},
"expected_authentication_reference": {
"description": "Original options authentication reference; the current same-owner session may differ.",
"type": "string"
},
"expected_user_id": {
"type": "string"
}
},
"required": [
"challenge_id",
"expected_user_id",
"expected_account_id",
"expected_authentication_reference"
],
"type": "object"
},
"PasskeyAdditionResultResponse": {
"description": "Unknown is not proof that registration failed: completion may still be in flight.\nSaved proves the credential commit, not session issuance or current session health.",
"oneOf": [
{
"additionalProperties": false,
"properties": {
"status": {
"const": "unknown",
"type": "string"
}
},
"required": [
"status"
],
"type": "object"
},
{
"additionalProperties": false,
"properties": {
"key": {
"$ref": "#/components/schemas/PasskeySummary"
},
"status": {
"const": "saved",
"type": "string"
},
"user": {
"$ref": "#/components/schemas/AuthenticatedUser"
}
},
"required": [
"status",
"user",
"key"
],
"type": "object"
}
]
},
"PasskeyListResponse": {
"additionalProperties": false,
"properties": {
"keys": {
"items": {
"$ref": "#/components/schemas/PasskeySummary"
},
"type": "array"
},
"user": {
"$ref": "#/components/schemas/AuthenticatedUser"
}
},
"required": [
"user",
"keys"
],
"type": "object"
},
"PasskeyLoginCompleteRequest": {
@@ -6820,6 +6922,35 @@
],
"type": "object"
},
"PasskeySummary": {
"additionalProperties": false,
"description": "Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.\nNormal-login verification is observed verifier success, not inferred discoverability.",
"properties": {
"created_at": {
"type": "string"
},
"key_reference": {
"type": "string"
},
"last_used_at": {
"type": [
"string",
"null"
]
},
"normal_login_verified_at": {
"type": [
"string",
"null"
]
}
},
"required": [
"key_reference",
"created_at"
],
"type": "object"
},
"PasteArtifactAvailability": {
"enum": [
"available",
@@ -15143,6 +15274,13 @@
}
]
},
"authentication_reference": {
"description": "Non-secret comparison evidence, never an authentication credential.",
"type": [
"string",
"null"
]
},
"session": {
"$ref": "#/components/schemas/BrowserSessionState"
}
@@ -18155,7 +18293,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7"
"x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23"
},
"openapi": "3.1.0",
"paths": {
@@ -18589,6 +18727,61 @@
}
}
},
"/api/auth/passkeys": {
"get": {
"operationId": "auth_passkey_list",
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyListResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/addition/complete": {
"post": {
"operationId": "auth_passkey_addition_complete",
@@ -18736,6 +18929,71 @@
]
}
},
"/api/auth/passkeys/addition/result": {
"post": {
"operationId": "auth_passkey_addition_result",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAdditionResultRequest"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PasskeyAdditionResultResponse"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/auth/passkeys/login/complete": {
"post": {
"operationId": "auth_passkey_login_complete",
+1
View File
@@ -9,6 +9,7 @@
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。
## 固定Workerの段階起動
+49 -78
View File
@@ -4,7 +4,7 @@
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
import { assert, assertEquals } from "@std/assert";
import { extname, join, normalize } from "@std/path";
import { serveProductionShell } from "./static_shell.ts";
import { type Browser, type BrowserContext, chromium } from "playwright";
const [backend, origin, build, handle] = Deno.args;
@@ -13,82 +13,43 @@ if (!backend || !origin || !build || !handle) {
"Expected isolated Backend URL, browser origin, production build, handle",
);
}
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
const mime: Record<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 });
const server = await serveProductionShell(
backend,
origin,
build,
async (request) => {
const url = new URL(request.url);
if (
url.pathname === "/__e2e/expired-session" && request.method === "POST"
) {
if (request.headers.get("origin") !== origin) {
return new Response(null, { status: 403 });
}
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
const fixture = await bridge("expired-session", {}) as {
set_cookies: string[];
};
const headers = new Headers({ "cache-control": "no-store" });
for (const cookie of fixture.set_cookies) {
headers.append("set-cookie", cookie);
}
return new Response(null, { status: 204, headers });
}
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
const fixture = await bridge("expired-session", {}) as {
set_cookies: string[];
};
const headers = new Headers({ "cache-control": "no-store" });
for (const cookie of fixture.set_cookies) {
headers.append("set-cookie", cookie);
if (
url.pathname === "/__e2e/primary-cookie-present" &&
request.method === "GET"
) {
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
const present = request.headers.get("cookie")?.split(";").some((pair) =>
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
) ?? false;
return Response.json(present, {
headers: { "cache-control": "no-store" },
});
}
return new Response(null, { status: 204, headers });
}
if (
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET"
) {
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
const present = request.headers.get("cookie")?.split(";").some((pair) =>
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
) ?? false;
return Response.json(present, { headers: { "cache-control": "no-store" } });
}
if (url.pathname.startsWith("/api/")) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
return await fetch(new URL(url.pathname + url.search, backend), {
method: request.method,
headers,
body: ["GET", "HEAD"].includes(request.method)
? undefined
: await request.arrayBuffer(),
redirect: "manual",
});
}
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(build, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
},
});
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (url.pathname.startsWith("/_app/")) {
return new Response("not found", { status: 404 });
}
return new Response(await Deno.readFile(join(build, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
return undefined;
},
);
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
@@ -313,7 +274,8 @@ try {
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
const expiredTarget =
"/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
const catalogResponse = () =>
page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/workspaces" &&
@@ -360,6 +322,7 @@ try {
assert(expiredCookieNames.includes(marker.name));
assertEquals(await whoami(), {
actor: null,
authentication_reference: null,
session: { kind: "expired" },
});
privateRequests.length = 0;
@@ -383,7 +346,11 @@ try {
[],
"expired reload must gate the real private catalog loader",
);
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
assertEquals(await whoami(), {
actor: null,
authentication_reference: null,
session: { kind: "expired" },
});
await page.reload();
await at(expiredLogin);
await page.getByRole("button", { name: "Log in with passkey", exact: true })
@@ -416,7 +383,11 @@ try {
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
await context.clearCookies({ name: session.name });
const partial = await whoami();
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
assertEquals(partial, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.reload();
await at("/");
await context.clearCookies();
@@ -0,0 +1,611 @@
/// <reference lib="dom" />
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
import { join } from "@std/path";
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
import { serveProductionShell } from "./static_shell.ts";
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
throw new Error(
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
);
}
const started = performance.now();
const server = await serveProductionShell(backend, origin, build);
let browser: Browser | undefined;
let phase = "launch";
const rows: {
resident: boolean;
authenticator: string;
outcome: string;
elapsed_ms: number;
}[] = [];
class NativeCheckError extends Error {}
function check(condition: unknown, diagnostic: string): asserts condition {
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
if (!condition) throw new NativeCheckError(diagnostic);
}
type User = { user_id: string; account_id: string; handle: string };
type Key = {
key_reference: string;
normal_login_verified_at: string | null;
last_used_at: string | null;
};
async function whoami(page: Page) {
const response = await page.request.get(`${origin}/api/auth/whoami`);
check(response.status() === 200, "whoami status");
return await response.json() as {
actor: User | null;
authentication_reference: string | null;
session: { kind: string };
};
}
async function owner(page: Page, user: User, reference?: string) {
const current = await whoami(page);
check(
current.session.kind === "authenticated" &&
current.actor?.user_id === user.user_id &&
current.actor.account_id === user.account_id &&
current.actor.handle === user.handle,
"authenticated owner mismatch",
);
check(
typeof current.authentication_reference === "string",
"missing authentication reference",
);
if (reference) {
check(
current.authentication_reference === reference,
"issued authentication mismatch",
);
}
return current;
}
async function keys(page: Page, user: User): Promise<Key[]> {
const response = await page.request.get(`${origin}/api/auth/passkeys`);
check(response.status() === 200, "key list status");
const result = await response.json();
check(
result.user.user_id === user.user_id &&
result.user.account_id === user.account_id,
"key list owner",
);
return result.keys;
}
async function presence(
cdp: CDPSession,
authenticatorId: string,
enabled: boolean,
) {
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
authenticatorId,
enabled,
});
}
async function authenticator(
cdp: CDPSession,
resident: boolean,
automaticPresenceSimulation = true,
) {
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
options: {
protocol: "ctap2",
transport: "usb",
hasResidentKey: resident,
hasUserVerification: true,
isUserVerified: true,
automaticPresenceSimulation,
},
})).authenticatorId;
}
async function account(page: Page) {
await page.goto(origin + "/account");
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
}
async function logout(page: Page) {
await account(page);
await page.getByRole("button", { name: "Log out", exact: true }).click();
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
check(
(await whoami(page)).session.kind === "anonymous",
"logout must clear authentication",
);
}
async function login(page: Page, user: User, legacy: boolean) {
await page.goto(origin + "/login?returnTo=%2Faccount");
const button = page.getByRole("button", {
name: legacy ? "Log in with older key" : "Log in with passkey",
exact: true,
});
if (legacy) {
await page.getByText("Use an older key with account ID", { exact: true })
.click();
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
user.handle,
);
} else {
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
check(
!await page.getByLabel("Account ID (handle)", { exact: true })
.isVisible(),
"normal login asks for ID",
);
}
const options = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
);
const complete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
);
await button.click();
const optionResponse = await options;
check(optionResponse.status() === 200, "login options status");
const input = optionResponse.request().postDataJSON();
if (!legacy) {
check(
Object.keys(input).every((key) => key === "browser_origin"),
"normal login selected an account",
);
const wire = await optionResponse.json();
check(
!wire.public_key.publicKey.allowCredentials?.length,
"normal login sends an allow-list",
);
}
const response = await complete;
check(response.status() === 200, "native login complete status");
const issued = await response.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id,
"login returned wrong owner",
);
await page.waitForURL((url) => url.pathname === "/account");
await owner(page, user, issued.authentication_reference);
}
const terminate = () => {
void (async () => {
await browser?.close();
await server.shutdown();
Deno.exit(143);
})();
};
Deno.addSignalListener("SIGTERM", terminate);
try {
browser = await chromium.launch({ headless: true, timeout: 15_000 });
for (const resident of [false, true]) {
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
serviceWorkers: "block",
});
context.setDefaultTimeout(12_000);
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
let page = await context.newPage();
let pageErrors = 0;
page.on("pageerror", () => ++pageErrors);
let cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
const old = await authenticator(cdp, true);
let completionPosts = 0;
const completions = () => completionPosts;
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
try {
phase = `legacy-fixture-resident-${resident}`;
await page.goto(origin + "/register");
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
const fixture = await page.evaluate(async ({ handle, resident }) => {
const post = async (path: string, body: unknown) => {
const response = await fetch(path, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error("synthetic legacy fixture HTTP rejected");
}
return await response.json();
};
const wire = await post("/api/auth/passkeys/registration/options", {
handle,
display_name: "Synthetic native migration",
browser_origin: location.origin,
});
const bytes = (value: string) =>
Uint8Array.from(
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
(c) => c.charCodeAt(0),
);
const options = wire.public_key.publicKey;
const random = crypto.getRandomValues(new Uint8Array(32));
const stable = bytes(options.user.id);
const differs = stable.length !== random.length ||
stable.some((byte: number, index: number) => byte !== random[index]);
options.challenge = bytes(options.challenge);
options.user.id = random;
options.excludeCredentials = options.excludeCredentials?.map((
item: { id: string },
) => ({ ...item, id: bytes(item.id) }));
options.authenticatorSelection = {
...options.authenticatorSelection,
residentKey: resident ? "required" : "discouraged",
requireResidentKey: resident,
};
const credential = await navigator.credentials.create({
publicKey: options,
}) as PublicKeyCredential | null;
if (
!credential ||
!(credential.response instanceof AuthenticatorAttestationResponse)
) throw new Error("native attestation missing");
const encode = (value: ArrayBuffer) =>
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
"+",
"-",
).replaceAll("/", "_").replaceAll("=", "");
const issued = await post("/api/auth/passkeys/registration/complete", {
challenge_id: wire.challenge_id,
credential: {
id: credential.id,
rawId: encode(credential.rawId),
type: credential.type,
response: {
attestationObject: encode(credential.response.attestationObject),
clientDataJSON: encode(credential.response.clientDataJSON),
transports: credential.response.getTransports(),
},
extensions: credential.getClientExtensionResults(),
},
});
return {
user: issued.user,
authentication_reference: issued.authentication_reference,
randomDiffersFromStable: differs,
};
}, {
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
resident,
});
check(
fixture.randomDiffersFromStable,
"fixture must use a legacy random handle",
);
const user: User = fixture.user;
await owner(page, user, fixture.authentication_reference);
const initial = await keys(page, user);
check(
initial.length === 1 && initial[0].normal_login_verified_at === null,
"legacy key initially unverified",
);
const oldReference = initial[0].key_reference;
await logout(page);
phase = `older-key-login-before-resident-${resident}`;
await login(page, user, true);
phase = `same-authenticator-excluded-resident-${resident}`;
const sameStarted = performance.now();
const add = () =>
page.getByRole("button", {
name: `Add passkey to ${user.handle}`,
exact: true,
});
const optionsResponse = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
check(
(await optionsResponse).status() === 200,
"addition options status",
);
const excluded = page.getByRole("alert").filter({
hasText: "[authenticator/excluded]",
});
await excluded.waitFor({ state: "visible" });
check(
(await excluded.innerText()).includes(
"different device, security key, or passkey provider",
),
"exclusion needs a concrete alternative",
);
check(
(await excluded.innerText()).includes("do not delete"),
"exclusion must preserve working access",
);
check(
completions() === 0,
"native exclusion must not submit completion",
);
const afterExcluded = await keys(page, user);
check(
afterExcluded.length === 1 &&
afterExcluded[0].key_reference === oldReference,
"exclusion changed old key",
);
await owner(page, user);
rows.push({
resident,
authenticator: "same",
outcome: "native-exclusion-no-completion-old-key-preserved",
elapsed_ms: Math.round(performance.now() - sameStarted),
});
phase = `other-authenticator-pending-resident-${resident}`;
const otherStarted = performance.now();
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
const legacyPage = page;
const legacyCdp = cdp;
page = await context.newPage();
page.on("pageerror", () => ++pageErrors);
page.on("request", (r) => {
if (
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
) ++completionPosts;
});
cdp = await context.newCDPSession(page);
await cdp.send("WebAuthn.enable", { enableUI: false });
const newer = await authenticator(cdp, true, false);
await account(page);
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
const beforeAddition = await owner(page, user);
const additionOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
const additionComplete = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
);
await add().click();
const optionResponse = await additionOptions;
check(
optionResponse.status() === 200,
"other authenticator options status",
);
let optionsInput = optionResponse.request().postDataJSON();
check(
optionsInput.expected_user_id === user.user_id &&
optionsInput.expected_account_id === user.account_id &&
optionsInput.expected_authentication_reference ===
beforeAddition.authentication_reference,
"addition must fence displayed owner and session",
);
let wire = await optionResponse.json();
check(
wire.public_key.publicKey.excludeCredentials.length === 1,
"old credential must remain excluded",
);
check(
wire.public_key.publicKey.authenticatorSelection.userVerification ===
"required",
"addition must require UV",
);
check(
wire.public_key.publicKey.authenticatorSelection.residentKey ===
"required",
"product addition must require residency",
);
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
check(
await page.getByRole("heading", { name: "Account", exact: true })
.isVisible(),
"account hidden while native pending",
);
check(
await add().isDisabled() && completions() === 0,
"pending must prevent duplicate/early completion",
);
for (
const viewport of [{ width: 1280, height: 900 }, {
width: 390,
height: 844,
}]
) {
await page.setViewportSize(viewport);
await pending.scrollIntoViewIfNeeded();
check(
await pending.isVisible(),
"pending must remain visible at viewport",
);
await page.screenshot({
path: join(
artifacts,
`native-addition-pending-${resident}-${viewport.width}.png`,
),
fullPage: false,
});
}
await page.setViewportSize({ width: 1280, height: 900 });
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
await page.getByRole("button", { name: "Stop waiting", exact: true })
.click();
await page.getByRole("alert").filter({
hasText: "Completion was not sent.",
}).waitFor();
check(
completions() === 0 && (await keys(page, user)).length === 1,
"cancel before touch must not save or complete",
);
await presence(cdp, newer, true);
const freshOptions = page.waitForResponse((r) =>
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
);
await add().click();
const freshResponse = await freshOptions;
check(freshResponse.status() === 200, "fresh addition options status");
optionsInput = freshResponse.request().postDataJSON();
wire = await freshResponse.json();
phase = `other-authenticator-completion-resident-${resident}`;
const complete = await additionComplete;
check(complete.status() === 200, "native addition complete status");
const issued = await complete.json();
check(
issued.user.user_id === user.user_id &&
issued.user.account_id === user.account_id &&
typeof issued.authentication_reference === "string",
"addition complete owner/reference",
);
await page.getByRole("status").filter({
hasText: "Passkey added to your account",
}).waitFor({ state: "visible" });
await owner(page, user, issued.authentication_reference);
check(completions() === 1, "addition must submit exactly once");
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
const receipt = await page.request.post(
`${origin}/api/auth/passkeys/addition/result`,
{
headers: { Origin: origin },
data: {
challenge_id: wire.challenge_id,
expected_user_id: user.user_id,
expected_account_id: user.account_id,
expected_authentication_reference:
optionsInput.expected_authentication_reference,
},
},
);
check(
receipt.status() === 200,
"saved addition receipt after Cookie issuance",
);
const saved = await receipt.json();
check(
saved.status === "saved" && saved.user.user_id === user.user_id &&
saved.user.account_id === user.account_id,
"receipt must identify saved owner",
);
const added = await keys(page, user);
check(
added.length === 2 &&
added.some((key) => key.key_reference === oldReference),
"addition must retain older key",
);
const newKey = added.find((key) => key.key_reference !== oldReference);
check(
newKey && saved.key.key_reference === newKey.key_reference &&
newKey.normal_login_verified_at === null,
"storage must not assert normal login",
);
await logout(page);
phase = `handle-free-login-resident-${resident}`;
await login(page, user, false);
const verified = await keys(page, user);
check(
verified.length === 2 &&
verified.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"actual handle-free verifier success must mark the new key",
);
check(
verified.find((key) => key.key_reference === oldReference)
?.normal_login_verified_at === null,
"normal login must not mark the unused legacy key",
);
await logout(page);
phase = `older-key-login-after-resident-${resident}`;
await presence(cdp, newer, false);
page = legacyPage;
cdp = legacyCdp;
await presence(cdp, old, true);
await login(page, user, true);
const retained = await keys(page, user);
check(
retained.length === 2 &&
retained.find((key) => key.key_reference === oldReference)
?.last_used_at != null,
"older key must remain usable after migration",
);
check(
retained.find((key) => key.key_reference === newKey.key_reference)
?.normal_login_verified_at != null,
"older login must preserve new key verification",
);
check(pageErrors === 0, "product page emitted an uncaught error");
rows.push({
resident,
authenticator: "other",
outcome:
"saved-correct-owner-handle-free-verified-older-login-preserved",
elapsed_ms: Math.round(performance.now() - otherStarted),
});
} finally {
await context.close();
}
}
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "passed",
boundary:
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
chromium: browser.version(),
rows,
elapsed_ms: Math.round(performance.now() - started),
viewports: ["1280x900", "390x844"],
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
console.error(
`native-migration: PASS ${rows.length} matrix rows; ${
Math.round(performance.now() - started)
}ms`,
);
} catch (error) {
const diagnostic = error instanceof NativeCheckError
? error.message
: "browser-or-tool-operation-failed";
await Deno.writeTextFile(
join(artifacts, "native-migration-result.json"),
JSON.stringify(
{
status: "failed",
phase,
diagnostic,
rows,
elapsed_ms: Math.round(performance.now() - started),
physical_smartphone: "unconfirmed",
physical_authenticator: "unconfirmed",
},
null,
2,
),
);
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
console.error(
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
Math.round(performance.now() - started)
}`,
);
Deno.exitCode = 1;
} finally {
Deno.removeSignalListener("SIGTERM", terminate);
await browser?.close();
await server.shutdown();
}
+76
View File
@@ -0,0 +1,76 @@
// 実Backendへの透過proxyと本番SPA配信をbrowser suite間で共有する。
// Cookie/Origin/署名検証/認可は置換しない。fixture入口は呼出側が明示的に渡す。
import { extname, join, normalize } from "@std/path";
export async function serveProductionShell(
backend: string,
origin: string,
build: string,
fixture?: (request: Request) => Promise<Response | undefined>,
) {
for (const address of [backend, origin]) {
const url = new URL(address);
if (
url.protocol !== "http:" ||
!["localhost", "127.0.0.1"].includes(url.hostname) || !url.port
) {
throw new Error("Browser E2E requires explicit owned loopback endpoints");
}
}
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
const mime: Record<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",
};
return Deno.serve({
hostname: "127.0.0.1",
port: Number(new URL(origin).port),
onListen() {},
}, async (request) => {
const url = new URL(request.url);
const special = await fixture?.(request);
if (special) return special;
if (url.pathname.startsWith("/api/")) {
const headers = new Headers(request.headers);
headers.delete("host");
headers.delete("connection");
return await fetch(new URL(url.pathname + url.search, backend), {
method: request.method,
headers,
body: ["GET", "HEAD"].includes(request.method)
? undefined
: await request.arrayBuffer(),
redirect: "manual",
});
}
const relative = normalize(
url.pathname.replace(/^\/+/, "") || "index.html",
);
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(build, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
},
});
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (url.pathname.startsWith("/_app/")) {
return new Response("not found", { status: 404 });
}
return new Response(await Deno.readFile(join(build, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
}
+21
View File
@@ -1074,6 +1074,27 @@ impl WorkerE2eEnvironment {
Ok(output)
}
/// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は
/// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの
/// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。
pub fn browser_command(&self, binary: &Path) -> Result<Command> {
let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect();
let mut command = self.isolated_command(binary);
for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] {
if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) {
command.env(name, value);
}
}
let temporary = self.root.join("browser-tmp");
let cache = self.root.join("browser-cache");
fs::create_dir_all(&temporary)?;
fs::create_dir_all(&cache)?;
command
.env("TMPDIR", temporary)
.env("XDG_CACHE_HOME", cache);
Ok(command)
}
fn isolated_command(&self, binary: &Path) -> Command {
let mut command = Command::new(binary);
command
+64 -12
View File
@@ -4,8 +4,9 @@
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
//! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
//! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
use std::error::Error;
@@ -21,8 +22,8 @@ use serde::Deserialize;
use serde::de::DeserializeOwned;
use serde_json::{Value, json};
use server_api::{
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest,
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsResponse, WhoamiResponse,
};
#[path = "../src/resident_authenticator.rs"]
@@ -30,8 +31,12 @@ mod resident_model;
use resident_model::ResidentAuthenticator;
use yoi_e2e::WorkerE2eEnvironment;
#[path = "auth_passkeys/browser_process.rs"]
mod browser_process;
#[path = "auth_passkeys/browser_sessions.rs"]
mod browser_sessions;
#[path = "auth_passkeys/native_migration.rs"]
mod native_migration;
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
const BROWSER_ORIGIN: &str = "http://localhost:5173";
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
Ok(())
}
// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。
fn addition_expectation(
request: RequestBuilder,
artifacts: &Path,
) -> Result<PasskeyAdditionOptionsRequest> {
let identity: WhoamiResponse = decode(send(
request,
"read addition owner and authentication reference",
artifacts,
)?)?;
let actor = identity
.actor
.ok_or("addition fixture requires an authenticated actor")?;
Ok(PasskeyAdditionOptionsRequest {
expected_user_id: actor.user_id,
expected_account_id: actor.account_id,
expected_authentication_reference: identity
.authentication_reference
.ok_or("missing addition authentication reference")?,
browser_origin: Some(BROWSER_ORIGIN.to_owned()),
})
}
fn host_admin_login(
environment: &WorkerE2eEnvironment,
client: &Client,
@@ -291,11 +319,17 @@ fn host_admin_login(
let bearer = setup["access_token"]
.as_str()
.ok_or("host setup omitted initial bearer")?;
let addition_body = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.bearer_auth(bearer),
artifacts,
)?;
let options: PasskeyRegistrationOptionsResponse = decode(send(
client
.post(format!("{base}/api/auth/passkeys/addition/options"))
.bearer_auth(bearer)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&addition_body),
"host-created administrator bearer starts actor-derived passkey addition",
artifacts,
)?)?;
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
policy(&client, base, artifacts, false)?;
let mut admin_key = ResidentAuthenticator::new(true);
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
let admin_addition = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &admin_cookie),
artifacts,
)?;
policy(&client, base, artifacts, false)?;
set_policy(&client, base, artifacts, &admin_cookie, true)?;
let whoami: Value = decode(send(
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
client
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&admin_addition),
"unauthenticated addition options",
artifacts,
)?,
@@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
)?)?;
assert!(options.public_key.public_key.allow_credentials.is_empty());
let addition_body = addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &cookie),
artifacts,
)?;
for (field, value) in [
("handle", &other.user.handle),
("user_id", &other.user.user_id),
("account_id", &other.user.account_id),
] {
let mut body = json!({"browser_origin": BROWSER_ORIGIN});
let mut body = serde_json::to_value(&addition_body)?;
body[field] = json!(value);
rejected(
send(
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
let mut request = client
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie)
.json(&json!({"browser_origin": BROWSER_ORIGIN}));
.json(&addition_body);
if let Some(origin) = origin {
request = request.header(ORIGIN, origin);
}
@@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&addition_body),
"authenticated self addition options",
artifacts,
)?)?;
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, revoked_cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&addition_body),
"logout prevents further self addition with the old cookie",
artifacts,
)?,
@@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"user_code": flow["user_code"]})),
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id,
"expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})),
];
for mut request in requests {
if let Some(bearer) = bearer {
@@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
.post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
.json(&addition_expectation(
client
.get(format!("{base}/api/auth/whoami"))
.header(COOKIE, &cookie),
artifacts,
)?),
"start distinct authenticated self-addition ceremony",
artifacts,
)?)?;
@@ -0,0 +1,58 @@
//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。
use super::*;
use std::process::{Child, ExitStatus};
use std::sync::mpsc;
pub(super) struct BrowserProcess(pub(super) Option<Child>);
impl BrowserProcess {
pub(super) fn child(&mut self) -> &mut Child {
self.0.as_mut().expect("browser process retained")
}
pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result<ExitStatus> {
let mut child = self.0.take().expect("browser process retained");
let pid = child.id() as libc::pid_t;
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ = send.send(child.wait());
});
let result = receive.recv_timeout(timeout);
if result.is_err() {
unsafe {
libc::kill(pid, libc::SIGTERM);
}
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
}
let _ = waiter.join();
Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??)
}
}
impl Drop for BrowserProcess {
fn drop(&mut self) {
let Some(mut child) = self.0.take() else {
return;
};
if matches!(child.try_wait(), Ok(Some(_))) {
return;
}
let pid = child.id() as libc::pid_t;
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
unsafe {
libc::kill(pid, libc::SIGTERM);
}
let (send, receive) = mpsc::channel();
let waiter = std::thread::spawn(move || {
let _ = send.send(child.wait());
});
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
unsafe {
libc::kill(pid, libc::SIGKILL);
}
}
let _ = waiter.join();
}
}
@@ -8,10 +8,11 @@
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
use super::browser_process::BrowserProcess;
use super::*;
use std::io::{BufRead, BufReader, Read};
use std::net::TcpListener;
use std::process::{Child, Command, Stdio};
use std::process::Stdio;
use std::sync::mpsc;
use std::time::Instant;
use yoi_workspace_server::auth::{
@@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value>
]}))
}
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
@@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit
drop(reservation);
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
let mut process = BrowserProcess(Some(
Command::new("deno")
environment
.browser_command(Path::new("deno"))?
.current_dir(&root)
.args([
"run",
@@ -0,0 +1,110 @@
//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。
//! random user handle × resident=false/true × 同じ/別の認証器を検査する。
//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。
//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。
//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。
//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored`
//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。
//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。
//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。
use super::browser_process::BrowserProcess;
use super::*;
use std::net::TcpListener;
use std::process::Stdio;
#[test]
#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"]
fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration()
-> Result {
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()?;
let build = root.join("web/workspace/build");
if !build.join("index.html").is_file() {
return Err("native browser E2E requires an existing production Web build".into());
}
let mut environment = WorkerE2eEnvironment::new()?;
let reservation = TcpListener::bind("127.0.0.1:0")?;
let origin = format!("http://localhost:{}", reservation.local_addr()?.port());
std::fs::write(
&environment.server_config,
format!("[browser]\npublic_url = \"{origin}\"\n"),
)?;
environment.start_empty_backend()?;
let admin =
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
let bearer = admin["access_token"]
.as_str()
.ok_or("missing setup token")?;
let base = environment
.server_url
.as_deref()
.ok_or("missing Backend URL")?;
let response = http_client()?
.put(format!("{base}/api/hosting/settings"))
.bearer_auth(bearer)
.json(&json!({"self_registration_enabled": true}))
.send()?;
if !response.status().is_success() {
return Err("native fixture could not enable synthetic self-registration".into());
}
// Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。
drop(reservation);
let stderr =
std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?;
let mut command = environment.browser_command(Path::new("deno"))?;
command
.current_dir(&root)
.args([
"run",
"--config",
"tools/web-ux/deno.json",
"--no-lock",
"--cached-only",
"--allow-env",
"--allow-net=127.0.0.1,localhost",
"--allow-read",
"--allow-write",
"--allow-run",
"--allow-sys",
"tests/e2e/browser/native_passkey_migration.ts",
base,
&origin,
build.to_str().ok_or("non-UTF8 build path")?,
&environment.account_handle,
environment
.artifacts_dir
.to_str()
.ok_or("non-UTF8 artifacts path")?,
])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(stderr);
let process = BrowserProcess(Some(command.spawn()?));
let status = process.wait_bounded(Duration::from_secs(180))?;
if !status.success() {
return Err(format!(
"native CTAP2 matrix failed; sanitized diagnostics: {}",
environment
.artifacts_dir
.join("native-browser.stderr.log")
.display()
)
.into());
}
let report: Value = serde_json::from_slice(&std::fs::read(
environment
.artifacts_dir
.join("native-migration-result.json"),
)?)?;
if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) {
return Err("native browser did not confirm all four matrix rows".into());
}
let cleanup = environment.cleanup()?;
assert!(
cleanup.cleanup_success,
"owned native browser environment cleanup failed"
);
Ok(())
}
+1 -1
View File
@@ -21,7 +21,7 @@
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts",
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
},
"imports": {
+23 -3
View File
@@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState,
/**
* Non-secret comparison evidence, never an authentication credential.
*/
authentication_reference: string | null, };
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
export type AuthUserResponse = { user: AuthenticatedUser, };
export type AuthUserResponse = { user: AuthenticatedUser,
/**
* Comparison evidence for the exact newly issued session, not a credential.
*/
authentication_reference: string, };
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, };
export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, };
export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array<PasskeySummary>, };
export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string,
/**
* Original options authentication reference; the current same-owner session may differ.
*/
expected_authentication_reference: string, };
export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, };
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -108,7 +108,7 @@
<details class="legacy-login" bind:open={legacyOpen}>
<summary>Use an older key with account ID</summary>
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p>
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> to inspect keys and verify migration. Adding with the same older-key authenticator may be excluded; use a different saved-key provider or authenticator. Keep older keys until normal ID-free login to the intended account is tested.</p>
<label>Account ID (handle)
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
</label>
@@ -131,6 +131,7 @@
{/if}
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication">
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
@@ -56,7 +56,7 @@
{#if !protectedPage}
{@render children()}
{:else}
{#if session.kind === 'loading'}
{#if session.kind === 'loading' && !admitted}
<div class="auth-page"><p role="status">Loading session…</p></div>
{:else if session.kind === 'error'}
<div class="auth-page">
@@ -66,7 +66,7 @@
</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>
<!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
<div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
{/if}
{/if}
@@ -1,10 +1,18 @@
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
import { browserSession } from "./session-state.ts";
import { activateWithPasskey } from "./activation.ts";
import {
authUser,
credentialFixture,
registrationOptions,
} from "./api.test-fixtures.ts";
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function equal(a: unknown, b: unknown) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
}
@@ -52,9 +60,14 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
String(input).endsWith("/options")
String(input) === "/api/auth/whoami"
? confirmedSession()
: String(input).endsWith("/options")
? registrationOptions
: { user: authUser },
: {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (options) => {
equal(options.signal === signal, true);
@@ -67,16 +80,21 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
equal(requests.map((r) => r.path), [
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
for (const { init } of requests) {
equal(init.method, "POST");
for (const { path, init } of requests) {
equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
equal(init.signal === signal, true);
equal(init.credentials, "same-origin");
equal(init.redirect, "error");
equal(init.mode, "same-origin");
equal(init.cache, "no-store");
equal(init.referrerPolicy, "no-referrer");
if (path !== "/api/auth/whoami") {
equal(init.referrerPolicy, "no-referrer");
}
equal(new Headers(init.headers).has("authorization"), false);
}
equal(requests[2].init.body, undefined);
equal(JSON.parse(String(requests[0].init.body)), {
handle: "alice",
code: "synthetic-input-only",
@@ -125,9 +143,10 @@ Deno.test("activation sanitizes HTTP transport malformed and mismatched completi
return Response.json({ unexpected: "synthetic-input-only" });
}
return Response.json(
String(input).endsWith("/options")
? registrationOptions
: { user: { ...authUser, handle: "other" } },
String(input).endsWith("/options") ? registrationOptions : {
user: { ...authUser, handle: "other" },
authentication_reference: "fixture-authentication",
},
);
},
async () => credentialFixture(),
@@ -173,3 +192,201 @@ Deno.test("activation invalid IDs and oversized code are rejected before network
},
);
});
for (
const mode of [
"anonymous",
"bob",
"account-mismatch",
"reference-mismatch",
"api-token",
"unavailable",
] as const
) {
Deno.test(
"activation rejects " + mode +
" whoami after successful completion without retry",
async () => {
const paths: string[] = [];
await boundary(
async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path !== "/api/auth/whoami") {
throw new Error("Unexpected endpoint");
}
if (mode === "unavailable") {
return new Response("private whoami diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "account-mismatch") {
confirmation.actor.account_id = "account-bob";
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
if (mode === "api-token") {
confirmation.actor.auth_method = "api_token";
}
return Response.json(confirmation);
},
async () => credentialFixture(),
async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
},
);
},
);
}
for (const stage of ["prompt", "completion", "confirmation"] as const) {
Deno.test(
"activation session generation change during " + stage +
" fences stale work",
async () => {
const paths: string[] = [];
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (path === "/api/auth/activation/options") {
return Response.json(registrationOptions);
}
if (path === "/api/auth/activation/complete") {
if (stage === "completion") browserSession.invalidate();
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/whoami") {
if (stage === "confirmation") browserSession.invalidate();
return Response.json(confirmedSession());
}
throw new Error("Unexpected endpoint");
}, async () => {
if (stage === "prompt") browserSession.invalidate();
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(paths, [
"/api/auth/activation/options",
...(stage === "prompt" ? [] : ["/api/auth/activation/complete"]),
...(stage === "confirmation" ? ["/api/auth/whoami"] : []),
]);
});
},
);
}
for (
const phase of ["options", "prompt", "complete", "confirmation"] as const
) {
for (const reason of ["logout", "authentication"] as const) {
Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const previous = Object.getOwnPropertyDescriptor(
globalThis,
"localStorage",
);
let stamp = "initial-operation-stamp";
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: { getItem: () => stamp },
});
const generation = browserSession.authenticationGeneration;
const paths: string[] = [];
try {
await boundary(async (input) => {
const path = String(input);
paths.push(path);
if (
(phase === "options" && path.endsWith("/options")) ||
(phase === "complete" && path.endsWith("/complete")) ||
(phase === "confirmation" && path === "/api/auth/whoami")
) {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return Response.json(
path.endsWith("/options")
? registrationOptions
: path.endsWith("/complete")
? {
user: authUser,
authentication_reference: "fixture-authentication",
}
: confirmedSession(),
);
}, async () => {
if (phase === "prompt") {
stamp = JSON.stringify({ id: "other-tab", reason });
}
return credentialFixture();
}, async () => {
await rejects(
activateWithPasskey(
"alice",
"synthetic-input-only",
new AbortController().signal,
),
);
equal(browserSession.authenticationGeneration, generation);
equal(stamp === "initial-operation-stamp", false);
equal(
paths.filter((path) => path.endsWith("/complete")).length,
phase === "complete" || phase === "confirmation" ? 1 : 0,
);
equal(paths.includes("/api/auth/whoami"), phase === "confirmation");
});
} finally {
if (previous) {
Object.defineProperty(
globalThis,
"localStorage",
previous,
);
} else Reflect.deleteProperty(globalThis, "localStorage");
}
});
}
}
@@ -2,7 +2,8 @@ import type {
ActivationOptionsRequest,
PasskeyRegistrationCompleteRequest,
} from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts";
import { browserSession } from "./session-state.ts";
import { normalizeActivationHandle } from "./hosting-accounts.ts";
import {
type AuthUser,
@@ -19,6 +20,16 @@ export async function activateWithPasskey(
code: string,
signal: AbortSignal,
): Promise<AuthUser> {
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
// 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。
const assertCurrent = () => {
signal.throwIfAborted();
if (
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) throw new Error("Activation superseded");
};
try {
handle = normalizeActivationHandle(handle);
if (window.isSecureContext !== true || !code || code.length > 4096) {
@@ -28,7 +39,7 @@ export async function activateWithPasskey(
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => {
signal.throwIfAborted();
assertCurrent();
const response = await fetch(path, {
method: "POST",
credentials: "same-origin",
@@ -44,7 +55,9 @@ export async function activateWithPasskey(
await response.body?.cancel();
throw new Error("Activation rejected");
}
return await readBoundedAuthResponseJson(response);
const result = await readBoundedAuthResponseJson(response);
assertCurrent();
return result;
};
const options = await request("/api/auth/activation/options", {
handle,
@@ -54,22 +67,33 @@ export async function activateWithPasskey(
code = "";
const publicKey = prepareRegistrationOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
signal.throwIfAborted();
assertCurrent();
const credential = await navigator.credentials.create({
publicKey,
signal,
});
if (!credential) throw new Error("Registration cancelled");
signal.throwIfAborted();
assertCurrent();
const result = parseAuthUserResponse(
await request("/api/auth/activation/complete", {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
}),
).user;
signal.throwIfAborted();
if (result.handle !== handle) throw new Error("Activation target mismatch");
return result;
);
assertCurrent();
if (result.user.handle !== handle) {
throw new Error("Activation target mismatch");
}
const current = await loadWhoami(signal);
assertCurrent();
if (
current.session.kind !== "authenticated" ||
current.actor?.auth_method !== "browser_session" ||
current.actor.user_id !== result.user.user_id ||
current.actor.account_id !== result.user.account_id ||
current.authentication_reference !== result.authentication_reference
) throw new Error("Activation session unconfirmed");
return result.user;
} catch {
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
throw new Error("Activation could not be confirmed.");
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
export function whoamiPayload(actor: unknown = null) {
return {
actor,
authentication_reference: actor === null ? null : "fixture-authentication",
session: actor === null
? { kind: "anonymous" }
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
+217 -88
View File
@@ -1,6 +1,7 @@
import type {
DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest,
PasskeyAdditionResultRequest,
PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest,
@@ -8,24 +9,38 @@ import type {
} from "#lib/generated/auth-api.ts";
import {
authenticationCredentialToJson,
type AuthUser,
AuthPayloadError,
type DeviceApprovalResponse,
parseAdditionResult,
parseAuthUserResponse,
parseDeviceApprovalResponse,
parseLogoutResponse,
parsePasskeyList,
parseWhoamiResponse,
type PasskeyUserResponse,
prepareLoginOptions,
prepareRegistrationOptions,
registrationCredentialToJson,
type WhoamiResponse,
} from "#lib/workspace/auth/model.ts";
import {
authCheckpoint,
AuthOperationError,
type AuthStage,
classifyAuthFailure,
expectedAuthSession,
recordAdditionAttempt,
} from "./operation.ts";
export class AuthRequestError extends Error {
constructor(readonly status: number) {
constructor(readonly status: number, readonly code: string | null = null) {
super(`Auth request failed (${status}).`);
}
}
class AuthResponseError extends Error {}
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
export async function readBoundedAuthResponseJson(
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
parsed > MAX_AUTH_RESPONSE_BYTES
) {
await response.body?.cancel();
throw new Error(
throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.",
);
}
}
if (response.body === null) {
throw new Error("Invalid auth response: response body is missing.");
throw new AuthResponseError(
"Invalid auth response: response body is missing.",
);
}
const reader = response.body.getReader();
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
totalBytes += value.byteLength;
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
await reader.cancel();
throw new Error(
throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.",
);
}
@@ -79,7 +96,9 @@ export async function readBoundedAuthResponseJson(
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
) as unknown;
} catch {
throw new Error("Invalid auth response: response body is not valid JSON.");
throw new AuthResponseError(
"Invalid auth response: response body is not valid JSON.",
);
}
}
@@ -96,8 +115,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
},
});
if (!response.ok) {
await response.body?.cancel();
throw new AuthRequestError(response.status);
let code: string | null = null;
try {
const value = await readBoundedAuthResponseJson(response);
const message = typeof value === "object" && value !== null
? (value as { message?: unknown }).message
: null;
if (typeof message === "string") {
code = [
"auth_required",
"passkey_authentication_changed",
"passkey_registration_binding_mismatch",
"webauthn_registration_verification_failed",
"webauthn_login_verification_failed",
"invalid_passkey_challenge",
"expired_passkey_challenge",
"self_registration_disabled",
"passkey_activation_required",
].find((code) => message.startsWith(code + ":")) ?? null;
}
} catch { /* 本文・解析例外は保存しない。statusだけでも失敗として扱う。 */ }
throw new AuthRequestError(response.status, code);
}
return await readBoundedAuthResponseJson(response);
}
@@ -122,7 +160,7 @@ export async function registerPasskey(
handle: string,
displayName?: string,
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle,
display_name: displayName ?? null,
@@ -131,8 +169,21 @@ export async function registerPasskey(
return await createPasskey("registration", optionsRequest, signal);
}
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
export async function addPasskey(
signal?: AbortSignal,
): Promise<PasskeyUserResponse> {
const expected = expectedAuthSession(signal) ?? await loadWhoami(signal);
if (!expected.actor || !expected.authentication_reference) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
const optionsRequest: PasskeyAdditionOptionsRequest = {
expected_user_id: expected.actor.user_id,
expected_account_id: expected.actor.account_id,
expected_authentication_reference: expected.authentication_reference,
browser_origin: window.location.origin,
};
return await createPasskey("addition", optionsRequest, signal);
@@ -144,51 +195,88 @@ async function createPasskey(
| PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest,
signal?: AbortSignal,
): Promise<AuthUser> {
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: registration_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError");
}
const credential = await navigator.credentials.create({
signal,
publicKey: prepareRegistrationOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST",
body: JSON.stringify(completeRequest),
body: JSON.stringify(optionsRequest),
signal,
}),
);
return result.user;
});
const publicKey = prepareRegistrationOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
stage = "authenticator";
await authCheckpoint(signal, stage);
if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("", "NotSupportedError");
}
const credential = await navigator.credentials.create({
signal,
publicKey,
});
if (!credential) throw new DOMException("", "NotAllowedError");
signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId,
credential: registrationCredentialToJson(credential),
};
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
if (purpose === "addition") {
const expected = optionsRequest as PasskeyAdditionOptionsRequest;
recordAdditionAttempt(signal, {
challenge_id: challengeId,
expected_user_id: expected.expected_user_id,
expected_account_id: expected.expected_account_id,
expected_authentication_reference:
expected.expected_authentication_reference,
});
}
sent = true;
const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
}
export async function loginWithPasskey(
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
return await authenticateWithPasskey({
browser_origin: window.location.origin,
}, signal);
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
export async function loginWithLegacyPasskey(
handle: string,
signal?: AbortSignal,
): Promise<AuthUser> {
): Promise<PasskeyUserResponse> {
const loginHandle = handle.trim();
if (!loginHandle) {
throw new Error("User handle is required for legacy passkey login.");
@@ -211,52 +299,77 @@ export async function loginWithLegacyPasskey(
async function authenticateWithPasskey(
optionsRequest: PasskeyLoginOptionsRequest,
signal?: AbortSignal,
): Promise<AuthUser> {
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
body: JSON.stringify(optionsRequest),
signal,
});
const optionsRecord = typeof options === "object" && options !== null
? options as Record<string, unknown>
: null;
const challengeId = optionsRecord?.challenge_id;
if (typeof challengeId !== "string" || challengeId.length === 0) {
throw new Error(
"Invalid auth payload: login_options.challenge_id is required.",
);
}
signal?.throwIfAborted();
if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError");
}
const credential = await navigator.credentials.get({
signal,
publicKey: prepareLoginOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted();
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
};
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", {
): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST",
body: JSON.stringify(completeRequest),
body: JSON.stringify(optionsRequest),
signal,
}),
);
return result.user;
});
const publicKey = prepareLoginOptions(options);
const challengeId = (options as { challenge_id: string }).challenge_id;
stage = "authenticator";
await authCheckpoint(signal, stage);
if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("", "NotSupportedError");
}
const credential = await navigator.credentials.get({ signal, publicKey });
if (!credential) throw new DOMException("", "NotAllowedError");
signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId,
credential: authenticationCredentialToJson(credential),
};
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
sent = true;
const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", {
method: "POST",
body: JSON.stringify(completeRequest),
signal,
}),
);
signal?.throwIfAborted();
return result;
} catch (error) {
if (error instanceof AuthRequestError) {
throw new AuthOperationError(
stage,
error.code === "auth_required" ||
error.code === "passkey_authentication_changed" ||
error.code === "passkey_registration_binding_mismatch"
? "session-changed"
: "rejected",
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
error.status,
);
}
if (
error instanceof AuthResponseError || error instanceof AuthPayloadError
) {
throw new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "invalid-response",
sent ? "unknown" : "not-sent",
);
}
throw classifyAuthFailure(error, stage, sent);
}
}
export async function approveDeviceLogin(
userCode: string,
signal?: AbortSignal,
): Promise<DeviceApprovalResponse> {
await authCheckpoint(signal, "before-complete");
const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", {
@@ -266,3 +379,19 @@ export async function approveDeviceLogin(
}),
);
}
export async function loadPasskeys(signal?: AbortSignal) {
return parsePasskeyList(await requestJson("/api/auth/passkeys", { signal }));
}
export async function readAdditionResult(
request: PasskeyAdditionResultRequest,
signal?: AbortSignal,
) {
return parseAdditionResult(
await requestJson("/api/auth/passkeys/addition/result", {
method: "POST",
body: JSON.stringify(request),
signal,
}),
);
}
@@ -1,11 +1,18 @@
import { AuthRequestError } from "./api.ts";
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import { AuthRequestError, loadWhoami, readAdditionResult } from "./api.ts";
import {
browserSession,
observeBrowserSession,
sessionMutation,
} from "./session-state.ts";
import { notifySessionChanged } from "./hosting.ts";
import type { RequestActor, WhoamiResponse } from "./model.ts";
import type { AuthUser, RequestActor, WhoamiResponse } from "./model.ts";
import {
AuthOperationError,
type AuthStage,
observeAuthOperation,
operationErrorMessage,
} from "./operation.ts";
export type BrowserSession =
| { kind: "loading" }
@@ -13,41 +20,60 @@ export type BrowserSession =
| ({ kind: "ready" } & WhoamiResponse);
export function authErrorMessage(error: unknown): string {
if (error instanceof AuthOperationError) return operationErrorMessage(error);
if (error instanceof AuthRequestError) {
if (error.status === 401) {
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
}
if (error.status === 400 || error.status === 410) {
return "The request or authentication challenge is invalid or expired. Restart the operation.";
}
return error.message;
return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
}
if (error instanceof DOMException && error.name === "NotSupportedError") {
return "This browser or key cannot complete this passkey operation. Use a supported device or key with saved passkeys and PIN or biometric verification. Completion is not confirmed.";
}
if (error instanceof DOMException && error.name === "NotAllowedError") {
return "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.";
}
if (error instanceof DOMException && error.name === "AbortError") {
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
}
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
return "Authentication could not be confirmed. Check your session before retrying the operation.";
// 例外本文/causeや外部応答は表示にも診断にも複写しない。
return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
}
function sameUser(a: AuthUser | null, b: AuthUser | null): boolean {
return a === null
? b === null
: b !== null && a.user_id === b.user_id && a.account_id === b.account_id;
}
function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
// anonymous/expiredはいずれも認証主体もbindingもない。別tabのlogoutでexpiredの
// 表示だけが古くなっても、新たな明示loginを拒否しない。認証済みへの変化は拒否する。
if (
!a.actor && !b.actor && a.session.kind !== "authenticated" &&
b.session.kind !== "authenticated"
) return true;
return sameUser(a.actor, b.actor) && a.session.kind === b.session.kind &&
(a.session.kind !== "authenticated" ||
a.authentication_reference === b.authentication_reference &&
b.session.kind === "authenticated" &&
a.session.expires_at === b.session.expires_at &&
a.actor?.auth_method === b.actor?.auth_method);
}
function returnedUser(value: unknown): AuthUser | null {
if (typeof value !== "object" || value === null) return null;
const user = (value as { user?: Partial<AuthUser> }).user;
return user && typeof user.user_id === "string" &&
typeof user.account_id === "string" && typeof user.handle === "string" &&
typeof user.display_name === "string"
? user as AuthUser
: null;
}
export class BrowserAuth {
session = $state<BrowserSession>({ kind: "loading" });
busy = $state(false);
error = $state("");
status = $state("");
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
additionSaved = $state(false);
private unsubscribe: () => void;
private disposed = false;
private controller: AbortController | null = null;
private operationGeneration: number | null = null;
constructor() {
observeBrowserSession();
this.unsubscribe = browserSession.subscribe((snapshot) => {
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
if (
this.operationGeneration !== null &&
this.operationGeneration !== browserSession.authenticationGeneration
) this.controller?.abort();
if (this.busy && snapshot.kind === "loading") return;
if (!this.disposed) {
this.session = snapshot.kind === "ready"
@@ -56,75 +82,239 @@ export class BrowserAuth {
}
});
}
get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null;
}
async ensure(): Promise<void> {
if (this.busy || this.disposed) return;
await browserSession.ensure();
if (!this.busy && !this.disposed) await browserSession.ensure();
}
async refresh(): Promise<void> {
if (this.busy || this.disposed) return;
await browserSession.refresh();
if (!this.busy && !this.disposed) await browserSession.refresh();
}
cancel(): void {
this.controller?.abort();
}
async run(
pending: string,
success: string,
operation: (signal: AbortSignal) => Promise<unknown>,
intent: "authentication" | "logout" = "authentication",
intent: "authentication" | "logout" | "device-approval" = "authentication",
): Promise<boolean> {
if (
this.busy || this.disposed ||
(intent !== "logout" && this.session.kind !== "ready")
) {
return false;
}
) return false;
const mutationReason = intent === "logout" ? "logout" : "authentication";
const expected = this.session.kind === "ready" ? this.session : null;
this.busy = true;
sessionMutation(intent);
const authenticationGeneration = browserSession.authenticationGeneration;
sessionMutation(mutationReason, true);
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
let acceptedGeneration = generation;
let acceptedStamp = stamp;
let submittedAddition = false;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
this.error = "";
this.status = pending;
const verifySession = async () => {
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
let current: WhoamiResponse;
try {
current = await loadWhoami(controller.signal);
} catch {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
if (!expected || !sameSession(expected, current)) {
throw new AuthOperationError(
"session-check",
"session-changed",
"not-sent",
);
}
};
observeAuthOperation(controller.signal, {
verifySession,
expected,
additionAttempt: (attempt) => {
submittedAddition = true;
this.additionAttempt = attempt;
this.additionSaved = false;
},
phase: (stage: AuthStage) => {
if (!this.disposed) {
this.status = stage === "authenticator"
? pending
: stage === "complete"
? "Confirming the operation with the server…"
: stage === "before-complete"
? "Checking the account before completion…"
: stage === "options"
? "Checking the account and requesting passkey options…"
: "Preparing the authenticator response…";
}
},
});
try {
await operation(controller.signal);
const superseded =
authenticationGeneration !== browserSession.authenticationGeneration;
sessionMutation(intent);
if (intent === "device-approval") await verifySession();
const result = await operation(controller.signal);
const user = returnedUser(result);
const issuedReference = typeof result === "object" && result !== null
? (result as { authentication_reference?: unknown })
.authentication_reference
: null;
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) return false;
// 自身の再確認によるinvalidateと、他の操作が行ったinvalidateを混同しない。
this.operationGeneration = null;
sessionMutation(mutationReason, true);
const confirmationGeneration = browserSession.authenticationGeneration;
acceptedGeneration = confirmationGeneration;
const confirmationStamp = browserSession.authenticationStamp;
acceptedStamp = confirmationStamp;
this.status = "Confirming the expected browser session…";
await browserSession.refresh();
if (
this.disposed || superseded ||
confirmationGeneration !== browserSession.authenticationGeneration
this.disposed || controller.signal.aborted ||
confirmationGeneration !== browserSession.authenticationGeneration ||
confirmationStamp !== browserSession.authenticationStamp
) {
if (!this.disposed) this.status = "";
return false;
}
const snapshot = browserSession.snapshot;
const confirmed = snapshot.kind === "ready" &&
(intent === "logout"
? snapshot.value.actor === null &&
snapshot.value.session.kind !== "authenticated"
: intent === "device-approval"
? snapshot.value.session.kind === "authenticated" && user !== null &&
sameUser(user, expected?.actor ?? null) &&
sameUser(user, snapshot.value.actor) &&
snapshot.value.authentication_reference ===
expected?.authentication_reference &&
typeof result === "object" && result !== null &&
(result as { status?: unknown }).status === "approved"
: snapshot.value.session.kind === "authenticated" &&
snapshot.value.actor?.auth_method === "browser_session" &&
user !== null && typeof issuedReference === "string" &&
issuedReference === snapshot.value.authentication_reference &&
sameUser(user, snapshot.value.actor) &&
(!expected?.actor || sameUser(expected.actor, user)));
if (!confirmed) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"saved-session-unconfirmed",
);
}
notifySessionChanged();
const confirmed = this.session.kind === "ready";
this.status = confirmed ? success : "";
return confirmed;
if (submittedAddition) this.additionSaved = true;
this.status = success;
return true;
} catch (error) {
if (!this.disposed) {
this.error = authErrorMessage(error);
this.status = "";
}
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
sessionMutation(intent);
await browserSession.refresh();
// 古い処理から新しいログアウト/主体確認をinvalidateしない。
if (
!this.disposed &&
acceptedGeneration === browserSession.authenticationGeneration &&
acceptedStamp === browserSession.authenticationStamp
) {
this.operationGeneration = null;
sessionMutation(mutationReason, true);
await browserSession.refresh();
}
return false;
} finally {
this.operationGeneration = null;
this.controller = null;
if (!this.disposed) this.busy = false;
}
}
async checkAddition(): Promise<void> {
const attempt = this.additionAttempt;
if (!attempt || this.busy || this.disposed) return;
this.busy = true;
this.error = "";
this.status = "Checking the saved addition result…";
// 保存の履歴は失敗に戻さないが、過去の照合で再追加を許可しない。
this.additionSaved = false;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
stamp !== browserSession.authenticationStamp
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
};
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (
!before.actor || before.actor.user_id !== attempt.expected_user_id ||
before.actor.account_id !== attempt.expected_account_id
) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
const result = await readAdditionResult(attempt, controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (!sameSession(before, after)) {
throw new AuthOperationError(
"confirmation",
"session-changed",
"unknown",
);
}
if (result.status === "saved" && sameUser(result.user, before.actor)) {
this.additionSaved = true;
this.status =
"This addition saved a key to your account. Your current session was checked; the session issued by completion is not confirmed. Test ID-free login with that key before retiring older-key access.";
} else {this.status =
"The addition result is still unknown. Completion may still be in flight. Do not add another key yet; check again or use the registered-key list and ID-free login to verify access.";}
} catch {
if (!this.disposed) {
this.busy = false;
this.controller = null;
this.error =
"The addition result could not be read with the current account/session. This does not prove failure. Return to the intended account and check its key list or ID-free login; do not recreate the original session or add blindly.";
this.status = "";
}
} finally {
this.operationGeneration = null;
this.controller = null;
if (!this.disposed) this.busy = false;
}
}
dispose(): void {
this.disposed = true;
this.unsubscribe();
@@ -1,7 +1,7 @@
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts";
import { HostingRequestError } from "./hosting.ts";
import { parseAuthUserResponse } from "./model.ts";
import { parseAuthUser } from "./model.ts";
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
export function normalizeActivationHandle(value: string): string {
@@ -57,7 +57,7 @@ export async function issueActivationCode(
typeof record.expires_at !== "string" ||
!Number.isFinite(Date.parse(record.expires_at))
) throw new Error();
const user = parseAuthUserResponse({ user: record.user }).user;
const user = parseAuthUser(record.user);
if (user.handle !== handle) throw new Error();
signal.throwIfAborted();
return { user, code: record.code, expires_at: record.expires_at };
@@ -7,6 +7,16 @@ import {
registrationOptions,
} from "./api.test-fixtures.ts";
const bearerIdentity = {
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
};
const completion = {
user: authUser,
authentication_reference: "fixture-issued-authentication",
};
function equal(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Values differ");
@@ -65,9 +75,11 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
String(input).endsWith("/options")
String(input).endsWith("/whoami")
? bearerIdentity
: String(input).endsWith("/options")
? registrationOptions
: { user: authUser },
: completion,
);
}, async () => {
equal(
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
authUser,
);
equal(requests.map((request) => request.path), [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
for (const { init } of requests) {
equal(init.method, "POST");
for (const { path, init } of requests) {
equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
equal(new Headers(init.headers).has("cookie"), false);
equal(init.credentials, "omit");
equal(init.mode, "same-origin");
equal(init.redirect, "error");
@@ -91,10 +105,15 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
);
equal(String(init.body).includes("host-test-token"), false);
}
equal(JSON.parse(String(requests[0].init.body)), {
equal(requests[0].init.body, undefined);
equal(JSON.parse(String(requests[1].init.body)), {
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference:
bearerIdentity.authentication_reference,
browser_origin: "https://yoi.example",
});
const complete = JSON.parse(String(requests[1].init.body));
const complete = JSON.parse(String(requests[2].init.body));
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
equal(complete.challenge_id, registrationOptions.challenge_id);
equal(complete.credential.response.attestationObject, "AQID");
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
}]
) {
let calls = 0;
await boundary(async () => Response.json(payload), async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 0);
}, async () => {
calls++;
return credentialFixture();
});
await boundary(
async (input) =>
Response.json(
String(input).endsWith("/whoami") ? bearerIdentity : payload,
),
async () => {
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 0);
},
async () => {
calls++;
return credentialFixture();
},
);
}
});
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
for (const abort of [false, true]) {
const controller = new AbortController();
let requests = 0;
await boundary(async () => {
requests++;
return Response.json(registrationOptions);
const requests: string[] = [];
let prompts = 0;
await boundary(async (input) => {
requests.push(String(input));
return Response.json(
String(input).endsWith("/whoami")
? bearerIdentity
: registrationOptions,
);
}, async () => {
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
equal(requests, 1);
equal(requests, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
]);
equal(prompts, 1);
}, async () => {
prompts++;
if (abort) {
controller.abort();
return credentialFixture();
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
let calls = 0;
await boundary(async (input) => {
calls++;
if (String(input).endsWith("/whoami")) {
return Response.json(bearerIdentity);
}
if (String(input).endsWith("/options")) {
return Response.json(registrationOptions);
}
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal),
);
equal(calls, 2);
equal(calls, 3);
});
}
});
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
import {
type AuthUser,
parseAuthUserResponse,
parseWhoamiResponse,
prepareRegistrationOptions,
registrationCredentialToJson,
} from "./model.ts";
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
}
const request = async (
path:
| "/api/auth/whoami"
| "/api/auth/passkeys/addition/options"
| "/api/auth/passkeys/addition/complete",
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => {
signal.throwIfAborted();
const response = await fetch(path, {
method: "POST",
method: path === "/api/auth/whoami" ? "GET" : "POST",
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
credentials: "omit",
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
}
return await readBoundedAuthResponseJson(response);
};
const identity = parseWhoamiResponse(await request("/api/auth/whoami"));
if (!identity.actor || !identity.authentication_reference || identity.actor.auth_method !== "api_token") throw new Error("Setup identity unconfirmed");
const options = await request("/api/auth/passkeys/addition/options", {
expected_user_id: identity.actor.user_id,
expected_account_id: identity.actor.account_id,
expected_authentication_reference: identity.authentication_reference,
browser_origin: window.location.origin,
});
const publicKey = prepareRegistrationOptions(options);
@@ -4,6 +4,7 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void;
};
import { AuthOperationError } from "./operation.ts";
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
import {
authUser,
@@ -64,7 +65,12 @@ async function boundary(
Response.json(body.handle ? legacyLoginOptions : loginOptions),
);
}
return Promise.resolve(Response.json({ user: authUser }));
return Promise.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
},
},
});
@@ -97,7 +103,7 @@ for (const legacy of [false, true]) {
await (legacy
? loginWithLegacyPasskey(" alice ", signal)
: loginWithPasskey(signal)),
authUser,
{ user: authUser, authentication_reference: "fixture-authentication" },
);
equal(state.requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options",
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
: loginWithPasskey(controller.signal));
throw new Error("Unexpected login success");
} catch (error) {
if (!(error instanceof DOMException)) throw error;
equal(error.name, late ? "AbortError" : "NotAllowedError");
if (!(error instanceof AuthOperationError)) throw error;
equal(error.stage, "authenticator");
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
equal(error.outcome, "not-sent");
}
equal(state.requests.length, 1);
});
@@ -2,6 +2,7 @@ import {
authenticationCredentialToJson,
base64UrlToBuffer,
bufferToBase64Url,
parseAuthUserResponse,
parseDeviceLoginPollResponse,
parseDeviceLoginStartResponse,
parseWhoamiResponse,
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
{ actor: null, session: { kind: "anonymous" } },
parseWhoamiResponse({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
},
);
assertThrows(
() =>
parseWhoamiResponse({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
user_id: "user-1",
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
"unknown auth method",
);
assertThrows(
() => parseWhoamiResponse({ actor: null, token: "secret" }),
() =>
parseWhoamiResponse({
actor: null,
authentication_reference: null,
token: "secret",
}),
"unexpected whoami field",
);
});
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
const attackerKey = `secret-${"x".repeat(512)}`;
try {
parseWhoamiResponse({ actor: null, [attackerKey]: true });
parseWhoamiResponse({
actor: null,
authentication_reference: null,
[attackerKey]: true,
});
throw new Error("expected an error");
} catch (error) {
if (!(error instanceof Error) || error.message.includes(attackerKey)) {
@@ -248,23 +267,153 @@ Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", ()
};
for (
const payload of [
{ actor, session: { kind: "anonymous" } },
{ actor, session: { kind: "expired" } },
{ actor, authentication_reference: null, session: { kind: "anonymous" } },
{ actor, authentication_reference: null, session: { kind: "expired" } },
{
actor: null,
authentication_reference: 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,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: null },
},
{
actor,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "tomorrow" },
},
{
actor: null,
authentication_reference: null,
session: { kind: "future" },
},
{
actor: null,
authentication_reference: 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" } },
parseWhoamiResponse({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "expired" },
},
);
});
Deno.test("whoami and completion require explicit consistent authentication references", () => {
const user = {
user_id: "u",
account_id: "a",
handle: "alice",
display_name: "Alice",
};
const authenticated = {
actor: { ...user, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
} as const;
for (const reference of [undefined, null, "", 42, "x".repeat(16385)]) {
assertThrows(
() =>
parseWhoamiResponse({
...authenticated,
authentication_reference: reference,
}),
"invalid authenticated reference",
);
assertThrows(
() =>
parseAuthUserResponse({ user, authentication_reference: reference }),
"invalid issuance reference",
);
}
for (const kind of ["anonymous", "expired"]) {
assertThrows(
() => parseWhoamiResponse({ actor: null, session: { kind } }),
"missing anonymous reference",
);
assertThrows(
() =>
parseWhoamiResponse({
actor: null,
authentication_reference: "issued",
session: { kind },
}),
"anonymous reference must be null",
);
}
const completion = { user, authentication_reference: "issued" };
assertEquals(parseAuthUserResponse(completion), completion);
assertEquals(
parseWhoamiResponse({
...authenticated,
authentication_reference: "issued",
}),
{
actor: authenticated.actor,
authentication_reference: "issued",
session: authenticated.session,
},
);
});
Deno.test("bounded future transports do not discard a created credential and required crypto bytes stay validated", () => {
const bytes = new Uint8Array([1, 2, 3]).buffer;
const credential = {
id: "AQID",
rawId: bytes,
type: "public-key",
getClientExtensionResults: () => ({
credProps: { rk: true },
prf: { results: { first: bytes } },
largeBlob: { blob: bytes },
}),
response: {
clientDataJSON: bytes,
attestationObject: bytes,
getTransports: () => ["internal", "hybrid", "future-bounded-transport"],
},
};
const converted = registrationCredentialToJson(credential) as {
response: { transports: string[] };
clientExtensionResults: unknown;
};
assertEquals(converted.response.transports, ["internal", "hybrid"]);
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: { ...credential.response, clientDataJSON: "not-binary" },
}),
"required client data remains binary",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
response: {
...credential.response,
getTransports: () => ["x".repeat(16385)],
},
}),
"transport bound remains enforced",
);
assertThrows(
() =>
registrationCredentialToJson({
...credential,
getClientExtensionResults: () => ({ credProps: { rk: bytes } }),
}),
"supported JSON extension never silently converts binary to an empty object",
);
});
+66 -22
View File
@@ -8,6 +8,9 @@ import type {
DeviceLoginPollStatus,
DeviceLoginStartResponse,
LogoutResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
@@ -21,6 +24,9 @@ export type {
AuthPublicConfig,
DeviceLoginPollResponse,
DeviceLoginStartResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse,
RequestActor,
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
export class AuthPayloadError extends Error {}
function invalid(path: string, reason: string): never {
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
throw new AuthPayloadError(`Invalid auth payload: ${path} ${reason}.`);
}
function asRecord(value: unknown, path: string): Record<string, unknown> {
@@ -119,6 +127,7 @@ function boundedJson(
if (budget.remainingNodes < 0) invalid(path, "has too many values");
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
if (value === null || typeof value === "boolean") return;
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
if (typeof value === "string") {
boundedString(value, path, { allowEmpty: true });
budget.remainingStringUnits -= value.length;
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
function parseAuthenticatorTransport(
value: unknown,
path: string,
): AuthenticatorTransport {
): AuthenticatorTransport | null {
const transport = boundedString(value, path);
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
invalid(path, "contains an unknown authenticator transport");
return null;
}
return transport as AuthenticatorTransport;
}
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(`${path}.transports`, "has too many items");
}
return descriptor.transports.map((transport, index) =>
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`)
);
return descriptor.transports.flatMap((transport, index) => {
const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
return parsed === null ? [] : [parsed];
});
})();
return {
type: "public-key",
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor", "session"]);
requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
const actor = response.actor === null
? null
: parseActor(response.actor, "whoami.actor");
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
"must identify the server session expiry",
);
}
return { actor, session: { kind: "authenticated", expires_at: expiry } };
if (authentication_reference === null) invalid("whoami", "authenticated session requires comparison evidence");
return { actor, authentication_reference, session: { kind: "authenticated", expires_at: expiry } };
}
requireExactKeys(session, "whoami.session", ["kind"]);
if (
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
) {
invalid("whoami", "inconsistent actor or session state");
}
return { actor: null, session: { kind: session.kind } };
if (authentication_reference !== null) invalid("whoami", "anonymous session cannot identify authentication");
return { actor: null, authentication_reference: null, session: { kind: session.kind } };
}
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
return result;
}
export function parseAuthUser(value: unknown): AuthUser {
return parseAuthenticatedUser(value, "auth_user.user");
}
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
const response = asRecord(value, "auth_user");
requireExactKeys(response, "auth_user", ["user"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user") };
requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user"), authentication_reference: boundedString(response.authentication_reference, "auth_user.authentication_reference") };
}
function parsePasskeySummary(value: unknown): PasskeySummary {
const key = asRecord(value, "passkey_summary");
requireExactKeys(key, "passkey_summary", ["key_reference", "created_at", "last_used_at", "normal_login_verified_at"]);
const date = (value: unknown): string => {
const text = boundedString(value, "passkey_summary.timestamp");
if (!Number.isFinite(Date.parse(text))) invalid("passkey_summary.timestamp", "must be a timestamp");
return text;
};
return { key_reference: boundedString(key.key_reference, "passkey_summary.key_reference"), created_at: date(key.created_at), last_used_at: key.last_used_at === null ? null : date(key.last_used_at), normal_login_verified_at: key.normal_login_verified_at === null ? null : date(key.normal_login_verified_at) };
}
export function parsePasskeyList(value: unknown): PasskeyListResponse {
const list = asRecord(value, "passkey_list");
requireExactKeys(list, "passkey_list", ["user", "keys"]);
if (!Array.isArray(list.keys) || list.keys.length > MAX_AUTH_ARRAY_LENGTH) invalid("passkey_list.keys", "must be a bounded array");
return { user: parseAuthenticatedUser(list.user, "passkey_list.user"), keys: list.keys.map(parsePasskeySummary) };
}
export function parseAdditionResult(value: unknown): PasskeyAdditionResultResponse {
const result = asRecord(value, "addition_result");
if (result.status === "unknown") { requireExactKeys(result, "addition_result", ["status"]); return { status: "unknown" }; }
requireExactKeys(result, "addition_result", ["status", "user", "key"]);
if (result.status !== "saved") invalid("addition_result", "contains an unknown status");
return { status: "saved", user: parseAuthenticatedUser(result.user, "addition_result.user"), key: parsePasskeySummary(result.key) };
}
export function prepareRegistrationOptions(
@@ -583,11 +624,14 @@ function clientExtensionResults(
const value = (credential.getClientExtensionResults as () => unknown).call(
credential,
);
boundedJson(value, "credential.clientExtensionResults");
return asRecord(
value,
"credential.clientExtensionResults",
) as AuthenticationExtensionsClientOutputs;
const output = asRecord(value, "credential.clientExtensionResults");
// 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
// outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
const supported = Object.fromEntries(
["appid", "credProps", "hmacSecret", "credProtect", "minPinLength"].filter(key => key in output).map(key => [key, output[key]]),
);
boundedJson(supported, "credential.clientExtensionResults");
return supported as AuthenticationExtensionsClientOutputs;
}
export function registrationCredentialToJson(credential: unknown): unknown {
@@ -605,12 +649,12 @@ export function registrationCredentialToJson(credential: unknown): unknown {
) {
invalid("credential.response.transports", "must be a bounded array");
}
const transports = transportsValue.map((transport, index) =>
parseAuthenticatorTransport(
transport,
`credential.response.transports[${index}]`,
)
);
// transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
// boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
const transports = transportsValue.flatMap((value, index) => {
const transport = boundedString(value, `credential.response.transports[${index}]`);
return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
});
const authenticatorAttachment = optionalString(
core.authenticatorAttachment,
"credential.authenticatorAttachment",
@@ -0,0 +1,140 @@
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
import type { WhoamiResponse } from "./model.ts";
export type AuthStage =
| "session-check"
| "options"
| "authenticator"
| "conversion"
| "before-complete"
| "complete"
| "confirmation";
export type AuthFailure =
| "cancelled-or-unavailable"
| "excluded"
| "unsupported"
| "environment"
| "conversion"
| "session-changed"
| "rejected"
| "transport"
| "invalid-response"
| "stopped";
export type AuthOutcome =
| "not-sent"
| "rejected"
| "unknown"
| "saved-session-unconfirmed";
// 診断は固定分類だけを持つ。cause、例外本文、HTTP body、credentialやceremony IDを保持しない。
export class AuthOperationError extends Error {
constructor(
readonly stage: AuthStage,
readonly reason: AuthFailure,
readonly outcome: AuthOutcome,
readonly httpStatus?: number,
) {
super(`Passkey ${stage}: ${reason} (${outcome}).`);
}
}
type Observer = {
phase: (stage: AuthStage) => void;
verifySession: () => Promise<void>;
expected: WhoamiResponse | null;
additionAttempt: (attempt: PasskeyAdditionResultRequest) => void;
};
const observers = new WeakMap<AbortSignal, Observer>();
export function observeAuthOperation(
signal: AbortSignal,
observer: Observer,
): void {
observers.set(signal, observer);
}
export function recordAdditionAttempt(
signal: AbortSignal | undefined,
attempt: PasskeyAdditionResultRequest,
): void {
if (signal) observers.get(signal)?.additionAttempt(attempt);
}
export function expectedAuthSession(
signal?: AbortSignal,
): WhoamiResponse | null {
return signal ? observers.get(signal)?.expected ?? null : null;
}
export async function authCheckpoint(
signal: AbortSignal | undefined,
stage: AuthStage,
): Promise<void> {
signal?.throwIfAborted();
const observer = signal ? observers.get(signal) : undefined;
observer?.phase(stage);
if (stage === "options" || stage === "before-complete") {
await observer?.verifySession();
}
signal?.throwIfAborted();
}
export function classifyAuthFailure(
error: unknown,
stage: AuthStage,
sent: boolean,
): AuthOperationError {
if (error instanceof AuthOperationError) return error;
const outcome = sent ? "unknown" : "not-sent";
if (
error instanceof DOMException &&
(stage === "authenticator" || error.name === "AbortError")
) {
switch (error.name) {
case "AbortError":
return new AuthOperationError(stage, "stopped", outcome);
case "InvalidStateError":
return new AuthOperationError(stage, "excluded", outcome);
case "NotAllowedError":
return new AuthOperationError(
stage,
"cancelled-or-unavailable",
outcome,
);
case "NotSupportedError":
return new AuthOperationError(stage, "unsupported", outcome);
case "SecurityError":
return new AuthOperationError(stage, "environment", outcome);
}
}
return new AuthOperationError(
stage,
stage === "conversion" ? "conversion" : "transport",
outcome,
);
}
export function operationErrorMessage(error: AuthOperationError): string {
const reason: Record<AuthFailure, string> = {
"cancelled-or-unavailable":
"The browser did not complete verification. It may have been cancelled, timed out, or found no usable key; the browser does not distinguish these reasons.",
excluded:
"The authenticator reported an existing key (duplicate exclusion). Keep the older key and choose a different device, security key, or passkey provider; do not delete your only working key.",
unsupported:
"This browser or key cannot complete this passkey operation. Use a supported device that can save passkeys and verify with a PIN or biometrics.",
environment:
"The browser blocked this operation because of an origin, security, or environment constraint. Check that you are using the intended secure site; the exact cause is not known.",
conversion:
"The device may have created a key, but its response could not be converted for the server. Registration was not sent. Keep existing keys; a device-side entry alone does not mean it was saved to this account.",
"session-changed":
"The account or session changed, or could not be confirmed. The result cannot be applied to the previously displayed account.",
rejected:
"The server rejected the request or verification. A challenge may be invalid or expired. Check your account and the operation result before starting a new ceremony.",
transport: "Communication could not be confirmed.",
"invalid-response": "The server response could not be confirmed.",
stopped: "Operation stopped.",
};
const outcome = error.outcome === "not-sent"
? "Completion was not sent."
: error.outcome === "rejected"
? "The server did not accept completion."
: error.outcome === "saved-session-unconfirmed"
? "The server confirmed the operation, but the expected browser session is not confirmed."
: "Completion may already have reached the server. Its result is unknown; check the operation result before adding another key. Stopping the browser does not undo a submitted request.";
return `${reason[error.reason]} ${outcome} [${error.stage}/${error.reason}${
error.httpStatus ? `/HTTP-${error.httpStatus}` : ""
}]`;
}
@@ -59,7 +59,7 @@ for (const kind of ["anonymous", "expired"] as const) {
await browserSession.ensure();
expect(fetcher).toHaveBeenCalledTimes(1);
fetcher.mockResolvedValueOnce(
Response.json({ actor: null, session: { kind } }),
Response.json({ authentication_reference: null, actor: null, session: { kind } }),
);
await expect(requirePageSession(target)).rejects.toMatchObject({
status: 307,
@@ -0,0 +1,624 @@
// @vitest-environment happy-dom
// BrowserAuth→本番adapterの競合/結果判定。HTTPと認証器のみをgate付きで代替し、
// Server検証/保存の根拠にはしない。秘密の本文は診断へ保存しない。
import { afterEach, expect, test, vi } from "vitest";
import { BrowserAuth } from "./browser-session.svelte.ts";
import { browserSession, sessionMutation } from "./session-state.ts";
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
import {
authUser,
credentialFixture,
loginOptions,
registrationOptions,
} from "./api.test-fixtures.ts";
const owners: BrowserAuth[] = [];
afterEach(() => {
for (const owner of owners.splice(0)) owner.dispose();
browserSession.invalidate();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function identity(user: typeof authUser | null) {
return Response.json({
authentication_reference: user ? "fixture-authentication" : null,
actor: user ? { ...user, auth_method: "browser_session" } : null,
session: user
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
});
}
function harness() {
const state = { user: authUser as typeof authUser | null, completed: false };
const create = vi.fn<CredentialsContainer["create"]>().mockResolvedValue(
credentialFixture(),
);
const get = vi.fn<CredentialsContainer["get"]>().mockResolvedValue(
credentialFixture(true),
);
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return identity(state.user);
if (path.endsWith("/options")) {
return Response.json(
path.includes("login") ? loginOptions : registrationOptions,
);
}
if (path.endsWith("/complete")) {
state.completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/logout") {
state.user = null;
return Response.json({ status: "logged_out" });
}
throw new Error("Unexpected synthetic endpoint");
});
vi.stubGlobal("fetch", fetcher);
const auth = new BrowserAuth();
owners.push(auth);
return { state, create, get, fetcher, auth };
}
function savedAddition() {
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
async function recoveryHarness() {
const context = harness();
await context.auth.refresh();
context.auth.additionAttempt = {
challenge_id: "synthetic-addition",
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "original-operation-reference",
};
return context;
}
for (const phase of ["initial-whoami", "receipt", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`recovery ${phase} rejects a shared ${reason} stamp before event delivery`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0, receiptReads = 0;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
++receiptReads;
expect(
JSON.parse(String(init?.body)).expected_authentication_reference,
)
.toBe("original-operation-reference");
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const checking = auth.checkAddition();
await started.promise;
await auth.checkAddition(); // busy recovery cannot issue a second read
const changed = JSON.stringify({
id: `recovery-${phase}-${reason}`,
reason,
});
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt?.expected_authentication_reference)
.toBe("original-operation-reference");
expect(auth.status).not.toContain("current session was checked");
expect(auth.error).toContain("does not prove failure");
expect(receiptReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
for (const phase of ["receipt", "final-whoami"] as const) {
for (const invalidation of ["cancel", "dispose", "generation"] as const) {
test(`recovery ${phase} ignores a delayed saved response after ${invalidation}`, async () => {
const { auth, fetcher } = await recoveryHarness();
const started = gate<void>(), response = gate<Response>();
let whoami = 0;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
if (++whoami === 2 && phase === "final-whoami") {
started.resolve();
return response.promise;
}
return identity(authUser);
}
if (path.endsWith("/addition/result")) {
if (phase === "receipt") {
started.resolve();
return response.promise;
}
return savedAddition();
}
throw new Error("Unexpected recovery endpoint");
});
const checking = auth.checkAddition();
await started.promise;
if (invalidation === "cancel") auth.cancel();
else if (invalidation === "dispose") auth.dispose();
else sessionMutation();
response.resolve(
phase === "receipt" ? savedAddition() : identity(authUser),
);
await checking;
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).not.toContain("current session was checked");
expect(whoami).toBe(phase === "receipt" ? 1 : 2);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
)
.toBe(false);
});
}
}
test("failed recheck cannot retain repeat-add permission from an older saved confirmation", async () => {
const { auth, fetcher } = await recoveryHarness();
auth.additionSaved = true;
fetcher.mockResolvedValue(identity(null));
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("does not prove failure");
});
test("unknown receipt retains the unresolved attempt and never permits blind repetition", async () => {
const { auth, fetcher } = await recoveryHarness();
fetcher.mockImplementation(async (input) =>
String(input).endsWith("/addition/result")
? Response.json({ status: "unknown" })
: identity(authUser)
);
await auth.checkAddition();
expect(auth.additionSaved).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.status).toContain("still unknown");
expect(auth.error).toBe("");
});
for (const outcome of ["anonymous", "other-actor", "unavailable"] as const) {
test(`addition response is not Added when confirming whoami is ${outcome}`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (state.completed && String(input) === "/api/auth/whoami") {
if (outcome === "unavailable") {
throw new TypeError("private network detail");
}
return identity(
outcome === "anonymous" ? null : {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
},
);
}
return await request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.status).not.toBe("Added");
expect(auth.error).not.toContain("private network detail");
});
}
for (const invalidation of ["logout", "dispose", "account-change"] as const) {
test(`${invalidation} during native prompt never posts a delayed login credential`, async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
const prompted = gate<void>();
const credential = gate<Credential | null>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
const run = auth.run("Waiting", "Logged in", loginWithPasskey);
await prompted.promise;
if (invalidation === "dispose") auth.dispose();
else if (invalidation === "account-change") sessionMutation();
else {
const signout = new BrowserAuth();
owners.push(signout);
await signout.run("Logging out", "Logged out", logout, "logout");
}
credential.resolve(credentialFixture(true));
expect(await run).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
expect(state.completed).toBe(false);
});
}
const bob = {
...authUser,
user_id: "bob-user",
account_id: "bob-account",
handle: "bob",
};
for (const changeAt of ["start", "before-complete"] as const) {
test(`stale Alice with Bob cookie at ${changeAt} cannot add to Bob`, async () => {
const { auth, state, create, fetcher } = harness();
await auth.refresh();
if (changeAt === "start") state.user = bob;
else {create.mockImplementationOnce(async () => {
state.user = bob;
return credentialFixture();
});}
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(state.completed).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
if (changeAt === "start") expect(create).not.toHaveBeenCalled();
expect(auth.error).toContain("session-changed");
});
}
test("same owner but different authentication binding before complete is not the expected session", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
let changed = false;
create.mockImplementationOnce(async () => {
changed = true;
return credentialFixture();
});
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" && changed
? Response.json({
...(await identity(authUser).json()),
authentication_reference: "different-binding",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("returned owner alone does not prove the issued browser session", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input).endsWith("/complete")
? Response.json({
user: authUser,
authentication_reference: "issued-but-not-current",
})
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("expected browser session is not confirmed");
});
for (
const reason of [
"InvalidStateError",
"NotAllowedError",
"SecurityError",
"NotSupportedError",
] as const
) {
test(`native ${reason} has a safe stage-specific classification without raw exceptions`, async () => {
const { auth, create } = harness();
await auth.refresh();
create.mockRejectedValueOnce(
new DOMException("RAW-CREDENTIAL-TOKEN-COOKIE", reason),
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-CREDENTIAL-TOKEN-COOKIE");
expect(auth.error).toContain("authenticator/");
expect(auth.error).toContain("Completion was not sent");
if (reason === "InvalidStateError") {
expect(auth.error).toContain("duplicate exclusion");
}
if (reason === "NotAllowedError") {
expect(auth.error).toContain("does not distinguish");
}
});
}
test("device creation followed by conversion failure never sends complete or claims registration", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
create.mockResolvedValueOnce(
{
...credentialFixture(),
rawId: "private-invalid-value",
} as unknown as Credential,
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain("conversion");
expect(auth.error).toContain("device may have created");
expect(auth.error).not.toContain("private-invalid-value");
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("duplicate operations are fenced and explicit cancellation stops late credentials before submission", async () => {
const { auth, create, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), credential = gate<Credential | null>();
create.mockImplementationOnce(() => {
started.resolve();
return credential.promise;
});
const first = auth.run("Waiting", "Added", addPasskey);
await started.promise;
const duplicate = vi.fn(async () => ({}));
expect(await auth.run("Duplicate", "Wrong", duplicate)).toBe(false);
expect(duplicate).not.toHaveBeenCalled();
auth.cancel();
credential.resolve(credentialFixture());
expect(await first).toBe(false);
expect(
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
).toBe(false);
});
test("abort after complete dispatch remains an unknown submitted addition with a recoverable operation", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const started = gate<void>(), response = gate<Response>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input).endsWith("/complete")) {
started.resolve();
return response.promise;
}
return request(input, init);
});
const run = auth.run("Waiting", "Added", addPasskey);
await started.promise;
auth.cancel();
response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).toContain("result is unknown");
expect(auth.error).not.toContain("Completion was not sent");
});
for (
const current of [
"original-session",
"cookie-updated",
"anonymous",
"bob",
"unavailable",
] as const
) {
test(`lost completion body with ${current} distinguishes saved, session evidence and unavailable authority`, async () => {
const { auth, state, fetcher } = harness();
await auth.refresh();
let dispatched = false, resultReads = 0;
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/complete")) {
dispatched = true;
throw new TypeError("RAW-HTTP-BODY-TOKEN");
}
if (path === "/api/auth/whoami" && dispatched) {
if (current === "unavailable") throw new TypeError("RAW-WHOAMI-ERROR");
if (current === "anonymous") return identity(null);
if (current === "bob") return identity(bob);
if (current === "cookie-updated") {
return Response.json({
...(await identity(authUser).json()),
authentication_reference: "new-live-session",
});
}
}
if (path.endsWith("/addition/result")) {
++resultReads;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "safe-opaque-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
return request(input, init);
});
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain("result is unknown");
await auth.checkAddition();
if (current === "original-session" || current === "cookie-updated") {
expect(auth.additionSaved).toBe(true);
expect(auth.status).toContain("saved a key");
expect(auth.status).toContain(
"session issued by completion is not confirmed",
);
expect(resultReads).toBe(1);
} else {
expect(auth.additionSaved).toBe(false);
expect(resultReads).toBe(0);
expect(auth.error).toContain("does not prove failure");
}
expect(state.completed).toBe(false); // synthetic boundary never acts as the real store
});
}
test("storage mutation arriving before the storage event fences prompt completion even if whoami stays anonymous", async () => {
const { auth, state, get, fetcher } = harness();
state.user = null;
await auth.refresh();
get.mockImplementationOnce(async () => {
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: "another-tab", reason: "logout" }),
);
return credentialFixture(true);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(
fetcher.mock.calls.some(([path]) =>
String(path).endsWith("/login/complete")
),
).toBe(false);
});
for (const phase of ["options", "complete"] as const) {
test(`malformed ${phase} response records the stage without leaking body or inventing completion`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith(`/${phase}`)
? Promise.resolve(
new Response("RAW-CHALLENGE-COOKIE", {
headers: { "content-type": "application/json" },
}),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).toContain(`${phase}/invalid-response`);
expect(auth.error).not.toContain("RAW-CHALLENGE-COOKIE");
expect(auth.additionAttempt === null).toBe(phase === "options");
if (phase === "options") {
expect(auth.error).toContain("Completion was not sent");
} else expect(auth.error).toContain("result is unknown");
});
}
for (
const code of [
"passkey_authentication_changed",
"webauthn_registration_verification_failed",
] as const
) {
test(`HTTP rejection classifies allowlisted ${code} without retaining HTTP body`, async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.resolve(
Response.json({
message: `${code}: RAW-EXCEPTION-BODY`,
secret: "RAW-TOKEN",
}, { status: 401 }),
)
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.error).not.toContain("RAW-");
expect(auth.error).toContain(
code === "passkey_authentication_changed"
? "session-changed"
: "rejected",
);
expect(auth.error).toContain("HTTP-401");
expect(auth.error).not.toContain("Completion was not sent");
});
}
test("successful logout never marks an earlier unknown addition as saved", async () => {
const { auth, fetcher } = harness();
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input).endsWith("/complete")
? Promise.reject(new TypeError("response lost"))
: request(input, init)
);
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
expect(auth.additionAttempt).not.toBeNull();
expect(auth.additionSaved).toBe(false);
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
true,
);
expect(auth.additionSaved).toBe(false);
});
test("explicit login from stale expired display remains allowed after another tab cleared the cookie", async () => {
const { auth, state, fetcher } = harness();
state.user = null;
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
if (String(input).endsWith("/complete")) state.user = authUser;
return await request(input, init);
});
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(true);
expect(auth.status).toBe("Logged in");
});
test("explicit login from expired display does not replace another tab's authenticated account", async () => {
const { auth, state, fetcher, get } = harness();
fetcher.mockResolvedValueOnce(
Response.json({
actor: null,
authentication_reference: null,
session: { kind: "expired" },
}),
);
await auth.refresh();
state.user = bob;
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
expect(get).not.toHaveBeenCalled();
expect(state.completed).toBe(false);
});
@@ -43,7 +43,7 @@ test("active expired state removes private content and preserves browser query a
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } })
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
),
);
await browserSession.refresh();
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } })
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
),
);
await browserSession.refresh();
@@ -13,13 +13,14 @@ afterEach(() => {
localStorage.clear();
});
const anonymous: WhoamiResponse = {
actor: null,
actor: null, authentication_reference: null,
session: { kind: "anonymous" },
};
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
const authenticated = (
expires_at: string | null = "2099-01-01T00:00:00Z",
): WhoamiResponse => ({
authentication_reference: "fixture-authentication",
actor: { ...authUser, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at },
});
@@ -17,6 +17,10 @@ export class SessionState {
private listeners = new Set<(state: SessionSnapshot) => void>();
private stale = true;
get authenticationStamp(): string | null {
return storageStamp();
}
get authenticationGeneration(): number {
return this.generation;
}
@@ -38,7 +42,7 @@ export class SessionState {
++this.generation;
this.pending = null;
this.stale = true;
if (!preserveSnapshot) this.publish({ kind: "loading" });
this.publish(preserveSnapshot ? this.snapshot : { kind: "loading" });
}
// Page load owns freshness. Components mounted after it reuse that result.
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
export function sessionMutation(
reason: "authentication" | "logout" = "authentication",
preserveSnapshot = false,
): void {
browserSession.invalidate();
browserSession.invalidate(preserveSnapshot);
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
try {
localStorage.setItem(GENERATION_KEY, message);
@@ -35,6 +35,7 @@ function session() {
function whoami(authenticated: boolean) {
return Response.json({
authentication_reference: authenticated ? "fixture-authentication" : null,
session: authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
return new Response(null, { status: state.completeStatus });
}
state.authenticated = true;
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
});
@@ -106,6 +110,7 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
});
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
oldResponse.resolve(whoami(true));
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(auth.status).toBe("Logged out");
@@ -183,26 +190,34 @@ test("busy login rejects duplicate operations and refresh until its post-complet
const confirmingStarted = gate<void>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
if (String(input) === "/api/auth/passkeys/login/options") {
return options.promise;
}
if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
confirmingStarted.resolve();
return confirming.promise;
}
return request(input, init);
});
fetcher.mockReturnValueOnce(options.promise);
const run = auth.run(
"Waiting",
"Logged in",
(signal) => loginWithPasskey(signal),
);
expect(auth.busy).toBe(true);
expect(auth.status).toBe("Waiting");
expect(auth.status).toMatch(/Checking the account/);
const duplicate = vi.fn(async () => {});
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(duplicate).not.toHaveBeenCalled();
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
// Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
await vi.waitFor(() =>
expect(paths(fetcher)).toContain("/api/auth/passkeys/login/options")
);
expect(paths(fetcher).filter((path) => path !== "/api/auth/whoami")).toEqual([
"/api/auth/passkeys/login/options",
]);
options.resolve(Response.json(loginOptions));
@@ -212,15 +227,17 @@ test("busy login rejects duplicate operations and refresh until its post-complet
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(4);
confirming.resolve(whoami(true));
expect(await run).toBe(true);
expect(auth.busy).toBe(false);
expect(auth.actor?.handle).toBe("alice");
expect(auth.status).toBe("Logged in");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami",
]);
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
expect(result).toBe(false);
expect(auth.busy).toBe(false);
expect(auth.status).toBe("");
expect(auth.error).toMatch(
status === 401
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
expect(auth.error).toMatch(/server rejected the request or verification/);
expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
expect(auth.error).toContain("server did not accept completion");
expect(auth.session.kind).toBe("ready");
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/whoami",
"/api/auth/passkeys/addition/complete",
"/api/auth/whoami",
]);
@@ -278,22 +295,25 @@ for (
).toBe(false);
expect(auth.error).toMatch(
name === "NotAllowedError"
? /cancelled or timed out/
? /cancelled, timed out, or found no usable key/
: name === "NotSupportedError"
? /Completion is not confirmed/
? /supported device/
: name === "AbortError"
? /Completion is not confirmed/
: /Authentication could not be confirmed/,
? /Operation stopped/
: /Communication could not be confirmed/,
);
expect(auth.error).toContain("Completion was not sent.");
expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe("");
expect(auth.busy).toBe(false);
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
const { fetcher } = boundary();
const auth = session();
await auth.refresh();
let completed = false;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") {
return completed ? new Response(null, { status: 503 }) : whoami(false);
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
});
@@ -385,14 +412,15 @@ test("dispose aborts WebAuthn without accepting late credentials, while shared s
expect(auth.session).toEqual({
kind: "ready",
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
expect(auth.status).toBe("Waiting");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
@@ -409,6 +437,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const started = gate<AbortSignal>();
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") return whoami(false);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
@@ -427,16 +456,22 @@ test("dispose ignores a completion response even when the HTTP boundary does not
const signal = await started.promise;
auth.dispose();
expect(signal.aborted).toBe(true);
response.resolve(Response.json({ user: authUser }));
response.resolve(
Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
}),
);
expect(await run).toBe(false);
expect(auth.actor).toBeNull();
expect(auth.status).toBe("Waiting");
expect(auth.status).toBe("Confirming the operation with the server…");
expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
]);
expect(
dispatch.mock.calls.filter(([event]) =>
@@ -451,15 +486,21 @@ test("dispose during the post-operation whoami prevents its late actor from beco
await auth.refresh();
const response = gate<Response>();
const started = gate<void>();
let completed = false;
fetcher.mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return Response.json({ user: authUser });
completed = true;
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/whoami") {
if (!completed) return whoami(false);
started.resolve();
return await response.promise;
}
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
const started = gate<void>();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
if (
String(input) === "/api/auth/whoami" &&
paths(fetcher).includes("/api/auth/passkeys/login/complete")
) {
started.resolve();
return confirming.promise;
}
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
sessionMutation(newer === "logout" ? "logout" : "authentication");
fetcher.mockResolvedValueOnce(
newer === "logout" ? whoami(false) : Response.json({
authentication_reference: "other-authentication",
actor: {
...authUser,
user_id: "other-user",
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
),
).toBe(false);
expect(auth.error).toMatch(/cancelled or timed out/);
expect(auth.error).toMatch(/cancelled, timed out, or found no usable key/);
expect(auth.error).toContain("Completion was not sent.");
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
false,
);
@@ -20,6 +20,7 @@ for (
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
+90 -10
View File
@@ -1,30 +1,72 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
import type { PasskeyListResponse } from '#lib/generated/auth-api.ts';
import { addPasskey, logout, loadPasskeys, loadWhoami } from '#lib/workspace/auth/api.ts';
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth();
async function refresh() {
await auth.refresh();
let keys = $state<PasskeyListResponse | null>(null);
let keysError = $state('');
let keysLoading = $state(false);
let keyRead = 0;
let active = true;
let keyController: AbortController | null = null;
function time(value: string | null) { return value ? new Date(value).toLocaleString() : 'Not recorded'; }
async function refreshKeys() {
const actor = auth.actor;
if (!actor || !active) return;
keyController?.abort();
const controller = new AbortController();
keyController = controller;
const read = ++keyRead;
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
const assertCurrent = () => {
controller.signal.throwIfAborted();
if (!active || read !== keyRead || generation !== browserSession.authenticationGeneration || stamp !== browserSession.authenticationStamp) throw new Error('Session changed');
};
keysLoading = true; keysError = '';
try {
const before = await loadWhoami(controller.signal);
assertCurrent();
if (before.actor?.user_id !== actor.user_id || before.actor.account_id !== actor.account_id) throw new Error('Account changed');
const result = await loadPasskeys(controller.signal);
assertCurrent();
const after = await loadWhoami(controller.signal);
assertCurrent();
if (result.user.user_id !== actor.user_id || result.user.account_id !== actor.account_id ||
after.actor?.user_id !== actor.user_id || after.actor.account_id !== actor.account_id ||
before.authentication_reference !== after.authentication_reference) throw new Error('Session changed');
keys = result;
} catch {
if (active && read === keyRead && !controller.signal.aborted) { keys = null; keysError = 'Registered keys could not be confirmed for this account. Refresh your session before trying again.'; }
} finally { if (read === keyRead) keysLoading = false; }
}
async function refresh() { await auth.refresh(); await refreshKeys(); }
async function add() {
if (!auth.actor) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account; the expected session was confirmed.', addPasskey);
await refreshKeys();
}
async function checkAddition() { await auth.checkAddition(); await refreshKeys(); }
async function signOut() {
if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
}
onMount(() => { void auth.ensure(); return () => auth.dispose(); });
onMount(() => { void auth.ensure().then(refreshKeys); return () => { active = false; ++keyRead; keyController?.abort(); auth.dispose(); }; });
</script>
<svelte:head><title>Account · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off">
<h1>Account</h1>
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
{#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'}
@@ -37,8 +79,41 @@
</dl>
<section aria-label="Your Passkeys">
<h2>Passkeys</h2>
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
<p class="muted">Add a passkey for login without an account ID. Use a device or key that saves passkeys and verifies you with a PIN or biometrics. Existing keys are preserved.</p>
<p>If an older key is in the same authenticator or saved-key provider, duplicate exclusion can block addition there. Choose a different device, security key, or passkey provider. A different provider on the same device may work only if it does not contain an excluded key. Do not delete an older key to bypass this check.</p>
<button type="button" disabled={auth.busy || !!auth.additionAttempt && !auth.additionSaved} onclick={add}>Add passkey to {auth.actor.handle}</button>
{#if auth.additionAttempt}
<button type="button" disabled={auth.busy} onclick={checkAddition}>Check addition result</button>
<p class="muted">A saved result confirms this addition's server record, not that ID-free login works. If the session changed, result access may be unavailable; do not restore an old session or add blindly.</p>
{/if}
{#if keysLoading}<p role="status">Checking registered keys…</p>{/if}
{#if keysError}<p role="alert" class="error">{keysError}</p>{/if}
{#if keys && keys.user.user_id === auth.actor.user_id && keys.user.account_id === auth.actor.account_id}
<ul class="passkey-list" aria-label="Registered keys">
{#each keys.keys as key (key.key_reference)}
<li>
<dl>
<div><dt>Created</dt><dd>{time(key.created_at)}</dd></div>
<div><dt>Last used</dt><dd>{time(key.last_used_at)}</dd></div>
<div><dt>ID-free login</dt><dd>{key.normal_login_verified_at ? `Verified ${time(key.normal_login_verified_at)}` : 'Not yet verified — storage alone does not prove support'}</dd></div>
</dl>
<details><summary>Key reference</summary><code>{key.key_reference}</code></details>
</li>
{/each}
</ul>
{#if keys.keys.length === 0}<p>No registered keys were returned for this account.</p>{/if}
{/if}
<button type="button" disabled={auth.busy || keysLoading} onclick={refreshKeys}>Refresh registered keys</button>
<details>
<summary>Verify migration before retiring older-key login</summary>
<ol>
<li>Keep this working session and every working older key. If addition is excluded, use a different saved-key provider or authenticator; removing your last key first can lock you out.</li>
<li>In a separate browser profile or private window, open <a href="/login" target="_blank" rel="noopener">normal passkey login</a>. Do not open the older-key/ID form. Select the newly saved key and complete verification.</li>
<li>Confirm the intended account ID in that window's Account page. Refresh the key list here and check that the chosen key now records verified ID-free login. Repeat for every key and device you intend to rely on; a stored key or resident-key hint alone is not proof.</li>
<li>Retain a tested independent way back in before ceasing to rely on an older key. If any required device cannot complete normal login, keep the older-key path available. Do not delete old keys as a prerequisite.</li>
</ol>
<p>Removing the older-key entry for the service is not safe based on one account's success. The operator must first confirm migration and a working alternative for every affected account/device, keep a rollback path to the older-key entry, and verify normal login before retiring that entry. This page does not delete keys or disable compatibility login.</p>
</details>
</section>
<div class="actions">
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
@@ -48,7 +123,12 @@
<p>No browser session is active.</p>
<a href={loginUrl('/account')}>Log in to your account</a>
{/if}
{#if auth.status}<p role="status">{auth.status}</p>{/if}
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<a href="/dashboard">Dashboard / Workspaces</a>
</div>
<style>
.passkey-list { padding-inline-start: var(--space-5); display: grid; gap: var(--space-4); }
.passkey-list li { min-width: 0; }
code { overflow-wrap: anywhere; }
ol { padding-inline-start: var(--space-5); }
li + li { margin-block-start: var(--space-2); }
</style>
@@ -63,6 +63,9 @@ function boundary({
return new Response(null, { status: state.whoamiStatus });
}
return Response.json({
authentication_reference: state.authenticated
? "fixture-authentication"
: null,
session: state.authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -71,6 +74,9 @@ function boundary({
: null,
});
}
if (path === "/api/auth/passkeys") {
return Response.json({ user: authUser, keys: [] });
}
if (path === "/api/auth/registration-policy") {
if (state.policy === "loading") return await policyResponse.promise;
if (state.policy === "error") return new Response(null, { status: 503 });
@@ -97,7 +103,10 @@ function boundary({
return new Response(null, { status: state.completeStatus });
}
state.authenticated = true;
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/logout") {
state.authenticated = false;
@@ -149,6 +158,143 @@ async function submit(name: string) {
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
}
for (const phase of ["initial-whoami", "list", "final-whoami"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`registered-key ${phase} confirmation rejects shared ${reason} before event delivery`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let whoami = 0, listReads = 0;
const metadata = () =>
Response.json({
user: authUser,
keys: [{
key_reference: "delayed-key-metadata",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path === "/api/auth/whoami") {
++whoami;
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
started.resolve();
return response.promise;
}
}
if (path === "/api/auth/passkeys") {
++listReads;
if (phase === "list") {
started.resolve();
return response.promise;
}
return metadata();
}
return request(input, init);
});
const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
await fireEvent.click(
screen.getByRole("button", { name: "Refresh registered keys" }),
);
await started.promise;
const changed = JSON.stringify({ id: `keys-${phase}-${reason}`, reason });
localStorage.setItem("yoi.browser-auth-generation", changed);
expect(browserSession.authenticationStamp).not.toBe(stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(
phase === "list" ? metadata() : await request("/api/auth/whoami"),
);
await screen.findByText(/Registered keys could not be confirmed/);
expect(screen.queryByText("delayed-key-metadata")).toBeNull();
expect(listReads).toBe(phase === "initial-whoami" ? 0 : 1);
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
expect(posted(fetcher)).toEqual([]);
});
}
}
for (const reason of ["logout", "authentication"] as const) {
test(`stale receipt confirmation after shared ${reason} leaves visible repeat-add disabled`, async () => {
const { fetcher } = boundary({ authenticated: true });
render(Account);
await screen.findByText(
"No registered keys were returned for this account.",
);
const request = fetcher.getMockImplementation()!;
const started = gate<void>(), response = gate<Response>();
let receiptRead = false;
fetcher.mockImplementation(async (input, init) => {
const path = String(input);
if (path.endsWith("/addition/complete")) {
throw new TypeError("lost response");
}
if (path.endsWith("/addition/result")) {
receiptRead = true;
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
.toBe("fixture-authentication");
return Response.json({
status: "saved",
user: authUser,
key: {
key_reference: "saved-but-session-unconfirmed",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
},
});
}
if (path === "/api/auth/whoami" && receiptRead) {
receiptRead = false;
started.resolve();
return response.promise;
}
return request(input, init);
});
const add = screen.getByRole("button", {
name: /Add passkey to/,
}) as HTMLButtonElement;
await fireEvent.click(add);
await screen.findByText(/result is unknown/);
await waitFor(() =>
expect(screen.queryByText("Checking registered keys…")).toBeNull()
);
expect(add.disabled).toBe(true);
const checking = screen.getByRole("button", {
name: "Check addition result",
});
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
await fireEvent.click(checking);
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: `visible-${reason}`, reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
response.resolve(await request("/api/auth/whoami"));
await screen.findByText(/The addition result could not be read/);
await waitFor(() =>
expect((checking as HTMLButtonElement).disabled).toBe(false)
);
expect(screen.queryByText(/Your current session was checked/)).toBeNull();
expect(add.disabled).toBe(true);
await fireEvent.click(add);
expect(
posted(fetcher).filter(({ path }) => path.endsWith("/addition/complete")),
).toHaveLength(1);
});
}
test("normal login needs no account ID and links separate registration and code activation", async () => {
const { fetcher } = boundary({ policy: true });
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
@@ -231,19 +377,23 @@ for (
expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled();
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(4);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
});
}
test("successful login completion without confirmed whoami never navigates", async () => {
const { state, get } = boundary();
const { state, fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => {
const response = await request(input, init);
if (String(input) === "/api/auth/passkeys/login/complete") {
state.whoamiStatus = 503;
}
return response;
});
render(Login);
await enterIdentity();
get.mockImplementationOnce(async () => {
state.whoamiStatus = 503;
return credentialFixture(true);
});
await submit("Log in with passkey");
await screen.findByText(/Your session could not be loaded/);
expect(goto).not.toHaveBeenCalled();
@@ -347,7 +497,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
await waitFor(() => expect(create).toHaveBeenCalledOnce());
state.completeStatus = 403;
credential.resolve(credentialFixture());
await screen.findByText("Auth request failed (403).");
await screen.findByText(/\[complete\/rejected\/HTTP-403\]/);
await screen.findByText(/New account registration is disabled/);
expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull();
@@ -376,7 +526,9 @@ test("account displays the current identity without editable target fields and a
await fireEvent.click(
screen.getByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText("Passkey added to your account.");
await screen.findByText(
"Passkey added to your account; the expected session was confirmed.",
);
await waitFor(() =>
expect(
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
@@ -388,7 +540,12 @@ test("account displays the current identity without editable target fields and a
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
expect(requests[0].body).toEqual({
browser_origin: window.location.origin,
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "fixture-authentication",
});
expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id",
"credential",
@@ -485,11 +642,18 @@ test("logout confirms the server session is cleared and returns to the public ho
expect(posted(fetcher)).toEqual([
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
]);
expect(apiPaths(fetcher)).toEqual([
const paths = apiPaths(fetcher);
expect(paths[0]).toBe("/api/auth/whoami");
expect(paths.slice(paths.indexOf("/api/auth/logout") + 1)).toContain(
"/api/auth/whoami",
"/api/auth/logout",
"/api/auth/whoami",
]);
);
expect(
paths.every((path) =>
["/api/auth/whoami", "/api/auth/logout", "/api/auth/passkeys"].includes(
path,
)
),
).toBe(true);
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
});
@@ -499,10 +663,14 @@ test("expired addition revokes stale identity without silently retrying as regis
await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }),
);
await screen.findByText(/Authentication request was rejected/);
await screen.findByText(/server rejected the request or verification/);
await screen.findByRole("link", { name: "Log in to your account" });
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; the expected session was confirmed.",
),
).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
@@ -545,7 +713,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
await enterIdentity();
await submit("Log in with passkey");
await screen.findByText(
"Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.",
/cancelled, timed out, or found no usable key/,
);
expect(screen.getByRole("alert").textContent).toContain(
"Completion was not sent.",
);
expect(screen.getByRole("alert").textContent).toContain(
"[authenticator/cancelled-or-unavailable]",
);
await waitFor(() =>
expect(
@@ -651,7 +825,12 @@ test("device approval locks its code and rejects a second submit while the serve
name: "Approve device login",
});
const response = gate<Response>();
fetcher.mockReturnValueOnce(response.promise);
const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) =>
String(input) === "/api/auth/device-login/approve"
? response.promise
: request(input, init)
);
const form = approve.closest("form")!;
await fireEvent.submit(form);
await screen.findByText("Approving device login…");
@@ -659,9 +838,11 @@ test("device approval locks its code and rejects a second submit while the serve
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
.toBe(true);
await fireEvent.submit(form);
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve",
]);
await waitFor(() =>
expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve",
])
);
response.resolve(Response.json({ status: "approved", user: authUser }));
await screen.findByText("Device login approved. You can return to the CLI.");
expect(posted(fetcher)).toHaveLength(1);
@@ -679,11 +860,8 @@ for (const status of [401, 410]) {
(await screen.findByRole("button", { name: "Approve device login" }))
.closest("form")!,
);
await screen.findByText(
status === 401
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
await screen.findByText(/Authentication request was rejected/);
expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
expect(
screen.queryByText("Device login approved. You can return to the CLI."),
).toBeNull();
@@ -721,7 +899,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
target: { value: "alice" },
});
await submit("Log in with older key");
await screen.findByText(/cancelled or timed out/);
await screen.findByText(/cancelled, timed out, or found no usable key/);
await screen.findByText("Use an older key with account ID");
expect(
(screen.getByText("Use an older key with account ID").closest(
@@ -3,6 +3,7 @@
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import { browserSession } from '#lib/workspace/auth/session-state.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
let handle = $state('');
@@ -19,12 +20,15 @@
try { handle = normalizeActivationHandle(handle); }
catch { error = 'Check the account ID supplied by your administrator.'; return; }
const current = ++generation;
const authGeneration = browserSession.authenticationGeneration;
const authStamp = browserSession.authenticationStamp;
controller = new AbortController(); busy = true; error = '';
const pending = activateWithPasskey(handle, code, controller.signal);
code = '';
try {
const confirmed = await pending;
if (current !== generation) return;
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
user = confirmed;
notifySessionChanged();
} catch {
@@ -7,6 +7,11 @@ import {
waitFor,
} from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest";
import {
browserSession,
sessionMutation,
} from "#lib/workspace/auth/session-state.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import Activate from "./+page.svelte";
import {
authUser,
@@ -15,9 +20,34 @@ import {
} from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => {
cleanup();
browserSession.invalidate();
vi.unstubAllGlobals();
});
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
function gate<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function confirmedSession() {
return {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
};
}
function boundary(
mode:
| "success"
| "pending"
| "cancel"
| "unknown"
| "anonymous"
| "bob"
| "reference-mismatch"
| "unavailable",
) {
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
}
if (String(input) === "/api/auth/activation/complete") {
if (mode === "unknown") throw new Error("private diagnostic");
return Response.json({ user: authUser });
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (String(input) === "/api/auth/whoami") {
if (mode === "unavailable") {
return new Response("private diagnostic", { status: 503 });
}
if (mode === "anonymous") {
return Response.json({
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
}
const confirmation = confirmedSession();
if (mode === "bob") {
confirmation.actor = {
...confirmation.actor,
user_id: "user-bob",
account_id: "account-bob",
handle: "bob",
display_name: "Bob",
};
}
if (mode === "reference-mismatch") {
confirmation.authentication_reference = "another-authentication";
}
return Response.json(confirmation);
}
throw new Error("Unexpected endpoint");
});
@@ -54,12 +113,29 @@ async function submit() {
}
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
const { fetcher } = boundary("success");
const confirmation = gate<Response>();
const started = gate<void>();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
return confirmation.promise;
}
return original(input, init);
});
render(Activate);
await submit();
await started.promise;
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
.toBe("");
confirmation.resolve(Response.json(confirmedSession()));
await screen.findByText(/registration and activation confirmed/);
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
expect(screen.queryByLabelText("Activation code")).toBeNull();
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
@@ -99,3 +175,106 @@ for (const mode of ["cancel", "unknown"] as const) {
.toBe("");
});
}
for (
const mode of [
"anonymous",
"bob",
"reference-mismatch",
"unavailable",
] as const
) {
test(
"activation " + mode +
" confirmation never claims signed in after successful completion",
async () => {
const { fetcher, create } = boundary(mode);
const changed = vi.fn();
window.addEventListener(SESSION_CHANGED_EVENT, changed);
try {
render(Activate);
await submit();
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
"/api/auth/activation/complete",
"/api/auth/whoami",
]);
expect(create).toHaveBeenCalledOnce();
expect(screen.getByRole("alert").textContent).toContain(
"Passkey login",
);
expect(screen.getByRole("alert").textContent).not.toContain(
"private diagnostic",
);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(
(screen.getByLabelText("Activation code") as HTMLInputElement).value,
).toBe("");
expect(changed).not.toHaveBeenCalled();
} finally {
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
}
},
);
}
test("logout during the activation prompt fences a late credential before completion", async () => {
const { fetcher, create } = boundary("success");
const credential = gate<Credential>();
create.mockReturnValueOnce(credential.promise);
render(Activate);
await submit();
await waitFor(() => expect(create).toHaveBeenCalledOnce());
sessionMutation("logout");
credential.resolve(credentialFixture());
await screen.findByRole("alert");
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options",
]);
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
});
for (const phase of ["prompt", "confirmation"] as const) {
for (const reason of ["logout", "authentication"] as const) {
test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { create, fetcher } = boundary("success");
const started = gate<void>();
const pending = gate<void>();
const request = fetcher.getMockImplementation()!;
if (phase === "prompt") {
create.mockImplementation(async () => {
started.resolve();
await pending.promise;
return credentialFixture();
});
} else {fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/auth/whoami") {
started.resolve();
await pending.promise;
}
return await request(input, init);
});}
render(Activate);
await submit();
await started.promise;
const generation = browserSession.authenticationGeneration;
localStorage.setItem(
"yoi.browser-auth-generation",
JSON.stringify({ id: crypto.randomUUID(), reason }),
);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
expect(screen.queryByText(/You are signed in/)).toBeNull();
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/activation/complete"
),
).toHaveLength(phase === "prompt" ? 0 : 1);
});
}
}
@@ -53,6 +53,7 @@ function boundary() {
if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200
? Response.json({
authentication_reference: state.signedIn ? "fixture-authentication" : null,
session: state.signedIn
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -326,6 +327,7 @@ test("pending create remains single during an explicit authentication change and
expect(goto).not.toHaveBeenCalled();
state.sessionGate.resolve(
Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" },
}),
@@ -22,6 +22,7 @@ function boundary(
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
if (input === "/api/auth/whoami") {
return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -27,6 +27,7 @@ function boundary(
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
session: persona !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -1,7 +1,8 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
import { loginWithPasskey, loadWhoami } from '#lib/workspace/auth/api.ts';
import { observeAuthOperation } from '#lib/workspace/auth/operation.ts';
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts';
@@ -12,10 +13,12 @@
let verifiedLogin = $state(false);
let error = $state('');
const controller = new AbortController();
let loginController: AbortController | null = null;
let active = true;
let verifiedGeneration: number | null = null;
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
let verifiedStamp: string | null = null;
const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp;
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
function matchesSession(expected: AuthUser): boolean {
const snapshot = browserSession.snapshot;
@@ -28,9 +31,10 @@
return browserSession.subscribe(() => {
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
verifiedLogin = false;
verifiedGeneration = null;
verifiedStamp = null;
}
});
});
@@ -61,34 +65,56 @@
error = '';
sessionMutation();
let generation = browserSession.authenticationGeneration;
let stamp = browserSession.authenticationStamp;
const operation = new AbortController();
loginController = operation;
let promptGeneration: number | null = generation;
const unsubscribe = browserSession.subscribe(() => {
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
});
try {
const signedIn = await loginWithPasskey(controller.signal);
if (!isCurrent(generation)) throw new Error('Login superseded');
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
const initial = await loadWhoami(operation.signal);
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
observeAuthOperation(operation.signal, {
expected: initial, phase: () => {}, additionAttempt: () => {},
verifySession: async () => {
if (!isCurrent(generation, stamp)) operation.abort();
operation.signal.throwIfAborted();
const current = await loadWhoami(operation.signal);
if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed');
},
});
const signedIn = await loginWithPasskey(operation.signal);
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
throw new Error('Unexpected login actor');
}
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
promptGeneration = null;
sessionMutation();
generation = browserSession.authenticationGeneration;
stamp = browserSession.authenticationStamp;
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
const access = await loadHostingAccess();
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified');
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
verifiedGeneration = generation;
verifiedStamp = stamp;
verifiedLogin = true;
notifySessionChanged();
} 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.';
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
if (isCurrent(generation)) {
if (isCurrent(generation, stamp)) {
sessionMutation();
await browserSession.refresh();
}
} finally {
promptGeneration = null; unsubscribe(); loginController = null;
if (active) busy = false;
}
}
@@ -97,6 +123,7 @@
active = false;
token = '';
controller.abort();
loginController?.abort();
});
</script>
@@ -36,37 +36,56 @@ function boundary(
const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => credentialFixture(true));
vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
const path = String(input);
if (path === "/api/auth/whoami") {
return Response.json({
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: admin });
}
if (path === "/api/auth/passkeys/addition/options") {
return status === 200
? Response.json(registrationOptions)
: new Response("host-test-token", { status });
}
if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({ user: authUser });
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return failLogin
? new Response(null, { status: 401 })
: Response.json({ user: authUser });
}
throw new Error(`Unexpected API: ${path}`);
});
const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input);
if (
path === "/api/auth/whoami" &&
new Headers(init?.headers).has("authorization")
) {
return status === 200
? Response.json({
actor: { ...authUser, auth_method: "api_token" },
authentication_reference: "fixture-bearer-authentication",
session: { kind: "authenticated", expires_at: null },
})
: new Response("host-test-token", { status });
}
if (path === "/api/auth/whoami") {
return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
});
}
if (path === "/api/hosting/access") {
return Response.json({ is_admin: admin });
}
if (path === "/api/auth/passkeys/addition/options") {
return status === 200
? Response.json(registrationOptions)
: new Response("host-test-token", { status });
}
if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions);
}
if (path === "/api/auth/passkeys/login/complete") {
return failLogin ? new Response(null, { status: 401 }) : Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
}
throw new Error(`Unexpected API: ${path}`);
},
);
vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get };
}
@@ -117,8 +136,10 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
for (const [path, init] of requests) {
expect(String(path)).not.toContain("host-test-token");
expect(String(init?.body)).not.toContain("host-test-token");
expect(init?.credentials).toBe("omit");
expect(new Headers(init?.headers).has("cookie")).toBe(false);
expect(new Headers(init?.headers).get("authorization")).toBe(
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
"Bearer host-test-token",
);
}
await fireEvent.click(
@@ -130,7 +151,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
expect(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(2);
.toHaveLength(6);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1);
expect(
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
String(input) === "/api/auth/whoami" &&
!new Headers(init?.headers).has("authorization")
? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: {
...authUser,
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
unmount();
expect(signal.aborted).toBe(true);
resolve(credentialFixture());
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
await waitFor(() =>
expect(fetcher.mock.calls.map(([path]) => path)).toEqual([
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
])
);
const { unmount: unmountAgain } = render(Setup);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
@@ -248,7 +276,7 @@ test("completion response loss does not claim success and points to login before
);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe("");
expect(fetcher).toHaveBeenCalledTimes(2);
expect(fetcher).toHaveBeenCalledTimes(3);
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull();
});
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami"
? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "api_token" },
})
@@ -292,6 +321,7 @@ const replacedActor = {
function identityResponse(actor: typeof setupActor | null) {
return Response.json({
actor,
authentication_reference: actor ? "fixture-authentication" : null,
session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
const started = gate<void>();
const pending = gate<Response>();
let current: typeof setupActor | null = setupActor;
let reads = 0;
let loginCompleted = false;
let accessRead = false;
let held = false;
fetcher.mockImplementation((input, init) => {
const path = String(input);
// Bearer identity resolution belongs to enrollment, never the cookie-login gates.
if (new Headers(init?.headers).has("authorization")) {
return original(input, init);
}
if (path === "/api/auth/passkeys/login/complete") loginCompleted = true;
if (path === "/api/hosting/access") accessRead = true;
if (path === "/api/auth/whoami") {
++reads;
if (
(phase === "whoami" && reads === 1) ||
(phase === "confirmation" && reads === 2)
!held && loginCompleted && (
(phase === "whoami" && !accessRead) ||
(phase === "confirmation" && accessRead)
)
) {
held = true;
started.resolve();
return pending.promise;
}
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
phase === "access"
? Response.json({ is_admin: true })
: phase === "login"
? Response.json({ user: authUser })
? Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
})
: identityResponse(setupActor),
);
await waitFor(() =>
@@ -421,3 +464,154 @@ for (const replacement of [null, replacedActor]) {
).toBe(false);
});
}
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
const { get, fetcher } = boundary();
const prompted = gate<void>();
const credential = gate<Credential>();
get.mockImplementation(() => {
prompted.resolve();
return credential.promise;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await prompted.promise;
await changedInAnotherTab(null);
const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0]
.signal!;
expect(signal.aborted).toBe(true);
credential.resolve(credentialFixture(true));
await screen.findByRole("alert");
expect(
fetcher.mock.calls.some(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toBe(false);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
test("setup final confirmation rejects replacement session for the same owner", async () => {
const { fetcher } = boundary();
const request = fetcher.getMockImplementation()!;
let accessRead = false;
fetcher.mockImplementation(async (input, init) => {
if (String(input) === "/api/hosting/access") accessRead = true;
if (String(input) === "/api/auth/whoami" && accessRead) {
return Response.json({
actor: setupActor,
authentication_reference: "same-owner-different-session",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return await request(input, init);
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await screen.findByRole("alert");
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
});
for (
const phase of [
"initial",
"options",
"prompt",
"pre-complete",
"complete",
"whoami",
"access",
"confirmation",
] as const
) {
for (const reason of ["logout", "authentication"] as const) {
test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
const { fetcher, get } = boundary();
const request = fetcher.getMockImplementation()!;
const started = gate<void>();
const pending = gate<void>();
let optionsRead = false,
promptReturned = false,
completionSent = false,
accessRead = false,
held = false;
const hold = async () => {
held = true;
started.resolve();
await pending.promise;
};
get.mockImplementation(async () => {
if (phase === "prompt") await hold();
promptReturned = true;
return credentialFixture(true);
});
fetcher.mockImplementation(async (input, init) => {
if (new Headers(init?.headers).has("authorization")) {
return await request(input, init);
}
const path = String(input);
const point = path === "/api/auth/whoami"
? !optionsRead
? "initial"
: !completionSent && promptReturned
? "pre-complete"
: accessRead
? "confirmation"
: "whoami"
: path.endsWith("/login/options")
? "options"
: path.endsWith("/login/complete")
? "complete"
: path === "/api/hosting/access"
? "access"
: null;
if (point === "options") optionsRead = true;
if (point === "complete") completionSent = true;
if (point === "access") accessRead = true;
const response = await request(input, init);
if (!held && point === phase) await hold();
return response;
});
render(Setup);
await enroll();
await fireEvent.click(
await screen.findByRole("button", { name: "Sign in with new passkey" }),
);
await started.promise;
const generation = browserSession.authenticationGeneration;
const stamp = JSON.stringify({ id: crypto.randomUUID(), reason });
// 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。
localStorage.setItem("yoi.browser-auth-generation", stamp);
expect(browserSession.authenticationGeneration).toBe(generation);
pending.resolve();
await screen.findByRole("alert");
await waitFor(() =>
expect(
(screen.getByRole("button", {
name: "Sign in with new passkey",
}) as HTMLButtonElement).disabled,
).toBe(false)
);
expect(
fetcher.mock.calls.filter(([path]) =>
path === "/api/auth/passkeys/login/complete"
),
).toHaveLength(
["complete", "whoami", "access", "confirmation"].includes(phase)
? 1
: 0,
);
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
.toBeNull();
// 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。
expect(browserSession.authenticationStamp).toBe(stamp);
});
}
}
@@ -13,11 +13,12 @@
async function approve() {
if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
const code = userCode.trim().toUpperCase();
await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
const confirmed = await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
const result = await approveDeviceLogin(code, signal);
if (result.status !== 'approved') throw new Error('Device approval not confirmed');
approved = true;
});
return result;
}, 'device-approval');
if (confirmed) approved = true;
}
onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
@@ -57,7 +57,7 @@ beforeEach(() => {
"fetch",
vi.fn(async (input) => {
if (String(input) === "/api/auth/whoami") {
return Response.json({ session: { kind: "anonymous" }, actor: null });
return Response.json({ session: { kind: "anonymous" }, authentication_reference: null, actor: null });
}
throw new Error(`Unexpected request ${input}`);
}),
@@ -41,6 +41,7 @@ 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/auth/whoami") {
return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -89,7 +90,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (mode === "activation-unknown") {
return new Response(null, { status: 503 });
}
return Response.json({ user });
return Response.json({ user, authentication_reference: "fixture-authentication" });
}
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
const file = join(root, decodeURIComponent(path));
@@ -34,6 +34,11 @@ const register = async (page: Page) => {
await page.getByRole("button", { name: "Register new account", exact: true })
.click();
};
const additionConfirmed = (page: Page) =>
page.getByRole("status").filter({
hasText:
/^Passkey added to your account; the expected session was confirmed\.$/,
});
const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => {
@@ -151,7 +156,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
`${root}/target/t783/web-shell-final/processes`,
[],
);
let browser: Browser | undefined;
@@ -229,9 +234,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
const additions = mutations.filter((entry) =>
entry.path.includes("/addition/")
);
@@ -239,7 +242,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
assertEquals(additions[0].keys, ["browser_origin"]);
assertEquals(additions[0].keys, [
"browser_origin",
"expected_account_id",
"expected_authentication_reference",
"expected_user_id",
]);
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")),
@@ -357,8 +365,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice",
exact: true,
}).click();
await page.getByRole("status").filter({ hasText: /added/i })
.waitFor();
await additionConfirmed(page).waitFor();
assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"),
1,
@@ -784,9 +791,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
.__authFixtureRelease()
);
if (purpose === "addition") {
await page.getByRole("status").filter({
hasText: /added|registered/i,
}).waitFor();
await additionConfirmed(page).waitFor();
} else await at(page, "/dashboard");
assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`),
@@ -813,8 +818,18 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Approve device login",
exact: true,
}).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
.waitFor();
await page.getByRole("alert").filter({
hasText:
/^Authentication request was rejected \(400\)\. Check your session or restart the operation\.$/,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/login/device");
assertEquals(
await page.getByText(
"Device login approved. You can return to the CLI.",
{ exact: true },
).count(),
0,
);
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}),
);
@@ -554,7 +554,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
},
);
await step(
"SPA page access checks once without hiding the admitted page",
"SPA page access checks once and fences key-list reads without hiding the admitted page",
async (page) => {
let checks = 0;
page.on("request", (request) => {
@@ -582,6 +582,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await page.getByRole("link", { name: "Account", exact: true })
.click();
await requested;
assertEquals(checks, 2);
assertEquals(
await page.getByRole("heading", {
name: "Dashboard",
@@ -599,7 +600,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true,
}).waitFor();
assertEquals(new URL(page.url()).pathname, "/account");
assertEquals(checks, 2);
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
// page入場の一回に加え、本人鍵一覧は読取りの前後で本人bindingを確認する。
assertEquals(checks, 4);
} finally {
release();
}
@@ -158,9 +158,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response(null, { status: 503 });
}
if (mode === "session-malformed") {
return Response.json({ actor: { handle: "untrusted" } });
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: { handle: "untrusted" },
});
}
return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: state.signedIn
? { ...user, auth_method: "browser_session" }
: null,
@@ -168,11 +176,25 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
? {
kind: "authenticated",
expires_at: state.expiresAt ??
new Date(Date.now() + 86_400_000).toISOString(),
"2099-01-01T00:00:00Z",
}
: { kind: state.expired ? "expired" : "anonymous" },
});
}
if (path === "/api/auth/passkeys") {
return Response.json({
user,
keys: [{
key_reference: "synthetic-safe-key-reference",
created_at: "2026-01-01T00:00:00Z",
last_used_at: null,
normal_login_verified_at: null,
}],
});
}
if (path === "/api/auth/passkeys/addition/result") {
return Response.json({ status: "unknown" });
}
if (path === "/api/auth/registration-policy") {
if (mode === "policy-loading") return await new Promise<Response>(() => {});
if (mode === "policy-error") return new Response(null, { status: 503 });
@@ -227,7 +249,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
}
if (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 });
if (mode === "device-expired") return new Response(null, { status: 410 });
if (mode === "device-expired") {
return Response.json({
message: "expired_device_login: device login code expired",
}, { status: 400 });
}
return Response.json({ status: "approved", user });
}
const passkey = path.match(
@@ -263,7 +289,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/activation/options") {
return Response.json(options(false));
@@ -273,7 +302,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false;
state.cookieSession = false;
state.expiresAt = null;
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
if (workspaceFixture && path.startsWith("/api/w/")) {
@@ -0,0 +1,208 @@
/// <reference lib="dom" />
// 本番shellと可視状態の試験。HTTP/認証器を合成fixtureへ置換するため、暗号/保存の成功根拠ではない。
// native CTAP2 + 実Backend経路はtests/e2eの別suiteが所有する。
import { assert, assertEquals } from "@std/assert";
import { resolve } from "@std/path";
import { type Browser, chromium } from "playwright";
import {
startOwnedProcesses,
stopOwnedProcesses,
} from "../../../../tools/web-ux/src/processes.ts";
Deno.test("account addition keeps pending and recovery visible at desktop and mobile widths", async (t) => {
const root = resolve(import.meta.dirname!, "../../../..");
const reservation = Deno.listen({ hostname: "127.0.0.1", port: 0 });
const port = (reservation.addr as Deno.NetAddr).port;
reservation.close();
const origin = `http://127.0.0.1:${port}`;
const out = `${root}/target/web-ux/t783-after`;
const processes = await startOwnedProcesses(
[{
id: "passkey-state",
command: "deno",
cwd: root,
args: [
"run",
"--config",
"tools/web-ux/deno.json",
"--allow-read",
"--allow-net",
"web/workspace/test/auth-browser/fixture-server.ts",
String(port),
"web/workspace/build",
],
readyUrl: `${origin}/health`,
}],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
`${out}/processes`,
[],
);
let browser: Browser | undefined;
const records: unknown[] = [];
try {
browser = await chromium.launch();
for (const theme of ["light", "dark"] as const) {
for (const width of [1440, 768, 390]) {
const passed = await t.step(
`${theme}/${width}: visible pending, stop and migration metadata`,
async () => {
const context = await browser!.newContext({
viewport: { width, height: 900 },
colorScheme: theme,
reducedMotion: "reduce",
});
try {
const page = await context.newPage();
page.setDefaultTimeout(5000);
const pageErrors: string[] = [];
const statuses: number[] = [];
const consoleErrors: string[] = [];
const requestErrors: string[] = [];
page.on(
"requestfailed",
() => requestErrors.push("request-failed"),
);
page.on("pageerror", () => pageErrors.push("page-error"));
page.on("console", (msg) => {
if (msg.type() === "error") {
consoleErrors.push("console-error");
}
});
page.on("response", (response) => {
if (response.status() >= 400) statuses.push(response.status());
});
await context.route(
"**/*",
(route) =>
new URL(route.request().url()).origin === origin
? route.continue()
: route.abort(),
);
await page.request.get(`${origin}/?fixture=addition-pending`);
const documentResponse = await page.goto(`${origin}/account`);
const add = page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
});
await add.waitFor();
await page.getByRole("list", { name: "Registered keys" })
.waitFor();
await page.screenshot({
path: `${out}/account-${theme}-${width}.png`,
fullPage: true,
});
await add.click();
const pending = page.getByRole("status").filter({
hasText: "Waiting for passkey addition",
});
await pending.waitFor({ state: "visible" });
const box = await pending.boundingBox();
assert(
box && box.y >= 0 && box.y + box.height <= 900,
"pending must be in the initial visible viewport",
);
assertEquals(await add.isDisabled(), true);
assertEquals(
await page.getByText("Loading session…", { exact: true })
.isVisible(),
false,
);
await page.screenshot({
path: `${out}/pending-${theme}-${width}.png`,
fullPage: true,
});
const pendingAccessibility = await page.locator("main")
.ariaSnapshot();
await page.getByRole("button", {
name: "Stop waiting",
exact: true,
}).click();
await page.evaluate(() =>
(window as unknown as { __authFixtureRelease: () => void })
.__authFixtureRelease()
);
await page.getByRole("alert").filter({
hasText: "Completion was not sent",
}).waitFor();
await page.getByRole("button", {
name: "Refresh registered keys",
exact: true,
}).click();
await page.getByText(
"Not yet verified — storage alone does not prove support",
{ exact: true },
).waitFor();
await page.getByText(
"Verify migration before retiring older-key login",
{ exact: true },
).click();
await page.screenshot({
path: `${out}/migration-${theme}-${width}.png`,
fullPage: true,
});
assertEquals(
await page.evaluate(() =>
document.documentElement.scrollWidth > innerWidth
),
false,
);
assertEquals(documentResponse?.status(), 200);
assertEquals(pageErrors, []);
assertEquals(consoleErrors, []);
assertEquals(statuses, []);
assertEquals(requestErrors, []);
const images = [];
for (
const name of [
`account-${theme}-${width}.png`,
`pending-${theme}-${width}.png`,
`migration-${theme}-${width}.png`,
]
) {
const digest = await crypto.subtle.digest(
"SHA-256",
await Deno.readFile(`${out}/${name}`),
);
images.push({
name,
sha256: Array.from(
new Uint8Array(digest),
(byte) => byte.toString(16).padStart(2, "0"),
).join(""),
});
}
records.push({
route: "/account",
actor: "synthetic Alice",
theme,
width,
documentStatus: documentResponse?.status(),
pageErrors,
consoleErrors,
requestErrors,
statuses,
pendingAccessibility,
visible: await page.locator("main").innerText(),
images,
});
} finally {
await context.close();
}
},
);
if (!passed) {
throw new Error(
"Stop on the first failed visibility condition; investigate before repeating the matrix",
);
}
}
}
} finally {
await browser?.close();
await stopOwnedProcesses(processes);
await Deno.writeTextFile(
`${out}/review-context.json`,
JSON.stringify(records, null, 2),
);
}
});
@@ -239,6 +239,10 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).waitFor();
// Accountの初期metadata確認はwhoamiを前後で読む。完了を可視結果で
// 待ち、短命cookieの観測へ初期requestを混入させない。
await page.getByRole("list", { name: "Registered keys", 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) =>
@@ -361,6 +365,10 @@ export async function sessionNavigation(
await go("/account?tab=keys#stale", "account");
await page.getByRole("button", { name: "Log out", exact: true })
.waitFor();
// 保留するのは明示refresh。初期metadata requestは離脱時にabortされる
// 別のlifetimeなので、先に完了させる。
await page.getByRole("list", { name: "Registered keys", exact: true })
.waitFor();
let started!: () => void;
let failed!: (error: unknown) => void;
let release!: () => void;
@@ -433,7 +441,7 @@ export async function sessionNavigation(
},
);
await step(
"challenge 401 leaves an authenticated account in place and permits retry",
"challenge 401 leaves an authenticated account in place but requires result recovery before another addition",
async ({ page, go }) => {
const target = "/account?tab=keys#challenge";
await go(target, "addition-expired");
@@ -453,7 +461,21 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
true,
false,
);
await page.getByRole("button", {
name: "Check addition result",
exact: true,
}).click();
await page.getByRole("status").filter({
hasText: "addition result is still unknown",
}).waitFor();
assertEquals(
await page.getByRole("button", {
name: "Add passkey to alice",
exact: true,
}).isEnabled(),
false,
);
},
);
@@ -466,7 +488,11 @@ export async function sessionNavigation(
await at(page, "/");
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
.json();
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
assertEquals(wire, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.evaluate(() => {
localStorage.clear();
sessionStorage.clear();
@@ -487,6 +513,9 @@ export async function sessionNavigation(
name: "Add passkey to alice",
exact: true,
}).waitFor();
// 初期metadata確認のwhoamiをlifecycle event由来と誤計数しない。
await page.getByRole("list", { name: "Registered keys", exact: true })
.waitFor();
await control(page, "expire");
let checks = 0;
page.on("request", (request) => {
@@ -19,7 +19,7 @@ const mime: Record<string, string> = {
};
const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
// 送らないので、Bearer有無と直前の合成page条件で応答を選ぶ。header値は読まない。
let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url);
@@ -28,19 +28,27 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
setupMode = selected;
}
const tokenRequest = request.headers.has("authorization");
const mode = selected ??
(tokenRequest ? setupMode : null) ??
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);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
const setup = setupProjectionResponse(
url.pathname,
mode,
selected !== null,
tokenRequest,
);
if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") {
return Response.json({
authentication_reference: anonymous ? null : "fixture-authentication",
session: !anonymous
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -53,6 +61,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
},
});
}
if (url.pathname === "/api/auth/passkeys") {
return Response.json({
user: {
user_id: "fixture-user",
account_id: "fixture-account",
handle: "alice",
display_name: "Alice",
},
keys: [],
});
}
if (url.pathname === "/api/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous });
@@ -19,6 +19,7 @@ export function setupProjectionResponse(
path: string,
mode: string,
reset: boolean,
tokenRequest = false,
): Response | null {
if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode);
@@ -28,7 +29,17 @@ export function setupProjectionResponse(
});
}
if (path === "/api/auth/whoami") {
if (tokenRequest) {
return Response.json({
actor: { ...user, auth_method: "api_token" },
authentication_reference: "fixture-token-binding",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return Response.json({
authentication_reference: stages.get(mode) === "login"
? "fixture-authentication"
: null,
session: stages.get(mode) === "login"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" },
@@ -56,7 +67,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
if (path === "/api/auth/passkeys/login/options") {
return Response.json({
@@ -66,7 +80,10 @@ export function setupProjectionResponse(
}
if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login");
return Response.json({ user });
return Response.json({
user,
authentication_reference: "fixture-authentication",
});
}
return null;
}
+7 -1
View File
@@ -26,6 +26,7 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
display_name: "Alice",
auth_method: "browser_session",
},
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
@@ -60,6 +61,7 @@ Deno.test("account loads its sidebar catalog without making catalog failure an a
display_name: "Alice",
auth_method: "browser_session",
},
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
browserSession.invalidate();
@@ -106,7 +108,11 @@ Deno.test("root admission never starts private catalog while anonymous, expired,
if (condition === "loading") await pending;
return condition === "error" || condition === "loading"
? new Response(null, { status: 503 })
: Response.json({ actor: null, session: { kind: condition } });
: Response.json({
actor: null,
authentication_reference: null,
session: { kind: condition },
});
};
let privateCalls = 0;
const event = {
+56 -12
View File
@@ -168,35 +168,62 @@ for (const purpose of ["registration", "addition"] as const) {
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? registrationOptions : { user: authUser },
String(input) === "/api/auth/whoami"
? {
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: {
kind: "authenticated",
expires_at: "2099-01-01T00:00:00Z",
},
}
: String(input).endsWith("/options")
? registrationOptions
: {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (calls) => {
const user = purpose === "registration"
? await registerPasskey("alice", "Alice")
: await addPasskey();
assertEquals(user, authUser);
assertEquals(user, {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 1, get: 0 });
});
assertEquals(requests.map(({ path }) => path), [
...(purpose === "addition" ? ["/api/auth/whoami"] : []),
`/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`,
]);
for (const { init } of requests) {
assertEquals(init.method, "POST");
for (const { path, init } of requests) {
assertEquals(
init.method ?? "GET",
path.endsWith("/whoami") ? "GET" : "POST",
);
assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" });
}
const ceremony = requests.filter(({ path }) => !path.endsWith("/whoami"));
assertEquals(
JSON.parse(String(requests[0].init.body)),
JSON.parse(String(ceremony[0].init.body)),
purpose === "registration"
? {
handle: "alice",
display_name: "Alice",
browser_origin: "https://yoi.example",
}
: { browser_origin: "https://yoi.example" },
: {
expected_user_id: authUser.user_id,
expected_account_id: authUser.account_id,
expected_authentication_reference: "fixture-authentication",
browser_origin: "https://yoi.example",
},
);
const complete = JSON.parse(String(requests[1].init.body));
const complete = JSON.parse(String(ceremony[1].init.body));
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID");
@@ -208,13 +235,21 @@ Deno.test("addition rejects expired-session completion without falling back to r
const paths: string[] = [];
await withBrowserBoundary(async (input) => {
paths.push(String(input));
return paths.length === 1
if (String(input).endsWith("/whoami")) {
return Response.json({
actor: { ...authUser, auth_method: "browser_session" },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
});
}
return String(input).endsWith("/options")
? Response.json(registrationOptions)
: new Response(null, { status: 401 });
}, async () => {
await assertRejects(addPasskey(), "Auth request failed (401).");
await assertRejects(addPasskey(), "Passkey complete: rejected (rejected).");
});
assertEquals(paths, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete",
]);
@@ -226,7 +261,10 @@ Deno.test("existing-handle registration rejection never starts WebAuthn or retri
paths.push(String(input));
return new Response(null, { status: 409 });
}, async (calls) => {
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
await assertRejects(
registerPasskey("alice"),
"Passkey options: rejected (not-sent).",
);
assertEquals(calls, { create: 0, get: 0 });
});
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
@@ -251,10 +289,16 @@ Deno.test("login sends the explicit trimmed handle and serializes the assertion"
await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! });
return Response.json(
requests.length === 1 ? loginOptions : { user: authUser },
requests.length === 1 ? loginOptions : {
user: authUser,
authentication_reference: "fixture-authentication",
},
);
}, async (calls) => {
assertEquals(await loginWithLegacyPasskey(" alice "), authUser);
assertEquals(await loginWithLegacyPasskey(" alice "), {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 0, get: 1 });
});
assertEquals(requests.map(({ path }) => path), [