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