this repo has no description
1#![allow(unused_imports)]
2#![allow(unused_variables)]
3
4mod common;
5mod helpers;
6
7use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
8use bspds::oauth::dpop::{DPoPVerifier, DPoPJwk, compute_jwk_thumbprint};
9use chrono::Utc;
10use common::{base_url, client};
11use reqwest::{redirect, StatusCode};
12use serde_json::{json, Value};
13use sha2::{Digest, Sha256};
14use wiremock::{Mock, MockServer, ResponseTemplate};
15use wiremock::matchers::{method, path};
16
17fn no_redirect_client() -> reqwest::Client {
18 reqwest::Client::builder()
19 .redirect(redirect::Policy::none())
20 .build()
21 .unwrap()
22}
23
24fn generate_pkce() -> (String, String) {
25 let verifier_bytes: [u8; 32] = rand::random();
26 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
27
28 let mut hasher = Sha256::new();
29 hasher.update(code_verifier.as_bytes());
30 let hash = hasher.finalize();
31 let code_challenge = URL_SAFE_NO_PAD.encode(&hash);
32
33 (code_verifier, code_challenge)
34}
35
36async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
37 let mock_server = MockServer::start().await;
38
39 let client_id = mock_server.uri();
40 let metadata = json!({
41 "client_id": client_id,
42 "client_name": "Security Test Client",
43 "redirect_uris": [redirect_uri],
44 "grant_types": ["authorization_code", "refresh_token"],
45 "response_types": ["code"],
46 "token_endpoint_auth_method": "none",
47 "dpop_bound_access_tokens": false
48 });
49
50 Mock::given(method("GET"))
51 .and(path("/"))
52 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
53 .mount(&mock_server)
54 .await;
55
56 mock_server
57}
58
59async fn get_oauth_tokens(
60 http_client: &reqwest::Client,
61 url: &str,
62) -> (String, String, String) {
63 let ts = Utc::now().timestamp_millis();
64 let handle = format!("sec-test-{}", ts);
65 let email = format!("sec-test-{}@example.com", ts);
66 let password = "security-test-password";
67
68 http_client
69 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
70 .json(&json!({
71 "handle": handle,
72 "email": email,
73 "password": password
74 }))
75 .send()
76 .await
77 .unwrap();
78
79 let redirect_uri = "https://example.com/sec-callback";
80 let mock_client = setup_mock_client_metadata(redirect_uri).await;
81 let client_id = mock_client.uri();
82
83 let (code_verifier, code_challenge) = generate_pkce();
84
85 let par_body: Value = http_client
86 .post(format!("{}/oauth/par", url))
87 .form(&[
88 ("response_type", "code"),
89 ("client_id", &client_id),
90 ("redirect_uri", redirect_uri),
91 ("code_challenge", &code_challenge),
92 ("code_challenge_method", "S256"),
93 ])
94 .send()
95 .await
96 .unwrap()
97 .json()
98 .await
99 .unwrap();
100
101 let request_uri = par_body["request_uri"].as_str().unwrap();
102
103 let auth_client = no_redirect_client();
104 let auth_res = auth_client
105 .post(format!("{}/oauth/authorize", url))
106 .form(&[
107 ("request_uri", request_uri),
108 ("username", &handle),
109 ("password", password),
110 ("remember_device", "false"),
111 ])
112 .send()
113 .await
114 .unwrap();
115
116 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
117 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
118
119 let token_body: Value = http_client
120 .post(format!("{}/oauth/token", url))
121 .form(&[
122 ("grant_type", "authorization_code"),
123 ("code", code),
124 ("redirect_uri", redirect_uri),
125 ("code_verifier", &code_verifier),
126 ("client_id", &client_id),
127 ])
128 .send()
129 .await
130 .unwrap()
131 .json()
132 .await
133 .unwrap();
134
135 let access_token = token_body["access_token"].as_str().unwrap().to_string();
136 let refresh_token = token_body["refresh_token"].as_str().unwrap().to_string();
137
138 (access_token, refresh_token, client_id)
139}
140
141#[tokio::test]
142async fn test_security_forged_token_signature_rejected() {
143 let url = base_url().await;
144 let http_client = client();
145
146 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
147
148 let parts: Vec<&str> = access_token.split('.').collect();
149 assert_eq!(parts.len(), 3, "Token should have 3 parts");
150
151 let forged_signature = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
152 let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_signature);
153
154 let res = http_client
155 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
156 .header("Authorization", format!("Bearer {}", forged_token))
157 .send()
158 .await
159 .unwrap();
160
161 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected");
162}
163
164#[tokio::test]
165async fn test_security_modified_payload_rejected() {
166 let url = base_url().await;
167 let http_client = client();
168
169 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
170
171 let parts: Vec<&str> = access_token.split('.').collect();
172
173 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
174 let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
175 payload["sub"] = json!("did:plc:attacker");
176 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
177 let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
178
179 let res = http_client
180 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
181 .header("Authorization", format!("Bearer {}", modified_token))
182 .send()
183 .await
184 .unwrap();
185
186 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected");
187}
188
189#[tokio::test]
190async fn test_security_algorithm_none_attack_rejected() {
191 let url = base_url().await;
192 let http_client = client();
193
194 let header = json!({
195 "alg": "none",
196 "typ": "at+jwt"
197 });
198 let payload = json!({
199 "iss": "https://test.pds",
200 "sub": "did:plc:attacker",
201 "aud": "https://test.pds",
202 "iat": Utc::now().timestamp(),
203 "exp": Utc::now().timestamp() + 3600,
204 "jti": "fake-token-id",
205 "scope": "atproto"
206 });
207
208 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
209 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
210 let malicious_token = format!("{}.{}.", header_b64, payload_b64);
211
212 let res = http_client
213 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
214 .header("Authorization", format!("Bearer {}", malicious_token))
215 .send()
216 .await
217 .unwrap();
218
219 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm 'none' attack should be rejected");
220}
221
222#[tokio::test]
223async fn test_security_algorithm_substitution_attack_rejected() {
224 let url = base_url().await;
225 let http_client = client();
226
227 let header = json!({
228 "alg": "RS256",
229 "typ": "at+jwt"
230 });
231 let payload = json!({
232 "iss": "https://test.pds",
233 "sub": "did:plc:attacker",
234 "aud": "https://test.pds",
235 "iat": Utc::now().timestamp(),
236 "exp": Utc::now().timestamp() + 3600,
237 "jti": "fake-token-id"
238 });
239
240 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
241 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
242 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 64]);
243 let malicious_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
244
245 let res = http_client
246 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
247 .header("Authorization", format!("Bearer {}", malicious_token))
248 .send()
249 .await
250 .unwrap();
251
252 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Algorithm substitution attack should be rejected");
253}
254
255#[tokio::test]
256async fn test_security_expired_token_rejected() {
257 let url = base_url().await;
258 let http_client = client();
259
260 let header = json!({
261 "alg": "HS256",
262 "typ": "at+jwt"
263 });
264 let payload = json!({
265 "iss": "https://test.pds",
266 "sub": "did:plc:test",
267 "aud": "https://test.pds",
268 "iat": Utc::now().timestamp() - 7200,
269 "exp": Utc::now().timestamp() - 3600,
270 "jti": "expired-token-id"
271 });
272
273 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
274 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
275 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
276 let expired_token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
277
278 let res = http_client
279 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
280 .header("Authorization", format!("Bearer {}", expired_token))
281 .send()
282 .await
283 .unwrap();
284
285 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected");
286}
287
288#[tokio::test]
289async fn test_security_pkce_plain_method_rejected() {
290 let url = base_url().await;
291 let http_client = client();
292
293 let redirect_uri = "https://example.com/pkce-plain-callback";
294 let mock_client = setup_mock_client_metadata(redirect_uri).await;
295 let client_id = mock_client.uri();
296
297 let res = http_client
298 .post(format!("{}/oauth/par", url))
299 .form(&[
300 ("response_type", "code"),
301 ("client_id", &client_id),
302 ("redirect_uri", redirect_uri),
303 ("code_challenge", "plain-text-challenge"),
304 ("code_challenge_method", "plain"),
305 ])
306 .send()
307 .await
308 .unwrap();
309
310 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected");
311 let body: Value = res.json().await.unwrap();
312 assert_eq!(body["error"], "invalid_request");
313 assert!(
314 body["error_description"].as_str().unwrap().to_lowercase().contains("s256"),
315 "Error should mention S256 requirement"
316 );
317}
318
319#[tokio::test]
320async fn test_security_pkce_missing_challenge_rejected() {
321 let url = base_url().await;
322 let http_client = client();
323
324 let redirect_uri = "https://example.com/no-pkce-callback";
325 let mock_client = setup_mock_client_metadata(redirect_uri).await;
326 let client_id = mock_client.uri();
327
328 let res = http_client
329 .post(format!("{}/oauth/par", url))
330 .form(&[
331 ("response_type", "code"),
332 ("client_id", &client_id),
333 ("redirect_uri", redirect_uri),
334 ])
335 .send()
336 .await
337 .unwrap();
338
339 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
340}
341
342#[tokio::test]
343async fn test_security_pkce_wrong_verifier_rejected() {
344 let url = base_url().await;
345 let http_client = client();
346
347 let ts = Utc::now().timestamp_millis();
348 let handle = format!("pkce-attack-{}", ts);
349 let email = format!("pkce-attack-{}@example.com", ts);
350 let password = "pkce-attack-password";
351
352 http_client
353 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
354 .json(&json!({
355 "handle": handle,
356 "email": email,
357 "password": password
358 }))
359 .send()
360 .await
361 .unwrap();
362
363 let redirect_uri = "https://example.com/pkce-attack-callback";
364 let mock_client = setup_mock_client_metadata(redirect_uri).await;
365 let client_id = mock_client.uri();
366
367 let (_, code_challenge) = generate_pkce();
368 let (attacker_verifier, _) = generate_pkce();
369
370 let par_body: Value = http_client
371 .post(format!("{}/oauth/par", url))
372 .form(&[
373 ("response_type", "code"),
374 ("client_id", &client_id),
375 ("redirect_uri", redirect_uri),
376 ("code_challenge", &code_challenge),
377 ("code_challenge_method", "S256"),
378 ])
379 .send()
380 .await
381 .unwrap()
382 .json()
383 .await
384 .unwrap();
385
386 let request_uri = par_body["request_uri"].as_str().unwrap();
387
388 let auth_client = no_redirect_client();
389 let auth_res = auth_client
390 .post(format!("{}/oauth/authorize", url))
391 .form(&[
392 ("request_uri", request_uri),
393 ("username", &handle),
394 ("password", password),
395 ("remember_device", "false"),
396 ])
397 .send()
398 .await
399 .unwrap();
400
401 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
402 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
403
404 let token_res = http_client
405 .post(format!("{}/oauth/token", url))
406 .form(&[
407 ("grant_type", "authorization_code"),
408 ("code", code),
409 ("redirect_uri", redirect_uri),
410 ("code_verifier", &attacker_verifier),
411 ("client_id", &client_id),
412 ])
413 .send()
414 .await
415 .unwrap();
416
417 assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected");
418 let body: Value = token_res.json().await.unwrap();
419 assert_eq!(body["error"], "invalid_grant");
420}
421
422#[tokio::test]
423async fn test_security_authorization_code_replay_attack() {
424 let url = base_url().await;
425 let http_client = client();
426
427 let ts = Utc::now().timestamp_millis();
428 let handle = format!("code-replay-{}", ts);
429 let email = format!("code-replay-{}@example.com", ts);
430 let password = "code-replay-password";
431
432 http_client
433 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
434 .json(&json!({
435 "handle": handle,
436 "email": email,
437 "password": password
438 }))
439 .send()
440 .await
441 .unwrap();
442
443 let redirect_uri = "https://example.com/code-replay-callback";
444 let mock_client = setup_mock_client_metadata(redirect_uri).await;
445 let client_id = mock_client.uri();
446
447 let (code_verifier, code_challenge) = generate_pkce();
448
449 let par_body: Value = http_client
450 .post(format!("{}/oauth/par", url))
451 .form(&[
452 ("response_type", "code"),
453 ("client_id", &client_id),
454 ("redirect_uri", redirect_uri),
455 ("code_challenge", &code_challenge),
456 ("code_challenge_method", "S256"),
457 ])
458 .send()
459 .await
460 .unwrap()
461 .json()
462 .await
463 .unwrap();
464
465 let request_uri = par_body["request_uri"].as_str().unwrap();
466
467 let auth_client = no_redirect_client();
468 let auth_res = auth_client
469 .post(format!("{}/oauth/authorize", url))
470 .form(&[
471 ("request_uri", request_uri),
472 ("username", &handle),
473 ("password", password),
474 ("remember_device", "false"),
475 ])
476 .send()
477 .await
478 .unwrap();
479
480 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
481 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
482 let stolen_code = code.to_string();
483
484 let first_res = http_client
485 .post(format!("{}/oauth/token", url))
486 .form(&[
487 ("grant_type", "authorization_code"),
488 ("code", code),
489 ("redirect_uri", redirect_uri),
490 ("code_verifier", &code_verifier),
491 ("client_id", &client_id),
492 ])
493 .send()
494 .await
495 .unwrap();
496
497 assert_eq!(first_res.status(), StatusCode::OK, "First use should succeed");
498
499 let replay_res = http_client
500 .post(format!("{}/oauth/token", url))
501 .form(&[
502 ("grant_type", "authorization_code"),
503 ("code", &stolen_code),
504 ("redirect_uri", redirect_uri),
505 ("code_verifier", &code_verifier),
506 ("client_id", &client_id),
507 ])
508 .send()
509 .await
510 .unwrap();
511
512 assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Replay attack should fail");
513 let body: Value = replay_res.json().await.unwrap();
514 assert_eq!(body["error"], "invalid_grant");
515}
516
517#[tokio::test]
518async fn test_security_refresh_token_replay_attack() {
519 let url = base_url().await;
520 let http_client = client();
521
522 let ts = Utc::now().timestamp_millis();
523 let handle = format!("rt-replay-{}", ts);
524 let email = format!("rt-replay-{}@example.com", ts);
525 let password = "rt-replay-password";
526
527 http_client
528 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
529 .json(&json!({
530 "handle": handle,
531 "email": email,
532 "password": password
533 }))
534 .send()
535 .await
536 .unwrap();
537
538 let redirect_uri = "https://example.com/rt-replay-callback";
539 let mock_client = setup_mock_client_metadata(redirect_uri).await;
540 let client_id = mock_client.uri();
541
542 let (code_verifier, code_challenge) = generate_pkce();
543
544 let par_body: Value = http_client
545 .post(format!("{}/oauth/par", url))
546 .form(&[
547 ("response_type", "code"),
548 ("client_id", &client_id),
549 ("redirect_uri", redirect_uri),
550 ("code_challenge", &code_challenge),
551 ("code_challenge_method", "S256"),
552 ])
553 .send()
554 .await
555 .unwrap()
556 .json()
557 .await
558 .unwrap();
559
560 let request_uri = par_body["request_uri"].as_str().unwrap();
561
562 let auth_client = no_redirect_client();
563 let auth_res = auth_client
564 .post(format!("{}/oauth/authorize", url))
565 .form(&[
566 ("request_uri", request_uri),
567 ("username", &handle),
568 ("password", password),
569 ("remember_device", "false"),
570 ])
571 .send()
572 .await
573 .unwrap();
574
575 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
576 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
577
578 let token_body: Value = http_client
579 .post(format!("{}/oauth/token", url))
580 .form(&[
581 ("grant_type", "authorization_code"),
582 ("code", code),
583 ("redirect_uri", redirect_uri),
584 ("code_verifier", &code_verifier),
585 ("client_id", &client_id),
586 ])
587 .send()
588 .await
589 .unwrap()
590 .json()
591 .await
592 .unwrap();
593
594 let stolen_refresh_token = token_body["refresh_token"].as_str().unwrap().to_string();
595
596 let first_refresh: Value = http_client
597 .post(format!("{}/oauth/token", url))
598 .form(&[
599 ("grant_type", "refresh_token"),
600 ("refresh_token", &stolen_refresh_token),
601 ("client_id", &client_id),
602 ])
603 .send()
604 .await
605 .unwrap()
606 .json()
607 .await
608 .unwrap();
609
610 assert!(first_refresh["access_token"].is_string(), "First refresh should succeed");
611 let new_refresh_token = first_refresh["refresh_token"].as_str().unwrap();
612
613 let replay_res = http_client
614 .post(format!("{}/oauth/token", url))
615 .form(&[
616 ("grant_type", "refresh_token"),
617 ("refresh_token", &stolen_refresh_token),
618 ("client_id", &client_id),
619 ])
620 .send()
621 .await
622 .unwrap();
623
624 assert_eq!(replay_res.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail");
625 let body: Value = replay_res.json().await.unwrap();
626 assert_eq!(body["error"], "invalid_grant");
627 assert!(
628 body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"),
629 "Error should mention token reuse"
630 );
631
632 let family_revoked_res = http_client
633 .post(format!("{}/oauth/token", url))
634 .form(&[
635 ("grant_type", "refresh_token"),
636 ("refresh_token", new_refresh_token),
637 ("client_id", &client_id),
638 ])
639 .send()
640 .await
641 .unwrap();
642
643 assert_eq!(
644 family_revoked_res.status(),
645 StatusCode::BAD_REQUEST,
646 "Token family should be revoked after replay detection"
647 );
648}
649
650#[tokio::test]
651async fn test_security_redirect_uri_manipulation() {
652 let url = base_url().await;
653 let http_client = client();
654
655 let registered_redirect = "https://legitimate-app.com/callback";
656 let attacker_redirect = "https://attacker.com/steal";
657 let mock_client = setup_mock_client_metadata(registered_redirect).await;
658 let client_id = mock_client.uri();
659
660 let (_, code_challenge) = generate_pkce();
661
662 let res = http_client
663 .post(format!("{}/oauth/par", url))
664 .form(&[
665 ("response_type", "code"),
666 ("client_id", &client_id),
667 ("redirect_uri", attacker_redirect),
668 ("code_challenge", &code_challenge),
669 ("code_challenge_method", "S256"),
670 ])
671 .send()
672 .await
673 .unwrap();
674
675 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected");
676}
677
678#[tokio::test]
679async fn test_security_deactivated_account_blocked() {
680 let url = base_url().await;
681 let http_client = client();
682
683 let ts = Utc::now().timestamp_millis();
684 let handle = format!("deact-sec-{}", ts);
685 let email = format!("deact-sec-{}@example.com", ts);
686 let password = "deact-sec-password";
687
688 let create_res = http_client
689 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
690 .json(&json!({
691 "handle": handle,
692 "email": email,
693 "password": password
694 }))
695 .send()
696 .await
697 .unwrap();
698
699 assert_eq!(create_res.status(), StatusCode::OK);
700 let account: Value = create_res.json().await.unwrap();
701 let access_jwt = account["accessJwt"].as_str().unwrap();
702
703 let deact_res = http_client
704 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
705 .header("Authorization", format!("Bearer {}", access_jwt))
706 .json(&json!({}))
707 .send()
708 .await
709 .unwrap();
710 assert_eq!(deact_res.status(), StatusCode::OK);
711
712 let redirect_uri = "https://example.com/deact-sec-callback";
713 let mock_client = setup_mock_client_metadata(redirect_uri).await;
714 let client_id = mock_client.uri();
715
716 let (_, code_challenge) = generate_pkce();
717
718 let par_body: Value = http_client
719 .post(format!("{}/oauth/par", url))
720 .form(&[
721 ("response_type", "code"),
722 ("client_id", &client_id),
723 ("redirect_uri", redirect_uri),
724 ("code_challenge", &code_challenge),
725 ("code_challenge_method", "S256"),
726 ])
727 .send()
728 .await
729 .unwrap()
730 .json()
731 .await
732 .unwrap();
733
734 let request_uri = par_body["request_uri"].as_str().unwrap();
735
736 let auth_res = http_client
737 .post(format!("{}/oauth/authorize", url))
738 .header("Accept", "application/json")
739 .form(&[
740 ("request_uri", request_uri),
741 ("username", &handle),
742 ("password", password),
743 ("remember_device", "false"),
744 ])
745 .send()
746 .await
747 .unwrap();
748
749 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth");
750 let body: Value = auth_res.json().await.unwrap();
751 assert_eq!(body["error"], "access_denied");
752}
753
754#[tokio::test]
755async fn test_security_url_injection_in_state_parameter() {
756 let url = base_url().await;
757 let http_client = client();
758
759 let ts = Utc::now().timestamp_millis();
760 let handle = format!("inject-state-{}", ts);
761 let email = format!("inject-state-{}@example.com", ts);
762 let password = "inject-state-password";
763
764 http_client
765 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
766 .json(&json!({
767 "handle": handle,
768 "email": email,
769 "password": password
770 }))
771 .send()
772 .await
773 .unwrap();
774
775 let redirect_uri = "https://example.com/inject-callback";
776 let mock_client = setup_mock_client_metadata(redirect_uri).await;
777 let client_id = mock_client.uri();
778
779 let (code_verifier, code_challenge) = generate_pkce();
780
781 let malicious_state = "state&redirect_uri=https://attacker.com&extra=";
782
783 let par_body: Value = http_client
784 .post(format!("{}/oauth/par", url))
785 .form(&[
786 ("response_type", "code"),
787 ("client_id", &client_id),
788 ("redirect_uri", redirect_uri),
789 ("code_challenge", &code_challenge),
790 ("code_challenge_method", "S256"),
791 ("state", malicious_state),
792 ])
793 .send()
794 .await
795 .unwrap()
796 .json()
797 .await
798 .unwrap();
799
800 let request_uri = par_body["request_uri"].as_str().unwrap();
801
802 let auth_client = no_redirect_client();
803 let auth_res = auth_client
804 .post(format!("{}/oauth/authorize", url))
805 .form(&[
806 ("request_uri", request_uri),
807 ("username", &handle),
808 ("password", password),
809 ("remember_device", "false"),
810 ])
811 .send()
812 .await
813 .unwrap();
814
815 assert!(auth_res.status().is_redirection(), "Should redirect successfully");
816 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
817
818 assert!(
819 location.starts_with(redirect_uri),
820 "Redirect should go to registered URI, not attacker URI. Got: {}",
821 location
822 );
823
824 let redirect_uri_count = location.matches("redirect_uri=").count();
825 assert!(
826 redirect_uri_count <= 1,
827 "State injection should not add extra redirect_uri parameters"
828 );
829
830 assert!(
831 location.contains(&urlencoding::encode(malicious_state).to_string()) ||
832 location.contains("state=state%26redirect_uri"),
833 "State parameter should be properly URL-encoded. Got: {}",
834 location
835 );
836}
837
838#[tokio::test]
839async fn test_security_cross_client_token_theft() {
840 let url = base_url().await;
841 let http_client = client();
842
843 let ts = Utc::now().timestamp_millis();
844 let handle = format!("cross-client-{}", ts);
845 let email = format!("cross-client-{}@example.com", ts);
846 let password = "cross-client-password";
847
848 http_client
849 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
850 .json(&json!({
851 "handle": handle,
852 "email": email,
853 "password": password
854 }))
855 .send()
856 .await
857 .unwrap();
858
859 let redirect_uri_a = "https://app-a.com/callback";
860 let mock_client_a = setup_mock_client_metadata(redirect_uri_a).await;
861 let client_id_a = mock_client_a.uri();
862
863 let redirect_uri_b = "https://app-b.com/callback";
864 let mock_client_b = setup_mock_client_metadata(redirect_uri_b).await;
865 let client_id_b = mock_client_b.uri();
866
867 let (code_verifier, code_challenge) = generate_pkce();
868
869 let par_body: Value = http_client
870 .post(format!("{}/oauth/par", url))
871 .form(&[
872 ("response_type", "code"),
873 ("client_id", &client_id_a),
874 ("redirect_uri", redirect_uri_a),
875 ("code_challenge", &code_challenge),
876 ("code_challenge_method", "S256"),
877 ])
878 .send()
879 .await
880 .unwrap()
881 .json()
882 .await
883 .unwrap();
884
885 let request_uri = par_body["request_uri"].as_str().unwrap();
886
887 let auth_client = no_redirect_client();
888 let auth_res = auth_client
889 .post(format!("{}/oauth/authorize", url))
890 .form(&[
891 ("request_uri", request_uri),
892 ("username", &handle),
893 ("password", password),
894 ("remember_device", "false"),
895 ])
896 .send()
897 .await
898 .unwrap();
899
900 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
901 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
902
903 let token_res = http_client
904 .post(format!("{}/oauth/token", url))
905 .form(&[
906 ("grant_type", "authorization_code"),
907 ("code", code),
908 ("redirect_uri", redirect_uri_a),
909 ("code_verifier", &code_verifier),
910 ("client_id", &client_id_b),
911 ])
912 .send()
913 .await
914 .unwrap();
915
916 assert_eq!(
917 token_res.status(),
918 StatusCode::BAD_REQUEST,
919 "Cross-client code exchange must be explicitly rejected (defense-in-depth)"
920 );
921 let body: Value = token_res.json().await.unwrap();
922 assert_eq!(body["error"], "invalid_grant");
923 assert!(
924 body["error_description"].as_str().unwrap().contains("client_id"),
925 "Error should mention client_id mismatch"
926 );
927}
928
929#[test]
930fn test_security_dpop_nonce_tamper_detection() {
931 let secret = b"test-dpop-secret-32-bytes-long!!";
932 let verifier = DPoPVerifier::new(secret);
933
934 let nonce = verifier.generate_nonce();
935 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
936
937 let mut tampered = nonce_bytes.clone();
938 if !tampered.is_empty() {
939 tampered[0] ^= 0xFF;
940 }
941 let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered);
942
943 let result = verifier.validate_nonce(&tampered_nonce);
944 assert!(result.is_err(), "Tampered nonce should be rejected");
945}
946
947#[test]
948fn test_security_dpop_nonce_cross_server_rejected() {
949 let secret1 = b"server-1-secret-32-bytes-long!!!";
950 let secret2 = b"server-2-secret-32-bytes-long!!!";
951
952 let verifier1 = DPoPVerifier::new(secret1);
953 let verifier2 = DPoPVerifier::new(secret2);
954
955 let nonce_from_server1 = verifier1.generate_nonce();
956
957 let result = verifier2.validate_nonce(&nonce_from_server1);
958 assert!(result.is_err(), "Nonce from different server should be rejected");
959}
960
961#[test]
962fn test_security_dpop_proof_signature_tampering() {
963 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
964 use p256::elliptic_curve::sec1::ToEncodedPoint;
965
966 let secret = b"test-dpop-secret-32-bytes-long!!";
967 let verifier = DPoPVerifier::new(secret);
968
969 let signing_key = SigningKey::random(&mut rand::thread_rng());
970 let verifying_key = signing_key.verifying_key();
971 let point = verifying_key.to_encoded_point(false);
972
973 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
974 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
975
976 let header = json!({
977 "typ": "dpop+jwt",
978 "alg": "ES256",
979 "jwk": {
980 "kty": "EC",
981 "crv": "P-256",
982 "x": x,
983 "y": y
984 }
985 });
986
987 let payload = json!({
988 "jti": format!("tamper-test-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
989 "htm": "POST",
990 "htu": "https://example.com/token",
991 "iat": Utc::now().timestamp()
992 });
993
994 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
995 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
996
997 let signing_input = format!("{}.{}", header_b64, payload_b64);
998 let signature: Signature = signing_key.sign(signing_input.as_bytes());
999 let mut sig_bytes = signature.to_bytes().to_vec();
1000
1001 sig_bytes[0] ^= 0xFF;
1002 let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes);
1003
1004 let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig);
1005
1006 let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None);
1007 assert!(result.is_err(), "Tampered DPoP signature should be rejected");
1008}
1009
1010#[test]
1011fn test_security_dpop_proof_key_substitution() {
1012 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1013 use p256::elliptic_curve::sec1::ToEncodedPoint;
1014
1015 let secret = b"test-dpop-secret-32-bytes-long!!";
1016 let verifier = DPoPVerifier::new(secret);
1017
1018 let signing_key = SigningKey::random(&mut rand::thread_rng());
1019
1020 let attacker_key = SigningKey::random(&mut rand::thread_rng());
1021 let attacker_verifying = attacker_key.verifying_key();
1022 let attacker_point = attacker_verifying.to_encoded_point(false);
1023
1024 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
1025 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
1026
1027 let header = json!({
1028 "typ": "dpop+jwt",
1029 "alg": "ES256",
1030 "jwk": {
1031 "kty": "EC",
1032 "crv": "P-256",
1033 "x": x,
1034 "y": y
1035 }
1036 });
1037
1038 let payload = json!({
1039 "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1040 "htm": "POST",
1041 "htu": "https://example.com/token",
1042 "iat": Utc::now().timestamp()
1043 });
1044
1045 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1046 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1047 let signing_input = format!("{}.{}", header_b64, payload_b64);
1048 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1049 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1050
1051 let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1052
1053 let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None);
1054 assert!(result.is_err(), "DPoP proof with mismatched key should be rejected");
1055}
1056
1057#[test]
1058fn test_security_jwk_thumbprint_consistency() {
1059 let jwk = DPoPJwk {
1060 kty: "EC".to_string(),
1061 crv: Some("P-256".to_string()),
1062 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
1063 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
1064 };
1065
1066 let mut results = Vec::new();
1067 for _ in 0..100 {
1068 results.push(compute_jwk_thumbprint(&jwk).unwrap());
1069 }
1070
1071 let first = &results[0];
1072 for (i, result) in results.iter().enumerate() {
1073 assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i);
1074 }
1075}
1076
1077#[test]
1078fn test_security_dpop_iat_clock_skew_limits() {
1079 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1080 use p256::elliptic_curve::sec1::ToEncodedPoint;
1081
1082 let secret = b"test-dpop-secret-32-bytes-long!!";
1083 let verifier = DPoPVerifier::new(secret);
1084
1085 let test_offsets = vec![
1086 (-600, true),
1087 (-301, true),
1088 (-299, false),
1089 (0, false),
1090 (299, false),
1091 (301, true),
1092 (600, true),
1093 ];
1094
1095 for (offset_secs, should_fail) in test_offsets {
1096 let signing_key = SigningKey::random(&mut rand::thread_rng());
1097 let verifying_key = signing_key.verifying_key();
1098 let point = verifying_key.to_encoded_point(false);
1099
1100 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1101 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1102
1103 let header = json!({
1104 "typ": "dpop+jwt",
1105 "alg": "ES256",
1106 "jwk": {
1107 "kty": "EC",
1108 "crv": "P-256",
1109 "x": x,
1110 "y": y
1111 }
1112 });
1113
1114 let payload = json!({
1115 "jti": format!("clock-{}-{}", offset_secs, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1116 "htm": "POST",
1117 "htu": "https://example.com/token",
1118 "iat": Utc::now().timestamp() + offset_secs
1119 });
1120
1121 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1122 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1123 let signing_input = format!("{}.{}", header_b64, payload_b64);
1124 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1125 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1126
1127 let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1128
1129 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1130
1131 if should_fail {
1132 assert!(result.is_err(), "iat offset {} should be rejected", offset_secs);
1133 } else {
1134 assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs);
1135 }
1136 }
1137}
1138
1139#[test]
1140fn test_security_dpop_method_case_insensitivity() {
1141 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1142 use p256::elliptic_curve::sec1::ToEncodedPoint;
1143
1144 let secret = b"test-dpop-secret-32-bytes-long!!";
1145 let verifier = DPoPVerifier::new(secret);
1146
1147 let signing_key = SigningKey::random(&mut rand::thread_rng());
1148 let verifying_key = signing_key.verifying_key();
1149 let point = verifying_key.to_encoded_point(false);
1150
1151 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1152 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1153
1154 let header = json!({
1155 "typ": "dpop+jwt",
1156 "alg": "ES256",
1157 "jwk": {
1158 "kty": "EC",
1159 "crv": "P-256",
1160 "x": x,
1161 "y": y
1162 }
1163 });
1164
1165 let payload = json!({
1166 "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1167 "htm": "post",
1168 "htu": "https://example.com/token",
1169 "iat": Utc::now().timestamp()
1170 });
1171
1172 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1173 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1174 let signing_input = format!("{}.{}", header_b64, payload_b64);
1175 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1176 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1177
1178 let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1179
1180 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1181 assert!(result.is_ok(), "HTTP method comparison should be case-insensitive");
1182}
1183
1184#[tokio::test]
1185async fn test_security_invalid_grant_type_rejected() {
1186 let url = base_url().await;
1187 let http_client = client();
1188
1189 let grant_types = vec![
1190 "client_credentials",
1191 "password",
1192 "implicit",
1193 "urn:ietf:params:oauth:grant-type:jwt-bearer",
1194 "urn:ietf:params:oauth:grant-type:device_code",
1195 "",
1196 "AUTHORIZATION_CODE",
1197 "Authorization_Code",
1198 ];
1199
1200 for grant_type in grant_types {
1201 let res = http_client
1202 .post(format!("{}/oauth/token", url))
1203 .form(&[
1204 ("grant_type", grant_type),
1205 ("client_id", "https://example.com"),
1206 ])
1207 .send()
1208 .await
1209 .unwrap();
1210
1211 assert_eq!(
1212 res.status(),
1213 StatusCode::BAD_REQUEST,
1214 "Grant type '{}' should be rejected",
1215 grant_type
1216 );
1217 }
1218}
1219
1220#[tokio::test]
1221async fn test_security_token_with_wrong_typ_rejected() {
1222 let url = base_url().await;
1223 let http_client = client();
1224
1225 let wrong_types = vec![
1226 "JWT",
1227 "jwt",
1228 "at+JWT",
1229 "access_token",
1230 "",
1231 ];
1232
1233 for typ in wrong_types {
1234 let header = json!({
1235 "alg": "HS256",
1236 "typ": typ
1237 });
1238 let payload = json!({
1239 "iss": "https://test.pds",
1240 "sub": "did:plc:test",
1241 "aud": "https://test.pds",
1242 "iat": Utc::now().timestamp(),
1243 "exp": Utc::now().timestamp() + 3600,
1244 "jti": "wrong-typ-token"
1245 });
1246
1247 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1248 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1249 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
1250 let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
1251
1252 let res = http_client
1253 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1254 .header("Authorization", format!("Bearer {}", token))
1255 .send()
1256 .await
1257 .unwrap();
1258
1259 assert_eq!(
1260 res.status(),
1261 StatusCode::UNAUTHORIZED,
1262 "Token with typ='{}' should be rejected",
1263 typ
1264 );
1265 }
1266}
1267
1268#[tokio::test]
1269async fn test_security_missing_required_claims_rejected() {
1270 let url = base_url().await;
1271 let http_client = client();
1272
1273 let tokens_missing_claims = vec![
1274 (json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"),
1275 (json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"),
1276 (json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"),
1277 ];
1278
1279 for (payload, missing_claim) in tokens_missing_claims {
1280 let header = json!({
1281 "alg": "HS256",
1282 "typ": "at+jwt"
1283 });
1284
1285 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1286 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1287 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
1288 let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
1289
1290 let res = http_client
1291 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1292 .header("Authorization", format!("Bearer {}", token))
1293 .send()
1294 .await
1295 .unwrap();
1296
1297 assert_eq!(
1298 res.status(),
1299 StatusCode::UNAUTHORIZED,
1300 "Token missing '{}' claim should be rejected",
1301 missing_claim
1302 );
1303 }
1304}
1305
1306#[tokio::test]
1307async fn test_security_malformed_tokens_rejected() {
1308 let url = base_url().await;
1309 let http_client = client();
1310
1311 let malformed_tokens = vec![
1312 "",
1313 "not-a-token",
1314 "one.two",
1315 "one.two.three.four",
1316 "....",
1317 "eyJhbGciOiJIUzI1NiJ9",
1318 "eyJhbGciOiJIUzI1NiJ9.",
1319 "eyJhbGciOiJIUzI1NiJ9..",
1320 ".eyJzdWIiOiJ0ZXN0In0.",
1321 "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig",
1322 "eyJhbGciOiJIUzI1NiJ9.!!invalid!!.sig",
1323 ];
1324
1325 for token in malformed_tokens {
1326 let res = http_client
1327 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1328 .header("Authorization", format!("Bearer {}", token))
1329 .send()
1330 .await
1331 .unwrap();
1332
1333 assert_eq!(
1334 res.status(),
1335 StatusCode::UNAUTHORIZED,
1336 "Malformed token '{}' should be rejected",
1337 if token.len() > 50 { &token[..50] } else { token }
1338 );
1339 }
1340}
1341
1342#[tokio::test]
1343async fn test_security_authorization_header_formats() {
1344 let url = base_url().await;
1345 let http_client = client();
1346
1347 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
1348
1349 let valid_case_variants = vec![
1350 format!("bearer {}", access_token),
1351 format!("BEARER {}", access_token),
1352 format!("Bearer {}", access_token),
1353 ];
1354
1355 for auth_header in valid_case_variants {
1356 let res = http_client
1357 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1358 .header("Authorization", &auth_header)
1359 .send()
1360 .await
1361 .unwrap();
1362
1363 assert_eq!(
1364 res.status(),
1365 StatusCode::OK,
1366 "Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)",
1367 if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
1368 );
1369 }
1370
1371 let invalid_formats = vec![
1372 format!("Basic {}", access_token),
1373 format!("Digest {}", access_token),
1374 access_token.clone(),
1375 format!("Bearer{}", access_token),
1376 ];
1377
1378 for auth_header in invalid_formats {
1379 let res = http_client
1380 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1381 .header("Authorization", &auth_header)
1382 .send()
1383 .await
1384 .unwrap();
1385
1386 assert_eq!(
1387 res.status(),
1388 StatusCode::UNAUTHORIZED,
1389 "Auth header '{}...' should be rejected",
1390 if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
1391 );
1392 }
1393}
1394
1395#[tokio::test]
1396async fn test_security_no_authorization_header() {
1397 let url = base_url().await;
1398 let http_client = client();
1399
1400 let res = http_client
1401 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1402 .send()
1403 .await
1404 .unwrap();
1405
1406 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401");
1407}
1408
1409#[tokio::test]
1410async fn test_security_empty_authorization_header() {
1411 let url = base_url().await;
1412 let http_client = client();
1413
1414 let res = http_client
1415 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1416 .header("Authorization", "")
1417 .send()
1418 .await
1419 .unwrap();
1420
1421 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401");
1422}
1423
1424#[tokio::test]
1425async fn test_security_revoked_token_rejected() {
1426 let url = base_url().await;
1427 let http_client = client();
1428
1429 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
1430
1431 let revoke_res = http_client
1432 .post(format!("{}/oauth/revoke", url))
1433 .form(&[("token", &refresh_token)])
1434 .send()
1435 .await
1436 .unwrap();
1437
1438 assert_eq!(revoke_res.status(), StatusCode::OK);
1439
1440 let introspect_res = http_client
1441 .post(format!("{}/oauth/introspect", url))
1442 .form(&[("token", &access_token)])
1443 .send()
1444 .await
1445 .unwrap();
1446
1447 let introspect_body: Value = introspect_res.json().await.unwrap();
1448 assert_eq!(introspect_body["active"], false, "Revoked token should be inactive");
1449}
1450
1451#[tokio::test]
1452async fn test_security_oauth_authorize_rate_limiting() {
1453 let url = base_url().await;
1454 let http_client = no_redirect_client();
1455
1456 let ts = Utc::now().timestamp_nanos_opt().unwrap_or(0);
1457 let unique_ip = format!("10.{}.{}.{}", (ts >> 16) & 0xFF, (ts >> 8) & 0xFF, ts & 0xFF);
1458
1459 let redirect_uri = "https://example.com/rate-limit-callback";
1460 let mock_client = setup_mock_client_metadata(redirect_uri).await;
1461 let client_id = mock_client.uri();
1462
1463 let (_, code_challenge) = generate_pkce();
1464
1465 let client_for_par = client();
1466 let par_body: Value = client_for_par
1467 .post(format!("{}/oauth/par", url))
1468 .form(&[
1469 ("response_type", "code"),
1470 ("client_id", &client_id),
1471 ("redirect_uri", redirect_uri),
1472 ("code_challenge", &code_challenge),
1473 ("code_challenge_method", "S256"),
1474 ])
1475 .send()
1476 .await
1477 .unwrap()
1478 .json()
1479 .await
1480 .unwrap();
1481
1482 let request_uri = par_body["request_uri"].as_str().unwrap();
1483
1484 let mut rate_limited_count = 0;
1485 let mut other_count = 0;
1486
1487 for _ in 0..15 {
1488 let res = http_client
1489 .post(format!("{}/oauth/authorize", url))
1490 .header("X-Forwarded-For", &unique_ip)
1491 .form(&[
1492 ("request_uri", request_uri),
1493 ("username", "nonexistent_user"),
1494 ("password", "wrong_password"),
1495 ("remember_device", "false"),
1496 ])
1497 .send()
1498 .await
1499 .unwrap();
1500
1501 match res.status() {
1502 StatusCode::TOO_MANY_REQUESTS => rate_limited_count += 1,
1503 _ => other_count += 1,
1504 }
1505 }
1506
1507 assert!(
1508 rate_limited_count > 0,
1509 "Expected at least one rate-limited response after 15 OAuth authorize attempts. Got {} other and {} rate limited.",
1510 other_count,
1511 rate_limited_count
1512 );
1513}