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:
@@ -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",
|
||||
] {
|
||||
|
||||
@@ -116,7 +116,7 @@ pub async fn resolve_request_actor(
|
||||
|
||||
// One owned synchronous stage includes token usage marking and the user read.
|
||||
// Do not call async store wrappers here: they use the same executor themselves.
|
||||
fn resolve_request_actor_sync(
|
||||
pub(crate) fn resolve_request_actor_sync(
|
||||
store: &dyn ControlPlaneStore,
|
||||
bearer: Option<String>,
|
||||
session: Option<String>,
|
||||
|
||||
+1792
-106
File diff suppressed because it is too large
Load Diff
@@ -661,6 +661,30 @@ pub struct UserRecord {
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Enrollment authority persisted with the WebAuthn verifier, never supplied by a client.
|
||||
/// New-account identities are only allocations until verified completion commits them.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "purpose", rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub enum PasskeyRegistrationTarget {
|
||||
NewAccount {
|
||||
user: UserRecord,
|
||||
},
|
||||
Addition {
|
||||
user: UserRecord,
|
||||
authentication: PasskeyAuthenticationBinding,
|
||||
},
|
||||
}
|
||||
|
||||
/// Hash of the exact transport credential that authorized an addition. A second
|
||||
/// session for the same user cannot complete its ceremony; revocation is checked
|
||||
/// again in the credential insertion transaction.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "method", content = "token_hash", rename_all = "snake_case")]
|
||||
pub enum PasskeyAuthenticationBinding {
|
||||
BrowserSession(String),
|
||||
ApiToken(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct PasskeyCredentialRecord {
|
||||
pub credential_id: String,
|
||||
@@ -1552,6 +1576,13 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
|
||||
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
|
||||
fn any_user(&self) -> Result<Option<UserRecord>>;
|
||||
/// Atomically creates a new account/user/credential, or inserts a credential
|
||||
/// for a still-authenticated existing target. Never overwrites a credential.
|
||||
fn register_verified_passkey(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
) -> Result<()>;
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()>;
|
||||
fn get_passkey_credential(
|
||||
&self,
|
||||
@@ -6323,6 +6354,66 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn register_verified_passkey(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
credential: &PasskeyCredentialRecord,
|
||||
) -> Result<()> {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let user = match target {
|
||||
PasskeyRegistrationTarget::NewAccount { user } => {
|
||||
let occupied: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
|
||||
params![user.handle], |row| row.get(0),
|
||||
)?;
|
||||
if occupied {
|
||||
return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered"));
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO accounts (account_id, kind, handle, display_name, created_at, updated_at) VALUES (?1, 'user', ?2, ?3, ?4, ?5)",
|
||||
params![user.account_id, user.handle, user.display_name, user.created_at, user.updated_at],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO users (user_id, account_id, handle, display_name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
params![user.user_id, user.account_id, user.handle, user.display_name, user.created_at, user.updated_at],
|
||||
)?;
|
||||
user
|
||||
}
|
||||
PasskeyRegistrationTarget::Addition { user, authentication } => {
|
||||
let current = tx.query_row(
|
||||
user_select_sql("WHERE user_id = ?1 AND account_id = ?2").as_str(),
|
||||
params![user.user_id, user.account_id], read_user_record,
|
||||
).optional()?;
|
||||
if current.is_none() {
|
||||
return Err(crate::auth::auth_error("passkey_target_mismatch", "addition target no longer exists"));
|
||||
}
|
||||
let expires_at: Option<Option<String>> = match authentication {
|
||||
PasskeyAuthenticationBinding::BrowserSession(hash) => tx.query_row(
|
||||
"SELECT expires_at FROM browser_sessions WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL",
|
||||
params![hash, user.user_id], |row| row.get(0),
|
||||
).optional()?,
|
||||
PasskeyAuthenticationBinding::ApiToken(hash) => tx.query_row(
|
||||
"SELECT expires_at FROM api_tokens WHERE token_hash = ?1 AND user_id = ?2 AND revoked_at IS NULL",
|
||||
params![hash, user.user_id], |row| row.get(0),
|
||||
).optional()?,
|
||||
};
|
||||
if !expires_at.is_some_and(|expiry| expiry.as_deref().is_none_or(|value| !crate::auth::is_expired(value))) {
|
||||
return Err(crate::auth::auth_error("auth_required", "addition credential has expired or been revoked"));
|
||||
}
|
||||
user
|
||||
}
|
||||
};
|
||||
if user.user_id != credential.user_id {
|
||||
return Err(crate::auth::auth_error("passkey_target_mismatch", "credential does not match enrollment target"));
|
||||
}
|
||||
tx.execute(
|
||||
"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],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
conn.execute(
|
||||
@@ -6412,7 +6503,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
ceremony: &str,
|
||||
consumed_at: &str,
|
||||
) -> Result<Option<AuthChallengeRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
let record = conn
|
||||
.query_row(
|
||||
auth_challenge_select_sql(
|
||||
|
||||
Reference in New Issue
Block a user