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