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