fix(auth): confirm passkey operations and preserve safe legacy migration

Fence displayed identity and native prompt generations, classify secret-free outcomes, recover atomic addition receipts without restoring old credentials, and expose truthful own-key verification metadata. Preserve required verifier and enrollment authority boundaries. Include real-store, production browser, native CTAP2 and visible responsive regressions.

Validation: root check/fmt; API 115; passkey security 25; Web unit 712/component 605; auth/entry/activation/hosting browsers; product auth E2E 8 and two explicit browser suites. Server suite: 1262 lib+12 CLI, 3 ignored and 2 environment-specific exclusions. State-name checker retains 15 byte-identical baseline findings; no new exceptions. Physical keys/mobile and hosted CI remain unverified.
This commit is contained in:
2026-10-26 07:18:23 +09:00
parent b284934e34
commit f17b5212c7
66 changed files with 4716 additions and 532 deletions
+112 -5
View File
@@ -225,6 +225,10 @@ impl_openapi_schema!(
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest, PasskeyAdditionOptionsRequest,
PasskeySummary,
PasskeyListResponse,
PasskeyAdditionResultRequest,
PasskeyAdditionResultResponse,
PasskeyRegistrationOptionsRequest, PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -648,6 +652,33 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest, #[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>; ) -> 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( #[post(
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
status = 200, status = 200,
@@ -4115,6 +4146,8 @@ impl RequestActor {
pub struct WhoamiResponse { pub struct WhoamiResponse {
pub actor: Option<RequestActor>, pub actor: Option<RequestActor>,
pub session: BrowserSessionState, 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. /// Browser navigation evidence, not an authorization grant or a generic 401 classification.
@@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest {
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct AuthUserResponse { pub struct AuthUserResponse {
pub user: AuthenticatedUser, 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. /// 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>, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest { 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))] #[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>, 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)] #[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String {
AuthBootstrapUserRequest::decl(&config), AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config), AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config), PasskeyAdditionOptionsRequest::decl(&config),
PasskeySummary::decl(&config),
PasskeyListResponse::decl(&config),
PasskeyAdditionResultRequest::decl(&config),
PasskeyAdditionResultResponse::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config), PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config), PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config), PasskeyRegistrationCompleteRequest::decl(&config),
@@ -14069,7 +14156,8 @@ mod tests {
"display_name": "Hare", "display_name": "Hare",
"auth_method": "browser_session" "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()) let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
.expect("server whoami fixture should match shared DTO"); .expect("server whoami fixture should match shared DTO");
@@ -14091,7 +14179,8 @@ mod tests {
"account_id": "account-1", "account_id": "account-1",
"handle": "hare", "handle": "hare",
"display_name": "Hare" "display_name": "Hare"
} },
"authentication_reference": "synthetic-session-reference"
}); });
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone()) let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
.expect("server auth-user fixture should match shared DTO"); .expect("server auth-user fixture should match shared DTO");
@@ -14264,7 +14353,7 @@ mod tests {
serde_json::json!({"kind":"expired"}), serde_json::json!({"kind":"expired"}),
serde_json::json!({"kind":"authenticated", "expires_at":null}), 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(); let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
assert_eq!(serde_json::to_value(response).unwrap(), value); assert_eq!(serde_json::to_value(response).unwrap(), value);
} }
@@ -14802,10 +14891,28 @@ mod openapi_artifact_tests {
); );
} }
assert!( 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] #[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() { fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); 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()), (99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()), (100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".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()) { 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}; use server_api::{BrowserSessionState, WhoamiResponse};
let anonymous = || WhoamiResponse { let anonymous = || WhoamiResponse {
actor: None, actor: None,
authentication_reference: None,
session: BrowserSessionState::Anonymous {}, session: BrowserSessionState::Anonymous {},
}; };
// Preserve the existing API-token precedence and fallback rules, including expired tokens. // Preserve the existing API-token precedence and fallback rules, including expired tokens.
if let Some((actor, expires_at)) = 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 { return Ok(WhoamiResponse {
authentication_reference,
actor: Some(actor), actor: Some(actor),
session: BrowserSessionState::Authenticated { expires_at }, session: BrowserSessionState::Authenticated { expires_at },
}); });
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
if within_session_expiry_grace(&session.expires_at, Utc::now()) { if within_session_expiry_grace(&session.expires_at, Utc::now()) {
return Ok(WhoamiResponse { return Ok(WhoamiResponse {
actor: None, actor: None,
authentication_reference: None,
session: BrowserSessionState::Expired {}, session: BrowserSessionState::Expired {},
}); });
} }
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
Ok(anonymous()) 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 { fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| { DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS) 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_results;
DROP TABLE ticket_implementation_delegations; DROP TABLE ticket_implementation_delegations;
DROP TABLE merge_request_source_observations; 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; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; 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. // 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; 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 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; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; DROP TABLE subject_worker_releases;
@@ -679,6 +687,10 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
if historical { 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; 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 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; DROP TABLE subject_create_recoveries;
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102; UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
DROP TABLE subject_worker_releases; DROP TABLE subject_worker_releases;
@@ -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( .merge(server_api::server_api_axum::hosting_settings_update(
service.clone(), 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( .merge(server_api::server_api_axum::auth_passkey_addition_options(
service.clone(), 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( async fn auth_passkey_addition_options(
&self, &self,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
.db_executor() .db_executor()
.run(move || { .run(move || {
let (user, authentication) = passkey_addition_authority(&api, &headers)?; 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( start_passkey_registration(
&api, &api,
&headers, &headers,
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
fn passkey_addition_authority( fn passkey_addition_authority(
api: &ServerAuthApi, api: &ServerAuthApi,
headers: &HeaderMap, 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)> { ) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
let auth = auth_public_config(&api.config); let auth = auth_public_config(&api.config);
let actor = crate::auth::resolve_request_actor_sync( let actor = crate::auth::resolve_request_actor_sync(
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
})?; })?;
let binding = match actor.auth_method { let binding = match actor.auth_method {
ActorAuthMethod::BrowserSession => { 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( return Err(Error::WorkspacePermissionDenied(
"cookie-authenticated passkey addition requires the configured Browser origin" "cookie-authenticated passkey addition requires the configured Browser origin"
.into(), .into(),
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
Ok((user, binding)) 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( fn start_passkey_registration(
api: &ServerAuthApi, api: &ServerAuthApi,
headers: &HeaderMap, headers: &HeaderMap,
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
/// Credential commit precedes session issuance. If issuing the session or /// Credential commit precedes session issuance. If issuing the session or
/// delivering the response fails, the credential can already exist: log in with /// delivering the response fails, the credential can already exist: log in with
/// that key rather than overwriting it or replaying the consumed completion. /// 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 /// enrollment with fresh options only for an unused handle with no persisted
/// identity; legacy pre-created identities follow the migration policy on /// identity; legacy pre-created identities follow the migration policy on
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits. /// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
request: PasskeyRegistrationCompleteRequest, request: PasskeyRegistrationCompleteRequest,
completion: EnrollmentCompletion<'_>, completion: EnrollmentCompletion<'_>,
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> { ) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
let authority = match completion { let authority = match completion {
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?), EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
_ => None, _ => None,
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
&error.to_string(), &error.to_string(),
) )
})?; })?;
api.store.register_verified_passkey( api.store.register_verified_passkey_with_result(
&envelope.target, &envelope.target,
&PasskeyCredentialRecord { &PasskeyCredentialRecord {
credential_id: passkey_credential_id(&passkey)?, credential_id: passkey_credential_id(&passkey)?,
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
created_at: crate::auth::now_rfc3339(), created_at: crate::auth::now_rfc3339(),
last_used_at: None, last_used_at: None,
}, },
is_addition.then_some(request.challenge_id.as_str()),
)?; )?;
issue_browser_session(api, user) issue_browser_session(api, user)
} }
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
.into()); .into());
} }
let webauthn = webauthn_for_challenge(&api.config, &challenge)?; let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
let auth_result = match state { let auth_result = match state {
PasskeyLoginState::Discoverable(state) => { PasskeyLoginState::Discoverable(state) => {
// userHandle is unsigned routing data, never identity authority. The // 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()))?; .map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
api.store api.store
.upsert_passkey_credential(&PasskeyCredentialRecord { .upsert_passkey_credential(&PasskeyCredentialRecord {
credential_id, credential_id: credential_id.clone(),
user_id: user.user_id.clone(), user_id: user.user_id.clone(),
public_key_cose, public_key_cose,
transports_json: stored.transports_json, transports_json: stored.transports_json,
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
created_at: stored.created_at, created_at: stored.created_at,
last_used_at: Some(crate::auth::now_rfc3339()), 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) issue_browser_session(api, user)
} }
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
Ok(( Ok((
AuthUserResponse { AuthUserResponse {
user: user_response(user), user: user_response(user),
authentication_reference: crate::auth::authentication_reference(&token_hash(
&session_token,
)),
}, },
server_api::SetCookieHeader::new(cookie), server_api::SetCookieHeader::new(cookie),
)) ))
@@ -52401,6 +52515,7 @@ mod tests {
include!("server_activation_tests.rs"); include!("server_activation_tests.rs");
include!("server_discoverable_tests.rs"); include!("server_discoverable_tests.rs");
include!("server_passkey_results_tests.rs");
const ORIGIN_URL: &str = "https://workspace.test"; const ORIGIN_URL: &str = "https://workspace.test";
type Authenticator = ResidentAuthenticator; type Authenticator = ResidentAuthenticator;
@@ -52454,9 +52569,61 @@ mod tests {
origin: Option<&str>, origin: Option<&str>,
body: Value, body: Value,
) -> (StatusCode, HeaderMap, 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 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( async fn post_to(
app: Router, app: Router,
path: &str, path: &str,
@@ -52612,6 +52779,14 @@ mod tests {
.next() .next()
.unwrap() .unwrap()
.to_string(); .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, cookie,
serde_json::from_value::<AuthUserResponse>(body) serde_json::from_value::<AuthUserResponse>(body)
@@ -52699,6 +52874,14 @@ mod tests {
.next() .next()
.unwrap() .unwrap()
.to_string(); .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, cookie,
serde_json::from_value::<AuthUserResponse>(body) serde_json::from_value::<AuthUserResponse>(body)
@@ -53222,6 +53405,9 @@ mod tests {
.auth_passkey_addition_options( .auth_passkey_addition_options(
context, context,
server_api::PasskeyAdditionOptionsRequest { 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()), browser_origin: Some(ORIGIN_URL.into()),
}, },
) )
@@ -53236,6 +53422,9 @@ mod tests {
.auth_passkey_addition_options( .auth_passkey_addition_options(
context, context,
server_api::PasskeyAdditionOptionsRequest { 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()), browser_origin: Some(ORIGIN_URL.into()),
}, },
) )
@@ -54241,7 +54430,7 @@ mod tests {
.header(axum::http::header::COOKIE, &marker) .header(axum::http::header::COOKIE, &marker)
.header(ORIGIN, &auth.origin) .header(ORIGIN, &auth.origin)
.header(CONTENT_TYPE, "application/json") .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(), .unwrap(),
) )
.await .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 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_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Durable failed Subject creation recovery", name: "Durable failed Subject creation recovery",
apply: migrate_subject_create_recovery_v101_to_v102, 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)] #[derive(Clone, Copy)]
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
ApiToken(String), 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct PasskeyCredentialRecord { pub struct PasskeyCredentialRecord {
pub credential_id: String, pub credential_id: String,
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
&self, &self,
target: &PasskeyRegistrationTarget, target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord, 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<()>; ) -> Result<()>;
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>; fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
fn get_passkey_credential( fn get_passkey_credential(
@@ -6972,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
}) })
} }
fn register_verified_passkey( fn register_verified_passkey_with_result(
&self, &self,
target: &PasskeyRegistrationTarget, target: &PasskeyRegistrationTarget,
credential: &PasskeyCredentialRecord, credential: &PasskeyCredentialRecord,
addition_challenge_id: Option<&str>,
) -> Result<()> { ) -> Result<()> {
self.with_immediate_transaction(|tx| { self.with_immediate_transaction(|tx| {
let user = match target { 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)", "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], 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 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 { 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")); 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<()> { fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
self.with_immediate_transaction(|conn| { self.with_immediate_transaction(|conn| {
activation::require_active(conn, &record.user_id)?; activation::require_active(conn, &record.user_id)?;
@@ -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( fn read_passkey_credential_record(
row: &rusqlite::Row<'_>, row: &rusqlite::Row<'_>,
) -> rusqlite::Result<PasskeyCredentialRecord> { ) -> rusqlite::Result<PasskeyCredentialRecord> {
@@ -15164,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
Ok(()) 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<()> { fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 101 { if current_schema_version(conn)? != 101 {
return Err(Error::Store( return Err(Error::Store(
@@ -15255,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?; 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!("activation_schema.sql"))?;
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
Ok(()) 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:100, name:"Provisional users and scoped passkey activation".into()},
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()}, WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".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()), (100, "Provisional users and scoped passkey activation".to_string()),
(101, "Durable explicit Subject body release".to_string()), (101, "Durable explicit Subject body release".to_string()),
(102, "Durable failed Subject creation recovery".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")?); 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] #[test]
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() { fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -21327,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
drop(conn); drop(conn);
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap(); let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
reopened reopened
.with_conn(|c| verify_schema_history(c, 102)) .with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
.unwrap(); .unwrap();
} }
@@ -22266,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(99, "Explicit hosting administrators and self-registration policy".into()), (99, "Explicit hosting administrators and self-registration policy".into()),
(100, "Provisional users and scoped passkey activation".into()), (100, "Provisional users and scoped passkey activation".into()),
(101, "Durable explicit Subject body release".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()) { for (table, rows) in unchanged.iter().zip(before.iter()) {
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
} }
+261 -3
View File
@@ -298,12 +298,17 @@
"AuthUserResponse": { "AuthUserResponse": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"authentication_reference": {
"description": "Comparison evidence for the exact newly issued session, not a credential.",
"type": "string"
},
"user": { "user": {
"$ref": "#/components/schemas/AuthenticatedUser" "$ref": "#/components/schemas/AuthenticatedUser"
} }
}, },
"required": [ "required": [
"user" "user",
"authentication_reference"
], ],
"type": "object" "type": "object"
}, },
@@ -6709,15 +6714,112 @@
}, },
"PasskeyAdditionOptionsRequest": { "PasskeyAdditionOptionsRequest": {
"additionalProperties": false, "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": { "properties": {
"browser_origin": { "browser_origin": {
"type": [ "type": [
"string", "string",
"null" "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" "type": "object"
}, },
"PasskeyLoginCompleteRequest": { "PasskeyLoginCompleteRequest": {
@@ -6820,6 +6922,35 @@
], ],
"type": "object" "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": { "PasteArtifactAvailability": {
"enum": [ "enum": [
"available", "available",
@@ -15143,6 +15274,13 @@
} }
] ]
}, },
"authentication_reference": {
"description": "Non-secret comparison evidence, never an authentication credential.",
"type": [
"string",
"null"
]
},
"session": { "session": {
"$ref": "#/components/schemas/BrowserSessionState" "$ref": "#/components/schemas/BrowserSessionState"
} }
@@ -18155,7 +18293,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7" "x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "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": { "/api/auth/passkeys/addition/complete": {
"post": { "post": {
"operationId": "auth_passkey_addition_complete", "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": { "/api/auth/passkeys/login/complete": {
"post": { "post": {
"operationId": "auth_passkey_login_complete", "operationId": "auth_passkey_login_complete",
+1
View File
@@ -9,6 +9,7 @@
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。 - [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。 - [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。 - [`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の段階起動 ## 固定Workerの段階起動
+28 -57
View File
@@ -4,7 +4,7 @@
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。 // 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。 // navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
import { assert, assertEquals } from "@std/assert"; 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"; import { type Browser, type BrowserContext, chromium } from "playwright";
const [backend, origin, build, handle] = Deno.args; const [backend, origin, build, handle] = Deno.args;
@@ -13,24 +13,15 @@ if (!backend || !origin || !build || !handle) {
"Expected isolated Backend URL, browser origin, production build, handle", "Expected isolated Backend URL, browser origin, production build, handle",
); );
} }
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。 const server = await serveProductionShell(
const mime: Record<string, string> = { backend,
".html": "text/html; charset=utf-8", origin,
".js": "text/javascript", build,
".css": "text/css", async (request) => {
".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); const url = new URL(request.url);
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") { if (
url.pathname === "/__e2e/expired-session" && request.method === "POST"
) {
if (request.headers.get("origin") !== origin) { if (request.headers.get("origin") !== origin) {
return new Response(null, { status: 403 }); return new Response(null, { status: 403 });
} }
@@ -45,50 +36,20 @@ const server = Deno.serve({
return new Response(null, { status: 204, headers }); return new Response(null, { status: 204, headers });
} }
if ( if (
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET" url.pathname === "/__e2e/primary-cookie-present" &&
request.method === "GET"
) { ) {
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。 // HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
const present = request.headers.get("cookie")?.split(";").some((pair) => const present = request.headers.get("cookie")?.split(";").some((pair) =>
pair.trim().split("=", 1)[0] === "yoi_workspace_session" pair.trim().split("=", 1)[0] === "yoi_workspace_session"
) ?? false; ) ?? false;
return Response.json(present, { headers: { "cache-control": "no-store" } }); 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"); return undefined;
if (relative.startsWith("..")) {
return new Response("not found", { status: 404 });
}
let file = join(build, relative);
try {
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
return new Response(await Deno.readFile(file), {
headers: {
"content-type": mime[extname(file)] ?? "application/octet-stream",
}, },
}); );
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) throw error;
if (url.pathname.startsWith("/_app/")) {
return new Response("not found", { status: 404 });
}
return new Response(await Deno.readFile(join(build, "index.html")), {
headers: { "content-type": mime[".html"] },
});
}
});
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。 // 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream()) const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
@@ -313,7 +274,8 @@ try {
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、 // 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。 // 自然消失後の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 = () => const catalogResponse = () =>
page.waitForResponse((response) => page.waitForResponse((response) =>
new URL(response.url()).pathname === "/api/workspaces" && new URL(response.url()).pathname === "/api/workspaces" &&
@@ -360,6 +322,7 @@ try {
assert(expiredCookieNames.includes(marker.name)); assert(expiredCookieNames.includes(marker.name));
assertEquals(await whoami(), { assertEquals(await whoami(), {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "expired" }, session: { kind: "expired" },
}); });
privateRequests.length = 0; privateRequests.length = 0;
@@ -383,7 +346,11 @@ try {
[], [],
"expired reload must gate the real private catalog loader", "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 page.reload();
await at(expiredLogin); await at(expiredLogin);
await page.getByRole("button", { name: "Log in with passkey", exact: true }) await page.getByRole("button", { name: "Log in with passkey", exact: true })
@@ -416,7 +383,11 @@ try {
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。 // authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
await context.clearCookies({ name: session.name }); await context.clearCookies({ name: session.name });
const partial = await whoami(); 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 page.reload();
await at("/"); await at("/");
await context.clearCookies(); 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) 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 { fn isolated_command(&self, binary: &Path) -> Command {
let mut command = Command::new(binary); let mut command = Command::new(binary);
command command
+64 -12
View File
@@ -4,8 +4,9 @@
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 //! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが //! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。 //! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。 //! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
use std::error::Error; use std::error::Error;
@@ -21,8 +22,8 @@ use serde::Deserialize;
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde_json::{Value, json}; use serde_json::{Value, json};
use server_api::{ use server_api::{
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest, ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest, PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
PasskeyRegistrationOptionsResponse, WhoamiResponse, PasskeyRegistrationOptionsResponse, WhoamiResponse,
}; };
#[path = "../src/resident_authenticator.rs"] #[path = "../src/resident_authenticator.rs"]
@@ -30,8 +31,12 @@ mod resident_model;
use resident_model::ResidentAuthenticator; use resident_model::ResidentAuthenticator;
use yoi_e2e::WorkerE2eEnvironment; use yoi_e2e::WorkerE2eEnvironment;
#[path = "auth_passkeys/browser_process.rs"]
mod browser_process;
#[path = "auth_passkeys/browser_sessions.rs"] #[path = "auth_passkeys/browser_sessions.rs"]
mod browser_sessions; mod browser_sessions;
#[path = "auth_passkeys/native_migration.rs"]
mod native_migration;
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>; type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
const BROWSER_ORIGIN: &str = "http://localhost:5173"; const BROWSER_ORIGIN: &str = "http://localhost:5173";
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
Ok(()) 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( fn host_admin_login(
environment: &WorkerE2eEnvironment, environment: &WorkerE2eEnvironment,
client: &Client, client: &Client,
@@ -291,11 +319,17 @@ fn host_admin_login(
let bearer = setup["access_token"] let bearer = setup["access_token"]
.as_str() .as_str()
.ok_or("host setup omitted initial bearer")?; .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( let options: PasskeyRegistrationOptionsResponse = decode(send(
client client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.bearer_auth(bearer) .bearer_auth(bearer)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"host-created administrator bearer starts actor-derived passkey addition", "host-created administrator bearer starts actor-derived passkey addition",
artifacts, artifacts,
)?)?; )?)?;
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
policy(&client, base, artifacts, false)?; policy(&client, base, artifacts, false)?;
let mut admin_key = ResidentAuthenticator::new(true); let mut admin_key = ResidentAuthenticator::new(true);
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; 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)?; policy(&client, base, artifacts, false)?;
set_policy(&client, base, artifacts, &admin_cookie, true)?; set_policy(&client, base, artifacts, &admin_cookie, true)?;
let whoami: Value = decode(send( let whoami: Value = decode(send(
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
client client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&admin_addition),
"unauthenticated addition options", "unauthenticated addition options",
artifacts, 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()); 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 [ for (field, value) in [
("handle", &other.user.handle), ("handle", &other.user.handle),
("user_id", &other.user.user_id), ("user_id", &other.user.user_id),
("account_id", &other.user.account_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); body[field] = json!(value);
rejected( rejected(
send( send(
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
let mut request = client let mut request = client
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.json(&json!({"browser_origin": BROWSER_ORIGIN})); .json(&addition_body);
if let Some(origin) = origin { if let Some(origin) = origin {
request = request.header(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")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"authenticated self addition options", "authenticated self addition options",
artifacts, artifacts,
)?)?; )?)?;
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
.post(format!("{base}/api/auth/passkeys/addition/options")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, revoked_cookie) .header(COOKIE, revoked_cookie)
.header(ORIGIN, BROWSER_ORIGIN) .header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"browser_origin": BROWSER_ORIGIN})), .json(&addition_body),
"logout prevents further self addition with the old cookie", "logout prevents further self addition with the old cookie",
artifacts, 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) client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
.json(&json!({"user_code": flow["user_code"]})), .json(&json!({"user_code": flow["user_code"]})),
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN) 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 { for mut request in requests {
if let Some(bearer) = bearer { 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")) .post(format!("{base}/api/auth/passkeys/addition/options"))
.header(COOKIE, &cookie) .header(COOKIE, &cookie)
.header(ORIGIN, BROWSER_ORIGIN) .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", "start distinct authenticated self-addition ceremony",
artifacts, 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: //! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored` //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。 //! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
use super::browser_process::BrowserProcess;
use super::*; use super::*;
use std::io::{BufRead, BufReader, Read}; use std::io::{BufRead, BufReader, Read};
use std::net::TcpListener; use std::net::TcpListener;
use std::process::{Child, Command, Stdio}; use std::process::Stdio;
use std::sync::mpsc; use std::sync::mpsc;
use std::time::Instant; use std::time::Instant;
use yoi_workspace_server::auth::{ 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] #[test]
#[ignore = "requires production Web assets, Deno and Playwright Chromium"] #[ignore = "requires production Web assets, Deno and Playwright Chromium"]
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result 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); drop(reservation);
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?; let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
let mut process = BrowserProcess(Some( let mut process = BrowserProcess(Some(
Command::new("deno") environment
.browser_command(Path::new("deno"))?
.current_dir(&root) .current_dir(&root)
.args([ .args([
"run", "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", "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", "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", "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: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"
}, },
"imports": { "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 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 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, }; 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. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256: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 // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256: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 // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7) // Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -108,7 +108,7 @@
<details class="legacy-login" bind:open={legacyOpen}> <details class="legacy-login" bind:open={legacyOpen}>
<summary>Use an older key with account ID</summary> <summary>Use an older key with account ID</summary>
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}> <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) <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" /> <input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
</label> </label>
@@ -131,6 +131,7 @@
{/if} {/if}
{/if} {/if}
{#if auth.status}<p role="status">{auth.status}</p>{/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} {#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
<nav aria-label="Authentication"> <nav aria-label="Authentication">
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor} {#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
@@ -56,7 +56,7 @@
{#if !protectedPage} {#if !protectedPage}
{@render children()} {@render children()}
{:else} {:else}
{#if session.kind === 'loading'} {#if session.kind === 'loading' && !admitted}
<div class="auth-page"><p role="status">Loading session…</p></div> <div class="auth-page"><p role="status">Loading session…</p></div>
{:else if session.kind === 'error'} {:else if session.kind === 'error'}
<div class="auth-page"> <div class="auth-page">
@@ -66,7 +66,7 @@
</div> </div>
{/if} {/if}
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted} {#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 --> <!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div> <div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
{/if} {/if}
{/if} {/if}
@@ -1,10 +1,18 @@
declare const Deno: { test(name: string, fn: () => Promise<void>): void }; declare const Deno: { test(name: string, fn: () => Promise<void>): void };
import { browserSession } from "./session-state.ts";
import { activateWithPasskey } from "./activation.ts"; import { activateWithPasskey } from "./activation.ts";
import { import {
authUser, authUser,
credentialFixture, credentialFixture,
registrationOptions, registrationOptions,
} from "./api.test-fixtures.ts"; } 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) { function equal(a: unknown, b: unknown) {
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ"); 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) => { await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( return Response.json(
String(input).endsWith("/options") String(input) === "/api/auth/whoami"
? confirmedSession()
: String(input).endsWith("/options")
? registrationOptions ? registrationOptions
: { user: authUser }, : {
user: authUser,
authentication_reference: "fixture-authentication",
},
); );
}, async (options) => { }, async (options) => {
equal(options.signal === signal, true); 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), [ equal(requests.map((r) => r.path), [
"/api/auth/activation/options", "/api/auth/activation/options",
"/api/auth/activation/complete", "/api/auth/activation/complete",
"/api/auth/whoami",
]); ]);
for (const { init } of requests) { for (const { path, init } of requests) {
equal(init.method, "POST"); equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
equal(init.signal === signal, true);
equal(init.credentials, "same-origin"); equal(init.credentials, "same-origin");
equal(init.redirect, "error"); equal(init.redirect, "error");
equal(init.mode, "same-origin"); equal(init.mode, "same-origin");
equal(init.cache, "no-store"); equal(init.cache, "no-store");
if (path !== "/api/auth/whoami") {
equal(init.referrerPolicy, "no-referrer"); equal(init.referrerPolicy, "no-referrer");
}
equal(new Headers(init.headers).has("authorization"), false); equal(new Headers(init.headers).has("authorization"), false);
} }
equal(requests[2].init.body, undefined);
equal(JSON.parse(String(requests[0].init.body)), { equal(JSON.parse(String(requests[0].init.body)), {
handle: "alice", handle: "alice",
code: "synthetic-input-only", 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({ unexpected: "synthetic-input-only" });
} }
return Response.json( return Response.json(
String(input).endsWith("/options") String(input).endsWith("/options") ? registrationOptions : {
? registrationOptions user: { ...authUser, handle: "other" },
: { user: { ...authUser, handle: "other" } }, authentication_reference: "fixture-authentication",
},
); );
}, },
async () => credentialFixture(), async () => credentialFixture(),
@@ -173,3 +192,130 @@ 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"] : []),
]);
});
},
);
}
@@ -2,7 +2,8 @@ import type {
ActivationOptionsRequest, ActivationOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
} from "#lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts"; import { readBoundedAuthResponseJson, loadWhoami } from "./api.ts";
import { browserSession } from "./session-state.ts";
import { normalizeActivationHandle } from "./hosting-accounts.ts"; import { normalizeActivationHandle } from "./hosting-accounts.ts";
import { import {
type AuthUser, type AuthUser,
@@ -19,6 +20,7 @@ export async function activateWithPasskey(
code: string, code: string,
signal: AbortSignal, signal: AbortSignal,
): Promise<AuthUser> { ): Promise<AuthUser> {
const generation = browserSession.authenticationGeneration;
try { try {
handle = normalizeActivationHandle(handle); handle = normalizeActivationHandle(handle);
if (window.isSecureContext !== true || !code || code.length > 4096) { if (window.isSecureContext !== true || !code || code.length > 4096) {
@@ -61,15 +63,19 @@ export async function activateWithPasskey(
}); });
if (!credential) throw new Error("Registration cancelled"); if (!credential) throw new Error("Registration cancelled");
signal.throwIfAborted(); signal.throwIfAborted();
if (generation !== browserSession.authenticationGeneration) throw new Error("Activation superseded");
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await request("/api/auth/activation/complete", { await request("/api/auth/activation/complete", {
challenge_id: challengeId, challenge_id: challengeId,
credential: registrationCredentialToJson(credential), credential: registrationCredentialToJson(credential),
}), }),
).user; );
signal.throwIfAborted(); signal.throwIfAborted();
if (result.handle !== handle) throw new Error("Activation target mismatch"); if (result.user.handle !== handle || generation !== browserSession.authenticationGeneration) throw new Error("Activation target mismatch");
return result; const current = await loadWhoami(signal);
signal.throwIfAborted();
if (generation !== browserSession.authenticationGeneration || 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 { } catch {
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。 // HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
throw new Error("Activation could not be confirmed."); throw new Error("Activation could not be confirmed.");
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
export function whoamiPayload(actor: unknown = null) { export function whoamiPayload(actor: unknown = null) {
return { return {
actor, actor,
authentication_reference: actor === null ? null : "fixture-authentication",
session: actor === null session: actor === null
? { kind: "anonymous" } ? { kind: "anonymous" }
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, : { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
+180 -51
View File
@@ -1,6 +1,7 @@
import type { import type {
DeviceLoginApproveRequest, DeviceLoginApproveRequest,
PasskeyAdditionOptionsRequest, PasskeyAdditionOptionsRequest,
PasskeyAdditionResultRequest,
PasskeyLoginCompleteRequest, PasskeyLoginCompleteRequest,
PasskeyLoginOptionsRequest, PasskeyLoginOptionsRequest,
PasskeyRegistrationCompleteRequest, PasskeyRegistrationCompleteRequest,
@@ -8,24 +9,38 @@ import type {
} from "#lib/generated/auth-api.ts"; } from "#lib/generated/auth-api.ts";
import { import {
authenticationCredentialToJson, authenticationCredentialToJson,
type AuthUser, AuthPayloadError,
type DeviceApprovalResponse, type DeviceApprovalResponse,
parseAdditionResult,
parseAuthUserResponse, parseAuthUserResponse,
parseDeviceApprovalResponse, parseDeviceApprovalResponse,
parseLogoutResponse, parseLogoutResponse,
parsePasskeyList,
parseWhoamiResponse, parseWhoamiResponse,
type PasskeyUserResponse,
prepareLoginOptions, prepareLoginOptions,
prepareRegistrationOptions, prepareRegistrationOptions,
registrationCredentialToJson, registrationCredentialToJson,
type WhoamiResponse, type WhoamiResponse,
} from "#lib/workspace/auth/model.ts"; } from "#lib/workspace/auth/model.ts";
import {
authCheckpoint,
AuthOperationError,
type AuthStage,
classifyAuthFailure,
expectedAuthSession,
recordAdditionAttempt,
} from "./operation.ts";
export class AuthRequestError extends Error { export class AuthRequestError extends Error {
constructor(readonly status: number) { constructor(readonly status: number, readonly code: string | null = null) {
super(`Auth request failed (${status}).`); super(`Auth request failed (${status}).`);
} }
} }
class AuthResponseError extends Error {}
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024; const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
export async function readBoundedAuthResponseJson( export async function readBoundedAuthResponseJson(
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
parsed > MAX_AUTH_RESPONSE_BYTES parsed > MAX_AUTH_RESPONSE_BYTES
) { ) {
await response.body?.cancel(); await response.body?.cancel();
throw new Error( throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.", "Invalid auth response: response body exceeds the size limit.",
); );
} }
} }
if (response.body === null) { 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(); const reader = response.body.getReader();
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
totalBytes += value.byteLength; totalBytes += value.byteLength;
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) { if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
await reader.cancel(); await reader.cancel();
throw new Error( throw new AuthResponseError(
"Invalid auth response: response body exceeds the size limit.", "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), new TextDecoder("utf-8", { fatal: true }).decode(bytes),
) as unknown; ) as unknown;
} catch { } 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) { if (!response.ok) {
await response.body?.cancel(); let code: string | null = null;
throw new AuthRequestError(response.status); 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); return await readBoundedAuthResponseJson(response);
} }
@@ -122,7 +160,7 @@ export async function registerPasskey(
handle: string, handle: string,
displayName?: string, displayName?: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
const optionsRequest: PasskeyRegistrationOptionsRequest = { const optionsRequest: PasskeyRegistrationOptionsRequest = {
handle, handle,
display_name: displayName ?? null, display_name: displayName ?? null,
@@ -131,8 +169,21 @@ export async function registerPasskey(
return await createPasskey("registration", optionsRequest, signal); 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 = { 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, browser_origin: window.location.origin,
}; };
return await createPasskey("addition", optionsRequest, signal); return await createPasskey("addition", optionsRequest, signal);
@@ -144,38 +195,50 @@ async function createPasskey(
| PasskeyRegistrationOptionsRequest | PasskeyRegistrationOptionsRequest
| PasskeyAdditionOptionsRequest, | PasskeyAdditionOptionsRequest,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, { const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
method: "POST", method: "POST",
body: JSON.stringify(optionsRequest), body: JSON.stringify(optionsRequest),
signal, signal,
}); });
const optionsRecord = typeof options === "object" && options !== null const publicKey = prepareRegistrationOptions(options);
? options as Record<string, unknown> const challengeId = (options as { challenge_id: string }).challenge_id;
: null; stage = "authenticator";
const challengeId = optionsRecord?.challenge_id; await authCheckpoint(signal, stage);
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") { if (typeof navigator.credentials?.create !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError"); throw new DOMException("", "NotSupportedError");
} }
const credential = await navigator.credentials.create({ const credential = await navigator.credentials.create({
signal, signal,
publicKey: prepareRegistrationOptions(options), publicKey,
}); });
if (!credential) { if (!credential) throw new DOMException("", "NotAllowedError");
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted(); signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyRegistrationCompleteRequest = { const completeRequest: PasskeyRegistrationCompleteRequest = {
challenge_id: challengeId, challenge_id: challengeId,
credential: registrationCredentialToJson(credential), 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( const result = parseAuthUserResponse(
await requestJson(`/api/auth/passkeys/${purpose}/complete`, { await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
method: "POST", method: "POST",
@@ -183,12 +246,37 @@ async function createPasskey(
signal, signal,
}), }),
); );
return result.user; 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( export async function loginWithPasskey(
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
return await authenticateWithPasskey({ return await authenticateWithPasskey({
browser_origin: window.location.origin, browser_origin: window.location.origin,
}, signal); }, signal);
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
export async function loginWithLegacyPasskey( export async function loginWithLegacyPasskey(
handle: string, handle: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
const loginHandle = handle.trim(); const loginHandle = handle.trim();
if (!loginHandle) { if (!loginHandle) {
throw new Error("User handle is required for legacy passkey login."); throw new Error("User handle is required for legacy passkey login.");
@@ -211,38 +299,37 @@ export async function loginWithLegacyPasskey(
async function authenticateWithPasskey( async function authenticateWithPasskey(
optionsRequest: PasskeyLoginOptionsRequest, optionsRequest: PasskeyLoginOptionsRequest,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<AuthUser> { ): Promise<PasskeyUserResponse> {
let stage: AuthStage = "options";
let sent = false;
try {
await authCheckpoint(signal, stage);
const options = await requestJson("/api/auth/passkeys/login/options", { const options = await requestJson("/api/auth/passkeys/login/options", {
method: "POST", method: "POST",
body: JSON.stringify(optionsRequest), body: JSON.stringify(optionsRequest),
signal, signal,
}); });
const optionsRecord = typeof options === "object" && options !== null const publicKey = prepareLoginOptions(options);
? options as Record<string, unknown> const challengeId = (options as { challenge_id: string }).challenge_id;
: null; stage = "authenticator";
const challengeId = optionsRecord?.challenge_id; await authCheckpoint(signal, stage);
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") { if (typeof navigator.credentials?.get !== "function") {
throw new DOMException("Passkeys unavailable", "NotSupportedError"); throw new DOMException("", "NotSupportedError");
} }
const credential = await navigator.credentials.get({ const credential = await navigator.credentials.get({ signal, publicKey });
signal, if (!credential) throw new DOMException("", "NotAllowedError");
publicKey: prepareLoginOptions(options),
});
if (!credential) {
throw new DOMException("Passkey cancelled", "NotAllowedError");
}
signal?.throwIfAborted(); signal?.throwIfAborted();
stage = "conversion";
await authCheckpoint(signal, stage);
const completeRequest: PasskeyLoginCompleteRequest = { const completeRequest: PasskeyLoginCompleteRequest = {
challenge_id: challengeId, challenge_id: challengeId,
credential: authenticationCredentialToJson(credential), credential: authenticationCredentialToJson(credential),
}; };
stage = "before-complete";
await authCheckpoint(signal, stage);
stage = "complete";
await authCheckpoint(signal, stage);
sent = true;
const result = parseAuthUserResponse( const result = parseAuthUserResponse(
await requestJson("/api/auth/passkeys/login/complete", { await requestJson("/api/auth/passkeys/login/complete", {
method: "POST", method: "POST",
@@ -250,13 +337,39 @@ async function authenticateWithPasskey(
signal, signal,
}), }),
); );
return result.user; 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( export async function approveDeviceLogin(
userCode: string, userCode: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<DeviceApprovalResponse> { ): Promise<DeviceApprovalResponse> {
await authCheckpoint(signal, "before-complete");
const request: DeviceLoginApproveRequest = { user_code: userCode }; const request: DeviceLoginApproveRequest = { user_code: userCode };
return parseDeviceApprovalResponse( return parseDeviceApprovalResponse(
await requestJson("/api/auth/device-login/approve", { 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 { import {
browserSession, browserSession,
observeBrowserSession, observeBrowserSession,
sessionMutation, sessionMutation,
} from "./session-state.ts"; } from "./session-state.ts";
import { notifySessionChanged } from "./hosting.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 = export type BrowserSession =
| { kind: "loading" } | { kind: "loading" }
@@ -13,41 +20,60 @@ export type BrowserSession =
| ({ kind: "ready" } & WhoamiResponse); | ({ kind: "ready" } & WhoamiResponse);
export function authErrorMessage(error: unknown): string { export function authErrorMessage(error: unknown): string {
if (error instanceof AuthOperationError) return operationErrorMessage(error);
if (error instanceof AuthRequestError) { if (error instanceof AuthRequestError) {
if (error.status === 401) { return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
} }
if (error.status === 400 || error.status === 410) { // 例外本文/causeや外部応答は表示にも診断にも複写しない。
return "The request or authentication challenge is invalid or expired. Restart the operation."; return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
} }
return error.message; 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;
} }
if (error instanceof DOMException && error.name === "NotSupportedError") { function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
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."; // 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);
} }
if (error instanceof DOMException && error.name === "NotAllowedError") { function returnedUser(value: unknown): AuthUser | null {
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 (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;
} }
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.";
}
export class BrowserAuth { export class BrowserAuth {
session = $state<BrowserSession>({ kind: "loading" }); session = $state<BrowserSession>({ kind: "loading" });
busy = $state(false); busy = $state(false);
error = $state(""); error = $state("");
status = $state(""); status = $state("");
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
additionSaved = $state(false);
private unsubscribe: () => void; private unsubscribe: () => void;
private disposed = false; private disposed = false;
private controller: AbortController | null = null; private controller: AbortController | null = null;
private operationGeneration: number | null = null;
constructor() { constructor() {
observeBrowserSession(); observeBrowserSession();
this.unsubscribe = browserSession.subscribe((snapshot) => { 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.busy && snapshot.kind === "loading") return;
if (!this.disposed) { if (!this.disposed) {
this.session = snapshot.kind === "ready" this.session = snapshot.kind === "ready"
@@ -56,75 +82,224 @@ export class BrowserAuth {
} }
}); });
} }
get actor(): RequestActor | null { get actor(): RequestActor | null {
return this.session.kind === "ready" ? this.session.actor : null; return this.session.kind === "ready" ? this.session.actor : null;
} }
async ensure(): Promise<void> { async ensure(): Promise<void> {
if (this.busy || this.disposed) return; if (!this.busy && !this.disposed) await browserSession.ensure();
await browserSession.ensure();
} }
async refresh(): Promise<void> { async refresh(): Promise<void> {
if (this.busy || this.disposed) return; if (!this.busy && !this.disposed) await browserSession.refresh();
await browserSession.refresh(); }
cancel(): void {
this.controller?.abort();
} }
async run( async run(
pending: string, pending: string,
success: string, success: string,
operation: (signal: AbortSignal) => Promise<unknown>, operation: (signal: AbortSignal) => Promise<unknown>,
intent: "authentication" | "logout" = "authentication", intent: "authentication" | "logout" | "device-approval" = "authentication",
): Promise<boolean> { ): Promise<boolean> {
if ( if (
this.busy || this.disposed || this.busy || this.disposed ||
(intent !== "logout" && this.session.kind !== "ready") (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; this.busy = true;
sessionMutation(intent); sessionMutation(mutationReason, true);
const authenticationGeneration = browserSession.authenticationGeneration; const generation = browserSession.authenticationGeneration;
const stamp = browserSession.authenticationStamp;
let acceptedGeneration = generation;
let acceptedStamp = stamp;
let submittedAddition = false;
const controller = new AbortController(); const controller = new AbortController();
this.controller = controller; this.controller = controller;
this.operationGeneration = generation;
this.error = ""; this.error = "";
this.status = pending; this.status = pending;
const verifySession = async () => {
if (stamp !== browserSession.authenticationStamp) controller.abort();
controller.signal.throwIfAborted();
let current: WhoamiResponse;
try { try {
await operation(controller.signal); current = await loadWhoami(controller.signal);
const superseded = } catch {
authenticationGeneration !== browserSession.authenticationGeneration; throw new AuthOperationError(
sessionMutation(intent); "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 {
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; const confirmationGeneration = browserSession.authenticationGeneration;
acceptedGeneration = confirmationGeneration;
const confirmationStamp = browserSession.authenticationStamp;
acceptedStamp = confirmationStamp;
this.status = "Confirming the expected browser session…";
await browserSession.refresh(); await browserSession.refresh();
if ( if (
this.disposed || superseded || this.disposed || controller.signal.aborted ||
confirmationGeneration !== browserSession.authenticationGeneration confirmationGeneration !== browserSession.authenticationGeneration ||
confirmationStamp !== browserSession.authenticationStamp
) { ) {
if (!this.disposed) this.status = ""; if (!this.disposed) this.status = "";
return false; 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(); notifySessionChanged();
const confirmed = this.session.kind === "ready"; if (submittedAddition) this.additionSaved = true;
this.status = confirmed ? success : ""; this.status = success;
return confirmed; return true;
} catch (error) { } catch (error) {
if (!this.disposed) { if (!this.disposed) {
this.error = authErrorMessage(error); this.error = authErrorMessage(error);
this.status = ""; this.status = "";
} }
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。 // 古い処理から新しいログアウト/主体確認をinvalidateしない。
sessionMutation(intent); if (
!this.disposed &&
acceptedGeneration === browserSession.authenticationGeneration &&
acceptedStamp === browserSession.authenticationStamp
) {
this.operationGeneration = null;
sessionMutation(mutationReason, true);
await browserSession.refresh(); await browserSession.refresh();
}
return false; return false;
} finally { } finally {
if (!this.disposed) { this.operationGeneration = null;
this.busy = false;
this.controller = 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…";
const generation = browserSession.authenticationGeneration;
const controller = new AbortController();
this.controller = controller;
this.operationGeneration = generation;
try {
const before = await loadWhoami(controller.signal);
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);
const after = await loadWhoami(controller.signal);
controller.signal.throwIfAborted();
if (
this.disposed ||
generation !== browserSession.authenticationGeneration ||
!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.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 { dispose(): void {
this.disposed = true; this.disposed = true;
this.unsubscribe(); this.unsubscribe();
@@ -1,7 +1,7 @@
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts"; import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
import { readBoundedAuthResponseJson } from "./api.ts"; import { readBoundedAuthResponseJson } from "./api.ts";
import { HostingRequestError } from "./hosting.ts"; import { HostingRequestError } from "./hosting.ts";
import { parseAuthUserResponse } from "./model.ts"; import { parseAuthUser } from "./model.ts";
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。 // Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
export function normalizeActivationHandle(value: string): string { export function normalizeActivationHandle(value: string): string {
@@ -57,7 +57,7 @@ export async function issueActivationCode(
typeof record.expires_at !== "string" || typeof record.expires_at !== "string" ||
!Number.isFinite(Date.parse(record.expires_at)) !Number.isFinite(Date.parse(record.expires_at))
) throw new Error(); ) throw new Error();
const user = parseAuthUserResponse({ user: record.user }).user; const user = parseAuthUser(record.user);
if (user.handle !== handle) throw new Error(); if (user.handle !== handle) throw new Error();
signal.throwIfAborted(); signal.throwIfAborted();
return { user, code: record.code, expires_at: record.expires_at }; return { user, code: record.code, expires_at: record.expires_at };
@@ -7,6 +7,16 @@ import {
registrationOptions, registrationOptions,
} from "./api.test-fixtures.ts"; } 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 { function equal(actual: unknown, expected: unknown): void {
if (JSON.stringify(actual) !== JSON.stringify(expected)) { if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error("Values differ"); 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) => { await boundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( return Response.json(
String(input).endsWith("/options") String(input).endsWith("/whoami")
? bearerIdentity
: String(input).endsWith("/options")
? registrationOptions ? registrationOptions
: { user: authUser }, : completion,
); );
}, async () => { }, async () => {
equal( equal(
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
authUser, authUser,
); );
equal(requests.map((request) => request.path), [ equal(requests.map((request) => request.path), [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
]); ]);
for (const { init } of requests) { for (const { path, init } of requests) {
equal(init.method, "POST"); equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
equal(new Headers(init.headers).has("cookie"), false);
equal(init.credentials, "omit"); equal(init.credentials, "omit");
equal(init.mode, "same-origin"); equal(init.mode, "same-origin");
equal(init.redirect, "error"); 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(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", 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(Object.keys(complete).sort(), ["challenge_id", "credential"]);
equal(complete.challenge_id, registrationOptions.challenge_id); equal(complete.challenge_id, registrationOptions.challenge_id);
equal(complete.credential.response.attestationObject, "AQID"); equal(complete.credential.response.attestationObject, "AQID");
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
}] }]
) { ) {
let calls = 0; let calls = 0;
await boundary(async () => Response.json(payload), async () => { await boundary(
async (input) =>
Response.json(
String(input).endsWith("/whoami") ? bearerIdentity : payload,
),
async () => {
await rejects( await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal), enrollHostingPasskey("host-test-token", new AbortController().signal),
); );
equal(calls, 0); equal(calls, 0);
}, async () => { },
async () => {
calls++; calls++;
return credentialFixture(); return credentialFixture();
}); },
);
} }
}); });
Deno.test("host enrollment cancellation and teardown prevent completion", async () => { Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
for (const abort of [false, true]) { for (const abort of [false, true]) {
const controller = new AbortController(); const controller = new AbortController();
let requests = 0; const requests: string[] = [];
await boundary(async () => { let prompts = 0;
requests++; await boundary(async (input) => {
return Response.json(registrationOptions); requests.push(String(input));
return Response.json(
String(input).endsWith("/whoami")
? bearerIdentity
: registrationOptions,
);
}, async () => { }, async () => {
await rejects(enrollHostingPasskey("host-test-token", controller.signal)); 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 () => { }, async () => {
prompts++;
if (abort) { if (abort) {
controller.abort(); controller.abort();
return credentialFixture(); return credentialFixture();
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
let calls = 0; let calls = 0;
await boundary(async (input) => { await boundary(async (input) => {
calls++; calls++;
if (String(input).endsWith("/whoami")) {
return Response.json(bearerIdentity);
}
if (String(input).endsWith("/options")) { if (String(input).endsWith("/options")) {
return Response.json(registrationOptions); return Response.json(registrationOptions);
} }
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
await rejects( await rejects(
enrollHostingPasskey("host-test-token", new AbortController().signal), enrollHostingPasskey("host-test-token", new AbortController().signal),
); );
equal(calls, 2); equal(calls, 3);
}); });
} }
}); });
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
import { import {
type AuthUser, type AuthUser,
parseAuthUserResponse, parseAuthUserResponse,
parseWhoamiResponse,
prepareRegistrationOptions, prepareRegistrationOptions,
registrationCredentialToJson, registrationCredentialToJson,
} from "./model.ts"; } from "./model.ts";
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
} }
const request = async ( const request = async (
path: path:
| "/api/auth/whoami"
| "/api/auth/passkeys/addition/options" | "/api/auth/passkeys/addition/options"
| "/api/auth/passkeys/addition/complete", | "/api/auth/passkeys/addition/complete",
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest, body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
): Promise<unknown> => { ): Promise<unknown> => {
signal.throwIfAborted(); signal.throwIfAborted();
const response = await fetch(path, { const response = await fetch(path, {
method: "POST", method: path === "/api/auth/whoami" ? "GET" : "POST",
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、 // Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。 // completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
credentials: "omit", credentials: "omit",
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
} }
return await readBoundedAuthResponseJson(response); 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", { 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, browser_origin: window.location.origin,
}); });
const publicKey = prepareRegistrationOptions(options); const publicKey = prepareRegistrationOptions(options);
@@ -4,6 +4,7 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void; test(name: string, fn: () => void | Promise<void>): void;
}; };
import { AuthOperationError } from "./operation.ts";
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts"; import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
import { import {
authUser, authUser,
@@ -64,7 +65,12 @@ async function boundary(
Response.json(body.handle ? legacyLoginOptions : loginOptions), 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 await (legacy
? loginWithLegacyPasskey(" alice ", signal) ? loginWithLegacyPasskey(" alice ", signal)
: loginWithPasskey(signal)), : loginWithPasskey(signal)),
authUser, { user: authUser, authentication_reference: "fixture-authentication" },
); );
equal(state.requests.map(({ path }) => path), [ equal(state.requests.map(({ path }) => path), [
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
: loginWithPasskey(controller.signal)); : loginWithPasskey(controller.signal));
throw new Error("Unexpected login success"); throw new Error("Unexpected login success");
} catch (error) { } catch (error) {
if (!(error instanceof DOMException)) throw error; if (!(error instanceof AuthOperationError)) throw error;
equal(error.name, late ? "AbortError" : "NotAllowedError"); equal(error.stage, "authenticator");
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
equal(error.outcome, "not-sent");
} }
equal(state.requests.length, 1); equal(state.requests.length, 1);
}); });
@@ -2,6 +2,7 @@ import {
authenticationCredentialToJson, authenticationCredentialToJson,
base64UrlToBuffer, base64UrlToBuffer,
bufferToBase64Url, bufferToBase64Url,
parseAuthUserResponse,
parseDeviceLoginPollResponse, parseDeviceLoginPollResponse,
parseDeviceLoginStartResponse, parseDeviceLoginStartResponse,
parseWhoamiResponse, parseWhoamiResponse,
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
Deno.test("whoami rejects unknown auth methods and extra fields", () => { Deno.test("whoami rejects unknown auth methods and extra fields", () => {
assertEquals( assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }), parseWhoamiResponse({
{ actor: null, session: { kind: "anonymous" } }, actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
}),
{
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
},
); );
assertThrows( assertThrows(
() => () =>
parseWhoamiResponse({ parseWhoamiResponse({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
user_id: "user-1", user_id: "user-1",
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
"unknown auth method", "unknown auth method",
); );
assertThrows( assertThrows(
() => parseWhoamiResponse({ actor: null, token: "secret" }), () =>
parseWhoamiResponse({
actor: null,
authentication_reference: null,
token: "secret",
}),
"unexpected whoami field", "unexpected whoami field",
); );
}); });
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
const attackerKey = `secret-${"x".repeat(512)}`; const attackerKey = `secret-${"x".repeat(512)}`;
try { try {
parseWhoamiResponse({ actor: null, [attackerKey]: true }); parseWhoamiResponse({
actor: null,
authentication_reference: null,
[attackerKey]: true,
});
throw new Error("expected an error"); throw new Error("expected an error");
} catch (error) { } catch (error) {
if (!(error instanceof Error) || error.message.includes(attackerKey)) { 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 ( for (
const payload of [ const payload of [
{ actor, session: { kind: "anonymous" } }, { actor, authentication_reference: null, session: { kind: "anonymous" } },
{ actor, session: { kind: "expired" } }, { actor, authentication_reference: null, session: { kind: "expired" } },
{ {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, 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,
{ actor: null, session: { kind: "future" } }, authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: null },
},
{
actor,
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "tomorrow" },
},
{ {
actor: null, actor: null,
authentication_reference: null,
session: { kind: "future" },
},
{
actor: null,
authentication_reference: null,
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
}, },
] ]
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami"); ) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
assertEquals( assertEquals(
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }), parseWhoamiResponse({
{ actor: null, session: { kind: "expired" } }, 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, DeviceLoginPollStatus,
DeviceLoginStartResponse, DeviceLoginStartResponse,
LogoutResponse, LogoutResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
RequestActor, RequestActor,
@@ -21,6 +24,9 @@ export type {
AuthPublicConfig, AuthPublicConfig,
DeviceLoginPollResponse, DeviceLoginPollResponse,
DeviceLoginStartResponse, DeviceLoginStartResponse,
PasskeyListResponse,
PasskeySummary,
PasskeyAdditionResultResponse,
PasskeyLoginOptionsResponse, PasskeyLoginOptionsResponse,
PasskeyRegistrationOptionsResponse, PasskeyRegistrationOptionsResponse,
RequestActor, RequestActor,
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000; const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/; const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
export class AuthPayloadError extends Error {}
function invalid(path: string, reason: string): never { 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> { 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 (budget.remainingNodes < 0) invalid(path, "has too many values");
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested"); if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
if (value === null || typeof value === "boolean") return; if (value === null || typeof value === "boolean") return;
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
if (typeof value === "string") { if (typeof value === "string") {
boundedString(value, path, { allowEmpty: true }); boundedString(value, path, { allowEmpty: true });
budget.remainingStringUnits -= value.length; budget.remainingStringUnits -= value.length;
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
function parseAuthenticatorTransport( function parseAuthenticatorTransport(
value: unknown, value: unknown,
path: string, path: string,
): AuthenticatorTransport { ): AuthenticatorTransport | null {
const transport = boundedString(value, path); const transport = boundedString(value, path);
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) { if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
invalid(path, "contains an unknown authenticator transport"); return null;
} }
return transport as AuthenticatorTransport; return transport as AuthenticatorTransport;
} }
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) { if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
invalid(`${path}.transports`, "has too many items"); invalid(`${path}.transports`, "has too many items");
} }
return descriptor.transports.map((transport, index) => return descriptor.transports.flatMap((transport, index) => {
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`) const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
); return parsed === null ? [] : [parsed];
});
})(); })();
return { return {
type: "public-key", type: "public-key",
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
export function parseWhoamiResponse(value: unknown): WhoamiResponse { export function parseWhoamiResponse(value: unknown): WhoamiResponse {
const response = asRecord(value, "whoami"); const response = asRecord(value, "whoami");
requireExactKeys(response, "whoami", ["actor", "session"]); requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
const actor = response.actor === null const actor = response.actor === null
? null ? null
: parseActor(response.actor, "whoami.actor"); : parseActor(response.actor, "whoami.actor");
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
"must identify the server session expiry", "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"]); requireExactKeys(session, "whoami.session", ["kind"]);
if ( if (
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
) { ) {
invalid("whoami", "inconsistent actor or session state"); 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 { export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
return result; return result;
} }
export function parseAuthUser(value: unknown): AuthUser {
return parseAuthenticatedUser(value, "auth_user.user");
}
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse { export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
const response = asRecord(value, "auth_user"); const response = asRecord(value, "auth_user");
requireExactKeys(response, "auth_user", ["user"]); requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
return { user: parseAuthenticatedUser(response.user, "auth_user.user") }; 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( export function prepareRegistrationOptions(
@@ -583,11 +624,14 @@ function clientExtensionResults(
const value = (credential.getClientExtensionResults as () => unknown).call( const value = (credential.getClientExtensionResults as () => unknown).call(
credential, credential,
); );
boundedJson(value, "credential.clientExtensionResults"); const output = asRecord(value, "credential.clientExtensionResults");
return asRecord( // 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
value, // outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
"credential.clientExtensionResults", const supported = Object.fromEntries(
) as AuthenticationExtensionsClientOutputs; ["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 { 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"); invalid("credential.response.transports", "must be a bounded array");
} }
const transports = transportsValue.map((transport, index) => // transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
parseAuthenticatorTransport( // boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
transport, const transports = transportsValue.flatMap((value, index) => {
`credential.response.transports[${index}]`, const transport = boundedString(value, `credential.response.transports[${index}]`);
) return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
); });
const authenticatorAttachment = optionalString( const authenticatorAttachment = optionalString(
core.authenticatorAttachment, core.authenticatorAttachment,
"credential.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(); await browserSession.ensure();
expect(fetcher).toHaveBeenCalledTimes(1); expect(fetcher).toHaveBeenCalledTimes(1);
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
Response.json({ actor: null, session: { kind } }), Response.json({ authentication_reference: null, actor: null, session: { kind } }),
); );
await expect(requirePageSession(target)).rejects.toMatchObject({ await expect(requirePageSession(target)).rejects.toMatchObject({
status: 307, status: 307,
@@ -0,0 +1,474 @@
// @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 };
}
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( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } }) Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
), ),
); );
await browserSession.refresh(); await browserSession.refresh();
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => vi.fn(async () =>
Response.json({ actor: null, session: { kind: "expired" } }) Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
), ),
); );
await browserSession.refresh(); await browserSession.refresh();
@@ -13,13 +13,14 @@ afterEach(() => {
localStorage.clear(); localStorage.clear();
}); });
const anonymous: WhoamiResponse = { const anonymous: WhoamiResponse = {
actor: null, actor: null, authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}; };
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } }; const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
const authenticated = ( const authenticated = (
expires_at: string | null = "2099-01-01T00:00:00Z", expires_at: string | null = "2099-01-01T00:00:00Z",
): WhoamiResponse => ({ ): WhoamiResponse => ({
authentication_reference: "fixture-authentication",
actor: { ...authUser, auth_method: "browser_session" }, actor: { ...authUser, auth_method: "browser_session" },
session: { kind: "authenticated", expires_at }, session: { kind: "authenticated", expires_at },
}); });
@@ -17,6 +17,10 @@ export class SessionState {
private listeners = new Set<(state: SessionSnapshot) => void>(); private listeners = new Set<(state: SessionSnapshot) => void>();
private stale = true; private stale = true;
get authenticationStamp(): string | null {
return storageStamp();
}
get authenticationGeneration(): number { get authenticationGeneration(): number {
return this.generation; return this.generation;
} }
@@ -38,7 +42,7 @@ export class SessionState {
++this.generation; ++this.generation;
this.pending = null; this.pending = null;
this.stale = true; 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. // Page load owns freshness. Components mounted after it reuse that result.
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
export function sessionMutation( export function sessionMutation(
reason: "authentication" | "logout" = "authentication", reason: "authentication" | "logout" = "authentication",
preserveSnapshot = false,
): void { ): void {
browserSession.invalidate(); browserSession.invalidate(preserveSnapshot);
const message = JSON.stringify({ id: crypto.randomUUID(), reason }); const message = JSON.stringify({ id: crypto.randomUUID(), reason });
try { try {
localStorage.setItem(GENERATION_KEY, message); localStorage.setItem(GENERATION_KEY, message);
@@ -35,6 +35,7 @@ function session() {
function whoami(authenticated: boolean) { function whoami(authenticated: boolean) {
return Response.json({ return Response.json({
authentication_reference: authenticated ? "fixture-authentication" : null,
session: authenticated session: authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
return new Response(null, { status: state.completeStatus }); return new Response(null, { status: state.completeStatus });
} }
state.authenticated = true; state.authenticated = true;
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
throw new Error(`Unexpected API: ${path}`); 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({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
}); });
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
oldResponse.resolve(whoami(true)); oldResponse.resolve(whoami(true));
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(auth.status).toBe("Logged out"); 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 confirmingStarted = gate<void>();
const request = fetcher.getMockImplementation()!; const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => { 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(); confirmingStarted.resolve();
return confirming.promise; return confirming.promise;
} }
return request(input, init); return request(input, init);
}); });
fetcher.mockReturnValueOnce(options.promise);
const run = auth.run( const run = auth.run(
"Waiting", "Waiting",
"Logged in", "Logged in",
(signal) => loginWithPasskey(signal), (signal) => loginWithPasskey(signal),
); );
expect(auth.busy).toBe(true); expect(auth.busy).toBe(true);
expect(auth.status).toBe("Waiting"); expect(auth.status).toMatch(/Checking the account/);
const duplicate = vi.fn(async () => {}); const duplicate = vi.fn(async () => {});
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh(); await auth.refresh();
expect(duplicate).not.toHaveBeenCalled(); expect(duplicate).not.toHaveBeenCalled();
expect(paths(fetcher)).toEqual([ // Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
"/api/auth/whoami", 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", "/api/auth/passkeys/login/options",
]); ]);
options.resolve(Response.json(loginOptions)); 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); expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
await auth.refresh(); await auth.refresh();
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami")) expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(4);
confirming.resolve(whoami(true)); confirming.resolve(whoami(true));
expect(await run).toBe(true); expect(await run).toBe(true);
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.actor?.handle).toBe("alice"); expect(auth.actor?.handle).toBe("alice");
expect(auth.status).toBe("Logged in"); expect(auth.status).toBe("Logged in");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete", "/api/auth/passkeys/login/complete",
"/api/auth/whoami", "/api/auth/whoami",
]); ]);
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
expect(result).toBe(false); expect(result).toBe(false);
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.status).toBe(""); expect(auth.status).toBe("");
expect(auth.error).toMatch( expect(auth.error).toMatch(/server rejected the request or verification/);
status === 401 expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
? /Authentication request was rejected/ expect(auth.error).toContain("server did not accept completion");
: /The request or authentication challenge is invalid or expired/,
);
expect(auth.session.kind).toBe("ready"); expect(auth.session.kind).toBe("ready");
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice"); expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/whoami",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
"/api/auth/whoami", "/api/auth/whoami",
]); ]);
@@ -278,22 +295,25 @@ for (
).toBe(false); ).toBe(false);
expect(auth.error).toMatch( expect(auth.error).toMatch(
name === "NotAllowedError" name === "NotAllowedError"
? /cancelled or timed out/ ? /cancelled, timed out, or found no usable key/
: name === "NotSupportedError" : name === "NotSupportedError"
? /Completion is not confirmed/ ? /supported device/
: name === "AbortError" : name === "AbortError"
? /Completion is not confirmed/ ? /Operation stopped/
: /Authentication could not be confirmed/, : /Communication could not be confirmed/,
); );
expect(auth.error).toContain("Completion was not sent.");
expect(auth.error).not.toContain("secret credential detail"); expect(auth.error).not.toContain("secret credential detail");
expect(auth.status).toBe(""); expect(auth.status).toBe("");
expect(auth.busy).toBe(false); expect(auth.busy).toBe(false);
expect(auth.session).toEqual({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options", "/api/auth/passkeys/login/options",
"/api/auth/whoami", "/api/auth/whoami",
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
const { fetcher } = boundary(); const { fetcher } = boundary();
const auth = session(); const auth = session();
await auth.refresh(); await auth.refresh();
let completed = false;
fetcher.mockImplementation(async (input) => { fetcher.mockImplementation(async (input) => {
const path = String(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") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { 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}`); 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({ expect(auth.session).toEqual({
kind: "ready", kind: "ready",
actor: null, actor: null,
authentication_reference: null,
session: { kind: "anonymous" }, session: { kind: "anonymous" },
}); });
expect(auth.status).toBe("Waiting"); expect(auth.status).toBe("Waiting");
expect(auth.error).toBe(""); expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => 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>(); const started = gate<AbortSignal>();
fetcher.mockImplementation(async (input, init) => { fetcher.mockImplementation(async (input, init) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") return whoami(false);
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); 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; const signal = await started.promise;
auth.dispose(); auth.dispose();
expect(signal.aborted).toBe(true); 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(await run).toBe(false);
expect(auth.actor).toBeNull(); expect(auth.actor).toBeNull();
expect(auth.status).toBe("Waiting"); expect(auth.status).toBe("Confirming the operation with the server…");
expect(auth.error).toBe(""); expect(auth.error).toBe("");
expect(paths(fetcher)).toEqual([ expect(paths(fetcher)).toEqual([
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/passkeys/login/complete",
"/api/auth/whoami", "/api/auth/whoami",
"/api/auth/passkeys/login/options",
"/api/auth/whoami",
"/api/auth/passkeys/login/complete",
]); ]);
expect( expect(
dispatch.mock.calls.filter(([event]) => 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(); await auth.refresh();
const response = gate<Response>(); const response = gate<Response>();
const started = gate<void>(); const started = gate<void>();
let completed = false;
fetcher.mockImplementation(async (input) => { fetcher.mockImplementation(async (input) => {
const path = String(input); const path = String(input);
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { 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 (path === "/api/auth/whoami") {
if (!completed) return whoami(false);
started.resolve(); started.resolve();
return await response.promise; return await response.promise;
} }
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
const started = gate<void>(); const started = gate<void>();
const request = fetcher.getMockImplementation()!; const request = fetcher.getMockImplementation()!;
fetcher.mockImplementation((input, init) => { 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(); started.resolve();
return confirming.promise; return confirming.promise;
} }
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
sessionMutation(newer === "logout" ? "logout" : "authentication"); sessionMutation(newer === "logout" ? "logout" : "authentication");
fetcher.mockResolvedValueOnce( fetcher.mockResolvedValueOnce(
newer === "logout" ? whoami(false) : Response.json({ newer === "logout" ? whoami(false) : Response.json({
authentication_reference: "other-authentication",
actor: { actor: {
...authUser, ...authUser,
user_id: "other-user", user_id: "other-user",
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal), purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
), ),
).toBe(false); ).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( expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
false, false,
); );
@@ -18,6 +18,7 @@ for (
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
if (String(input) === "/api/auth/whoami") { if (String(input) === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
+86 -10
View File
@@ -1,30 +1,68 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; 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 { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
import { loginUrl } from '#lib/workspace/auth/routes.ts'; import { loginUrl } from '#lib/workspace/auth/routes.ts';
import '#lib/workspace/auth/auth-page.css'; import '#lib/workspace/auth/auth-page.css';
const auth = new BrowserAuth(); const auth = new BrowserAuth();
async function refresh() { let keys = $state<PasskeyListResponse | null>(null);
await auth.refresh(); 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;
keysLoading = true; keysError = '';
try {
const before = await loadWhoami(controller.signal);
controller.signal.throwIfAborted();
if (!active || read !== keyRead) return;
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);
controller.signal.throwIfAborted();
if (!active || read !== keyRead) return;
const after = await loadWhoami(controller.signal);
controller.signal.throwIfAborted();
if (read !== keyRead) return;
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() { async function add() {
if (!auth.actor) return; if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey); 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() { async function signOut() {
if (!auth.actor) return; if (!auth.actor) return;
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout'); const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
if (confirmed && !auth.actor) await goto('/', { replaceState: true }); 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> </script>
<svelte:head><title>Account · Yoi</title></svelte:head> <svelte:head><title>Account · Yoi</title></svelte:head>
<div class="auth-page" data-sveltekit-preload-data="off"> <div class="auth-page" data-sveltekit-preload-data="off">
<h1>Account</h1> <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'} {#if auth.session.kind === 'loading'}
<p class="muted" role="status">Loading session…</p> <p class="muted" role="status">Loading session…</p>
{:else if auth.session.kind === 'error'} {:else if auth.session.kind === 'error'}
@@ -37,8 +75,41 @@
</dl> </dl>
<section aria-label="Your Passkeys"> <section aria-label="Your Passkeys">
<h2>Passkeys</h2> <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> <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>
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button> <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> </section>
<div class="actions"> <div class="actions">
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button> <button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
@@ -48,7 +119,12 @@
<p>No browser session is active.</p> <p>No browser session is active.</p>
<a href={loginUrl('/account')}>Log in to your account</a> <a href={loginUrl('/account')}>Log in to your account</a>
{/if} {/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> <a href="/dashboard">Dashboard / Workspaces</a>
</div> </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 new Response(null, { status: state.whoamiStatus });
} }
return Response.json({ return Response.json({
authentication_reference: state.authenticated
? "fixture-authentication"
: null,
session: state.authenticated session: state.authenticated
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -71,6 +74,9 @@ function boundary({
: null, : null,
}); });
} }
if (path === "/api/auth/passkeys") {
return Response.json({ user: authUser, keys: [] });
}
if (path === "/api/auth/registration-policy") { if (path === "/api/auth/registration-policy") {
if (state.policy === "loading") return await policyResponse.promise; if (state.policy === "loading") return await policyResponse.promise;
if (state.policy === "error") return new Response(null, { status: 503 }); if (state.policy === "error") return new Response(null, { status: 503 });
@@ -97,7 +103,10 @@ function boundary({
return new Response(null, { status: state.completeStatus }); return new Response(null, { status: state.completeStatus });
} }
state.authenticated = true; state.authenticated = true;
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/logout") { if (path === "/api/auth/logout") {
state.authenticated = false; state.authenticated = false;
@@ -231,19 +240,23 @@ for (
expect(get).toHaveBeenCalledOnce(); expect(get).toHaveBeenCalledOnce();
expect(create).not.toHaveBeenCalled(); expect(create).not.toHaveBeenCalled();
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami")) expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(4);
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve"); expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
}); });
} }
test("successful login completion without confirmed whoami never navigates", async () => { 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); render(Login);
await enterIdentity(); await enterIdentity();
get.mockImplementationOnce(async () => {
state.whoamiStatus = 503;
return credentialFixture(true);
});
await submit("Log in with passkey"); await submit("Log in with passkey");
await screen.findByText(/Your session could not be loaded/); await screen.findByText(/Your session could not be loaded/);
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
@@ -347,7 +360,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
await waitFor(() => expect(create).toHaveBeenCalledOnce()); await waitFor(() => expect(create).toHaveBeenCalledOnce());
state.completeStatus = 403; state.completeStatus = 403;
credential.resolve(credentialFixture()); 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/); await screen.findByText(/New account registration is disabled/);
expect(screen.queryByRole("button", { name: "Register new account" })) expect(screen.queryByRole("button", { name: "Register new account" }))
.toBeNull(); .toBeNull();
@@ -376,7 +389,9 @@ test("account displays the current identity without editable target fields and a
await fireEvent.click( await fireEvent.click(
screen.getByRole("button", { name: "Add passkey to alice" }), 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(() => await waitFor(() =>
expect( expect(
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement) (screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
@@ -388,7 +403,12 @@ test("account displays the current identity without editable target fields and a
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/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([ expect(Object.keys(requests[1].body).sort()).toEqual([
"challenge_id", "challenge_id",
"credential", "credential",
@@ -485,11 +505,18 @@ test("logout confirms the server session is cleared and returns to the public ho
expect(posted(fetcher)).toEqual([ expect(posted(fetcher)).toEqual([
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" }, { 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/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(); expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
}); });
@@ -499,10 +526,14 @@ test("expired addition revokes stale identity without silently retrying as regis
await fireEvent.click( await fireEvent.click(
await screen.findByRole("button", { name: "Add passkey to alice" }), await screen.findByRole("button", { name: "Add passkey to alice" }),
); );
await screen.findByText(/Authentication request was rejected/); await screen.findByText(/server rejected the request or verification/);
await screen.findByRole("link", { name: "Log in to your account" }); await screen.findByRole("link", { name: "Log in to your account" });
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull(); expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
expect(screen.queryByText("Passkey added to your account.")).toBeNull(); expect(
screen.queryByText(
"Passkey added to your account; the expected session was confirmed.",
),
).toBeNull();
expect(posted(fetcher).map(({ path }) => path)).toEqual([ expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
@@ -545,7 +576,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
await enterIdentity(); await enterIdentity();
await submit("Log in with passkey"); await submit("Log in with passkey");
await screen.findByText( 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(() => await waitFor(() =>
expect( expect(
@@ -651,7 +688,12 @@ test("device approval locks its code and rejects a second submit while the serve
name: "Approve device login", name: "Approve device login",
}); });
const response = gate<Response>(); 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")!; const form = approve.closest("form")!;
await fireEvent.submit(form); await fireEvent.submit(form);
await screen.findByText("Approving device login…"); await screen.findByText("Approving device login…");
@@ -659,9 +701,11 @@ test("device approval locks its code and rejects a second submit while the serve
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled) expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
.toBe(true); .toBe(true);
await fireEvent.submit(form); await fireEvent.submit(form);
await waitFor(() =>
expect(posted(fetcher).map(({ path }) => path)).toEqual([ expect(posted(fetcher).map(({ path }) => path)).toEqual([
"/api/auth/device-login/approve", "/api/auth/device-login/approve",
]); ])
);
response.resolve(Response.json({ status: "approved", user: authUser })); response.resolve(Response.json({ status: "approved", user: authUser }));
await screen.findByText("Device login approved. You can return to the CLI."); await screen.findByText("Device login approved. You can return to the CLI.");
expect(posted(fetcher)).toHaveLength(1); expect(posted(fetcher)).toHaveLength(1);
@@ -679,11 +723,8 @@ for (const status of [401, 410]) {
(await screen.findByRole("button", { name: "Approve device login" })) (await screen.findByRole("button", { name: "Approve device login" }))
.closest("form")!, .closest("form")!,
); );
await screen.findByText( await screen.findByText(/Authentication request was rejected/);
status === 401 expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
? /Authentication request was rejected/
: /The request or authentication challenge is invalid or expired/,
);
expect( expect(
screen.queryByText("Device login approved. You can return to the CLI."), screen.queryByText("Device login approved. You can return to the CLI."),
).toBeNull(); ).toBeNull();
@@ -721,7 +762,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
target: { value: "alice" }, target: { value: "alice" },
}); });
await submit("Log in with older key"); 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"); await screen.findByText("Use an older key with account ID");
expect( expect(
(screen.getByText("Use an older key with account ID").closest( (screen.getByText("Use an older key with account ID").closest(
@@ -7,6 +7,11 @@ import {
waitFor, waitFor,
} from "@testing-library/svelte"; } from "@testing-library/svelte";
import { afterEach, expect, test, vi } from "vitest"; import { afterEach, expect, test, vi } from "vitest";
import {
browserSession,
sessionMutation,
} from "#lib/workspace/auth/session-state.ts";
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
import Activate from "./+page.svelte"; import Activate from "./+page.svelte";
import { import {
authUser, authUser,
@@ -15,9 +20,34 @@ import {
} from "#lib/workspace/auth/api.test-fixtures.ts"; } from "#lib/workspace/auth/api.test-fixtures.ts";
afterEach(() => { afterEach(() => {
cleanup(); cleanup();
browserSession.invalidate();
vi.unstubAllGlobals(); 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", { Object.defineProperty(window, "isSecureContext", {
configurable: true, configurable: true,
value: true, value: true,
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
} }
if (String(input) === "/api/auth/activation/complete") { if (String(input) === "/api/auth/activation/complete") {
if (mode === "unknown") throw new Error("private diagnostic"); 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"); 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 () => { test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
const { fetcher } = boundary("success"); 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); render(Activate);
await submit(); 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/); await screen.findByText(/registration and activation confirmed/);
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([ expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
"/api/auth/activation/options", "/api/auth/activation/options",
"/api/auth/activation/complete", "/api/auth/activation/complete",
"/api/auth/whoami",
]); ]);
expect(screen.queryByLabelText("Activation code")).toBeNull(); expect(screen.queryByLabelText("Activation code")).toBeNull();
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe( expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
@@ -99,3 +175,64 @@ for (const mode of ["cancel", "unknown"] as const) {
.toBe(""); .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();
});
@@ -53,6 +53,7 @@ function boundary() {
if (state.sessionGate) return state.sessionGate.promise; if (state.sessionGate) return state.sessionGate.promise;
return state.whoamiStatus === 200 return state.whoamiStatus === 200
? Response.json({ ? Response.json({
authentication_reference: state.signedIn ? "fixture-authentication" : null,
session: state.signedIn session: state.signedIn
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -333,6 +334,7 @@ test("pending create remains single during an explicit authentication change and
expect(goto).not.toHaveBeenCalled(); expect(goto).not.toHaveBeenCalled();
state.sessionGate.resolve( state.sessionGate.resolve(
Response.json({ Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "browser_session" }, actor: { ...authUser, auth_method: "browser_session" },
}), }),
@@ -22,6 +22,7 @@ function boundary(
const fetcher = vi.fn<typeof fetch>(async (input, init) => { const fetcher = vi.fn<typeof fetch>(async (input, init) => {
if (input === "/api/auth/whoami") { if (input === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous" session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -27,6 +27,7 @@ function boundary(
const path = String(input); const path = String(input);
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
session: persona !== "anonymous" session: persona !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -1,7 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts"; 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 { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts'; import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
import type { AuthUser } from '#lib/workspace/auth/model.ts'; import type { AuthUser } from '#lib/workspace/auth/model.ts';
@@ -12,6 +13,7 @@
let verifiedLogin = $state(false); let verifiedLogin = $state(false);
let error = $state(''); let error = $state('');
const controller = new AbortController(); const controller = new AbortController();
let loginController: AbortController | null = null;
let active = true; let active = true;
let verifiedGeneration: number | null = null; let verifiedGeneration: number | null = null;
@@ -61,22 +63,40 @@
error = ''; error = '';
sessionMutation(); sessionMutation();
let generation = browserSession.authenticationGeneration; let generation = browserSession.authenticationGeneration;
const operation = new AbortController();
loginController = operation;
let promptGeneration: number | null = generation;
const unsubscribe = browserSession.subscribe(() => {
if (promptGeneration !== null && promptGeneration !== browserSession.authenticationGeneration) operation.abort();
});
try { try {
const signedIn = await loginWithPasskey(controller.signal); const initial = await loadWhoami(operation.signal);
if (!isCurrent(generation)) throw new Error('Login superseded'); if (!isCurrent(generation)) throw new Error('Login superseded');
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) { observeAuthOperation(operation.signal, {
expected: initial, phase: () => {}, additionAttempt: () => {},
verifySession: async () => {
if (!isCurrent(generation)) operation.abort();
operation.signal.throwIfAborted();
const current = await loadWhoami(operation.signal);
if (!isCurrent(generation) || 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)) 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'); throw new Error('Unexpected login actor');
} }
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。 // Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
promptGeneration = null;
sessionMutation(); sessionMutation();
generation = browserSession.authenticationGeneration; generation = browserSession.authenticationGeneration;
await browserSession.refresh(); await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified'); if (!isCurrent(generation) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
const access = await loadHostingAccess(); const access = await loadHostingAccess();
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified'); if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。 // accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
await browserSession.refresh(); await browserSession.refresh();
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded'); if (!isCurrent(generation) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
verifiedGeneration = generation; verifiedGeneration = generation;
verifiedLogin = true; verifiedLogin = true;
notifySessionChanged(); notifySessionChanged();
@@ -89,6 +109,7 @@
await browserSession.refresh(); await browserSession.refresh();
} }
} finally { } finally {
promptGeneration = null; unsubscribe(); loginController = null;
if (active) busy = false; if (active) busy = false;
} }
} }
@@ -97,6 +118,7 @@
active = false; active = false;
token = ''; token = '';
controller.abort(); controller.abort();
loginController?.abort();
}); });
</script> </script>
@@ -36,10 +36,24 @@ function boundary(
const create = vi.fn(async () => credentialFixture()); const create = vi.fn(async () => credentialFixture());
const get = vi.fn(async () => credentialFixture(true)); const get = vi.fn(async () => credentialFixture(true));
vi.stubGlobal("navigator", { credentials: { create, get } }); vi.stubGlobal("navigator", { credentials: { create, get } });
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => { const fetcher = vi.fn<typeof fetch>().mockImplementation(
async (input, init) => {
const path = String(input); 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") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -55,18 +69,23 @@ function boundary(
: new Response("host-test-token", { status }); : new Response("host-test-token", { status });
} }
if (path === "/api/auth/passkeys/addition/complete") { if (path === "/api/auth/passkeys/addition/complete") {
return Response.json({ user: authUser }); return Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json(loginOptions); return Response.json(loginOptions);
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
return failLogin return failLogin ? new Response(null, { status: 401 }) : Response.json({
? new Response(null, { status: 401 }) user: authUser,
: Response.json({ user: authUser }); authentication_reference: "fixture-authentication",
});
} }
throw new Error(`Unexpected API: ${path}`); throw new Error(`Unexpected API: ${path}`);
}); },
);
vi.stubGlobal("fetch", fetcher); vi.stubGlobal("fetch", fetcher);
return { fetcher, create, get }; 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) { for (const [path, init] of requests) {
expect(String(path)).not.toContain("host-test-token"); expect(String(path)).not.toContain("host-test-token");
expect(String(init?.body)).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( expect(new Headers(init?.headers).get("authorization")).toBe(
String(path).includes("/addition/") ? "Bearer host-test-token" : null, "Bearer host-test-token",
); );
} }
await fireEvent.click( 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(settings.getAttribute("href")).toBe("/hosting/settings");
expect(get).toHaveBeenCalledTimes(1); expect(get).toHaveBeenCalledTimes(1);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
.toHaveLength(2); .toHaveLength(6);
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access")) expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
.toHaveLength(1); .toHaveLength(1);
expect( expect(
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
const { fetcher } = boundary(); const { fetcher } = boundary();
const original = fetcher.getMockImplementation()!; const original = fetcher.getMockImplementation()!;
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami" &&
!new Headers(init?.headers).has("authorization")
? Response.json({ ? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { actor: {
...authUser, ...authUser,
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
unmount(); unmount();
expect(signal.aborted).toBe(true); expect(signal.aborted).toBe(true);
resolve(credentialFixture()); 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); const { unmount: unmountAgain } = render(Setup);
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value) expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe(""); .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) expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
.toBe(""); .toBe("");
expect(fetcher).toHaveBeenCalledTimes(2); expect(fetcher).toHaveBeenCalledTimes(3);
expect(screen.queryByRole("button", { name: "Sign in with new passkey" })) expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
.toBeNull(); .toBeNull();
}); });
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
fetcher.mockImplementation(async (input, init) => fetcher.mockImplementation(async (input, init) =>
String(input) === "/api/auth/whoami" String(input) === "/api/auth/whoami"
? Response.json({ ? Response.json({
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
actor: { ...authUser, auth_method: "api_token" }, actor: { ...authUser, auth_method: "api_token" },
}) })
@@ -292,6 +321,7 @@ const replacedActor = {
function identityResponse(actor: typeof setupActor | null) { function identityResponse(actor: typeof setupActor | null) {
return Response.json({ return Response.json({
actor, actor,
authentication_reference: actor ? "fixture-authentication" : null,
session: actor session: actor
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
const started = gate<void>(); const started = gate<void>();
const pending = gate<Response>(); const pending = gate<Response>();
let current: typeof setupActor | null = setupActor; let current: typeof setupActor | null = setupActor;
let reads = 0; let loginCompleted = false;
let accessRead = false;
let held = false;
fetcher.mockImplementation((input, init) => { fetcher.mockImplementation((input, init) => {
const path = String(input); 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") { if (path === "/api/auth/whoami") {
++reads;
if ( if (
(phase === "whoami" && reads === 1) || !held && loginCompleted && (
(phase === "confirmation" && reads === 2) (phase === "whoami" && !accessRead) ||
(phase === "confirmation" && accessRead)
)
) { ) {
held = true;
started.resolve(); started.resolve();
return pending.promise; return pending.promise;
} }
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
phase === "access" phase === "access"
? Response.json({ is_admin: true }) ? Response.json({ is_admin: true })
: phase === "login" : phase === "login"
? Response.json({ user: authUser }) ? Response.json({
user: authUser,
authentication_reference: "fixture-authentication",
})
: identityResponse(setupActor), : identityResponse(setupActor),
); );
await waitFor(() => await waitFor(() =>
@@ -421,3 +464,36 @@ for (const replacement of [null, replacedActor]) {
).toBe(false); ).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();
});
@@ -13,11 +13,12 @@
async function approve() { async function approve() {
if (auth.busy || !auth.actor || approved || !userCode.trim()) return; if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
const code = userCode.trim().toUpperCase(); 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); const result = await approveDeviceLogin(code, signal);
if (result.status !== 'approved') throw new Error('Device approval not confirmed'); if (result.status !== 'approved') throw new Error('Device approval not confirmed');
approved = true; return result;
}); }, 'device-approval');
if (confirmed) approved = true;
} }
onMount(() => { onMount(() => {
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? ''; userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
@@ -57,7 +57,7 @@ beforeEach(() => {
"fetch", "fetch",
vi.fn(async (input) => { vi.fn(async (input) => {
if (String(input) === "/api/auth/whoami") { 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}`); 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/workspaces") return new Response(null, { status: 503 });
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
session: mode !== "anonymous" session: mode !== "anonymous"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -89,7 +90,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
if (mode === "activation-unknown") { if (mode === "activation-unknown") {
return new Response(null, { status: 503 }); 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 }); if (path.startsWith("/api/")) return new Response(null, { status: 404 });
const file = join(root, decodeURIComponent(path)); 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 }) await page.getByRole("button", { name: "Register new account", exact: true })
.click(); .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) => const count = (mutations: Mutation[], path: string) =>
mutations.filter((entry) => entry.path === path).length; mutations.filter((entry) => entry.path === path).length;
const at = async (page: Page, path: string) => { 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`, readyUrl: `${origin}/health`,
}], }],
`${root}/tools/web-ux/scenarios/anonymous-entry.json`, `${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; let browser: Browser | undefined;
@@ -229,9 +234,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
name: "Add passkey to alice", name: "Add passkey to alice",
exact: true, exact: true,
}).click(); }).click();
await page.getByRole("status").filter({ await additionConfirmed(page).waitFor();
hasText: /added|registered/i,
}).waitFor();
const additions = mutations.filter((entry) => const additions = mutations.filter((entry) =>
entry.path.includes("/addition/") 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/options",
"/api/auth/passkeys/addition/complete", "/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(additions[1].keys, ["challenge_id", "credential"]);
assertEquals( assertEquals(
mutations.some((entry) => entry.path.includes("/registration/")), 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", name: "Add passkey to alice",
exact: true, exact: true,
}).click(); }).click();
await page.getByRole("status").filter({ hasText: /added/i }) await additionConfirmed(page).waitFor();
.waitFor();
assertEquals( assertEquals(
count(mutations, "/api/auth/passkeys/addition/complete"), count(mutations, "/api/auth/passkeys/addition/complete"),
1, 1,
@@ -784,9 +791,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
.__authFixtureRelease() .__authFixtureRelease()
); );
if (purpose === "addition") { if (purpose === "addition") {
await page.getByRole("status").filter({ await additionConfirmed(page).waitFor();
hasText: /added|registered/i,
}).waitFor();
} else await at(page, "/dashboard"); } else await at(page, "/dashboard");
assertEquals( assertEquals(
count(mutations, `/api/auth/passkeys/${purpose}/options`), 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", name: "Approve device login",
exact: true, exact: true,
}).click(); }).click();
await page.getByRole("alert").filter({ hasText: /invalid|expired/i }) await page.getByRole("alert").filter({
.waitFor(); 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); assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
}), }),
); );
@@ -549,7 +549,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
}, },
); );
await step( 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) => { async (page) => {
let checks = 0; let checks = 0;
page.on("request", (request) => { page.on("request", (request) => {
@@ -577,6 +577,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
await page.getByRole("link", { name: "Account", exact: true }) await page.getByRole("link", { name: "Account", exact: true })
.click(); .click();
await requested; await requested;
assertEquals(checks, 2);
assertEquals( assertEquals(
await page.getByRole("heading", { await page.getByRole("heading", {
name: "Dashboard", name: "Dashboard",
@@ -594,7 +595,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
exact: true, exact: true,
}).waitFor(); }).waitFor();
assertEquals(new URL(page.url()).pathname, "/account"); assertEquals(new URL(page.url()).pathname, "/account");
assertEquals(checks, 2); await page.getByRole("list", { name: "Registered keys" })
.waitFor();
// page入場の一回に加え、本人鍵一覧は読取りの前後で本人bindingを確認する。
assertEquals(checks, 4);
} finally { } finally {
release(); release();
} }
@@ -158,9 +158,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
return new Response(null, { status: 503 }); return new Response(null, { status: 503 });
} }
if (mode === "session-malformed") { 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({ return Response.json({
authentication_reference: state.signedIn
? "fixture-authentication"
: null,
actor: state.signedIn actor: state.signedIn
? { ...user, auth_method: "browser_session" } ? { ...user, auth_method: "browser_session" }
: null, : null,
@@ -168,11 +176,25 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
? { ? {
kind: "authenticated", kind: "authenticated",
expires_at: state.expiresAt ?? expires_at: state.expiresAt ??
new Date(Date.now() + 86_400_000).toISOString(), "2099-01-01T00:00:00Z",
} }
: { kind: state.expired ? "expired" : "anonymous" }, : { 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 (path === "/api/auth/registration-policy") {
if (mode === "policy-loading") return await new Promise<Response>(() => {}); if (mode === "policy-loading") return await new Promise<Response>(() => {});
if (mode === "policy-error") return new Response(null, { status: 503 }); 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 (path === "/api/auth/device-login/approve") {
if (!state.signedIn) return new Response(null, { status: 401 }); 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 }); return Response.json({ status: "approved", user });
} }
const passkey = path.match( const passkey = path.match(
@@ -263,7 +289,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
state.expired = false; state.expired = false;
state.cookieSession = false; state.cookieSession = false;
state.expiresAt = null; state.expiresAt = null;
return Response.json({ user }); return Response.json({
user,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/activation/options") { if (path === "/api/auth/activation/options") {
return Response.json(options(false)); 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.expired = false;
state.cookieSession = false; state.cookieSession = false;
state.expiresAt = null; state.expiresAt = null;
return Response.json({ user }); return Response.json({
user,
authentication_reference: "fixture-authentication",
});
} }
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。 // Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
if (workspaceFixture && path.startsWith("/api/w/")) { 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),
);
}
});
@@ -431,7 +431,7 @@ export async function sessionNavigation(
}, },
); );
await step( 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 }) => { async ({ page, go }) => {
const target = "/account?tab=keys#challenge"; const target = "/account?tab=keys#challenge";
await go(target, "addition-expired"); await go(target, "addition-expired");
@@ -451,7 +451,21 @@ export async function sessionNavigation(
name: "Add passkey to alice", name: "Add passkey to alice",
exact: true, exact: true,
}).isEnabled(), }).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,
); );
}, },
); );
@@ -464,7 +478,11 @@ export async function sessionNavigation(
await at(page, "/"); await at(page, "/");
const wire = await (await page.request.get(`${origin}/api/auth/whoami`)) const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
.json(); .json();
assertEquals(wire, { actor: null, session: { kind: "anonymous" } }); assertEquals(wire, {
actor: null,
authentication_reference: null,
session: { kind: "anonymous" },
});
await page.evaluate(() => { await page.evaluate(() => {
localStorage.clear(); localStorage.clear();
sessionStorage.clear(); sessionStorage.clear();
@@ -19,7 +19,7 @@ const mime: Record<string, string> = {
}; };
const policies = new Map<string, boolean>(); const policies = new Map<string, boolean>();
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを // 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。 // 送らないので、Bearer有無と直前の合成page条件で応答を選ぶ。header値は読まない。
let setupMode: string | null = null; let setupMode: string | null = null;
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => { Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
const url = new URL(request.url); 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-")) { if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
setupMode = selected; setupMode = selected;
} }
const tokenRequest = request.headers.has("authorization");
const mode = selected ?? const mode = selected ??
(tokenRequest ? setupMode : null) ??
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
(url.pathname.startsWith("/api/auth/passkeys/addition/") (url.pathname.startsWith("/api/auth/passkeys/addition/")
? setupMode ? setupMode
: null) ?? : null) ??
"admin-on"; "admin-on";
if (selected) policies.delete(mode); if (selected) policies.delete(mode);
const setup = setupProjectionResponse(url.pathname, mode, selected !== null); const setup = setupProjectionResponse(
url.pathname,
mode,
selected !== null,
tokenRequest,
);
if (setup) return setup; if (setup) return setup;
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-"); const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
const ordinary = mode.startsWith("ordinary"); const ordinary = mode.startsWith("ordinary");
if (url.pathname === "/api/auth/whoami") { if (url.pathname === "/api/auth/whoami") {
return Response.json({ return Response.json({
authentication_reference: anonymous ? null : "fixture-authentication",
session: !anonymous session: !anonymous
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { 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/workspaces") return Response.json([]);
if (url.pathname === "/api/hosting/access") { if (url.pathname === "/api/hosting/access") {
return Response.json({ is_admin: !ordinary && !anonymous }); return Response.json({ is_admin: !ordinary && !anonymous });
@@ -19,6 +19,7 @@ export function setupProjectionResponse(
path: string, path: string,
mode: string, mode: string,
reset: boolean, reset: boolean,
tokenRequest = false,
): Response | null { ): Response | null {
if (!mode.startsWith("setup-")) return null; if (!mode.startsWith("setup-")) return null;
if (reset) stages.delete(mode); if (reset) stages.delete(mode);
@@ -28,7 +29,17 @@ export function setupProjectionResponse(
}); });
} }
if (path === "/api/auth/whoami") { if (path === "/api/auth/whoami") {
if (tokenRequest) {
return Response.json({ 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" session: stages.get(mode) === "login"
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" } ? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
: { kind: "anonymous" }, : { kind: "anonymous" },
@@ -56,7 +67,10 @@ export function setupProjectionResponse(
} }
if (path === "/api/auth/passkeys/addition/complete") { if (path === "/api/auth/passkeys/addition/complete") {
stages.set(mode, "enrolled"); stages.set(mode, "enrolled");
return Response.json({ user }); return Response.json({
user,
authentication_reference: "fixture-authentication",
});
} }
if (path === "/api/auth/passkeys/login/options") { if (path === "/api/auth/passkeys/login/options") {
return Response.json({ return Response.json({
@@ -66,7 +80,10 @@ export function setupProjectionResponse(
} }
if (path === "/api/auth/passkeys/login/complete") { if (path === "/api/auth/passkeys/login/complete") {
stages.set(mode, "login"); stages.set(mode, "login");
return Response.json({ user }); return Response.json({
user,
authentication_reference: "fixture-authentication",
});
} }
return null; return null;
} }
+6 -1
View File
@@ -27,6 +27,7 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
display_name: "Alice", display_name: "Alice",
auth_method: "browser_session", auth_method: "browser_session",
}, },
authentication_reference: "fixture-authentication",
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }, session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
}); });
browserSession.invalidate(); browserSession.invalidate();
@@ -68,7 +69,11 @@ Deno.test("root admission never starts private catalog while anonymous, expired,
if (condition === "loading") await pending; if (condition === "loading") await pending;
return condition === "error" || condition === "loading" return condition === "error" || condition === "loading"
? new Response(null, { status: 503 }) ? 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; let privateCalls = 0;
const event = { const event = {
+56 -12
View File
@@ -168,35 +168,62 @@ for (const purpose of ["registration", "addition"] as const) {
await withBrowserBoundary(async (input, init) => { await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( 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) => { }, async (calls) => {
const user = purpose === "registration" const user = purpose === "registration"
? await registerPasskey("alice", "Alice") ? await registerPasskey("alice", "Alice")
: await addPasskey(); : await addPasskey();
assertEquals(user, authUser); assertEquals(user, {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 1, get: 0 }); assertEquals(calls, { create: 1, get: 0 });
}); });
assertEquals(requests.map(({ path }) => path), [ assertEquals(requests.map(({ path }) => path), [
...(purpose === "addition" ? ["/api/auth/whoami"] : []),
`/api/auth/passkeys/${purpose}/options`, `/api/auth/passkeys/${purpose}/options`,
`/api/auth/passkeys/${purpose}/complete`, `/api/auth/passkeys/${purpose}/complete`,
]); ]);
for (const { init } of requests) { for (const { path, init } of requests) {
assertEquals(init.method, "POST"); assertEquals(
init.method ?? "GET",
path.endsWith("/whoami") ? "GET" : "POST",
);
assertEquals(init.credentials, "same-origin"); assertEquals(init.credentials, "same-origin");
assertEquals(init.headers, { "content-type": "application/json" }); assertEquals(init.headers, { "content-type": "application/json" });
} }
const ceremony = requests.filter(({ path }) => !path.endsWith("/whoami"));
assertEquals( assertEquals(
JSON.parse(String(requests[0].init.body)), JSON.parse(String(ceremony[0].init.body)),
purpose === "registration" purpose === "registration"
? { ? {
handle: "alice", handle: "alice",
display_name: "Alice", display_name: "Alice",
browser_origin: "https://yoi.example", 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(Object.keys(complete).sort(), ["challenge_id", "credential"]);
assertEquals(complete.challenge_id, registrationOptions.challenge_id); assertEquals(complete.challenge_id, registrationOptions.challenge_id);
assertEquals(complete.credential.response.attestationObject, "AQID"); 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[] = []; const paths: string[] = [];
await withBrowserBoundary(async (input) => { await withBrowserBoundary(async (input) => {
paths.push(String(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) ? Response.json(registrationOptions)
: new Response(null, { status: 401 }); : new Response(null, { status: 401 });
}, async () => { }, async () => {
await assertRejects(addPasskey(), "Auth request failed (401)."); await assertRejects(addPasskey(), "Passkey complete: rejected (rejected).");
}); });
assertEquals(paths, [ assertEquals(paths, [
"/api/auth/whoami",
"/api/auth/passkeys/addition/options", "/api/auth/passkeys/addition/options",
"/api/auth/passkeys/addition/complete", "/api/auth/passkeys/addition/complete",
]); ]);
@@ -226,7 +261,10 @@ Deno.test("existing-handle registration rejection never starts WebAuthn or retri
paths.push(String(input)); paths.push(String(input));
return new Response(null, { status: 409 }); return new Response(null, { status: 409 });
}, async (calls) => { }, 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(calls, { create: 0, get: 0 });
}); });
assertEquals(paths, ["/api/auth/passkeys/registration/options"]); 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) => { await withBrowserBoundary(async (input, init) => {
requests.push({ path: String(input), init: init! }); requests.push({ path: String(input), init: init! });
return Response.json( return Response.json(
requests.length === 1 ? loginOptions : { user: authUser }, requests.length === 1 ? loginOptions : {
user: authUser,
authentication_reference: "fixture-authentication",
},
); );
}, async (calls) => { }, async (calls) => {
assertEquals(await loginWithLegacyPasskey(" alice "), authUser); assertEquals(await loginWithLegacyPasskey(" alice "), {
user: authUser,
authentication_reference: "fixture-authentication",
});
assertEquals(calls, { create: 0, get: 1 }); assertEquals(calls, { create: 0, get: 1 });
}); });
assertEquals(requests.map(({ path }) => path), [ assertEquals(requests.map(({ path }) => path), [