this repo has no description
1mod common;
2use base64::Engine;
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use common::*;
5use k256::ecdsa::{SigningKey, signature::Signer};
6use reqwest::StatusCode;
7use serde_json::{Value, json};
8use wiremock::matchers::{method, path};
9use wiremock::{Mock, MockServer, ResponseTemplate};
10
11#[tokio::test]
12async fn test_create_self_hosted_did_web() {
13 let client = client();
14 let handle = format!("sw{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
15 let payload = json!({
16 "handle": handle,
17 "email": format!("{}@example.com", handle),
18 "password": "Testpass123!",
19 "didType": "web"
20 });
21 let res = client
22 .post(format!(
23 "{}/xrpc/com.atproto.server.createAccount",
24 base_url().await
25 ))
26 .json(&payload)
27 .send()
28 .await
29 .expect("Failed to send request");
30 if res.status() != StatusCode::OK {
31 let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
32 panic!("createAccount failed: {:?}", body);
33 }
34 let body: Value = res.json().await.expect("Response was not JSON");
35 let did = body["did"].as_str().expect("No DID in response");
36 assert!(
37 did.starts_with("did:web:"),
38 "DID should start with did:web:, got: {}",
39 did
40 );
41 assert!(
42 did.contains(&handle),
43 "DID should contain handle {}, got: {}",
44 handle,
45 did
46 );
47 assert!(
48 !did.contains(":u:"),
49 "Self-hosted did:web should use subdomain format (no :u:), got: {}",
50 did
51 );
52 let jwt = verify_new_account(&client, did).await;
53 let res = client
54 .get(format!("{}/u/{}/did.json", base_url().await, handle))
55 .send()
56 .await
57 .expect("Failed to fetch DID doc via path");
58 assert_eq!(
59 res.status(),
60 StatusCode::OK,
61 "Self-hosted did:web should have DID doc served by PDS (via path for backwards compat)"
62 );
63 let doc: Value = res.json().await.expect("DID doc was not JSON");
64 assert_eq!(doc["id"], did);
65 assert!(
66 doc["verificationMethod"][0]["publicKeyMultibase"].is_string(),
67 "DID doc should have publicKeyMultibase"
68 );
69 let res = client
70 .post(format!(
71 "{}/xrpc/com.atproto.repo.createRecord",
72 base_url().await
73 ))
74 .bearer_auth(&jwt)
75 .json(&json!({
76 "repo": did,
77 "collection": "app.bsky.feed.post",
78 "record": {
79 "$type": "app.bsky.feed.post",
80 "text": "Hello from did:web!",
81 "createdAt": chrono::Utc::now().to_rfc3339()
82 }
83 }))
84 .send()
85 .await
86 .expect("Failed to create post");
87 assert_eq!(
88 res.status(),
89 StatusCode::OK,
90 "Self-hosted did:web account should be able to create records"
91 );
92}
93
94#[tokio::test]
95async fn test_external_did_web_no_local_doc() {
96 let client = client();
97 let mock_server = MockServer::start().await;
98 let mock_uri = mock_server.uri();
99 let mock_addr = mock_uri.trim_start_matches("http://");
100 let did = format!("did:web:{}", mock_addr.replace(":", "%3A"));
101 let handle = format!("xw{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
102 let pds_endpoint = base_url().await.replace("http://", "https://");
103
104 let reserve_res = client
105 .post(format!(
106 "{}/xrpc/com.atproto.server.reserveSigningKey",
107 base_url().await
108 ))
109 .json(&json!({ "did": did }))
110 .send()
111 .await
112 .expect("Failed to reserve signing key");
113 assert_eq!(reserve_res.status(), StatusCode::OK);
114 let reserve_body: Value = reserve_res.json().await.expect("Response was not JSON");
115 let signing_key = reserve_body["signingKey"]
116 .as_str()
117 .expect("No signingKey returned");
118 let public_key_multibase = signing_key
119 .strip_prefix("did:key:")
120 .expect("signingKey should start with did:key:");
121
122 let did_doc = json!({
123 "@context": ["https://www.w3.org/ns/did/v1"],
124 "id": did,
125 "verificationMethod": [{
126 "id": format!("{}#atproto", did),
127 "type": "Multikey",
128 "controller": did,
129 "publicKeyMultibase": public_key_multibase
130 }],
131 "service": [{
132 "id": "#atproto_pds",
133 "type": "AtprotoPersonalDataServer",
134 "serviceEndpoint": pds_endpoint
135 }]
136 });
137 Mock::given(method("GET"))
138 .and(path("/.well-known/did.json"))
139 .respond_with(ResponseTemplate::new(200).set_body_json(did_doc))
140 .mount(&mock_server)
141 .await;
142 let payload = json!({
143 "handle": handle,
144 "email": format!("{}@example.com", handle),
145 "password": "Testpass123!",
146 "didType": "web-external",
147 "did": did,
148 "signingKey": signing_key
149 });
150 let res = client
151 .post(format!(
152 "{}/xrpc/com.atproto.server.createAccount",
153 base_url().await
154 ))
155 .json(&payload)
156 .send()
157 .await
158 .expect("Failed to send request");
159 if res.status() != StatusCode::OK {
160 let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
161 panic!("createAccount failed: {:?}", body);
162 }
163 let res = client
164 .get(format!("{}/u/{}/did.json", base_url().await, handle))
165 .send()
166 .await
167 .expect("Failed to fetch DID doc");
168 assert_eq!(
169 res.status(),
170 StatusCode::NOT_FOUND,
171 "External did:web should NOT have DID doc served by PDS"
172 );
173 let body: Value = res.json().await.expect("Response was not JSON");
174 assert!(
175 body["message"].as_str().unwrap_or("").contains("External"),
176 "Error message should indicate external did:web"
177 );
178}
179
180#[tokio::test]
181async fn test_plc_operations_blocked_for_did_web() {
182 let client = client();
183 let handle = format!("pb{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
184 let payload = json!({
185 "handle": handle,
186 "email": format!("{}@example.com", handle),
187 "password": "Testpass123!",
188 "didType": "web"
189 });
190 let res = client
191 .post(format!(
192 "{}/xrpc/com.atproto.server.createAccount",
193 base_url().await
194 ))
195 .json(&payload)
196 .send()
197 .await
198 .expect("Failed to send request");
199 assert_eq!(res.status(), StatusCode::OK);
200 let body: Value = res.json().await.expect("Response was not JSON");
201 let did = body["did"].as_str().expect("No DID").to_string();
202 let jwt = verify_new_account(&client, &did).await;
203 let res = client
204 .post(format!(
205 "{}/xrpc/com.atproto.identity.signPlcOperation",
206 base_url().await
207 ))
208 .bearer_auth(&jwt)
209 .json(&json!({
210 "token": "fake-token"
211 }))
212 .send()
213 .await
214 .expect("Failed to send request");
215 assert_eq!(
216 res.status(),
217 StatusCode::BAD_REQUEST,
218 "signPlcOperation should be blocked for did:web users"
219 );
220 let body: Value = res.json().await.expect("Response was not JSON");
221 assert!(
222 body["message"].as_str().unwrap_or("").contains("did:plc"),
223 "Error should mention did:plc: {:?}",
224 body
225 );
226 let res = client
227 .post(format!(
228 "{}/xrpc/com.atproto.identity.submitPlcOperation",
229 base_url().await
230 ))
231 .bearer_auth(&jwt)
232 .json(&json!({
233 "operation": {}
234 }))
235 .send()
236 .await
237 .expect("Failed to send request");
238 assert_eq!(
239 res.status(),
240 StatusCode::BAD_REQUEST,
241 "submitPlcOperation should be blocked for did:web users"
242 );
243}
244
245#[tokio::test]
246async fn test_get_recommended_did_credentials_no_rotation_keys_for_did_web() {
247 let client = client();
248 let handle = format!("cr{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
249 let payload = json!({
250 "handle": handle,
251 "email": format!("{}@example.com", handle),
252 "password": "Testpass123!",
253 "didType": "web"
254 });
255 let res = client
256 .post(format!(
257 "{}/xrpc/com.atproto.server.createAccount",
258 base_url().await
259 ))
260 .json(&payload)
261 .send()
262 .await
263 .expect("Failed to send request");
264 assert_eq!(res.status(), StatusCode::OK);
265 let body: Value = res.json().await.expect("Response was not JSON");
266 let did = body["did"].as_str().expect("No DID").to_string();
267 let jwt = verify_new_account(&client, &did).await;
268 let res = client
269 .get(format!(
270 "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
271 base_url().await
272 ))
273 .bearer_auth(&jwt)
274 .send()
275 .await
276 .expect("Failed to send request");
277 assert_eq!(res.status(), StatusCode::OK);
278 let body: Value = res.json().await.expect("Response was not JSON");
279 let rotation_keys = body["rotationKeys"]
280 .as_array()
281 .expect("rotationKeys should be an array");
282 assert!(
283 rotation_keys.is_empty(),
284 "did:web should have no rotation keys, got: {:?}",
285 rotation_keys
286 );
287 assert!(
288 body["verificationMethods"].is_object(),
289 "verificationMethods should be present"
290 );
291 assert!(body["services"].is_object(), "services should be present");
292}
293
294#[tokio::test]
295async fn test_did_plc_still_works_with_did_type_param() {
296 let client = client();
297 let handle = format!("pt{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
298 let payload = json!({
299 "handle": handle,
300 "email": format!("{}@example.com", handle),
301 "password": "Testpass123!",
302 "didType": "plc"
303 });
304 let res = client
305 .post(format!(
306 "{}/xrpc/com.atproto.server.createAccount",
307 base_url().await
308 ))
309 .json(&payload)
310 .send()
311 .await
312 .expect("Failed to send request");
313 assert_eq!(res.status(), StatusCode::OK);
314 let body: Value = res.json().await.expect("Response was not JSON");
315 let did = body["did"].as_str().expect("No DID").to_string();
316 assert!(
317 did.starts_with("did:plc:"),
318 "DID with didType=plc should be did:plc:, got: {}",
319 did
320 );
321}
322
323#[tokio::test]
324async fn test_external_did_web_requires_did_field() {
325 let client = client();
326 let handle = format!("nd{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
327 let payload = json!({
328 "handle": handle,
329 "email": format!("{}@example.com", handle),
330 "password": "Testpass123!",
331 "didType": "web-external"
332 });
333 let res = client
334 .post(format!(
335 "{}/xrpc/com.atproto.server.createAccount",
336 base_url().await
337 ))
338 .json(&payload)
339 .send()
340 .await
341 .expect("Failed to send request");
342 assert_eq!(
343 res.status(),
344 StatusCode::BAD_REQUEST,
345 "web-external without did should fail"
346 );
347 let body: Value = res.json().await.expect("Response was not JSON");
348 assert!(
349 body["message"].as_str().unwrap_or("").contains("did"),
350 "Error should mention did field is required: {:?}",
351 body
352 );
353}
354
355fn signing_key_to_multibase(signing_key: &SigningKey) -> String {
356 let verifying_key = signing_key.verifying_key();
357 let compressed = verifying_key.to_sec1_bytes();
358 let mut multicodec = vec![0xe7, 0x01];
359 multicodec.extend_from_slice(&compressed);
360 multibase::encode(multibase::Base::Base58Btc, &multicodec)
361}
362
363fn create_service_jwt(signing_key: &SigningKey, did: &str, aud: &str) -> String {
364 let header = json!({"alg": "ES256K", "typ": "jwt"});
365 let now = chrono::Utc::now().timestamp() as usize;
366 let claims = json!({
367 "iss": did,
368 "sub": did,
369 "aud": aud,
370 "exp": now + 300,
371 "iat": now,
372 "lxm": "com.atproto.server.createAccount",
373 "jti": uuid::Uuid::new_v4().to_string()
374 });
375 let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string());
376 let claims_b64 = URL_SAFE_NO_PAD.encode(claims.to_string());
377 let message = format!("{}.{}", header_b64, claims_b64);
378 let signature: k256::ecdsa::Signature = signing_key.sign(message.as_bytes());
379 let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
380 format!("{}.{}", message, sig_b64)
381}
382
383#[tokio::test]
384async fn test_did_web_byod_flow() {
385 let client = client();
386 let mock_server = MockServer::start().await;
387 let mock_uri = mock_server.uri();
388 let mock_addr = mock_uri.trim_start_matches("http://");
389 let unique_id = uuid::Uuid::new_v4().to_string().replace("-", "");
390 let did = format!(
391 "did:web:{}:byod:{}",
392 mock_addr.replace(":", "%3A"),
393 unique_id
394 );
395 let handle = format!("by{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
396 let pds_endpoint = base_url().await.replace("http://", "https://");
397 let pds_did = format!("did:web:{}", pds_endpoint.trim_start_matches("https://"));
398
399 let temp_key = SigningKey::random(&mut rand::thread_rng());
400 let public_key_multibase = signing_key_to_multibase(&temp_key);
401
402 let did_doc = json!({
403 "@context": ["https://www.w3.org/ns/did/v1"],
404 "id": did,
405 "verificationMethod": [{
406 "id": format!("{}#atproto", did),
407 "type": "Multikey",
408 "controller": did,
409 "publicKeyMultibase": public_key_multibase
410 }],
411 "service": [{
412 "id": "#atproto_pds",
413 "type": "AtprotoPersonalDataServer",
414 "serviceEndpoint": pds_endpoint
415 }]
416 });
417 Mock::given(method("GET"))
418 .and(path(format!("/byod/{}/did.json", unique_id)))
419 .respond_with(ResponseTemplate::new(200).set_body_json(&did_doc))
420 .mount(&mock_server)
421 .await;
422
423 let service_jwt = create_service_jwt(&temp_key, &did, &pds_did);
424 let payload = json!({
425 "handle": handle,
426 "email": format!("{}@example.com", handle),
427 "password": "Testpass123!",
428 "did": did
429 });
430 let res = client
431 .post(format!(
432 "{}/xrpc/com.atproto.server.createAccount",
433 base_url().await
434 ))
435 .header("Authorization", format!("Bearer {}", service_jwt))
436 .json(&payload)
437 .send()
438 .await
439 .expect("Failed to send request");
440 if res.status() != StatusCode::OK {
441 let body: Value = res.json().await.unwrap_or(json!({"error": "parse failed"}));
442 panic!("createAccount BYOD failed: {:?}", body);
443 }
444 let body: Value = res.json().await.expect("Response was not JSON");
445 let returned_did = body["did"].as_str().expect("No DID in response");
446 assert_eq!(returned_did, did, "Returned DID should match requested DID");
447 assert_eq!(
448 body["verificationRequired"], true,
449 "BYOD accounts should require verification"
450 );
451
452 let access_jwt = common::verify_new_account(&client, returned_did).await;
453
454 let res = client
455 .get(format!(
456 "{}/xrpc/com.atproto.server.checkAccountStatus",
457 base_url().await
458 ))
459 .bearer_auth(&access_jwt)
460 .send()
461 .await
462 .expect("Failed to check account status");
463 assert_eq!(res.status(), StatusCode::OK);
464 let status: Value = res.json().await.expect("Response was not JSON");
465 assert_eq!(
466 status["activated"], false,
467 "BYOD account should be deactivated initially"
468 );
469
470 let res = client
471 .get(format!(
472 "{}/xrpc/com.atproto.identity.getRecommendedDidCredentials",
473 base_url().await
474 ))
475 .bearer_auth(&access_jwt)
476 .send()
477 .await
478 .expect("Failed to get recommended credentials");
479 assert_eq!(res.status(), StatusCode::OK);
480 let creds: Value = res.json().await.expect("Response was not JSON");
481 assert!(
482 creds["verificationMethods"]["atproto"].is_string(),
483 "Should return PDS signing key"
484 );
485 let pds_signing_key = creds["verificationMethods"]["atproto"]
486 .as_str()
487 .expect("No atproto verification method");
488 assert!(
489 pds_signing_key.starts_with("did:key:"),
490 "PDS signing key should be did:key format"
491 );
492
493 let res = client
494 .post(format!(
495 "{}/xrpc/com.atproto.server.activateAccount",
496 base_url().await
497 ))
498 .bearer_auth(&access_jwt)
499 .send()
500 .await
501 .expect("Failed to activate account");
502 assert_eq!(
503 res.status(),
504 StatusCode::OK,
505 "activateAccount should succeed"
506 );
507
508 let res = client
509 .get(format!(
510 "{}/xrpc/com.atproto.server.checkAccountStatus",
511 base_url().await
512 ))
513 .bearer_auth(&access_jwt)
514 .send()
515 .await
516 .expect("Failed to check account status");
517 assert_eq!(res.status(), StatusCode::OK);
518 let status: Value = res.json().await.expect("Response was not JSON");
519 assert_eq!(
520 status["activated"], true,
521 "Account should be activated after activateAccount call"
522 );
523
524 let res = client
525 .post(format!(
526 "{}/xrpc/com.atproto.repo.createRecord",
527 base_url().await
528 ))
529 .bearer_auth(&access_jwt)
530 .json(&json!({
531 "repo": did,
532 "collection": "app.bsky.feed.post",
533 "record": {
534 "$type": "app.bsky.feed.post",
535 "text": "Hello from BYOD did:web!",
536 "createdAt": chrono::Utc::now().to_rfc3339()
537 }
538 }))
539 .send()
540 .await
541 .expect("Failed to create post");
542 assert_eq!(
543 res.status(),
544 StatusCode::OK,
545 "Activated BYOD account should be able to create records"
546 );
547}
548
549#[tokio::test]
550async fn test_did_web_can_edit_did_document() {
551 let client = client();
552 let base = base_url().await;
553 let handle = format!("doc{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
554 let payload = json!({
555 "handle": handle,
556 "email": format!("{}@example.com", handle),
557 "password": "Testpass123!",
558 "didType": "web"
559 });
560 let res = client
561 .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
562 .json(&payload)
563 .send()
564 .await
565 .expect("Failed to send request");
566 assert_eq!(res.status(), StatusCode::OK);
567 let body: Value = res.json().await.expect("Response was not JSON");
568 let did = body["did"].as_str().expect("No DID").to_string();
569 let jwt = verify_new_account(&client, &did).await;
570 let res = client
571 .get(format!("{}/xrpc/_account.getDidDocument", base))
572 .bearer_auth(&jwt)
573 .send()
574 .await
575 .expect("Failed to send request");
576 assert_eq!(res.status(), StatusCode::OK);
577 let body: Value = res.json().await.expect("Response was not JSON");
578 assert!(
579 body["didDocument"].is_object(),
580 "Should return DID document"
581 );
582 assert_eq!(
583 body["didDocument"]["id"], did,
584 "DID document should have correct id"
585 );
586 let res = client
587 .post(format!("{}/xrpc/_account.updateDidDocument", base))
588 .bearer_auth(&jwt)
589 .json(&json!({
590 "alsoKnownAs": ["at://custom.handle.test"]
591 }))
592 .send()
593 .await
594 .expect("Failed to send request");
595 assert_eq!(
596 res.status(),
597 StatusCode::OK,
598 "Non-migrated did:web user should be able to update DID document"
599 );
600 let body: Value = res.json().await.expect("Response was not JSON");
601 assert!(body["success"].as_bool().unwrap_or(false));
602 let also_known_as = body["didDocument"]["alsoKnownAs"]
603 .as_array()
604 .expect("alsoKnownAs should be array");
605 assert!(
606 also_known_as
607 .iter()
608 .any(|v| v.as_str() == Some("at://custom.handle.test")),
609 "alsoKnownAs should contain custom entry"
610 );
611}
612
613#[tokio::test]
614async fn test_deactivate_account_basic() {
615 let client = client();
616 let base = base_url().await;
617 let handle = format!("dea{}", &uuid::Uuid::new_v4().simple().to_string()[..12]);
618 let payload = json!({
619 "handle": handle,
620 "email": format!("{}@example.com", handle),
621 "password": "Testpass123!",
622 "didType": "web"
623 });
624 let res = client
625 .post(format!("{}/xrpc/com.atproto.server.createAccount", base))
626 .json(&payload)
627 .send()
628 .await
629 .expect("Failed to send request");
630 assert_eq!(res.status(), StatusCode::OK);
631 let body: Value = res.json().await.expect("Response was not JSON");
632 let did = body["did"].as_str().expect("No DID").to_string();
633 let jwt = verify_new_account(&client, &did).await;
634 let res = client
635 .post(format!(
636 "{}/xrpc/com.atproto.server.deactivateAccount",
637 base
638 ))
639 .bearer_auth(&jwt)
640 .json(&json!({}))
641 .send()
642 .await
643 .expect("Failed to send request");
644 assert_eq!(res.status(), StatusCode::OK);
645 let res = client
646 .get(format!("{}/xrpc/com.atproto.server.getSession", base))
647 .bearer_auth(&jwt)
648 .send()
649 .await
650 .expect("Failed to send request");
651 assert_eq!(res.status(), StatusCode::OK);
652 let body: Value = res.json().await.expect("Response was not JSON");
653 assert_eq!(body["active"], false, "Account should be deactivated");
654 assert_eq!(
655 body["status"], "deactivated",
656 "Status should be 'deactivated'"
657 );
658}