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