forked from
oyster.cafe/bspds-sandbox
fork
Configure Feed
Select the types of activity you want to include in your feed.
PDS software with bells & whistles you didn’t even know you needed. will move this to its own account when ready.
fork
Configure Feed
Select the types of activity you want to include in your feed.
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, 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(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 (commit_bytes, _sig) =
516 match create_signed_commit(&did, mst_root, rev.as_ref(), None, &secret_key) {
517 Ok(result) => result,
518 Err(e) => {
519 error!("Error creating genesis commit: {:?}", e);
520 return ApiError::InternalError(None).into_response();
521 }
522 };
523 let commit_cid: cid::Cid = match state.block_store.put(&commit_bytes).await {
524 Ok(c) => c,
525 Err(e) => {
526 error!("Error saving genesis commit: {:?}", e);
527 return ApiError::InternalError(None).into_response();
528 }
529 };
530 let commit_cid_str = commit_cid.to_string();
531 let rev_str = rev.as_ref().to_string();
532 if let Err(e) = sqlx::query!(
533 "INSERT INTO repos (user_id, repo_root_cid, repo_rev) VALUES ($1, $2, $3)",
534 user_id,
535 commit_cid_str,
536 rev_str
537 )
538 .execute(&mut *tx)
539 .await
540 {
541 error!("Error inserting repo: {:?}", e);
542 return ApiError::InternalError(None).into_response();
543 }
544 let genesis_block_cids = vec![mst_root.to_bytes(), commit_cid.to_bytes()];
545 if let Err(e) = sqlx::query!(
546 r#"
547 INSERT INTO user_blocks (user_id, block_cid)
548 SELECT $1, block_cid FROM UNNEST($2::bytea[]) AS t(block_cid)
549 ON CONFLICT (user_id, block_cid) DO NOTHING
550 "#,
551 user_id,
552 &genesis_block_cids
553 )
554 .execute(&mut *tx)
555 .await
556 {
557 error!("Error inserting user_blocks: {:?}", e);
558 return ApiError::InternalError(None).into_response();
559 }
560
561 if let Some(ref code) = input.invite_code {
562 let _ = sqlx::query!(
563 "UPDATE invite_codes SET available_uses = available_uses - 1 WHERE code = $1",
564 code
565 )
566 .execute(&mut *tx)
567 .await;
568
569 let _ = sqlx::query!(
570 "INSERT INTO invite_code_uses (code, used_by_user) VALUES ($1, $2)",
571 code,
572 user_id
573 )
574 .execute(&mut *tx)
575 .await;
576 }
577
578 if std::env::var("PDS_AGE_ASSURANCE_OVERRIDE").is_ok() {
579 let birthdate_pref = json!({
580 "$type": "app.bsky.actor.defs#personalDetailsPref",
581 "birthDate": "1998-05-06T00:00:00.000Z"
582 });
583 if let Err(e) = sqlx::query!(
584 "INSERT INTO account_preferences (user_id, name, value_json) VALUES ($1, $2, $3)
585 ON CONFLICT (user_id, name) DO NOTHING",
586 user_id,
587 "app.bsky.actor.defs#personalDetailsPref",
588 birthdate_pref
589 )
590 .execute(&mut *tx)
591 .await
592 {
593 warn!("Failed to set default birthdate preference: {:?}", e);
594 }
595 }
596
597 if let Err(e) = tx.commit().await {
598 error!("Error committing transaction: {:?}", e);
599 return ApiError::InternalError(None).into_response();
600 }
601
602 if !is_byod_did_web {
603 if let Err(e) =
604 crate::api::repo::record::sequence_identity_event(&state, &did, Some(&handle)).await
605 {
606 warn!("Failed to sequence identity event for {}: {}", did, e);
607 }
608 if let Err(e) =
609 crate::api::repo::record::sequence_account_event(&state, &did, true, None).await
610 {
611 warn!("Failed to sequence account event for {}: {}", did, e);
612 }
613 let profile_record = serde_json::json!({
614 "$type": "app.bsky.actor.profile",
615 "displayName": handle
616 });
617 if let Err(e) = crate::api::repo::record::create_record_internal(
618 &state,
619 &did,
620 "app.bsky.actor.profile",
621 "self",
622 &profile_record,
623 )
624 .await
625 {
626 warn!("Failed to create default profile for {}: {}", did, e);
627 }
628 }
629
630 let verification_token = crate::auth::verification_token::generate_signup_token(
631 &did,
632 verification_channel,
633 &verification_recipient,
634 );
635 let formatted_token =
636 crate::auth::verification_token::format_token_for_display(&verification_token);
637 if let Err(e) = crate::comms::enqueue_signup_verification(
638 &state.db,
639 user_id,
640 verification_channel,
641 &verification_recipient,
642 &formatted_token,
643 None,
644 )
645 .await
646 {
647 warn!("Failed to enqueue signup verification: {:?}", e);
648 }
649
650 info!(did = %did, handle = %handle, "Passkey-only account created, awaiting setup completion");
651
652 let access_jwt = if byod_auth.is_some() {
653 match crate::auth::token::create_access_token_with_metadata(&did, &secret_key_bytes) {
654 Ok(token_meta) => {
655 let refresh_jti = uuid::Uuid::new_v4().to_string();
656 let refresh_expires = chrono::Utc::now() + chrono::Duration::hours(24);
657 let no_scope: Option<String> = None;
658 if let Err(e) = sqlx::query!(
659 "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)",
660 did,
661 token_meta.jti,
662 refresh_jti,
663 token_meta.expires_at,
664 refresh_expires,
665 false,
666 false,
667 no_scope
668 )
669 .execute(&state.db)
670 .await
671 {
672 warn!(did = %did, "Failed to insert migration session: {:?}", e);
673 }
674 info!(did = %did, "Generated migration access token for BYOD passkey account");
675 Some(token_meta.token)
676 }
677 Err(e) => {
678 warn!(did = %did, "Failed to generate migration access token: {:?}", e);
679 None
680 }
681 }
682 } else {
683 None
684 };
685
686 Json(CreatePasskeyAccountResponse {
687 did: did.into(),
688 handle: handle.into(),
689 setup_token,
690 setup_expires_at,
691 access_jwt,
692 })
693 .into_response()
694}
695
696#[derive(Deserialize)]
697#[serde(rename_all = "camelCase")]
698pub struct CompletePasskeySetupInput {
699 pub did: Did,
700 pub setup_token: String,
701 pub passkey_credential: serde_json::Value,
702 pub passkey_friendly_name: Option<String>,
703}
704
705#[derive(Serialize)]
706#[serde(rename_all = "camelCase")]
707pub struct CompletePasskeySetupResponse {
708 pub did: Did,
709 pub handle: Handle,
710 pub app_password: String,
711 pub app_password_name: String,
712}
713
714pub async fn complete_passkey_setup(
715 State(state): State<AppState>,
716 Json(input): Json<CompletePasskeySetupInput>,
717) -> Response {
718 let user = sqlx::query!(
719 r#"SELECT id, handle, recovery_token, recovery_token_expires_at, password_required
720 FROM users WHERE did = $1"#,
721 input.did.as_str()
722 )
723 .fetch_optional(&state.db)
724 .await;
725
726 let user = match user {
727 Ok(Some(u)) => u,
728 Ok(None) => {
729 return ApiError::AccountNotFound.into_response();
730 }
731 Err(e) => {
732 error!("DB error: {:?}", e);
733 return ApiError::InternalError(None).into_response();
734 }
735 };
736
737 if user.password_required {
738 return ApiError::InvalidAccount.into_response();
739 }
740
741 let token_hash = match &user.recovery_token {
742 Some(h) => h,
743 None => {
744 return ApiError::SetupExpired.into_response();
745 }
746 };
747
748 if let Some(expires_at) = user.recovery_token_expires_at
749 && expires_at < Utc::now()
750 {
751 return ApiError::SetupExpired.into_response();
752 }
753
754 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
755 return ApiError::InvalidToken(None).into_response();
756 }
757
758 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
759 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
760 Ok(w) => w,
761 Err(e) => {
762 error!("Failed to create WebAuthn config: {:?}", e);
763 return ApiError::InternalError(None).into_response();
764 }
765 };
766
767 let reg_state =
768 match crate::auth::webauthn::load_registration_state(&state.db, &input.did).await {
769 Ok(Some(s)) => s,
770 Ok(None) => {
771 return ApiError::NoChallengeInProgress.into_response();
772 }
773 Err(e) => {
774 error!("Error loading registration state: {:?}", e);
775 return ApiError::InternalError(None).into_response();
776 }
777 };
778
779 let credential: webauthn_rs::prelude::RegisterPublicKeyCredential =
780 match serde_json::from_value(input.passkey_credential) {
781 Ok(c) => c,
782 Err(e) => {
783 warn!("Failed to parse credential: {:?}", e);
784 return ApiError::InvalidCredential.into_response();
785 }
786 };
787
788 let security_key = match webauthn.finish_registration(&credential, ®_state) {
789 Ok(sk) => sk,
790 Err(e) => {
791 warn!("Passkey registration failed: {:?}", e);
792 return ApiError::RegistrationFailed.into_response();
793 }
794 };
795
796 if let Err(e) = crate::auth::webauthn::save_passkey(
797 &state.db,
798 &input.did,
799 &security_key,
800 input.passkey_friendly_name.as_deref(),
801 )
802 .await
803 {
804 error!("Error saving passkey: {:?}", e);
805 return ApiError::InternalError(None).into_response();
806 }
807
808 let _ = crate::auth::webauthn::delete_registration_state(&state.db, &input.did).await;
809
810 let app_password = generate_app_password();
811 let app_password_name = "bsky.app".to_string();
812 let password_hash = match hash(&app_password, DEFAULT_COST) {
813 Ok(h) => h,
814 Err(e) => {
815 error!("Error hashing app password: {:?}", e);
816 return ApiError::InternalError(None).into_response();
817 }
818 };
819
820 if let Err(e) = sqlx::query!(
821 "INSERT INTO app_passwords (user_id, name, password_hash, privileged) VALUES ($1, $2, $3, FALSE)",
822 user.id,
823 app_password_name,
824 password_hash
825 )
826 .execute(&state.db)
827 .await
828 {
829 error!("Error creating app password: {:?}", e);
830 return ApiError::InternalError(None).into_response();
831 }
832
833 if let Err(e) = sqlx::query!(
834 "UPDATE users SET recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $1",
835 input.did.as_str()
836 )
837 .execute(&state.db)
838 .await
839 {
840 error!("Error clearing setup token: {:?}", e);
841 }
842
843 info!(did = %input.did, "Passkey-only account setup completed");
844
845 Json(CompletePasskeySetupResponse {
846 did: input.did.clone(),
847 handle: user.handle.into(),
848 app_password,
849 app_password_name,
850 })
851 .into_response()
852}
853
854pub async fn start_passkey_registration_for_setup(
855 State(state): State<AppState>,
856 Json(input): Json<StartPasskeyRegistrationInput>,
857) -> Response {
858 let user = sqlx::query!(
859 r#"SELECT handle, recovery_token, recovery_token_expires_at, password_required
860 FROM users WHERE did = $1"#,
861 input.did.as_str()
862 )
863 .fetch_optional(&state.db)
864 .await;
865
866 let user = match user {
867 Ok(Some(u)) => u,
868 Ok(None) => {
869 return ApiError::AccountNotFound.into_response();
870 }
871 Err(e) => {
872 error!("DB error: {:?}", e);
873 return ApiError::InternalError(None).into_response();
874 }
875 };
876
877 if user.password_required {
878 return ApiError::InvalidAccount.into_response();
879 }
880
881 let token_hash = match &user.recovery_token {
882 Some(h) => h,
883 None => {
884 return ApiError::SetupExpired.into_response();
885 }
886 };
887
888 if let Some(expires_at) = user.recovery_token_expires_at
889 && expires_at < Utc::now()
890 {
891 return ApiError::SetupExpired.into_response();
892 }
893
894 if !bcrypt::verify(&input.setup_token, token_hash).unwrap_or(false) {
895 return ApiError::InvalidToken(None).into_response();
896 }
897
898 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
899 let webauthn = match crate::auth::webauthn::WebAuthnConfig::new(&pds_hostname) {
900 Ok(w) => w,
901 Err(e) => {
902 error!("Failed to create WebAuthn config: {:?}", e);
903 return ApiError::InternalError(None).into_response();
904 }
905 };
906
907 let existing_passkeys = crate::auth::webauthn::get_passkeys_for_user(&state.db, &input.did)
908 .await
909 .unwrap_or_default();
910
911 let exclude_credentials: Vec<webauthn_rs::prelude::CredentialID> = existing_passkeys
912 .iter()
913 .map(|p| webauthn_rs::prelude::CredentialID::from(p.credential_id.clone()))
914 .collect();
915
916 let display_name = input.friendly_name.as_deref().unwrap_or(&user.handle);
917
918 let (ccr, reg_state) = match webauthn.start_registration(
919 &input.did,
920 &user.handle,
921 display_name,
922 exclude_credentials,
923 ) {
924 Ok(result) => result,
925 Err(e) => {
926 error!("Failed to start passkey registration: {:?}", e);
927 return ApiError::InternalError(None).into_response();
928 }
929 };
930
931 if let Err(e) =
932 crate::auth::webauthn::save_registration_state(&state.db, &input.did, ®_state).await
933 {
934 error!("Failed to save registration state: {:?}", e);
935 return ApiError::InternalError(None).into_response();
936 }
937
938 let options = serde_json::to_value(&ccr).unwrap_or(json!({}));
939 Json(json!({"options": options})).into_response()
940}
941
942#[derive(Deserialize)]
943#[serde(rename_all = "camelCase")]
944pub struct StartPasskeyRegistrationInput {
945 pub did: Did,
946 pub setup_token: String,
947 pub friendly_name: Option<String>,
948}
949
950#[derive(Deserialize)]
951#[serde(rename_all = "camelCase")]
952pub struct RequestPasskeyRecoveryInput {
953 #[serde(alias = "identifier")]
954 pub email: String,
955}
956
957pub async fn request_passkey_recovery(
958 State(state): State<AppState>,
959 headers: HeaderMap,
960 Json(input): Json<RequestPasskeyRecoveryInput>,
961) -> Response {
962 let client_ip = extract_client_ip(&headers);
963 if !state
964 .check_rate_limit(RateLimitKind::PasswordReset, &client_ip)
965 .await
966 {
967 return ApiError::RateLimitExceeded(None).into_response();
968 }
969
970 let pds_hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
971 let identifier = input.email.trim().to_lowercase();
972 let identifier = identifier.strip_prefix('@').unwrap_or(&identifier);
973 let normalized_handle = if identifier.contains('@') || identifier.contains('.') {
974 identifier.to_string()
975 } else {
976 format!("{}.{}", identifier, pds_hostname)
977 };
978
979 let user = sqlx::query!(
980 "SELECT id, did, handle, password_required FROM users WHERE LOWER(email) = $1 OR handle = $2",
981 identifier,
982 normalized_handle
983 )
984 .fetch_optional(&state.db)
985 .await;
986
987 let user = match user {
988 Ok(Some(u)) if !u.password_required => u,
989 _ => {
990 return SuccessResponse::ok().into_response();
991 }
992 };
993
994 let recovery_token = generate_setup_token();
995 let recovery_token_hash = match hash(&recovery_token, DEFAULT_COST) {
996 Ok(h) => h,
997 Err(_) => {
998 return ApiError::InternalError(None).into_response();
999 }
1000 };
1001 let expires_at = Utc::now() + Duration::hours(1);
1002
1003 if let Err(e) = sqlx::query!(
1004 "UPDATE users SET recovery_token = $1, recovery_token_expires_at = $2 WHERE did = $3",
1005 recovery_token_hash,
1006 expires_at,
1007 &user.did
1008 )
1009 .execute(&state.db)
1010 .await
1011 {
1012 error!("Error updating recovery token: {:?}", e);
1013 return ApiError::InternalError(None).into_response();
1014 }
1015
1016 let hostname = std::env::var("PDS_HOSTNAME").unwrap_or_else(|_| "localhost".to_string());
1017 let recovery_url = format!(
1018 "https://{}/app/recover-passkey?did={}&token={}",
1019 hostname,
1020 urlencoding::encode(&user.did),
1021 urlencoding::encode(&recovery_token)
1022 );
1023
1024 let _ =
1025 crate::comms::enqueue_passkey_recovery(&state.db, user.id, &recovery_url, &hostname).await;
1026
1027 info!(did = %user.did, "Passkey recovery requested");
1028 SuccessResponse::ok().into_response()
1029}
1030
1031#[derive(Deserialize)]
1032#[serde(rename_all = "camelCase")]
1033pub struct RecoverPasskeyAccountInput {
1034 pub did: Did,
1035 pub recovery_token: String,
1036 pub new_password: PlainPassword,
1037}
1038
1039pub async fn recover_passkey_account(
1040 State(state): State<AppState>,
1041 Json(input): Json<RecoverPasskeyAccountInput>,
1042) -> Response {
1043 if let Err(e) = validate_password(&input.new_password) {
1044 return ApiError::InvalidRequest(e.to_string()).into_response();
1045 }
1046
1047 let user = sqlx::query!(
1048 "SELECT id, did, recovery_token, recovery_token_expires_at FROM users WHERE did = $1",
1049 input.did.as_str()
1050 )
1051 .fetch_optional(&state.db)
1052 .await;
1053
1054 let user = match user {
1055 Ok(Some(u)) => u,
1056 _ => {
1057 return ApiError::InvalidRecoveryLink.into_response();
1058 }
1059 };
1060
1061 let token_hash = match &user.recovery_token {
1062 Some(h) => h,
1063 None => {
1064 return ApiError::InvalidRecoveryLink.into_response();
1065 }
1066 };
1067
1068 if let Some(expires_at) = user.recovery_token_expires_at
1069 && expires_at < Utc::now()
1070 {
1071 return ApiError::RecoveryLinkExpired.into_response();
1072 }
1073
1074 if !bcrypt::verify(&input.recovery_token, token_hash).unwrap_or(false) {
1075 return ApiError::InvalidRecoveryLink.into_response();
1076 }
1077
1078 let password_hash = match hash(&input.new_password, DEFAULT_COST) {
1079 Ok(h) => h,
1080 Err(_) => {
1081 return ApiError::InternalError(None).into_response();
1082 }
1083 };
1084
1085 if let Err(e) = sqlx::query!(
1086 "UPDATE users SET password_hash = $1, password_required = TRUE, recovery_token = NULL, recovery_token_expires_at = NULL WHERE did = $2",
1087 password_hash,
1088 input.did.as_str()
1089 )
1090 .execute(&state.db)
1091 .await
1092 {
1093 error!("Error updating password: {:?}", e);
1094 return ApiError::InternalError(None).into_response();
1095 }
1096
1097 let deleted = sqlx::query!("DELETE FROM passkeys WHERE did = $1", input.did.as_str())
1098 .execute(&state.db)
1099 .await;
1100 match deleted {
1101 Ok(result) => {
1102 if result.rows_affected() > 0 {
1103 info!(did = %input.did, count = result.rows_affected(), "Deleted lost passkeys during account recovery");
1104 }
1105 }
1106 Err(e) => {
1107 warn!(did = %input.did, "Failed to delete passkeys during recovery: {:?}", e);
1108 }
1109 }
1110
1111 info!(did = %input.did, "Passkey-only account recovered with temporary password");
1112 SuccessResponse::ok().into_response()
1113}