fix: integrate passkey results with current dogfooding shell
Preserve authenticated home/account navigation and guarded mobile sidebar swipes. Keep both browser task selections. Complete the new account fixture contract and synchronize lifecycle probes on visible registered-key results, without changing their success or security assertions.
This commit is contained in:
@@ -225,6 +225,10 @@ impl_openapi_schema!(
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeySummary,
|
||||
PasskeyListResponse,
|
||||
PasskeyAdditionResultRequest,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyRegistrationOptionsRequest,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -648,6 +652,33 @@ pub trait ServerApi {
|
||||
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
|
||||
|
||||
#[get(
|
||||
"/api/auth/passkeys",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn auth_passkey_list(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
) -> Result<PasskeyListResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
status = 200,
|
||||
error_status = 400,
|
||||
additional_error_statuses = [403, 500],
|
||||
bearer_auth = true,
|
||||
browser_auth = true
|
||||
)]
|
||||
async fn auth_passkey_addition_result(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: PasskeyAdditionResultRequest,
|
||||
) -> Result<PasskeyAdditionResultResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/passkeys/addition/options",
|
||||
status = 200,
|
||||
@@ -4115,6 +4146,8 @@ impl RequestActor {
|
||||
pub struct WhoamiResponse {
|
||||
pub actor: Option<RequestActor>,
|
||||
pub session: BrowserSessionState,
|
||||
/// Non-secret comparison evidence, never an authentication credential.
|
||||
pub authentication_reference: Option<String>,
|
||||
}
|
||||
|
||||
/// Browser navigation evidence, not an authorization grant or a generic 401 classification.
|
||||
@@ -4142,6 +4175,8 @@ pub struct AuthBootstrapUserRequest {
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AuthUserResponse {
|
||||
pub user: AuthenticatedUser,
|
||||
/// Comparison evidence for the exact newly issued session, not a credential.
|
||||
pub authentication_reference: String,
|
||||
}
|
||||
|
||||
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
|
||||
@@ -4158,16 +4193,64 @@ pub struct PasskeyRegistrationOptionsRequest {
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
/// Adds a credential to the current authenticated actor; target identity is never public input.
|
||||
/// Adds to the current actor. Expected identity/binding fields only fence stale UI;
|
||||
/// they never select an owner or authorize an addition.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyAdditionOptionsRequest {
|
||||
pub expected_user_id: String,
|
||||
pub expected_account_id: String,
|
||||
pub expected_authentication_reference: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
/// Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.
|
||||
/// Normal-login verification is observed verifier success, not inferred discoverability.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeySummary {
|
||||
pub key_reference: String,
|
||||
pub created_at: String,
|
||||
pub last_used_at: Option<String>,
|
||||
pub normal_login_verified_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyListResponse {
|
||||
pub user: AuthenticatedUser,
|
||||
pub keys: Vec<PasskeySummary>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PasskeyAdditionResultRequest {
|
||||
pub challenge_id: String,
|
||||
pub expected_user_id: String,
|
||||
pub expected_account_id: String,
|
||||
/// Original options authentication reference; the current same-owner session may differ.
|
||||
pub expected_authentication_reference: String,
|
||||
}
|
||||
|
||||
/// Unknown is not proof that registration failed: completion may still be in flight.
|
||||
/// Saved proves the credential commit, not session issuance or current session health.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PasskeyAdditionResultResponse {
|
||||
Unknown {},
|
||||
Saved {
|
||||
user: AuthenticatedUser,
|
||||
key: PasskeySummary,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
@@ -11642,6 +11725,10 @@ pub fn auth_api_typescript() -> String {
|
||||
AuthBootstrapUserRequest::decl(&config),
|
||||
AuthUserResponse::decl(&config),
|
||||
PasskeyAdditionOptionsRequest::decl(&config),
|
||||
PasskeySummary::decl(&config),
|
||||
PasskeyListResponse::decl(&config),
|
||||
PasskeyAdditionResultRequest::decl(&config),
|
||||
PasskeyAdditionResultResponse::decl(&config),
|
||||
PasskeyRegistrationOptionsRequest::decl(&config),
|
||||
PasskeyRegistrationOptionsResponse::decl(&config),
|
||||
PasskeyRegistrationCompleteRequest::decl(&config),
|
||||
@@ -14069,7 +14156,8 @@ mod tests {
|
||||
"display_name": "Hare",
|
||||
"auth_method": "browser_session"
|
||||
},
|
||||
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" }
|
||||
"session": { "kind": "authenticated", "expires_at": "2026-10-20T00:00:00Z" },
|
||||
"authentication_reference": "synthetic-session-reference"
|
||||
});
|
||||
let decoded = serde_json::from_value::<WhoamiResponse>(whoami.clone())
|
||||
.expect("server whoami fixture should match shared DTO");
|
||||
@@ -14091,7 +14179,8 @@ mod tests {
|
||||
"account_id": "account-1",
|
||||
"handle": "hare",
|
||||
"display_name": "Hare"
|
||||
}
|
||||
},
|
||||
"authentication_reference": "synthetic-session-reference"
|
||||
});
|
||||
let decoded = serde_json::from_value::<AuthUserResponse>(auth_user.clone())
|
||||
.expect("server auth-user fixture should match shared DTO");
|
||||
@@ -14264,7 +14353,7 @@ mod tests {
|
||||
serde_json::json!({"kind":"expired"}),
|
||||
serde_json::json!({"kind":"authenticated", "expires_at":null}),
|
||||
] {
|
||||
let value = serde_json::json!({"actor":null, "session":session});
|
||||
let value = serde_json::json!({"actor":null, "session":session, "authentication_reference": null});
|
||||
let response: WhoamiResponse = serde_json::from_value(value.clone()).unwrap();
|
||||
assert_eq!(serde_json::to_value(response).unwrap(), value);
|
||||
}
|
||||
@@ -14802,10 +14891,28 @@ mod openapi_artifact_tests {
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
|
||||
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_addition_requires_expected_owner_and_binding() {
|
||||
let expected = serde_json::json!({
|
||||
"expected_user_id": "user-a", "expected_account_id": "account-a",
|
||||
"expected_authentication_reference": "reference"
|
||||
});
|
||||
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(expected.clone()).is_ok());
|
||||
for field in [
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference",
|
||||
] {
|
||||
let mut missing = expected.clone();
|
||||
missing.as_object_mut().unwrap().remove(field);
|
||||
assert!(serde_json::from_value::<PasskeyAdditionOptionsRequest>(missing).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
|
||||
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
|
||||
|
||||
@@ -476,7 +476,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into())
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into())
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
|
||||
@@ -228,13 +228,21 @@ fn resolve_whoami_sync(
|
||||
use server_api::{BrowserSessionState, WhoamiResponse};
|
||||
let anonymous = || WhoamiResponse {
|
||||
actor: None,
|
||||
authentication_reference: None,
|
||||
session: BrowserSessionState::Anonymous {},
|
||||
};
|
||||
// Preserve the existing API-token precedence and fallback rules, including expired tokens.
|
||||
if let Some((actor, expires_at)) =
|
||||
resolve_credentials_sync(store, bearer, session_token.clone())?
|
||||
resolve_credentials_sync(store, bearer.clone(), session_token.clone())?
|
||||
{
|
||||
let credential = match actor.auth_method {
|
||||
ActorAuthMethod::ApiToken => bearer.as_deref(),
|
||||
ActorAuthMethod::BrowserSession => session_token.as_deref(),
|
||||
}
|
||||
.ok_or_else(|| auth_error("auth_required", "authentication binding missing"))?;
|
||||
let authentication_reference = Some(authentication_reference(&token_hash(credential)));
|
||||
return Ok(WhoamiResponse {
|
||||
authentication_reference,
|
||||
actor: Some(actor),
|
||||
session: BrowserSessionState::Authenticated { expires_at },
|
||||
});
|
||||
@@ -255,6 +263,7 @@ fn resolve_whoami_sync(
|
||||
if within_session_expiry_grace(&session.expires_at, Utc::now()) {
|
||||
return Ok(WhoamiResponse {
|
||||
actor: None,
|
||||
authentication_reference: None,
|
||||
session: BrowserSessionState::Expired {},
|
||||
});
|
||||
}
|
||||
@@ -262,6 +271,13 @@ fn resolve_whoami_sync(
|
||||
Ok(anonymous())
|
||||
}
|
||||
|
||||
/// Domain-separated public comparison value; never accepted as a credential.
|
||||
pub(crate) fn authentication_reference(credential_hash: &str) -> String {
|
||||
token_hash(&format!(
|
||||
"passkey-authentication-reference:{credential_hash}"
|
||||
))
|
||||
}
|
||||
|
||||
fn within_session_expiry_grace(expires_at: &str, now: DateTime<Utc>) -> bool {
|
||||
DateTime::parse_from_rfc3339(expires_at).is_ok_and(|expires_at| {
|
||||
expires_at <= now && now < expires_at + Duration::seconds(SESSION_EXPIRY_GRACE_SECONDS)
|
||||
|
||||
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
DROP TABLE ticket_implementation_results;
|
||||
DROP TABLE ticket_implementation_delegations;
|
||||
DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
@@ -517,6 +521,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
|
||||
// Reconstruct the prior composition before its new business capture existed.
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
@@ -679,7 +687,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
if historical {
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
DROP TRIGGER passkey_metadata_insert;
|
||||
DROP TABLE passkey_addition_receipts;
|
||||
DROP TABLE passkey_metadata;
|
||||
UPDATE __yoi_schema_migrations SET version=102 WHERE version=103;
|
||||
DROP TABLE subject_create_recoveries;
|
||||
UPDATE __yoi_schema_migrations SET version=101 WHERE version=102;
|
||||
DROP TABLE subject_worker_releases;
|
||||
UPDATE __yoi_schema_migrations SET version=100 WHERE version=101;
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE passkey_metadata (
|
||||
credential_id TEXT PRIMARY KEY REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
|
||||
key_reference TEXT NOT NULL UNIQUE,
|
||||
normal_login_verified_at TEXT
|
||||
);
|
||||
INSERT INTO passkey_metadata(credential_id, key_reference)
|
||||
SELECT credential_id, lower(hex(randomblob(16))) FROM passkey_credentials;
|
||||
CREATE TRIGGER passkey_metadata_insert AFTER INSERT ON passkey_credentials
|
||||
BEGIN
|
||||
INSERT INTO passkey_metadata(credential_id, key_reference)
|
||||
VALUES (NEW.credential_id, lower(hex(randomblob(16))));
|
||||
END;
|
||||
CREATE TABLE passkey_addition_receipts (
|
||||
challenge_id TEXT PRIMARY KEY,
|
||||
credential_id TEXT NOT NULL UNIQUE REFERENCES passkey_credentials(credential_id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
authentication_reference TEXT NOT NULL
|
||||
);
|
||||
@@ -6970,6 +6970,12 @@ fn generated_auth_contract_router(service: ServerApiContractService) -> Router {
|
||||
.merge(server_api::server_api_axum::hosting_settings_update(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_list(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_addition_result(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::auth_passkey_addition_options(
|
||||
service.clone(),
|
||||
))
|
||||
@@ -8179,6 +8185,65 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
)
|
||||
}
|
||||
|
||||
async fn auth_passkey_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
) -> std::result::Result<server_api::PasskeyListResponse, server_api::RepositoryApiError> {
|
||||
let headers = contract_request_headers(&context)?;
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, _) = passkey_owner_authority(&api, &headers, false)?;
|
||||
Ok::<_, ApiError>(server_api::PasskeyListResponse {
|
||||
keys: api.store.list_passkey_summaries(&user.user_id)?,
|
||||
user: user_response(user),
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn auth_passkey_addition_result(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
request: server_api::PasskeyAdditionResultRequest,
|
||||
) -> std::result::Result<
|
||||
server_api::PasskeyAdditionResultResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let headers = contract_request_headers(&context)?;
|
||||
let api = self.auth_api();
|
||||
api.store
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, _) = passkey_addition_authority(&api, &headers)?;
|
||||
if user.user_id != request.expected_user_id
|
||||
|| user.account_id != request.expected_account_id
|
||||
{
|
||||
return Err(auth_error(
|
||||
"passkey_authentication_changed",
|
||||
"current authenticated owner does not match the operation owner",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let key = api.store.read_passkey_addition_result(
|
||||
&user.user_id,
|
||||
&request.expected_authentication_reference,
|
||||
&request.challenge_id,
|
||||
)?;
|
||||
Ok::<_, ApiError>(match key {
|
||||
Some(key) => server_api::PasskeyAdditionResultResponse::Saved {
|
||||
user: user_response(user),
|
||||
key,
|
||||
},
|
||||
None => server_api::PasskeyAdditionResultResponse::Unknown {},
|
||||
})
|
||||
})
|
||||
.await
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn auth_passkey_addition_options(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -8191,6 +8256,13 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.db_executor()
|
||||
.run(move || {
|
||||
let (user, authentication) = passkey_addition_authority(&api, &headers)?;
|
||||
require_expected_passkey_owner(
|
||||
&user,
|
||||
&authentication,
|
||||
&request.expected_user_id,
|
||||
&request.expected_account_id,
|
||||
&request.expected_authentication_reference,
|
||||
)?;
|
||||
start_passkey_registration(
|
||||
&api,
|
||||
&headers,
|
||||
@@ -30161,6 +30233,14 @@ async fn post_passkey_registration_options(
|
||||
fn passkey_addition_authority(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
|
||||
passkey_owner_authority(api, headers, true)
|
||||
}
|
||||
|
||||
fn passkey_owner_authority(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
require_origin: bool,
|
||||
) -> ApiResult<(UserRecord, PasskeyAuthenticationBinding)> {
|
||||
let auth = auth_public_config(&api.config);
|
||||
let actor = crate::auth::resolve_request_actor_sync(
|
||||
@@ -30176,7 +30256,7 @@ fn passkey_addition_authority(
|
||||
})?;
|
||||
let binding = match actor.auth_method {
|
||||
ActorAuthMethod::BrowserSession => {
|
||||
if request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
|
||||
if require_origin && request_origin(headers).as_deref() != Some(auth.origin.as_str()) {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"cookie-authenticated passkey addition requires the configured Browser origin"
|
||||
.into(),
|
||||
@@ -30208,6 +30288,26 @@ fn passkey_addition_authority(
|
||||
Ok((user, binding))
|
||||
}
|
||||
|
||||
fn require_expected_passkey_owner(
|
||||
user: &UserRecord,
|
||||
binding: &PasskeyAuthenticationBinding,
|
||||
expected_user_id: &str,
|
||||
expected_account_id: &str,
|
||||
expected_authentication_reference: &str,
|
||||
) -> ApiResult<()> {
|
||||
if user.user_id != expected_user_id
|
||||
|| user.account_id != expected_account_id
|
||||
|| binding.reference() != expected_authentication_reference
|
||||
{
|
||||
return Err(auth_error(
|
||||
"passkey_authentication_changed",
|
||||
"authentication changed; refresh account before starting another operation",
|
||||
)
|
||||
.into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn start_passkey_registration(
|
||||
api: &ServerAuthApi,
|
||||
headers: &HeaderMap,
|
||||
@@ -30289,7 +30389,11 @@ fn start_passkey_registration(
|
||||
/// Credential commit precedes session issuance. If issuing the session or
|
||||
/// delivering the response fails, the credential can already exist: log in with
|
||||
/// that key rather than overwriting it or replaying the consumed completion.
|
||||
/// After an unknown commit outcome, try login first. Restart new-account
|
||||
/// Own additions save a receipt atomically with the credential: a currently
|
||||
/// authenticated same owner can inspect it using the original operation binding
|
||||
/// reference even if the response body was lost after Set-Cookie took effect.
|
||||
/// This read does not authorize a completion or prove current session health.
|
||||
/// For unknown new-account outcomes, try login first. Restart new-account
|
||||
/// enrollment with fresh options only for an unused handle with no persisted
|
||||
/// identity; legacy pre-created identities follow the migration policy on
|
||||
/// [`PasskeyRegistrationEnvelope`]. Neither recovery path requires DB edits.
|
||||
@@ -30313,6 +30417,7 @@ fn complete_passkey_enrollment(
|
||||
request: PasskeyRegistrationCompleteRequest,
|
||||
completion: EnrollmentCompletion<'_>,
|
||||
) -> ApiResult<(AuthUserResponse, server_api::SetCookieHeader)> {
|
||||
let is_addition = matches!(&completion, EnrollmentCompletion::Addition(_));
|
||||
let authority = match completion {
|
||||
EnrollmentCompletion::Addition(headers) => Some(passkey_addition_authority(api, headers)?),
|
||||
_ => None,
|
||||
@@ -30398,7 +30503,7 @@ fn complete_passkey_enrollment(
|
||||
&error.to_string(),
|
||||
)
|
||||
})?;
|
||||
api.store.register_verified_passkey(
|
||||
api.store.register_verified_passkey_with_result(
|
||||
&envelope.target,
|
||||
&PasskeyCredentialRecord {
|
||||
credential_id: passkey_credential_id(&passkey)?,
|
||||
@@ -30411,6 +30516,7 @@ fn complete_passkey_enrollment(
|
||||
created_at: crate::auth::now_rfc3339(),
|
||||
last_used_at: None,
|
||||
},
|
||||
is_addition.then_some(request.challenge_id.as_str()),
|
||||
)?;
|
||||
issue_browser_session(api, user)
|
||||
}
|
||||
@@ -30580,6 +30686,7 @@ fn complete_passkey_login(
|
||||
.into());
|
||||
}
|
||||
let webauthn = webauthn_for_challenge(&api.config, &challenge)?;
|
||||
let normal_login = matches!(state, PasskeyLoginState::Discoverable(_));
|
||||
let auth_result = match state {
|
||||
PasskeyLoginState::Discoverable(state) => {
|
||||
// userHandle is unsigned routing data, never identity authority. The
|
||||
@@ -30635,7 +30742,7 @@ fn complete_passkey_login(
|
||||
.map_err(|error| auth_error("webauthn_state_serialize_failed", &error.to_string()))?;
|
||||
api.store
|
||||
.upsert_passkey_credential(&PasskeyCredentialRecord {
|
||||
credential_id,
|
||||
credential_id: credential_id.clone(),
|
||||
user_id: user.user_id.clone(),
|
||||
public_key_cose,
|
||||
transports_json: stored.transports_json,
|
||||
@@ -30643,6 +30750,10 @@ fn complete_passkey_login(
|
||||
created_at: stored.created_at,
|
||||
last_used_at: Some(crate::auth::now_rfc3339()),
|
||||
})?;
|
||||
if normal_login {
|
||||
api.store
|
||||
.mark_passkey_normal_login_verified(&credential_id, &crate::auth::now_rfc3339())?;
|
||||
}
|
||||
issue_browser_session(api, user)
|
||||
}
|
||||
|
||||
@@ -30668,6 +30779,9 @@ fn issue_browser_session(
|
||||
Ok((
|
||||
AuthUserResponse {
|
||||
user: user_response(user),
|
||||
authentication_reference: crate::auth::authentication_reference(&token_hash(
|
||||
&session_token,
|
||||
)),
|
||||
},
|
||||
server_api::SetCookieHeader::new(cookie),
|
||||
))
|
||||
@@ -52401,6 +52515,7 @@ mod tests {
|
||||
|
||||
include!("server_activation_tests.rs");
|
||||
include!("server_discoverable_tests.rs");
|
||||
include!("server_passkey_results_tests.rs");
|
||||
|
||||
const ORIGIN_URL: &str = "https://workspace.test";
|
||||
type Authenticator = ResidentAuthenticator;
|
||||
@@ -52454,9 +52569,61 @@ mod tests {
|
||||
origin: Option<&str>,
|
||||
body: Value,
|
||||
) -> (StatusCode, HeaderMap, Value) {
|
||||
let mut body = body;
|
||||
if path == "/api/auth/passkeys/addition/options"
|
||||
&& body.get("expected_user_id").is_none()
|
||||
{
|
||||
let expected = self.expected_owner(cookie, bearer).await;
|
||||
for (field, value) in expected.as_object().unwrap() {
|
||||
body[field] = value.clone();
|
||||
}
|
||||
}
|
||||
Self::post_to(self.app.clone(), path, cookie, bearer, origin, body).await
|
||||
}
|
||||
|
||||
async fn get_authenticated(
|
||||
&self,
|
||||
path: &str,
|
||||
cookie: Option<&str>,
|
||||
bearer: Option<&str>,
|
||||
) -> (StatusCode, Value) {
|
||||
let mut request = Request::builder().uri(path);
|
||||
if let Some(cookie) = cookie {
|
||||
request = request.header(axum::http::header::COOKIE, cookie);
|
||||
}
|
||||
if let Some(bearer) = bearer {
|
||||
request = request.header(
|
||||
axum::http::header::AUTHORIZATION,
|
||||
format!("Bearer {bearer}"),
|
||||
);
|
||||
}
|
||||
let response = self
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
|
||||
let status = response.status();
|
||||
let body = serde_json::from_slice(
|
||||
&to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
(status, body)
|
||||
}
|
||||
|
||||
async fn expected_owner(&self, cookie: Option<&str>, bearer: Option<&str>) -> Value {
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", cookie, bearer)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
serde_json::json!({
|
||||
"expected_user_id": whoami["actor"]["user_id"].as_str().unwrap_or("absent"),
|
||||
"expected_account_id": whoami["actor"]["account_id"].as_str().unwrap_or("absent"),
|
||||
"expected_authentication_reference": whoami["authentication_reference"].as_str().unwrap_or("absent"),
|
||||
})
|
||||
}
|
||||
|
||||
async fn post_to(
|
||||
app: Router,
|
||||
path: &str,
|
||||
@@ -52612,6 +52779,14 @@ mod tests {
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body["authentication_reference"] == whoami["authentication_reference"],
|
||||
"completion must identify its exact issued session"
|
||||
);
|
||||
(
|
||||
cookie,
|
||||
serde_json::from_value::<AuthUserResponse>(body)
|
||||
@@ -52699,6 +52874,14 @@ mod tests {
|
||||
.next()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let (status, whoami) = self
|
||||
.get_authenticated("/api/auth/whoami", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(
|
||||
body["authentication_reference"] == whoami["authentication_reference"],
|
||||
"login completion must identify its exact issued session"
|
||||
);
|
||||
(
|
||||
cookie,
|
||||
serde_json::from_value::<AuthUserResponse>(body)
|
||||
@@ -53222,6 +53405,9 @@ mod tests {
|
||||
.auth_passkey_addition_options(
|
||||
context,
|
||||
server_api::PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: user.user_id.clone(),
|
||||
expected_account_id: user.account_id.clone(),
|
||||
expected_authentication_reference: "not-authority".into(),
|
||||
browser_origin: Some(ORIGIN_URL.into()),
|
||||
},
|
||||
)
|
||||
@@ -53236,6 +53422,9 @@ mod tests {
|
||||
.auth_passkey_addition_options(
|
||||
context,
|
||||
server_api::PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: user.user_id.clone(),
|
||||
expected_account_id: user.account_id.clone(),
|
||||
expected_authentication_reference: "not-authority".into(),
|
||||
browser_origin: Some(ORIGIN_URL.into()),
|
||||
},
|
||||
)
|
||||
@@ -54241,7 +54430,7 @@ mod tests {
|
||||
.header(axum::http::header::COOKIE, &marker)
|
||||
.header(ORIGIN, &auth.origin)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.body(Body::from(r#"{"expected_user_id":"absent","expected_account_id":"absent","expected_authentication_reference":"absent"}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
// Integration boundary: generated HTTP admission -> real WebAuthn verifier ->
|
||||
// SQLite credential/receipt transaction -> real session issuance. Only the
|
||||
// authenticator is synthetic; failures are SQLite triggers, not success fakes.
|
||||
|
||||
impl Fixture {
|
||||
async fn addition_result(&self, cookie: &str, challenge_id: &str) -> (StatusCode, Value) {
|
||||
let mut request = self.expected_owner(Some(cookie), None).await;
|
||||
request["challenge_id"] = challenge_id.into();
|
||||
let (status, _, body) = self
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
(status, body)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn addition_receipt_is_atomic_and_recovers_credential_commit_not_session_success() {
|
||||
for failure in [
|
||||
"before_credential",
|
||||
"receipt_insert",
|
||||
"session_insert",
|
||||
"lost_response",
|
||||
"lost_body_accepted_cookie",
|
||||
] {
|
||||
let f = Fixture::new().await;
|
||||
let mut original = authenticator();
|
||||
let (cookie, owner) = f.enroll("registration", None, "owner", &mut original).await;
|
||||
let original_expectation = f.expected_owner(Some(&cookie), None).await;
|
||||
let options = f.options("addition", Some(&cookie), "").await;
|
||||
let challenge_id = options.challenge_id.clone();
|
||||
let mut added = authenticator();
|
||||
let request = Fixture::credential(options, &mut added);
|
||||
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "unknown");
|
||||
let table = match failure {
|
||||
"before_credential" => Some("passkey_credentials"),
|
||||
"receipt_insert" => Some("passkey_addition_receipts"),
|
||||
"session_insert" => Some("browser_sessions"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(table) = table {
|
||||
f.api.config_store.with_conn(|conn| {
|
||||
conn.execute_batch(&format!("CREATE TRIGGER inject_passkey_failure BEFORE INSERT ON {table} BEGIN SELECT RAISE(ABORT, 'synthetic failure'); END;"))?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
// Discard the completion body; one variant still accepts Set-Cookie.
|
||||
let (status, headers, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
if matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
}
|
||||
);
|
||||
if !matches!(failure, "lost_response" | "lost_body_accepted_cookie") {
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
}
|
||||
let saved = matches!(
|
||||
failure,
|
||||
"session_insert" | "lost_response" | "lost_body_accepted_cookie"
|
||||
);
|
||||
if failure == "lost_body_accepted_cookie" {
|
||||
let accepted_cookie = headers[axum::http::header::SET_COOKIE]
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.split(';')
|
||||
.next()
|
||||
.unwrap();
|
||||
let current = f.expected_owner(Some(accepted_cookie), None).await;
|
||||
assert!(
|
||||
current["expected_authentication_reference"]
|
||||
!= original_expectation["expected_authentication_reference"],
|
||||
"addition issued a distinct session"
|
||||
);
|
||||
let mut recovery = original_expectation.clone();
|
||||
recovery["challenge_id"] = challenge_id.clone().into();
|
||||
let (status, _, result) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(accepted_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "saved");
|
||||
assert_eq!(result["user"]["user_id"], owner.user_id);
|
||||
}
|
||||
let (status, result) = f.addition_result(&cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], if saved { "saved" } else { "unknown" });
|
||||
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
|
||||
let receipt_count = f
|
||||
.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
Ok(conn.query_row(
|
||||
"SELECT COUNT(*) FROM passkey_addition_receipts",
|
||||
[],
|
||||
|row| row.get::<_, u64>(0),
|
||||
)?)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(receipt_count, u64::from(saved));
|
||||
if saved {
|
||||
assert_eq!(result["user"]["user_id"], owner.user_id);
|
||||
let (_, listed) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
let reference = result["key"]["key_reference"].as_str().unwrap();
|
||||
assert!(
|
||||
listed["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|key| key["key_reference"] == reference)
|
||||
);
|
||||
assert!(result["key"]["normal_login_verified_at"].is_null());
|
||||
}
|
||||
if table.is_some() {
|
||||
f.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER inject_passkey_failure")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
// Consumed completion is not retry authority, even after a transaction failure.
|
||||
let (status, headers, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
assert_eq!(f.credentials(&owner).len(), if saved { 2 } else { 1 });
|
||||
if saved {
|
||||
assert_eq!(f.discoverable_login(&mut added).await, owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stale_displayed_owner_or_session_cannot_start_addition_or_read_another_binding_result() {
|
||||
let f = Fixture::new().await;
|
||||
let mut alice_key = authenticator();
|
||||
let (alice_cookie, alice) = f
|
||||
.enroll("registration", None, "alice", &mut alice_key)
|
||||
.await;
|
||||
let (bob_cookie, _) = f
|
||||
.enroll("registration", None, "bob", &mut authenticator())
|
||||
.await;
|
||||
let expected = f.expected_owner(Some(&alice_cookie), None).await;
|
||||
let (replacement_cookie, _) = f.login_session("alice", &mut alice_key).await;
|
||||
let count_challenges = || {
|
||||
f.api
|
||||
.config_store
|
||||
.with_conn(|conn| {
|
||||
Ok(
|
||||
conn.query_row("SELECT COUNT(*) FROM auth_challenges", [], |row| {
|
||||
row.get::<_, u64>(0)
|
||||
})?,
|
||||
)
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
let before = count_challenges();
|
||||
for cookie in [&bob_cookie, &replacement_cookie] {
|
||||
let (status, _, _) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/options",
|
||||
Some(cookie),
|
||||
Some(ORIGIN_URL),
|
||||
expected.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
count_challenges(),
|
||||
before,
|
||||
"stale options must not persist a ceremony"
|
||||
);
|
||||
}
|
||||
let options = f.options("addition", Some(&alice_cookie), "").await;
|
||||
let challenge_id = options.challenge_id.clone();
|
||||
let request = Fixture::credential(options, &mut authenticator());
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
f.addition_result(&alice_cookie, &challenge_id).await.1["status"],
|
||||
"saved"
|
||||
);
|
||||
// Current owner authorization is separate from original operation binding.
|
||||
let mut recovery = expected.clone();
|
||||
recovery["challenge_id"] = challenge_id.clone().into();
|
||||
let (status, _, result) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&replacement_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result["status"], "saved");
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&bob_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
recovery.clone()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
recovery["expected_authentication_reference"] = "changed-original-reference".into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&replacement_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
recovery
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
// Bob's own expected fields cannot discover Alice's operation either.
|
||||
let (status, result) = f.addition_result(&bob_cookie, &challenge_id).await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(result, serde_json::json!({"status":"unknown"}));
|
||||
assert_eq!(f.credentials(&alice).len(), 2);
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/logout",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::json!({})
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::OK
|
||||
);
|
||||
let mut request = expected;
|
||||
request["challenge_id"] = challenge_id.into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/addition/result",
|
||||
Some(&alice_cookie),
|
||||
Some(ORIGIN_URL),
|
||||
request
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn own_key_list_is_safe_and_only_verified_normal_login_marks_confirmation() {
|
||||
let f = Fixture::new().await;
|
||||
let mut key = authenticator();
|
||||
let (cookie, owner) = f.enroll("registration", None, "owner", &mut key).await;
|
||||
let (other_cookie, _) = f
|
||||
.enroll("registration", None, "other", &mut authenticator())
|
||||
.await;
|
||||
assert_eq!(
|
||||
f.get_authenticated("/api/auth/passkeys", None, None)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let (status, list) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(list["user"]["user_id"], owner.user_id);
|
||||
assert_eq!(list["keys"].as_array().unwrap().len(), 1);
|
||||
let summary = &list["keys"][0];
|
||||
assert!(summary["normal_login_verified_at"].is_null());
|
||||
assert!(summary["last_used_at"].is_null());
|
||||
let reference = summary["key_reference"].as_str().unwrap();
|
||||
assert_eq!(reference.len(), 32);
|
||||
assert_ne!(reference, f.credentials(&owner)[0].credential_id);
|
||||
assert_eq!(
|
||||
summary
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>(),
|
||||
[
|
||||
"key_reference",
|
||||
"created_at",
|
||||
"last_used_at",
|
||||
"normal_login_verified_at"
|
||||
]
|
||||
.into_iter()
|
||||
.collect()
|
||||
);
|
||||
let (_, other) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&other_cookie), None)
|
||||
.await;
|
||||
assert_ne!(other["keys"][0]["key_reference"], reference);
|
||||
assert_eq!(f.login("owner", &mut key).await, owner);
|
||||
let (_, legacy) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert!(legacy["keys"][0]["last_used_at"].is_string());
|
||||
assert!(legacy["keys"][0]["normal_login_verified_at"].is_null());
|
||||
// Rejected normal assertion does not produce confirmation metadata.
|
||||
let mut invalid = f.discoverable_request(&mut key).await;
|
||||
invalid.credential.response.signature = vec![0u8; 64].into();
|
||||
assert_eq!(
|
||||
f.post(
|
||||
"/api/auth/passkeys/login/complete",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::to_value(invalid).unwrap()
|
||||
)
|
||||
.await
|
||||
.0,
|
||||
StatusCode::BAD_REQUEST
|
||||
);
|
||||
assert!(
|
||||
f.api.store.list_passkey_summaries(&owner.user_id).unwrap()[0]
|
||||
.normal_login_verified_at
|
||||
.is_none()
|
||||
);
|
||||
assert_eq!(f.discoverable_login(&mut key).await, owner);
|
||||
let (_, confirmed) = f
|
||||
.get_authenticated("/api/auth/passkeys", Some(&cookie), None)
|
||||
.await;
|
||||
assert_eq!(confirmed["keys"][0]["key_reference"], reference);
|
||||
assert!(confirmed["keys"][0]["normal_login_verified_at"].is_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unknown_transport_metadata_does_not_discard_verified_registration() {
|
||||
let f = Fixture::new().await;
|
||||
let options = f.options("registration", None, "future-transport").await;
|
||||
let mut key = authenticator();
|
||||
let mut request = Fixture::credential(options, &mut key);
|
||||
request["credential"]["response"]["transports"] =
|
||||
serde_json::json!(["internal", "future-transport", "test"]);
|
||||
let (status, _, body) = f
|
||||
.post(
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
request,
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
let user = serde_json::from_value::<AuthUserResponse>(body)
|
||||
.unwrap()
|
||||
.user;
|
||||
assert_eq!(f.discoverable_login(&mut key).await, user);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 102;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 103;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -437,6 +437,11 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Durable failed Subject creation recovery",
|
||||
apply: migrate_subject_create_recovery_v101_to_v102,
|
||||
},
|
||||
Migration {
|
||||
version: 103,
|
||||
name: "Passkey metadata and atomic addition results",
|
||||
apply: migrate_passkey_results_v102_to_v103,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -749,6 +754,15 @@ pub enum PasskeyAuthenticationBinding {
|
||||
ApiToken(String),
|
||||
}
|
||||
|
||||
impl PasskeyAuthenticationBinding {
|
||||
pub(crate) fn reference(&self) -> String {
|
||||
let hash = match self {
|
||||
Self::BrowserSession(hash) | Self::ApiToken(hash) => hash,
|
||||
};
|
||||
crate::auth::authentication_reference(hash)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PasskeyCredentialRecord {
|
||||
pub credential_id: String,
|
||||
@@ -1713,6 +1727,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
) -> Result<()> {
|
||||
self.register_verified_passkey_with_result(target, credential, None)
|
||||
}
|
||||
/// Receipt and credential commit together; no receipt claims session issuance.
|
||||
fn register_verified_passkey_with_result(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
addition_challenge_id: Option<&str>,
|
||||
) -> Result<()>;
|
||||
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>>;
|
||||
fn read_passkey_addition_result(
|
||||
&self,
|
||||
user_id: &str,
|
||||
original_authentication_reference: &str,
|
||||
challenge_id: &str,
|
||||
) -> Result<Option<server_api::PasskeySummary>>;
|
||||
fn mark_passkey_normal_login_verified(
|
||||
&self,
|
||||
credential_id: &str,
|
||||
verified_at: &str,
|
||||
) -> Result<()>;
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
|
||||
fn get_passkey_credential(
|
||||
@@ -6972,10 +7007,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn register_verified_passkey(
|
||||
fn register_verified_passkey_with_result(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
addition_challenge_id: Option<&str>,
|
||||
) -> Result<()> {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let user = match target {
|
||||
@@ -7042,6 +7078,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
|
||||
)?;
|
||||
if let Some(challenge_id) = addition_challenge_id {
|
||||
let PasskeyRegistrationTarget::Addition { user, authentication } = target else {
|
||||
return Err(crate::auth::auth_error("passkey_target_mismatch", "receipt requires an addition"));
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO passkey_addition_receipts(challenge_id, credential_id, user_id, authentication_reference) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![challenge_id, credential.credential_id, user.user_id, authentication.reference()],
|
||||
)?;
|
||||
}
|
||||
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
|
||||
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
|
||||
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
|
||||
@@ -7051,6 +7096,57 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn list_passkey_summaries(&self, user_id: &str) -> Result<Vec<server_api::PasskeySummary>> {
|
||||
self.with_conn(|conn| {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at
|
||||
FROM passkey_credentials c JOIN passkey_metadata m USING(credential_id)
|
||||
WHERE c.user_id = ?1 ORDER BY c.created_at, m.key_reference",
|
||||
)?;
|
||||
let rows = stmt.query_map([user_id], read_passkey_summary)?;
|
||||
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn read_passkey_addition_result(
|
||||
&self,
|
||||
user_id: &str,
|
||||
original_authentication_reference: &str,
|
||||
challenge_id: &str,
|
||||
) -> Result<Option<server_api::PasskeySummary>> {
|
||||
self.with_conn(|conn| {
|
||||
// Current authenticated ownership authorizes reading. The original
|
||||
// binding reference identifies the saved operation, not the current
|
||||
// session: Set-Cookie may have arrived even if its body was lost.
|
||||
let result = conn.query_row(
|
||||
"SELECT m.key_reference, c.created_at, c.last_used_at, m.normal_login_verified_at, r.authentication_reference
|
||||
FROM passkey_addition_receipts r
|
||||
JOIN passkey_credentials c USING(credential_id)
|
||||
JOIN passkey_metadata m USING(credential_id)
|
||||
WHERE r.challenge_id = ?1 AND r.user_id = ?2 AND c.user_id = ?2",
|
||||
params![challenge_id, user_id],
|
||||
|row| Ok((read_passkey_summary(row)?, row.get::<_, String>(4)?)),
|
||||
).optional()?;
|
||||
match result {
|
||||
Some((key, reference)) if reference == original_authentication_reference => Ok(Some(key)),
|
||||
Some(_) => Err(crate::auth::auth_error("passkey_result_binding_mismatch", "original operation authentication reference does not match")),
|
||||
None => Ok(None),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn mark_passkey_normal_login_verified(
|
||||
&self,
|
||||
credential_id: &str,
|
||||
verified_at: &str,
|
||||
) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute("UPDATE passkey_metadata SET normal_login_verified_at = ?2 WHERE credential_id = ?1", params![credential_id, verified_at])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
@@ -11911,6 +12007,15 @@ fn passkey_select_sql(where_clause: &str) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
fn read_passkey_summary(row: &rusqlite::Row<'_>) -> rusqlite::Result<server_api::PasskeySummary> {
|
||||
Ok(server_api::PasskeySummary {
|
||||
key_reference: row.get(0)?,
|
||||
created_at: row.get(1)?,
|
||||
last_used_at: row.get(2)?,
|
||||
normal_login_verified_at: row.get(3)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn read_passkey_credential_record(
|
||||
row: &rusqlite::Row<'_>,
|
||||
) -> rusqlite::Result<PasskeyCredentialRecord> {
|
||||
@@ -15164,6 +15269,22 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_passkey_results_v102_to_v103(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 102 {
|
||||
return Err(Error::Store(
|
||||
"Passkey results migration requires schema 102".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(include_str!("passkey_results_schema.sql"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(103,?1)",
|
||||
["Passkey metadata and atomic addition results"],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_subject_create_recovery_v101_to_v102(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 101 {
|
||||
return Err(Error::Store(
|
||||
@@ -15255,6 +15376,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
|
||||
conn.execute_batch(include_str!("activation_schema.sql"))?;
|
||||
conn.execute_batch(include_str!("passkey_results_schema.sql"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -18948,6 +19070,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
|
||||
WorkspaceSchemaMigrationStep {version:101, name:"Durable explicit Subject body release".into()},
|
||||
WorkspaceSchemaMigrationStep {version:102, name:"Durable failed Subject creation recovery".into()},
|
||||
WorkspaceSchemaMigrationStep {version:103, name:"Passkey metadata and atomic addition results".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -19060,6 +19183,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(100, "Provisional users and scoped passkey activation".to_string()),
|
||||
(101, "Durable explicit Subject body release".to_string()),
|
||||
(102, "Durable failed Subject creation recovery".to_string()),
|
||||
(103, "Passkey metadata and atomic addition results".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -21288,6 +21412,83 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passkey_results_migration_preserves_old_keys_unknown_confirmation_and_rolls_back() {
|
||||
for baseline in [50, 102] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let database = dir.path().join("server.db");
|
||||
if baseline == 50 {
|
||||
prepare_schema_v50(&database, None);
|
||||
} else {
|
||||
prepare_retained_schema(&database, baseline);
|
||||
}
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
conn.execute_batch(r#"INSERT INTO accounts(account_id, kind, handle, display_name, created_at, updated_at)
|
||||
VALUES('account-old', 'user', 'old', 'Old', 'created', 'updated');
|
||||
INSERT INTO users(user_id, account_id, handle, display_name, created_at, updated_at)
|
||||
VALUES('user-old', 'account-old', 'old', 'Old', 'created', 'updated');
|
||||
INSERT INTO passkey_credentials(credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
|
||||
VALUES('old-key', 'user-old', 'unchanged-key-material', '["internal"]', 7, 'created-key', 'used-key');"#)
|
||||
.unwrap();
|
||||
if baseline == 102 {
|
||||
conn.execute_batch("CREATE TRIGGER interrupt_passkey_migration BEFORE INSERT ON __yoi_schema_migrations
|
||||
WHEN NEW.version=103 BEGIN SELECT RAISE(ABORT, 'synthetic migration interruption'); END;").unwrap();
|
||||
assert!(migrate_passkey_results_v102_to_v103(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 102);
|
||||
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
|
||||
assert!(!table_exists(&conn, "passkey_addition_receipts").unwrap());
|
||||
conn.execute_batch("DROP TRIGGER interrupt_passkey_migration")
|
||||
.unwrap();
|
||||
}
|
||||
drop(conn);
|
||||
let preflight = SqliteWorkspaceStore::migration_plan(&database).unwrap();
|
||||
assert!(preflight.migration_required());
|
||||
// Preflight must not install metadata in the source database.
|
||||
let conn = Connection::open(&database).unwrap();
|
||||
assert!(!table_exists(&conn, "passkey_metadata").unwrap());
|
||||
drop(conn);
|
||||
let migrated = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
let record = migrated.get_passkey_credential("old-key").unwrap().unwrap();
|
||||
assert_eq!(record.public_key_cose, "unchanged-key-material");
|
||||
assert_eq!(record.sign_count, 7);
|
||||
assert_eq!(record.last_used_at.as_deref(), Some("used-key"));
|
||||
let summary = migrated
|
||||
.list_passkey_summaries("user-old")
|
||||
.unwrap()
|
||||
.remove(0);
|
||||
assert_eq!(summary.created_at, "created-key");
|
||||
assert_eq!(summary.last_used_at.as_deref(), Some("used-key"));
|
||||
assert!(summary.normal_login_verified_at.is_none());
|
||||
assert_eq!(summary.key_reference.len(), 32);
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let schema = |conn: &Connection| -> Result<Vec<(String, String)>> {
|
||||
let mut stmt = conn.prepare("SELECT name, sql FROM sqlite_schema WHERE name IN
|
||||
('passkey_metadata', 'passkey_metadata_insert', 'passkey_addition_receipts') ORDER BY name")?;
|
||||
Ok(stmt
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?)
|
||||
};
|
||||
assert_eq!(
|
||||
migrated.with_conn(schema).unwrap(),
|
||||
fresh.with_conn(schema).unwrap()
|
||||
);
|
||||
migrated.with_conn(|conn| {
|
||||
assert!(conn.execute("INSERT INTO passkey_metadata(credential_id,key_reference) VALUES('missing-key','ref')", []).is_err());
|
||||
assert!(conn.execute("INSERT INTO passkey_addition_receipts(challenge_id,credential_id,user_id,authentication_reference)
|
||||
VALUES('operation','missing-key','user-old','{}')", []).is_err());
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| row.get::<_, u64>(0))?, 0);
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
drop(migrated);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
assert_eq!(
|
||||
reopened.list_passkey_summaries("user-old").unwrap(),
|
||||
vec![summary]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subject_create_recovery_migration_is_atomic_and_matches_fresh_schema() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -21327,7 +21528,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
drop(conn);
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap();
|
||||
reopened
|
||||
.with_conn(|c| verify_schema_history(c, 102))
|
||||
.with_conn(|c| verify_schema_history(c, LATEST_SCHEMA_VERSION))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
@@ -22266,7 +22467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into()),
|
||||
(101, "Durable explicit Subject body release".into()),
|
||||
(102, "Durable failed Subject creation recovery".into())]);
|
||||
(102, "Durable failed Subject creation recovery".into()),
|
||||
(103, "Passkey metadata and atomic addition results".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
|
||||
+261
-3
@@ -298,12 +298,17 @@
|
||||
"AuthUserResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"authentication_reference": {
|
||||
"description": "Comparison evidence for the exact newly issued session, not a credential.",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user"
|
||||
"user",
|
||||
"authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
@@ -6709,15 +6714,112 @@
|
||||
},
|
||||
"PasskeyAdditionOptionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Adds a credential to the current authenticated actor; target identity is never public input.",
|
||||
"description": "Adds to the current actor. Expected identity/binding fields only fence stale UI;\nthey never select an owner or authorize an addition.",
|
||||
"properties": {
|
||||
"browser_origin": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"expected_account_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_authentication_reference": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyAdditionResultRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"challenge_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_account_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"expected_authentication_reference": {
|
||||
"description": "Original options authentication reference; the current same-owner session may differ.",
|
||||
"type": "string"
|
||||
},
|
||||
"expected_user_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"challenge_id",
|
||||
"expected_user_id",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyAdditionResultResponse": {
|
||||
"description": "Unknown is not proof that registration failed: completion may still be in flight.\nSaved proves the credential commit, not session issuance or current session health.",
|
||||
"oneOf": [
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"status": {
|
||||
"const": "unknown",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"key": {
|
||||
"$ref": "#/components/schemas/PasskeySummary"
|
||||
},
|
||||
"status": {
|
||||
"const": "saved",
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"user",
|
||||
"key"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"PasskeyListResponse": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"keys": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/PasskeySummary"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"keys"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeyLoginCompleteRequest": {
|
||||
@@ -6820,6 +6922,35 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasskeySummary": {
|
||||
"additionalProperties": false,
|
||||
"description": "Safe account metadata. Neither the WebAuthn credential ID nor key material is exposed.\nNormal-login verification is observed verifier success, not inferred discoverability.",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"key_reference": {
|
||||
"type": "string"
|
||||
},
|
||||
"last_used_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"normal_login_verified_at": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"key_reference",
|
||||
"created_at"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PasteArtifactAvailability": {
|
||||
"enum": [
|
||||
"available",
|
||||
@@ -15143,6 +15274,13 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"authentication_reference": {
|
||||
"description": "Non-secret comparison evidence, never an authentication credential.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"session": {
|
||||
"$ref": "#/components/schemas/BrowserSessionState"
|
||||
}
|
||||
@@ -18155,7 +18293,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7"
|
||||
"x-yoi-source-digest": "sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
@@ -18589,6 +18727,61 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys": {
|
||||
"get": {
|
||||
"operationId": "auth_passkey_list",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyListResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/addition/complete": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_addition_complete",
|
||||
@@ -18736,6 +18929,71 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/addition/result": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_addition_result",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyAdditionResultRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyAdditionResultResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/auth/passkeys/login/complete": {
|
||||
"post": {
|
||||
"operationId": "auth_passkey_login_complete",
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
- [`WorkerE2eEnvironment`]: Backend/Runtime用プロセスとfixtureの管理。
|
||||
- [`FixtureSource`]: 提供資産の全ファイルSHA-256記録、独立copy、原本不変の検証。
|
||||
- [`WorkerSessionSubscription`]: 公開Worker WebSocketの初期full Snapshot照合と停止確認。モデル呼出しは行わない。
|
||||
- `tests/auth_passkeys/native_migration.rs`・`browser/native_passkey_migration.ts`: 実Backend/本番shell/Chromium native CTAP2の旧鍵移行。fixture境界・未確認範囲・明示選択コマンドはmoduleに置く。通常suiteやsoftware authenticatorの成功で代替しない。
|
||||
|
||||
## 固定Workerの段階起動
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// 固定expiry/短命cookieを作る明示fixtureだけが発行境界を代替し、実分類はBackendへ委ねる。
|
||||
// navigator.credentialsのみRustのResidentAuthenticatorへ接続する(物理認証器ではない)。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { extname, join, normalize } from "@std/path";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
import { type Browser, type BrowserContext, chromium } from "playwright";
|
||||
|
||||
const [backend, origin, build, handle] = Deno.args;
|
||||
@@ -13,82 +13,43 @@ if (!backend || !origin || !build || !handle) {
|
||||
"Expected isolated Backend URL, browser origin, production build, handle",
|
||||
);
|
||||
}
|
||||
await Deno.stat(join(build, "index.html")); // 暗黙buildや別asset rootへのfallbackはしない。
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html; charset=utf-8",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".png": "image/png",
|
||||
".woff2": "font/woff2",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const server = Deno.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: Number(new URL(origin).port),
|
||||
onListen() {},
|
||||
}, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__e2e/expired-session" && request.method === "POST") {
|
||||
if (request.headers.get("origin") !== origin) {
|
||||
return new Response(null, { status: 403 });
|
||||
const server = await serveProductionShell(
|
||||
backend,
|
||||
origin,
|
||||
build,
|
||||
async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (
|
||||
url.pathname === "/__e2e/expired-session" && request.method === "POST"
|
||||
) {
|
||||
if (request.headers.get("origin") !== origin) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||
const fixture = await bridge("expired-session", {}) as {
|
||||
set_cookies: string[];
|
||||
};
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
for (const cookie of fixture.set_cookies) {
|
||||
headers.append("set-cookie", cookie);
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
// Rustは環境専用DBの実登録userへ公開store APIで保存する。secretをbody/ログへ出さない。
|
||||
const fixture = await bridge("expired-session", {}) as {
|
||||
set_cookies: string[];
|
||||
};
|
||||
const headers = new Headers({ "cache-control": "no-store" });
|
||||
for (const cookie of fixture.set_cookies) {
|
||||
headers.append("set-cookie", cookie);
|
||||
if (
|
||||
url.pathname === "/__e2e/primary-cookie-present" &&
|
||||
request.method === "GET"
|
||||
) {
|
||||
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||
) ?? false;
|
||||
return Response.json(present, {
|
||||
headers: { "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
return new Response(null, { status: 204, headers });
|
||||
}
|
||||
if (
|
||||
url.pathname === "/__e2e/primary-cookie-present" && request.method === "GET"
|
||||
) {
|
||||
// HTTPで受け取ったprimaryの有無だけを返す。値、marker、DB状態は公開しない。
|
||||
const present = request.headers.get("cookie")?.split(";").some((pair) =>
|
||||
pair.trim().split("=", 1)[0] === "yoi_workspace_session"
|
||||
) ?? false;
|
||||
return Response.json(present, { headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("host");
|
||||
headers.delete("connection");
|
||||
// OriginとCookieを保持し、Set-Cookieを含む実応答をそのまま返す。
|
||||
return await fetch(new URL(url.pathname + url.search, backend), {
|
||||
method: request.method,
|
||||
headers,
|
||||
body: ["GET", "HEAD"].includes(request.method)
|
||||
? undefined
|
||||
: await request.arrayBuffer(),
|
||||
redirect: "manual",
|
||||
});
|
||||
}
|
||||
const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html");
|
||||
if (relative.startsWith("..")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
let file = join(build, relative);
|
||||
try {
|
||||
if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html");
|
||||
return new Response(await Deno.readFile(file), {
|
||||
headers: {
|
||||
"content-type": mime[extname(file)] ?? "application/octet-stream",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound)) throw error;
|
||||
if (url.pathname.startsWith("/_app/")) {
|
||||
return new Response("not found", { status: 404 });
|
||||
}
|
||||
return new Response(await Deno.readFile(join(build, "index.html")), {
|
||||
headers: { "content-type": mime[".html"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
|
||||
// 逐次ceremony/fixtureだけを扱う短いNDJSON bridge。署名/cookieをartifactへ記録しない。
|
||||
const input = Deno.stdin.readable.pipeThrough(new TextDecoderStream())
|
||||
@@ -313,7 +274,8 @@ try {
|
||||
|
||||
// 14日実時間の前進ではない。固定expiry実store recordとMax-Age=2のHTTP cookieで、
|
||||
// 自然消失後のmarker→実whoami→製品loader/router→実署名loginの接続を検証する。
|
||||
const expiredTarget = "/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const expiredTarget =
|
||||
"/dashboard?view=workspaces&filter=a%2Fb&ratio=100%25#selected%25";
|
||||
const catalogResponse = () =>
|
||||
page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/workspaces" &&
|
||||
@@ -360,6 +322,7 @@ try {
|
||||
assert(expiredCookieNames.includes(marker.name));
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
privateRequests.length = 0;
|
||||
@@ -383,7 +346,11 @@ try {
|
||||
[],
|
||||
"expired reload must gate the real private catalog loader",
|
||||
);
|
||||
assertEquals(await whoami(), { actor: null, session: { kind: "expired" } });
|
||||
assertEquals(await whoami(), {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
});
|
||||
await page.reload();
|
||||
await at(expiredLogin);
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
@@ -416,7 +383,11 @@ try {
|
||||
// authenticatedにもexpiredにもならない。これを14日経過の成功とは数えない。
|
||||
await context.clearCookies({ name: session.name });
|
||||
const partial = await whoami();
|
||||
assertEquals(partial, { actor: null, session: { kind: "anonymous" } });
|
||||
assertEquals(partial, {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
await page.reload();
|
||||
await at("/");
|
||||
await context.clearCookies();
|
||||
|
||||
@@ -0,0 +1,611 @@
|
||||
/// <reference lib="dom" />
|
||||
// 実Backend + 本番static shell + Chromium native WebAuthn/CTAP2の旧鍵移行matrix。
|
||||
// navigator.credentials、HTTP応答、暗号検証を置換しない。CDPは合成認証器の
|
||||
// 能力/本人確認/タッチだけを制御し、credentialのexport/import/deleteはしない。
|
||||
// 旧鍵fixtureだけは実optionsのuser.idとresident指定を変更してnative createする。
|
||||
// 登録attestationはuser handleを署名しないため、本番verifier/storeで保存できる。
|
||||
// 現行製品の登録動作の再現ではなく、旧random handle保存状態の明示的な合成である。
|
||||
// 実スマホ/OSのprovider選択/物理鍵は対象外。mobileはChromiumのviewportのみ。
|
||||
import { join } from "@std/path";
|
||||
import { type Browser, type CDPSession, chromium, type Page } from "playwright";
|
||||
import { serveProductionShell } from "./static_shell.ts";
|
||||
|
||||
const [backend, origin, build, handlePrefix, artifacts] = Deno.args;
|
||||
if (!backend || !origin || !build || !handlePrefix || !artifacts) {
|
||||
throw new Error(
|
||||
"Expected owned Backend, origin, production build, synthetic handle prefix, artifacts",
|
||||
);
|
||||
}
|
||||
const started = performance.now();
|
||||
const server = await serveProductionShell(backend, origin, build);
|
||||
let browser: Browser | undefined;
|
||||
let phase = "launch";
|
||||
const rows: {
|
||||
resident: boolean;
|
||||
authenticator: string;
|
||||
outcome: string;
|
||||
elapsed_ms: number;
|
||||
}[] = [];
|
||||
class NativeCheckError extends Error {}
|
||||
function check(condition: unknown, diagnostic: string): asserts condition {
|
||||
// 値をdumpするassertEqualsは使わない。例外/HTTP本文/credential/cookieをログに残さない。
|
||||
if (!condition) throw new NativeCheckError(diagnostic);
|
||||
}
|
||||
type User = { user_id: string; account_id: string; handle: string };
|
||||
type Key = {
|
||||
key_reference: string;
|
||||
normal_login_verified_at: string | null;
|
||||
last_used_at: string | null;
|
||||
};
|
||||
async function whoami(page: Page) {
|
||||
const response = await page.request.get(`${origin}/api/auth/whoami`);
|
||||
check(response.status() === 200, "whoami status");
|
||||
return await response.json() as {
|
||||
actor: User | null;
|
||||
authentication_reference: string | null;
|
||||
session: { kind: string };
|
||||
};
|
||||
}
|
||||
async function owner(page: Page, user: User, reference?: string) {
|
||||
const current = await whoami(page);
|
||||
check(
|
||||
current.session.kind === "authenticated" &&
|
||||
current.actor?.user_id === user.user_id &&
|
||||
current.actor.account_id === user.account_id &&
|
||||
current.actor.handle === user.handle,
|
||||
"authenticated owner mismatch",
|
||||
);
|
||||
check(
|
||||
typeof current.authentication_reference === "string",
|
||||
"missing authentication reference",
|
||||
);
|
||||
if (reference) {
|
||||
check(
|
||||
current.authentication_reference === reference,
|
||||
"issued authentication mismatch",
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
async function keys(page: Page, user: User): Promise<Key[]> {
|
||||
const response = await page.request.get(`${origin}/api/auth/passkeys`);
|
||||
check(response.status() === 200, "key list status");
|
||||
const result = await response.json();
|
||||
check(
|
||||
result.user.user_id === user.user_id &&
|
||||
result.user.account_id === user.account_id,
|
||||
"key list owner",
|
||||
);
|
||||
return result.keys;
|
||||
}
|
||||
async function presence(
|
||||
cdp: CDPSession,
|
||||
authenticatorId: string,
|
||||
enabled: boolean,
|
||||
) {
|
||||
await cdp.send("WebAuthn.setAutomaticPresenceSimulation", {
|
||||
authenticatorId,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
async function authenticator(
|
||||
cdp: CDPSession,
|
||||
resident: boolean,
|
||||
automaticPresenceSimulation = true,
|
||||
) {
|
||||
return (await cdp.send("WebAuthn.addVirtualAuthenticator", {
|
||||
options: {
|
||||
protocol: "ctap2",
|
||||
transport: "usb",
|
||||
hasResidentKey: resident,
|
||||
hasUserVerification: true,
|
||||
isUserVerified: true,
|
||||
automaticPresenceSimulation,
|
||||
},
|
||||
})).authenticatorId;
|
||||
}
|
||||
async function account(page: Page) {
|
||||
await page.goto(origin + "/account");
|
||||
await page.getByRole("heading", { name: "Account", exact: true }).waitFor();
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).waitFor();
|
||||
}
|
||||
async function logout(page: Page) {
|
||||
await account(page);
|
||||
await page.getByRole("button", { name: "Log out", exact: true }).click();
|
||||
await page.waitForURL((url) => url.origin === origin && url.pathname === "/");
|
||||
check(
|
||||
(await whoami(page)).session.kind === "anonymous",
|
||||
"logout must clear authentication",
|
||||
);
|
||||
}
|
||||
async function login(page: Page, user: User, legacy: boolean) {
|
||||
await page.goto(origin + "/login?returnTo=%2Faccount");
|
||||
const button = page.getByRole("button", {
|
||||
name: legacy ? "Log in with older key" : "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
if (legacy) {
|
||||
await page.getByText("Use an older key with account ID", { exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill(
|
||||
user.handle,
|
||||
);
|
||||
} else {
|
||||
// 互換入口は閉じたまま。通常loginのrequestにhandle/user/accountが入らないことも検査する。
|
||||
check(
|
||||
!await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.isVisible(),
|
||||
"normal login asks for ID",
|
||||
);
|
||||
}
|
||||
const options = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/options"
|
||||
);
|
||||
const complete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/login/complete"
|
||||
);
|
||||
await button.click();
|
||||
const optionResponse = await options;
|
||||
check(optionResponse.status() === 200, "login options status");
|
||||
const input = optionResponse.request().postDataJSON();
|
||||
if (!legacy) {
|
||||
check(
|
||||
Object.keys(input).every((key) => key === "browser_origin"),
|
||||
"normal login selected an account",
|
||||
);
|
||||
const wire = await optionResponse.json();
|
||||
check(
|
||||
!wire.public_key.publicKey.allowCredentials?.length,
|
||||
"normal login sends an allow-list",
|
||||
);
|
||||
}
|
||||
const response = await complete;
|
||||
check(response.status() === 200, "native login complete status");
|
||||
const issued = await response.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id,
|
||||
"login returned wrong owner",
|
||||
);
|
||||
await page.waitForURL((url) => url.pathname === "/account");
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
}
|
||||
|
||||
const terminate = () => {
|
||||
void (async () => {
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
Deno.exit(143);
|
||||
})();
|
||||
};
|
||||
Deno.addSignalListener("SIGTERM", terminate);
|
||||
try {
|
||||
browser = await chromium.launch({ headless: true, timeout: 15_000 });
|
||||
for (const resident of [false, true]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width: 1280, height: 900 },
|
||||
serviceWorkers: "block",
|
||||
});
|
||||
context.setDefaultTimeout(12_000);
|
||||
// 製品もfixtureも外部通信しない。loopback proxy以外へのbrowser requestは拒否する。
|
||||
await context.route(
|
||||
"**/*",
|
||||
(route) =>
|
||||
new URL(route.request().url()).origin === origin
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
let page = await context.newPage();
|
||||
let pageErrors = 0;
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
let cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
// 両行とも認証器はresident key対応にする。residentは旧credentialの作成指定だけを変え、
|
||||
// resident必須の追加が能力不足で拒否されるケースとduplicate exclusionを混同しない。
|
||||
const old = await authenticator(cdp, true);
|
||||
let completionPosts = 0;
|
||||
const completions = () => completionPosts;
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
try {
|
||||
phase = `legacy-fixture-resident-${resident}`;
|
||||
await page.goto(origin + "/register");
|
||||
// 生options/credentialはpage内にとどめる。user.id/residency以外はServerの要求を維持する。
|
||||
const fixture = await page.evaluate(async ({ handle, resident }) => {
|
||||
const post = async (path: string, body: unknown) => {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("synthetic legacy fixture HTTP rejected");
|
||||
}
|
||||
return await response.json();
|
||||
};
|
||||
const wire = await post("/api/auth/passkeys/registration/options", {
|
||||
handle,
|
||||
display_name: "Synthetic native migration",
|
||||
browser_origin: location.origin,
|
||||
});
|
||||
const bytes = (value: string) =>
|
||||
Uint8Array.from(
|
||||
atob(value.replaceAll("-", "+").replaceAll("_", "/")),
|
||||
(c) => c.charCodeAt(0),
|
||||
);
|
||||
const options = wire.public_key.publicKey;
|
||||
const random = crypto.getRandomValues(new Uint8Array(32));
|
||||
const stable = bytes(options.user.id);
|
||||
const differs = stable.length !== random.length ||
|
||||
stable.some((byte: number, index: number) => byte !== random[index]);
|
||||
options.challenge = bytes(options.challenge);
|
||||
options.user.id = random;
|
||||
options.excludeCredentials = options.excludeCredentials?.map((
|
||||
item: { id: string },
|
||||
) => ({ ...item, id: bytes(item.id) }));
|
||||
options.authenticatorSelection = {
|
||||
...options.authenticatorSelection,
|
||||
residentKey: resident ? "required" : "discouraged",
|
||||
requireResidentKey: resident,
|
||||
};
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey: options,
|
||||
}) as PublicKeyCredential | null;
|
||||
if (
|
||||
!credential ||
|
||||
!(credential.response instanceof AuthenticatorAttestationResponse)
|
||||
) throw new Error("native attestation missing");
|
||||
const encode = (value: ArrayBuffer) =>
|
||||
btoa(String.fromCharCode(...new Uint8Array(value))).replaceAll(
|
||||
"+",
|
||||
"-",
|
||||
).replaceAll("/", "_").replaceAll("=", "");
|
||||
const issued = await post("/api/auth/passkeys/registration/complete", {
|
||||
challenge_id: wire.challenge_id,
|
||||
credential: {
|
||||
id: credential.id,
|
||||
rawId: encode(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
attestationObject: encode(credential.response.attestationObject),
|
||||
clientDataJSON: encode(credential.response.clientDataJSON),
|
||||
transports: credential.response.getTransports(),
|
||||
},
|
||||
extensions: credential.getClientExtensionResults(),
|
||||
},
|
||||
});
|
||||
return {
|
||||
user: issued.user,
|
||||
authentication_reference: issued.authentication_reference,
|
||||
randomDiffersFromStable: differs,
|
||||
};
|
||||
}, {
|
||||
handle: `${handlePrefix}-${resident ? "resident" : "nonresident"}`,
|
||||
resident,
|
||||
});
|
||||
check(
|
||||
fixture.randomDiffersFromStable,
|
||||
"fixture must use a legacy random handle",
|
||||
);
|
||||
const user: User = fixture.user;
|
||||
await owner(page, user, fixture.authentication_reference);
|
||||
const initial = await keys(page, user);
|
||||
check(
|
||||
initial.length === 1 && initial[0].normal_login_verified_at === null,
|
||||
"legacy key initially unverified",
|
||||
);
|
||||
const oldReference = initial[0].key_reference;
|
||||
await logout(page);
|
||||
phase = `older-key-login-before-resident-${resident}`;
|
||||
await login(page, user, true);
|
||||
|
||||
phase = `same-authenticator-excluded-resident-${resident}`;
|
||||
const sameStarted = performance.now();
|
||||
const add = () =>
|
||||
page.getByRole("button", {
|
||||
name: `Add passkey to ${user.handle}`,
|
||||
exact: true,
|
||||
});
|
||||
const optionsResponse = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
check(
|
||||
(await optionsResponse).status() === 200,
|
||||
"addition options status",
|
||||
);
|
||||
const excluded = page.getByRole("alert").filter({
|
||||
hasText: "[authenticator/excluded]",
|
||||
});
|
||||
await excluded.waitFor({ state: "visible" });
|
||||
check(
|
||||
(await excluded.innerText()).includes(
|
||||
"different device, security key, or passkey provider",
|
||||
),
|
||||
"exclusion needs a concrete alternative",
|
||||
);
|
||||
check(
|
||||
(await excluded.innerText()).includes("do not delete"),
|
||||
"exclusion must preserve working access",
|
||||
);
|
||||
check(
|
||||
completions() === 0,
|
||||
"native exclusion must not submit completion",
|
||||
);
|
||||
const afterExcluded = await keys(page, user);
|
||||
check(
|
||||
afterExcluded.length === 1 &&
|
||||
afterExcluded[0].key_reference === oldReference,
|
||||
"exclusion changed old key",
|
||||
);
|
||||
await owner(page, user);
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "same",
|
||||
outcome: "native-exclusion-no-completion-old-key-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - sameStarted),
|
||||
});
|
||||
|
||||
phase = `other-authenticator-pending-resident-${resident}`;
|
||||
const otherStarted = performance.now();
|
||||
// CDPの仮想認証器選択は物理providerの選択UIを再現しない。別targetに別認証器を置き、
|
||||
// 旧targetと旧鍵は生存させる。共有contextの既存sessionを使い、cookie/鍵の移送はしない。
|
||||
const legacyPage = page;
|
||||
const legacyCdp = cdp;
|
||||
page = await context.newPage();
|
||||
page.on("pageerror", () => ++pageErrors);
|
||||
page.on("request", (r) => {
|
||||
if (
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
) ++completionPosts;
|
||||
});
|
||||
cdp = await context.newCDPSession(page);
|
||||
await cdp.send("WebAuthn.enable", { enableUI: false });
|
||||
const newer = await authenticator(cdp, true, false);
|
||||
await account(page);
|
||||
// options応答後、native操作はCTAPのpresence=falseで止める。timeout/sleepでpromptを推測しない。
|
||||
const beforeAddition = await owner(page, user);
|
||||
const additionOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
const additionComplete = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/complete"
|
||||
);
|
||||
await add().click();
|
||||
const optionResponse = await additionOptions;
|
||||
check(
|
||||
optionResponse.status() === 200,
|
||||
"other authenticator options status",
|
||||
);
|
||||
let optionsInput = optionResponse.request().postDataJSON();
|
||||
check(
|
||||
optionsInput.expected_user_id === user.user_id &&
|
||||
optionsInput.expected_account_id === user.account_id &&
|
||||
optionsInput.expected_authentication_reference ===
|
||||
beforeAddition.authentication_reference,
|
||||
"addition must fence displayed owner and session",
|
||||
);
|
||||
let wire = await optionResponse.json();
|
||||
check(
|
||||
wire.public_key.publicKey.excludeCredentials.length === 1,
|
||||
"old credential must remain excluded",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.userVerification ===
|
||||
"required",
|
||||
"addition must require UV",
|
||||
);
|
||||
check(
|
||||
wire.public_key.publicKey.authenticatorSelection.residentKey ===
|
||||
"required",
|
||||
"product addition must require residency",
|
||||
);
|
||||
const pending = page.getByRole("status").filter({
|
||||
hasText: "Waiting for passkey addition",
|
||||
});
|
||||
await pending.waitFor({ state: "visible" });
|
||||
check(
|
||||
await page.getByRole("heading", { name: "Account", exact: true })
|
||||
.isVisible(),
|
||||
"account hidden while native pending",
|
||||
);
|
||||
check(
|
||||
await add().isDisabled() && completions() === 0,
|
||||
"pending must prevent duplicate/early completion",
|
||||
);
|
||||
for (
|
||||
const viewport of [{ width: 1280, height: 900 }, {
|
||||
width: 390,
|
||||
height: 844,
|
||||
}]
|
||||
) {
|
||||
await page.setViewportSize(viewport);
|
||||
await pending.scrollIntoViewIfNeeded();
|
||||
check(
|
||||
await pending.isVisible(),
|
||||
"pending must remain visible at viewport",
|
||||
);
|
||||
await page.screenshot({
|
||||
path: join(
|
||||
artifacts,
|
||||
`native-addition-pending-${resident}-${viewport.width}.png`,
|
||||
),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
// presence=falseで既に待機中のCTAP要求を後からtrueにして解放できるとは仮定しない。
|
||||
// pending/取消と成功を分ける。送信前の停止を確認し、別ceremonyを明示開始する。
|
||||
await page.getByRole("button", { name: "Stop waiting", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Completion was not sent.",
|
||||
}).waitFor();
|
||||
check(
|
||||
completions() === 0 && (await keys(page, user)).length === 1,
|
||||
"cancel before touch must not save or complete",
|
||||
);
|
||||
await presence(cdp, newer, true);
|
||||
const freshOptions = page.waitForResponse((r) =>
|
||||
new URL(r.url()).pathname === "/api/auth/passkeys/addition/options"
|
||||
);
|
||||
await add().click();
|
||||
const freshResponse = await freshOptions;
|
||||
check(freshResponse.status() === 200, "fresh addition options status");
|
||||
optionsInput = freshResponse.request().postDataJSON();
|
||||
wire = await freshResponse.json();
|
||||
phase = `other-authenticator-completion-resident-${resident}`;
|
||||
const complete = await additionComplete;
|
||||
check(complete.status() === 200, "native addition complete status");
|
||||
const issued = await complete.json();
|
||||
check(
|
||||
issued.user.user_id === user.user_id &&
|
||||
issued.user.account_id === user.account_id &&
|
||||
typeof issued.authentication_reference === "string",
|
||||
"addition complete owner/reference",
|
||||
);
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey added to your account",
|
||||
}).waitFor({ state: "visible" });
|
||||
await owner(page, user, issued.authentication_reference);
|
||||
check(completions() === 1, "addition must submit exactly once");
|
||||
// 元のbinding referenceはreceipt照合用。Cookie発行後も同じ現在ownerから確認する。
|
||||
const receipt = await page.request.post(
|
||||
`${origin}/api/auth/passkeys/addition/result`,
|
||||
{
|
||||
headers: { Origin: origin },
|
||||
data: {
|
||||
challenge_id: wire.challenge_id,
|
||||
expected_user_id: user.user_id,
|
||||
expected_account_id: user.account_id,
|
||||
expected_authentication_reference:
|
||||
optionsInput.expected_authentication_reference,
|
||||
},
|
||||
},
|
||||
);
|
||||
check(
|
||||
receipt.status() === 200,
|
||||
"saved addition receipt after Cookie issuance",
|
||||
);
|
||||
const saved = await receipt.json();
|
||||
check(
|
||||
saved.status === "saved" && saved.user.user_id === user.user_id &&
|
||||
saved.user.account_id === user.account_id,
|
||||
"receipt must identify saved owner",
|
||||
);
|
||||
const added = await keys(page, user);
|
||||
check(
|
||||
added.length === 2 &&
|
||||
added.some((key) => key.key_reference === oldReference),
|
||||
"addition must retain older key",
|
||||
);
|
||||
const newKey = added.find((key) => key.key_reference !== oldReference);
|
||||
check(
|
||||
newKey && saved.key.key_reference === newKey.key_reference &&
|
||||
newKey.normal_login_verified_at === null,
|
||||
"storage must not assert normal login",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `handle-free-login-resident-${resident}`;
|
||||
await login(page, user, false);
|
||||
const verified = await keys(page, user);
|
||||
check(
|
||||
verified.length === 2 &&
|
||||
verified.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"actual handle-free verifier success must mark the new key",
|
||||
);
|
||||
check(
|
||||
verified.find((key) => key.key_reference === oldReference)
|
||||
?.normal_login_verified_at === null,
|
||||
"normal login must not mark the unused legacy key",
|
||||
);
|
||||
await logout(page);
|
||||
phase = `older-key-login-after-resident-${resident}`;
|
||||
await presence(cdp, newer, false);
|
||||
page = legacyPage;
|
||||
cdp = legacyCdp;
|
||||
await presence(cdp, old, true);
|
||||
await login(page, user, true);
|
||||
const retained = await keys(page, user);
|
||||
check(
|
||||
retained.length === 2 &&
|
||||
retained.find((key) => key.key_reference === oldReference)
|
||||
?.last_used_at != null,
|
||||
"older key must remain usable after migration",
|
||||
);
|
||||
check(
|
||||
retained.find((key) => key.key_reference === newKey.key_reference)
|
||||
?.normal_login_verified_at != null,
|
||||
"older login must preserve new key verification",
|
||||
);
|
||||
check(pageErrors === 0, "product page emitted an uncaught error");
|
||||
rows.push({
|
||||
resident,
|
||||
authenticator: "other",
|
||||
outcome:
|
||||
"saved-correct-owner-handle-free-verified-older-login-preserved",
|
||||
elapsed_ms: Math.round(performance.now() - otherStarted),
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "passed",
|
||||
boundary:
|
||||
"real Backend/static shell/native Chromium CTAP2; synthetic legacy creation override only",
|
||||
chromium: browser.version(),
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
viewports: ["1280x900", "390x844"],
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.error(
|
||||
`native-migration: PASS ${rows.length} matrix rows; ${
|
||||
Math.round(performance.now() - started)
|
||||
}ms`,
|
||||
);
|
||||
} catch (error) {
|
||||
const diagnostic = error instanceof NativeCheckError
|
||||
? error.message
|
||||
: "browser-or-tool-operation-failed";
|
||||
await Deno.writeTextFile(
|
||||
join(artifacts, "native-migration-result.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
status: "failed",
|
||||
phase,
|
||||
diagnostic,
|
||||
rows,
|
||||
elapsed_ms: Math.round(performance.now() - started),
|
||||
physical_smartphone: "unconfirmed",
|
||||
physical_authenticator: "unconfirmed",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
// Playwrightの例外にはrequest/引数を含み得るため本文やstackを保存しない。
|
||||
console.error(
|
||||
`native-migration: FAIL phase=${phase}; completed_rows=${rows.length}; elapsed_ms=${
|
||||
Math.round(performance.now() - started)
|
||||
}`,
|
||||
);
|
||||
Deno.exitCode = 1;
|
||||
} finally {
|
||||
Deno.removeSignalListener("SIGTERM", terminate);
|
||||
await browser?.close();
|
||||
await server.shutdown();
|
||||
}
|
||||
@@ -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"] },
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1074,6 +1074,27 @@ impl WorkerE2eEnvironment {
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// browser toolだけに必要なtool探索設定を引き継ぎ、profile・HOME・XDG・一時領域は
|
||||
/// このfixtureへ閉じ込める。DENO_DIR/PLAYWRIGHT_BROWSERS_PATHはインストール済みtoolの
|
||||
/// 供給元であり、製品データや認証情報を引き継ぐ経路にはしない。
|
||||
pub fn browser_command(&self, binary: &Path) -> Result<Command> {
|
||||
let snapshot: std::collections::HashMap<_, _> = std::env::vars_os().collect();
|
||||
let mut command = self.isolated_command(binary);
|
||||
for name in ["PATH", "DENO_DIR", "PLAYWRIGHT_BROWSERS_PATH"] {
|
||||
if let Some(value) = snapshot.get(std::ffi::OsStr::new(name)) {
|
||||
command.env(name, value);
|
||||
}
|
||||
}
|
||||
let temporary = self.root.join("browser-tmp");
|
||||
let cache = self.root.join("browser-cache");
|
||||
fs::create_dir_all(&temporary)?;
|
||||
fs::create_dir_all(&cache)?;
|
||||
command
|
||||
.env("TMPDIR", temporary)
|
||||
.env("XDG_CACHE_HOME", cache);
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn isolated_command(&self, binary: &Path) -> Command {
|
||||
let mut command = Command::new(binary);
|
||||
command
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。
|
||||
//! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。
|
||||
//! 通常ケースのHTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。追加の明示選択browser_sessionsだけが
|
||||
//! 本番static shellをloopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! 実Server CLIにはstatic assets設定経路がない。明示選択browser_sessionsは本番static shellを
|
||||
//! loopback proxyで実Backendへ接続し、Cookieの自然消失と復帰を検証する。
|
||||
//! native_migrationは同じshellと実BackendをChromium native WebAuthn/CTAP2へ接続する。
|
||||
//! 固定expiry sessionを公開store APIで作るfixture境界と、14日実時間経過の未検証は同moduleに記す。
|
||||
|
||||
use std::error::Error;
|
||||
@@ -21,8 +22,8 @@ use serde::Deserialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde_json::{Value, json};
|
||||
use server_api::{
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
ActorAuthMethod, AuthUserResponse, AuthenticatedUser, PasskeyAdditionOptionsRequest,
|
||||
PasskeyLoginCompleteRequest, PasskeyLoginOptionsResponse, PasskeyRegistrationCompleteRequest,
|
||||
PasskeyRegistrationOptionsResponse, WhoamiResponse,
|
||||
};
|
||||
#[path = "../src/resident_authenticator.rs"]
|
||||
@@ -30,8 +31,12 @@ mod resident_model;
|
||||
use resident_model::ResidentAuthenticator;
|
||||
use yoi_e2e::WorkerE2eEnvironment;
|
||||
|
||||
#[path = "auth_passkeys/browser_process.rs"]
|
||||
mod browser_process;
|
||||
#[path = "auth_passkeys/browser_sessions.rs"]
|
||||
mod browser_sessions;
|
||||
#[path = "auth_passkeys/native_migration.rs"]
|
||||
mod native_migration;
|
||||
|
||||
type Result<T = ()> = std::result::Result<T, Box<dyn Error>>;
|
||||
const BROWSER_ORIGIN: &str = "http://localhost:5173";
|
||||
@@ -270,6 +275,29 @@ fn logout(client: &Client, base: &str, artifacts: &Path, cookie: &str) -> Result
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// fixtureも現在の本人/認証bindingをwhoamiから取得する。期待値は対象を選ぶ権限ではない。
|
||||
fn addition_expectation(
|
||||
request: RequestBuilder,
|
||||
artifacts: &Path,
|
||||
) -> Result<PasskeyAdditionOptionsRequest> {
|
||||
let identity: WhoamiResponse = decode(send(
|
||||
request,
|
||||
"read addition owner and authentication reference",
|
||||
artifacts,
|
||||
)?)?;
|
||||
let actor = identity
|
||||
.actor
|
||||
.ok_or("addition fixture requires an authenticated actor")?;
|
||||
Ok(PasskeyAdditionOptionsRequest {
|
||||
expected_user_id: actor.user_id,
|
||||
expected_account_id: actor.account_id,
|
||||
expected_authentication_reference: identity
|
||||
.authentication_reference
|
||||
.ok_or("missing addition authentication reference")?,
|
||||
browser_origin: Some(BROWSER_ORIGIN.to_owned()),
|
||||
})
|
||||
}
|
||||
|
||||
fn host_admin_login(
|
||||
environment: &WorkerE2eEnvironment,
|
||||
client: &Client,
|
||||
@@ -291,11 +319,17 @@ fn host_admin_login(
|
||||
let bearer = setup["access_token"]
|
||||
.as_str()
|
||||
.ok_or("host setup omitted initial bearer")?;
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.bearer_auth(bearer),
|
||||
artifacts,
|
||||
)?;
|
||||
let options: PasskeyRegistrationOptionsResponse = decode(send(
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"host-created administrator bearer starts actor-derived passkey addition",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -348,6 +382,12 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
policy(&client, base, artifacts, false)?;
|
||||
let mut admin_key = ResidentAuthenticator::new(true);
|
||||
let (admin, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?;
|
||||
let admin_addition = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &admin_cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
policy(&client, base, artifacts, false)?;
|
||||
set_policy(&client, base, artifacts, &admin_cookie, true)?;
|
||||
let whoami: Value = decode(send(
|
||||
@@ -379,7 +419,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&admin_addition),
|
||||
"unauthenticated addition options",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -445,12 +485,18 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
)?)?;
|
||||
assert!(options.public_key.public_key.allow_credentials.is_empty());
|
||||
|
||||
let addition_body = addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?;
|
||||
for (field, value) in [
|
||||
("handle", &other.user.handle),
|
||||
("user_id", &other.user.user_id),
|
||||
("account_id", &other.user.account_id),
|
||||
] {
|
||||
let mut body = json!({"browser_origin": BROWSER_ORIGIN});
|
||||
let mut body = serde_json::to_value(&addition_body)?;
|
||||
body[field] = json!(value);
|
||||
rejected(
|
||||
send(
|
||||
@@ -470,7 +516,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
let mut request = client
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN}));
|
||||
.json(&addition_body);
|
||||
if let Some(origin) = origin {
|
||||
request = request.header(ORIGIN, origin);
|
||||
}
|
||||
@@ -490,7 +536,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"authenticated self addition options",
|
||||
artifacts,
|
||||
)?)?;
|
||||
@@ -591,7 +637,7 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, revoked_cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_body),
|
||||
"logout prevents further self addition with the old cookie",
|
||||
artifacts,
|
||||
)?,
|
||||
@@ -1797,7 +1843,8 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
client.post(format!("{base}/api/auth/device-login/approve")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"user_code": flow["user_code"]})),
|
||||
client.post(format!("{base}/api/auth/passkeys/addition/options")).header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN, "expected_user_id": issue.user.user_id,
|
||||
"expected_account_id": issue.user.account_id, "expected_authentication_reference": "unauthenticated-fixture-not-a-credential"})),
|
||||
];
|
||||
for mut request in requests {
|
||||
if let Some(bearer) = bearer {
|
||||
@@ -1901,7 +1948,12 @@ fn activation_codes_challenges_and_unverified_state_cannot_cross_account_or_purp
|
||||
.post(format!("{base}/api/auth/passkeys/addition/options"))
|
||||
.header(COOKIE, &cookie)
|
||||
.header(ORIGIN, BROWSER_ORIGIN)
|
||||
.json(&json!({"browser_origin": BROWSER_ORIGIN})),
|
||||
.json(&addition_expectation(
|
||||
client
|
||||
.get(format!("{base}/api/auth/whoami"))
|
||||
.header(COOKIE, &cookie),
|
||||
artifacts,
|
||||
)?),
|
||||
"start distinct authenticated self-addition ceremony",
|
||||
artifacts,
|
||||
)?)?;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//! browser suiteの子process回収と実行期限を共有する。診断にraw IPCを複製しない。
|
||||
use super::*;
|
||||
use std::process::{Child, ExitStatus};
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub(super) struct BrowserProcess(pub(super) Option<Child>);
|
||||
impl BrowserProcess {
|
||||
pub(super) fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
|
||||
pub(super) fn wait_bounded(mut self, timeout: Duration) -> Result<ExitStatus> {
|
||||
let mut child = self.0.take().expect("browser process retained");
|
||||
let pid = child.id() as libc::pid_t;
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
let result = receive.recv_timeout(timeout);
|
||||
if result.is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
Ok(result.map_err(|_| "browser E2E timed out; see sanitized browser diagnostic")??)
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserProcess {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut child) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,11 @@
|
||||
//! Deno/Playwright Chromiumと最新production buildが必要な明示選択suite:
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys real_backend_browser -- --ignored`
|
||||
//! 通常auth_passkeysの成功/ignoreを、このbrowser接続の実行成功とは数えない。
|
||||
use super::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::io::{BufRead, BufReader, Read};
|
||||
use std::net::TcpListener;
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::process::Stdio;
|
||||
use std::sync::mpsc;
|
||||
use std::time::Instant;
|
||||
use yoi_workspace_server::auth::{
|
||||
@@ -62,38 +63,6 @@ fn expired_session_fixture(environment: &WorkerE2eEnvironment) -> Result<Value>
|
||||
]}))
|
||||
}
|
||||
|
||||
struct BrowserProcess(Option<Child>);
|
||||
impl BrowserProcess {
|
||||
fn child(&mut self) -> &mut Child {
|
||||
self.0.as_mut().expect("browser process retained")
|
||||
}
|
||||
}
|
||||
impl Drop for BrowserProcess {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut child) = self.0.take() else {
|
||||
return;
|
||||
};
|
||||
if matches!(child.try_wait(), Ok(Some(_))) {
|
||||
return;
|
||||
}
|
||||
let pid = child.id() as libc::pid_t;
|
||||
// Denoのsignal handlerがPlaywright/HTTPを閉じる猶予を与える。
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGTERM);
|
||||
}
|
||||
let (send, receive) = mpsc::channel();
|
||||
let waiter = std::thread::spawn(move || {
|
||||
let _ = send.send(child.wait());
|
||||
});
|
||||
if receive.recv_timeout(Duration::from_secs(3)).is_err() {
|
||||
unsafe {
|
||||
libc::kill(pid, libc::SIGKILL);
|
||||
}
|
||||
}
|
||||
let _ = waiter.join();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production Web assets, Deno and Playwright Chromium"]
|
||||
fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit_logout() -> Result
|
||||
@@ -132,7 +101,8 @@ fn real_backend_browser_cookies_preserve_identity_expiry_return_url_and_explicit
|
||||
drop(reservation);
|
||||
let stderr = std::fs::File::create(environment.artifacts_dir.join("browser.stderr.log"))?;
|
||||
let mut process = BrowserProcess(Some(
|
||||
Command::new("deno")
|
||||
environment
|
||||
.browser_command(Path::new("deno"))?
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
//! 実Backend・本番SPA・Chromium native WebAuthn + CTAP2による旧鍵移行。
|
||||
//! random user handle × resident=false/true × 同じ/別の認証器を検査する。
|
||||
//! 旧鍵fixtureのuser.id/residency指定だけを変更し、実attestationを本番verifier/storeへ送る。
|
||||
//! 現行登録を旧挙動と偽らず、SQL直書き・認証API/mock navigator置換はしない。
|
||||
//! 起動/DB/HOME/XDG/port/回収はWorkerE2eEnvironment、browser profileはその一時領域が所有する。
|
||||
//! DenoとPlaywright Chromium、最新production assets/binariesを呼出側が用意する。
|
||||
//! `cargo test -p yoi-e2e --features e2e --test auth_passkeys native_ctap2 -- --ignored`
|
||||
//! 暗黙buildを避ける実行時は既存providerの3つのbinary overrideを全て指定する。
|
||||
//! DENO_DIR/PLAYWRIGHT_BROWSERS_PATHは事前取得済みtoolの供給元として明示する。
|
||||
//! 物理鍵/実スマホ/provider選択は未確認。CI自動配線の代用にはしない。
|
||||
use super::browser_process::BrowserProcess;
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::process::Stdio;
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires production assets/binaries, cached Deno dependencies and Playwright Chromium"]
|
||||
fn native_ctap2_legacy_random_handle_residency_exclusion_and_other_authenticator_migration()
|
||||
-> Result {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../..")
|
||||
.canonicalize()?;
|
||||
let build = root.join("web/workspace/build");
|
||||
if !build.join("index.html").is_file() {
|
||||
return Err("native browser E2E requires an existing production Web build".into());
|
||||
}
|
||||
let mut environment = WorkerE2eEnvironment::new()?;
|
||||
let reservation = TcpListener::bind("127.0.0.1:0")?;
|
||||
let origin = format!("http://localhost:{}", reservation.local_addr()?.port());
|
||||
std::fs::write(
|
||||
&environment.server_config,
|
||||
format!("[browser]\npublic_url = \"{origin}\"\n"),
|
||||
)?;
|
||||
environment.start_empty_backend()?;
|
||||
let admin =
|
||||
environment.setup_hosting_admin(&format!("{}-admin", environment.account_handle), true)?;
|
||||
let bearer = admin["access_token"]
|
||||
.as_str()
|
||||
.ok_or("missing setup token")?;
|
||||
let base = environment
|
||||
.server_url
|
||||
.as_deref()
|
||||
.ok_or("missing Backend URL")?;
|
||||
let response = http_client()?
|
||||
.put(format!("{base}/api/hosting/settings"))
|
||||
.bearer_auth(bearer)
|
||||
.json(&json!({"self_registration_enabled": true}))
|
||||
.send()?;
|
||||
if !response.status().is_success() {
|
||||
return Err("native fixture could not enable synthetic self-registration".into());
|
||||
}
|
||||
// Server public_urlと同一originを使う。API proxyだけでOrigin/Cookieを書き換えない。
|
||||
drop(reservation);
|
||||
let stderr =
|
||||
std::fs::File::create(environment.artifacts_dir.join("native-browser.stderr.log"))?;
|
||||
let mut command = environment.browser_command(Path::new("deno"))?;
|
||||
command
|
||||
.current_dir(&root)
|
||||
.args([
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--no-lock",
|
||||
"--cached-only",
|
||||
"--allow-env",
|
||||
"--allow-net=127.0.0.1,localhost",
|
||||
"--allow-read",
|
||||
"--allow-write",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tests/e2e/browser/native_passkey_migration.ts",
|
||||
base,
|
||||
&origin,
|
||||
build.to_str().ok_or("non-UTF8 build path")?,
|
||||
&environment.account_handle,
|
||||
environment
|
||||
.artifacts_dir
|
||||
.to_str()
|
||||
.ok_or("non-UTF8 artifacts path")?,
|
||||
])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(stderr);
|
||||
let process = BrowserProcess(Some(command.spawn()?));
|
||||
let status = process.wait_bounded(Duration::from_secs(180))?;
|
||||
if !status.success() {
|
||||
return Err(format!(
|
||||
"native CTAP2 matrix failed; sanitized diagnostics: {}",
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-browser.stderr.log")
|
||||
.display()
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let report: Value = serde_json::from_slice(&std::fs::read(
|
||||
environment
|
||||
.artifacts_dir
|
||||
.join("native-migration-result.json"),
|
||||
)?)?;
|
||||
if report["status"] != "passed" || report["rows"].as_array().map(Vec::len) != Some(4) {
|
||||
return Err("native browser did not confirm all four matrix rows".into());
|
||||
}
|
||||
let cleanup = environment.cleanup()?;
|
||||
assert!(
|
||||
cleanup.cleanup_success,
|
||||
"owned native browser environment cleanup failed"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -21,7 +21,7 @@
|
||||
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
|
||||
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
|
||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
|
||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts test/auth-browser/passkey-state.browser.test.ts",
|
||||
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts test/auth-browser/sidebar-shell.browser.test.ts"
|
||||
},
|
||||
"imports": {
|
||||
|
||||
@@ -21,13 +21,33 @@ export type RequestActor = { user_id: string, account_id: string, handle: string
|
||||
|
||||
export type BrowserSessionState = { "kind": "anonymous", } | { "kind": "authenticated", expires_at: string | null, } | { "kind": "expired", };
|
||||
|
||||
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState, };
|
||||
export type WhoamiResponse = { actor: RequestActor | null, session: BrowserSessionState,
|
||||
/**
|
||||
* Non-secret comparison evidence, never an authentication credential.
|
||||
*/
|
||||
authentication_reference: string | null, };
|
||||
|
||||
export type AuthBootstrapUserRequest = { handle: string, display_name?: string | null, };
|
||||
|
||||
export type AuthUserResponse = { user: AuthenticatedUser, };
|
||||
export type AuthUserResponse = { user: AuthenticatedUser,
|
||||
/**
|
||||
* Comparison evidence for the exact newly issued session, not a credential.
|
||||
*/
|
||||
authentication_reference: string, };
|
||||
|
||||
export type PasskeyAdditionOptionsRequest = { browser_origin?: string | null, };
|
||||
export type PasskeyAdditionOptionsRequest = { expected_user_id: string, expected_account_id: string, expected_authentication_reference: string, browser_origin?: string | null, };
|
||||
|
||||
export type PasskeySummary = { key_reference: string, created_at: string, last_used_at: string | null, normal_login_verified_at: string | null, };
|
||||
|
||||
export type PasskeyListResponse = { user: AuthenticatedUser, keys: Array<PasskeySummary>, };
|
||||
|
||||
export type PasskeyAdditionResultRequest = { challenge_id: string, expected_user_id: string, expected_account_id: string,
|
||||
/**
|
||||
* Original options authentication reference; the current same-owner session may differ.
|
||||
*/
|
||||
expected_authentication_reference: string, };
|
||||
|
||||
export type PasskeyAdditionResultResponse = { "status": "unknown", } | { "status": "saved", user: AuthenticatedUser, key: PasskeySummary, };
|
||||
|
||||
export type PasskeyRegistrationOptionsRequest = { handle: string, display_name?: string | null, browser_origin?: string | null, };
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:5de3f3b716555717d3274b8c6a385fb66624585941628a53b8b3050cb58f8ab7)
|
||||
// Input contract: 0.1.0 (sha256:3e33a924668d7f7c07611d45b2fda7f1115c66431b741c94e1ff4e8e3e559b23)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<details class="legacy-login" bind:open={legacyOpen}>
|
||||
<summary>Use an older key with account ID</summary>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void submit(true); }}>
|
||||
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> and add a discoverable passkey for ID-free login. Your existing keys are preserved.</p>
|
||||
<p class="muted">If an older key is not listed or cannot sign in above, use compatibility login. After login, open <a href="/account">Account</a> to inspect keys and verify migration. Adding with the same older-key authenticator may be excluded; use a different saved-key provider or authenticator. Keep older keys until normal ID-free login to the intended account is tested.</p>
|
||||
<label>Account ID (handle)
|
||||
<input bind:value={handle} required maxlength="128" autocomplete="username" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="legacy-id-help" />
|
||||
</label>
|
||||
@@ -131,6 +131,7 @@
|
||||
{/if}
|
||||
{/if}
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<nav aria-label="Authentication">
|
||||
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
{#if !protectedPage}
|
||||
{@render children()}
|
||||
{:else}
|
||||
{#if session.kind === 'loading'}
|
||||
{#if session.kind === 'loading' && !admitted}
|
||||
<div class="auth-page"><p role="status">Loading session…</p></div>
|
||||
{:else if session.kind === 'error'}
|
||||
<div class="auth-page">
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
{#if session.kind === 'ready' ? session.value.session.kind === 'authenticated' && sameActor : admitted}
|
||||
<!-- 再確認中は表示/操作を停止し、進行中のlogout等をunmountで取消さない。 -->
|
||||
<div hidden={session.kind !== 'ready'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||
<!-- 再確認中は既存内容と操作進捗を可視に保ち、操作だけをinertで停止する。 -->
|
||||
<div hidden={session.kind === 'error'} inert={session.kind !== 'ready'}>{@render children()}</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { activateWithPasskey } from "./activation.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
function confirmedSession() {
|
||||
return {
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
};
|
||||
}
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
@@ -52,9 +60,14 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
String(input) === "/api/auth/whoami"
|
||||
? confirmedSession()
|
||||
: String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
: {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
}, async (options) => {
|
||||
equal(options.signal === signal, true);
|
||||
@@ -67,16 +80,21 @@ Deno.test("activation uses only dedicated endpoints and confirms normalized targ
|
||||
equal(requests.map((r) => r.path), [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
for (const { path, init } of requests) {
|
||||
equal(init.method ?? "GET", path === "/api/auth/whoami" ? "GET" : "POST");
|
||||
equal(init.signal === signal, true);
|
||||
equal(init.credentials, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
if (path !== "/api/auth/whoami") {
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
}
|
||||
equal(new Headers(init.headers).has("authorization"), false);
|
||||
}
|
||||
equal(requests[2].init.body, undefined);
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
handle: "alice",
|
||||
code: "synthetic-input-only",
|
||||
@@ -125,9 +143,10 @@ Deno.test("activation sanitizes HTTP transport malformed and mismatched completi
|
||||
return Response.json({ unexpected: "synthetic-input-only" });
|
||||
}
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: { ...authUser, handle: "other" } },
|
||||
String(input).endsWith("/options") ? registrationOptions : {
|
||||
user: { ...authUser, handle: "other" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
@@ -173,3 +192,201 @@ Deno.test("activation invalid IDs and oversized code are rejected before network
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
for (
|
||||
const mode of [
|
||||
"anonymous",
|
||||
"bob",
|
||||
"account-mismatch",
|
||||
"reference-mismatch",
|
||||
"api-token",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
Deno.test(
|
||||
"activation rejects " + mode +
|
||||
" whoami after successful completion without retry",
|
||||
async () => {
|
||||
const paths: string[] = [];
|
||||
await boundary(
|
||||
async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path !== "/api/auth/whoami") {
|
||||
throw new Error("Unexpected endpoint");
|
||||
}
|
||||
if (mode === "unavailable") {
|
||||
return new Response("private whoami diagnostic", { status: 503 });
|
||||
}
|
||||
if (mode === "anonymous") {
|
||||
return Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
const confirmation = confirmedSession();
|
||||
if (mode === "bob") {
|
||||
confirmation.actor = {
|
||||
...confirmation.actor,
|
||||
user_id: "user-bob",
|
||||
account_id: "account-bob",
|
||||
handle: "bob",
|
||||
display_name: "Bob",
|
||||
};
|
||||
}
|
||||
if (mode === "account-mismatch") {
|
||||
confirmation.actor.account_id = "account-bob";
|
||||
}
|
||||
if (mode === "reference-mismatch") {
|
||||
confirmation.authentication_reference = "another-authentication";
|
||||
}
|
||||
if (mode === "api-token") {
|
||||
confirmation.actor.auth_method = "api_token";
|
||||
}
|
||||
return Response.json(confirmation);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(paths, [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (const stage of ["prompt", "completion", "confirmation"] as const) {
|
||||
Deno.test(
|
||||
"activation session generation change during " + stage +
|
||||
" fences stale work",
|
||||
async () => {
|
||||
const paths: string[] = [];
|
||||
await boundary(async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
if (stage === "completion") browserSession.invalidate();
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (stage === "confirmation") browserSession.invalidate();
|
||||
return Response.json(confirmedSession());
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
}, async () => {
|
||||
if (stage === "prompt") browserSession.invalidate();
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(paths, [
|
||||
"/api/auth/activation/options",
|
||||
...(stage === "prompt" ? [] : ["/api/auth/activation/complete"]),
|
||||
...(stage === "confirmation" ? ["/api/auth/whoami"] : []),
|
||||
]);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
for (
|
||||
const phase of ["options", "prompt", "complete", "confirmation"] as const
|
||||
) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
Deno.test(`activation fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const previous = Object.getOwnPropertyDescriptor(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
);
|
||||
let stamp = "initial-operation-stamp";
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: { getItem: () => stamp },
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const paths: string[] = [];
|
||||
try {
|
||||
await boundary(async (input) => {
|
||||
const path = String(input);
|
||||
paths.push(path);
|
||||
if (
|
||||
(phase === "options" && path.endsWith("/options")) ||
|
||||
(phase === "complete" && path.endsWith("/complete")) ||
|
||||
(phase === "confirmation" && path === "/api/auth/whoami")
|
||||
) {
|
||||
stamp = JSON.stringify({ id: "other-tab", reason });
|
||||
}
|
||||
return Response.json(
|
||||
path.endsWith("/options")
|
||||
? registrationOptions
|
||||
: path.endsWith("/complete")
|
||||
? {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}
|
||||
: confirmedSession(),
|
||||
);
|
||||
}, async () => {
|
||||
if (phase === "prompt") {
|
||||
stamp = JSON.stringify({ id: "other-tab", reason });
|
||||
}
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(browserSession.authenticationGeneration, generation);
|
||||
equal(stamp === "initial-operation-stamp", false);
|
||||
equal(
|
||||
paths.filter((path) => path.endsWith("/complete")).length,
|
||||
phase === "complete" || phase === "confirmation" ? 1 : 0,
|
||||
);
|
||||
equal(paths.includes("/api/auth/whoami"), phase === "confirmation");
|
||||
});
|
||||
} finally {
|
||||
if (previous) {
|
||||
Object.defineProperty(
|
||||
globalThis,
|
||||
"localStorage",
|
||||
previous,
|
||||
);
|
||||
} else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ import type {
|
||||
ActivationOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { loadWhoami, readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { browserSession } from "./session-state.ts";
|
||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
@@ -19,6 +20,16 @@ export async function activateWithPasskey(
|
||||
code: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
// 別tabのstorage event配送より先にcredential/応答が返っても送信・適用しない。
|
||||
const assertCurrent = () => {
|
||||
signal.throwIfAborted();
|
||||
if (
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) throw new Error("Activation superseded");
|
||||
};
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
||||
@@ -28,7 +39,7 @@ export async function activateWithPasskey(
|
||||
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
|
||||
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
@@ -44,7 +55,9 @@ export async function activateWithPasskey(
|
||||
await response.body?.cancel();
|
||||
throw new Error("Activation rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
const result = await readBoundedAuthResponseJson(response);
|
||||
assertCurrent();
|
||||
return result;
|
||||
};
|
||||
const options = await request("/api/auth/activation/options", {
|
||||
handle,
|
||||
@@ -54,22 +67,33 @@ export async function activateWithPasskey(
|
||||
code = "";
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Registration cancelled");
|
||||
signal.throwIfAborted();
|
||||
assertCurrent();
|
||||
const result = parseAuthUserResponse(
|
||||
await request("/api/auth/activation/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
}),
|
||||
).user;
|
||||
signal.throwIfAborted();
|
||||
if (result.handle !== handle) throw new Error("Activation target mismatch");
|
||||
return result;
|
||||
);
|
||||
assertCurrent();
|
||||
if (result.user.handle !== handle) {
|
||||
throw new Error("Activation target mismatch");
|
||||
}
|
||||
const current = await loadWhoami(signal);
|
||||
assertCurrent();
|
||||
if (
|
||||
current.session.kind !== "authenticated" ||
|
||||
current.actor?.auth_method !== "browser_session" ||
|
||||
current.actor.user_id !== result.user.user_id ||
|
||||
current.actor.account_id !== result.user.account_id ||
|
||||
current.authentication_reference !== result.authentication_reference
|
||||
) throw new Error("Activation session unconfirmed");
|
||||
return result.user;
|
||||
} catch {
|
||||
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
||||
throw new Error("Activation could not be confirmed.");
|
||||
|
||||
@@ -67,6 +67,7 @@ export function credentialFixture(authentication = false): Credential {
|
||||
export function whoamiPayload(actor: unknown = null) {
|
||||
return {
|
||||
actor,
|
||||
authentication_reference: actor === null ? null : "fixture-authentication",
|
||||
session: actor === null
|
||||
? { kind: "anonymous" }
|
||||
: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
DeviceLoginApproveRequest,
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyAdditionResultRequest,
|
||||
PasskeyLoginCompleteRequest,
|
||||
PasskeyLoginOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
@@ -8,24 +9,38 @@ import type {
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import {
|
||||
authenticationCredentialToJson,
|
||||
type AuthUser,
|
||||
AuthPayloadError,
|
||||
type DeviceApprovalResponse,
|
||||
parseAdditionResult,
|
||||
parseAuthUserResponse,
|
||||
parseDeviceApprovalResponse,
|
||||
parseLogoutResponse,
|
||||
parsePasskeyList,
|
||||
parseWhoamiResponse,
|
||||
type PasskeyUserResponse,
|
||||
prepareLoginOptions,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
type WhoamiResponse,
|
||||
} from "#lib/workspace/auth/model.ts";
|
||||
|
||||
import {
|
||||
authCheckpoint,
|
||||
AuthOperationError,
|
||||
type AuthStage,
|
||||
classifyAuthFailure,
|
||||
expectedAuthSession,
|
||||
recordAdditionAttempt,
|
||||
} from "./operation.ts";
|
||||
|
||||
export class AuthRequestError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
constructor(readonly status: number, readonly code: string | null = null) {
|
||||
super(`Auth request failed (${status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
class AuthResponseError extends Error {}
|
||||
|
||||
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||||
|
||||
export async function readBoundedAuthResponseJson(
|
||||
@@ -39,13 +54,15 @@ export async function readBoundedAuthResponseJson(
|
||||
parsed > MAX_AUTH_RESPONSE_BYTES
|
||||
) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body exceeds the size limit.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (response.body === null) {
|
||||
throw new Error("Invalid auth response: response body is missing.");
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body is missing.",
|
||||
);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
@@ -58,7 +75,7 @@ export async function readBoundedAuthResponseJson(
|
||||
totalBytes += value.byteLength;
|
||||
if (totalBytes > MAX_AUTH_RESPONSE_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new Error(
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body exceeds the size limit.",
|
||||
);
|
||||
}
|
||||
@@ -79,7 +96,9 @@ export async function readBoundedAuthResponseJson(
|
||||
new TextDecoder("utf-8", { fatal: true }).decode(bytes),
|
||||
) as unknown;
|
||||
} catch {
|
||||
throw new Error("Invalid auth response: response body is not valid JSON.");
|
||||
throw new AuthResponseError(
|
||||
"Invalid auth response: response body is not valid JSON.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,8 +115,27 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new AuthRequestError(response.status);
|
||||
let code: string | null = null;
|
||||
try {
|
||||
const value = await readBoundedAuthResponseJson(response);
|
||||
const message = typeof value === "object" && value !== null
|
||||
? (value as { message?: unknown }).message
|
||||
: null;
|
||||
if (typeof message === "string") {
|
||||
code = [
|
||||
"auth_required",
|
||||
"passkey_authentication_changed",
|
||||
"passkey_registration_binding_mismatch",
|
||||
"webauthn_registration_verification_failed",
|
||||
"webauthn_login_verification_failed",
|
||||
"invalid_passkey_challenge",
|
||||
"expired_passkey_challenge",
|
||||
"self_registration_disabled",
|
||||
"passkey_activation_required",
|
||||
].find((code) => message.startsWith(code + ":")) ?? null;
|
||||
}
|
||||
} catch { /* 本文・解析例外は保存しない。statusだけでも失敗として扱う。 */ }
|
||||
throw new AuthRequestError(response.status, code);
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
}
|
||||
@@ -122,7 +160,7 @@ export async function registerPasskey(
|
||||
handle: string,
|
||||
displayName?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const optionsRequest: PasskeyRegistrationOptionsRequest = {
|
||||
handle,
|
||||
display_name: displayName ?? null,
|
||||
@@ -131,8 +169,21 @@ export async function registerPasskey(
|
||||
return await createPasskey("registration", optionsRequest, signal);
|
||||
}
|
||||
|
||||
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
|
||||
export async function addPasskey(
|
||||
signal?: AbortSignal,
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const expected = expectedAuthSession(signal) ?? await loadWhoami(signal);
|
||||
if (!expected.actor || !expected.authentication_reference) {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
const optionsRequest: PasskeyAdditionOptionsRequest = {
|
||||
expected_user_id: expected.actor.user_id,
|
||||
expected_account_id: expected.actor.account_id,
|
||||
expected_authentication_reference: expected.authentication_reference,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
return await createPasskey("addition", optionsRequest, signal);
|
||||
@@ -144,51 +195,88 @@ async function createPasskey(
|
||||
| PasskeyRegistrationOptionsRequest
|
||||
| PasskeyAdditionOptionsRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: null;
|
||||
const challengeId = optionsRecord?.challenge_id;
|
||||
if (typeof challengeId !== "string" || challengeId.length === 0) {
|
||||
throw new Error(
|
||||
"Invalid auth payload: registration_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof navigator.credentials?.create !== "function") {
|
||||
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.create({
|
||||
signal,
|
||||
publicKey: prepareRegistrationOptions(options),
|
||||
});
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
let stage: AuthStage = "options";
|
||||
let sent = false;
|
||||
try {
|
||||
await authCheckpoint(signal, stage);
|
||||
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
stage = "authenticator";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (typeof navigator.credentials?.create !== "function") {
|
||||
throw new DOMException("", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.create({
|
||||
signal,
|
||||
publicKey,
|
||||
});
|
||||
if (!credential) throw new DOMException("", "NotAllowedError");
|
||||
signal?.throwIfAborted();
|
||||
stage = "conversion";
|
||||
await authCheckpoint(signal, stage);
|
||||
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
};
|
||||
stage = "before-complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
stage = "complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (purpose === "addition") {
|
||||
const expected = optionsRequest as PasskeyAdditionOptionsRequest;
|
||||
recordAdditionAttempt(signal, {
|
||||
challenge_id: challengeId,
|
||||
expected_user_id: expected.expected_user_id,
|
||||
expected_account_id: expected.expected_account_id,
|
||||
expected_authentication_reference:
|
||||
expected.expected_authentication_reference,
|
||||
});
|
||||
}
|
||||
sent = true;
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
error.code === "auth_required" ||
|
||||
error.code === "passkey_authentication_changed" ||
|
||||
error.code === "passkey_registration_binding_mismatch"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AuthResponseError || error instanceof AuthPayloadError
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "invalid-response",
|
||||
sent ? "unknown" : "not-sent",
|
||||
);
|
||||
}
|
||||
throw classifyAuthFailure(error, stage, sent);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
return await authenticateWithPasskey({
|
||||
browser_origin: window.location.origin,
|
||||
}, signal);
|
||||
@@ -197,7 +285,7 @@ export async function loginWithPasskey(
|
||||
export async function loginWithLegacyPasskey(
|
||||
handle: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
const loginHandle = handle.trim();
|
||||
if (!loginHandle) {
|
||||
throw new Error("User handle is required for legacy passkey login.");
|
||||
@@ -211,52 +299,77 @@ export async function loginWithLegacyPasskey(
|
||||
async function authenticateWithPasskey(
|
||||
optionsRequest: PasskeyLoginOptionsRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
: null;
|
||||
const challengeId = optionsRecord?.challenge_id;
|
||||
if (typeof challengeId !== "string" || challengeId.length === 0) {
|
||||
throw new Error(
|
||||
"Invalid auth payload: login_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
if (typeof navigator.credentials?.get !== "function") {
|
||||
throw new DOMException("Passkeys unavailable", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.get({
|
||||
signal,
|
||||
publicKey: prepareLoginOptions(options),
|
||||
});
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
};
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
): Promise<PasskeyUserResponse> {
|
||||
let stage: AuthStage = "options";
|
||||
let sent = false;
|
||||
try {
|
||||
await authCheckpoint(signal, stage);
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
});
|
||||
const publicKey = prepareLoginOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
stage = "authenticator";
|
||||
await authCheckpoint(signal, stage);
|
||||
if (typeof navigator.credentials?.get !== "function") {
|
||||
throw new DOMException("", "NotSupportedError");
|
||||
}
|
||||
const credential = await navigator.credentials.get({ signal, publicKey });
|
||||
if (!credential) throw new DOMException("", "NotAllowedError");
|
||||
signal?.throwIfAborted();
|
||||
stage = "conversion";
|
||||
await authCheckpoint(signal, stage);
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
};
|
||||
stage = "before-complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
stage = "complete";
|
||||
await authCheckpoint(signal, stage);
|
||||
sent = true;
|
||||
const result = parseAuthUserResponse(
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (error instanceof AuthRequestError) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
error.code === "auth_required" ||
|
||||
error.code === "passkey_authentication_changed" ||
|
||||
error.code === "passkey_registration_binding_mismatch"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
sent ? error.status >= 500 ? "unknown" : "rejected" : "not-sent",
|
||||
error.status,
|
||||
);
|
||||
}
|
||||
if (
|
||||
error instanceof AuthResponseError || error instanceof AuthPayloadError
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "invalid-response",
|
||||
sent ? "unknown" : "not-sent",
|
||||
);
|
||||
}
|
||||
throw classifyAuthFailure(error, stage, sent);
|
||||
}
|
||||
}
|
||||
|
||||
export async function approveDeviceLogin(
|
||||
userCode: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DeviceApprovalResponse> {
|
||||
await authCheckpoint(signal, "before-complete");
|
||||
const request: DeviceLoginApproveRequest = { user_code: userCode };
|
||||
return parseDeviceApprovalResponse(
|
||||
await requestJson("/api/auth/device-login/approve", {
|
||||
@@ -266,3 +379,19 @@ export async function approveDeviceLogin(
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadPasskeys(signal?: AbortSignal) {
|
||||
return parsePasskeyList(await requestJson("/api/auth/passkeys", { signal }));
|
||||
}
|
||||
export async function readAdditionResult(
|
||||
request: PasskeyAdditionResultRequest,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
return parseAdditionResult(
|
||||
await requestJson("/api/auth/passkeys/addition/result", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import { AuthRequestError } from "./api.ts";
|
||||
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
|
||||
import { AuthRequestError, loadWhoami, readAdditionResult } from "./api.ts";
|
||||
import {
|
||||
browserSession,
|
||||
observeBrowserSession,
|
||||
sessionMutation,
|
||||
} from "./session-state.ts";
|
||||
import { notifySessionChanged } from "./hosting.ts";
|
||||
import type { RequestActor, WhoamiResponse } from "./model.ts";
|
||||
import type { AuthUser, RequestActor, WhoamiResponse } from "./model.ts";
|
||||
import {
|
||||
AuthOperationError,
|
||||
type AuthStage,
|
||||
observeAuthOperation,
|
||||
operationErrorMessage,
|
||||
} from "./operation.ts";
|
||||
|
||||
export type BrowserSession =
|
||||
| { kind: "loading" }
|
||||
@@ -13,41 +20,60 @@ export type BrowserSession =
|
||||
| ({ kind: "ready" } & WhoamiResponse);
|
||||
|
||||
export function authErrorMessage(error: unknown): string {
|
||||
if (error instanceof AuthOperationError) return operationErrorMessage(error);
|
||||
if (error instanceof AuthRequestError) {
|
||||
if (error.status === 401) {
|
||||
return "Authentication request was rejected. Check your session or restart the Passkey operation.";
|
||||
}
|
||||
if (error.status === 400 || error.status === 410) {
|
||||
return "The request or authentication challenge is invalid or expired. Restart the operation.";
|
||||
}
|
||||
return error.message;
|
||||
return `Authentication request was rejected (${error.status}). Check your session or restart the operation.`;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "NotSupportedError") {
|
||||
return "This browser or key cannot complete this passkey operation. Use a supported device or key with saved passkeys and PIN or biometric verification. Completion is not confirmed.";
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
||||
return "Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.";
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
|
||||
}
|
||||
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
|
||||
return "Authentication could not be confirmed. Check your session before retrying the operation.";
|
||||
// 例外本文/causeや外部応答は表示にも診断にも複写しない。
|
||||
return "Authentication could not be confirmed. Check your session and the operation result before retrying.";
|
||||
}
|
||||
function sameUser(a: AuthUser | null, b: AuthUser | null): boolean {
|
||||
return a === null
|
||||
? b === null
|
||||
: b !== null && a.user_id === b.user_id && a.account_id === b.account_id;
|
||||
}
|
||||
function sameSession(a: WhoamiResponse, b: WhoamiResponse): boolean {
|
||||
// anonymous/expiredはいずれも認証主体もbindingもない。別tabのlogoutでexpiredの
|
||||
// 表示だけが古くなっても、新たな明示loginを拒否しない。認証済みへの変化は拒否する。
|
||||
if (
|
||||
!a.actor && !b.actor && a.session.kind !== "authenticated" &&
|
||||
b.session.kind !== "authenticated"
|
||||
) return true;
|
||||
return sameUser(a.actor, b.actor) && a.session.kind === b.session.kind &&
|
||||
(a.session.kind !== "authenticated" ||
|
||||
a.authentication_reference === b.authentication_reference &&
|
||||
b.session.kind === "authenticated" &&
|
||||
a.session.expires_at === b.session.expires_at &&
|
||||
a.actor?.auth_method === b.actor?.auth_method);
|
||||
}
|
||||
function returnedUser(value: unknown): AuthUser | null {
|
||||
if (typeof value !== "object" || value === null) return null;
|
||||
const user = (value as { user?: Partial<AuthUser> }).user;
|
||||
return user && typeof user.user_id === "string" &&
|
||||
typeof user.account_id === "string" && typeof user.handle === "string" &&
|
||||
typeof user.display_name === "string"
|
||||
? user as AuthUser
|
||||
: null;
|
||||
}
|
||||
|
||||
export class BrowserAuth {
|
||||
session = $state<BrowserSession>({ kind: "loading" });
|
||||
busy = $state(false);
|
||||
error = $state("");
|
||||
status = $state("");
|
||||
additionAttempt = $state<PasskeyAdditionResultRequest | null>(null);
|
||||
additionSaved = $state(false);
|
||||
private unsubscribe: () => void;
|
||||
private disposed = false;
|
||||
private controller: AbortController | null = null;
|
||||
private operationGeneration: number | null = null;
|
||||
|
||||
constructor() {
|
||||
observeBrowserSession();
|
||||
this.unsubscribe = browserSession.subscribe((snapshot) => {
|
||||
// 操作pendingはsession確認中と別。formを消してfocus/入力を失わず、busyで再操作を止める。
|
||||
if (
|
||||
this.operationGeneration !== null &&
|
||||
this.operationGeneration !== browserSession.authenticationGeneration
|
||||
) this.controller?.abort();
|
||||
if (this.busy && snapshot.kind === "loading") return;
|
||||
if (!this.disposed) {
|
||||
this.session = snapshot.kind === "ready"
|
||||
@@ -56,75 +82,239 @@ export class BrowserAuth {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get actor(): RequestActor | null {
|
||||
return this.session.kind === "ready" ? this.session.actor : null;
|
||||
}
|
||||
|
||||
async ensure(): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
await browserSession.ensure();
|
||||
if (!this.busy && !this.disposed) await browserSession.ensure();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
await browserSession.refresh();
|
||||
if (!this.busy && !this.disposed) await browserSession.refresh();
|
||||
}
|
||||
cancel(): void {
|
||||
this.controller?.abort();
|
||||
}
|
||||
|
||||
async run(
|
||||
pending: string,
|
||||
success: string,
|
||||
operation: (signal: AbortSignal) => Promise<unknown>,
|
||||
intent: "authentication" | "logout" = "authentication",
|
||||
intent: "authentication" | "logout" | "device-approval" = "authentication",
|
||||
): Promise<boolean> {
|
||||
if (
|
||||
this.busy || this.disposed ||
|
||||
(intent !== "logout" && this.session.kind !== "ready")
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
) return false;
|
||||
const mutationReason = intent === "logout" ? "logout" : "authentication";
|
||||
const expected = this.session.kind === "ready" ? this.session : null;
|
||||
this.busy = true;
|
||||
sessionMutation(intent);
|
||||
const authenticationGeneration = browserSession.authenticationGeneration;
|
||||
sessionMutation(mutationReason, true);
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
let acceptedGeneration = generation;
|
||||
let acceptedStamp = stamp;
|
||||
let submittedAddition = false;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.operationGeneration = generation;
|
||||
this.error = "";
|
||||
this.status = pending;
|
||||
const verifySession = async () => {
|
||||
if (stamp !== browserSession.authenticationStamp) controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
let current: WhoamiResponse;
|
||||
try {
|
||||
current = await loadWhoami(controller.signal);
|
||||
} catch {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
if (stamp !== browserSession.authenticationStamp) controller.abort();
|
||||
controller.signal.throwIfAborted();
|
||||
if (!expected || !sameSession(expected, current)) {
|
||||
throw new AuthOperationError(
|
||||
"session-check",
|
||||
"session-changed",
|
||||
"not-sent",
|
||||
);
|
||||
}
|
||||
};
|
||||
observeAuthOperation(controller.signal, {
|
||||
verifySession,
|
||||
expected,
|
||||
additionAttempt: (attempt) => {
|
||||
submittedAddition = true;
|
||||
this.additionAttempt = attempt;
|
||||
this.additionSaved = false;
|
||||
},
|
||||
phase: (stage: AuthStage) => {
|
||||
if (!this.disposed) {
|
||||
this.status = stage === "authenticator"
|
||||
? pending
|
||||
: stage === "complete"
|
||||
? "Confirming the operation with the server…"
|
||||
: stage === "before-complete"
|
||||
? "Checking the account before completion…"
|
||||
: stage === "options"
|
||||
? "Checking the account and requesting passkey options…"
|
||||
: "Preparing the authenticator response…";
|
||||
}
|
||||
},
|
||||
});
|
||||
try {
|
||||
await operation(controller.signal);
|
||||
const superseded =
|
||||
authenticationGeneration !== browserSession.authenticationGeneration;
|
||||
sessionMutation(intent);
|
||||
if (intent === "device-approval") await verifySession();
|
||||
const result = await operation(controller.signal);
|
||||
const user = returnedUser(result);
|
||||
const issuedReference = typeof result === "object" && result !== null
|
||||
? (result as { authentication_reference?: unknown })
|
||||
.authentication_reference
|
||||
: null;
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
this.disposed ||
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) return false;
|
||||
// 自身の再確認によるinvalidateと、他の操作が行ったinvalidateを混同しない。
|
||||
this.operationGeneration = null;
|
||||
sessionMutation(mutationReason, true);
|
||||
const confirmationGeneration = browserSession.authenticationGeneration;
|
||||
acceptedGeneration = confirmationGeneration;
|
||||
const confirmationStamp = browserSession.authenticationStamp;
|
||||
acceptedStamp = confirmationStamp;
|
||||
this.status = "Confirming the expected browser session…";
|
||||
await browserSession.refresh();
|
||||
if (
|
||||
this.disposed || superseded ||
|
||||
confirmationGeneration !== browserSession.authenticationGeneration
|
||||
this.disposed || controller.signal.aborted ||
|
||||
confirmationGeneration !== browserSession.authenticationGeneration ||
|
||||
confirmationStamp !== browserSession.authenticationStamp
|
||||
) {
|
||||
if (!this.disposed) this.status = "";
|
||||
return false;
|
||||
}
|
||||
const snapshot = browserSession.snapshot;
|
||||
const confirmed = snapshot.kind === "ready" &&
|
||||
(intent === "logout"
|
||||
? snapshot.value.actor === null &&
|
||||
snapshot.value.session.kind !== "authenticated"
|
||||
: intent === "device-approval"
|
||||
? snapshot.value.session.kind === "authenticated" && user !== null &&
|
||||
sameUser(user, expected?.actor ?? null) &&
|
||||
sameUser(user, snapshot.value.actor) &&
|
||||
snapshot.value.authentication_reference ===
|
||||
expected?.authentication_reference &&
|
||||
typeof result === "object" && result !== null &&
|
||||
(result as { status?: unknown }).status === "approved"
|
||||
: snapshot.value.session.kind === "authenticated" &&
|
||||
snapshot.value.actor?.auth_method === "browser_session" &&
|
||||
user !== null && typeof issuedReference === "string" &&
|
||||
issuedReference === snapshot.value.authentication_reference &&
|
||||
sameUser(user, snapshot.value.actor) &&
|
||||
(!expected?.actor || sameUser(expected.actor, user)));
|
||||
if (!confirmed) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"saved-session-unconfirmed",
|
||||
);
|
||||
}
|
||||
notifySessionChanged();
|
||||
const confirmed = this.session.kind === "ready";
|
||||
this.status = confirmed ? success : "";
|
||||
return confirmed;
|
||||
if (submittedAddition) this.additionSaved = true;
|
||||
this.status = success;
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (!this.disposed) {
|
||||
this.error = authErrorMessage(error);
|
||||
this.status = "";
|
||||
}
|
||||
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
||||
sessionMutation(intent);
|
||||
await browserSession.refresh();
|
||||
// 古い処理から新しいログアウト/主体確認をinvalidateしない。
|
||||
if (
|
||||
!this.disposed &&
|
||||
acceptedGeneration === browserSession.authenticationGeneration &&
|
||||
acceptedStamp === browserSession.authenticationStamp
|
||||
) {
|
||||
this.operationGeneration = null;
|
||||
sessionMutation(mutationReason, true);
|
||||
await browserSession.refresh();
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
this.operationGeneration = null;
|
||||
this.controller = null;
|
||||
if (!this.disposed) this.busy = false;
|
||||
}
|
||||
}
|
||||
async checkAddition(): Promise<void> {
|
||||
const attempt = this.additionAttempt;
|
||||
if (!attempt || this.busy || this.disposed) return;
|
||||
this.busy = true;
|
||||
this.error = "";
|
||||
this.status = "Checking the saved addition result…";
|
||||
// 保存の履歴は失敗に戻さないが、過去の照合で再追加を許可しない。
|
||||
this.additionSaved = false;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.operationGeneration = generation;
|
||||
const assertCurrent = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (
|
||||
this.disposed ||
|
||||
generation !== browserSession.authenticationGeneration ||
|
||||
stamp !== browserSession.authenticationStamp
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (
|
||||
!before.actor || before.actor.user_id !== attempt.expected_user_id ||
|
||||
before.actor.account_id !== attempt.expected_account_id
|
||||
) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
const result = await readAdditionResult(attempt, controller.signal);
|
||||
assertCurrent();
|
||||
const after = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (!sameSession(before, after)) {
|
||||
throw new AuthOperationError(
|
||||
"confirmation",
|
||||
"session-changed",
|
||||
"unknown",
|
||||
);
|
||||
}
|
||||
if (result.status === "saved" && sameUser(result.user, before.actor)) {
|
||||
this.additionSaved = true;
|
||||
this.status =
|
||||
"This addition saved a key to your account. Your current session was checked; the session issued by completion is not confirmed. Test ID-free login with that key before retiring older-key access.";
|
||||
} else {this.status =
|
||||
"The addition result is still unknown. Completion may still be in flight. Do not add another key yet; check again or use the registered-key list and ID-free login to verify access.";}
|
||||
} catch {
|
||||
if (!this.disposed) {
|
||||
this.busy = false;
|
||||
this.controller = null;
|
||||
this.error =
|
||||
"The addition result could not be read with the current account/session. This does not prove failure. Return to the intended account and check its key list or ID-free login; do not recreate the original session or add blindly.";
|
||||
this.status = "";
|
||||
}
|
||||
} finally {
|
||||
this.operationGeneration = null;
|
||||
this.controller = null;
|
||||
if (!this.disposed) this.busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.unsubscribe();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { parseAuthUserResponse } from "./model.ts";
|
||||
import { parseAuthUser } from "./model.ts";
|
||||
|
||||
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
|
||||
export function normalizeActivationHandle(value: string): string {
|
||||
@@ -57,7 +57,7 @@ export async function issueActivationCode(
|
||||
typeof record.expires_at !== "string" ||
|
||||
!Number.isFinite(Date.parse(record.expires_at))
|
||||
) throw new Error();
|
||||
const user = parseAuthUserResponse({ user: record.user }).user;
|
||||
const user = parseAuthUser(record.user);
|
||||
if (user.handle !== handle) throw new Error();
|
||||
signal.throwIfAborted();
|
||||
return { user, code: record.code, expires_at: record.expires_at };
|
||||
|
||||
@@ -7,6 +7,16 @@ import {
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
const bearerIdentity = {
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
authentication_reference: "fixture-bearer-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
};
|
||||
const completion = {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-issued-authentication",
|
||||
};
|
||||
|
||||
function equal(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Values differ");
|
||||
@@ -65,9 +75,11 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
String(input).endsWith("/whoami")
|
||||
? bearerIdentity
|
||||
: String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
: completion,
|
||||
);
|
||||
}, async () => {
|
||||
equal(
|
||||
@@ -75,11 +87,13 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((request) => request.path), [
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
for (const { path, init } of requests) {
|
||||
equal(init.method, path.endsWith("/whoami") ? "GET" : "POST");
|
||||
equal(new Headers(init.headers).has("cookie"), false);
|
||||
equal(init.credentials, "omit");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
@@ -91,10 +105,15 @@ Deno.test("host enrollment sends bearer only to same-origin addition endpoints,
|
||||
);
|
||||
equal(String(init.body).includes("host-test-token"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
equal(requests[0].init.body, undefined);
|
||||
equal(JSON.parse(String(requests[1].init.body)), {
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference:
|
||||
bearerIdentity.authentication_reference,
|
||||
browser_origin: "https://yoi.example",
|
||||
});
|
||||
const complete = JSON.parse(String(requests[1].init.body));
|
||||
const complete = JSON.parse(String(requests[2].init.body));
|
||||
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||
equal(complete.challenge_id, registrationOptions.challenge_id);
|
||||
equal(complete.credential.response.attestationObject, "AQID");
|
||||
@@ -151,29 +170,46 @@ Deno.test("host enrollment validates options before opening credential prompt",
|
||||
}]
|
||||
) {
|
||||
let calls = 0;
|
||||
await boundary(async () => Response.json(payload), async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
}, async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
});
|
||||
await boundary(
|
||||
async (input) =>
|
||||
Response.json(
|
||||
String(input).endsWith("/whoami") ? bearerIdentity : payload,
|
||||
),
|
||||
async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
},
|
||||
async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
|
||||
for (const abort of [false, true]) {
|
||||
const controller = new AbortController();
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json(registrationOptions);
|
||||
const requests: string[] = [];
|
||||
let prompts = 0;
|
||||
await boundary(async (input) => {
|
||||
requests.push(String(input));
|
||||
return Response.json(
|
||||
String(input).endsWith("/whoami")
|
||||
? bearerIdentity
|
||||
: registrationOptions,
|
||||
);
|
||||
}, async () => {
|
||||
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
|
||||
equal(requests, 1);
|
||||
equal(requests, [
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
]);
|
||||
equal(prompts, 1);
|
||||
}, async () => {
|
||||
prompts++;
|
||||
if (abort) {
|
||||
controller.abort();
|
||||
return credentialFixture();
|
||||
@@ -188,6 +224,9 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
|
||||
let calls = 0;
|
||||
await boundary(async (input) => {
|
||||
calls++;
|
||||
if (String(input).endsWith("/whoami")) {
|
||||
return Response.json(bearerIdentity);
|
||||
}
|
||||
if (String(input).endsWith("/options")) {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
@@ -201,7 +240,7 @@ Deno.test("host enrollment cannot confirm a rejected, lost or malformed completi
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 2);
|
||||
equal(calls, 3);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
parseWhoamiResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
@@ -26,13 +27,14 @@ export async function enrollHostingPasskey(
|
||||
}
|
||||
const request = async (
|
||||
path:
|
||||
| "/api/auth/whoami"
|
||||
| "/api/auth/passkeys/addition/options"
|
||||
| "/api/auth/passkeys/addition/complete",
|
||||
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
body?: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
method: path === "/api/auth/whoami" ? "GET" : "POST",
|
||||
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
|
||||
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
|
||||
credentials: "omit",
|
||||
@@ -53,7 +55,12 @@ export async function enrollHostingPasskey(
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const identity = parseWhoamiResponse(await request("/api/auth/whoami"));
|
||||
if (!identity.actor || !identity.authentication_reference || identity.actor.auth_method !== "api_token") throw new Error("Setup identity unconfirmed");
|
||||
const options = await request("/api/auth/passkeys/addition/options", {
|
||||
expected_user_id: identity.actor.user_id,
|
||||
expected_account_id: identity.actor.account_id,
|
||||
expected_authentication_reference: identity.authentication_reference,
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
import { AuthOperationError } from "./operation.ts";
|
||||
import { loginWithLegacyPasskey, loginWithPasskey } from "./api.ts";
|
||||
import {
|
||||
authUser,
|
||||
@@ -64,7 +65,12 @@ async function boundary(
|
||||
Response.json(body.handle ? legacyLoginOptions : loginOptions),
|
||||
);
|
||||
}
|
||||
return Promise.resolve(Response.json({ user: authUser }));
|
||||
return Promise.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -97,7 +103,7 @@ for (const legacy of [false, true]) {
|
||||
await (legacy
|
||||
? loginWithLegacyPasskey(" alice ", signal)
|
||||
: loginWithPasskey(signal)),
|
||||
authUser,
|
||||
{ user: authUser, authentication_reference: "fixture-authentication" },
|
||||
);
|
||||
equal(state.requests.map(({ path }) => path), [
|
||||
"/api/auth/passkeys/login/options",
|
||||
@@ -131,8 +137,10 @@ for (const legacy of [false, true]) {
|
||||
: loginWithPasskey(controller.signal));
|
||||
throw new Error("Unexpected login success");
|
||||
} catch (error) {
|
||||
if (!(error instanceof DOMException)) throw error;
|
||||
equal(error.name, late ? "AbortError" : "NotAllowedError");
|
||||
if (!(error instanceof AuthOperationError)) throw error;
|
||||
equal(error.stage, "authenticator");
|
||||
equal(error.reason, late ? "stopped" : "cancelled-or-unavailable");
|
||||
equal(error.outcome, "not-sent");
|
||||
}
|
||||
equal(state.requests.length, 1);
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
authenticationCredentialToJson,
|
||||
base64UrlToBuffer,
|
||||
bufferToBase64Url,
|
||||
parseAuthUserResponse,
|
||||
parseDeviceLoginPollResponse,
|
||||
parseDeviceLoginStartResponse,
|
||||
parseWhoamiResponse,
|
||||
@@ -119,12 +120,21 @@ Deno.test("prepareLoginOptions decodes challenge and allowed credential ids", ()
|
||||
|
||||
Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "anonymous" } }),
|
||||
{ actor: null, session: { kind: "anonymous" } },
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
}),
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
},
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
user_id: "user-1",
|
||||
@@ -137,7 +147,12 @@ Deno.test("whoami rejects unknown auth methods and extra fields", () => {
|
||||
"unknown auth method",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseWhoamiResponse({ actor: null, token: "secret" }),
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
token: "secret",
|
||||
}),
|
||||
"unexpected whoami field",
|
||||
);
|
||||
});
|
||||
@@ -197,7 +212,11 @@ Deno.test("auth validation enforces cumulative budgets without echoing unknown k
|
||||
|
||||
const attackerKey = `secret-${"x".repeat(512)}`;
|
||||
try {
|
||||
parseWhoamiResponse({ actor: null, [attackerKey]: true });
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
[attackerKey]: true,
|
||||
});
|
||||
throw new Error("expected an error");
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message.includes(attackerKey)) {
|
||||
@@ -248,23 +267,153 @@ Deno.test("whoami fails closed on actor-state mismatch and malformed expiry", ()
|
||||
};
|
||||
for (
|
||||
const payload of [
|
||||
{ actor, session: { kind: "anonymous" } },
|
||||
{ actor, session: { kind: "expired" } },
|
||||
{ actor, authentication_reference: null, session: { kind: "anonymous" } },
|
||||
{ actor, authentication_reference: null, session: { kind: "expired" } },
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
{ actor, session: { kind: "authenticated", expires_at: null } },
|
||||
{ actor, session: { kind: "authenticated", expires_at: "tomorrow" } },
|
||||
{ actor: null, session: { kind: "future" } },
|
||||
{
|
||||
actor,
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
},
|
||||
{
|
||||
actor,
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "tomorrow" },
|
||||
},
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "future" },
|
||||
},
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired", expires_at: "2099-01-01T00:00:00Z" },
|
||||
},
|
||||
]
|
||||
) assertThrows(() => parseWhoamiResponse(payload), "inconsistent whoami");
|
||||
assertEquals(
|
||||
parseWhoamiResponse({ actor: null, session: { kind: "expired" } }),
|
||||
{ actor: null, session: { kind: "expired" } },
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
{
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("whoami and completion require explicit consistent authentication references", () => {
|
||||
const user = {
|
||||
user_id: "u",
|
||||
account_id: "a",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
const authenticated = {
|
||||
actor: { ...user, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
} as const;
|
||||
for (const reference of [undefined, null, "", 42, "x".repeat(16385)]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
...authenticated,
|
||||
authentication_reference: reference,
|
||||
}),
|
||||
"invalid authenticated reference",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseAuthUserResponse({ user, authentication_reference: reference }),
|
||||
"invalid issuance reference",
|
||||
);
|
||||
}
|
||||
for (const kind of ["anonymous", "expired"]) {
|
||||
assertThrows(
|
||||
() => parseWhoamiResponse({ actor: null, session: { kind } }),
|
||||
"missing anonymous reference",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
parseWhoamiResponse({
|
||||
actor: null,
|
||||
authentication_reference: "issued",
|
||||
session: { kind },
|
||||
}),
|
||||
"anonymous reference must be null",
|
||||
);
|
||||
}
|
||||
const completion = { user, authentication_reference: "issued" };
|
||||
assertEquals(parseAuthUserResponse(completion), completion);
|
||||
assertEquals(
|
||||
parseWhoamiResponse({
|
||||
...authenticated,
|
||||
authentication_reference: "issued",
|
||||
}),
|
||||
{
|
||||
actor: authenticated.actor,
|
||||
authentication_reference: "issued",
|
||||
session: authenticated.session,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("bounded future transports do not discard a created credential and required crypto bytes stay validated", () => {
|
||||
const bytes = new Uint8Array([1, 2, 3]).buffer;
|
||||
const credential = {
|
||||
id: "AQID",
|
||||
rawId: bytes,
|
||||
type: "public-key",
|
||||
getClientExtensionResults: () => ({
|
||||
credProps: { rk: true },
|
||||
prf: { results: { first: bytes } },
|
||||
largeBlob: { blob: bytes },
|
||||
}),
|
||||
response: {
|
||||
clientDataJSON: bytes,
|
||||
attestationObject: bytes,
|
||||
getTransports: () => ["internal", "hybrid", "future-bounded-transport"],
|
||||
},
|
||||
};
|
||||
const converted = registrationCredentialToJson(credential) as {
|
||||
response: { transports: string[] };
|
||||
clientExtensionResults: unknown;
|
||||
};
|
||||
assertEquals(converted.response.transports, ["internal", "hybrid"]);
|
||||
assertEquals(converted.clientExtensionResults, { credProps: { rk: true } });
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
response: { ...credential.response, clientDataJSON: "not-binary" },
|
||||
}),
|
||||
"required client data remains binary",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
response: {
|
||||
...credential.response,
|
||||
getTransports: () => ["x".repeat(16385)],
|
||||
},
|
||||
}),
|
||||
"transport bound remains enforced",
|
||||
);
|
||||
assertThrows(
|
||||
() =>
|
||||
registrationCredentialToJson({
|
||||
...credential,
|
||||
getClientExtensionResults: () => ({ credProps: { rk: bytes } }),
|
||||
}),
|
||||
"supported JSON extension never silently converts binary to an empty object",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -8,6 +8,9 @@ import type {
|
||||
DeviceLoginPollStatus,
|
||||
DeviceLoginStartResponse,
|
||||
LogoutResponse,
|
||||
PasskeyListResponse,
|
||||
PasskeySummary,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
RequestActor,
|
||||
@@ -21,6 +24,9 @@ export type {
|
||||
AuthPublicConfig,
|
||||
DeviceLoginPollResponse,
|
||||
DeviceLoginStartResponse,
|
||||
PasskeyListResponse,
|
||||
PasskeySummary,
|
||||
PasskeyAdditionResultResponse,
|
||||
PasskeyLoginOptionsResponse,
|
||||
PasskeyRegistrationOptionsResponse,
|
||||
RequestActor,
|
||||
@@ -37,8 +43,10 @@ const MAX_DEVICE_LOGIN_SECONDS = 24 * 60 * 60;
|
||||
const MAX_WEBAUTHN_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const BASE64URL_PATTERN = /^[A-Za-z0-9_-]+={0,2}$/;
|
||||
|
||||
export class AuthPayloadError extends Error {}
|
||||
|
||||
function invalid(path: string, reason: string): never {
|
||||
throw new Error(`Invalid auth payload: ${path} ${reason}.`);
|
||||
throw new AuthPayloadError(`Invalid auth payload: ${path} ${reason}.`);
|
||||
}
|
||||
|
||||
function asRecord(value: unknown, path: string): Record<string, unknown> {
|
||||
@@ -119,6 +127,7 @@ function boundedJson(
|
||||
if (budget.remainingNodes < 0) invalid(path, "has too many values");
|
||||
if (depth > MAX_AUTH_VALUE_DEPTH) invalid(path, "is too deeply nested");
|
||||
if (value === null || typeof value === "boolean") return;
|
||||
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value)) invalid(path, "contains unsupported binary data");
|
||||
if (typeof value === "string") {
|
||||
boundedString(value, path, { allowEmpty: true });
|
||||
budget.remainingStringUnits -= value.length;
|
||||
@@ -205,10 +214,10 @@ export function bufferToBase64Url(value: ArrayBuffer): string {
|
||||
function parseAuthenticatorTransport(
|
||||
value: unknown,
|
||||
path: string,
|
||||
): AuthenticatorTransport {
|
||||
): AuthenticatorTransport | null {
|
||||
const transport = boundedString(value, path);
|
||||
if (!["ble", "hybrid", "internal", "nfc", "usb"].includes(transport)) {
|
||||
invalid(path, "contains an unknown authenticator transport");
|
||||
return null;
|
||||
}
|
||||
return transport as AuthenticatorTransport;
|
||||
}
|
||||
@@ -229,9 +238,10 @@ function parseCredentialDescriptor(
|
||||
if (descriptor.transports.length > MAX_AUTH_ARRAY_LENGTH) {
|
||||
invalid(`${path}.transports`, "has too many items");
|
||||
}
|
||||
return descriptor.transports.map((transport, index) =>
|
||||
parseAuthenticatorTransport(transport, `${path}.transports[${index}]`)
|
||||
);
|
||||
return descriptor.transports.flatMap((transport, index) => {
|
||||
const parsed = parseAuthenticatorTransport(transport, `${path}.transports[${index}]`);
|
||||
return parsed === null ? [] : [parsed];
|
||||
});
|
||||
})();
|
||||
return {
|
||||
type: "public-key",
|
||||
@@ -463,7 +473,8 @@ function parseActor(value: unknown, path: string): RequestActor {
|
||||
|
||||
export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
const response = asRecord(value, "whoami");
|
||||
requireExactKeys(response, "whoami", ["actor", "session"]);
|
||||
requireExactKeys(response, "whoami", ["actor", "session", "authentication_reference"]);
|
||||
const authentication_reference = optionalString(response.authentication_reference, "whoami.authentication_reference");
|
||||
const actor = response.actor === null
|
||||
? null
|
||||
: parseActor(response.actor, "whoami.actor");
|
||||
@@ -487,7 +498,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
"must identify the server session expiry",
|
||||
);
|
||||
}
|
||||
return { actor, session: { kind: "authenticated", expires_at: expiry } };
|
||||
if (authentication_reference === null) invalid("whoami", "authenticated session requires comparison evidence");
|
||||
return { actor, authentication_reference, session: { kind: "authenticated", expires_at: expiry } };
|
||||
}
|
||||
requireExactKeys(session, "whoami.session", ["kind"]);
|
||||
if (
|
||||
@@ -496,7 +508,8 @@ export function parseWhoamiResponse(value: unknown): WhoamiResponse {
|
||||
) {
|
||||
invalid("whoami", "inconsistent actor or session state");
|
||||
}
|
||||
return { actor: null, session: { kind: session.kind } };
|
||||
if (authentication_reference !== null) invalid("whoami", "anonymous session cannot identify authentication");
|
||||
return { actor: null, authentication_reference: null, session: { kind: session.kind } };
|
||||
}
|
||||
|
||||
export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||
@@ -537,10 +550,38 @@ export function parseAuthPublicConfig(value: unknown): AuthPublicConfig {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function parseAuthUser(value: unknown): AuthUser {
|
||||
return parseAuthenticatedUser(value, "auth_user.user");
|
||||
}
|
||||
|
||||
export function parseAuthUserResponse(value: unknown): PasskeyUserResponse {
|
||||
const response = asRecord(value, "auth_user");
|
||||
requireExactKeys(response, "auth_user", ["user"]);
|
||||
return { user: parseAuthenticatedUser(response.user, "auth_user.user") };
|
||||
requireExactKeys(response, "auth_user", ["user", "authentication_reference"]);
|
||||
return { user: parseAuthenticatedUser(response.user, "auth_user.user"), authentication_reference: boundedString(response.authentication_reference, "auth_user.authentication_reference") };
|
||||
}
|
||||
|
||||
function parsePasskeySummary(value: unknown): PasskeySummary {
|
||||
const key = asRecord(value, "passkey_summary");
|
||||
requireExactKeys(key, "passkey_summary", ["key_reference", "created_at", "last_used_at", "normal_login_verified_at"]);
|
||||
const date = (value: unknown): string => {
|
||||
const text = boundedString(value, "passkey_summary.timestamp");
|
||||
if (!Number.isFinite(Date.parse(text))) invalid("passkey_summary.timestamp", "must be a timestamp");
|
||||
return text;
|
||||
};
|
||||
return { key_reference: boundedString(key.key_reference, "passkey_summary.key_reference"), created_at: date(key.created_at), last_used_at: key.last_used_at === null ? null : date(key.last_used_at), normal_login_verified_at: key.normal_login_verified_at === null ? null : date(key.normal_login_verified_at) };
|
||||
}
|
||||
export function parsePasskeyList(value: unknown): PasskeyListResponse {
|
||||
const list = asRecord(value, "passkey_list");
|
||||
requireExactKeys(list, "passkey_list", ["user", "keys"]);
|
||||
if (!Array.isArray(list.keys) || list.keys.length > MAX_AUTH_ARRAY_LENGTH) invalid("passkey_list.keys", "must be a bounded array");
|
||||
return { user: parseAuthenticatedUser(list.user, "passkey_list.user"), keys: list.keys.map(parsePasskeySummary) };
|
||||
}
|
||||
export function parseAdditionResult(value: unknown): PasskeyAdditionResultResponse {
|
||||
const result = asRecord(value, "addition_result");
|
||||
if (result.status === "unknown") { requireExactKeys(result, "addition_result", ["status"]); return { status: "unknown" }; }
|
||||
requireExactKeys(result, "addition_result", ["status", "user", "key"]);
|
||||
if (result.status !== "saved") invalid("addition_result", "contains an unknown status");
|
||||
return { status: "saved", user: parseAuthenticatedUser(result.user, "addition_result.user"), key: parsePasskeySummary(result.key) };
|
||||
}
|
||||
|
||||
export function prepareRegistrationOptions(
|
||||
@@ -583,11 +624,14 @@ function clientExtensionResults(
|
||||
const value = (credential.getClientExtensionResults as () => unknown).call(
|
||||
credential,
|
||||
);
|
||||
boundedJson(value, "credential.clientExtensionResults");
|
||||
return asRecord(
|
||||
value,
|
||||
"credential.clientExtensionResults",
|
||||
) as AuthenticationExtensionsClientOutputs;
|
||||
const output = asRecord(value, "credential.clientExtensionResults");
|
||||
// 現Serverが消費するJSON拡張だけを転送する。PRF/largeBlob等の未要求binary
|
||||
// outputsをJSON.stringifyで{}へ変えたり、未使用機能として有効化したりしない。
|
||||
const supported = Object.fromEntries(
|
||||
["appid", "credProps", "hmacSecret", "credProtect", "minPinLength"].filter(key => key in output).map(key => [key, output[key]]),
|
||||
);
|
||||
boundedJson(supported, "credential.clientExtensionResults");
|
||||
return supported as AuthenticationExtensionsClientOutputs;
|
||||
}
|
||||
|
||||
export function registrationCredentialToJson(credential: unknown): unknown {
|
||||
@@ -605,12 +649,12 @@ export function registrationCredentialToJson(credential: unknown): unknown {
|
||||
) {
|
||||
invalid("credential.response.transports", "must be a bounded array");
|
||||
}
|
||||
const transports = transportsValue.map((transport, index) =>
|
||||
parseAuthenticatorTransport(
|
||||
transport,
|
||||
`credential.response.transports[${index}]`,
|
||||
)
|
||||
);
|
||||
// transportは補助hint。Serverのwebauthn-rsも未知値をUnknownとして無視する。
|
||||
// boundedな新値を理由に、端末上で作成済みの暗号credential全体を捨てない。
|
||||
const transports = transportsValue.flatMap((value, index) => {
|
||||
const transport = boundedString(value, `credential.response.transports[${index}]`);
|
||||
return ["ble", "hybrid", "internal", "nfc", "usb"].includes(transport) ? [transport] : [];
|
||||
});
|
||||
const authenticatorAttachment = optionalString(
|
||||
core.authenticatorAttachment,
|
||||
"credential.authenticatorAttachment",
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import type { PasskeyAdditionResultRequest } from "#lib/generated/auth-api.ts";
|
||||
import type { WhoamiResponse } from "./model.ts";
|
||||
|
||||
export type AuthStage =
|
||||
| "session-check"
|
||||
| "options"
|
||||
| "authenticator"
|
||||
| "conversion"
|
||||
| "before-complete"
|
||||
| "complete"
|
||||
| "confirmation";
|
||||
export type AuthFailure =
|
||||
| "cancelled-or-unavailable"
|
||||
| "excluded"
|
||||
| "unsupported"
|
||||
| "environment"
|
||||
| "conversion"
|
||||
| "session-changed"
|
||||
| "rejected"
|
||||
| "transport"
|
||||
| "invalid-response"
|
||||
| "stopped";
|
||||
export type AuthOutcome =
|
||||
| "not-sent"
|
||||
| "rejected"
|
||||
| "unknown"
|
||||
| "saved-session-unconfirmed";
|
||||
|
||||
// 診断は固定分類だけを持つ。cause、例外本文、HTTP body、credentialやceremony IDを保持しない。
|
||||
export class AuthOperationError extends Error {
|
||||
constructor(
|
||||
readonly stage: AuthStage,
|
||||
readonly reason: AuthFailure,
|
||||
readonly outcome: AuthOutcome,
|
||||
readonly httpStatus?: number,
|
||||
) {
|
||||
super(`Passkey ${stage}: ${reason} (${outcome}).`);
|
||||
}
|
||||
}
|
||||
type Observer = {
|
||||
phase: (stage: AuthStage) => void;
|
||||
verifySession: () => Promise<void>;
|
||||
expected: WhoamiResponse | null;
|
||||
additionAttempt: (attempt: PasskeyAdditionResultRequest) => void;
|
||||
};
|
||||
const observers = new WeakMap<AbortSignal, Observer>();
|
||||
export function observeAuthOperation(
|
||||
signal: AbortSignal,
|
||||
observer: Observer,
|
||||
): void {
|
||||
observers.set(signal, observer);
|
||||
}
|
||||
export function recordAdditionAttempt(
|
||||
signal: AbortSignal | undefined,
|
||||
attempt: PasskeyAdditionResultRequest,
|
||||
): void {
|
||||
if (signal) observers.get(signal)?.additionAttempt(attempt);
|
||||
}
|
||||
export function expectedAuthSession(
|
||||
signal?: AbortSignal,
|
||||
): WhoamiResponse | null {
|
||||
return signal ? observers.get(signal)?.expected ?? null : null;
|
||||
}
|
||||
export async function authCheckpoint(
|
||||
signal: AbortSignal | undefined,
|
||||
stage: AuthStage,
|
||||
): Promise<void> {
|
||||
signal?.throwIfAborted();
|
||||
const observer = signal ? observers.get(signal) : undefined;
|
||||
observer?.phase(stage);
|
||||
if (stage === "options" || stage === "before-complete") {
|
||||
await observer?.verifySession();
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
}
|
||||
export function classifyAuthFailure(
|
||||
error: unknown,
|
||||
stage: AuthStage,
|
||||
sent: boolean,
|
||||
): AuthOperationError {
|
||||
if (error instanceof AuthOperationError) return error;
|
||||
const outcome = sent ? "unknown" : "not-sent";
|
||||
if (
|
||||
error instanceof DOMException &&
|
||||
(stage === "authenticator" || error.name === "AbortError")
|
||||
) {
|
||||
switch (error.name) {
|
||||
case "AbortError":
|
||||
return new AuthOperationError(stage, "stopped", outcome);
|
||||
case "InvalidStateError":
|
||||
return new AuthOperationError(stage, "excluded", outcome);
|
||||
case "NotAllowedError":
|
||||
return new AuthOperationError(
|
||||
stage,
|
||||
"cancelled-or-unavailable",
|
||||
outcome,
|
||||
);
|
||||
case "NotSupportedError":
|
||||
return new AuthOperationError(stage, "unsupported", outcome);
|
||||
case "SecurityError":
|
||||
return new AuthOperationError(stage, "environment", outcome);
|
||||
}
|
||||
}
|
||||
return new AuthOperationError(
|
||||
stage,
|
||||
stage === "conversion" ? "conversion" : "transport",
|
||||
outcome,
|
||||
);
|
||||
}
|
||||
export function operationErrorMessage(error: AuthOperationError): string {
|
||||
const reason: Record<AuthFailure, string> = {
|
||||
"cancelled-or-unavailable":
|
||||
"The browser did not complete verification. It may have been cancelled, timed out, or found no usable key; the browser does not distinguish these reasons.",
|
||||
excluded:
|
||||
"The authenticator reported an existing key (duplicate exclusion). Keep the older key and choose a different device, security key, or passkey provider; do not delete your only working key.",
|
||||
unsupported:
|
||||
"This browser or key cannot complete this passkey operation. Use a supported device that can save passkeys and verify with a PIN or biometrics.",
|
||||
environment:
|
||||
"The browser blocked this operation because of an origin, security, or environment constraint. Check that you are using the intended secure site; the exact cause is not known.",
|
||||
conversion:
|
||||
"The device may have created a key, but its response could not be converted for the server. Registration was not sent. Keep existing keys; a device-side entry alone does not mean it was saved to this account.",
|
||||
"session-changed":
|
||||
"The account or session changed, or could not be confirmed. The result cannot be applied to the previously displayed account.",
|
||||
rejected:
|
||||
"The server rejected the request or verification. A challenge may be invalid or expired. Check your account and the operation result before starting a new ceremony.",
|
||||
transport: "Communication could not be confirmed.",
|
||||
"invalid-response": "The server response could not be confirmed.",
|
||||
stopped: "Operation stopped.",
|
||||
};
|
||||
const outcome = error.outcome === "not-sent"
|
||||
? "Completion was not sent."
|
||||
: error.outcome === "rejected"
|
||||
? "The server did not accept completion."
|
||||
: error.outcome === "saved-session-unconfirmed"
|
||||
? "The server confirmed the operation, but the expected browser session is not confirmed."
|
||||
: "Completion may already have reached the server. Its result is unknown; check the operation result before adding another key. Stopping the browser does not undo a submitted request.";
|
||||
return `${reason[error.reason]} ${outcome} [${error.stage}/${error.reason}${
|
||||
error.httpStatus ? `/HTTP-${error.httpStatus}` : ""
|
||||
}]`;
|
||||
}
|
||||
@@ -59,7 +59,7 @@ for (const kind of ["anonymous", "expired"] as const) {
|
||||
await browserSession.ensure();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({ actor: null, session: { kind } }),
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind } }),
|
||||
);
|
||||
await expect(requirePageSession(target)).rejects.toMatchObject({
|
||||
status: 307,
|
||||
|
||||
@@ -0,0 +1,624 @@
|
||||
// @vitest-environment happy-dom
|
||||
// BrowserAuth→本番adapterの競合/結果判定。HTTPと認証器のみをgate付きで代替し、
|
||||
// Server検証/保存の根拠にはしない。秘密の本文は診断へ保存しない。
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { BrowserAuth } from "./browser-session.svelte.ts";
|
||||
import { browserSession, sessionMutation } from "./session-state.ts";
|
||||
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
const owners: BrowserAuth[] = [];
|
||||
afterEach(() => {
|
||||
for (const owner of owners.splice(0)) owner.dispose();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function identity(user: typeof authUser | null) {
|
||||
return Response.json({
|
||||
authentication_reference: user ? "fixture-authentication" : null,
|
||||
actor: user ? { ...user, auth_method: "browser_session" } : null,
|
||||
session: user
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
function harness() {
|
||||
const state = { user: authUser as typeof authUser | null, completed: false };
|
||||
const create = vi.fn<CredentialsContainer["create"]>().mockResolvedValue(
|
||||
credentialFixture(),
|
||||
);
|
||||
const get = vi.fn<CredentialsContainer["get"]>().mockResolvedValue(
|
||||
credentialFixture(true),
|
||||
);
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return identity(state.user);
|
||||
if (path.endsWith("/options")) {
|
||||
return Response.json(
|
||||
path.includes("login") ? loginOptions : registrationOptions,
|
||||
);
|
||||
}
|
||||
if (path.endsWith("/complete")) {
|
||||
state.completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.user = null;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
throw new Error("Unexpected synthetic endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
const auth = new BrowserAuth();
|
||||
owners.push(auth);
|
||||
return { state, create, get, fetcher, auth };
|
||||
}
|
||||
function savedAddition() {
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "safe-opaque-reference",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
async function recoveryHarness() {
|
||||
const context = harness();
|
||||
await context.auth.refresh();
|
||||
context.auth.additionAttempt = {
|
||||
challenge_id: "synthetic-addition",
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference: "original-operation-reference",
|
||||
};
|
||||
return context;
|
||||
}
|
||||
for (const phase of ["initial-whoami", "receipt", "final-whoami"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`recovery ${phase} rejects a shared ${reason} stamp before event delivery`, async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0, receiptReads = 0;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
++whoami;
|
||||
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return identity(authUser);
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
++receiptReads;
|
||||
expect(
|
||||
JSON.parse(String(init?.body)).expected_authentication_reference,
|
||||
)
|
||||
.toBe("original-operation-reference");
|
||||
if (phase === "receipt") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return savedAddition();
|
||||
}
|
||||
throw new Error("Unexpected recovery endpoint");
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const checking = auth.checkAddition();
|
||||
await started.promise;
|
||||
await auth.checkAddition(); // busy recovery cannot issue a second read
|
||||
const changed = JSON.stringify({
|
||||
id: `recovery-${phase}-${reason}`,
|
||||
reason,
|
||||
});
|
||||
localStorage.setItem("yoi.browser-auth-generation", changed);
|
||||
expect(browserSession.authenticationStamp).not.toBe(stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(
|
||||
phase === "receipt" ? savedAddition() : identity(authUser),
|
||||
);
|
||||
await checking;
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt?.expected_authentication_reference)
|
||||
.toBe("original-operation-reference");
|
||||
expect(auth.status).not.toContain("current session was checked");
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
expect(receiptReads).toBe(phase === "initial-whoami" ? 0 : 1);
|
||||
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
|
||||
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const phase of ["receipt", "final-whoami"] as const) {
|
||||
for (const invalidation of ["cancel", "dispose", "generation"] as const) {
|
||||
test(`recovery ${phase} ignores a delayed saved response after ${invalidation}`, async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (++whoami === 2 && phase === "final-whoami") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return identity(authUser);
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
if (phase === "receipt") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return savedAddition();
|
||||
}
|
||||
throw new Error("Unexpected recovery endpoint");
|
||||
});
|
||||
const checking = auth.checkAddition();
|
||||
await started.promise;
|
||||
if (invalidation === "cancel") auth.cancel();
|
||||
else if (invalidation === "dispose") auth.dispose();
|
||||
else sessionMutation();
|
||||
response.resolve(
|
||||
phase === "receipt" ? savedAddition() : identity(authUser),
|
||||
);
|
||||
await checking;
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.status).not.toContain("current session was checked");
|
||||
expect(whoami).toBe(phase === "receipt" ? 1 : 2);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
)
|
||||
.toBe(false);
|
||||
});
|
||||
}
|
||||
}
|
||||
test("failed recheck cannot retain repeat-add permission from an older saved confirmation", async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
auth.additionSaved = true;
|
||||
fetcher.mockResolvedValue(identity(null));
|
||||
await auth.checkAddition();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
});
|
||||
test("unknown receipt retains the unresolved attempt and never permits blind repetition", async () => {
|
||||
const { auth, fetcher } = await recoveryHarness();
|
||||
fetcher.mockImplementation(async (input) =>
|
||||
String(input).endsWith("/addition/result")
|
||||
? Response.json({ status: "unknown" })
|
||||
: identity(authUser)
|
||||
);
|
||||
await auth.checkAddition();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.status).toContain("still unknown");
|
||||
expect(auth.error).toBe("");
|
||||
});
|
||||
for (const outcome of ["anonymous", "other-actor", "unavailable"] as const) {
|
||||
test(`addition response is not Added when confirming whoami is ${outcome}`, async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (state.completed && String(input) === "/api/auth/whoami") {
|
||||
if (outcome === "unavailable") {
|
||||
throw new TypeError("private network detail");
|
||||
}
|
||||
return identity(
|
||||
outcome === "anonymous" ? null : {
|
||||
...authUser,
|
||||
user_id: "bob-user",
|
||||
account_id: "bob-account",
|
||||
handle: "bob",
|
||||
},
|
||||
);
|
||||
}
|
||||
return await request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.status).not.toBe("Added");
|
||||
expect(auth.error).not.toContain("private network detail");
|
||||
});
|
||||
}
|
||||
for (const invalidation of ["logout", "dispose", "account-change"] as const) {
|
||||
test(`${invalidation} during native prompt never posts a delayed login credential`, async () => {
|
||||
const { auth, state, get, fetcher } = harness();
|
||||
state.user = null;
|
||||
await auth.refresh();
|
||||
const prompted = gate<void>();
|
||||
const credential = gate<Credential | null>();
|
||||
get.mockImplementation(() => {
|
||||
prompted.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
const run = auth.run("Waiting", "Logged in", loginWithPasskey);
|
||||
await prompted.promise;
|
||||
if (invalidation === "dispose") auth.dispose();
|
||||
else if (invalidation === "account-change") sessionMutation();
|
||||
else {
|
||||
const signout = new BrowserAuth();
|
||||
owners.push(signout);
|
||||
await signout.run("Logging out", "Logged out", logout, "logout");
|
||||
}
|
||||
credential.resolve(credentialFixture(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
String(path).endsWith("/login/complete")
|
||||
),
|
||||
).toBe(false);
|
||||
expect(state.completed).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
const bob = {
|
||||
...authUser,
|
||||
user_id: "bob-user",
|
||||
account_id: "bob-account",
|
||||
handle: "bob",
|
||||
};
|
||||
for (const changeAt of ["start", "before-complete"] as const) {
|
||||
test(`stale Alice with Bob cookie at ${changeAt} cannot add to Bob`, async () => {
|
||||
const { auth, state, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
if (changeAt === "start") state.user = bob;
|
||||
else {create.mockImplementationOnce(async () => {
|
||||
state.user = bob;
|
||||
return credentialFixture();
|
||||
});}
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(state.completed).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
if (changeAt === "start") expect(create).not.toHaveBeenCalled();
|
||||
expect(auth.error).toContain("session-changed");
|
||||
});
|
||||
}
|
||||
test("same owner but different authentication binding before complete is not the expected session", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
let changed = false;
|
||||
create.mockImplementationOnce(async () => {
|
||||
changed = true;
|
||||
return credentialFixture();
|
||||
});
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami" && changed
|
||||
? Response.json({
|
||||
...(await identity(authUser).json()),
|
||||
authentication_reference: "different-binding",
|
||||
})
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("returned owner alone does not prove the issued browser session", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "issued-but-not-current",
|
||||
})
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain("expected browser session is not confirmed");
|
||||
});
|
||||
for (
|
||||
const reason of [
|
||||
"InvalidStateError",
|
||||
"NotAllowedError",
|
||||
"SecurityError",
|
||||
"NotSupportedError",
|
||||
] as const
|
||||
) {
|
||||
test(`native ${reason} has a safe stage-specific classification without raw exceptions`, async () => {
|
||||
const { auth, create } = harness();
|
||||
await auth.refresh();
|
||||
create.mockRejectedValueOnce(
|
||||
new DOMException("RAW-CREDENTIAL-TOKEN-COOKIE", reason),
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).not.toContain("RAW-CREDENTIAL-TOKEN-COOKIE");
|
||||
expect(auth.error).toContain("authenticator/");
|
||||
expect(auth.error).toContain("Completion was not sent");
|
||||
if (reason === "InvalidStateError") {
|
||||
expect(auth.error).toContain("duplicate exclusion");
|
||||
}
|
||||
if (reason === "NotAllowedError") {
|
||||
expect(auth.error).toContain("does not distinguish");
|
||||
}
|
||||
});
|
||||
}
|
||||
test("device creation followed by conversion failure never sends complete or claims registration", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
create.mockResolvedValueOnce(
|
||||
{
|
||||
...credentialFixture(),
|
||||
rawId: "private-invalid-value",
|
||||
} as unknown as Credential,
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain("conversion");
|
||||
expect(auth.error).toContain("device may have created");
|
||||
expect(auth.error).not.toContain("private-invalid-value");
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("duplicate operations are fenced and explicit cancellation stops late credentials before submission", async () => {
|
||||
const { auth, create, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const started = gate<void>(), credential = gate<Credential | null>();
|
||||
create.mockImplementationOnce(() => {
|
||||
started.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
const first = auth.run("Waiting", "Added", addPasskey);
|
||||
await started.promise;
|
||||
const duplicate = vi.fn(async () => ({}));
|
||||
expect(await auth.run("Duplicate", "Wrong", duplicate)).toBe(false);
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
auth.cancel();
|
||||
credential.resolve(credentialFixture());
|
||||
expect(await first).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).endsWith("/complete")),
|
||||
).toBe(false);
|
||||
});
|
||||
test("abort after complete dispatch remains an unknown submitted addition with a recoverable operation", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input).endsWith("/complete")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const run = auth.run("Waiting", "Added", addPasskey);
|
||||
await started.promise;
|
||||
auth.cancel();
|
||||
response.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).toContain("result is unknown");
|
||||
expect(auth.error).not.toContain("Completion was not sent");
|
||||
});
|
||||
for (
|
||||
const current of [
|
||||
"original-session",
|
||||
"cookie-updated",
|
||||
"anonymous",
|
||||
"bob",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
test(`lost completion body with ${current} distinguishes saved, session evidence and unavailable authority`, async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
let dispatched = false, resultReads = 0;
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/complete")) {
|
||||
dispatched = true;
|
||||
throw new TypeError("RAW-HTTP-BODY-TOKEN");
|
||||
}
|
||||
if (path === "/api/auth/whoami" && dispatched) {
|
||||
if (current === "unavailable") throw new TypeError("RAW-WHOAMI-ERROR");
|
||||
if (current === "anonymous") return identity(null);
|
||||
if (current === "bob") return identity(bob);
|
||||
if (current === "cookie-updated") {
|
||||
return Response.json({
|
||||
...(await identity(authUser).json()),
|
||||
authentication_reference: "new-live-session",
|
||||
});
|
||||
}
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
++resultReads;
|
||||
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
|
||||
.toBe("fixture-authentication");
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "safe-opaque-reference",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.error).not.toContain("RAW-");
|
||||
expect(auth.error).toContain("result is unknown");
|
||||
await auth.checkAddition();
|
||||
if (current === "original-session" || current === "cookie-updated") {
|
||||
expect(auth.additionSaved).toBe(true);
|
||||
expect(auth.status).toContain("saved a key");
|
||||
expect(auth.status).toContain(
|
||||
"session issued by completion is not confirmed",
|
||||
);
|
||||
expect(resultReads).toBe(1);
|
||||
} else {
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(resultReads).toBe(0);
|
||||
expect(auth.error).toContain("does not prove failure");
|
||||
}
|
||||
expect(state.completed).toBe(false); // synthetic boundary never acts as the real store
|
||||
});
|
||||
}
|
||||
|
||||
test("storage mutation arriving before the storage event fences prompt completion even if whoami stays anonymous", async () => {
|
||||
const { auth, state, get, fetcher } = harness();
|
||||
state.user = null;
|
||||
await auth.refresh();
|
||||
get.mockImplementationOnce(async () => {
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: "another-tab", reason: "logout" }),
|
||||
);
|
||||
return credentialFixture(true);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
String(path).endsWith("/login/complete")
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
for (const phase of ["options", "complete"] as const) {
|
||||
test(`malformed ${phase} response records the stage without leaking body or inventing completion`, async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith(`/${phase}`)
|
||||
? Promise.resolve(
|
||||
new Response("RAW-CHALLENGE-COOKIE", {
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
)
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).toContain(`${phase}/invalid-response`);
|
||||
expect(auth.error).not.toContain("RAW-CHALLENGE-COOKIE");
|
||||
expect(auth.additionAttempt === null).toBe(phase === "options");
|
||||
if (phase === "options") {
|
||||
expect(auth.error).toContain("Completion was not sent");
|
||||
} else expect(auth.error).toContain("result is unknown");
|
||||
});
|
||||
}
|
||||
for (
|
||||
const code of [
|
||||
"passkey_authentication_changed",
|
||||
"webauthn_registration_verification_failed",
|
||||
] as const
|
||||
) {
|
||||
test(`HTTP rejection classifies allowlisted ${code} without retaining HTTP body`, async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Promise.resolve(
|
||||
Response.json({
|
||||
message: `${code}: RAW-EXCEPTION-BODY`,
|
||||
secret: "RAW-TOKEN",
|
||||
}, { status: 401 }),
|
||||
)
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.error).not.toContain("RAW-");
|
||||
expect(auth.error).toContain(
|
||||
code === "passkey_authentication_changed"
|
||||
? "session-changed"
|
||||
: "rejected",
|
||||
);
|
||||
expect(auth.error).toContain("HTTP-401");
|
||||
expect(auth.error).not.toContain("Completion was not sent");
|
||||
});
|
||||
}
|
||||
|
||||
test("successful logout never marks an earlier unknown addition as saved", async () => {
|
||||
const { auth, fetcher } = harness();
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input).endsWith("/complete")
|
||||
? Promise.reject(new TypeError("response lost"))
|
||||
: request(input, init)
|
||||
);
|
||||
expect(await auth.run("Waiting", "Added", addPasskey)).toBe(false);
|
||||
expect(auth.additionAttempt).not.toBeNull();
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
expect(await auth.run("Logging out", "Logged out", logout, "logout")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(auth.additionSaved).toBe(false);
|
||||
});
|
||||
|
||||
test("explicit login from stale expired display remains allowed after another tab cleared the cookie", async () => {
|
||||
const { auth, state, fetcher } = harness();
|
||||
state.user = null;
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
);
|
||||
await auth.refresh();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input).endsWith("/complete")) state.user = authUser;
|
||||
return await request(input, init);
|
||||
});
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(true);
|
||||
expect(auth.status).toBe("Logged in");
|
||||
});
|
||||
test("explicit login from expired display does not replace another tab's authenticated account", async () => {
|
||||
const { auth, state, fetcher, get } = harness();
|
||||
fetcher.mockResolvedValueOnce(
|
||||
Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "expired" },
|
||||
}),
|
||||
);
|
||||
await auth.refresh();
|
||||
state.user = bob;
|
||||
expect(await auth.run("Waiting", "Logged in", loginWithPasskey)).toBe(false);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(state.completed).toBe(false);
|
||||
});
|
||||
@@ -43,7 +43,7 @@ test("active expired state removes private content and preserves browser query a
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ actor: null, session: { kind: "expired" } })
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
@@ -62,7 +62,7 @@ test("navigation admission does not redirect to the old page return URL", async
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ actor: null, session: { kind: "expired" } })
|
||||
Response.json({ authentication_reference: null, actor: null, session: { kind: "expired" } })
|
||||
),
|
||||
);
|
||||
await browserSession.refresh();
|
||||
|
||||
@@ -13,13 +13,14 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
const anonymous: WhoamiResponse = {
|
||||
actor: null,
|
||||
actor: null, authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
};
|
||||
const expired: WhoamiResponse = { actor: null, session: { kind: "expired" } };
|
||||
const expired: WhoamiResponse = { actor: null, authentication_reference: null, session: { kind: "expired" } };
|
||||
const authenticated = (
|
||||
expires_at: string | null = "2099-01-01T00:00:00Z",
|
||||
): WhoamiResponse => ({
|
||||
authentication_reference: "fixture-authentication",
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
session: { kind: "authenticated", expires_at },
|
||||
});
|
||||
|
||||
@@ -17,6 +17,10 @@ export class SessionState {
|
||||
private listeners = new Set<(state: SessionSnapshot) => void>();
|
||||
private stale = true;
|
||||
|
||||
get authenticationStamp(): string | null {
|
||||
return storageStamp();
|
||||
}
|
||||
|
||||
get authenticationGeneration(): number {
|
||||
return this.generation;
|
||||
}
|
||||
@@ -38,7 +42,7 @@ export class SessionState {
|
||||
++this.generation;
|
||||
this.pending = null;
|
||||
this.stale = true;
|
||||
if (!preserveSnapshot) this.publish({ kind: "loading" });
|
||||
this.publish(preserveSnapshot ? this.snapshot : { kind: "loading" });
|
||||
}
|
||||
|
||||
// Page load owns freshness. Components mounted after it reuse that result.
|
||||
@@ -100,8 +104,9 @@ export const browserSession = new SessionState();
|
||||
|
||||
export function sessionMutation(
|
||||
reason: "authentication" | "logout" = "authentication",
|
||||
preserveSnapshot = false,
|
||||
): void {
|
||||
browserSession.invalidate();
|
||||
browserSession.invalidate(preserveSnapshot);
|
||||
const message = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||
try {
|
||||
localStorage.setItem(GENERATION_KEY, message);
|
||||
|
||||
@@ -35,6 +35,7 @@ function session() {
|
||||
|
||||
function whoami(authenticated: boolean) {
|
||||
return Response.json({
|
||||
authentication_reference: authenticated ? "fixture-authentication" : null,
|
||||
session: authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -75,7 +76,10 @@ function boundary(authenticated = false) {
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
@@ -106,6 +110,7 @@ test("loading and failed whoami are not anonymous readiness and cannot start aut
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
});
|
||||
@@ -162,6 +167,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
oldResponse.resolve(whoami(true));
|
||||
@@ -169,6 +175,7 @@ test("a delayed pre-logout whoami cannot resurrect the previous actor", async ()
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Logged out");
|
||||
@@ -183,26 +190,34 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
||||
const confirmingStarted = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (String(input) === "/api/auth/passkeys/login/options") {
|
||||
return options.promise;
|
||||
}
|
||||
if (
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
paths(fetcher).includes("/api/auth/passkeys/login/complete")
|
||||
) {
|
||||
confirmingStarted.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
fetcher.mockReturnValueOnce(options.promise);
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey(signal),
|
||||
);
|
||||
expect(auth.busy).toBe(true);
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.status).toMatch(/Checking the account/);
|
||||
const duplicate = vi.fn(async () => {});
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
// Preflight is asynchronous; wait for the options stage, not an HTTP ordinal.
|
||||
await vi.waitFor(() =>
|
||||
expect(paths(fetcher)).toContain("/api/auth/passkeys/login/options")
|
||||
);
|
||||
expect(paths(fetcher).filter((path) => path !== "/api/auth/whoami")).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
options.resolve(Response.json(loginOptions));
|
||||
@@ -212,15 +227,17 @@ test("busy login rejects duplicate operations and refresh until its post-complet
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(4);
|
||||
confirming.resolve(whoami(true));
|
||||
expect(await run).toBe(true);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.actor?.handle).toBe("alice");
|
||||
expect(auth.status).toBe("Logged in");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
@@ -236,16 +253,16 @@ for (const status of [400, 401, 410]) {
|
||||
expect(result).toBe(false);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.error).toMatch(
|
||||
status === 401
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(auth.error).toMatch(/server rejected the request or verification/);
|
||||
expect(auth.error).toContain(`[complete/rejected/HTTP-${status}]`);
|
||||
expect(auth.error).toContain("server did not accept completion");
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
@@ -278,22 +295,25 @@ for (
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(
|
||||
name === "NotAllowedError"
|
||||
? /cancelled or timed out/
|
||||
? /cancelled, timed out, or found no usable key/
|
||||
: name === "NotSupportedError"
|
||||
? /Completion is not confirmed/
|
||||
? /supported device/
|
||||
: name === "AbortError"
|
||||
? /Completion is not confirmed/
|
||||
: /Authentication could not be confirmed/,
|
||||
? /Operation stopped/
|
||||
: /Communication could not be confirmed/,
|
||||
);
|
||||
expect(auth.error).toContain("Completion was not sent.");
|
||||
expect(auth.error).not.toContain("secret credential detail");
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
@@ -314,14 +334,21 @@ test("completion does not confirm authentication when the subsequent whoami fail
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
let completed = false;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return completed ? new Response(null, { status: 503 }) : whoami(false);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
@@ -385,14 +412,15 @@ test("dispose aborts WebAuthn without accepting late credentials, while shared s
|
||||
expect(auth.session).toEqual({
|
||||
kind: "ready",
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -409,6 +437,7 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
||||
const started = gate<AbortSignal>();
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return whoami(false);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
@@ -427,16 +456,22 @@ test("dispose ignores a completion response even when the HTTP boundary does not
|
||||
const signal = await started.promise;
|
||||
auth.dispose();
|
||||
expect(signal.aborted).toBe(true);
|
||||
response.resolve(Response.json({ user: authUser }));
|
||||
response.resolve(
|
||||
Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
}),
|
||||
);
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.actor).toBeNull();
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.status).toBe("Confirming the operation with the server…");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
@@ -451,15 +486,21 @@ test("dispose during the post-operation whoami prevents its late actor from beco
|
||||
await auth.refresh();
|
||||
const response = gate<Response>();
|
||||
const started = gate<void>();
|
||||
let completed = false;
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
completed = true;
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (!completed) return whoami(false);
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
}
|
||||
@@ -489,7 +530,10 @@ for (const newer of ["logout", "another-actor"] as const) {
|
||||
const started = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
paths(fetcher).includes("/api/auth/passkeys/login/complete")
|
||||
) {
|
||||
started.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
@@ -509,6 +553,7 @@ for (const newer of ["logout", "another-actor"] as const) {
|
||||
sessionMutation(newer === "logout" ? "logout" : "authentication");
|
||||
fetcher.mockResolvedValueOnce(
|
||||
newer === "logout" ? whoami(false) : Response.json({
|
||||
authentication_reference: "other-authentication",
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
@@ -552,7 +597,8 @@ for (const purpose of ["login", "addition"] as const) {
|
||||
purpose === "login" ? loginWithPasskey(signal) : addPasskey(signal),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(/cancelled or timed out/);
|
||||
expect(auth.error).toMatch(/cancelled, timed out, or found no usable key/);
|
||||
expect(auth.error).toContain("Completion was not sent.");
|
||||
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
|
||||
false,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ for (
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
|
||||
@@ -1,30 +1,72 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
|
||||
import type { PasskeyListResponse } from '#lib/generated/auth-api.ts';
|
||||
import { addPasskey, logout, loadPasskeys, loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
const auth = new BrowserAuth();
|
||||
async function refresh() {
|
||||
await auth.refresh();
|
||||
let keys = $state<PasskeyListResponse | null>(null);
|
||||
let keysError = $state('');
|
||||
let keysLoading = $state(false);
|
||||
let keyRead = 0;
|
||||
let active = true;
|
||||
let keyController: AbortController | null = null;
|
||||
function time(value: string | null) { return value ? new Date(value).toLocaleString() : 'Not recorded'; }
|
||||
async function refreshKeys() {
|
||||
const actor = auth.actor;
|
||||
if (!actor || !active) return;
|
||||
keyController?.abort();
|
||||
const controller = new AbortController();
|
||||
keyController = controller;
|
||||
const read = ++keyRead;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
const assertCurrent = () => {
|
||||
controller.signal.throwIfAborted();
|
||||
if (!active || read !== keyRead || generation !== browserSession.authenticationGeneration || stamp !== browserSession.authenticationStamp) throw new Error('Session changed');
|
||||
};
|
||||
keysLoading = true; keysError = '';
|
||||
try {
|
||||
const before = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (before.actor?.user_id !== actor.user_id || before.actor.account_id !== actor.account_id) throw new Error('Account changed');
|
||||
const result = await loadPasskeys(controller.signal);
|
||||
assertCurrent();
|
||||
const after = await loadWhoami(controller.signal);
|
||||
assertCurrent();
|
||||
if (result.user.user_id !== actor.user_id || result.user.account_id !== actor.account_id ||
|
||||
after.actor?.user_id !== actor.user_id || after.actor.account_id !== actor.account_id ||
|
||||
before.authentication_reference !== after.authentication_reference) throw new Error('Session changed');
|
||||
keys = result;
|
||||
} catch {
|
||||
if (active && read === keyRead && !controller.signal.aborted) { keys = null; keysError = 'Registered keys could not be confirmed for this account. Refresh your session before trying again.'; }
|
||||
} finally { if (read === keyRead) keysLoading = false; }
|
||||
}
|
||||
async function refresh() { await auth.refresh(); await refreshKeys(); }
|
||||
async function add() {
|
||||
if (!auth.actor) return;
|
||||
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
|
||||
if (!auth.actor || auth.additionAttempt && !auth.additionSaved) return;
|
||||
await auth.run('Waiting for passkey addition…', 'Passkey added to your account; the expected session was confirmed.', addPasskey);
|
||||
await refreshKeys();
|
||||
}
|
||||
async function checkAddition() { await auth.checkAddition(); await refreshKeys(); }
|
||||
async function signOut() {
|
||||
if (!auth.actor) return;
|
||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout, 'logout');
|
||||
if (confirmed && !auth.actor) await goto('/', { replaceState: true });
|
||||
}
|
||||
onMount(() => { void auth.ensure(); return () => auth.dispose(); });
|
||||
onMount(() => { void auth.ensure().then(refreshKeys); return () => { active = false; ++keyRead; keyController?.abort(); auth.dispose(); }; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Account · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<h1>Account</h1>
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.busy}<button type="button" onclick={() => auth.cancel()}>Stop waiting</button>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
@@ -37,8 +79,41 @@
|
||||
</dl>
|
||||
<section aria-label="Your Passkeys">
|
||||
<h2>Passkeys</h2>
|
||||
<p class="muted">Add a discoverable passkey for login without an account ID. Use a device or key that can save passkeys and verify you with a PIN or biometrics. Existing keys are preserved; do not remove them before confirming the new key works.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||
<p class="muted">Add a passkey for login without an account ID. Use a device or key that saves passkeys and verifies you with a PIN or biometrics. Existing keys are preserved.</p>
|
||||
<p>If an older key is in the same authenticator or saved-key provider, duplicate exclusion can block addition there. Choose a different device, security key, or passkey provider. A different provider on the same device may work only if it does not contain an excluded key. Do not delete an older key to bypass this check.</p>
|
||||
<button type="button" disabled={auth.busy || !!auth.additionAttempt && !auth.additionSaved} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||
{#if auth.additionAttempt}
|
||||
<button type="button" disabled={auth.busy} onclick={checkAddition}>Check addition result</button>
|
||||
<p class="muted">A saved result confirms this addition's server record, not that ID-free login works. If the session changed, result access may be unavailable; do not restore an old session or add blindly.</p>
|
||||
{/if}
|
||||
{#if keysLoading}<p role="status">Checking registered keys…</p>{/if}
|
||||
{#if keysError}<p role="alert" class="error">{keysError}</p>{/if}
|
||||
{#if keys && keys.user.user_id === auth.actor.user_id && keys.user.account_id === auth.actor.account_id}
|
||||
<ul class="passkey-list" aria-label="Registered keys">
|
||||
{#each keys.keys as key (key.key_reference)}
|
||||
<li>
|
||||
<dl>
|
||||
<div><dt>Created</dt><dd>{time(key.created_at)}</dd></div>
|
||||
<div><dt>Last used</dt><dd>{time(key.last_used_at)}</dd></div>
|
||||
<div><dt>ID-free login</dt><dd>{key.normal_login_verified_at ? `Verified ${time(key.normal_login_verified_at)}` : 'Not yet verified — storage alone does not prove support'}</dd></div>
|
||||
</dl>
|
||||
<details><summary>Key reference</summary><code>{key.key_reference}</code></details>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if keys.keys.length === 0}<p>No registered keys were returned for this account.</p>{/if}
|
||||
{/if}
|
||||
<button type="button" disabled={auth.busy || keysLoading} onclick={refreshKeys}>Refresh registered keys</button>
|
||||
<details>
|
||||
<summary>Verify migration before retiring older-key login</summary>
|
||||
<ol>
|
||||
<li>Keep this working session and every working older key. If addition is excluded, use a different saved-key provider or authenticator; removing your last key first can lock you out.</li>
|
||||
<li>In a separate browser profile or private window, open <a href="/login" target="_blank" rel="noopener">normal passkey login</a>. Do not open the older-key/ID form. Select the newly saved key and complete verification.</li>
|
||||
<li>Confirm the intended account ID in that window's Account page. Refresh the key list here and check that the chosen key now records verified ID-free login. Repeat for every key and device you intend to rely on; a stored key or resident-key hint alone is not proof.</li>
|
||||
<li>Retain a tested independent way back in before ceasing to rely on an older key. If any required device cannot complete normal login, keep the older-key path available. Do not delete old keys as a prerequisite.</li>
|
||||
</ol>
|
||||
<p>Removing the older-key entry for the service is not safe based on one account's success. The operator must first confirm migration and a working alternative for every affected account/device, keep a rollback path to the older-key entry, and verify normal login before retiring that entry. This page does not delete keys or disable compatibility login.</p>
|
||||
</details>
|
||||
</section>
|
||||
<div class="actions">
|
||||
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
|
||||
@@ -48,7 +123,12 @@
|
||||
<p>No browser session is active.</p>
|
||||
<a href={loginUrl('/account')}>Log in to your account</a>
|
||||
{/if}
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<a href="/dashboard">Dashboard / Workspaces</a>
|
||||
</div>
|
||||
<style>
|
||||
.passkey-list { padding-inline-start: var(--space-5); display: grid; gap: var(--space-4); }
|
||||
.passkey-list li { min-width: 0; }
|
||||
code { overflow-wrap: anywhere; }
|
||||
ol { padding-inline-start: var(--space-5); }
|
||||
li + li { margin-block-start: var(--space-2); }
|
||||
</style>
|
||||
|
||||
@@ -63,6 +63,9 @@ function boundary({
|
||||
return new Response(null, { status: state.whoamiStatus });
|
||||
}
|
||||
return Response.json({
|
||||
authentication_reference: state.authenticated
|
||||
? "fixture-authentication"
|
||||
: null,
|
||||
session: state.authenticated
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -71,6 +74,9 @@ function boundary({
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys") {
|
||||
return Response.json({ user: authUser, keys: [] });
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (state.policy === "loading") return await policyResponse.promise;
|
||||
if (state.policy === "error") return new Response(null, { status: 503 });
|
||||
@@ -97,7 +103,10 @@ function boundary({
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.authenticated = false;
|
||||
@@ -149,6 +158,143 @@ async function submit(name: string) {
|
||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
||||
}
|
||||
|
||||
for (const phase of ["initial-whoami", "list", "final-whoami"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`registered-key ${phase} confirmation rejects shared ${reason} before event delivery`, async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
render(Account);
|
||||
await screen.findByText(
|
||||
"No registered keys were returned for this account.",
|
||||
);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let whoami = 0, listReads = 0;
|
||||
const metadata = () =>
|
||||
Response.json({
|
||||
user: authUser,
|
||||
keys: [{
|
||||
key_reference: "delayed-key-metadata",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
}],
|
||||
});
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
++whoami;
|
||||
if (phase === (whoami === 1 ? "initial-whoami" : "final-whoami")) {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
}
|
||||
if (path === "/api/auth/passkeys") {
|
||||
++listReads;
|
||||
if (phase === "list") {
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return metadata();
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = browserSession.authenticationStamp;
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Refresh registered keys" }),
|
||||
);
|
||||
await started.promise;
|
||||
const changed = JSON.stringify({ id: `keys-${phase}-${reason}`, reason });
|
||||
localStorage.setItem("yoi.browser-auth-generation", changed);
|
||||
expect(browserSession.authenticationStamp).not.toBe(stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(
|
||||
phase === "list" ? metadata() : await request("/api/auth/whoami"),
|
||||
);
|
||||
await screen.findByText(/Registered keys could not be confirmed/);
|
||||
expect(screen.queryByText("delayed-key-metadata")).toBeNull();
|
||||
expect(listReads).toBe(phase === "initial-whoami" ? 0 : 1);
|
||||
expect(whoami).toBe(phase === "final-whoami" ? 2 : 1);
|
||||
expect(localStorage.getItem("yoi.browser-auth-generation")).toBe(changed);
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`stale receipt confirmation after shared ${reason} leaves visible repeat-add disabled`, async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
render(Account);
|
||||
await screen.findByText(
|
||||
"No registered keys were returned for this account.",
|
||||
);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>(), response = gate<Response>();
|
||||
let receiptRead = false;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path.endsWith("/addition/complete")) {
|
||||
throw new TypeError("lost response");
|
||||
}
|
||||
if (path.endsWith("/addition/result")) {
|
||||
receiptRead = true;
|
||||
expect(JSON.parse(String(init?.body)).expected_authentication_reference)
|
||||
.toBe("fixture-authentication");
|
||||
return Response.json({
|
||||
status: "saved",
|
||||
user: authUser,
|
||||
key: {
|
||||
key_reference: "saved-but-session-unconfirmed",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami" && receiptRead) {
|
||||
receiptRead = false;
|
||||
started.resolve();
|
||||
return response.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
const add = screen.getByRole("button", {
|
||||
name: /Add passkey to/,
|
||||
}) as HTMLButtonElement;
|
||||
await fireEvent.click(add);
|
||||
await screen.findByText(/result is unknown/);
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByText("Checking registered keys…")).toBeNull()
|
||||
);
|
||||
expect(add.disabled).toBe(true);
|
||||
const checking = screen.getByRole("button", {
|
||||
name: "Check addition result",
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect((checking as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
await fireEvent.click(checking);
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: `visible-${reason}`, reason }),
|
||||
);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
response.resolve(await request("/api/auth/whoami"));
|
||||
await screen.findByText(/The addition result could not be read/);
|
||||
await waitFor(() =>
|
||||
expect((checking as HTMLButtonElement).disabled).toBe(false)
|
||||
);
|
||||
expect(screen.queryByText(/Your current session was checked/)).toBeNull();
|
||||
expect(add.disabled).toBe(true);
|
||||
await fireEvent.click(add);
|
||||
expect(
|
||||
posted(fetcher).filter(({ path }) => path.endsWith("/addition/complete")),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("normal login needs no account ID and links separate registration and code activation", async () => {
|
||||
const { fetcher } = boundary({ policy: true });
|
||||
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
|
||||
@@ -231,19 +377,23 @@ for (
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(4);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
|
||||
});
|
||||
}
|
||||
|
||||
test("successful login completion without confirmed whoami never navigates", async () => {
|
||||
const { state, get } = boundary();
|
||||
const { state, fetcher } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const response = await request(input, init);
|
||||
if (String(input) === "/api/auth/passkeys/login/complete") {
|
||||
state.whoamiStatus = 503;
|
||||
}
|
||||
return response;
|
||||
});
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
get.mockImplementationOnce(async () => {
|
||||
state.whoamiStatus = 503;
|
||||
return credentialFixture(true);
|
||||
});
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(/Your session could not be loaded/);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
@@ -347,7 +497,7 @@ test("policy disabled during WebAuthn rejects registration, reloads policy and n
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce());
|
||||
state.completeStatus = 403;
|
||||
credential.resolve(credentialFixture());
|
||||
await screen.findByText("Auth request failed (403).");
|
||||
await screen.findByText(/\[complete\/rejected\/HTTP-403\]/);
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
@@ -376,7 +526,9 @@ test("account displays the current identity without editable target fields and a
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText("Passkey added to your account.");
|
||||
await screen.findByText(
|
||||
"Passkey added to your account; the expected session was confirmed.",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
|
||||
@@ -388,7 +540,12 @@ test("account displays the current identity without editable target fields and a
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
expect(requests[0].body).toEqual({ browser_origin: window.location.origin });
|
||||
expect(requests[0].body).toEqual({
|
||||
browser_origin: window.location.origin,
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference: "fixture-authentication",
|
||||
});
|
||||
expect(Object.keys(requests[1].body).sort()).toEqual([
|
||||
"challenge_id",
|
||||
"credential",
|
||||
@@ -485,11 +642,18 @@ test("logout confirms the server session is cleared and returns to the public ho
|
||||
expect(posted(fetcher)).toEqual([
|
||||
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
|
||||
]);
|
||||
expect(apiPaths(fetcher)).toEqual([
|
||||
const paths = apiPaths(fetcher);
|
||||
expect(paths[0]).toBe("/api/auth/whoami");
|
||||
expect(paths.slice(paths.indexOf("/api/auth/logout") + 1)).toContain(
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
);
|
||||
expect(
|
||||
paths.every((path) =>
|
||||
["/api/auth/whoami", "/api/auth/logout", "/api/auth/passkeys"].includes(
|
||||
path,
|
||||
)
|
||||
),
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
});
|
||||
|
||||
@@ -499,10 +663,14 @@ test("expired addition revokes stale identity without silently retrying as regis
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText(/Authentication request was rejected/);
|
||||
await screen.findByText(/server rejected the request or verification/);
|
||||
await screen.findByRole("link", { name: "Log in to your account" });
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText(
|
||||
"Passkey added to your account; the expected session was confirmed.",
|
||||
),
|
||||
).toBeNull();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
@@ -545,7 +713,13 @@ test("WebAuthn cancellation is visible, sends no completion, and allows an expli
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(
|
||||
"Passkey operation was cancelled or timed out, or no matching key was found. Completion is not confirmed. Use a supported device and try again.",
|
||||
/cancelled, timed out, or found no usable key/,
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Completion was not sent.",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"[authenticator/cancelled-or-unavailable]",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
@@ -651,7 +825,12 @@ test("device approval locks its code and rejects a second submit while the serve
|
||||
name: "Approve device login",
|
||||
});
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) =>
|
||||
String(input) === "/api/auth/device-login/approve"
|
||||
? response.promise
|
||||
: request(input, init)
|
||||
);
|
||||
const form = approve.closest("form")!;
|
||||
await fireEvent.submit(form);
|
||||
await screen.findByText("Approving device login…");
|
||||
@@ -659,9 +838,11 @@ test("device approval locks its code and rejects a second submit while the serve
|
||||
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
|
||||
.toBe(true);
|
||||
await fireEvent.submit(form);
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/device-login/approve",
|
||||
]);
|
||||
await waitFor(() =>
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/device-login/approve",
|
||||
])
|
||||
);
|
||||
response.resolve(Response.json({ status: "approved", user: authUser }));
|
||||
await screen.findByText("Device login approved. You can return to the CLI.");
|
||||
expect(posted(fetcher)).toHaveLength(1);
|
||||
@@ -679,11 +860,8 @@ for (const status of [401, 410]) {
|
||||
(await screen.findByRole("button", { name: "Approve device login" }))
|
||||
.closest("form")!,
|
||||
);
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? /Authentication request was rejected/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
await screen.findByText(/Authentication request was rejected/);
|
||||
expect(screen.getByRole("alert").textContent).toContain("(" + status + ")");
|
||||
expect(
|
||||
screen.queryByText("Device login approved. You can return to the CLI."),
|
||||
).toBeNull();
|
||||
@@ -721,7 +899,7 @@ test("compatibility login keeps its selected form and ID after cancellation and
|
||||
target: { value: "alice" },
|
||||
});
|
||||
await submit("Log in with older key");
|
||||
await screen.findByText(/cancelled or timed out/);
|
||||
await screen.findByText(/cancelled, timed out, or found no usable key/);
|
||||
await screen.findByText("Use an older key with account ID");
|
||||
expect(
|
||||
(screen.getByText("Use an older key with account ID").closest(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
||||
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import { browserSession } from '#lib/workspace/auth/session-state.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let handle = $state('');
|
||||
@@ -19,12 +20,15 @@
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
||||
const current = ++generation;
|
||||
const authGeneration = browserSession.authenticationGeneration;
|
||||
const authStamp = browserSession.authenticationStamp;
|
||||
controller = new AbortController(); busy = true; error = '';
|
||||
const pending = activateWithPasskey(handle, code, controller.signal);
|
||||
code = '';
|
||||
try {
|
||||
const confirmed = await pending;
|
||||
if (current !== generation) return;
|
||||
if (authGeneration !== browserSession.authenticationGeneration || authStamp !== browserSession.authenticationStamp) throw new Error('Activation superseded');
|
||||
user = confirmed;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
|
||||
@@ -7,6 +7,11 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import {
|
||||
browserSession,
|
||||
sessionMutation,
|
||||
} from "#lib/workspace/auth/session-state.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
import Activate from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
@@ -15,9 +20,34 @@ import {
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
browserSession.invalidate();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function confirmedSession() {
|
||||
return {
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
};
|
||||
}
|
||||
function boundary(
|
||||
mode:
|
||||
| "success"
|
||||
| "pending"
|
||||
| "cancel"
|
||||
| "unknown"
|
||||
| "anonymous"
|
||||
| "bob"
|
||||
| "reference-mismatch"
|
||||
| "unavailable",
|
||||
) {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
@@ -34,7 +64,36 @@ function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
}
|
||||
if (String(input) === "/api/auth/activation/complete") {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({ user: authUser });
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
if (mode === "unavailable") {
|
||||
return new Response("private diagnostic", { status: 503 });
|
||||
}
|
||||
if (mode === "anonymous") {
|
||||
return Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
}
|
||||
const confirmation = confirmedSession();
|
||||
if (mode === "bob") {
|
||||
confirmation.actor = {
|
||||
...confirmation.actor,
|
||||
user_id: "user-bob",
|
||||
account_id: "account-bob",
|
||||
handle: "bob",
|
||||
display_name: "Bob",
|
||||
};
|
||||
}
|
||||
if (mode === "reference-mismatch") {
|
||||
confirmation.authentication_reference = "another-authentication";
|
||||
}
|
||||
return Response.json(confirmation);
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
@@ -54,12 +113,29 @@ async function submit() {
|
||||
}
|
||||
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
|
||||
const { fetcher } = boundary("success");
|
||||
const confirmation = gate<Response>();
|
||||
const started = gate<void>();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
return confirmation.promise;
|
||||
}
|
||||
return original(input, init);
|
||||
});
|
||||
render(Activate);
|
||||
await submit();
|
||||
await started.promise;
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
confirmation.resolve(Response.json(confirmedSession()));
|
||||
await screen.findByText(/registration and activation confirmed/);
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(screen.queryByLabelText("Activation code")).toBeNull();
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
|
||||
@@ -99,3 +175,106 @@ for (const mode of ["cancel", "unknown"] as const) {
|
||||
.toBe("");
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
const mode of [
|
||||
"anonymous",
|
||||
"bob",
|
||||
"reference-mismatch",
|
||||
"unavailable",
|
||||
] as const
|
||||
) {
|
||||
test(
|
||||
"activation " + mode +
|
||||
" confirmation never claims signed in after successful completion",
|
||||
async () => {
|
||||
const { fetcher, create } = boundary(mode);
|
||||
const changed = vi.fn();
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
try {
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"Passkey login",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect(
|
||||
(screen.getByLabelText("Activation code") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
expect(changed).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, changed);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
test("logout during the activation prompt fences a late credential before completion", async () => {
|
||||
const { fetcher, create } = boundary("success");
|
||||
const credential = gate<Credential>();
|
||||
create.mockReturnValueOnce(credential.promise);
|
||||
render(Activate);
|
||||
await submit();
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce());
|
||||
sessionMutation("logout");
|
||||
credential.resolve(credentialFixture());
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
]);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
});
|
||||
|
||||
for (const phase of ["prompt", "confirmation"] as const) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`activation UI rejects shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const { create, fetcher } = boundary("success");
|
||||
const started = gate<void>();
|
||||
const pending = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
if (phase === "prompt") {
|
||||
create.mockImplementation(async () => {
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
return credentialFixture();
|
||||
});
|
||||
} else {fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
}
|
||||
return await request(input, init);
|
||||
});}
|
||||
render(Activate);
|
||||
await submit();
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
localStorage.setItem(
|
||||
"yoi.browser-auth-generation",
|
||||
JSON.stringify({ id: crypto.randomUUID(), reason }),
|
||||
);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
pending.resolve();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open Account" })).toBeNull();
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([path]) =>
|
||||
path === "/api/auth/activation/complete"
|
||||
),
|
||||
).toHaveLength(phase === "prompt" ? 0 : 1);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ function boundary() {
|
||||
if (state.sessionGate) return state.sessionGate.promise;
|
||||
return state.whoamiStatus === 200
|
||||
? Response.json({
|
||||
authentication_reference: state.signedIn ? "fixture-authentication" : null,
|
||||
session: state.signedIn
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -326,6 +327,7 @@ test("pending create remains single during an explicit authentication change and
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
}),
|
||||
|
||||
@@ -22,6 +22,7 @@ function boundary(
|
||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (input === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
|
||||
session: mode !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
|
||||
@@ -27,6 +27,7 @@ function boundary(
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: persona !== "anonymous" ? "fixture-authentication" : null,
|
||||
session: persona !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { browserSession, observeBrowserSession, sessionMutation } from "#lib/workspace/auth/session-state.ts";
|
||||
import { loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { loginWithPasskey, loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { observeAuthOperation } from '#lib/workspace/auth/operation.ts';
|
||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
@@ -12,10 +13,12 @@
|
||||
let verifiedLogin = $state(false);
|
||||
let error = $state('');
|
||||
const controller = new AbortController();
|
||||
let loginController: AbortController | null = null;
|
||||
let active = true;
|
||||
|
||||
let verifiedGeneration: number | null = null;
|
||||
const isCurrent = (generation: number) => active && generation === browserSession.authenticationGeneration;
|
||||
let verifiedStamp: string | null = null;
|
||||
const isCurrent = (generation: number, stamp: string | null) => active && generation === browserSession.authenticationGeneration && stamp === browserSession.authenticationStamp;
|
||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||
function matchesSession(expected: AuthUser): boolean {
|
||||
const snapshot = browserSession.snapshot;
|
||||
@@ -28,9 +31,10 @@
|
||||
return browserSession.subscribe(() => {
|
||||
// 公開pageにはprivate SessionBoundaryの取消がない。成功表示も確認した世代に限る。
|
||||
// 後続の再確認/失効/主体変更では明示loginとaccess確認からやり直す。
|
||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || !matchesSession(user)) {
|
||||
if (!user || verifiedGeneration !== browserSession.authenticationGeneration || verifiedStamp !== browserSession.authenticationStamp || !matchesSession(user)) {
|
||||
verifiedLogin = false;
|
||||
verifiedGeneration = null;
|
||||
verifiedStamp = null;
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -61,34 +65,56 @@
|
||||
error = '';
|
||||
sessionMutation();
|
||||
let generation = browserSession.authenticationGeneration;
|
||||
let stamp = browserSession.authenticationStamp;
|
||||
const operation = new AbortController();
|
||||
loginController = operation;
|
||||
let promptGeneration: number | null = generation;
|
||||
const unsubscribe = browserSession.subscribe(() => {
|
||||
if (promptGeneration !== null && !isCurrent(promptGeneration, stamp)) operation.abort();
|
||||
});
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(controller.signal);
|
||||
if (!isCurrent(generation)) throw new Error('Login superseded');
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id) {
|
||||
const initial = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
|
||||
observeAuthOperation(operation.signal, {
|
||||
expected: initial, phase: () => {}, additionAttempt: () => {},
|
||||
verifySession: async () => {
|
||||
if (!isCurrent(generation, stamp)) operation.abort();
|
||||
operation.signal.throwIfAborted();
|
||||
const current = await loadWhoami(operation.signal);
|
||||
if (!isCurrent(generation, stamp) || initial.actor?.user_id !== current.actor?.user_id || initial.actor?.account_id !== current.actor?.account_id || initial.authentication_reference !== current.authentication_reference) throw new Error('Session changed');
|
||||
},
|
||||
});
|
||||
const signedIn = await loginWithPasskey(operation.signal);
|
||||
if (!isCurrent(generation, stamp)) throw new Error('Login superseded');
|
||||
if (signedIn.user.user_id !== expected.user_id || signedIn.user.account_id !== expected.account_id) {
|
||||
throw new Error('Unexpected login actor');
|
||||
}
|
||||
// Set-Cookie後の確認を共有状態へ発行し、操作前/操作中のreadを再利用しない。
|
||||
promptGeneration = null;
|
||||
sessionMutation();
|
||||
generation = browserSession.authenticationGeneration;
|
||||
stamp = browserSession.authenticationStamp;
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session not verified');
|
||||
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session not verified');
|
||||
const access = await loadHostingAccess();
|
||||
if (!isCurrent(generation) || !access.is_admin) throw new Error('Access not verified');
|
||||
if (!isCurrent(generation, stamp) || !access.is_admin) throw new Error('Access not verified');
|
||||
// accessのawait中にもcookie/actorは変わり得る。success公開前の最終確認を行う。
|
||||
await browserSession.refresh();
|
||||
if (!isCurrent(generation) || !matchesSession(expected)) throw new Error('Session superseded');
|
||||
if (!isCurrent(generation, stamp) || !matchesSession(expected) || browserSession.snapshot.kind !== 'ready' || browserSession.snapshot.value.authentication_reference !== signedIn.authentication_reference) throw new Error('Session superseded');
|
||||
verifiedGeneration = generation;
|
||||
verifiedStamp = stamp;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||
// 応答不明時の再確認は自身がまだ現世代の時だけ。古いfinallyで新logout/loginを
|
||||
// invalidateしたり、その確定結果を再取得のloadingへ戻したりしない。
|
||||
if (isCurrent(generation)) {
|
||||
if (isCurrent(generation, stamp)) {
|
||||
sessionMutation();
|
||||
await browserSession.refresh();
|
||||
}
|
||||
} finally {
|
||||
promptGeneration = null; unsubscribe(); loginController = null;
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
@@ -97,6 +123,7 @@
|
||||
active = false;
|
||||
token = '';
|
||||
controller.abort();
|
||||
loginController?.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -36,37 +36,56 @@ function boundary(
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
const get = vi.fn(async () => credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin
|
||||
? new Response(null, { status: 401 })
|
||||
: Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async (input, init) => {
|
||||
const path = String(input);
|
||||
if (
|
||||
path === "/api/auth/whoami" &&
|
||||
new Headers(init?.headers).has("authorization")
|
||||
) {
|
||||
return status === 200
|
||||
? Response.json({
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
authentication_reference: "fixture-bearer-authentication",
|
||||
session: { kind: "authenticated", expires_at: null },
|
||||
})
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin ? new Response(null, { status: 401 }) : Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create, get };
|
||||
}
|
||||
@@ -117,8 +136,10 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
||||
for (const [path, init] of requests) {
|
||||
expect(String(path)).not.toContain("host-test-token");
|
||||
expect(String(init?.body)).not.toContain("host-test-token");
|
||||
expect(init?.credentials).toBe("omit");
|
||||
expect(new Headers(init?.headers).has("cookie")).toBe(false);
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
|
||||
"Bearer host-test-token",
|
||||
);
|
||||
}
|
||||
await fireEvent.click(
|
||||
@@ -130,7 +151,7 @@ test("enrollment clears token and omits cookies, then verifies an actual passkey
|
||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
.toHaveLength(6);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
@@ -179,8 +200,10 @@ test("a different cookie actor cannot be accepted as the enrolled administrator"
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
String(input) === "/api/auth/whoami" &&
|
||||
!new Headers(init?.headers).has("authorization")
|
||||
? Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: {
|
||||
...authUser,
|
||||
@@ -221,7 +244,12 @@ test("leaving during the passkey prompt aborts enrollment and prevents later com
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
resolve(credentialFixture());
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
await waitFor(() =>
|
||||
expect(fetcher.mock.calls.map(([path]) => path)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
])
|
||||
);
|
||||
const { unmount: unmountAgain } = render(Setup);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
@@ -248,7 +276,7 @@ test("completion response loss does not claim success and points to login before
|
||||
);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(fetcher).toHaveBeenCalledTimes(3);
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -259,6 +287,7 @@ test("API token identity is not mistaken for a verified browser session", async
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
actor: { ...authUser, auth_method: "api_token" },
|
||||
})
|
||||
@@ -292,6 +321,7 @@ const replacedActor = {
|
||||
function identityResponse(actor: typeof setupActor | null) {
|
||||
return Response.json({
|
||||
actor,
|
||||
authentication_reference: actor ? "fixture-authentication" : null,
|
||||
session: actor
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -317,15 +347,25 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||
const started = gate<void>();
|
||||
const pending = gate<Response>();
|
||||
let current: typeof setupActor | null = setupActor;
|
||||
let reads = 0;
|
||||
let loginCompleted = false;
|
||||
let accessRead = false;
|
||||
let held = false;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
const path = String(input);
|
||||
// Bearer identity resolution belongs to enrollment, never the cookie-login gates.
|
||||
if (new Headers(init?.headers).has("authorization")) {
|
||||
return original(input, init);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") loginCompleted = true;
|
||||
if (path === "/api/hosting/access") accessRead = true;
|
||||
if (path === "/api/auth/whoami") {
|
||||
++reads;
|
||||
if (
|
||||
(phase === "whoami" && reads === 1) ||
|
||||
(phase === "confirmation" && reads === 2)
|
||||
!held && loginCompleted && (
|
||||
(phase === "whoami" && !accessRead) ||
|
||||
(phase === "confirmation" && accessRead)
|
||||
)
|
||||
) {
|
||||
held = true;
|
||||
started.resolve();
|
||||
return pending.promise;
|
||||
}
|
||||
@@ -357,7 +397,10 @@ for (const phase of ["login", "whoami", "access", "confirmation"] as const) {
|
||||
phase === "access"
|
||||
? Response.json({ is_admin: true })
|
||||
: phase === "login"
|
||||
? Response.json({ user: authUser })
|
||||
? Response.json({
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
})
|
||||
: identityResponse(setupActor),
|
||||
);
|
||||
await waitFor(() =>
|
||||
@@ -421,3 +464,154 @@ for (const replacement of [null, replacedActor]) {
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
test("setup logout during native login prompt aborts and never submits the late credential", async () => {
|
||||
const { get, fetcher } = boundary();
|
||||
const prompted = gate<void>();
|
||||
const credential = gate<Credential>();
|
||||
get.mockImplementation(() => {
|
||||
prompted.resolve();
|
||||
return credential.promise;
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await prompted.promise;
|
||||
await changedInAnotherTab(null);
|
||||
const signal = (get.mock.calls[0] as unknown as [CredentialRequestOptions])[0]
|
||||
.signal!;
|
||||
expect(signal.aborted).toBe(true);
|
||||
credential.resolve(credentialFixture(true));
|
||||
await screen.findByRole("alert");
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) =>
|
||||
path === "/api/auth/passkeys/login/complete"
|
||||
),
|
||||
).toBe(false);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("setup final confirmation rejects replacement session for the same owner", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
let accessRead = false;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/hosting/access") accessRead = true;
|
||||
if (String(input) === "/api/auth/whoami" && accessRead) {
|
||||
return Response.json({
|
||||
actor: setupActor,
|
||||
authentication_reference: "same-owner-different-session",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
return await request(input, init);
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
for (
|
||||
const phase of [
|
||||
"initial",
|
||||
"options",
|
||||
"prompt",
|
||||
"pre-complete",
|
||||
"complete",
|
||||
"whoami",
|
||||
"access",
|
||||
"confirmation",
|
||||
] as const
|
||||
) {
|
||||
for (const reason of ["logout", "authentication"] as const) {
|
||||
test(`setup fences shared ${reason} stamp during ${phase} before event delivery`, async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
const started = gate<void>();
|
||||
const pending = gate<void>();
|
||||
let optionsRead = false,
|
||||
promptReturned = false,
|
||||
completionSent = false,
|
||||
accessRead = false,
|
||||
held = false;
|
||||
const hold = async () => {
|
||||
held = true;
|
||||
started.resolve();
|
||||
await pending.promise;
|
||||
};
|
||||
get.mockImplementation(async () => {
|
||||
if (phase === "prompt") await hold();
|
||||
promptReturned = true;
|
||||
return credentialFixture(true);
|
||||
});
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (new Headers(init?.headers).has("authorization")) {
|
||||
return await request(input, init);
|
||||
}
|
||||
const path = String(input);
|
||||
const point = path === "/api/auth/whoami"
|
||||
? !optionsRead
|
||||
? "initial"
|
||||
: !completionSent && promptReturned
|
||||
? "pre-complete"
|
||||
: accessRead
|
||||
? "confirmation"
|
||||
: "whoami"
|
||||
: path.endsWith("/login/options")
|
||||
? "options"
|
||||
: path.endsWith("/login/complete")
|
||||
? "complete"
|
||||
: path === "/api/hosting/access"
|
||||
? "access"
|
||||
: null;
|
||||
if (point === "options") optionsRead = true;
|
||||
if (point === "complete") completionSent = true;
|
||||
if (point === "access") accessRead = true;
|
||||
const response = await request(input, init);
|
||||
if (!held && point === phase) await hold();
|
||||
return response;
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await started.promise;
|
||||
const generation = browserSession.authenticationGeneration;
|
||||
const stamp = JSON.stringify({ id: crypto.randomUUID(), reason });
|
||||
// 同一documentのsetItemはstorage eventを配送しない。generationを直接変えず順序を固定する。
|
||||
localStorage.setItem("yoi.browser-auth-generation", stamp);
|
||||
expect(browserSession.authenticationGeneration).toBe(generation);
|
||||
pending.resolve();
|
||||
await screen.findByRole("alert");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Sign in with new passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([path]) =>
|
||||
path === "/api/auth/passkeys/login/complete"
|
||||
),
|
||||
).toHaveLength(
|
||||
["complete", "whoami", "access", "confirmation"].includes(phase)
|
||||
? 1
|
||||
: 0,
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
// 古いcatchが新tabのstampを自身のsessionMutationで上書きしない。
|
||||
expect(browserSession.authenticationStamp).toBe(stamp);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@
|
||||
async function approve() {
|
||||
if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
|
||||
const code = userCode.trim().toUpperCase();
|
||||
await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
|
||||
const confirmed = await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
|
||||
const result = await approveDeviceLogin(code, signal);
|
||||
if (result.status !== 'approved') throw new Error('Device approval not confirmed');
|
||||
approved = true;
|
||||
});
|
||||
return result;
|
||||
}, 'device-approval');
|
||||
if (confirmed) approved = true;
|
||||
}
|
||||
onMount(() => {
|
||||
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
|
||||
|
||||
@@ -57,7 +57,7 @@ beforeEach(() => {
|
||||
"fetch",
|
||||
vi.fn(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({ session: { kind: "anonymous" }, actor: null });
|
||||
return Response.json({ session: { kind: "anonymous" }, authentication_reference: null, actor: null });
|
||||
}
|
||||
throw new Error(`Unexpected request ${input}`);
|
||||
}),
|
||||
|
||||
@@ -41,6 +41,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (path === "/api/workspaces") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: mode !== "anonymous" ? "fixture-authentication" : null,
|
||||
session: mode !== "anonymous"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -89,7 +90,7 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (mode === "activation-unknown") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ user });
|
||||
return Response.json({ user, authentication_reference: "fixture-authentication" });
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
const file = join(root, decodeURIComponent(path));
|
||||
|
||||
@@ -34,6 +34,11 @@ const register = async (page: Page) => {
|
||||
await page.getByRole("button", { name: "Register new account", exact: true })
|
||||
.click();
|
||||
};
|
||||
const additionConfirmed = (page: Page) =>
|
||||
page.getByRole("status").filter({
|
||||
hasText:
|
||||
/^Passkey added to your account; the expected session was confirmed\.$/,
|
||||
});
|
||||
const count = (mutations: Mutation[], path: string) =>
|
||||
mutations.filter((entry) => entry.path === path).length;
|
||||
const at = async (page: Page, path: string) => {
|
||||
@@ -151,7 +156,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
readyUrl: `${origin}/health`,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/t767-validation/auth-interactions/processes`,
|
||||
`${root}/target/t783/web-shell-final/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
@@ -229,9 +234,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).waitFor();
|
||||
await additionConfirmed(page).waitFor();
|
||||
const additions = mutations.filter((entry) =>
|
||||
entry.path.includes("/addition/")
|
||||
);
|
||||
@@ -239,7 +242,12 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
assertEquals(additions[0].keys, ["browser_origin"]);
|
||||
assertEquals(additions[0].keys, [
|
||||
"browser_origin",
|
||||
"expected_account_id",
|
||||
"expected_authentication_reference",
|
||||
"expected_user_id",
|
||||
]);
|
||||
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
|
||||
assertEquals(
|
||||
mutations.some((entry) => entry.path.includes("/registration/")),
|
||||
@@ -357,8 +365,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({ hasText: /added/i })
|
||||
.waitFor();
|
||||
await additionConfirmed(page).waitFor();
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/addition/complete"),
|
||||
1,
|
||||
@@ -784,9 +791,7 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
.__authFixtureRelease()
|
||||
);
|
||||
if (purpose === "addition") {
|
||||
await page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).waitFor();
|
||||
await additionConfirmed(page).waitFor();
|
||||
} else await at(page, "/dashboard");
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/options`),
|
||||
@@ -813,8 +818,18 @@ Deno.test("standalone auth separates login, registration, own account and explic
|
||||
name: "Approve device login",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
|
||||
.waitFor();
|
||||
await page.getByRole("alert").filter({
|
||||
hasText:
|
||||
/^Authentication request was rejected \(400\)\. Check your session or restart the operation\.$/,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/login/device");
|
||||
assertEquals(
|
||||
await page.getByText(
|
||||
"Device login approved. You can return to the CLI.",
|
||||
{ exact: true },
|
||||
).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -554,7 +554,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"SPA page access checks once without hiding the admitted page",
|
||||
"SPA page access checks once and fences key-list reads without hiding the admitted page",
|
||||
async (page) => {
|
||||
let checks = 0;
|
||||
page.on("request", (request) => {
|
||||
@@ -582,6 +582,7 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
await page.getByRole("link", { name: "Account", exact: true })
|
||||
.click();
|
||||
await requested;
|
||||
assertEquals(checks, 2);
|
||||
assertEquals(
|
||||
await page.getByRole("heading", {
|
||||
name: "Dashboard",
|
||||
@@ -599,7 +600,10 @@ Deno.test("public entry isolates Workspace authority and authenticated dashboard
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/account");
|
||||
assertEquals(checks, 2);
|
||||
await page.getByRole("list", { name: "Registered keys" })
|
||||
.waitFor();
|
||||
// page入場の一回に加え、本人鍵一覧は読取りの前後で本人bindingを確認する。
|
||||
assertEquals(checks, 4);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
|
||||
@@ -158,9 +158,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === "session-malformed") {
|
||||
return Response.json({ actor: { handle: "untrusted" } });
|
||||
return Response.json({
|
||||
authentication_reference: state.signedIn
|
||||
? "fixture-authentication"
|
||||
: null,
|
||||
actor: { handle: "untrusted" },
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
authentication_reference: state.signedIn
|
||||
? "fixture-authentication"
|
||||
: null,
|
||||
actor: state.signedIn
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
@@ -168,11 +176,25 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
? {
|
||||
kind: "authenticated",
|
||||
expires_at: state.expiresAt ??
|
||||
new Date(Date.now() + 86_400_000).toISOString(),
|
||||
"2099-01-01T00:00:00Z",
|
||||
}
|
||||
: { kind: state.expired ? "expired" : "anonymous" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys") {
|
||||
return Response.json({
|
||||
user,
|
||||
keys: [{
|
||||
key_reference: "synthetic-safe-key-reference",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
last_used_at: null,
|
||||
normal_login_verified_at: null,
|
||||
}],
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/result") {
|
||||
return Response.json({ status: "unknown" });
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (mode === "policy-loading") return await new Promise<Response>(() => {});
|
||||
if (mode === "policy-error") return new Response(null, { status: 503 });
|
||||
@@ -227,7 +249,11 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (!state.signedIn) return new Response(null, { status: 401 });
|
||||
if (mode === "device-expired") return new Response(null, { status: 410 });
|
||||
if (mode === "device-expired") {
|
||||
return Response.json({
|
||||
message: "expired_device_login: device login code expired",
|
||||
}, { status: 400 });
|
||||
}
|
||||
return Response.json({ status: "approved", user });
|
||||
}
|
||||
const passkey = path.match(
|
||||
@@ -263,7 +289,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
return Response.json({
|
||||
user,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(options(false));
|
||||
@@ -273,7 +302,10 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
state.expired = false;
|
||||
state.cookieSession = false;
|
||||
state.expiresAt = null;
|
||||
return Response.json({ user });
|
||||
return Response.json({
|
||||
user,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
|
||||
if (workspaceFixture && path.startsWith("/api/w/")) {
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
/// <reference lib="dom" />
|
||||
// 本番shellと可視状態の試験。HTTP/認証器を合成fixtureへ置換するため、暗号/保存の成功根拠ではない。
|
||||
// native CTAP2 + 実Backend経路はtests/e2eの別suiteが所有する。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { type Browser, chromium } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
|
||||
Deno.test("account addition keeps pending and recovery visible at desktop and mobile widths", async (t) => {
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const reservation = Deno.listen({ hostname: "127.0.0.1", port: 0 });
|
||||
const port = (reservation.addr as Deno.NetAddr).port;
|
||||
reservation.close();
|
||||
const origin = `http://127.0.0.1:${port}`;
|
||||
const out = `${root}/target/web-ux/t783-after`;
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "passkey-state",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
String(port),
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: `${origin}/health`,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${out}/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
const records: unknown[] = [];
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const passed = await t.step(
|
||||
`${theme}/${width}: visible pending, stop and migration metadata`,
|
||||
async () => {
|
||||
const context = await browser!.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
page.setDefaultTimeout(5000);
|
||||
const pageErrors: string[] = [];
|
||||
const statuses: number[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const requestErrors: string[] = [];
|
||||
page.on(
|
||||
"requestfailed",
|
||||
() => requestErrors.push("request-failed"),
|
||||
);
|
||||
page.on("pageerror", () => pageErrors.push("page-error"));
|
||||
page.on("console", (msg) => {
|
||||
if (msg.type() === "error") {
|
||||
consoleErrors.push("console-error");
|
||||
}
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) statuses.push(response.status());
|
||||
});
|
||||
await context.route(
|
||||
"**/*",
|
||||
(route) =>
|
||||
new URL(route.request().url()).origin === origin
|
||||
? route.continue()
|
||||
: route.abort(),
|
||||
);
|
||||
await page.request.get(`${origin}/?fixture=addition-pending`);
|
||||
const documentResponse = await page.goto(`${origin}/account`);
|
||||
const add = page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
});
|
||||
await add.waitFor();
|
||||
await page.getByRole("list", { name: "Registered keys" })
|
||||
.waitFor();
|
||||
await page.screenshot({
|
||||
path: `${out}/account-${theme}-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
await add.click();
|
||||
const pending = page.getByRole("status").filter({
|
||||
hasText: "Waiting for passkey addition",
|
||||
});
|
||||
await pending.waitFor({ state: "visible" });
|
||||
const box = await pending.boundingBox();
|
||||
assert(
|
||||
box && box.y >= 0 && box.y + box.height <= 900,
|
||||
"pending must be in the initial visible viewport",
|
||||
);
|
||||
assertEquals(await add.isDisabled(), true);
|
||||
assertEquals(
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
.isVisible(),
|
||||
false,
|
||||
);
|
||||
await page.screenshot({
|
||||
path: `${out}/pending-${theme}-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
const pendingAccessibility = await page.locator("main")
|
||||
.ariaSnapshot();
|
||||
await page.getByRole("button", {
|
||||
name: "Stop waiting",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { __authFixtureRelease: () => void })
|
||||
.__authFixtureRelease()
|
||||
);
|
||||
await page.getByRole("alert").filter({
|
||||
hasText: "Completion was not sent",
|
||||
}).waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Refresh registered keys",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByText(
|
||||
"Not yet verified — storage alone does not prove support",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
await page.getByText(
|
||||
"Verify migration before retiring older-key login",
|
||||
{ exact: true },
|
||||
).click();
|
||||
await page.screenshot({
|
||||
path: `${out}/migration-${theme}-${width}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(documentResponse?.status(), 200);
|
||||
assertEquals(pageErrors, []);
|
||||
assertEquals(consoleErrors, []);
|
||||
assertEquals(statuses, []);
|
||||
assertEquals(requestErrors, []);
|
||||
const images = [];
|
||||
for (
|
||||
const name of [
|
||||
`account-${theme}-${width}.png`,
|
||||
`pending-${theme}-${width}.png`,
|
||||
`migration-${theme}-${width}.png`,
|
||||
]
|
||||
) {
|
||||
const digest = await crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
await Deno.readFile(`${out}/${name}`),
|
||||
);
|
||||
images.push({
|
||||
name,
|
||||
sha256: Array.from(
|
||||
new Uint8Array(digest),
|
||||
(byte) => byte.toString(16).padStart(2, "0"),
|
||||
).join(""),
|
||||
});
|
||||
}
|
||||
records.push({
|
||||
route: "/account",
|
||||
actor: "synthetic Alice",
|
||||
theme,
|
||||
width,
|
||||
documentStatus: documentResponse?.status(),
|
||||
pageErrors,
|
||||
consoleErrors,
|
||||
requestErrors,
|
||||
statuses,
|
||||
pendingAccessibility,
|
||||
visible: await page.locator("main").innerText(),
|
||||
images,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
if (!passed) {
|
||||
throw new Error(
|
||||
"Stop on the first failed visibility condition; investigate before repeating the matrix",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
await Deno.writeTextFile(
|
||||
`${out}/review-context.json`,
|
||||
JSON.stringify(records, null, 2),
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -239,6 +239,10 @@ export async function sessionNavigation(
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
// Accountの初期metadata確認はwhoamiを前後で読む。完了を可視結果で
|
||||
// 待ち、短命cookieの観測へ初期requestを混入させない。
|
||||
await page.getByRole("list", { name: "Registered keys", exact: true })
|
||||
.waitFor();
|
||||
const armed = await control(page, "arm-cookie");
|
||||
assert(armed.headers()["set-cookie"].includes("Max-Age=3"));
|
||||
const cookie = (await page.context().cookies()).find((c) =>
|
||||
@@ -361,6 +365,10 @@ export async function sessionNavigation(
|
||||
await go("/account?tab=keys#stale", "account");
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
// 保留するのは明示refresh。初期metadata requestは離脱時にabortされる
|
||||
// 別のlifetimeなので、先に完了させる。
|
||||
await page.getByRole("list", { name: "Registered keys", exact: true })
|
||||
.waitFor();
|
||||
let started!: () => void;
|
||||
let failed!: (error: unknown) => void;
|
||||
let release!: () => void;
|
||||
@@ -433,7 +441,7 @@ export async function sessionNavigation(
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"challenge 401 leaves an authenticated account in place and permits retry",
|
||||
"challenge 401 leaves an authenticated account in place but requires result recovery before another addition",
|
||||
async ({ page, go }) => {
|
||||
const target = "/account?tab=keys#challenge";
|
||||
await go(target, "addition-expired");
|
||||
@@ -453,7 +461,21 @@ export async function sessionNavigation(
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).isEnabled(),
|
||||
true,
|
||||
false,
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Check addition result",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "addition result is still unknown",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).isEnabled(),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -466,7 +488,11 @@ export async function sessionNavigation(
|
||||
await at(page, "/");
|
||||
const wire = await (await page.request.get(`${origin}/api/auth/whoami`))
|
||||
.json();
|
||||
assertEquals(wire, { actor: null, session: { kind: "anonymous" } });
|
||||
assertEquals(wire, {
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: "anonymous" },
|
||||
});
|
||||
await page.evaluate(() => {
|
||||
localStorage.clear();
|
||||
sessionStorage.clear();
|
||||
@@ -487,6 +513,9 @@ export async function sessionNavigation(
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
// 初期metadata確認のwhoamiをlifecycle event由来と誤計数しない。
|
||||
await page.getByRole("list", { name: "Registered keys", exact: true })
|
||||
.waitFor();
|
||||
await control(page, "expire");
|
||||
let checks = 0;
|
||||
page.on("request", (request) => {
|
||||
|
||||
@@ -19,7 +19,7 @@ const mime: Record<string, string> = {
|
||||
};
|
||||
const policies = new Map<string, boolean>();
|
||||
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
|
||||
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
|
||||
// 送らないので、Bearer有無と直前の合成page条件で応答を選ぶ。header値は読まない。
|
||||
let setupMode: string | null = null;
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
@@ -28,19 +28,27 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) {
|
||||
setupMode = selected;
|
||||
}
|
||||
const tokenRequest = request.headers.has("authorization");
|
||||
const mode = selected ??
|
||||
(tokenRequest ? setupMode : null) ??
|
||||
request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/")
|
||||
? setupMode
|
||||
: null) ??
|
||||
"admin-on";
|
||||
if (selected) policies.delete(mode);
|
||||
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
|
||||
const setup = setupProjectionResponse(
|
||||
url.pathname,
|
||||
mode,
|
||||
selected !== null,
|
||||
tokenRequest,
|
||||
);
|
||||
if (setup) return setup;
|
||||
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
|
||||
const ordinary = mode.startsWith("ordinary");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
authentication_reference: anonymous ? null : "fixture-authentication",
|
||||
session: !anonymous
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -53,6 +61,17 @@ Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/auth/passkeys") {
|
||||
return Response.json({
|
||||
user: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
},
|
||||
keys: [],
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") return Response.json([]);
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: !ordinary && !anonymous });
|
||||
|
||||
@@ -19,6 +19,7 @@ export function setupProjectionResponse(
|
||||
path: string,
|
||||
mode: string,
|
||||
reset: boolean,
|
||||
tokenRequest = false,
|
||||
): Response | null {
|
||||
if (!mode.startsWith("setup-")) return null;
|
||||
if (reset) stages.delete(mode);
|
||||
@@ -28,7 +29,17 @@ export function setupProjectionResponse(
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (tokenRequest) {
|
||||
return Response.json({
|
||||
actor: { ...user, auth_method: "api_token" },
|
||||
authentication_reference: "fixture-token-binding",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
return Response.json({
|
||||
authentication_reference: stages.get(mode) === "login"
|
||||
? "fixture-authentication"
|
||||
: null,
|
||||
session: stages.get(mode) === "login"
|
||||
? { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" }
|
||||
: { kind: "anonymous" },
|
||||
@@ -56,7 +67,10 @@ export function setupProjectionResponse(
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
stages.set(mode, "enrolled");
|
||||
return Response.json({ user });
|
||||
return Response.json({
|
||||
user,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json({
|
||||
@@ -66,7 +80,10 @@ export function setupProjectionResponse(
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
stages.set(mode, "login");
|
||||
return Response.json({ user });
|
||||
return Response.json({
|
||||
user,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ Deno.test("authentication and hosting shells never require Workspace catalog aut
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
browserSession.invalidate();
|
||||
@@ -60,6 +61,7 @@ Deno.test("account loads its sidebar catalog without making catalog failure an a
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
browserSession.invalidate();
|
||||
@@ -106,7 +108,11 @@ Deno.test("root admission never starts private catalog while anonymous, expired,
|
||||
if (condition === "loading") await pending;
|
||||
return condition === "error" || condition === "loading"
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json({ actor: null, session: { kind: condition } });
|
||||
: Response.json({
|
||||
actor: null,
|
||||
authentication_reference: null,
|
||||
session: { kind: condition },
|
||||
});
|
||||
};
|
||||
let privateCalls = 0;
|
||||
const event = {
|
||||
|
||||
@@ -168,35 +168,62 @@ for (const purpose of ["registration", "addition"] as const) {
|
||||
await withBrowserBoundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
requests.length === 1 ? registrationOptions : { user: authUser },
|
||||
String(input) === "/api/auth/whoami"
|
||||
? {
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: {
|
||||
kind: "authenticated",
|
||||
expires_at: "2099-01-01T00:00:00Z",
|
||||
},
|
||||
}
|
||||
: String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
}, async (calls) => {
|
||||
const user = purpose === "registration"
|
||||
? await registerPasskey("alice", "Alice")
|
||||
: await addPasskey();
|
||||
assertEquals(user, authUser);
|
||||
assertEquals(user, {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
assertEquals(calls, { create: 1, get: 0 });
|
||||
});
|
||||
assertEquals(requests.map(({ path }) => path), [
|
||||
...(purpose === "addition" ? ["/api/auth/whoami"] : []),
|
||||
`/api/auth/passkeys/${purpose}/options`,
|
||||
`/api/auth/passkeys/${purpose}/complete`,
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
assertEquals(init.method, "POST");
|
||||
for (const { path, init } of requests) {
|
||||
assertEquals(
|
||||
init.method ?? "GET",
|
||||
path.endsWith("/whoami") ? "GET" : "POST",
|
||||
);
|
||||
assertEquals(init.credentials, "same-origin");
|
||||
assertEquals(init.headers, { "content-type": "application/json" });
|
||||
}
|
||||
const ceremony = requests.filter(({ path }) => !path.endsWith("/whoami"));
|
||||
assertEquals(
|
||||
JSON.parse(String(requests[0].init.body)),
|
||||
JSON.parse(String(ceremony[0].init.body)),
|
||||
purpose === "registration"
|
||||
? {
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
browser_origin: "https://yoi.example",
|
||||
}
|
||||
: { browser_origin: "https://yoi.example" },
|
||||
: {
|
||||
expected_user_id: authUser.user_id,
|
||||
expected_account_id: authUser.account_id,
|
||||
expected_authentication_reference: "fixture-authentication",
|
||||
browser_origin: "https://yoi.example",
|
||||
},
|
||||
);
|
||||
const complete = JSON.parse(String(requests[1].init.body));
|
||||
const complete = JSON.parse(String(ceremony[1].init.body));
|
||||
assertEquals(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||
assertEquals(complete.challenge_id, registrationOptions.challenge_id);
|
||||
assertEquals(complete.credential.response.attestationObject, "AQID");
|
||||
@@ -208,13 +235,21 @@ Deno.test("addition rejects expired-session completion without falling back to r
|
||||
const paths: string[] = [];
|
||||
await withBrowserBoundary(async (input) => {
|
||||
paths.push(String(input));
|
||||
return paths.length === 1
|
||||
if (String(input).endsWith("/whoami")) {
|
||||
return Response.json({
|
||||
actor: { ...authUser, auth_method: "browser_session" },
|
||||
authentication_reference: "fixture-authentication",
|
||||
session: { kind: "authenticated", expires_at: "2099-01-01T00:00:00Z" },
|
||||
});
|
||||
}
|
||||
return String(input).endsWith("/options")
|
||||
? Response.json(registrationOptions)
|
||||
: new Response(null, { status: 401 });
|
||||
}, async () => {
|
||||
await assertRejects(addPasskey(), "Auth request failed (401).");
|
||||
await assertRejects(addPasskey(), "Passkey complete: rejected (rejected).");
|
||||
});
|
||||
assertEquals(paths, [
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
@@ -226,7 +261,10 @@ Deno.test("existing-handle registration rejection never starts WebAuthn or retri
|
||||
paths.push(String(input));
|
||||
return new Response(null, { status: 409 });
|
||||
}, async (calls) => {
|
||||
await assertRejects(registerPasskey("alice"), "Auth request failed (409).");
|
||||
await assertRejects(
|
||||
registerPasskey("alice"),
|
||||
"Passkey options: rejected (not-sent).",
|
||||
);
|
||||
assertEquals(calls, { create: 0, get: 0 });
|
||||
});
|
||||
assertEquals(paths, ["/api/auth/passkeys/registration/options"]);
|
||||
@@ -251,10 +289,16 @@ Deno.test("login sends the explicit trimmed handle and serializes the assertion"
|
||||
await withBrowserBoundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
requests.length === 1 ? loginOptions : { user: authUser },
|
||||
requests.length === 1 ? loginOptions : {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
},
|
||||
);
|
||||
}, async (calls) => {
|
||||
assertEquals(await loginWithLegacyPasskey(" alice "), authUser);
|
||||
assertEquals(await loginWithLegacyPasskey(" alice "), {
|
||||
user: authUser,
|
||||
authentication_reference: "fixture-authentication",
|
||||
});
|
||||
assertEquals(calls, { create: 0, get: 1 });
|
||||
});
|
||||
assertEquals(requests.map(({ path }) => path), [
|
||||
|
||||
Reference in New Issue
Block a user