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