feat: activate provisional users with scoped passkey enrollment

This commit is contained in:
2026-10-16 08:48:35 +09:00
parent af008f802d
commit d22899d39e
34 changed files with 4278 additions and 49 deletions
+138
View File
@@ -172,6 +172,9 @@ impl_openapi_schema!(
AuthPublicConfig,
HostingSettings,
HostingAccess,
ProvisionalAccountRequest,
ActivationCodeResponse,
ActivationOptionsRequest,
AuthUserResponse,
DeviceLoginApproveRequest,
DeviceLoginApproveResponse,
@@ -539,6 +542,38 @@ pub trait ServerApi {
#[body] request: HostingSettings,
) -> Result<HostingSettings, RepositoryApiError>;
#[post("/api/hosting/accounts", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)]
async fn hosting_account_create(
&self,
#[extension] context: ServerRequestContext,
#[body] request: ProvisionalAccountRequest,
) -> Result<ActivationCodeResponse, RepositoryApiError>;
#[post("/api/hosting/accounts/activation-code", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)]
async fn hosting_activation_code_reissue(
&self,
#[extension] context: ServerRequestContext,
#[body] request: ProvisionalAccountRequest,
) -> Result<ActivationCodeResponse, RepositoryApiError>;
#[post("/api/auth/activation/options", status = 200, error_status = 400, additional_error_statuses = [500])]
async fn auth_activation_options(
&self,
#[extension] context: ServerRequestContext,
#[body] request: ActivationOptionsRequest,
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
#[post(
"/api/auth/activation/complete",
responses = [(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])],
error_status = 400,
additional_error_statuses = [500]
)]
async fn auth_activation_complete(
&self,
#[body] request: PasskeyRegistrationCompleteRequest,
) -> Result<server_api_responses::AuthActivationComplete, RepositoryApiError>;
#[post(
"/api/auth/bootstrap-user",
status = 200,
@@ -3886,6 +3921,60 @@ pub struct HostingAccess {
pub is_admin: bool,
}
/// A hosting administrator reserves the same normalized ID used by passkey login.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ProvisionalAccountRequest {
pub handle: String,
}
/// The only response allowed to disclose an activation code. Clients must keep it
/// out of URLs, persistent browser storage and diagnostics. A lost response is
/// recovered by reissuing, which invalidates the previous code and its ceremonies.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ActivationCodeResponse {
pub user: AuthenticatedUser,
pub code: String,
pub expires_at: String,
}
impl std::fmt::Debug for ActivationCodeResponse {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ActivationCodeResponse")
.field("user", &self.user)
.field("code", &"[REDACTED]")
.field("expires_at", &self.expires_at)
.finish()
}
}
/// Proof for a pending account's initial passkey ceremony, never a bearer token
/// or browser session. No caller-selected account ID or enrollment purpose is accepted.
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct ActivationOptionsRequest {
pub handle: String,
pub code: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub browser_origin: Option<String>,
}
impl std::fmt::Debug for ActivationOptionsRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ActivationOptionsRequest")
.field("handle", &self.handle)
.field("code", &"[REDACTED]")
.field("browser_origin", &self.browser_origin)
.finish()
}
}
/// Authentication method that established the current request actor.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
@@ -11437,6 +11526,9 @@ pub fn auth_api_typescript() -> String {
AuthPublicConfig::decl(&config),
HostingSettings::decl(&config),
HostingAccess::decl(&config),
ProvisionalAccountRequest::decl(&config),
ActivationCodeResponse::decl(&config),
ActivationOptionsRequest::decl(&config),
ActorAuthMethod::decl(&config),
AuthenticatedUser::decl(&config),
RequestActor::decl(&config),
@@ -14423,6 +14515,8 @@ mod openapi_artifact_tests {
"health",
"auth_config",
"auth_registration_policy",
"auth_activation_options",
"auth_activation_complete",
"auth_bootstrap_user",
"auth_passkey_registration_options",
"auth_passkey_registration_complete",
@@ -14845,3 +14939,47 @@ mod workdir_error_diagnostic_tests {
assert!(schema["properties"].get("denial_reason").is_none());
}
}
#[cfg(test)]
mod activation_contract_tests {
use super::*;
#[test]
fn activation_inputs_reject_caller_selected_authority_and_debug_redacts_codes() {
for field in [
"account_id",
"user_id",
"purpose",
"is_admin",
"authentication",
] {
let mut body = serde_json::json!({"handle":"new-user","code":"activation-secret"});
body[field] = serde_json::json!("caller-controlled");
assert!(serde_json::from_value::<ActivationOptionsRequest>(body).is_err());
let mut body = serde_json::json!({"handle":"new-user"});
body[field] = serde_json::json!("caller-controlled");
assert!(serde_json::from_value::<ProvisionalAccountRequest>(body).is_err());
}
let request = ActivationOptionsRequest {
handle: "new-user".into(),
code: "activation-secret".into(),
browser_origin: None,
};
assert!(!format!("{request:?}").contains(&request.code));
let response = ActivationCodeResponse {
user: AuthenticatedUser {
user_id: "u".into(),
account_id: "a".into(),
handle: "new-user".into(),
display_name: "new-user".into(),
},
code: "activation-secret".into(),
expires_at: "2030-01-01T00:00:00Z".into(),
};
assert!(!format!("{response:?}").contains(&response.code));
assert_eq!(
serde_json::to_value(&response).unwrap()["code"],
"activation-secret"
);
}
}