fix: separate account registration from authenticated passkey addition

Bind enrollment purpose and allocated/current account to verifier state. Reject existing-handle registration, derive additions from live transport authentication, recheck revocation/expiry in the insert-only transaction, and consume challenges atomically across connections. Derive stable account WebAuthn handles without rewriting legacy credentials; reject implicit-user login and unsafe legacy ceremonies.

Align account callers, DTO/OpenAPI/generated artifacts and migration/recovery rationale. Preserve credential-less legacy identities rather than allowing anonymous reclaim.

Validation: 14 actual-verifier passkey tests; full workspace-server 1086 library + 11 binary passed (Drive ignored case explicitly selected separately and passed); server-api default 99 and TypeScript feature 108 passed; isolated auth/Runtime E2E 1+2 passed; Web 645 + component 394 passed; root cargo check, docs/doctests, Server/Runtime builds, fmt, naming and generated consistency passed. Software authenticator only; hosted CI/hardware login not claimed.
This commit is contained in:
2026-10-16 00:55:16 +09:00
parent b744efd104
commit 6218c27fb6
24 changed files with 3184 additions and 140 deletions
+74
View File
@@ -179,6 +179,7 @@ impl_openapi_schema!(
PasskeyLoginOptionsRequest,
PasskeyLoginOptionsResponse,
PasskeyLoginCompleteRequest,
PasskeyAdditionOptionsRequest,
PasskeyRegistrationOptionsRequest,
PasskeyRegistrationOptionsResponse,
PasskeyRegistrationCompleteRequest,
@@ -546,6 +547,36 @@ pub trait ServerApi {
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyRegistrationComplete, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/options",
status = 200,
error_status = 400,
additional_error_statuses = [403, 500, 502],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_options(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyAdditionOptionsRequest,
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
#[post(
"/api/auth/passkeys/addition/complete",
responses = [
(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])
],
error_status = 400,
additional_error_statuses = [403, 500],
bearer_auth = true,
browser_auth = true
)]
async fn auth_passkey_addition_complete(
&self,
#[extension] context: ServerRequestContext,
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthPasskeyAdditionComplete, RepositoryApiError>;
#[post(
"/api/auth/passkeys/login/options",
status = 200,
@@ -3872,6 +3903,7 @@ pub struct AuthUserResponse {
pub user: AuthenticatedUser,
}
/// Starts new-account registration only; an existing handle is never interpreted as key addition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -3885,6 +3917,16 @@ pub struct PasskeyRegistrationOptionsRequest {
pub browser_origin: Option<String>,
}
/// Adds a credential to the current authenticated actor; target identity is never public input.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct PasskeyAdditionOptionsRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "typescript", ts(optional = nullable))]
pub browser_origin: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -3905,6 +3947,10 @@ pub struct PasskeyRegistrationCompleteRequest {
pub credential: RegisterPublicKeyCredential,
}
/// Identified-account login. Missing handles are rejected, not resolved to an implicit user.
///
/// The nullable wire field permits an explicit authentication error for old callers; it does not
/// advertise a discoverable-login ceremony.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -11344,6 +11390,7 @@ pub fn auth_api_typescript() -> String {
WhoamiResponse::decl(&config),
AuthBootstrapUserRequest::decl(&config),
AuthUserResponse::decl(&config),
PasskeyAdditionOptionsRequest::decl(&config),
PasskeyRegistrationOptionsRequest::decl(&config),
PasskeyRegistrationOptionsResponse::decl(&config),
PasskeyRegistrationCompleteRequest::decl(&config),
@@ -12061,6 +12108,16 @@ mod tests {
HttpMethod::Post,
"/api/auth/passkeys/registration/options",
),
(
"auth_passkey_addition_options",
HttpMethod::Post,
"/api/auth/passkeys/addition/options",
),
(
"auth_passkey_addition_complete",
HttpMethod::Post,
"/api/auth/passkeys/addition/complete",
),
(
"auth_passkey_login_options",
HttpMethod::Post,
@@ -14452,6 +14509,20 @@ mod openapi_artifact_tests {
}
}
#[test]
fn passkey_addition_rejects_caller_selected_target_fields() {
for field in ["handle", "user_id", "account_id"] {
let payload = serde_json::json!({ (field): "other-user" });
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(payload).is_err(),
"addition must reject caller-selected {field}"
);
}
assert!(
serde_json::from_value::<PasskeyAdditionOptionsRequest>(serde_json::json!({})).is_ok()
);
}
#[test]
fn auth_workspace_catalog_and_typed_header_contract_is_strict() {
let document = canonical_openapi_document().expect("canonical OpenAPI contract must build");
@@ -14465,6 +14536,8 @@ mod openapi_artifact_tests {
("/api/auth/bootstrap-user", "post"),
("/api/auth/passkeys/registration/options", "post"),
("/api/auth/passkeys/registration/complete", "post"),
("/api/auth/passkeys/addition/options", "post"),
("/api/auth/passkeys/addition/complete", "post"),
("/api/auth/passkeys/login/options", "post"),
("/api/auth/passkeys/login/complete", "post"),
("/api/auth/logout", "post"),
@@ -14503,6 +14576,7 @@ mod openapi_artifact_tests {
}
for path in [
"/api/auth/passkeys/registration/complete",
"/api/auth/passkeys/addition/complete",
"/api/auth/passkeys/login/complete",
"/api/auth/logout",
] {