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