this repo has no description
1mod common;
2mod helpers;
3
4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5use chrono::Utc;
6use common::{base_url, client};
7use reqwest::{redirect, StatusCode};
8use serde_json::{json, Value};
9use sha2::{Digest, Sha256};
10use wiremock::{Mock, MockServer, ResponseTemplate};
11use wiremock::matchers::{method, path};
12
13fn generate_pkce() -> (String, String) {
14 let verifier_bytes: [u8; 32] = rand::random();
15 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
16
17 let mut hasher = Sha256::new();
18 hasher.update(code_verifier.as_bytes());
19 let hash = hasher.finalize();
20 let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
21
22 (code_verifier, code_challenge)
23}
24
25fn no_redirect_client() -> reqwest::Client {
26 reqwest::Client::builder()
27 .redirect(redirect::Policy::none())
28 .build()
29 .unwrap()
30}
31
32async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
33 let mock_server = MockServer::start().await;
34
35 let client_id = mock_server.uri();
36 let metadata = json!({
37 "client_id": client_id,
38 "client_name": "Test OAuth Client",
39 "redirect_uris": [redirect_uri],
40 "grant_types": ["authorization_code", "refresh_token"],
41 "response_types": ["code"],
42 "token_endpoint_auth_method": "none",
43 "dpop_bound_access_tokens": false
44 });
45
46 Mock::given(method("GET"))
47 .and(path("/"))
48 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
49 .mount(&mock_server)
50 .await;
51
52 mock_server
53}
54
55struct OAuthSession {
56 access_token: String,
57 refresh_token: String,
58 did: String,
59 client_id: String,
60}
61
62async fn create_user_and_oauth_session(handle_prefix: &str, redirect_uri: &str) -> (OAuthSession, MockServer) {
63 let url = base_url().await;
64 let http_client = client();
65
66 let ts = Utc::now().timestamp_millis();
67 let handle = format!("{}-{}", handle_prefix, ts);
68 let email = format!("{}-{}@example.com", handle_prefix, ts);
69 let password = format!("{}-password", handle_prefix);
70
71 let create_res = http_client
72 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
73 .json(&json!({
74 "handle": handle,
75 "email": email,
76 "password": password
77 }))
78 .send()
79 .await
80 .expect("Account creation failed");
81
82 assert_eq!(create_res.status(), StatusCode::OK);
83 let account: Value = create_res.json().await.unwrap();
84 let user_did = account["did"].as_str().unwrap().to_string();
85
86 let mock_client = setup_mock_client_metadata(redirect_uri).await;
87 let client_id = mock_client.uri();
88
89 let (code_verifier, code_challenge) = generate_pkce();
90
91 let par_res = http_client
92 .post(format!("{}/oauth/par", url))
93 .form(&[
94 ("response_type", "code"),
95 ("client_id", &client_id),
96 ("redirect_uri", redirect_uri),
97 ("code_challenge", &code_challenge),
98 ("code_challenge_method", "S256"),
99 ("scope", "atproto"),
100 ])
101 .send()
102 .await
103 .expect("PAR failed");
104
105 assert_eq!(par_res.status(), StatusCode::OK);
106 let par_body: Value = par_res.json().await.unwrap();
107 let request_uri = par_body["request_uri"].as_str().unwrap();
108
109 let auth_client = no_redirect_client();
110 let auth_res = auth_client
111 .post(format!("{}/oauth/authorize", url))
112 .form(&[
113 ("request_uri", request_uri),
114 ("username", &handle),
115 ("password", &password),
116 ("remember_device", "false"),
117 ])
118 .send()
119 .await
120 .expect("Authorize failed");
121
122 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
123 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
124
125 let token_res = http_client
126 .post(format!("{}/oauth/token", url))
127 .form(&[
128 ("grant_type", "authorization_code"),
129 ("code", code),
130 ("redirect_uri", redirect_uri),
131 ("code_verifier", &code_verifier),
132 ("client_id", &client_id),
133 ])
134 .send()
135 .await
136 .expect("Token request failed");
137
138 assert_eq!(token_res.status(), StatusCode::OK);
139 let token_body: Value = token_res.json().await.unwrap();
140
141 let session = OAuthSession {
142 access_token: token_body["access_token"].as_str().unwrap().to_string(),
143 refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
144 did: user_did,
145 client_id,
146 };
147
148 (session, mock_client)
149}
150
151#[tokio::test]
152async fn test_oauth_token_can_create_and_read_records() {
153 let url = base_url().await;
154 let http_client = client();
155
156 let (session, _mock) = create_user_and_oauth_session(
157 "oauth-records",
158 "https://example.com/callback"
159 ).await;
160
161 let collection = "app.bsky.feed.post";
162 let post_text = "Hello from OAuth! This post was created with an OAuth access token.";
163
164 let create_res = http_client
165 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
166 .bearer_auth(&session.access_token)
167 .json(&json!({
168 "repo": session.did,
169 "collection": collection,
170 "record": {
171 "$type": collection,
172 "text": post_text,
173 "createdAt": Utc::now().to_rfc3339()
174 }
175 }))
176 .send()
177 .await
178 .expect("createRecord failed");
179
180 assert_eq!(create_res.status(), StatusCode::OK, "Should create record with OAuth token");
181
182 let create_body: Value = create_res.json().await.unwrap();
183 let uri = create_body["uri"].as_str().unwrap();
184 let rkey = uri.split('/').last().unwrap();
185
186 let get_res = http_client
187 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
188 .bearer_auth(&session.access_token)
189 .query(&[
190 ("repo", session.did.as_str()),
191 ("collection", collection),
192 ("rkey", rkey),
193 ])
194 .send()
195 .await
196 .expect("getRecord failed");
197
198 assert_eq!(get_res.status(), StatusCode::OK, "Should read record with OAuth token");
199
200 let get_body: Value = get_res.json().await.unwrap();
201 assert_eq!(get_body["value"]["text"], post_text);
202}
203
204#[tokio::test]
205async fn test_oauth_token_can_upload_blob() {
206 let url = base_url().await;
207 let http_client = client();
208
209 let (session, _mock) = create_user_and_oauth_session(
210 "oauth-blob",
211 "https://example.com/callback"
212 ).await;
213
214 let blob_data = b"This is test blob data uploaded via OAuth";
215
216 let upload_res = http_client
217 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
218 .bearer_auth(&session.access_token)
219 .header("Content-Type", "text/plain")
220 .body(blob_data.to_vec())
221 .send()
222 .await
223 .expect("uploadBlob failed");
224
225 assert_eq!(upload_res.status(), StatusCode::OK, "Should upload blob with OAuth token");
226
227 let upload_body: Value = upload_res.json().await.unwrap();
228 assert!(upload_body["blob"]["ref"]["$link"].is_string());
229 assert_eq!(upload_body["blob"]["mimeType"], "text/plain");
230}
231
232#[tokio::test]
233async fn test_oauth_token_can_describe_repo() {
234 let url = base_url().await;
235 let http_client = client();
236
237 let (session, _mock) = create_user_and_oauth_session(
238 "oauth-describe",
239 "https://example.com/callback"
240 ).await;
241
242 let describe_res = http_client
243 .get(format!("{}/xrpc/com.atproto.repo.describeRepo", url))
244 .bearer_auth(&session.access_token)
245 .query(&[("repo", session.did.as_str())])
246 .send()
247 .await
248 .expect("describeRepo failed");
249
250 assert_eq!(describe_res.status(), StatusCode::OK, "Should describe repo with OAuth token");
251
252 let describe_body: Value = describe_res.json().await.unwrap();
253 assert_eq!(describe_body["did"], session.did);
254 assert!(describe_body["handle"].is_string());
255}
256
257#[tokio::test]
258async fn test_oauth_full_post_lifecycle_create_edit_delete() {
259 let url = base_url().await;
260 let http_client = client();
261
262 let (session, _mock) = create_user_and_oauth_session(
263 "oauth-lifecycle",
264 "https://example.com/callback"
265 ).await;
266
267 let collection = "app.bsky.feed.post";
268 let original_text = "Original post content";
269
270 let create_res = http_client
271 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
272 .bearer_auth(&session.access_token)
273 .json(&json!({
274 "repo": session.did,
275 "collection": collection,
276 "record": {
277 "$type": collection,
278 "text": original_text,
279 "createdAt": Utc::now().to_rfc3339()
280 }
281 }))
282 .send()
283 .await
284 .unwrap();
285
286 assert_eq!(create_res.status(), StatusCode::OK);
287 let create_body: Value = create_res.json().await.unwrap();
288 let uri = create_body["uri"].as_str().unwrap();
289 let rkey = uri.split('/').last().unwrap();
290
291 let updated_text = "Updated post content via OAuth putRecord";
292
293 let put_res = http_client
294 .post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
295 .bearer_auth(&session.access_token)
296 .json(&json!({
297 "repo": session.did,
298 "collection": collection,
299 "rkey": rkey,
300 "record": {
301 "$type": collection,
302 "text": updated_text,
303 "createdAt": Utc::now().to_rfc3339()
304 }
305 }))
306 .send()
307 .await
308 .unwrap();
309
310 assert_eq!(put_res.status(), StatusCode::OK, "Should update record with OAuth token");
311
312 let get_res = http_client
313 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
314 .bearer_auth(&session.access_token)
315 .query(&[
316 ("repo", session.did.as_str()),
317 ("collection", collection),
318 ("rkey", rkey),
319 ])
320 .send()
321 .await
322 .unwrap();
323
324 let get_body: Value = get_res.json().await.unwrap();
325 assert_eq!(get_body["value"]["text"], updated_text, "Record should have updated text");
326
327 let delete_res = http_client
328 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
329 .bearer_auth(&session.access_token)
330 .json(&json!({
331 "repo": session.did,
332 "collection": collection,
333 "rkey": rkey
334 }))
335 .send()
336 .await
337 .unwrap();
338
339 assert_eq!(delete_res.status(), StatusCode::OK, "Should delete record with OAuth token");
340
341 let get_deleted_res = http_client
342 .get(format!("{}/xrpc/com.atproto.repo.getRecord", url))
343 .bearer_auth(&session.access_token)
344 .query(&[
345 ("repo", session.did.as_str()),
346 ("collection", collection),
347 ("rkey", rkey),
348 ])
349 .send()
350 .await
351 .unwrap();
352
353 assert!(
354 get_deleted_res.status() == StatusCode::BAD_REQUEST || get_deleted_res.status() == StatusCode::NOT_FOUND,
355 "Deleted record should not be found, got {}",
356 get_deleted_res.status()
357 );
358}
359
360#[tokio::test]
361async fn test_oauth_batch_operations_apply_writes() {
362 let url = base_url().await;
363 let http_client = client();
364
365 let (session, _mock) = create_user_and_oauth_session(
366 "oauth-batch",
367 "https://example.com/callback"
368 ).await;
369
370 let collection = "app.bsky.feed.post";
371 let now = Utc::now().to_rfc3339();
372
373 let apply_res = http_client
374 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
375 .bearer_auth(&session.access_token)
376 .json(&json!({
377 "repo": session.did,
378 "writes": [
379 {
380 "$type": "com.atproto.repo.applyWrites#create",
381 "collection": collection,
382 "rkey": "batch1",
383 "value": {
384 "$type": collection,
385 "text": "Batch post 1",
386 "createdAt": now
387 }
388 },
389 {
390 "$type": "com.atproto.repo.applyWrites#create",
391 "collection": collection,
392 "rkey": "batch2",
393 "value": {
394 "$type": collection,
395 "text": "Batch post 2",
396 "createdAt": now
397 }
398 },
399 {
400 "$type": "com.atproto.repo.applyWrites#create",
401 "collection": collection,
402 "rkey": "batch3",
403 "value": {
404 "$type": collection,
405 "text": "Batch post 3",
406 "createdAt": now
407 }
408 }
409 ]
410 }))
411 .send()
412 .await
413 .unwrap();
414
415 assert_eq!(apply_res.status(), StatusCode::OK, "Should apply batch writes with OAuth token");
416
417 let list_res = http_client
418 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
419 .bearer_auth(&session.access_token)
420 .query(&[
421 ("repo", session.did.as_str()),
422 ("collection", collection),
423 ])
424 .send()
425 .await
426 .unwrap();
427
428 assert_eq!(list_res.status(), StatusCode::OK);
429 let list_body: Value = list_res.json().await.unwrap();
430 let records = list_body["records"].as_array().unwrap();
431 assert!(records.len() >= 3, "Should have at least 3 records from batch");
432}
433
434#[tokio::test]
435async fn test_oauth_token_refresh_maintains_access() {
436 let url = base_url().await;
437 let http_client = client();
438
439 let (session, _mock) = create_user_and_oauth_session(
440 "oauth-refresh-access",
441 "https://example.com/callback"
442 ).await;
443
444 let collection = "app.bsky.feed.post";
445 let create_res = http_client
446 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
447 .bearer_auth(&session.access_token)
448 .json(&json!({
449 "repo": session.did,
450 "collection": collection,
451 "record": {
452 "$type": collection,
453 "text": "Post before refresh",
454 "createdAt": Utc::now().to_rfc3339()
455 }
456 }))
457 .send()
458 .await
459 .unwrap();
460
461 assert_eq!(create_res.status(), StatusCode::OK, "Original token should work");
462
463 let refresh_res = http_client
464 .post(format!("{}/oauth/token", url))
465 .form(&[
466 ("grant_type", "refresh_token"),
467 ("refresh_token", &session.refresh_token),
468 ("client_id", &session.client_id),
469 ])
470 .send()
471 .await
472 .unwrap();
473
474 assert_eq!(refresh_res.status(), StatusCode::OK);
475 let refresh_body: Value = refresh_res.json().await.unwrap();
476 let new_access_token = refresh_body["access_token"].as_str().unwrap();
477
478 assert_ne!(new_access_token, session.access_token, "New token should be different");
479
480 let create_res2 = http_client
481 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
482 .bearer_auth(new_access_token)
483 .json(&json!({
484 "repo": session.did,
485 "collection": collection,
486 "record": {
487 "$type": collection,
488 "text": "Post after refresh with new token",
489 "createdAt": Utc::now().to_rfc3339()
490 }
491 }))
492 .send()
493 .await
494 .unwrap();
495
496 assert_eq!(create_res2.status(), StatusCode::OK, "New token should work for creating records");
497
498 let list_res = http_client
499 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
500 .bearer_auth(new_access_token)
501 .query(&[
502 ("repo", session.did.as_str()),
503 ("collection", collection),
504 ])
505 .send()
506 .await
507 .unwrap();
508
509 assert_eq!(list_res.status(), StatusCode::OK, "New token should work for listing records");
510 let list_body: Value = list_res.json().await.unwrap();
511 let records = list_body["records"].as_array().unwrap();
512 assert_eq!(records.len(), 2, "Should have both posts");
513}
514
515#[tokio::test]
516async fn test_oauth_revoked_token_cannot_access_resources() {
517 let url = base_url().await;
518 let http_client = client();
519
520 let (session, _mock) = create_user_and_oauth_session(
521 "oauth-revoke-access",
522 "https://example.com/callback"
523 ).await;
524
525 let collection = "app.bsky.feed.post";
526 let create_res = http_client
527 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
528 .bearer_auth(&session.access_token)
529 .json(&json!({
530 "repo": session.did,
531 "collection": collection,
532 "record": {
533 "$type": collection,
534 "text": "Post before revocation",
535 "createdAt": Utc::now().to_rfc3339()
536 }
537 }))
538 .send()
539 .await
540 .unwrap();
541
542 assert_eq!(create_res.status(), StatusCode::OK, "Token should work before revocation");
543
544 let revoke_res = http_client
545 .post(format!("{}/oauth/revoke", url))
546 .form(&[("token", session.refresh_token.as_str())])
547 .send()
548 .await
549 .unwrap();
550
551 assert_eq!(revoke_res.status(), StatusCode::OK, "Revocation should succeed");
552
553 let refresh_res = http_client
554 .post(format!("{}/oauth/token", url))
555 .form(&[
556 ("grant_type", "refresh_token"),
557 ("refresh_token", &session.refresh_token),
558 ("client_id", &session.client_id),
559 ])
560 .send()
561 .await
562 .unwrap();
563
564 assert_eq!(refresh_res.status(), StatusCode::BAD_REQUEST, "Revoked refresh token should not work");
565}
566
567#[tokio::test]
568async fn test_oauth_multiple_clients_same_user() {
569 let url = base_url().await;
570 let http_client = client();
571
572 let ts = Utc::now().timestamp_millis();
573 let handle = format!("multi-client-{}", ts);
574 let email = format!("multi-client-{}@example.com", ts);
575 let password = "multi-client-password";
576
577 let create_res = http_client
578 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
579 .json(&json!({
580 "handle": handle,
581 "email": email,
582 "password": password
583 }))
584 .send()
585 .await
586 .unwrap();
587
588 assert_eq!(create_res.status(), StatusCode::OK);
589 let account: Value = create_res.json().await.unwrap();
590 let user_did = account["did"].as_str().unwrap();
591
592 let mock_client1 = setup_mock_client_metadata("https://client1.example.com/callback").await;
593 let client1_id = mock_client1.uri();
594
595 let mock_client2 = setup_mock_client_metadata("https://client2.example.com/callback").await;
596 let client2_id = mock_client2.uri();
597
598 let (verifier1, challenge1) = generate_pkce();
599 let par_res1 = http_client
600 .post(format!("{}/oauth/par", url))
601 .form(&[
602 ("response_type", "code"),
603 ("client_id", &client1_id),
604 ("redirect_uri", "https://client1.example.com/callback"),
605 ("code_challenge", &challenge1),
606 ("code_challenge_method", "S256"),
607 ])
608 .send()
609 .await
610 .unwrap();
611 let par_body1: Value = par_res1.json().await.unwrap();
612 let request_uri1 = par_body1["request_uri"].as_str().unwrap();
613
614 let auth_client = no_redirect_client();
615 let auth_res1 = auth_client
616 .post(format!("{}/oauth/authorize", url))
617 .form(&[
618 ("request_uri", request_uri1),
619 ("username", &handle),
620 ("password", password),
621 ("remember_device", "false"),
622 ])
623 .send()
624 .await
625 .unwrap();
626 let location1 = auth_res1.headers().get("location").unwrap().to_str().unwrap();
627 let code1 = location1.split("code=").nth(1).unwrap().split('&').next().unwrap();
628
629 let token_res1 = http_client
630 .post(format!("{}/oauth/token", url))
631 .form(&[
632 ("grant_type", "authorization_code"),
633 ("code", code1),
634 ("redirect_uri", "https://client1.example.com/callback"),
635 ("code_verifier", &verifier1),
636 ("client_id", &client1_id),
637 ])
638 .send()
639 .await
640 .unwrap();
641 let token_body1: Value = token_res1.json().await.unwrap();
642 let token1 = token_body1["access_token"].as_str().unwrap();
643
644 let (verifier2, challenge2) = generate_pkce();
645 let par_res2 = http_client
646 .post(format!("{}/oauth/par", url))
647 .form(&[
648 ("response_type", "code"),
649 ("client_id", &client2_id),
650 ("redirect_uri", "https://client2.example.com/callback"),
651 ("code_challenge", &challenge2),
652 ("code_challenge_method", "S256"),
653 ])
654 .send()
655 .await
656 .unwrap();
657 let par_body2: Value = par_res2.json().await.unwrap();
658 let request_uri2 = par_body2["request_uri"].as_str().unwrap();
659
660 let auth_res2 = auth_client
661 .post(format!("{}/oauth/authorize", url))
662 .form(&[
663 ("request_uri", request_uri2),
664 ("username", &handle),
665 ("password", password),
666 ("remember_device", "false"),
667 ])
668 .send()
669 .await
670 .unwrap();
671 let location2 = auth_res2.headers().get("location").unwrap().to_str().unwrap();
672 let code2 = location2.split("code=").nth(1).unwrap().split('&').next().unwrap();
673
674 let token_res2 = http_client
675 .post(format!("{}/oauth/token", url))
676 .form(&[
677 ("grant_type", "authorization_code"),
678 ("code", code2),
679 ("redirect_uri", "https://client2.example.com/callback"),
680 ("code_verifier", &verifier2),
681 ("client_id", &client2_id),
682 ])
683 .send()
684 .await
685 .unwrap();
686 let token_body2: Value = token_res2.json().await.unwrap();
687 let token2 = token_body2["access_token"].as_str().unwrap();
688
689 assert_ne!(token1, token2, "Different clients should get different tokens");
690
691 let collection = "app.bsky.feed.post";
692
693 let create_res1 = http_client
694 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
695 .bearer_auth(token1)
696 .json(&json!({
697 "repo": user_did,
698 "collection": collection,
699 "record": {
700 "$type": collection,
701 "text": "Post from client 1",
702 "createdAt": Utc::now().to_rfc3339()
703 }
704 }))
705 .send()
706 .await
707 .unwrap();
708
709 assert_eq!(create_res1.status(), StatusCode::OK, "Client 1 token should work");
710
711 let create_res2 = http_client
712 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
713 .bearer_auth(token2)
714 .json(&json!({
715 "repo": user_did,
716 "collection": collection,
717 "record": {
718 "$type": collection,
719 "text": "Post from client 2",
720 "createdAt": Utc::now().to_rfc3339()
721 }
722 }))
723 .send()
724 .await
725 .unwrap();
726
727 assert_eq!(create_res2.status(), StatusCode::OK, "Client 2 token should work");
728
729 let list_res = http_client
730 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
731 .bearer_auth(token1)
732 .query(&[
733 ("repo", user_did),
734 ("collection", collection),
735 ])
736 .send()
737 .await
738 .unwrap();
739
740 let list_body: Value = list_res.json().await.unwrap();
741 let records = list_body["records"].as_array().unwrap();
742 assert_eq!(records.len(), 2, "Both posts should be visible to either client");
743}
744
745#[tokio::test]
746async fn test_oauth_social_interactions_follow_like_repost() {
747 let url = base_url().await;
748 let http_client = client();
749
750 let (alice, _mock_alice) = create_user_and_oauth_session(
751 "alice-social",
752 "https://alice-app.example.com/callback"
753 ).await;
754
755 let (bob, _mock_bob) = create_user_and_oauth_session(
756 "bob-social",
757 "https://bob-app.example.com/callback"
758 ).await;
759
760 let post_collection = "app.bsky.feed.post";
761 let post_res = http_client
762 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
763 .bearer_auth(&alice.access_token)
764 .json(&json!({
765 "repo": alice.did,
766 "collection": post_collection,
767 "record": {
768 "$type": post_collection,
769 "text": "Hello from Alice! Looking for friends.",
770 "createdAt": Utc::now().to_rfc3339()
771 }
772 }))
773 .send()
774 .await
775 .unwrap();
776
777 assert_eq!(post_res.status(), StatusCode::OK);
778 let post_body: Value = post_res.json().await.unwrap();
779 let post_uri = post_body["uri"].as_str().unwrap();
780 let post_cid = post_body["cid"].as_str().unwrap();
781
782 let follow_collection = "app.bsky.graph.follow";
783 let follow_res = http_client
784 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
785 .bearer_auth(&bob.access_token)
786 .json(&json!({
787 "repo": bob.did,
788 "collection": follow_collection,
789 "record": {
790 "$type": follow_collection,
791 "subject": alice.did,
792 "createdAt": Utc::now().to_rfc3339()
793 }
794 }))
795 .send()
796 .await
797 .unwrap();
798
799 assert_eq!(follow_res.status(), StatusCode::OK, "Bob should be able to follow Alice via OAuth");
800
801 let like_collection = "app.bsky.feed.like";
802 let like_res = http_client
803 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
804 .bearer_auth(&bob.access_token)
805 .json(&json!({
806 "repo": bob.did,
807 "collection": like_collection,
808 "record": {
809 "$type": like_collection,
810 "subject": {
811 "uri": post_uri,
812 "cid": post_cid
813 },
814 "createdAt": Utc::now().to_rfc3339()
815 }
816 }))
817 .send()
818 .await
819 .unwrap();
820
821 assert_eq!(like_res.status(), StatusCode::OK, "Bob should be able to like Alice's post via OAuth");
822
823 let repost_collection = "app.bsky.feed.repost";
824 let repost_res = http_client
825 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
826 .bearer_auth(&bob.access_token)
827 .json(&json!({
828 "repo": bob.did,
829 "collection": repost_collection,
830 "record": {
831 "$type": repost_collection,
832 "subject": {
833 "uri": post_uri,
834 "cid": post_cid
835 },
836 "createdAt": Utc::now().to_rfc3339()
837 }
838 }))
839 .send()
840 .await
841 .unwrap();
842
843 assert_eq!(repost_res.status(), StatusCode::OK, "Bob should be able to repost Alice's post via OAuth");
844
845 let bob_follows = http_client
846 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
847 .bearer_auth(&bob.access_token)
848 .query(&[
849 ("repo", bob.did.as_str()),
850 ("collection", follow_collection),
851 ])
852 .send()
853 .await
854 .unwrap();
855
856 let follows_body: Value = bob_follows.json().await.unwrap();
857 let follows = follows_body["records"].as_array().unwrap();
858 assert_eq!(follows.len(), 1, "Bob should have 1 follow");
859 assert_eq!(follows[0]["value"]["subject"], alice.did);
860
861 let bob_likes = http_client
862 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
863 .bearer_auth(&bob.access_token)
864 .query(&[
865 ("repo", bob.did.as_str()),
866 ("collection", like_collection),
867 ])
868 .send()
869 .await
870 .unwrap();
871
872 let likes_body: Value = bob_likes.json().await.unwrap();
873 let likes = likes_body["records"].as_array().unwrap();
874 assert_eq!(likes.len(), 1, "Bob should have 1 like");
875}
876
877#[tokio::test]
878async fn test_oauth_cannot_modify_other_users_repo() {
879 let url = base_url().await;
880 let http_client = client();
881
882 let (alice, _mock_alice) = create_user_and_oauth_session(
883 "alice-boundary",
884 "https://alice.example.com/callback"
885 ).await;
886
887 let (bob, _mock_bob) = create_user_and_oauth_session(
888 "bob-boundary",
889 "https://bob.example.com/callback"
890 ).await;
891
892 let collection = "app.bsky.feed.post";
893 let malicious_res = http_client
894 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
895 .bearer_auth(&bob.access_token)
896 .json(&json!({
897 "repo": alice.did,
898 "collection": collection,
899 "record": {
900 "$type": collection,
901 "text": "Bob trying to post as Alice!",
902 "createdAt": Utc::now().to_rfc3339()
903 }
904 }))
905 .send()
906 .await
907 .unwrap();
908
909 assert_ne!(
910 malicious_res.status(),
911 StatusCode::OK,
912 "Bob should NOT be able to create records in Alice's repo"
913 );
914
915 let alice_posts = http_client
916 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
917 .bearer_auth(&alice.access_token)
918 .query(&[
919 ("repo", alice.did.as_str()),
920 ("collection", collection),
921 ])
922 .send()
923 .await
924 .unwrap();
925
926 let posts_body: Value = alice_posts.json().await.unwrap();
927 let posts = posts_body["records"].as_array().unwrap();
928 assert_eq!(posts.len(), 0, "Alice's repo should have no posts from Bob");
929}
930
931#[tokio::test]
932async fn test_oauth_session_isolation_between_users() {
933 let url = base_url().await;
934 let http_client = client();
935
936 let (alice, _mock_alice) = create_user_and_oauth_session(
937 "alice-isolation",
938 "https://alice.example.com/callback"
939 ).await;
940
941 let (bob, _mock_bob) = create_user_and_oauth_session(
942 "bob-isolation",
943 "https://bob.example.com/callback"
944 ).await;
945
946 let collection = "app.bsky.feed.post";
947
948 let alice_post = http_client
949 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
950 .bearer_auth(&alice.access_token)
951 .json(&json!({
952 "repo": alice.did,
953 "collection": collection,
954 "record": {
955 "$type": collection,
956 "text": "Alice's private thoughts",
957 "createdAt": Utc::now().to_rfc3339()
958 }
959 }))
960 .send()
961 .await
962 .unwrap();
963
964 assert_eq!(alice_post.status(), StatusCode::OK);
965
966 let bob_post = http_client
967 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
968 .bearer_auth(&bob.access_token)
969 .json(&json!({
970 "repo": bob.did,
971 "collection": collection,
972 "record": {
973 "$type": collection,
974 "text": "Bob's different thoughts",
975 "createdAt": Utc::now().to_rfc3339()
976 }
977 }))
978 .send()
979 .await
980 .unwrap();
981
982 assert_eq!(bob_post.status(), StatusCode::OK);
983
984 let alice_list = http_client
985 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
986 .bearer_auth(&alice.access_token)
987 .query(&[
988 ("repo", alice.did.as_str()),
989 ("collection", collection),
990 ])
991 .send()
992 .await
993 .unwrap();
994
995 let alice_records: Value = alice_list.json().await.unwrap();
996 let alice_posts = alice_records["records"].as_array().unwrap();
997 assert_eq!(alice_posts.len(), 1);
998 assert_eq!(alice_posts[0]["value"]["text"], "Alice's private thoughts");
999
1000 let bob_list = http_client
1001 .get(format!("{}/xrpc/com.atproto.repo.listRecords", url))
1002 .bearer_auth(&bob.access_token)
1003 .query(&[
1004 ("repo", bob.did.as_str()),
1005 ("collection", collection),
1006 ])
1007 .send()
1008 .await
1009 .unwrap();
1010
1011 let bob_records: Value = bob_list.json().await.unwrap();
1012 let bob_posts = bob_records["records"].as_array().unwrap();
1013 assert_eq!(bob_posts.len(), 1);
1014 assert_eq!(bob_posts[0]["value"]["text"], "Bob's different thoughts");
1015}
1016
1017#[tokio::test]
1018async fn test_oauth_token_works_with_sync_endpoints() {
1019 let url = base_url().await;
1020 let http_client = client();
1021
1022 let (session, _mock) = create_user_and_oauth_session(
1023 "oauth-sync",
1024 "https://example.com/callback"
1025 ).await;
1026
1027 let collection = "app.bsky.feed.post";
1028 http_client
1029 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1030 .bearer_auth(&session.access_token)
1031 .json(&json!({
1032 "repo": session.did,
1033 "collection": collection,
1034 "record": {
1035 "$type": collection,
1036 "text": "Post to sync",
1037 "createdAt": Utc::now().to_rfc3339()
1038 }
1039 }))
1040 .send()
1041 .await
1042 .unwrap();
1043
1044 let latest_commit = http_client
1045 .get(format!("{}/xrpc/com.atproto.sync.getLatestCommit", url))
1046 .query(&[("did", session.did.as_str())])
1047 .send()
1048 .await
1049 .unwrap();
1050
1051 assert_eq!(latest_commit.status(), StatusCode::OK);
1052 let commit_body: Value = latest_commit.json().await.unwrap();
1053 assert!(commit_body["cid"].is_string());
1054 assert!(commit_body["rev"].is_string());
1055
1056 let repo_status = http_client
1057 .get(format!("{}/xrpc/com.atproto.sync.getRepoStatus", url))
1058 .query(&[("did", session.did.as_str())])
1059 .send()
1060 .await
1061 .unwrap();
1062
1063 assert_eq!(repo_status.status(), StatusCode::OK);
1064 let status_body: Value = repo_status.json().await.unwrap();
1065 assert_eq!(status_body["did"], session.did);
1066 assert!(status_body["active"].as_bool().unwrap());
1067}