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