this repo has no description
1mod common;
2mod helpers;
3use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
4use common::{base_url, client, get_test_db_pool};
5use helpers::verify_new_account;
6use reqwest::{StatusCode, redirect};
7use serde_json::{Value, json};
8use sha2::{Digest, Sha256};
9use wiremock::matchers::{method, path};
10use wiremock::{Mock, MockServer, ResponseTemplate};
11
12fn no_redirect_client() -> reqwest::Client {
13 reqwest::Client::builder()
14 .redirect(redirect::Policy::none())
15 .build()
16 .unwrap()
17}
18
19fn generate_pkce() -> (String, String) {
20 let verifier_bytes: [u8; 32] = rand::random();
21 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
22 let mut hasher = Sha256::new();
23 hasher.update(code_verifier.as_bytes());
24 let code_challenge = URL_SAFE_NO_PAD.encode(hasher.finalize());
25 (code_verifier, code_challenge)
26}
27
28async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
29 let mock_server = MockServer::start().await;
30 let client_id = mock_server.uri();
31 let metadata = json!({
32 "client_id": client_id,
33 "client_name": "Test OAuth Client",
34 "redirect_uris": [redirect_uri],
35 "grant_types": ["authorization_code", "refresh_token"],
36 "response_types": ["code"],
37 "token_endpoint_auth_method": "none",
38 "dpop_bound_access_tokens": false
39 });
40 Mock::given(method("GET"))
41 .and(path("/"))
42 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
43 .mount(&mock_server)
44 .await;
45 mock_server
46}
47
48#[tokio::test]
49async fn test_oauth_metadata_endpoints() {
50 let url = base_url().await;
51 let client = client();
52 let pr_res = client
53 .get(format!("{}/.well-known/oauth-protected-resource", url))
54 .send()
55 .await
56 .unwrap();
57 assert_eq!(pr_res.status(), StatusCode::OK);
58 let pr_body: Value = pr_res.json().await.unwrap();
59 assert!(pr_body["resource"].is_string());
60 assert!(pr_body["authorization_servers"].is_array());
61 assert!(
62 pr_body["bearer_methods_supported"]
63 .as_array()
64 .unwrap()
65 .contains(&json!("header"))
66 );
67 let as_res = client
68 .get(format!("{}/.well-known/oauth-authorization-server", url))
69 .send()
70 .await
71 .unwrap();
72 assert_eq!(as_res.status(), StatusCode::OK);
73 let as_body: Value = as_res.json().await.unwrap();
74 assert!(as_body["issuer"].is_string());
75 assert!(as_body["authorization_endpoint"].is_string());
76 assert!(as_body["token_endpoint"].is_string());
77 assert!(as_body["jwks_uri"].is_string());
78 assert!(
79 as_body["response_types_supported"]
80 .as_array()
81 .unwrap()
82 .contains(&json!("code"))
83 );
84 assert!(
85 as_body["grant_types_supported"]
86 .as_array()
87 .unwrap()
88 .contains(&json!("authorization_code"))
89 );
90 assert!(
91 as_body["code_challenge_methods_supported"]
92 .as_array()
93 .unwrap()
94 .contains(&json!("S256"))
95 );
96 assert_eq!(
97 as_body["require_pushed_authorization_requests"],
98 json!(true)
99 );
100 assert!(
101 as_body["dpop_signing_alg_values_supported"]
102 .as_array()
103 .unwrap()
104 .contains(&json!("ES256"))
105 );
106 let jwks_res = client
107 .get(format!("{}/oauth/jwks", url))
108 .send()
109 .await
110 .unwrap();
111 assert_eq!(jwks_res.status(), StatusCode::OK);
112 let jwks_body: Value = jwks_res.json().await.unwrap();
113 assert!(jwks_body["keys"].is_array());
114}
115
116#[tokio::test]
117async fn test_par_and_authorize() {
118 let url = base_url().await;
119 let client = client();
120 let redirect_uri = "https://example.com/callback";
121 let mock_client = setup_mock_client_metadata(redirect_uri).await;
122 let client_id = mock_client.uri();
123 let (_, code_challenge) = generate_pkce();
124 let par_res = client
125 .post(format!("{}/oauth/par", url))
126 .form(&[
127 ("response_type", "code"),
128 ("client_id", &client_id),
129 ("redirect_uri", redirect_uri),
130 ("code_challenge", &code_challenge),
131 ("code_challenge_method", "S256"),
132 ("scope", "atproto"),
133 ("state", "test-state"),
134 ])
135 .send()
136 .await
137 .unwrap();
138 assert_eq!(par_res.status(), StatusCode::CREATED, "PAR should succeed");
139 let par_body: Value = par_res.json().await.unwrap();
140 assert!(par_body["request_uri"].is_string());
141 assert!(par_body["expires_in"].is_number());
142 let request_uri = par_body["request_uri"].as_str().unwrap();
143 assert!(request_uri.starts_with("urn:ietf:params:oauth:request_uri:"));
144 let auth_res = client
145 .get(format!("{}/oauth/authorize", url))
146 .header("Accept", "application/json")
147 .query(&[("request_uri", request_uri)])
148 .send()
149 .await
150 .unwrap();
151 assert_eq!(auth_res.status(), StatusCode::OK);
152 let auth_body: Value = auth_res.json().await.unwrap();
153 assert_eq!(auth_body["client_id"], client_id);
154 assert_eq!(auth_body["redirect_uri"], redirect_uri);
155 assert_eq!(auth_body["scope"], "atproto");
156 let invalid_res = client
157 .get(format!("{}/oauth/authorize", url))
158 .header("Accept", "application/json")
159 .query(&[(
160 "request_uri",
161 "urn:ietf:params:oauth:request_uri:nonexistent",
162 )])
163 .send()
164 .await
165 .unwrap();
166 assert_eq!(invalid_res.status(), StatusCode::BAD_REQUEST);
167 let missing_client = no_redirect_client();
168 let missing_res = missing_client
169 .get(format!("{}/oauth/authorize", url))
170 .send()
171 .await
172 .unwrap();
173 assert!(
174 missing_res.status().is_redirection(),
175 "Should redirect to error page"
176 );
177 let error_location = missing_res
178 .headers()
179 .get("location")
180 .unwrap()
181 .to_str()
182 .unwrap();
183 assert!(
184 error_location.contains("oauth/error"),
185 "Should redirect to error page"
186 );
187}
188
189#[tokio::test]
190async fn test_full_oauth_flow() {
191 let url = base_url().await;
192 let http_client = client();
193 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
194 let handle = format!("ot{}", suffix);
195 let email = format!("ot{}@example.com", suffix);
196 let password = "Oauthtest123!";
197 let create_res = http_client
198 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
199 .json(&json!({ "handle": handle, "email": email, "password": password }))
200 .send()
201 .await
202 .unwrap();
203 assert_eq!(create_res.status(), StatusCode::OK);
204 let account: Value = create_res.json().await.unwrap();
205 let user_did = account["did"].as_str().unwrap();
206 verify_new_account(&http_client, user_did).await;
207 let redirect_uri = "https://example.com/oauth/callback";
208 let mock_client = setup_mock_client_metadata(redirect_uri).await;
209 let client_id = mock_client.uri();
210 let (code_verifier, code_challenge) = generate_pkce();
211 let state = format!("state-{}", suffix);
212 let par_res = http_client
213 .post(format!("{}/oauth/par", url))
214 .form(&[
215 ("response_type", "code"),
216 ("client_id", &client_id),
217 ("redirect_uri", redirect_uri),
218 ("code_challenge", &code_challenge),
219 ("code_challenge_method", "S256"),
220 ("scope", "atproto"),
221 ("state", &state),
222 ])
223 .send()
224 .await
225 .unwrap();
226 let par_body: Value = par_res.json().await.unwrap();
227 let request_uri = par_body["request_uri"].as_str().unwrap();
228 let auth_res = http_client
229 .post(format!("{}/oauth/authorize", url))
230 .header("Content-Type", "application/json")
231 .header("Accept", "application/json")
232 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
233 .send().await.unwrap();
234 assert_eq!(
235 auth_res.status(),
236 StatusCode::OK,
237 "Expected OK with JSON response"
238 );
239 let auth_body: Value = auth_res.json().await.unwrap();
240 let mut location = auth_body["redirect_uri"]
241 .as_str()
242 .expect("Expected redirect_uri in response")
243 .to_string();
244 if location.contains("/oauth/consent") {
245 let consent_res = http_client
246 .post(format!("{}/oauth/authorize/consent", url))
247 .header("Content-Type", "application/json")
248 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
249 .send().await.unwrap();
250 let consent_status = consent_res.status();
251 let consent_body: Value = consent_res.json().await.unwrap();
252 assert_eq!(
253 consent_status,
254 StatusCode::OK,
255 "Consent should succeed. Got: {:?}",
256 consent_body
257 );
258 location = consent_body["redirect_uri"]
259 .as_str()
260 .expect("Expected redirect_uri from consent")
261 .to_string();
262 }
263 assert!(
264 location.contains("code="),
265 "No code in redirect URI: {}",
266 location
267 );
268 assert!(
269 location.contains(&format!("state={}", state))
270 || location.contains(&format!("state%3D{}", state)),
271 "Wrong state in redirect: {}",
272 location
273 );
274 let code = location
275 .split("code=")
276 .nth(1)
277 .unwrap()
278 .split('&')
279 .next()
280 .unwrap();
281 let token_res = http_client
282 .post(format!("{}/oauth/token", url))
283 .form(&[
284 ("grant_type", "authorization_code"),
285 ("code", code),
286 ("redirect_uri", redirect_uri),
287 ("code_verifier", &code_verifier),
288 ("client_id", &client_id),
289 ])
290 .send()
291 .await
292 .unwrap();
293 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
294 let token_body: Value = token_res.json().await.unwrap();
295 assert!(token_body["access_token"].is_string());
296 assert!(token_body["refresh_token"].is_string());
297 assert_eq!(token_body["token_type"], "Bearer");
298 assert!(token_body["expires_in"].is_number());
299 assert_eq!(token_body["sub"], user_did);
300 let access_token = token_body["access_token"].as_str().unwrap();
301 let refresh_token = token_body["refresh_token"].as_str().unwrap();
302 let refresh_res = http_client
303 .post(format!("{}/oauth/token", url))
304 .form(&[
305 ("grant_type", "refresh_token"),
306 ("refresh_token", refresh_token),
307 ("client_id", &client_id),
308 ])
309 .send()
310 .await
311 .unwrap();
312 assert_eq!(refresh_res.status(), StatusCode::OK);
313 let refresh_body: Value = refresh_res.json().await.unwrap();
314 assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token);
315 assert_ne!(
316 refresh_body["refresh_token"].as_str().unwrap(),
317 refresh_token
318 );
319 let introspect_res = http_client
320 .post(format!("{}/oauth/introspect", url))
321 .form(&[("token", refresh_body["access_token"].as_str().unwrap())])
322 .send()
323 .await
324 .unwrap();
325 assert_eq!(introspect_res.status(), StatusCode::OK);
326 let introspect_body: Value = introspect_res.json().await.unwrap();
327 assert_eq!(introspect_body["active"], true);
328 let revoke_res = http_client
329 .post(format!("{}/oauth/revoke", url))
330 .form(&[("token", refresh_body["refresh_token"].as_str().unwrap())])
331 .send()
332 .await
333 .unwrap();
334 assert_eq!(revoke_res.status(), StatusCode::OK);
335 let introspect_after = http_client
336 .post(format!("{}/oauth/introspect", url))
337 .form(&[("token", refresh_body["access_token"].as_str().unwrap())])
338 .send()
339 .await
340 .unwrap();
341 let after_body: Value = introspect_after.json().await.unwrap();
342 assert_eq!(
343 after_body["active"], false,
344 "Revoked token should be inactive"
345 );
346}
347
348#[tokio::test]
349async fn test_oauth_error_cases() {
350 let url = base_url().await;
351 let http_client = client();
352 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
353 let handle = format!("wc{}", suffix);
354 let email = format!("wc{}@example.com", suffix);
355 http_client
356 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
357 .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" }))
358 .send()
359 .await
360 .unwrap();
361 let redirect_uri = "https://example.com/callback";
362 let mock_client = setup_mock_client_metadata(redirect_uri).await;
363 let client_id = mock_client.uri();
364 let (_, code_challenge) = generate_pkce();
365 let par_body: Value = http_client
366 .post(format!("{}/oauth/par", url))
367 .form(&[
368 ("response_type", "code"),
369 ("client_id", &client_id),
370 ("redirect_uri", redirect_uri),
371 ("code_challenge", &code_challenge),
372 ("code_challenge_method", "S256"),
373 ])
374 .send()
375 .await
376 .unwrap()
377 .json()
378 .await
379 .unwrap();
380 let request_uri = par_body["request_uri"].as_str().unwrap();
381 let auth_res = http_client
382 .post(format!("{}/oauth/authorize", url))
383 .header("Content-Type", "application/json")
384 .header("Accept", "application/json")
385 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false}))
386 .send().await.unwrap();
387 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN);
388 let error_body: Value = auth_res.json().await.unwrap();
389 assert_eq!(error_body["error"], "access_denied");
390 let unsupported = http_client
391 .post(format!("{}/oauth/token", url))
392 .form(&[
393 ("grant_type", "client_credentials"),
394 ("client_id", "https://example.com"),
395 ])
396 .send()
397 .await
398 .unwrap();
399 assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST);
400 let body: Value = unsupported.json().await.unwrap();
401 assert_eq!(body["error"], "unsupported_grant_type");
402 let invalid_refresh = http_client
403 .post(format!("{}/oauth/token", url))
404 .form(&[
405 ("grant_type", "refresh_token"),
406 ("refresh_token", "invalid-token"),
407 ("client_id", "https://example.com"),
408 ])
409 .send()
410 .await
411 .unwrap();
412 assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST);
413 let body: Value = invalid_refresh.json().await.unwrap();
414 assert_eq!(body["error"], "invalid_grant");
415 let invalid_introspect = http_client
416 .post(format!("{}/oauth/introspect", url))
417 .form(&[("token", "invalid.token.here")])
418 .send()
419 .await
420 .unwrap();
421 assert_eq!(invalid_introspect.status(), StatusCode::OK);
422 let body: Value = invalid_introspect.json().await.unwrap();
423 assert_eq!(body["active"], false);
424 let expired_res = http_client
425 .get(format!("{}/oauth/authorize", url))
426 .header("Accept", "application/json")
427 .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")])
428 .send()
429 .await
430 .unwrap();
431 assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST);
432}
433
434#[tokio::test]
435async fn test_oauth_2fa_flow() {
436 let url = base_url().await;
437 let http_client = client();
438 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
439 let handle = format!("ft{}", suffix);
440 let email = format!("ft{}@example.com", suffix);
441 let password = "Twofa123test!";
442 let create_res = http_client
443 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
444 .json(&json!({ "handle": handle, "email": email, "password": password }))
445 .send()
446 .await
447 .unwrap();
448 assert_eq!(create_res.status(), StatusCode::OK);
449 let account: Value = create_res.json().await.unwrap();
450 let user_did = account["did"].as_str().unwrap();
451 verify_new_account(&http_client, user_did).await;
452 let pool = get_test_db_pool().await;
453 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
454 .bind(user_did)
455 .execute(pool)
456 .await
457 .unwrap();
458 let redirect_uri = "https://example.com/2fa-callback";
459 let mock_client = setup_mock_client_metadata(redirect_uri).await;
460 let client_id = mock_client.uri();
461 let (code_verifier, code_challenge) = generate_pkce();
462 let par_body: Value = http_client
463 .post(format!("{}/oauth/par", url))
464 .form(&[
465 ("response_type", "code"),
466 ("client_id", &client_id),
467 ("redirect_uri", redirect_uri),
468 ("code_challenge", &code_challenge),
469 ("code_challenge_method", "S256"),
470 ])
471 .send()
472 .await
473 .unwrap()
474 .json()
475 .await
476 .unwrap();
477 let request_uri = par_body["request_uri"].as_str().unwrap();
478 let auth_res = http_client
479 .post(format!("{}/oauth/authorize", url))
480 .header("Content-Type", "application/json")
481 .header("Accept", "application/json")
482 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
483 .send().await.unwrap();
484 assert_eq!(
485 auth_res.status(),
486 StatusCode::OK,
487 "Should return OK with needs_2fa"
488 );
489 let auth_body: Value = auth_res.json().await.unwrap();
490 assert!(
491 auth_body["needs_2fa"].as_bool().unwrap_or(false),
492 "Should need 2FA, got: {:?}",
493 auth_body
494 );
495 let twofa_invalid = http_client
496 .post(format!("{}/oauth/authorize/2fa", url))
497 .header("Content-Type", "application/json")
498 .json(&json!({"request_uri": request_uri, "code": "000000"}))
499 .send()
500 .await
501 .unwrap();
502 assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN);
503 let body: Value = twofa_invalid.json().await.unwrap();
504 assert!(
505 body["error_description"]
506 .as_str()
507 .unwrap_or("")
508 .contains("Invalid")
509 || body["error"].as_str().unwrap_or("") == "invalid_code"
510 );
511 let twofa_code: String =
512 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
513 .bind(request_uri)
514 .fetch_one(pool)
515 .await
516 .unwrap();
517 let twofa_res = http_client
518 .post(format!("{}/oauth/authorize/2fa", url))
519 .header("Content-Type", "application/json")
520 .json(&json!({"request_uri": request_uri, "code": &twofa_code}))
521 .send()
522 .await
523 .unwrap();
524 assert_eq!(
525 twofa_res.status(),
526 StatusCode::OK,
527 "Valid 2FA code should succeed"
528 );
529 let twofa_body: Value = twofa_res.json().await.unwrap();
530 let final_location = twofa_body["redirect_uri"].as_str().unwrap();
531 assert!(
532 final_location.contains("code="),
533 "No code in redirect URI: {}",
534 final_location
535 );
536 let auth_code = final_location
537 .split("code=")
538 .nth(1)
539 .unwrap()
540 .split('&')
541 .next()
542 .unwrap();
543 let token_res = http_client
544 .post(format!("{}/oauth/token", url))
545 .form(&[
546 ("grant_type", "authorization_code"),
547 ("code", auth_code),
548 ("redirect_uri", redirect_uri),
549 ("code_verifier", &code_verifier),
550 ("client_id", &client_id),
551 ])
552 .send()
553 .await
554 .unwrap();
555 assert_eq!(token_res.status(), StatusCode::OK);
556 let token_body: Value = token_res.json().await.unwrap();
557 assert_eq!(token_body["sub"], user_did);
558}
559
560#[tokio::test]
561async fn test_oauth_2fa_lockout() {
562 let url = base_url().await;
563 let http_client = client();
564 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
565 let handle = format!("fl{}", suffix);
566 let email = format!("fl{}@example.com", suffix);
567 let password = "Twofa123test!";
568 let create_res = http_client
569 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
570 .json(&json!({ "handle": handle, "email": email, "password": password }))
571 .send()
572 .await
573 .unwrap();
574 let account: Value = create_res.json().await.unwrap();
575 let user_did = account["did"].as_str().unwrap();
576 verify_new_account(&http_client, user_did).await;
577 let pool = get_test_db_pool().await;
578 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
579 .bind(user_did)
580 .execute(pool)
581 .await
582 .unwrap();
583 let redirect_uri = "https://example.com/2fa-lockout-callback";
584 let mock_client = setup_mock_client_metadata(redirect_uri).await;
585 let client_id = mock_client.uri();
586 let (_, code_challenge) = generate_pkce();
587 let par_body: Value = http_client
588 .post(format!("{}/oauth/par", url))
589 .form(&[
590 ("response_type", "code"),
591 ("client_id", &client_id),
592 ("redirect_uri", redirect_uri),
593 ("code_challenge", &code_challenge),
594 ("code_challenge_method", "S256"),
595 ])
596 .send()
597 .await
598 .unwrap()
599 .json()
600 .await
601 .unwrap();
602 let request_uri = par_body["request_uri"].as_str().unwrap();
603 let auth_res = http_client
604 .post(format!("{}/oauth/authorize", url))
605 .header("Content-Type", "application/json")
606 .header("Accept", "application/json")
607 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
608 .send().await.unwrap();
609 assert_eq!(
610 auth_res.status(),
611 StatusCode::OK,
612 "Should return OK with needs_2fa"
613 );
614 let auth_body: Value = auth_res.json().await.unwrap();
615 assert!(
616 auth_body["needs_2fa"].as_bool().unwrap_or(false),
617 "Should need 2FA"
618 );
619 for i in 0..5 {
620 let res = http_client
621 .post(format!("{}/oauth/authorize/2fa", url))
622 .header("Content-Type", "application/json")
623 .json(&json!({"request_uri": request_uri, "code": "999999"}))
624 .send()
625 .await
626 .unwrap();
627 if i < 4 {
628 assert_eq!(
629 res.status(),
630 StatusCode::FORBIDDEN,
631 "Attempt {} should return 403",
632 i
633 );
634 }
635 }
636 let lockout_res = http_client
637 .post(format!("{}/oauth/authorize/2fa", url))
638 .header("Content-Type", "application/json")
639 .json(&json!({"request_uri": request_uri, "code": "999999"}))
640 .send()
641 .await
642 .unwrap();
643 let body: Value = lockout_res.json().await.unwrap();
644 let desc = body["error_description"].as_str().unwrap_or("");
645 assert!(
646 desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request",
647 "Expected lockout error, got: {:?}",
648 body
649 );
650}
651
652#[tokio::test]
653async fn test_account_selector_with_2fa() {
654 let url = base_url().await;
655 let http_client = client();
656 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
657 let handle = format!("sf{}", suffix);
658 let email = format!("sf{}@example.com", suffix);
659 let password = "Selector2fa123!";
660 let create_res = http_client
661 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
662 .json(&json!({ "handle": handle, "email": email, "password": password }))
663 .send()
664 .await
665 .unwrap();
666 let account: Value = create_res.json().await.unwrap();
667 let user_did = account["did"].as_str().unwrap().to_string();
668 verify_new_account(&http_client, &user_did).await;
669 let redirect_uri = "https://example.com/selector-2fa-callback";
670 let mock_client = setup_mock_client_metadata(redirect_uri).await;
671 let client_id = mock_client.uri();
672 let (code_verifier, code_challenge) = generate_pkce();
673 let par_body: Value = http_client
674 .post(format!("{}/oauth/par", url))
675 .form(&[
676 ("response_type", "code"),
677 ("client_id", &client_id),
678 ("redirect_uri", redirect_uri),
679 ("code_challenge", &code_challenge),
680 ("code_challenge_method", "S256"),
681 ])
682 .send()
683 .await
684 .unwrap()
685 .json()
686 .await
687 .unwrap();
688 let request_uri = par_body["request_uri"].as_str().unwrap();
689 let auth_res = http_client
690 .post(format!("{}/oauth/authorize", url))
691 .header("Content-Type", "application/json")
692 .header("Accept", "application/json")
693 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true}))
694 .send().await.unwrap();
695 assert_eq!(
696 auth_res.status(),
697 StatusCode::OK,
698 "Expected OK with JSON response"
699 );
700 let device_cookie = auth_res
701 .headers()
702 .get("set-cookie")
703 .and_then(|v| v.to_str().ok())
704 .map(|s| s.split(';').next().unwrap_or("").to_string())
705 .expect("Should have device cookie");
706 let auth_body: Value = auth_res.json().await.unwrap();
707 let mut location = auth_body["redirect_uri"]
708 .as_str()
709 .expect("Expected redirect_uri")
710 .to_string();
711 if location.contains("/oauth/consent") {
712 let consent_res = http_client
713 .post(format!("{}/oauth/authorize/consent", url))
714 .header("Content-Type", "application/json")
715 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true}))
716 .send().await.unwrap();
717 assert_eq!(
718 consent_res.status(),
719 StatusCode::OK,
720 "Consent should succeed"
721 );
722 let consent_body: Value = consent_res.json().await.unwrap();
723 location = consent_body["redirect_uri"]
724 .as_str()
725 .expect("Expected redirect_uri from consent")
726 .to_string();
727 }
728 assert!(location.contains("code="));
729 let code = location
730 .split("code=")
731 .nth(1)
732 .unwrap()
733 .split('&')
734 .next()
735 .unwrap();
736 let _ = http_client
737 .post(format!("{}/oauth/token", url))
738 .form(&[
739 ("grant_type", "authorization_code"),
740 ("code", code),
741 ("redirect_uri", redirect_uri),
742 ("code_verifier", &code_verifier),
743 ("client_id", &client_id),
744 ])
745 .send()
746 .await
747 .unwrap()
748 .json::<Value>()
749 .await
750 .unwrap();
751 let pool = get_test_db_pool().await;
752 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
753 .bind(&user_did)
754 .execute(pool)
755 .await
756 .unwrap();
757 let (code_verifier2, code_challenge2) = generate_pkce();
758 let par_body2: Value = http_client
759 .post(format!("{}/oauth/par", url))
760 .form(&[
761 ("response_type", "code"),
762 ("client_id", &client_id),
763 ("redirect_uri", redirect_uri),
764 ("code_challenge", &code_challenge2),
765 ("code_challenge_method", "S256"),
766 ])
767 .send()
768 .await
769 .unwrap()
770 .json()
771 .await
772 .unwrap();
773 let request_uri2 = par_body2["request_uri"].as_str().unwrap();
774 let select_res = http_client
775 .post(format!("{}/oauth/authorize/select", url))
776 .header("cookie", &device_cookie)
777 .header("Content-Type", "application/json")
778 .json(&json!({"request_uri": request_uri2, "did": &user_did}))
779 .send()
780 .await
781 .unwrap();
782 assert_eq!(
783 select_res.status(),
784 StatusCode::OK,
785 "Select should return OK with JSON"
786 );
787 let select_body: Value = select_res.json().await.unwrap();
788 assert!(
789 select_body["needs_2fa"].as_bool().unwrap_or(false),
790 "Should need 2FA"
791 );
792 let twofa_code: String =
793 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
794 .bind(request_uri2)
795 .fetch_one(pool)
796 .await
797 .unwrap();
798 let twofa_res = http_client
799 .post(format!("{}/oauth/authorize/2fa", url))
800 .header("cookie", &device_cookie)
801 .header("Content-Type", "application/json")
802 .json(&json!({"request_uri": request_uri2, "code": &twofa_code}))
803 .send()
804 .await
805 .unwrap();
806 assert_eq!(
807 twofa_res.status(),
808 StatusCode::OK,
809 "Valid 2FA should succeed"
810 );
811 let twofa_body: Value = twofa_res.json().await.unwrap();
812 let final_location = twofa_body["redirect_uri"].as_str().unwrap();
813 assert!(
814 final_location.contains("code="),
815 "No code in redirect URI: {}",
816 final_location
817 );
818 let final_code = final_location
819 .split("code=")
820 .nth(1)
821 .unwrap()
822 .split('&')
823 .next()
824 .unwrap();
825 let token_res = http_client
826 .post(format!("{}/oauth/token", url))
827 .form(&[
828 ("grant_type", "authorization_code"),
829 ("code", final_code),
830 ("redirect_uri", redirect_uri),
831 ("code_verifier", &code_verifier2),
832 ("client_id", &client_id),
833 ])
834 .send()
835 .await
836 .unwrap();
837 assert_eq!(token_res.status(), StatusCode::OK);
838 let final_token: Value = token_res.json().await.unwrap();
839 assert_eq!(final_token["sub"], user_did);
840}
841
842#[tokio::test]
843async fn test_oauth_state_encoding() {
844 let url = base_url().await;
845 let http_client = client();
846 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
847 let handle = format!("ss{}", suffix);
848 let email = format!("ss{}@example.com", suffix);
849 let password = "State123special!";
850 let create_res = http_client
851 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
852 .json(&json!({ "handle": handle, "email": email, "password": password }))
853 .send()
854 .await
855 .unwrap();
856 let account: Value = create_res.json().await.unwrap();
857 verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
858 let redirect_uri = "https://example.com/state-special-callback";
859 let mock_client = setup_mock_client_metadata(redirect_uri).await;
860 let client_id = mock_client.uri();
861 let (_, code_challenge) = generate_pkce();
862 let special_state = "state=with&special=chars&plus+more";
863 let par_body: Value = http_client
864 .post(format!("{}/oauth/par", url))
865 .form(&[
866 ("response_type", "code"),
867 ("client_id", &client_id),
868 ("redirect_uri", redirect_uri),
869 ("code_challenge", &code_challenge),
870 ("code_challenge_method", "S256"),
871 ("state", special_state),
872 ])
873 .send()
874 .await
875 .unwrap()
876 .json()
877 .await
878 .unwrap();
879 let request_uri = par_body["request_uri"].as_str().unwrap();
880 let auth_res = http_client
881 .post(format!("{}/oauth/authorize", url))
882 .header("Content-Type", "application/json")
883 .header("Accept", "application/json")
884 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
885 .send().await.unwrap();
886 assert_eq!(
887 auth_res.status(),
888 StatusCode::OK,
889 "Expected OK with JSON response"
890 );
891 let auth_body: Value = auth_res.json().await.unwrap();
892 let mut location = auth_body["redirect_uri"]
893 .as_str()
894 .expect("Expected redirect_uri")
895 .to_string();
896 if location.contains("/oauth/consent") {
897 let consent_res = http_client
898 .post(format!("{}/oauth/authorize/consent", url))
899 .header("Content-Type", "application/json")
900 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
901 .send().await.unwrap();
902 assert_eq!(
903 consent_res.status(),
904 StatusCode::OK,
905 "Consent should succeed"
906 );
907 let consent_body: Value = consent_res.json().await.unwrap();
908 location = consent_body["redirect_uri"]
909 .as_str()
910 .expect("Expected redirect_uri from consent")
911 .to_string();
912 }
913 assert!(location.contains("state="));
914 let encoded_state = urlencoding::encode(special_state);
915 assert!(
916 location.contains(&format!("state={}", encoded_state)),
917 "State should be URL-encoded. Got: {}",
918 location
919 );
920}
921
922async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) {
923 let url = base_url().await;
924 let http_client = client();
925 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
926 let handle = format!("st{}", suffix);
927 let email = format!("st{}@example.com", suffix);
928 let password = "Scopetest123!";
929 let create_res = http_client
930 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
931 .json(&json!({ "handle": handle, "email": email, "password": password }))
932 .send()
933 .await
934 .unwrap();
935 assert_eq!(create_res.status(), StatusCode::OK);
936 let account: Value = create_res.json().await.unwrap();
937 let user_did = account["did"].as_str().unwrap().to_string();
938 verify_new_account(&http_client, &user_did).await;
939 let redirect_uri = "https://example.com/scope-callback";
940 let mock_client = setup_mock_client_metadata(redirect_uri).await;
941 let client_id = mock_client.uri();
942 let (code_verifier, code_challenge) = generate_pkce();
943 let par_res = http_client
944 .post(format!("{}/oauth/par", url))
945 .form(&[
946 ("response_type", "code"),
947 ("client_id", &client_id),
948 ("redirect_uri", redirect_uri),
949 ("code_challenge", &code_challenge),
950 ("code_challenge_method", "S256"),
951 ("scope", scope),
952 ("state", "test"),
953 ])
954 .send()
955 .await
956 .unwrap();
957 assert_eq!(
958 par_res.status(),
959 StatusCode::CREATED,
960 "PAR should succeed for scope: {}",
961 scope
962 );
963 let par_body: Value = par_res.json().await.unwrap();
964 let request_uri = par_body["request_uri"].as_str().unwrap();
965 let auth_res = http_client
966 .post(format!("{}/oauth/authorize", url))
967 .header("Content-Type", "application/json")
968 .header("Accept", "application/json")
969 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
970 .send().await.unwrap();
971 assert_eq!(auth_res.status(), StatusCode::OK);
972 let auth_body: Value = auth_res.json().await.unwrap();
973 let mut location = auth_body["redirect_uri"]
974 .as_str()
975 .expect("Expected redirect_uri")
976 .to_string();
977 if location.contains("/oauth/consent") {
978 let approved_scopes: Vec<&str> = scope.split_whitespace().collect();
979 let consent_res = http_client
980 .post(format!("{}/oauth/authorize/consent", url))
981 .header("Content-Type", "application/json")
982 .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false}))
983 .send().await.unwrap();
984 let consent_status = consent_res.status();
985 let consent_body: Value = consent_res.json().await.unwrap();
986 assert_eq!(
987 consent_status,
988 StatusCode::OK,
989 "Consent should succeed. Scope: {}, Body: {:?}",
990 scope,
991 consent_body
992 );
993 location = consent_body["redirect_uri"]
994 .as_str()
995 .expect("Expected redirect_uri from consent")
996 .to_string();
997 }
998 let code = location
999 .split("code=")
1000 .nth(1)
1001 .unwrap()
1002 .split('&')
1003 .next()
1004 .unwrap();
1005 let token_res = http_client
1006 .post(format!("{}/oauth/token", url))
1007 .form(&[
1008 ("grant_type", "authorization_code"),
1009 ("code", code),
1010 ("redirect_uri", redirect_uri),
1011 ("code_verifier", &code_verifier),
1012 ("client_id", &client_id),
1013 ])
1014 .send()
1015 .await
1016 .unwrap();
1017 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
1018 let token_body: Value = token_res.json().await.unwrap();
1019 let access_token = token_body["access_token"].as_str().unwrap().to_string();
1020 (access_token, user_did, handle)
1021}
1022
1023#[tokio::test]
1024async fn test_granular_scope_repo_create_only() {
1025 let url = base_url().await;
1026 let http_client = client();
1027 let (token, did, _) =
1028 get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await;
1029 let now = chrono::Utc::now().to_rfc3339();
1030 let create_res = http_client
1031 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1032 .bearer_auth(&token)
1033 .json(&json!({
1034 "repo": &did,
1035 "collection": "app.bsky.feed.post",
1036 "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now }
1037 }))
1038 .send()
1039 .await
1040 .unwrap();
1041 assert_eq!(
1042 create_res.status(),
1043 StatusCode::OK,
1044 "Should allow creating posts with repo:app.bsky.feed.post?action=create"
1045 );
1046 let body: Value = create_res.json().await.unwrap();
1047 let uri = body["uri"].as_str().expect("Should have uri");
1048 let rkey = uri.split('/').next_back().unwrap();
1049 let delete_res = http_client
1050 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
1051 .bearer_auth(&token)
1052 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
1053 .send()
1054 .await
1055 .unwrap();
1056 assert_eq!(
1057 delete_res.status(),
1058 StatusCode::FORBIDDEN,
1059 "Should NOT allow deleting with create-only scope"
1060 );
1061 let like_res = http_client
1062 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1063 .bearer_auth(&token)
1064 .json(&json!({
1065 "repo": &did,
1066 "collection": "app.bsky.feed.like",
1067 "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now }
1068 }))
1069 .send().await.unwrap();
1070 assert_eq!(
1071 like_res.status(),
1072 StatusCode::FORBIDDEN,
1073 "Should NOT allow creating likes (wrong collection)"
1074 );
1075}
1076
1077#[tokio::test]
1078async fn test_granular_scope_wildcard_collection() {
1079 let url = base_url().await;
1080 let http_client = client();
1081 let (token, did, _) = get_oauth_token_with_scope(
1082 "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*",
1083 )
1084 .await;
1085 let now = chrono::Utc::now().to_rfc3339();
1086 let post_res = http_client
1087 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1088 .bearer_auth(&token)
1089 .json(&json!({
1090 "repo": &did,
1091 "collection": "app.bsky.feed.post",
1092 "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now }
1093 }))
1094 .send()
1095 .await
1096 .unwrap();
1097 assert_eq!(
1098 post_res.status(),
1099 StatusCode::OK,
1100 "Should allow app.bsky.feed.post with app.bsky.* scope"
1101 );
1102 let body: Value = post_res.json().await.unwrap();
1103 let uri = body["uri"].as_str().unwrap();
1104 let rkey = uri.split('/').next_back().unwrap();
1105 let delete_res = http_client
1106 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
1107 .bearer_auth(&token)
1108 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
1109 .send()
1110 .await
1111 .unwrap();
1112 assert_eq!(
1113 delete_res.status(),
1114 StatusCode::OK,
1115 "Should allow delete with action=delete"
1116 );
1117 let other_res = http_client
1118 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1119 .bearer_auth(&token)
1120 .json(&json!({
1121 "repo": &did,
1122 "collection": "com.example.record",
1123 "record": { "$type": "com.example.record", "data": "test", "createdAt": now }
1124 }))
1125 .send()
1126 .await
1127 .unwrap();
1128 assert_eq!(
1129 other_res.status(),
1130 StatusCode::FORBIDDEN,
1131 "Should NOT allow com.example.* with app.bsky.* scope"
1132 );
1133}
1134
1135#[tokio::test]
1136async fn test_granular_scope_email_read() {
1137 let url = base_url().await;
1138 let http_client = client();
1139 let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await;
1140 let session_res = http_client
1141 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1142 .bearer_auth(&token)
1143 .send()
1144 .await
1145 .unwrap();
1146 assert_eq!(session_res.status(), StatusCode::OK);
1147 let body: Value = session_res.json().await.unwrap();
1148 assert_eq!(body["did"], did);
1149 assert!(
1150 body["email"].is_string(),
1151 "Email should be visible with account:email?action=read. Got: {:?}",
1152 body
1153 );
1154}
1155
1156#[tokio::test]
1157async fn test_granular_scope_no_email_access() {
1158 let url = base_url().await;
1159 let http_client = client();
1160 let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await;
1161 let session_res = http_client
1162 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1163 .bearer_auth(&token)
1164 .send()
1165 .await
1166 .unwrap();
1167 assert_eq!(session_res.status(), StatusCode::OK);
1168 let body: Value = session_res.json().await.unwrap();
1169 assert_eq!(body["did"], did);
1170 assert!(
1171 body["email"].is_null() || body.get("email").is_none(),
1172 "Email should be hidden without account:email scope. Got: {:?}",
1173 body["email"]
1174 );
1175}
1176
1177#[tokio::test]
1178async fn test_granular_scope_rpc_specific_method() {
1179 let url = base_url().await;
1180 let http_client = client();
1181 let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await;
1182 let allowed_res = http_client
1183 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1184 .bearer_auth(&token)
1185 .query(&[
1186 ("aud", "did:web:api.bsky.app"),
1187 ("lxm", "app.bsky.feed.getTimeline"),
1188 ])
1189 .send()
1190 .await
1191 .unwrap();
1192 assert_eq!(
1193 allowed_res.status(),
1194 StatusCode::OK,
1195 "Should allow getServiceAuth for app.bsky.feed.getTimeline"
1196 );
1197 let body: Value = allowed_res.json().await.unwrap();
1198 assert!(body["token"].is_string(), "Should return service token");
1199 let blocked_res = http_client
1200 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1201 .bearer_auth(&token)
1202 .query(&[
1203 ("aud", "did:web:api.bsky.app"),
1204 ("lxm", "app.bsky.feed.getAuthorFeed"),
1205 ])
1206 .send()
1207 .await
1208 .unwrap();
1209 assert_eq!(
1210 blocked_res.status(),
1211 StatusCode::FORBIDDEN,
1212 "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed"
1213 );
1214 let blocked_body: Value = blocked_res.json().await.unwrap();
1215 assert!(
1216 blocked_body["error"]
1217 .as_str()
1218 .unwrap_or("")
1219 .contains("Scope")
1220 || blocked_body["message"]
1221 .as_str()
1222 .unwrap_or("")
1223 .contains("scope"),
1224 "Should mention scope restriction: {:?}",
1225 blocked_body
1226 );
1227 let no_lxm_res = http_client
1228 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1229 .bearer_auth(&token)
1230 .query(&[("aud", "did:web:api.bsky.app")])
1231 .send()
1232 .await
1233 .unwrap();
1234 assert_eq!(
1235 no_lxm_res.status(),
1236 StatusCode::BAD_REQUEST,
1237 "Should require lxm parameter for granular scopes"
1238 );
1239}