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 let rev_str = rev.as_ref().to_string();
616 if let Err(e) = sqlx::query!(
617 "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
618 user_id,
619 commit_cid_str,
620 rev_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 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
633 if let Err(e) = sqlx::query!(
634 r#"
635 INSERT INTO user_blocks (user_id, block_cid)
636 SELECT $1, block_cid FROM UNNEST($2::bytea[]) AS t(block_cid)
637 ON CONFLICT (user_id, block_cid) DO NOTHING
638 "#,
639 user_id,
640 &genesis_block_cids
641 )
642 .execute(&mut *tx)
643 .await
644 {
645 error!("Error inserting user_blocks: {:?}", e);
646 return (
647 StatusCode::INTERNAL_SERVER_ERROR,
648 Json(json!({"error": "InternalError"})),
649 )
650 .into_response();
651 }
652
653 if let Some(ref code) = input.invite_code {
654 let _ = sqlx::query!(
655 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
656 code
657 )
658 .execute(&mut *tx)
659 .await;
660
661 let _ = sqlx::query!(
662 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
663 code,
664 user_id
665 )
666 .execute(&mut *tx)
667 .await;
668 }
669
670 if let Err(e) = tx.commit().await {
671 error!("Error committing transaction: {:?}", e);
672 return (
673 StatusCode::INTERNAL_SERVER_ERROR,
674 Json(json!({"error": "InternalError"})),
675 )
676 .into_response();
677 }
678
679 if !is_byod_did_web {
680 if let Err(e) =
681 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
682 {
683 warn!("Failed to sequence identity event for {}: {}", did, e);
684 }
685 if let Err(e) =
686 crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
687 {
688 warn!("Failed to sequence account event for {}: {}", did, e);
689 }
690 let profile_record = serde_json::json!({
691 "$type": "app.bsky.actor.profile",
692 "displayName": handle
693 });
694 if let Err(e) = crate::api::repo::record::create_record_internal(
695 &state,
696 &did,
697 "app.bsky.actor.profile",
698 "self",
699 &profile_record,
700 )
701 .await
702 {
703 warn!("Failed to create default profile for {}: {}", did, e);
704 }
705 }
706
707 let verification_token = crate::auth::verification_token::generate_signup_token(
708 &did,
709 verification_channel,
710 &verification_recipient,
711 );
712 let formatted_token =
713 crate::auth::verification_token::format_token_for_display(&verification_token);
714 if let Err(e) = crate::comms::enqueue_signup_verification(
715 &state.db,
716 user_id,
717 verification_channel,
718 &verification_recipient,
719 &formatted_token,
720 None,
721 )
722 .await
723 {
724 warn!("Failed to enqueue signup verification: {:?}", e);
725 }
726
727 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
728
729 Json(CreatePasskeyAccountResponse {
730 did,
731 handle,
732 setup_token,
733 setup_expires_at,
734 })
735 .into_response()
736}
737
738#[derive(Deserialize)]
739#[serde(rename_all = "camelCase")]
740pub struct CompletePasskeySetupInput {
741 pub did: String,
742 pub setup_token: String,
743 pub passkey_credential: serde_json::Value,
744 pub passkey_friendly_name: Option<String>,
745}
746
747#[derive(Serialize)]
748#[serde(rename_all = "camelCase")]
749pub struct CompletePasskeySetupResponse {
750 pub did: String,
751 pub handle: String,
752 pub app_password: String,
753 pub app_password_name: String,
754}
755
756pub async fn complete_passkey_setup(
757 State(state): State<AppState>,
758 Json(input): Json<CompletePasskeySetupInput>,
759) -> Response {
760 let user = sqlx::query!(
761 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
762 FROM users WHERE did = $1"#,
763 input.did
764 )
765 .fetch_optional(&state.db)
766 .await;
767
768 let user = match user {
769 Ok(Some(u)) => u,
770 Ok(None) => {
771 return (
772 StatusCode::NOT_FOUND,
773 Json(json!({"error": "AccountNotFound", "message": "Account not found"})),
774 )
775 .into_response();
776 }
777 Err(e) => {
778 error!("DB error: {:?}", e);
779 return (
780 StatusCode::INTERNAL_SERVER_ERROR,
781 Json(json!({"error": "InternalError"})),
782 )
783 .into_response();
784 }
785 };
786
787 if user.password_required {
788 return (
789 StatusCode::BAD_REQUEST,
790 Json(json!({"error": "InvalidAccount", "message": "This account is not a passkey-only account"})),
791 )
792 .into_response();
793 }
794
795 let token_hash = match &user.recovery_token {
796 Some(h) => h,
797 None => {
798 return (
799 StatusCode::BAD_REQUEST,
800 Json(json!({"error": "SetupExpired", "message": "Setup has already been completed or expired"})),
801 )
802 .into_response();
803 }
804 };
805
806 if let Some(expires_at) = user.recovery_token_expires_at
807 && expires_at < Utc::now()
808 {
809 return (
810 StatusCode::BAD_REQUEST,
811 Json(json!({"error": "SetupExpired", "message": "Setup token has expired"})),
812 )
813 .into_response();
814 }
815
816 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
817 return (
818 StatusCode::UNAUTHORIZED,
819 Json(json!({"error": "InvalidToken", "message": "Invalid setup token"})),
820 )
821 .into_response();
822 }
823
824 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
825 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
826 Ok(w) => w,
827 Err(e) => {
828 error!("Failed to create WebAuthn config: {:?}", e);
829 return (
830 StatusCode::INTERNAL_SERVER_ERROR,
831 Json(json!({"error": "InternalError"})),
832 )
833 .into_response();
834 }
835 };
836
837 let reg_state = match crate::auth::webauthn::load_registration_state(&state.db, &input.did)
838 .await
839 {
840 Ok(Some(s)) => s,
841 Ok(None) => {
842 return (
843 StatusCode::BAD_REQUEST,
844 Json(json!({"error": "NoChallengeInProgress", "message": "Please start passkey registration first"})),
845 )
846 .into_response();
847 }
848 Err(e) => {
849 error!("Error loading registration state: {:?}", e);
850 return (
851 StatusCode::INTERNAL_SERVER_ERROR,
852 Json(json!({"error": "InternalError"})),
853 )
854 .into_response();
855 }
856 };
857
858 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
859 match serde_json::from_value(input.passkey_credential) {
860 Ok(c) => c,
861 Err(e) => {
862 warn!("Failed to parse credential: {:?}", e);
863 return (
864 StatusCode::BAD_REQUEST,
865 Json(
866 json!({"error": "InvalidCredential", "message": "Failed to parse credential"}),
867 ),
868 )
869 .into_response();
870 }
871 };
872
873 let security_key = match webauthn.finish_registration(&credential, ®_state) {
874 Ok(sk) => sk,
875 Err(e) => {
876 warn!("Passkey registration failed: {:?}", e);
877 return (
878 StatusCode::BAD_REQUEST,
879 Json(json!({"error": "RegistrationFailed", "message": "Passkey registration failed"})),
880 )
881 .into_response();
882 }
883 };
884
885 if let Err(e) = crate::auth::webauthn::save_passkey(
886 &state.db,
887 &input.did,
888 &security_key,
889 input.passkey_friendly_name.as_deref(),
890 )
891 .await
892 {
893 error!("Error saving passkey: {:?}", e);
894 return (
895 StatusCode::INTERNAL_SERVER_ERROR,
896 Json(json!({"error": "InternalError"})),
897 )
898 .into_response();
899 }
900
901 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
902
903 let app_password = generate_app_password();
904 let app_password_name = "bsky.app".to_string();
905 let password_hash = match hash(&app_password, DEFAULT_COST) {
906 Ok(h) => h,
907 Err(e) => {
908 error!("Error hashing app password: {:?}", e);
909 return (
910 StatusCode::INTERNAL_SERVER_ERROR,
911 Json(json!({"error": "InternalError"})),
912 )
913 .into_response();
914 }
915 };
916
917 if let Err(e) = sqlx::query!(
918 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
919 user.id,
920 app_password_name,
921 password_hash
922 )
923 .execute(&state.db)
924 .await
925 {
926 error!("Error creating app password: {:?}", e);
927 return (
928 StatusCode::INTERNAL_SERVER_ERROR,
929 Json(json!({"error": "InternalError"})),
930 )
931 .into_response();
932 }
933
934 if let Err(e) = sqlx::query!(
935 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
936 input.did
937 )
938 .execute(&state.db)
939 .await
940 {
941 error!("Error clearing setup token: {:?}", e);
942 }
943
944 info!(did = %input.did, "Passkey-only account setup completed");
945
946 Json(CompletePasskeySetupResponse {
947 did: input.did,
948 handle: user.handle,
949 app_password,
950 app_password_name,
951 })
952 .into_response()
953}
954
955pub async fn start_passkey_registration_for_setup(
956 State(state): State<AppState>,
957 Json(input): Json<StartPasskeyRegistrationInput>,
958) -> Response {
959 let user = sqlx::query!(
960 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
961 FROM users WHERE did = $1"#,
962 input.did
963 )
964 .fetch_optional(&state.db)
965 .await;
966
967 let user = match user {
968 Ok(Some(u)) => u,
969 Ok(None) => {
970 return (
971 StatusCode::NOT_FOUND,
972 Json(json!({"error": "AccountNotFound"})),
973 )
974 .into_response();
975 }
976 Err(e) => {
977 error!("DB error: {:?}", e);
978 return (
979 StatusCode::INTERNAL_SERVER_ERROR,
980 Json(json!({"error": "InternalError"})),
981 )
982 .into_response();
983 }
984 };
985
986 if user.password_required {
987 return (
988 StatusCode::BAD_REQUEST,
989 Json(json!({"error": "InvalidAccount"})),
990 )
991 .into_response();
992 }
993
994 let token_hash = match &user.recovery_token {
995 Some(h) => h,
996 None => {
997 return (
998 StatusCode::BAD_REQUEST,
999 Json(json!({"error": "SetupExpired"})),
1000 )
1001 .into_response();
1002 }
1003 };
1004
1005 if let Some(expires_at) = user.recovery_token_expires_at
1006 && expires_at < Utc::now()
1007 {
1008 return (
1009 StatusCode::BAD_REQUEST,
1010 Json(json!({"error": "SetupExpired"})),
1011 )
1012 .into_response();
1013 }
1014
1015 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
1016 return (
1017 StatusCode::UNAUTHORIZED,
1018 Json(json!({"error": "InvalidToken"})),
1019 )
1020 .into_response();
1021 }
1022
1023 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1024 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
1025 Ok(w) => w,
1026 Err(e) => {
1027 error!("Failed to create WebAuthn config: {:?}", e);
1028 return (
1029 StatusCode::INTERNAL_SERVER_ERROR,
1030 Json(json!({"error": "InternalError"})),
1031 )
1032 .into_response();
1033 }
1034 };
1035
1036 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did)
1037 .await
1038 .unwrap_or_default();
1039
1040 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys
1041 .iter()
1042 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone()))
1043 .collect();
1044
1045 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle);
1046
1047 let (ccr, reg_state) = match webauthn.start_registration(
1048 &input.did,
1049 &user.handle,
1050 display_name,
1051 exclude_credentials,
1052 ) {
1053 Ok(result) => result,
1054 Err(e) => {
1055 error!("Failed to start passkey registration: {:?}", e);
1056 return (
1057 StatusCode::INTERNAL_SERVER_ERROR,
1058 Json(json!({"error": "InternalError"})),
1059 )
1060 .into_response();
1061 }
1062 };
1063
1064 if let Err(e) =
1065 crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await
1066 {
1067 error!("Failed to save registration state: {:?}", e);
1068 return (
1069 StatusCode::INTERNAL_SERVER_ERROR,
1070 Json(json!({"error": "InternalError"})),
1071 )
1072 .into_response();
1073 }
1074
1075 let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
1076 Json(json!({"options": options})).into_response()
1077}
1078
1079#[derive(Deserialize)]
1080#[serde(rename_all = "camelCase")]
1081pub struct StartPasskeyRegistrationInput {
1082 pub did: String,
1083 pub setup_token: String,
1084 pub friendly_name: Option<String>,
1085}
1086
1087#[derive(Deserialize)]
1088#[serde(rename_all = "camelCase")]
1089pub struct RequestPasskeyRecoveryInput {
1090 #[serde(alias = "identifier")]
1091 pub email: String,
1092}
1093
1094pub async fn request_passkey_recovery(
1095 State(state): State<AppState>,
1096 headers: HeaderMap,
1097 Json(input): Json<RequestPasskeyRecoveryInput>,
1098) -> Response {
1099 let client_ip = extract_client_ip(&headers);
1100 if !state
1101 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
1102 .await
1103 {
1104 return (
1105 StatusCode::TOO_MANY_REQUESTS,
1106 Json(json!({"error": "RateLimitExceeded"})),
1107 )
1108 .into_response();
1109 }
1110
1111 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1112 let identifier = input.email.trim().to_lowercase();
1113 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
1114 let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
1115 identifier.to_string()
1116 } else {
1117 format!("{}.{}", identifier, pds_hostname)
1118 };
1119
1120 let user = sqlx::query!(
1121 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
1122 identifier,
1123 normalized_handle
1124 )
1125 .fetch_optional(&state.db)
1126 .await;
1127
1128 let user = match user {
1129 Ok(Some(u)) if !u.password_required => u,
1130 _ => {
1131 return Json(json!({"success": true})).into_response();
1132 }
1133 };
1134
1135 let recovery_token = generate_setup_token();
1136 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
1137 Ok(h) => h,
1138 Err(_) => {
1139 return (
1140 StatusCode::INTERNAL_SERVER_ERROR,
1141 Json(json!({"error": "InternalError"})),
1142 )
1143 .into_response();
1144 }
1145 };
1146 let expires_at = Utc::now() + Duration::hours(1);
1147
1148 if let Err(e) = sqlx::query!(
1149 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
1150 recovery_token_hash,
1151 expires_at,
1152 user.did
1153 )
1154 .execute(&state.db)
1155 .await
1156 {
1157 error!("Error updating recovery token: {:?}", e);
1158 return (
1159 StatusCode::INTERNAL_SERVER_ERROR,
1160 Json(json!({"error": "InternalError"})),
1161 )
1162 .into_response();
1163 }
1164
1165 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1166 let recovery_url = format!(
1167 "https://{}/#/recover-passkey?did={}&token={}",
1168 hostname,
1169 urlencoding::encode(&user.did),
1170 urlencoding::encode(&recovery_token)
1171 );
1172
1173 let _ =
1174 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
1175
1176 info!(did = %user.did, "Passkey recovery requested");
1177 Json(json!({"success": true})).into_response()
1178}
1179
1180#[derive(Deserialize)]
1181#[serde(rename_all = "camelCase")]
1182pub struct RecoverPasskeyAccountInput {
1183 pub did: String,
1184 pub recovery_token: String,
1185 pub new_password: String,
1186}
1187
1188pub async fn recover_passkey_account(
1189 State(state): State<AppState>,
1190 Json(input): Json<RecoverPasskeyAccountInput>,
1191) -> Response {
1192 if let Err(e) = validate_password(&input.new_password) {
1193 return (
1194 StatusCode::BAD_REQUEST,
1195 Json(json!({
1196 "error": "InvalidPassword",
1197 "message": e.to_string()
1198 })),
1199 )
1200 .into_response();
1201 }
1202
1203 let user = sqlx::query!(
1204 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
1205 input.did
1206 )
1207 .fetch_optional(&state.db)
1208 .await;
1209
1210 let user = match user {
1211 Ok(Some(u)) => u,
1212 _ => {
1213 return (
1214 StatusCode::NOT_FOUND,
1215 Json(json!({"error": "InvalidRecoveryLink"})),
1216 )
1217 .into_response();
1218 }
1219 };
1220
1221 let token_hash = match &user.recovery_token {
1222 Some(h) => h,
1223 None => {
1224 return (
1225 StatusCode::BAD_REQUEST,
1226 Json(json!({"error": "InvalidRecoveryLink"})),
1227 )
1228 .into_response();
1229 }
1230 };
1231
1232 if let Some(expires_at) = user.recovery_token_expires_at
1233 && expires_at < Utc::now()
1234 {
1235 return (
1236 StatusCode::BAD_REQUEST,
1237 Json(json!({"error": "RecoveryLinkExpired"})),
1238 )
1239 .into_response();
1240 }
1241
1242 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
1243 return (
1244 StatusCode::UNAUTHORIZED,
1245 Json(json!({"error": "InvalidRecoveryLink"})),
1246 )
1247 .into_response();
1248 }
1249
1250 let password_hash = match hash(&input.new_password, DEFAULT_COST) {
1251 Ok(h) => h,
1252 Err(_) => {
1253 return (
1254 StatusCode::INTERNAL_SERVER_ERROR,
1255 Json(json!({"error": "InternalError"})),
1256 )
1257 .into_response();
1258 }
1259 };
1260
1261 if let Err(e) = sqlx::query!(
1262 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
1263 password_hash,
1264 input.did
1265 )
1266 .execute(&state.db)
1267 .await
1268 {
1269 error!("Error updating password: {:?}", e);
1270 return (
1271 StatusCode::INTERNAL_SERVER_ERROR,
1272 Json(json!({"error": "InternalError"})),
1273 )
1274 .into_response();
1275 }
1276
1277 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did)
1278 .execute(&state.db)
1279 .await;
1280 match deleted {
1281 Ok(result) => {
1282 if result.rows_affected() > 0 {
1283 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery");
1284 }
1285 }
1286 Err(e) => {
1287 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
1288 }
1289 }
1290
1291 info!(did = %input.did, "Passkey-only account recovered with temporary password");
1292 Json(json!({"success": true})).into_response()
1293}