this repo has no description
1use axum::{
2 Json,
3 extract::State,
4 http::{HeaderMap, StatusCode},
5 response::{IntoResponse, Response},
6};
7use bcrypt::{DEFAULT_COST, hash};
8use chrono::{Duration, Utc};
9use jacquard::types::{integer::LimitedU32, string::Tid};
10use jacquard_repo::{mst::Mst, storage::BlockStore};
11use rand::Rng;
12use serde::{Deserialize, Serialize};
13use serde_json::json;
14use std::sync::Arc;
15use tracing::{debug, error, info, warn};
16use uuid::Uuid;
17
18use crate::api::repo::record::utils::create_signed_commit;
19use crate::auth::{ServiceTokenVerifier, extract_bearer_token_from_header, is_service_token};
20use crate::state::{AppState, RateLimitKind};
21use crate::validation::validate_password;
22
23fn extract_client_ip(headers: &HeaderMap) -> String {
24 if let Some(forwarded) = headers.get("x-forwarded-for")
25 && let Ok(value) = forwarded.to_str()
26 && let Some(first_ip) = value.split(',').next()
27 {
28 return first_ip.trim().to_string();
29 }
30 if let Some(real_ip) = headers.get("x-real-ip")
31 && let Ok(value) = real_ip.to_str()
32 {
33 return value.trim().to_string();
34 }
35 "unknown".to_string()
36}
37
38fn generate_setup_token() -> String {
39 let mut rng = rand::thread_rng();
40 (0..32)
41 .map(|_| {
42 let idx = rng.gen_range(0..36);
43 if idx < 10 {
44 (b'0' + idx) as char
45 } else {
46 (b'a' + idx - 10) as char
47 }
48 })
49 .collect()
50}
51
52fn generate_app_password() -> String {
53 let chars: &[u8] = b"abcdefghijklmnopqrstuvwxyz234567";
54 let mut rng = rand::thread_rng();
55 let segments: Vec<String> = (0..4)
56 .map(|_| {
57 (0..4)
58 .map(|_| chars[rng.gen_range(0..chars.len())] as char)
59 .collect()
60 })
61 .collect();
62 segments.join("-")
63}
64
65#[derive(Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct CreatePasskeyAccountInput {
68 pub handle: String,
69 pub email: Option<String>,
70 pub invite_code: Option<String>,
71 pub did: Option<String>,
72 pub did_type: Option<String>,
73 pub signing_key: Option<String>,
74 pub verification_channel: Option<String>,
75 pub discord_id: Option<String>,
76 pub telegram_username: Option<String>,
77 pub signal_number: Option<String>,
78}
79
80#[derive(Serialize)]
81#[serde(rename_all = "camelCase")]
82pub struct CreatePasskeyAccountResponse {
83 pub did: String,
84 pub handle: String,
85 pub setup_token: String,
86 pub setup_expires_at: chrono::DateTime<Utc>,
87}
88
89pub async fn create_passkey_account(
90 State(state): State<AppState>,
91 headers: HeaderMap,
92 Json(input): Json<CreatePasskeyAccountInput>,
93) -> Response {
94 let client_ip = extract_client_ip(&headers);
95 if !state
96 .check_rate_limit(RateLimitKind::AccountCreation, &client_ip)
97 .await
98 {
99 warn!(ip = %client_ip, "Account creation rate limit exceeded");
100 return (
101 StatusCode::TOO_MANY_REQUESTS,
102 Json(json!({
103 "error": "RateLimitExceeded",
104 "message": "Too many account creation attempts. Please try again later."
105 })),
106 )
107 .into_response();
108 }
109
110 let byod_auth = if let Some(token) =
111 extract_bearer_token_from_header(headers.get("Authorization").and_then(|h| h.to_str().ok()))
112 {
113 if is_service_token(&token) {
114 let verifier = ServiceTokenVerifier::new();
115 match verifier
116 .verify_service_token(&token, Some("com.atproto.server.createAccount"))
117 .await
118 {
119 Ok(claims) => {
120 debug!(
121 "Service token verified for BYOD did:web: iss={}",
122 claims.iss
123 );
124 Some(claims.iss)
125 }
126 Err(e) => {
127 error!("Service token verification failed: {:?}", e);
128 return (
129 StatusCode::UNAUTHORIZED,
130 Json(json!({
131 "error": "AuthenticationFailed",
132 "message": format!("Service token verification failed: {}", e)
133 })),
134 )
135 .into_response();
136 }
137 }
138 } else {
139 None
140 }
141 } else {
142 None
143 };
144
145 let is_byod_did_web = byod_auth.is_some()
146 && input
147 .did
148 .as_ref()
149 .map(|d| d.starts_with("did:web:"))
150 .unwrap_or(false);
151
152 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
153 let pds_suffix = format!(".{}", hostname);
154
155 let handle = if !input.handle.contains('.') || input.handle.ends_with(&pds_suffix) {
156 let handle_to_validate = if input.handle.ends_with(&pds_suffix) {
157 input
158 .handle
159 .strip_suffix(&pds_suffix)
160 .unwrap_or(&input.handle)
161 } else {
162 &input.handle
163 };
164 match crate::api::validation::validate_short_handle(handle_to_validate) {
165 Ok(h) => format!("{}.{}", h, hostname),
166 Err(e) => {
167 return (
168 StatusCode::BAD_REQUEST,
169 Json(json!({"error": "InvalidHandle", "message": e.to_string()})),
170 )
171 .into_response();
172 }
173 }
174 } else {
175 input.handle.to_lowercase()
176 };
177
178 let email = input
179 .email
180 .as_ref()
181 .map(|e| e.trim().to_string())
182 .filter(|e| !e.is_empty());
183 if let Some(ref email) = email
184 && !crate::api::validation::is_valid_email(email)
185 {
186 return (
187 StatusCode::BAD_REQUEST,
188 Json(json!({"error": "InvalidEmail", "message": "Invalid email format"})),
189 )
190 .into_response();
191 }
192
193 if let Some(ref code) = input.invite_code {
194 let valid = sqlx::query_scalar!(
195 "SELECT available_uses > 0 AND NOT disabled FROM invite_codes WHERE code = $1",
196 code
197 )
198 .fetch_optional(&state.db)
199 .await
200 .ok()
201 .flatten()
202 .unwrap_or(Some(false));
203
204 if valid != Some(true) {
205 return (
206 StatusCode::BAD_REQUEST,
207 Json(json!({"error": "InvalidInviteCode", "message": "Invalid or expired invite code"})),
208 )
209 .into_response();
210 }
211 } else {
212 let invite_required = std::env::var("INVITE_CODE_REQUIRED")
213 .map(|v| v == "true" || v == "1")
214 .unwrap_or(false);
215 if invite_required {
216 return (
217 StatusCode::BAD_REQUEST,
218 Json(json!({"error": "InviteCodeRequired", "message": "An invite code is required to create an account"})),
219 )
220 .into_response();
221 }
222 }
223
224 let verification_channel = input.verification_channel.as_deref().unwrap_or("email");
225 let verification_recipient = match verification_channel {
226 "email" => match &email {
227 Some(e) if !e.is_empty() => e.clone(),
228 _ => return (
229 StatusCode::BAD_REQUEST,
230 Json(json!({"error": "MissingEmail", "message": "Email is required when using email verification"})),
231 ).into_response(),
232 },
233 "discord" => match &input.discord_id {
234 Some(id) if !id.trim().is_empty() => id.trim().to_string(),
235 _ => return (
236 StatusCode::BAD_REQUEST,
237 Json(json!({"error": "MissingDiscordId", "message": "Discord ID is required when using Discord verification"})),
238 ).into_response(),
239 },
240 "telegram" => match &input.telegram_username {
241 Some(username) if !username.trim().is_empty() => username.trim().to_string(),
242 _ => return (
243 StatusCode::BAD_REQUEST,
244 Json(json!({"error": "MissingTelegramUsername", "message": "Telegram username is required when using Telegram verification"})),
245 ).into_response(),
246 },
247 "signal" => match &input.signal_number {
248 Some(number) if !number.trim().is_empty() => number.trim().to_string(),
249 _ => return (
250 StatusCode::BAD_REQUEST,
251 Json(json!({"error": "MissingSignalNumber", "message": "Signal phone number is required when using Signal verification"})),
252 ).into_response(),
253 },
254 _ => return (
255 StatusCode::BAD_REQUEST,
256 Json(json!({"error": "InvalidVerificationChannel", "message": "Invalid verification channel"})),
257 ).into_response(),
258 };
259
260 use k256::ecdsa::SigningKey;
261 use rand::rngs::OsRng;
262
263 let pds_endpoint = format!("https://{}", hostname);
264 let did_type = input.did_type.as_deref().unwrap_or("plc");
265
266 let (secret_key_bytes, reserved_key_id): (Vec<u8>, Option<Uuid>) =
267 if let Some(signing_key_did) = &input.signing_key {
268 let reserved = sqlx::query!(
269 r#"
270 SELECT id, private_key_bytes
271 FROM reserved_signing_keys
272 WHERE public_key_did_key = $1
273 AND used_at IS NULL
274 AND expires_at > NOW()
275 FOR UPDATE
276 "#,
277 signing_key_did
278 )
279 .fetch_optional(&state.db)
280 .await;
281 match reserved {
282 Ok(Some(row)) => (row.private_key_bytes, Some(row.id)),
283 Ok(None) => {
284 return (
285 StatusCode::BAD_REQUEST,
286 Json(json!({
287 "error": "InvalidSigningKey",
288 "message": "Signing key not found, already used, or expired"
289 })),
290 )
291 .into_response();
292 }
293 Err(e) => {
294 error!("Error looking up reserved signing key: {:?}", e);
295 return (
296 StatusCode::INTERNAL_SERVER_ERROR,
297 Json(json!({"error": "InternalError"})),
298 )
299 .into_response();
300 }
301 }
302 } else {
303 let secret_key = k256::SecretKey::random(&mut OsRng);
304 (secret_key.to_bytes().to_vec(), None)
305 };
306
307 let secret_key = match SigningKey::from_slice(&secret_key_bytes) {
308 Ok(k) => k,
309 Err(e) => {
310 error!("Error creating signing key: {:?}", e);
311 return (
312 StatusCode::INTERNAL_SERVER_ERROR,
313 Json(json!({"error": "InternalError"})),
314 )
315 .into_response();
316 }
317 };
318
319 let did = match did_type {
320 "web" => {
321 let subdomain_host = format!("{}.{}", input.handle, hostname);
322 let encoded_subdomain = subdomain_host.replace(':', "%3A");
323 let self_hosted_did = format!("did:web:{}", encoded_subdomain);
324 info!(did = %self_hosted_did, "Creating self-hosted did:web passkey account");
325 self_hosted_did
326 }
327 "web-external" => {
328 let d = match &input.did {
329 Some(d) if !d.trim().is_empty() => d.trim(),
330 _ => {
331 return (
332 StatusCode::BAD_REQUEST,
333 Json(json!({"error": "InvalidRequest", "message": "External did:web requires the 'did' field to be provided"})),
334 )
335 .into_response();
336 }
337 };
338 if !d.starts_with("did:web:") {
339 return (
340 StatusCode::BAD_REQUEST,
341 Json(
342 json!({"error": "InvalidDid", "message": "External DID must be a did:web"}),
343 ),
344 )
345 .into_response();
346 }
347 if is_byod_did_web {
348 if let Some(ref auth_did) = byod_auth
349 && d != auth_did
350 {
351 return (
352 StatusCode::FORBIDDEN,
353 Json(json!({
354 "error": "AuthorizationError",
355 "message": format!("Service token issuer {} does not match DID {}", auth_did, d)
356 })),
357 )
358 .into_response();
359 }
360 info!(did = %d, "Creating external did:web passkey account (BYOD key)");
361 } else {
362 if let Err(e) = crate::api::identity::did::verify_did_web(
363 d,
364 &hostname,
365 &input.handle,
366 input.signing_key.as_deref(),
367 )
368 .await
369 {
370 return (
371 StatusCode::BAD_REQUEST,
372 Json(json!({"error": "InvalidDid", "message": e})),
373 )
374 .into_response();
375 }
376 info!(did = %d, "Creating external did:web passkey account (reserved key)");
377 }
378 d.to_string()
379 }
380 _ => {
381 let rotation_key = std::env::var("PLC_ROTATION_KEY")
382 .unwrap_or_else(|_| crate::plc::signing_key_to_did_key(&secret_key));
383
384 let genesis_result = match crate::plc::create_genesis_operation(
385 &secret_key,
386 &rotation_key,
387 &handle,
388 &pds_endpoint,
389 ) {
390 Ok(r) => r,
391 Err(e) => {
392 error!("Error creating PLC genesis operation: {:?}", e);
393 return (
394 StatusCode::INTERNAL_SERVER_ERROR,
395 Json(json!({"error": "InternalError", "message": "Failed to create PLC operation"})),
396 )
397 .into_response();
398 }
399 };
400
401 let plc_client = crate::plc::PlcClient::new(None);
402 if let Err(e) = plc_client
403 .send_operation(&genesis_result.did, &genesis_result.signed_operation)
404 .await
405 {
406 error!("Failed to submit PLC genesis operation: {:?}", e);
407 return (
408 StatusCode::BAD_GATEWAY,
409 Json(json!({
410 "error": "UpstreamError",
411 "message": format!("Failed to register DID with PLC directory: {}", e)
412 })),
413 )
414 .into_response();
415 }
416 genesis_result.did
417 }
418 };
419
420 info!(did = %did, handle = %handle, "Created DID for passkey-only account");
421
422 let setup_token = generate_setup_token();
423 let setup_token_hash = match hash(&setup_token, DEFAULT_COST) {
424 Ok(h) => h,
425 Err(e) => {
426 error!("Error hashing setup token: {:?}", e);
427 return (
428 StatusCode::INTERNAL_SERVER_ERROR,
429 Json(json!({"error": "InternalError"})),
430 )
431 .into_response();
432 }
433 };
434 let setup_expires_at = Utc::now() + Duration::hours(1);
435
436 let mut tx = match state.db.begin().await {
437 Ok(tx) => tx,
438 Err(e) => {
439 error!("Error starting transaction: {:?}", e);
440 return (
441 StatusCode::INTERNAL_SERVER_ERROR,
442 Json(json!({"error": "InternalError"})),
443 )
444 .into_response();
445 }
446 };
447
448 let is_first_user = sqlx::query_scalar!("SELECT COUNT(*) as count FROM users")
449 .fetch_one(&mut *tx)
450 .await
451 .map(|c| c.unwrap_or(0) == 0)
452 .unwrap_or(false);
453
454 let deactivated_at: Option<chrono::DateTime<Utc>> = if is_byod_did_web {
455 Some(Utc::now())
456 } else {
457 None
458 };
459
460 let user_insert: Result<(Uuid,), _> = sqlx::query_as(
461 r#"INSERT INTO users (
462 handle, email, did, password_hash, password_required,
463 preferred_comms_channel,
464 discord_id, telegram_username, signal_number,
465 recovery_token, recovery_token_expires_at,
466 is_admin, deactivated_at
467 ) VALUES ($1, $2, $3, NULL, FALSE, $4::comms_channel, $5, $6, $7, $8, $9, $10, $11) RETURNING id"#,
468 )
469 .bind(&handle)
470 .bind(&email)
471 .bind(&did)
472 .bind(verification_channel)
473 .bind(
474 input
475 .discord_id
476 .as_deref()
477 .map(|s| s.trim())
478 .filter(|s| !s.is_empty()),
479 )
480 .bind(
481 input
482 .telegram_username
483 .as_deref()
484 .map(|s| s.trim())
485 .filter(|s| !s.is_empty()),
486 )
487 .bind(
488 input
489 .signal_number
490 .as_deref()
491 .map(|s| s.trim())
492 .filter(|s| !s.is_empty()),
493 )
494 .bind(&setup_token_hash)
495 .bind(setup_expires_at)
496 .bind(is_first_user)
497 .bind(deactivated_at)
498 .fetch_one(&mut *tx)
499 .await;
500
501 let user_id = match user_insert {
502 Ok((id,)) => id,
503 Err(e) => {
504 if let Some(db_err) = e.as_database_error()
505 && db_err.code().as_deref() == Some("23505")
506 {
507 let constraint = db_err.constraint().unwrap_or("");
508 if constraint.contains("handle") {
509 return (
510 StatusCode::BAD_REQUEST,
511 Json(json!({"error": "HandleNotAvailable", "message": "Handle already taken"})),
512 )
513 .into_response();
514 } else if constraint.contains("email") {
515 return (
516 StatusCode::BAD_REQUEST,
517 Json(
518 json!({"error": "InvalidEmail", "message": "Email already registered"}),
519 ),
520 )
521 .into_response();
522 }
523 }
524 error!("Error inserting user: {:?}", e);
525 return (
526 StatusCode::INTERNAL_SERVER_ERROR,
527 Json(json!({"error": "InternalError"})),
528 )
529 .into_response();
530 }
531 };
532
533 let encrypted_key_bytes = match crate::config::encrypt_key(&secret_key_bytes) {
534 Ok(bytes) => bytes,
535 Err(e) => {
536 error!("Error encrypting signing key: {:?}", e);
537 return (
538 StatusCode::INTERNAL_SERVER_ERROR,
539 Json(json!({"error": "InternalError"})),
540 )
541 .into_response();
542 }
543 };
544
545 if let Err(e) = sqlx::query!(
546 "INSERT INTO user_keys (user_id, key_bytes, encryption_version, encrypted_at) VALUES ($1, $2, $3, NOW())",
547 user_id,
548 &encrypted_key_bytes[..],
549 crate::config::ENCRYPTION_VERSION
550 )
551 .execute(&mut *tx)
552 .await
553 {
554 error!("Error inserting user key: {:?}", e);
555 return (
556 StatusCode::INTERNAL_SERVER_ERROR,
557 Json(json!({"error": "InternalError"})),
558 )
559 .into_response();
560 }
561
562 if let Some(key_id) = reserved_key_id
563 && let Err(e) = sqlx::query!(
564 "UPDATE reserved_signing_keys SET used_at = NOW() WHERE id = $1",
565 key_id
566 )
567 .execute(&mut *tx)
568 .await
569 {
570 error!("Error marking reserved key as used: {:?}", e);
571 return (
572 StatusCode::INTERNAL_SERVER_ERROR,
573 Json(json!({"error": "InternalError"})),
574 )
575 .into_response();
576 }
577
578 let mst = Mst::new(Arc::new(state.block_store.clone()));
579 let mst_root = match mst.persist().await {
580 Ok(c) => c,
581 Err(e) => {
582 error!("Error persisting MST: {:?}", e);
583 return (
584 StatusCode::INTERNAL_SERVER_ERROR,
585 Json(json!({"error": "InternalError"})),
586 )
587 .into_response();
588 }
589 };
590 let rev = Tid::now(LimitedU32::MIN);
591 let (commit_bytes, _sig) =
592 match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) {
593 Ok(result) => result,
594 Err(e) => {
595 error!("Error creating genesis commit: {:?}", e);
596 return (
597 StatusCode::INTERNAL_SERVER_ERROR,
598 Json(json!({"error": "InternalError"})),
599 )
600 .into_response();
601 }
602 };
603 let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
604 Ok(c) => c,
605 Err(e) => {
606 error!("Error saving genesis commit: {:?}", e);
607 return (
608 StatusCode::INTERNAL_SERVER_ERROR,
609 Json(json!({"error": "InternalError"})),
610 )
611 .into_response();
612 }
613 };
614 let commit_cid_str = commit_cid.to_string();
615 if let Err(e) = sqlx::query!(
616 "INSERT INTO repos (user_id, repo_root_cid) VALUES ($1, $2)",
617 user_id,
618 commit_cid_str
619 )
620 .execute(&mut *tx)
621 .await
622 {
623 error!("Error inserting repo: {:?}", e);
624 return (
625 StatusCode::INTERNAL_SERVER_ERROR,
626 Json(json!({"error": "InternalError"})),
627 )
628 .into_response();
629 }
630
631 if let Some(ref code) = input.invite_code {
632 let _ = sqlx::query!(
633 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
634 code
635 )
636 .execute(&mut *tx)
637 .await;
638
639 let _ = sqlx::query!(
640 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
641 code,
642 user_id
643 )
644 .execute(&mut *tx)
645 .await;
646 }
647
648 if let Err(e) = tx.commit().await {
649 error!("Error committing transaction: {:?}", e);
650 return (
651 StatusCode::INTERNAL_SERVER_ERROR,
652 Json(json!({"error": "InternalError"})),
653 )
654 .into_response();
655 }
656
657 if !is_byod_did_web {
658 if let Err(e) =
659 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
660 {
661 warn!("Failed to sequence identity event for {}: {}", did, e);
662 }
663 if let Err(e) =
664 crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
665 {
666 warn!("Failed to sequence account event for {}: {}", did, e);
667 }
668 let profile_record = serde_json::json!({
669 "$type": "app.bsky.actor.profile",
670 "displayName": handle
671 });
672 if let Err(e) = crate::api::repo::record::create_record_internal(
673 &state,
674 &did,
675 "app.bsky.actor.profile",
676 "self",
677 &profile_record,
678 )
679 .await
680 {
681 warn!("Failed to create default profile for {}: {}", did, e);
682 }
683 }
684
685 let verification_token = crate::auth::verification_token::generate_signup_token(
686 &did,
687 verification_channel,
688 &verification_recipient,
689 );
690 let formatted_token =
691 crate::auth::verification_token::format_token_for_display(&verification_token);
692 if let Err(e) = crate::comms::enqueue_signup_verification(
693 &state.db,
694 user_id,
695 verification_channel,
696 &verification_recipient,
697 &formatted_token,
698 None,
699 )
700 .await
701 {
702 warn!("Failed to enqueue signup verification: {:?}", e);
703 }
704
705 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
706
707 Json(CreatePasskeyAccountResponse {
708 did,
709 handle,
710 setup_token,
711 setup_expires_at,
712 })
713 .into_response()
714}
715
716#[derive(Deserialize)]
717#[serde(rename_all = "camelCase")]
718pub struct CompletePasskeySetupInput {
719 pub did: String,
720 pub setup_token: String,
721 pub passkey_credential: serde_json::Value,
722 pub passkey_friendly_name: Option<String>,
723}
724
725#[derive(Serialize)]
726#[serde(rename_all = "camelCase")]
727pub struct CompletePasskeySetupResponse {
728 pub did: String,
729 pub handle: String,
730 pub app_password: String,
731 pub app_password_name: String,
732}
733
734pub async fn complete_passkey_setup(
735 State(state): State<AppState>,
736 Json(input): Json<CompletePasskeySetupInput>,
737) -> Response {
738 let user = sqlx::query!(
739 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
740 FROM users WHERE did = $1"#,
741 input.did
742 )
743 .fetch_optional(&state.db)
744 .await;
745
746 let user = match user {
747 Ok(Some(u)) => u,
748 Ok(None) => {
749 return (
750 StatusCode::NOT_FOUND,
751 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
752 )
753 .into_response();
754 }
755 Err(e) => {
756 error!("DB error: {:?}", e);
757 return (
758 StatusCode::INTERNAL_SERVER_ERROR,
759 Json(json!({"error": "InternalError"})),
760 )
761 .into_response();
762 }
763 };
764
765 if user.password_required {
766 return (
767 StatusCode::BAD_REQUEST,
768 Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})),
769 )
770 .into_response();
771 }
772
773 let token_hash = match &user.recovery_token {
774 Some(h) => h,
775 None => {
776 return (
777 StatusCode::BAD_REQUEST,
778 Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})),
779 )
780 .into_response();
781 }
782 };
783
784 if let Some(expires_at) = user.recovery_token_expires_at
785 && expires_at < Utc::now()
786 {
787 return (
788 StatusCode::BAD_REQUEST,
789 Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})),
790 )
791 .into_response();
792 }
793
794 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
795 return (
796 StatusCode::UNAUTHORIZED,
797 Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})),
798 )
799 .into_response();
800 }
801
802 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
803 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
804 Ok(w) => w,
805 Err(e) => {
806 error!("Failed to create WebAuthn config: {:?}", e);
807 return (
808 StatusCode::INTERNAL_SERVER_ERROR,
809 Json(json!({"error": "InternalError"})),
810 )
811 .into_response();
812 }
813 };
814
815 let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did)
816 .await
817 {
818 Ok(Some(s)) => s,
819 Ok(None) => {
820 return (
821 StatusCode::BAD_REQUEST,
822 Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})),
823 )
824 .into_response();
825 }
826 Err(e) => {
827 error!("Error loading registration state: {:?}", e);
828 return (
829 StatusCode::INTERNAL_SERVER_ERROR,
830 Json(json!({"error": "InternalError"})),
831 )
832 .into_response();
833 }
834 };
835
836 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
837 match serde_json::from_value(input.passkey_credential) {
838 Ok(c) => c,
839 Err(e) => {
840 warn!("Failed to parse credential: {:?}", e);
841 return (
842 StatusCode::BAD_REQUEST,
843 Json(
844 json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
845 ),
846 )
847 .into_response();
848 }
849 };
850
851 let security_key = match webauthn.finish_registration(&credential, ®_state) {
852 Ok(sk) => sk,
853 Err(e) => {
854 warn!("Passkey registration failed: {:?}", e);
855 return (
856 StatusCode::BAD_REQUEST,
857 Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})),
858 )
859 .into_response();
860 }
861 };
862
863 if let Err(e) = crate::auth::webauthn::save_passkey(
864 &state.db,
865 &input.did,
866 &security_key,
867 input.passkey_friendly_name.as_deref(),
868 )
869 .await
870 {
871 error!("Error saving passkey: {:?}", e);
872 return (
873 StatusCode::INTERNAL_SERVER_ERROR,
874 Json(json!({"error": "InternalError"})),
875 )
876 .into_response();
877 }
878
879 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
880
881 let app_password = generate_app_password();
882 let app_password_name = "bsky.app".to_string();
883 let password_hash = match hash(&app_password, DEFAULT_COST) {
884 Ok(h) => h,
885 Err(e) => {
886 error!("Error hashing app password: {:?}", e);
887 return (
888 StatusCode::INTERNAL_SERVER_ERROR,
889 Json(json!({"error": "InternalError"})),
890 )
891 .into_response();
892 }
893 };
894
895 if let Err(e) = sqlx::query!(
896 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
897 user.id,
898 app_password_name,
899 password_hash
900 )
901 .execute(&state.db)
902 .await
903 {
904 error!("Error creating app password: {:?}", e);
905 return (
906 StatusCode::INTERNAL_SERVER_ERROR,
907 Json(json!({"error": "InternalError"})),
908 )
909 .into_response();
910 }
911
912 if let Err(e) = sqlx::query!(
913 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
914 input.did
915 )
916 .execute(&state.db)
917 .await
918 {
919 error!("Error clearing setup token: {:?}", e);
920 }
921
922 info!(did = %input.did, "Passkey-only account setup completed");
923
924 Json(CompletePasskeySetupResponse {
925 did: input.did,
926 handle: user.handle,
927 app_password,
928 app_password_name,
929 })
930 .into_response()
931}
932
933pub async fn start_passkey_registration_for_setup(
934 State(state): State<AppState>,
935 Json(input): Json<StartPasskeyRegistrationInput>,
936) -> Response {
937 let user = sqlx::query!(
938 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
939 FROM users WHERE did = $1"#,
940 input.did
941 )
942 .fetch_optional(&state.db)
943 .await;
944
945 let user = match user {
946 Ok(Some(u)) => u,
947 Ok(None) => {
948 return (
949 StatusCode::NOT_FOUND,
950 Json(json!({"error": "AccountNotFound"})),
951 )
952 .into_response();
953 }
954 Err(e) => {
955 error!("DB error: {:?}", e);
956 return (
957 StatusCode::INTERNAL_SERVER_ERROR,
958 Json(json!({"error": "InternalError"})),
959 )
960 .into_response();
961 }
962 };
963
964 if user.password_required {
965 return (
966 StatusCode::BAD_REQUEST,
967 Json(json!({"error": "InvalidAccount"})),
968 )
969 .into_response();
970 }
971
972 let token_hash = match &user.recovery_token {
973 Some(h) => h,
974 None => {
975 return (
976 StatusCode::BAD_REQUEST,
977 Json(json!({"error": "SetupExpired"})),
978 )
979 .into_response();
980 }
981 };
982
983 if let Some(expires_at) = user.recovery_token_expires_at
984 && expires_at < Utc::now()
985 {
986 return (
987 StatusCode::BAD_REQUEST,
988 Json(json!({"error": "SetupExpired"})),
989 )
990 .into_response();
991 }
992
993 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
994 return (
995 StatusCode::UNAUTHORIZED,
996 Json(json!({"error": "InvalidToken"})),
997 )
998 .into_response();
999 }
1000
1001 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1002 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
1003 Ok(w) => w,
1004 Err(e) => {
1005 error!("Failed to create WebAuthn config: {:?}", e);
1006 return (
1007 StatusCode::INTERNAL_SERVER_ERROR,
1008 Json(json!({"error": "InternalError"})),
1009 )
1010 .into_response();
1011 }
1012 };
1013
1014 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did)
1015 .await
1016 .unwrap_or_default();
1017
1018 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys
1019 .iter()
1020 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone()))
1021 .collect();
1022
1023 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle);
1024
1025 let (ccr, reg_state) = match webauthn.start_registration(
1026 &input.did,
1027 &user.handle,
1028 display_name,
1029 exclude_credentials,
1030 ) {
1031 Ok(result) => result,
1032 Err(e) => {
1033 error!("Failed to start passkey registration: {:?}", e);
1034 return (
1035 StatusCode::INTERNAL_SERVER_ERROR,
1036 Json(json!({"error": "InternalError"})),
1037 )
1038 .into_response();
1039 }
1040 };
1041
1042 if let Err(e) =
1043 crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await
1044 {
1045 error!("Failed to save registration state: {:?}", e);
1046 return (
1047 StatusCode::INTERNAL_SERVER_ERROR,
1048 Json(json!({"error": "InternalError"})),
1049 )
1050 .into_response();
1051 }
1052
1053 let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
1054 Json(json!({"options": options})).into_response()
1055}
1056
1057#[derive(Deserialize)]
1058#[serde(rename_all = "camelCase")]
1059pub struct StartPasskeyRegistrationInput {
1060 pub did: String,
1061 pub setup_token: String,
1062 pub friendly_name: Option<String>,
1063}
1064
1065#[derive(Deserialize)]
1066#[serde(rename_all = "camelCase")]
1067pub struct RequestPasskeyRecoveryInput {
1068 #[serde(alias = "identifier")]
1069 pub email: String,
1070}
1071
1072pub async fn request_passkey_recovery(
1073 State(state): State<AppState>,
1074 headers: HeaderMap,
1075 Json(input): Json<RequestPasskeyRecoveryInput>,
1076) -> Response {
1077 let client_ip = extract_client_ip(&headers);
1078 if !state
1079 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
1080 .await
1081 {
1082 return (
1083 StatusCode::TOO_MANY_REQUESTS,
1084 Json(json!({"error": "RateLimitExceeded"})),
1085 )
1086 .into_response();
1087 }
1088
1089 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1090 let identifier = input.email.trim().to_lowercase();
1091 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
1092 let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
1093 identifier.to_string()
1094 } else {
1095 format!("{}.{}", identifier, pds_hostname)
1096 };
1097
1098 let user = sqlx::query!(
1099 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
1100 identifier,
1101 normalized_handle
1102 )
1103 .fetch_optional(&state.db)
1104 .await;
1105
1106 let user = match user {
1107 Ok(Some(u)) if !u.password_required => u,
1108 _ => {
1109 return Json(json!({"success": true})).into_response();
1110 }
1111 };
1112
1113 let recovery_token = generate_setup_token();
1114 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
1115 Ok(h) => h,
1116 Err(_) => {
1117 return (
1118 StatusCode::INTERNAL_SERVER_ERROR,
1119 Json(json!({"error": "InternalError"})),
1120 )
1121 .into_response();
1122 }
1123 };
1124 let expires_at = Utc::now() + Duration::hours(1);
1125
1126 if let Err(e) = sqlx::query!(
1127 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
1128 recovery_token_hash,
1129 expires_at,
1130 user.did
1131 )
1132 .execute(&state.db)
1133 .await
1134 {
1135 error!("Error updating recovery token: {:?}", e);
1136 return (
1137 StatusCode::INTERNAL_SERVER_ERROR,
1138 Json(json!({"error": "InternalError"})),
1139 )
1140 .into_response();
1141 }
1142
1143 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1144 let recovery_url = format!(
1145 "https://{}/#/recover-passkey?did={}&token={}",
1146 hostname,
1147 urlencoding::encode(&user.did),
1148 urlencoding::encode(&recovery_token)
1149 );
1150
1151 let _ =
1152 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
1153
1154 info!(did = %user.did, "Passkey recovery requested");
1155 Json(json!({"success": true})).into_response()
1156}
1157
1158#[derive(Deserialize)]
1159#[serde(rename_all = "camelCase")]
1160pub struct RecoverPasskeyAccountInput {
1161 pub did: String,
1162 pub recovery_token: String,
1163 pub new_password: String,
1164}
1165
1166pub async fn recover_passkey_account(
1167 State(state): State<AppState>,
1168 Json(input): Json<RecoverPasskeyAccountInput>,
1169) -> Response {
1170 if let Err(e) = validate_password(&input.new_password) {
1171 return (
1172 StatusCode::BAD_REQUEST,
1173 Json(json!({
1174 "error": "InvalidPassword",
1175 "message": e.to_string()
1176 })),
1177 )
1178 .into_response();
1179 }
1180
1181 let user = sqlx::query!(
1182 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
1183 input.did
1184 )
1185 .fetch_optional(&state.db)
1186 .await;
1187
1188 let user = match user {
1189 Ok(Some(u)) => u,
1190 _ => {
1191 return (
1192 StatusCode::NOT_FOUND,
1193 Json(json!({"error": "InvalidRecoveryLink"})),
1194 )
1195 .into_response();
1196 }
1197 };
1198
1199 let token_hash = match &user.recovery_token {
1200 Some(h) => h,
1201 None => {
1202 return (
1203 StatusCode::BAD_REQUEST,
1204 Json(json!({"error": "InvalidRecoveryLink"})),
1205 )
1206 .into_response();
1207 }
1208 };
1209
1210 if let Some(expires_at) = user.recovery_token_expires_at
1211 && expires_at < Utc::now()
1212 {
1213 return (
1214 StatusCode::BAD_REQUEST,
1215 Json(json!({"error": "RecoveryLinkExpired"})),
1216 )
1217 .into_response();
1218 }
1219
1220 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
1221 return (
1222 StatusCode::UNAUTHORIZED,
1223 Json(json!({"error": "InvalidRecoveryLink"})),
1224 )
1225 .into_response();
1226 }
1227
1228 let password_hash = match hash(&input.new_password, DEFAULT_COST) {
1229 Ok(h) => h,
1230 Err(_) => {
1231 return (
1232 StatusCode::INTERNAL_SERVER_ERROR,
1233 Json(json!({"error": "InternalError"})),
1234 )
1235 .into_response();
1236 }
1237 };
1238
1239 if let Err(e) = sqlx::query!(
1240 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
1241 password_hash,
1242 input.did
1243 )
1244 .execute(&state.db)
1245 .await
1246 {
1247 error!("Error updating password: {:?}", e);
1248 return (
1249 StatusCode::INTERNAL_SERVER_ERROR,
1250 Json(json!({"error": "InternalError"})),
1251 )
1252 .into_response();
1253 }
1254
1255 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did)
1256 .execute(&state.db)
1257 .await;
1258 match deleted {
1259 Ok(result) => {
1260 if result.rows_affected() > 0 {
1261 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery");
1262 }
1263 }
1264 Err(e) => {
1265 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
1266 }
1267 }
1268
1269 info!(did = %input.did, "Passkey-only account recovered with temporary password");
1270 Json(json!({"success": true})).into_response()
1271}