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