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.starts_with(redirect_uri),
265 "Redirect to wrong URI: {}",
266 location
267 );
268 assert!(location.contains("code="), "No code in redirect");
269 assert!(
270 location.contains(&format!("state={}", state)),
271 "Wrong state"
272 );
273 let code = location
274 .split("code=")
275 .nth(1)
276 .unwrap()
277 .split('&')
278 .next()
279 .unwrap();
280 let token_res = http_client
281 .post(format!("{}/oauth/token", url))
282 .form(&[
283 ("grant_type", "authorization_code"),
284 ("code", code),
285 ("redirect_uri", redirect_uri),
286 ("code_verifier", &code_verifier),
287 ("client_id", &client_id),
288 ])
289 .send()
290 .await
291 .unwrap();
292 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
293 let token_body: Value = token_res.json().await.unwrap();
294 assert!(token_body["access_token"].is_string());
295 assert!(token_body["refresh_token"].is_string());
296 assert_eq!(token_body["token_type"], "Bearer");
297 assert!(token_body["expires_in"].is_number());
298 assert_eq!(token_body["sub"], user_did);
299 let access_token = token_body["access_token"].as_str().unwrap();
300 let refresh_token = token_body["refresh_token"].as_str().unwrap();
301 let refresh_res = http_client
302 .post(format!("{}/oauth/token", url))
303 .form(&[
304 ("grant_type", "refresh_token"),
305 ("refresh_token", refresh_token),
306 ("client_id", &client_id),
307 ])
308 .send()
309 .await
310 .unwrap();
311 assert_eq!(refresh_res.status(), StatusCode::OK);
312 let refresh_body: Value = refresh_res.json().await.unwrap();
313 assert_ne!(refresh_body["access_token"].as_str().unwrap(), access_token);
314 assert_ne!(
315 refresh_body["refresh_token"].as_str().unwrap(),
316 refresh_token
317 );
318 let introspect_res = http_client
319 .post(format!("{}/oauth/introspect", url))
320 .form(&[("token", refresh_body["access_token"].as_str().unwrap())])
321 .send()
322 .await
323 .unwrap();
324 assert_eq!(introspect_res.status(), StatusCode::OK);
325 let introspect_body: Value = introspect_res.json().await.unwrap();
326 assert_eq!(introspect_body["active"], true);
327 let revoke_res = http_client
328 .post(format!("{}/oauth/revoke", url))
329 .form(&[("token", refresh_body["refresh_token"].as_str().unwrap())])
330 .send()
331 .await
332 .unwrap();
333 assert_eq!(revoke_res.status(), StatusCode::OK);
334 let introspect_after = http_client
335 .post(format!("{}/oauth/introspect", url))
336 .form(&[("token", refresh_body["access_token"].as_str().unwrap())])
337 .send()
338 .await
339 .unwrap();
340 let after_body: Value = introspect_after.json().await.unwrap();
341 assert_eq!(
342 after_body["active"], false,
343 "Revoked token should be inactive"
344 );
345}
346
347#[tokio::test]
348async fn test_oauth_error_cases() {
349 let url = base_url().await;
350 let http_client = client();
351 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
352 let handle = format!("wc{}", suffix);
353 let email = format!("wc{}@example.com", suffix);
354 http_client
355 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
356 .json(&json!({ "handle": handle, "email": email, "password": "Correct123!" }))
357 .send()
358 .await
359 .unwrap();
360 let redirect_uri = "https://example.com/callback";
361 let mock_client = setup_mock_client_metadata(redirect_uri).await;
362 let client_id = mock_client.uri();
363 let (_, code_challenge) = generate_pkce();
364 let par_body: Value = http_client
365 .post(format!("{}/oauth/par", url))
366 .form(&[
367 ("response_type", "code"),
368 ("client_id", &client_id),
369 ("redirect_uri", redirect_uri),
370 ("code_challenge", &code_challenge),
371 ("code_challenge_method", "S256"),
372 ])
373 .send()
374 .await
375 .unwrap()
376 .json()
377 .await
378 .unwrap();
379 let request_uri = par_body["request_uri"].as_str().unwrap();
380 let auth_res = http_client
381 .post(format!("{}/oauth/authorize", url))
382 .header("Content-Type", "application/json")
383 .header("Accept", "application/json")
384 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "wrong-password", "remember_device": false}))
385 .send().await.unwrap();
386 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN);
387 let error_body: Value = auth_res.json().await.unwrap();
388 assert_eq!(error_body["error"], "access_denied");
389 let unsupported = http_client
390 .post(format!("{}/oauth/token", url))
391 .form(&[
392 ("grant_type", "client_credentials"),
393 ("client_id", "https://example.com"),
394 ])
395 .send()
396 .await
397 .unwrap();
398 assert_eq!(unsupported.status(), StatusCode::BAD_REQUEST);
399 let body: Value = unsupported.json().await.unwrap();
400 assert_eq!(body["error"], "unsupported_grant_type");
401 let invalid_refresh = http_client
402 .post(format!("{}/oauth/token", url))
403 .form(&[
404 ("grant_type", "refresh_token"),
405 ("refresh_token", "invalid-token"),
406 ("client_id", "https://example.com"),
407 ])
408 .send()
409 .await
410 .unwrap();
411 assert_eq!(invalid_refresh.status(), StatusCode::BAD_REQUEST);
412 let body: Value = invalid_refresh.json().await.unwrap();
413 assert_eq!(body["error"], "invalid_grant");
414 let invalid_introspect = http_client
415 .post(format!("{}/oauth/introspect", url))
416 .form(&[("token", "invalid.token.here")])
417 .send()
418 .await
419 .unwrap();
420 assert_eq!(invalid_introspect.status(), StatusCode::OK);
421 let body: Value = invalid_introspect.json().await.unwrap();
422 assert_eq!(body["active"], false);
423 let expired_res = http_client
424 .get(format!("{}/oauth/authorize", url))
425 .header("Accept", "application/json")
426 .query(&[("request_uri", "urn:ietf:params:oauth:request_uri:expired")])
427 .send()
428 .await
429 .unwrap();
430 assert_eq!(expired_res.status(), StatusCode::BAD_REQUEST);
431}
432
433#[tokio::test]
434async fn test_oauth_2fa_flow() {
435 let url = base_url().await;
436 let http_client = client();
437 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
438 let handle = format!("ft{}", suffix);
439 let email = format!("ft{}@example.com", suffix);
440 let password = "Twofa123test!";
441 let create_res = http_client
442 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
443 .json(&json!({ "handle": handle, "email": email, "password": password }))
444 .send()
445 .await
446 .unwrap();
447 assert_eq!(create_res.status(), StatusCode::OK);
448 let account: Value = create_res.json().await.unwrap();
449 let user_did = account["did"].as_str().unwrap();
450 verify_new_account(&http_client, user_did).await;
451 let pool = get_test_db_pool().await;
452 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
453 .bind(user_did)
454 .execute(pool)
455 .await
456 .unwrap();
457 let redirect_uri = "https://example.com/2fa-callback";
458 let mock_client = setup_mock_client_metadata(redirect_uri).await;
459 let client_id = mock_client.uri();
460 let (code_verifier, code_challenge) = generate_pkce();
461 let par_body: Value = http_client
462 .post(format!("{}/oauth/par", url))
463 .form(&[
464 ("response_type", "code"),
465 ("client_id", &client_id),
466 ("redirect_uri", redirect_uri),
467 ("code_challenge", &code_challenge),
468 ("code_challenge_method", "S256"),
469 ])
470 .send()
471 .await
472 .unwrap()
473 .json()
474 .await
475 .unwrap();
476 let request_uri = par_body["request_uri"].as_str().unwrap();
477 let auth_res = http_client
478 .post(format!("{}/oauth/authorize", url))
479 .header("Content-Type", "application/json")
480 .header("Accept", "application/json")
481 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
482 .send().await.unwrap();
483 assert_eq!(
484 auth_res.status(),
485 StatusCode::OK,
486 "Should return OK with needs_2fa"
487 );
488 let auth_body: Value = auth_res.json().await.unwrap();
489 assert!(
490 auth_body["needs_2fa"].as_bool().unwrap_or(false),
491 "Should need 2FA, got: {:?}",
492 auth_body
493 );
494 let twofa_invalid = http_client
495 .post(format!("{}/oauth/authorize/2fa", url))
496 .header("Content-Type", "application/json")
497 .json(&json!({"request_uri": request_uri, "code": "000000"}))
498 .send()
499 .await
500 .unwrap();
501 assert_eq!(twofa_invalid.status(), StatusCode::FORBIDDEN);
502 let body: Value = twofa_invalid.json().await.unwrap();
503 assert!(
504 body["error_description"]
505 .as_str()
506 .unwrap_or("")
507 .contains("Invalid")
508 || body["error"].as_str().unwrap_or("") == "invalid_code"
509 );
510 let twofa_code: String =
511 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
512 .bind(request_uri)
513 .fetch_one(pool)
514 .await
515 .unwrap();
516 let twofa_res = http_client
517 .post(format!("{}/oauth/authorize/2fa", url))
518 .header("Content-Type", "application/json")
519 .json(&json!({"request_uri": request_uri, "code": &twofa_code}))
520 .send()
521 .await
522 .unwrap();
523 assert_eq!(
524 twofa_res.status(),
525 StatusCode::OK,
526 "Valid 2FA code should succeed"
527 );
528 let twofa_body: Value = twofa_res.json().await.unwrap();
529 let final_location = twofa_body["redirect_uri"].as_str().unwrap();
530 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code="));
531 let auth_code = final_location
532 .split("code=")
533 .nth(1)
534 .unwrap()
535 .split('&')
536 .next()
537 .unwrap();
538 let token_res = http_client
539 .post(format!("{}/oauth/token", url))
540 .form(&[
541 ("grant_type", "authorization_code"),
542 ("code", auth_code),
543 ("redirect_uri", redirect_uri),
544 ("code_verifier", &code_verifier),
545 ("client_id", &client_id),
546 ])
547 .send()
548 .await
549 .unwrap();
550 assert_eq!(token_res.status(), StatusCode::OK);
551 let token_body: Value = token_res.json().await.unwrap();
552 assert_eq!(token_body["sub"], user_did);
553}
554
555#[tokio::test]
556async fn test_oauth_2fa_lockout() {
557 let url = base_url().await;
558 let http_client = client();
559 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
560 let handle = format!("fl{}", suffix);
561 let email = format!("fl{}@example.com", suffix);
562 let password = "Twofa123test!";
563 let create_res = http_client
564 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
565 .json(&json!({ "handle": handle, "email": email, "password": password }))
566 .send()
567 .await
568 .unwrap();
569 let account: Value = create_res.json().await.unwrap();
570 let user_did = account["did"].as_str().unwrap();
571 verify_new_account(&http_client, user_did).await;
572 let pool = get_test_db_pool().await;
573 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
574 .bind(user_did)
575 .execute(pool)
576 .await
577 .unwrap();
578 let redirect_uri = "https://example.com/2fa-lockout-callback";
579 let mock_client = setup_mock_client_metadata(redirect_uri).await;
580 let client_id = mock_client.uri();
581 let (_, code_challenge) = generate_pkce();
582 let par_body: Value = http_client
583 .post(format!("{}/oauth/par", url))
584 .form(&[
585 ("response_type", "code"),
586 ("client_id", &client_id),
587 ("redirect_uri", redirect_uri),
588 ("code_challenge", &code_challenge),
589 ("code_challenge_method", "S256"),
590 ])
591 .send()
592 .await
593 .unwrap()
594 .json()
595 .await
596 .unwrap();
597 let request_uri = par_body["request_uri"].as_str().unwrap();
598 let auth_res = http_client
599 .post(format!("{}/oauth/authorize", url))
600 .header("Content-Type", "application/json")
601 .header("Accept", "application/json")
602 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
603 .send().await.unwrap();
604 assert_eq!(
605 auth_res.status(),
606 StatusCode::OK,
607 "Should return OK with needs_2fa"
608 );
609 let auth_body: Value = auth_res.json().await.unwrap();
610 assert!(
611 auth_body["needs_2fa"].as_bool().unwrap_or(false),
612 "Should need 2FA"
613 );
614 for i in 0..5 {
615 let res = http_client
616 .post(format!("{}/oauth/authorize/2fa", url))
617 .header("Content-Type", "application/json")
618 .json(&json!({"request_uri": request_uri, "code": "999999"}))
619 .send()
620 .await
621 .unwrap();
622 if i < 4 {
623 assert_eq!(
624 res.status(),
625 StatusCode::FORBIDDEN,
626 "Attempt {} should return 403",
627 i
628 );
629 }
630 }
631 let lockout_res = http_client
632 .post(format!("{}/oauth/authorize/2fa", url))
633 .header("Content-Type", "application/json")
634 .json(&json!({"request_uri": request_uri, "code": "999999"}))
635 .send()
636 .await
637 .unwrap();
638 let body: Value = lockout_res.json().await.unwrap();
639 let desc = body["error_description"].as_str().unwrap_or("");
640 assert!(
641 desc.contains("Too many") || desc.contains("No 2FA") || body["error"] == "invalid_request",
642 "Expected lockout error, got: {:?}",
643 body
644 );
645}
646
647#[tokio::test]
648async fn test_account_selector_with_2fa() {
649 let url = base_url().await;
650 let http_client = client();
651 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
652 let handle = format!("sf{}", suffix);
653 let email = format!("sf{}@example.com", suffix);
654 let password = "Selector2fa123!";
655 let create_res = http_client
656 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
657 .json(&json!({ "handle": handle, "email": email, "password": password }))
658 .send()
659 .await
660 .unwrap();
661 let account: Value = create_res.json().await.unwrap();
662 let user_did = account["did"].as_str().unwrap().to_string();
663 verify_new_account(&http_client, &user_did).await;
664 let redirect_uri = "https://example.com/selector-2fa-callback";
665 let mock_client = setup_mock_client_metadata(redirect_uri).await;
666 let client_id = mock_client.uri();
667 let (code_verifier, code_challenge) = generate_pkce();
668 let par_body: Value = http_client
669 .post(format!("{}/oauth/par", url))
670 .form(&[
671 ("response_type", "code"),
672 ("client_id", &client_id),
673 ("redirect_uri", redirect_uri),
674 ("code_challenge", &code_challenge),
675 ("code_challenge_method", "S256"),
676 ])
677 .send()
678 .await
679 .unwrap()
680 .json()
681 .await
682 .unwrap();
683 let request_uri = par_body["request_uri"].as_str().unwrap();
684 let auth_res = http_client
685 .post(format!("{}/oauth/authorize", url))
686 .header("Content-Type", "application/json")
687 .header("Accept", "application/json")
688 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": true}))
689 .send().await.unwrap();
690 assert_eq!(
691 auth_res.status(),
692 StatusCode::OK,
693 "Expected OK with JSON response"
694 );
695 let device_cookie = auth_res
696 .headers()
697 .get("set-cookie")
698 .and_then(|v| v.to_str().ok())
699 .map(|s| s.split(';').next().unwrap_or("").to_string())
700 .expect("Should have device cookie");
701 let auth_body: Value = auth_res.json().await.unwrap();
702 let mut location = auth_body["redirect_uri"]
703 .as_str()
704 .expect("Expected redirect_uri")
705 .to_string();
706 if location.contains("/oauth/consent") {
707 let consent_res = http_client
708 .post(format!("{}/oauth/authorize/consent", url))
709 .header("Content-Type", "application/json")
710 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": true}))
711 .send().await.unwrap();
712 assert_eq!(
713 consent_res.status(),
714 StatusCode::OK,
715 "Consent should succeed"
716 );
717 let consent_body: Value = consent_res.json().await.unwrap();
718 location = consent_body["redirect_uri"]
719 .as_str()
720 .expect("Expected redirect_uri from consent")
721 .to_string();
722 }
723 assert!(location.contains("code="));
724 let code = location
725 .split("code=")
726 .nth(1)
727 .unwrap()
728 .split('&')
729 .next()
730 .unwrap();
731 let _ = http_client
732 .post(format!("{}/oauth/token", url))
733 .form(&[
734 ("grant_type", "authorization_code"),
735 ("code", code),
736 ("redirect_uri", redirect_uri),
737 ("code_verifier", &code_verifier),
738 ("client_id", &client_id),
739 ])
740 .send()
741 .await
742 .unwrap()
743 .json::<Value>()
744 .await
745 .unwrap();
746 let pool = get_test_db_pool().await;
747 sqlx::query("UPDATE users SET two_factor_enabled = true WHERE did = $1")
748 .bind(&user_did)
749 .execute(pool)
750 .await
751 .unwrap();
752 let (code_verifier2, code_challenge2) = generate_pkce();
753 let par_body2: Value = http_client
754 .post(format!("{}/oauth/par", url))
755 .form(&[
756 ("response_type", "code"),
757 ("client_id", &client_id),
758 ("redirect_uri", redirect_uri),
759 ("code_challenge", &code_challenge2),
760 ("code_challenge_method", "S256"),
761 ])
762 .send()
763 .await
764 .unwrap()
765 .json()
766 .await
767 .unwrap();
768 let request_uri2 = par_body2["request_uri"].as_str().unwrap();
769 let select_res = http_client
770 .post(format!("{}/oauth/authorize/select", url))
771 .header("cookie", &device_cookie)
772 .header("Content-Type", "application/json")
773 .json(&json!({"request_uri": request_uri2, "did": &user_did}))
774 .send()
775 .await
776 .unwrap();
777 assert_eq!(
778 select_res.status(),
779 StatusCode::OK,
780 "Select should return OK with JSON"
781 );
782 let select_body: Value = select_res.json().await.unwrap();
783 assert!(
784 select_body["needs_2fa"].as_bool().unwrap_or(false),
785 "Should need 2FA"
786 );
787 let twofa_code: String =
788 sqlx::query_scalar("SELECT code FROM oauth_2fa_challenge WHERE request_uri = $1")
789 .bind(request_uri2)
790 .fetch_one(pool)
791 .await
792 .unwrap();
793 let twofa_res = http_client
794 .post(format!("{}/oauth/authorize/2fa", url))
795 .header("cookie", &device_cookie)
796 .header("Content-Type", "application/json")
797 .json(&json!({"request_uri": request_uri2, "code": &twofa_code}))
798 .send()
799 .await
800 .unwrap();
801 assert_eq!(
802 twofa_res.status(),
803 StatusCode::OK,
804 "Valid 2FA should succeed"
805 );
806 let twofa_body: Value = twofa_res.json().await.unwrap();
807 let final_location = twofa_body["redirect_uri"].as_str().unwrap();
808 assert!(final_location.starts_with(redirect_uri) && final_location.contains("code="));
809 let final_code = final_location
810 .split("code=")
811 .nth(1)
812 .unwrap()
813 .split('&')
814 .next()
815 .unwrap();
816 let token_res = http_client
817 .post(format!("{}/oauth/token", url))
818 .form(&[
819 ("grant_type", "authorization_code"),
820 ("code", final_code),
821 ("redirect_uri", redirect_uri),
822 ("code_verifier", &code_verifier2),
823 ("client_id", &client_id),
824 ])
825 .send()
826 .await
827 .unwrap();
828 assert_eq!(token_res.status(), StatusCode::OK);
829 let final_token: Value = token_res.json().await.unwrap();
830 assert_eq!(final_token["sub"], user_did);
831}
832
833#[tokio::test]
834async fn test_oauth_state_encoding() {
835 let url = base_url().await;
836 let http_client = client();
837 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
838 let handle = format!("ss{}", suffix);
839 let email = format!("ss{}@example.com", suffix);
840 let password = "State123special!";
841 let create_res = http_client
842 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
843 .json(&json!({ "handle": handle, "email": email, "password": password }))
844 .send()
845 .await
846 .unwrap();
847 let account: Value = create_res.json().await.unwrap();
848 verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
849 let redirect_uri = "https://example.com/state-special-callback";
850 let mock_client = setup_mock_client_metadata(redirect_uri).await;
851 let client_id = mock_client.uri();
852 let (_, code_challenge) = generate_pkce();
853 let special_state = "state=with&special=chars&plus+more";
854 let par_body: Value = http_client
855 .post(format!("{}/oauth/par", url))
856 .form(&[
857 ("response_type", "code"),
858 ("client_id", &client_id),
859 ("redirect_uri", redirect_uri),
860 ("code_challenge", &code_challenge),
861 ("code_challenge_method", "S256"),
862 ("state", special_state),
863 ])
864 .send()
865 .await
866 .unwrap()
867 .json()
868 .await
869 .unwrap();
870 let request_uri = par_body["request_uri"].as_str().unwrap();
871 let auth_res = http_client
872 .post(format!("{}/oauth/authorize", url))
873 .header("Content-Type", "application/json")
874 .header("Accept", "application/json")
875 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
876 .send().await.unwrap();
877 assert_eq!(
878 auth_res.status(),
879 StatusCode::OK,
880 "Expected OK with JSON response"
881 );
882 let auth_body: Value = auth_res.json().await.unwrap();
883 let mut location = auth_body["redirect_uri"]
884 .as_str()
885 .expect("Expected redirect_uri")
886 .to_string();
887 if location.contains("/oauth/consent") {
888 let consent_res = http_client
889 .post(format!("{}/oauth/authorize/consent", url))
890 .header("Content-Type", "application/json")
891 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
892 .send().await.unwrap();
893 assert_eq!(
894 consent_res.status(),
895 StatusCode::OK,
896 "Consent should succeed"
897 );
898 let consent_body: Value = consent_res.json().await.unwrap();
899 location = consent_body["redirect_uri"]
900 .as_str()
901 .expect("Expected redirect_uri from consent")
902 .to_string();
903 }
904 assert!(location.contains("state="));
905 let encoded_state = urlencoding::encode(special_state);
906 assert!(
907 location.contains(&format!("state={}", encoded_state)),
908 "State should be URL-encoded. Got: {}",
909 location
910 );
911}
912
913async fn get_oauth_token_with_scope(scope: &str) -> (String, String, String) {
914 let url = base_url().await;
915 let http_client = client();
916 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
917 let handle = format!("st{}", suffix);
918 let email = format!("st{}@example.com", suffix);
919 let password = "Scopetest123!";
920 let create_res = http_client
921 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
922 .json(&json!({ "handle": handle, "email": email, "password": password }))
923 .send()
924 .await
925 .unwrap();
926 assert_eq!(create_res.status(), StatusCode::OK);
927 let account: Value = create_res.json().await.unwrap();
928 let user_did = account["did"].as_str().unwrap().to_string();
929 verify_new_account(&http_client, &user_did).await;
930 let redirect_uri = "https://example.com/scope-callback";
931 let mock_client = setup_mock_client_metadata(redirect_uri).await;
932 let client_id = mock_client.uri();
933 let (code_verifier, code_challenge) = generate_pkce();
934 let par_res = http_client
935 .post(format!("{}/oauth/par", url))
936 .form(&[
937 ("response_type", "code"),
938 ("client_id", &client_id),
939 ("redirect_uri", redirect_uri),
940 ("code_challenge", &code_challenge),
941 ("code_challenge_method", "S256"),
942 ("scope", scope),
943 ("state", "test"),
944 ])
945 .send()
946 .await
947 .unwrap();
948 assert_eq!(
949 par_res.status(),
950 StatusCode::CREATED,
951 "PAR should succeed for scope: {}",
952 scope
953 );
954 let par_body: Value = par_res.json().await.unwrap();
955 let request_uri = par_body["request_uri"].as_str().unwrap();
956 let auth_res = http_client
957 .post(format!("{}/oauth/authorize", url))
958 .header("Content-Type", "application/json")
959 .header("Accept", "application/json")
960 .json(&json!({"request_uri": request_uri, "username": &handle, "password": password, "remember_device": false}))
961 .send().await.unwrap();
962 assert_eq!(auth_res.status(), StatusCode::OK);
963 let auth_body: Value = auth_res.json().await.unwrap();
964 let mut location = auth_body["redirect_uri"]
965 .as_str()
966 .expect("Expected redirect_uri")
967 .to_string();
968 if location.contains("/oauth/consent") {
969 let approved_scopes: Vec<&str> = scope.split_whitespace().collect();
970 let consent_res = http_client
971 .post(format!("{}/oauth/authorize/consent", url))
972 .header("Content-Type", "application/json")
973 .json(&json!({"request_uri": request_uri, "approved_scopes": approved_scopes, "remember": false}))
974 .send().await.unwrap();
975 let consent_status = consent_res.status();
976 let consent_body: Value = consent_res.json().await.unwrap();
977 assert_eq!(
978 consent_status,
979 StatusCode::OK,
980 "Consent should succeed. Scope: {}, Body: {:?}",
981 scope,
982 consent_body
983 );
984 location = consent_body["redirect_uri"]
985 .as_str()
986 .expect("Expected redirect_uri from consent")
987 .to_string();
988 }
989 let code = location
990 .split("code=")
991 .nth(1)
992 .unwrap()
993 .split('&')
994 .next()
995 .unwrap();
996 let token_res = http_client
997 .post(format!("{}/oauth/token", url))
998 .form(&[
999 ("grant_type", "authorization_code"),
1000 ("code", code),
1001 ("redirect_uri", redirect_uri),
1002 ("code_verifier", &code_verifier),
1003 ("client_id", &client_id),
1004 ])
1005 .send()
1006 .await
1007 .unwrap();
1008 assert_eq!(token_res.status(), StatusCode::OK, "Token exchange failed");
1009 let token_body: Value = token_res.json().await.unwrap();
1010 let access_token = token_body["access_token"].as_str().unwrap().to_string();
1011 (access_token, user_did, handle)
1012}
1013
1014#[tokio::test]
1015async fn test_granular_scope_repo_create_only() {
1016 let url = base_url().await;
1017 let http_client = client();
1018 let (token, did, _) =
1019 get_oauth_token_with_scope("repo:app.bsky.feed.post?action=create blob:*/*").await;
1020 let now = chrono::Utc::now().to_rfc3339();
1021 let create_res = http_client
1022 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1023 .bearer_auth(&token)
1024 .json(&json!({
1025 "repo": &did,
1026 "collection": "app.bsky.feed.post",
1027 "record": { "$type": "app.bsky.feed.post", "text": "test post", "createdAt": now }
1028 }))
1029 .send()
1030 .await
1031 .unwrap();
1032 assert_eq!(
1033 create_res.status(),
1034 StatusCode::OK,
1035 "Should allow creating posts with repo:app.bsky.feed.post?action=create"
1036 );
1037 let body: Value = create_res.json().await.unwrap();
1038 let uri = body["uri"].as_str().expect("Should have uri");
1039 let rkey = uri.split('/').next_back().unwrap();
1040 let delete_res = http_client
1041 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
1042 .bearer_auth(&token)
1043 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
1044 .send()
1045 .await
1046 .unwrap();
1047 assert_eq!(
1048 delete_res.status(),
1049 StatusCode::FORBIDDEN,
1050 "Should NOT allow deleting with create-only scope"
1051 );
1052 let like_res = http_client
1053 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1054 .bearer_auth(&token)
1055 .json(&json!({
1056 "repo": &did,
1057 "collection": "app.bsky.feed.like",
1058 "record": { "$type": "app.bsky.feed.like", "subject": { "uri": uri, "cid": body["cid"] }, "createdAt": now }
1059 }))
1060 .send().await.unwrap();
1061 assert_eq!(
1062 like_res.status(),
1063 StatusCode::FORBIDDEN,
1064 "Should NOT allow creating likes (wrong collection)"
1065 );
1066}
1067
1068#[tokio::test]
1069async fn test_granular_scope_wildcard_collection() {
1070 let url = base_url().await;
1071 let http_client = client();
1072 let (token, did, _) = get_oauth_token_with_scope(
1073 "repo:app.bsky.*?action=create&action=update&action=delete blob:*/*",
1074 )
1075 .await;
1076 let now = chrono::Utc::now().to_rfc3339();
1077 let post_res = http_client
1078 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1079 .bearer_auth(&token)
1080 .json(&json!({
1081 "repo": &did,
1082 "collection": "app.bsky.feed.post",
1083 "record": { "$type": "app.bsky.feed.post", "text": "wildcard test", "createdAt": now }
1084 }))
1085 .send()
1086 .await
1087 .unwrap();
1088 assert_eq!(
1089 post_res.status(),
1090 StatusCode::OK,
1091 "Should allow app.bsky.feed.post with app.bsky.* scope"
1092 );
1093 let body: Value = post_res.json().await.unwrap();
1094 let uri = body["uri"].as_str().unwrap();
1095 let rkey = uri.split('/').next_back().unwrap();
1096 let delete_res = http_client
1097 .post(format!("{}/xrpc/com.atproto.repo.deleteRecord", url))
1098 .bearer_auth(&token)
1099 .json(&json!({ "repo": &did, "collection": "app.bsky.feed.post", "rkey": rkey }))
1100 .send()
1101 .await
1102 .unwrap();
1103 assert_eq!(
1104 delete_res.status(),
1105 StatusCode::OK,
1106 "Should allow delete with action=delete"
1107 );
1108 let other_res = http_client
1109 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1110 .bearer_auth(&token)
1111 .json(&json!({
1112 "repo": &did,
1113 "collection": "com.example.record",
1114 "record": { "$type": "com.example.record", "data": "test", "createdAt": now }
1115 }))
1116 .send()
1117 .await
1118 .unwrap();
1119 assert_eq!(
1120 other_res.status(),
1121 StatusCode::FORBIDDEN,
1122 "Should NOT allow com.example.* with app.bsky.* scope"
1123 );
1124}
1125
1126#[tokio::test]
1127async fn test_granular_scope_email_read() {
1128 let url = base_url().await;
1129 let http_client = client();
1130 let (token, did, _) = get_oauth_token_with_scope("account:email?action=read").await;
1131 let session_res = http_client
1132 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1133 .bearer_auth(&token)
1134 .send()
1135 .await
1136 .unwrap();
1137 assert_eq!(session_res.status(), StatusCode::OK);
1138 let body: Value = session_res.json().await.unwrap();
1139 assert_eq!(body["did"], did);
1140 assert!(
1141 body["email"].is_string(),
1142 "Email should be visible with account:email?action=read. Got: {:?}",
1143 body
1144 );
1145}
1146
1147#[tokio::test]
1148async fn test_granular_scope_no_email_access() {
1149 let url = base_url().await;
1150 let http_client = client();
1151 let (token, did, _) = get_oauth_token_with_scope("repo:*?action=create blob:*/*").await;
1152 let session_res = http_client
1153 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1154 .bearer_auth(&token)
1155 .send()
1156 .await
1157 .unwrap();
1158 assert_eq!(session_res.status(), StatusCode::OK);
1159 let body: Value = session_res.json().await.unwrap();
1160 assert_eq!(body["did"], did);
1161 assert!(
1162 body["email"].is_null() || body.get("email").is_none(),
1163 "Email should be hidden without account:email scope. Got: {:?}",
1164 body["email"]
1165 );
1166}
1167
1168#[tokio::test]
1169async fn test_granular_scope_rpc_specific_method() {
1170 let url = base_url().await;
1171 let http_client = client();
1172 let (token, _, _) = get_oauth_token_with_scope("rpc:app.bsky.feed.getTimeline?aud=*").await;
1173 let allowed_res = http_client
1174 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1175 .bearer_auth(&token)
1176 .query(&[
1177 ("aud", "did:web:api.bsky.app"),
1178 ("lxm", "app.bsky.feed.getTimeline"),
1179 ])
1180 .send()
1181 .await
1182 .unwrap();
1183 assert_eq!(
1184 allowed_res.status(),
1185 StatusCode::OK,
1186 "Should allow getServiceAuth for app.bsky.feed.getTimeline"
1187 );
1188 let body: Value = allowed_res.json().await.unwrap();
1189 assert!(body["token"].is_string(), "Should return service token");
1190 let blocked_res = http_client
1191 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1192 .bearer_auth(&token)
1193 .query(&[
1194 ("aud", "did:web:api.bsky.app"),
1195 ("lxm", "app.bsky.feed.getAuthorFeed"),
1196 ])
1197 .send()
1198 .await
1199 .unwrap();
1200 assert_eq!(
1201 blocked_res.status(),
1202 StatusCode::FORBIDDEN,
1203 "Should NOT allow getServiceAuth for app.bsky.feed.getAuthorFeed"
1204 );
1205 let blocked_body: Value = blocked_res.json().await.unwrap();
1206 assert!(
1207 blocked_body["error"]
1208 .as_str()
1209 .unwrap_or("")
1210 .contains("Scope")
1211 || blocked_body["message"]
1212 .as_str()
1213 .unwrap_or("")
1214 .contains("scope"),
1215 "Should mention scope restriction: {:?}",
1216 blocked_body
1217 );
1218 let no_lxm_res = http_client
1219 .get(format!("{}/xrpc/com.atproto.server.getServiceAuth", url))
1220 .bearer_auth(&token)
1221 .query(&[("aud", "did:web:api.bsky.app")])
1222 .send()
1223 .await
1224 .unwrap();
1225 assert_eq!(
1226 no_lxm_res.status(),
1227 StatusCode::BAD_REQUEST,
1228 "Should require lxm parameter for granular scopes"
1229 );
1230}