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