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