forked from
lewis.moe/bspds-sandbox
I've been saying "PDSes seem easy enough, they're what, some CRUD to a db? I can do that in my sleep". well i'm sleeping rn so let's go
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 Scope 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 #[allow(dead_code)]
47 refresh_token: String,
48 did: String,
49 #[allow(dead_code)]
50 client_id: String,
51 scope: String,
52}
53
54async fn create_user_and_oauth_session_with_scope(
55 handle_prefix: &str,
56 redirect_uri: &str,
57 scope: &str,
58) -> (OAuthSession, MockServer) {
59 let url = base_url().await;
60 let http_client = client();
61 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..4];
62 let handle = format!("{}{}", handle_prefix, suffix);
63 let email = format!("{}{}@example.com", handle_prefix, suffix);
64 let password = format!("{}Pass123!", handle_prefix);
65
66 let create_res = http_client
67 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
68 .json(&json!({
69 "handle": handle,
70 "email": email,
71 "password": password
72 }))
73 .send()
74 .await
75 .expect("Account creation failed");
76 assert_eq!(create_res.status(), StatusCode::OK);
77 let account: Value = create_res.json().await.unwrap();
78 let user_did = account["did"].as_str().unwrap().to_string();
79
80 let _ = verify_new_account(&http_client, &user_did).await;
81
82 let mock_client = setup_mock_client_metadata(redirect_uri).await;
83 let client_id = mock_client.uri();
84 let (code_verifier, code_challenge) = generate_pkce();
85
86 let par_res = http_client
87 .post(format!("{}/oauth/par", url))
88 .form(&[
89 ("response_type", "code"),
90 ("client_id", &client_id),
91 ("redirect_uri", redirect_uri),
92 ("code_challenge", &code_challenge),
93 ("code_challenge_method", "S256"),
94 ("scope", scope),
95 ])
96 .send()
97 .await
98 .expect("PAR failed");
99 assert!(
100 par_res.status() == StatusCode::OK || par_res.status() == StatusCode::CREATED,
101 "PAR should succeed, got {}",
102 par_res.status()
103 );
104 let par_body: Value = par_res.json().await.unwrap();
105 let request_uri = par_body["request_uri"].as_str().unwrap();
106
107 let auth_res = http_client
108 .post(format!("{}/oauth/authorize", url))
109 .header("Content-Type", "application/json")
110 .header("Accept", "application/json")
111 .json(&json!({
112 "request_uri": request_uri,
113 "username": &handle,
114 "password": &password,
115 "remember_device": false
116 }))
117 .send()
118 .await
119 .expect("Authorize failed");
120 assert_eq!(
121 auth_res.status(),
122 StatusCode::OK,
123 "Authorize should return OK"
124 );
125 let auth_body: Value = auth_res.json().await.unwrap();
126 let mut location = auth_body["redirect_uri"]
127 .as_str()
128 .expect("Expected redirect_uri")
129 .to_string();
130 if location.contains("/oauth/consent") {
131 let consent_res = http_client
132 .post(format!("{}/oauth/authorize/consent", url))
133 .header("Content-Type", "application/json")
134 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
135 .send().await.expect("Consent request failed");
136 assert_eq!(
137 consent_res.status(),
138 StatusCode::OK,
139 "Consent should succeed"
140 );
141 let consent_body: Value = consent_res.json().await.unwrap();
142 location = consent_body["redirect_uri"]
143 .as_str()
144 .expect("Expected redirect_uri from consent")
145 .to_string();
146 }
147 let code = location
148 .split("code=")
149 .nth(1)
150 .unwrap()
151 .split('&')
152 .next()
153 .unwrap();
154
155 let token_res = http_client
156 .post(format!("{}/oauth/token", url))
157 .form(&[
158 ("grant_type", "authorization_code"),
159 ("code", code),
160 ("redirect_uri", redirect_uri),
161 ("code_verifier", &code_verifier),
162 ("client_id", &client_id),
163 ])
164 .send()
165 .await
166 .expect("Token request failed");
167 assert_eq!(token_res.status(), StatusCode::OK);
168 let token_body: Value = token_res.json().await.unwrap();
169
170 let session = OAuthSession {
171 access_token: token_body["access_token"].as_str().unwrap().to_string(),
172 refresh_token: token_body["refresh_token"].as_str().unwrap().to_string(),
173 did: user_did,
174 client_id,
175 scope: scope.to_string(),
176 };
177 (session, mock_client)
178}
179
180#[tokio::test]
181async fn test_atproto_scope_allows_full_access() {
182 let url = base_url().await;
183 let http_client = client();
184 let (session, _mock) = create_user_and_oauth_session_with_scope(
185 "scope-full",
186 "https://example.com/callback",
187 "atproto",
188 )
189 .await;
190
191 let collection = "app.bsky.feed.post";
192 let create_res = http_client
193 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
194 .bearer_auth(&session.access_token)
195 .json(&json!({
196 "repo": session.did,
197 "collection": collection,
198 "record": {
199 "$type": collection,
200 "text": "Full access post",
201 "createdAt": Utc::now().to_rfc3339()
202 }
203 }))
204 .send()
205 .await
206 .unwrap();
207
208 assert_eq!(
209 create_res.status(),
210 StatusCode::OK,
211 "atproto scope should allow creating records"
212 );
213 let create_body: Value = create_res.json().await.unwrap();
214 let rkey = create_body["uri"]
215 .as_str()
216 .unwrap()
217 .split('/')
218 .next_back()
219 .unwrap();
220
221 let put_res = http_client
222 .post(format!("{}/xrpc/com.atproto.repo.putRecord", url))
223 .bearer_auth(&session.access_token)
224 .json(&json!({
225 "repo": session.did,
226 "collection": collection,
227 "rkey": rkey,
228 "record": {
229 "$type": collection,
230 "text": "Updated post",
231 "createdAt": Utc::now().to_rfc3339()
232 }
233 }))
234 .send()
235 .await
236 .unwrap();
237 assert_eq!(
238 put_res.status(),
239 StatusCode::OK,
240 "atproto scope should allow updating records"
241 );
242
243 let delete_res = http_client
244 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
245 .bearer_auth(&session.access_token)
246 .json(&json!({
247 "repo": session.did,
248 "collection": collection,
249 "rkey": rkey
250 }))
251 .send()
252 .await
253 .unwrap();
254 assert_eq!(
255 delete_res.status(),
256 StatusCode::OK,
257 "atproto scope should allow deleting records"
258 );
259}
260
261#[tokio::test]
262async fn test_atproto_scope_allows_blob_upload() {
263 let url = base_url().await;
264 let http_client = client();
265 let (session, _mock) = create_user_and_oauth_session_with_scope(
266 "scope-blob",
267 "https://example.com/callback",
268 "atproto",
269 )
270 .await;
271
272 let blob_data = b"Test blob data for scope test";
273 let upload_res = http_client
274 .post(format!("{}/xrpc/com.atproto.repo.uploadBlob", url))
275 .bearer_auth(&session.access_token)
276 .header("Content-Type", "text/plain")
277 .body(blob_data.to_vec())
278 .send()
279 .await
280 .unwrap();
281
282 assert_eq!(
283 upload_res.status(),
284 StatusCode::OK,
285 "atproto scope should allow blob upload"
286 );
287 let upload_body: Value = upload_res.json().await.unwrap();
288 assert!(upload_body["blob"]["ref"]["$link"].is_string());
289}
290
291#[tokio::test]
292async fn test_atproto_scope_allows_batch_writes() {
293 let url = base_url().await;
294 let http_client = client();
295 let (session, _mock) = create_user_and_oauth_session_with_scope(
296 "scope-batch",
297 "https://example.com/callback",
298 "atproto",
299 )
300 .await;
301
302 let collection = "app.bsky.feed.post";
303 let now = Utc::now().to_rfc3339();
304 let apply_res = http_client
305 .post(format!("{}/xrpc/com.atproto.repo.applyWrites", url))
306 .bearer_auth(&session.access_token)
307 .json(&json!({
308 "repo": session.did,
309 "writes": [
310 {
311 "$type": "com.atproto.repo.applyWrites#create",
312 "collection": collection,
313 "rkey": "batch-scope-1",
314 "value": {
315 "$type": collection,
316 "text": "Batch post 1",
317 "createdAt": now
318 }
319 },
320 {
321 "$type": "com.atproto.repo.applyWrites#create",
322 "collection": collection,
323 "rkey": "batch-scope-2",
324 "value": {
325 "$type": collection,
326 "text": "Batch post 2",
327 "createdAt": now
328 }
329 }
330 ]
331 }))
332 .send()
333 .await
334 .unwrap();
335
336 assert_eq!(
337 apply_res.status(),
338 StatusCode::OK,
339 "atproto scope should allow batch writes"
340 );
341}
342
343#[tokio::test]
344async fn test_transition_generic_scope_allows_access() {
345 let url = base_url().await;
346 let http_client = client();
347 let (session, _mock) = create_user_and_oauth_session_with_scope(
348 "scope-trans",
349 "https://example.com/callback",
350 "atproto transition:generic",
351 )
352 .await;
353
354 let collection = "app.bsky.feed.post";
355 let create_res = http_client
356 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
357 .bearer_auth(&session.access_token)
358 .json(&json!({
359 "repo": session.did,
360 "collection": collection,
361 "record": {
362 "$type": collection,
363 "text": "Post with transition scope",
364 "createdAt": Utc::now().to_rfc3339()
365 }
366 }))
367 .send()
368 .await
369 .unwrap();
370
371 assert_eq!(
372 create_res.status(),
373 StatusCode::OK,
374 "transition:generic scope combined with atproto should work"
375 );
376}
377
378#[tokio::test]
379async fn test_consent_endpoint_returns_scope_info() {
380 let url = base_url().await;
381 let http_client = client();
382
383 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
384 let handle = format!("ct{}", suffix);
385 let email = format!("ct{}@example.com", suffix);
386 let password = "Consent123!";
387 let redirect_uri = "https://consent-test.example.com/callback";
388
389 let create_res = http_client
390 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
391 .json(&json!({
392 "handle": handle,
393 "email": email,
394 "password": password
395 }))
396 .send()
397 .await
398 .unwrap();
399 assert_eq!(create_res.status(), StatusCode::OK);
400 let account: Value = create_res.json().await.unwrap();
401 let user_did = account["did"].as_str().unwrap();
402 let _ = verify_new_account(&http_client, user_did).await;
403
404 let mock_client = setup_mock_client_metadata(redirect_uri).await;
405 let client_id = mock_client.uri();
406 let (_, code_challenge) = generate_pkce();
407
408 let par_res = http_client
409 .post(format!("{}/oauth/par", url))
410 .form(&[
411 ("response_type", "code"),
412 ("client_id", &client_id),
413 ("redirect_uri", redirect_uri),
414 ("code_challenge", &code_challenge),
415 ("code_challenge_method", "S256"),
416 ("scope", "atproto transition:generic"),
417 ])
418 .send()
419 .await
420 .unwrap();
421 let par_body: Value = par_res.json().await.unwrap();
422 let request_uri = par_body["request_uri"].as_str().unwrap();
423
424 let auth_res = http_client
425 .post(format!("{}/oauth/authorize", url))
426 .header("Accept", "application/json")
427 .json(&json!({
428 "request_uri": request_uri,
429 "username": &handle,
430 "password": password,
431 "remember_device": false
432 }))
433 .send()
434 .await
435 .unwrap();
436 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
437
438 let consent_res = http_client
439 .get(format!("{}/oauth/authorize/consent", url))
440 .query(&[("request_uri", request_uri)])
441 .send()
442 .await
443 .unwrap();
444
445 assert_eq!(consent_res.status(), StatusCode::OK);
446 let consent_body: Value = consent_res.json().await.unwrap();
447
448 assert_eq!(consent_body["client_id"], client_id);
449 assert_eq!(consent_body["did"], user_did);
450 assert!(consent_body["scopes"].is_array());
451
452 let scopes = consent_body["scopes"].as_array().unwrap();
453 assert!(!scopes.is_empty(), "Should have scopes in response");
454
455 let atproto_scope = scopes.iter().find(|s| s["scope"] == "atproto");
456 assert!(atproto_scope.is_some(), "Should include atproto scope");
457 let atproto = atproto_scope.unwrap();
458 assert_eq!(atproto["required"], true, "atproto should be required");
459 assert!(atproto["description"].is_string());
460 assert!(atproto["display_name"].is_string());
461
462 let transition_scope = scopes.iter().find(|s| s["scope"] == "transition:generic");
463 assert!(
464 transition_scope.is_some(),
465 "Should include transition:generic scope"
466 );
467 let transition = transition_scope.unwrap();
468 assert_eq!(
469 transition["required"], false,
470 "transition:generic should be optional"
471 );
472}
473
474#[tokio::test]
475async fn test_consent_post_generates_code() {
476 let url = base_url().await;
477 let http_client = client();
478
479 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
480 let handle = format!("cp{}", suffix);
481 let email = format!("cp{}@example.com", suffix);
482 let password = "ConsentPost123!";
483 let redirect_uri = "https://consent-post.example.com/callback";
484
485 let create_res = http_client
486 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
487 .json(&json!({
488 "handle": handle,
489 "email": email,
490 "password": password
491 }))
492 .send()
493 .await
494 .unwrap();
495 assert_eq!(create_res.status(), StatusCode::OK);
496 let account: Value = create_res.json().await.unwrap();
497 let user_did = account["did"].as_str().unwrap();
498 let _ = verify_new_account(&http_client, user_did).await;
499
500 let mock_client = setup_mock_client_metadata(redirect_uri).await;
501 let client_id = mock_client.uri();
502 let (code_verifier, code_challenge) = generate_pkce();
503
504 let par_res = http_client
505 .post(format!("{}/oauth/par", url))
506 .form(&[
507 ("response_type", "code"),
508 ("client_id", &client_id),
509 ("redirect_uri", redirect_uri),
510 ("code_challenge", &code_challenge),
511 ("code_challenge_method", "S256"),
512 ("scope", "atproto"),
513 ])
514 .send()
515 .await
516 .unwrap();
517 let par_body: Value = par_res.json().await.unwrap();
518 let request_uri = par_body["request_uri"].as_str().unwrap();
519
520 let auth_res = http_client
521 .post(format!("{}/oauth/authorize", url))
522 .header("Accept", "application/json")
523 .json(&json!({
524 "request_uri": request_uri,
525 "username": &handle,
526 "password": password,
527 "remember_device": false
528 }))
529 .send()
530 .await
531 .unwrap();
532 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
533
534 let consent_post_res = http_client
535 .post(format!("{}/oauth/authorize/consent", url))
536 .json(&json!({
537 "request_uri": request_uri,
538 "approved_scopes": ["atproto"],
539 "remember": false
540 }))
541 .send()
542 .await
543 .unwrap();
544
545 assert_eq!(consent_post_res.status(), StatusCode::OK);
546 let consent_body: Value = consent_post_res.json().await.unwrap();
547 assert!(
548 consent_body["redirect_uri"].is_string(),
549 "Should return redirect URI"
550 );
551
552 let redirect_uri_response = consent_body["redirect_uri"].as_str().unwrap();
553 assert!(
554 redirect_uri_response.contains("code="),
555 "Redirect URI should contain authorization code"
556 );
557
558 let code = redirect_uri_response
559 .split("code=")
560 .nth(1)
561 .unwrap()
562 .split('&')
563 .next()
564 .unwrap();
565
566 let token_res = http_client
567 .post(format!("{}/oauth/token", url))
568 .form(&[
569 ("grant_type", "authorization_code"),
570 ("code", code),
571 ("redirect_uri", redirect_uri),
572 ("code_verifier", &code_verifier),
573 ("client_id", &client_id),
574 ])
575 .send()
576 .await
577 .unwrap();
578
579 assert_eq!(
580 token_res.status(),
581 StatusCode::OK,
582 "Token exchange should succeed"
583 );
584 let token_body: Value = token_res.json().await.unwrap();
585 assert!(token_body["access_token"].is_string());
586}
587
588#[tokio::test]
589async fn test_consent_post_requires_atproto_scope() {
590 let url = base_url().await;
591 let http_client = client();
592
593 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
594 let handle = format!("cq{}", suffix);
595 let email = format!("cq{}@example.com", suffix);
596 let password = "ConsentReq123!";
597 let redirect_uri = "https://consent-req.example.com/callback";
598
599 let create_res = http_client
600 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
601 .json(&json!({
602 "handle": handle,
603 "email": email,
604 "password": password
605 }))
606 .send()
607 .await
608 .unwrap();
609 assert_eq!(create_res.status(), StatusCode::OK);
610 let account: Value = create_res.json().await.unwrap();
611 let user_did = account["did"].as_str().unwrap();
612 let _ = verify_new_account(&http_client, user_did).await;
613
614 let mock_client = setup_mock_client_metadata(redirect_uri).await;
615 let client_id = mock_client.uri();
616 let (_, code_challenge) = generate_pkce();
617
618 let par_res = http_client
619 .post(format!("{}/oauth/par", url))
620 .form(&[
621 ("response_type", "code"),
622 ("client_id", &client_id),
623 ("redirect_uri", redirect_uri),
624 ("code_challenge", &code_challenge),
625 ("code_challenge_method", "S256"),
626 ("scope", "atproto transition:generic"),
627 ])
628 .send()
629 .await
630 .unwrap();
631 let par_body: Value = par_res.json().await.unwrap();
632 let request_uri = par_body["request_uri"].as_str().unwrap();
633
634 let auth_res = http_client
635 .post(format!("{}/oauth/authorize", url))
636 .header("Accept", "application/json")
637 .json(&json!({
638 "request_uri": request_uri,
639 "username": &handle,
640 "password": password,
641 "remember_device": false
642 }))
643 .send()
644 .await
645 .unwrap();
646 assert_eq!(auth_res.status(), StatusCode::OK, "Auth should succeed");
647
648 let consent_post_res = http_client
649 .post(format!("{}/oauth/authorize/consent", url))
650 .json(&json!({
651 "request_uri": request_uri,
652 "approved_scopes": ["transition:generic"],
653 "remember": false
654 }))
655 .send()
656 .await
657 .unwrap();
658
659 assert_eq!(
660 consent_post_res.status(),
661 StatusCode::BAD_REQUEST,
662 "Should reject consent without atproto scope"
663 );
664 let error_body: Value = consent_post_res.json().await.unwrap();
665 assert!(
666 error_body["error_description"]
667 .as_str()
668 .unwrap()
669 .contains("atproto")
670 );
671}
672
673#[tokio::test]
674async fn test_token_contains_requested_scope() {
675 let scope = "atproto transition:generic";
676 let (session, _mock) = create_user_and_oauth_session_with_scope(
677 "scope-token",
678 "https://example.com/callback",
679 scope,
680 )
681 .await;
682
683 assert_eq!(
684 session.scope, scope,
685 "Session should have the requested scope"
686 );
687
688 let parts: Vec<&str> = session.access_token.split('.').collect();
689 assert_eq!(parts.len(), 3, "Token should be a valid JWT");
690
691 let payload_json = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
692 let payload: Value = serde_json::from_slice(&payload_json).unwrap();
693
694 assert!(
695 payload["scope"].is_string(),
696 "Token payload should contain scope"
697 );
698 let token_scope = payload["scope"].as_str().unwrap();
699 assert!(
700 token_scope.contains("atproto"),
701 "Token scope should contain atproto"
702 );
703}
704
705#[tokio::test]
706async fn test_dereference_scope_endpoint() {
707 let url = base_url().await;
708 let http_client = client();
709 let (session, _mock) = create_user_and_oauth_session_with_scope(
710 "scope-deref",
711 "https://example.com/callback",
712 "atproto",
713 )
714 .await;
715
716 let deref_res = http_client
717 .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url))
718 .bearer_auth(&session.access_token)
719 .json(&json!({
720 "scope": "atproto transition:generic"
721 }))
722 .send()
723 .await
724 .unwrap();
725
726 assert_eq!(deref_res.status(), StatusCode::OK);
727 let deref_body: Value = deref_res.json().await.unwrap();
728 assert!(deref_body["scope"].is_string());
729 let resolved_scope = deref_body["scope"].as_str().unwrap();
730 assert!(resolved_scope.contains("atproto"));
731 assert!(resolved_scope.contains("transition:generic"));
732}
733
734#[tokio::test]
735async fn test_dereference_scope_requires_auth() {
736 let url = base_url().await;
737 let http_client = client();
738
739 let deref_res = http_client
740 .post(format!("{}/xrpc/com.atproto.temp.dereferenceScope", url))
741 .json(&json!({
742 "scope": "atproto"
743 }))
744 .send()
745 .await
746 .unwrap();
747
748 assert_eq!(
749 deref_res.status(),
750 StatusCode::UNAUTHORIZED,
751 "Should require authentication"
752 );
753}