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