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 .form(&[
739 ("request_uri", request_uri),
740 ("username", &handle),
741 ("password", password),
742 ("remember_device", "false"),
743 ])
744 .send()
745 .await
746 .unwrap();
747
748 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked from OAuth");
749 let body: Value = auth_res.json().await.unwrap();
750 assert_eq!(body["error"], "access_denied");
751}
752
753#[tokio::test]
754async fn test_security_url_injection_in_state_parameter() {
755 let url = base_url().await;
756 let http_client = client();
757
758 let ts = Utc::now().timestamp_millis();
759 let handle = format!("inject-state-{}", ts);
760 let email = format!("inject-state-{}@example.com", ts);
761 let password = "inject-state-password";
762
763 http_client
764 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
765 .json(&json!({
766 "handle": handle,
767 "email": email,
768 "password": password
769 }))
770 .send()
771 .await
772 .unwrap();
773
774 let redirect_uri = "https://example.com/inject-callback";
775 let mock_client = setup_mock_client_metadata(redirect_uri).await;
776 let client_id = mock_client.uri();
777
778 let (code_verifier, code_challenge) = generate_pkce();
779
780 let malicious_state = "state&redirect_uri=https://attacker.com&extra=";
781
782 let par_body: Value = http_client
783 .post(format!("{}/oauth/par", url))
784 .form(&[
785 ("response_type", "code"),
786 ("client_id", &client_id),
787 ("redirect_uri", redirect_uri),
788 ("code_challenge", &code_challenge),
789 ("code_challenge_method", "S256"),
790 ("state", malicious_state),
791 ])
792 .send()
793 .await
794 .unwrap()
795 .json()
796 .await
797 .unwrap();
798
799 let request_uri = par_body["request_uri"].as_str().unwrap();
800
801 let auth_client = no_redirect_client();
802 let auth_res = auth_client
803 .post(format!("{}/oauth/authorize", url))
804 .form(&[
805 ("request_uri", request_uri),
806 ("username", &handle),
807 ("password", password),
808 ("remember_device", "false"),
809 ])
810 .send()
811 .await
812 .unwrap();
813
814 assert!(auth_res.status().is_redirection(), "Should redirect successfully");
815 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
816
817 assert!(
818 location.starts_with(redirect_uri),
819 "Redirect should go to registered URI, not attacker URI. Got: {}",
820 location
821 );
822
823 let redirect_uri_count = location.matches("redirect_uri=").count();
824 assert!(
825 redirect_uri_count <= 1,
826 "State injection should not add extra redirect_uri parameters"
827 );
828
829 assert!(
830 location.contains(&urlencoding::encode(malicious_state).to_string()) ||
831 location.contains("state=state%26redirect_uri"),
832 "State parameter should be properly URL-encoded. Got: {}",
833 location
834 );
835}
836
837#[tokio::test]
838async fn test_security_cross_client_token_theft() {
839 let url = base_url().await;
840 let http_client = client();
841
842 let ts = Utc::now().timestamp_millis();
843 let handle = format!("cross-client-{}", ts);
844 let email = format!("cross-client-{}@example.com", ts);
845 let password = "cross-client-password";
846
847 http_client
848 .post(format!("{}/xrpc/com.atproto.server.createAccount", url))
849 .json(&json!({
850 "handle": handle,
851 "email": email,
852 "password": password
853 }))
854 .send()
855 .await
856 .unwrap();
857
858 let redirect_uri_a = "https://app-a.com/callback";
859 let mock_client_a = setup_mock_client_metadata(redirect_uri_a).await;
860 let client_id_a = mock_client_a.uri();
861
862 let redirect_uri_b = "https://app-b.com/callback";
863 let mock_client_b = setup_mock_client_metadata(redirect_uri_b).await;
864 let client_id_b = mock_client_b.uri();
865
866 let (code_verifier, code_challenge) = generate_pkce();
867
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
884 let request_uri = par_body["request_uri"].as_str().unwrap();
885
886 let auth_client = no_redirect_client();
887 let auth_res = auth_client
888 .post(format!("{}/oauth/authorize", url))
889 .form(&[
890 ("request_uri", request_uri),
891 ("username", &handle),
892 ("password", password),
893 ("remember_device", "false"),
894 ])
895 .send()
896 .await
897 .unwrap();
898
899 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
900 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
901
902 let token_res = http_client
903 .post(format!("{}/oauth/token", url))
904 .form(&[
905 ("grant_type", "authorization_code"),
906 ("code", code),
907 ("redirect_uri", redirect_uri_a),
908 ("code_verifier", &code_verifier),
909 ("client_id", &client_id_b),
910 ])
911 .send()
912 .await
913 .unwrap();
914
915 assert_eq!(
916 token_res.status(),
917 StatusCode::BAD_REQUEST,
918 "Cross-client code exchange must be explicitly rejected (defense-in-depth)"
919 );
920 let body: Value = token_res.json().await.unwrap();
921 assert_eq!(body["error"], "invalid_grant");
922 assert!(
923 body["error_description"].as_str().unwrap().contains("client_id"),
924 "Error should mention client_id mismatch"
925 );
926}
927
928#[test]
929fn test_security_dpop_nonce_tamper_detection() {
930 let secret = b"test-dpop-secret-32-bytes-long!!";
931 let verifier = DPoPVerifier::new(secret);
932
933 let nonce = verifier.generate_nonce();
934 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
935
936 let mut tampered = nonce_bytes.clone();
937 if !tampered.is_empty() {
938 tampered[0] ^= 0xFF;
939 }
940 let tampered_nonce = URL_SAFE_NO_PAD.encode(&tampered);
941
942 let result = verifier.validate_nonce(&tampered_nonce);
943 assert!(result.is_err(), "Tampered nonce should be rejected");
944}
945
946#[test]
947fn test_security_dpop_nonce_cross_server_rejected() {
948 let secret1 = b"server-1-secret-32-bytes-long!!!";
949 let secret2 = b"server-2-secret-32-bytes-long!!!";
950
951 let verifier1 = DPoPVerifier::new(secret1);
952 let verifier2 = DPoPVerifier::new(secret2);
953
954 let nonce_from_server1 = verifier1.generate_nonce();
955
956 let result = verifier2.validate_nonce(&nonce_from_server1);
957 assert!(result.is_err(), "Nonce from different server should be rejected");
958}
959
960#[test]
961fn test_security_dpop_proof_signature_tampering() {
962 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
963 use p256::elliptic_curve::sec1::ToEncodedPoint;
964
965 let secret = b"test-dpop-secret-32-bytes-long!!";
966 let verifier = DPoPVerifier::new(secret);
967
968 let signing_key = SigningKey::random(&mut rand::thread_rng());
969 let verifying_key = signing_key.verifying_key();
970 let point = verifying_key.to_encoded_point(false);
971
972 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
973 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
974
975 let header = json!({
976 "typ": "dpop+jwt",
977 "alg": "ES256",
978 "jwk": {
979 "kty": "EC",
980 "crv": "P-256",
981 "x": x,
982 "y": y
983 }
984 });
985
986 let payload = json!({
987 "jti": format!("tamper-test-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
988 "htm": "POST",
989 "htu": "https://example.com/token",
990 "iat": Utc::now().timestamp()
991 });
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
996 let signing_input = format!("{}.{}", header_b64, payload_b64);
997 let signature: Signature = signing_key.sign(signing_input.as_bytes());
998 let mut sig_bytes = signature.to_bytes().to_vec();
999
1000 sig_bytes[0] ^= 0xFF;
1001 let tampered_sig = URL_SAFE_NO_PAD.encode(&sig_bytes);
1002
1003 let tampered_proof = format!("{}.{}.{}", header_b64, payload_b64, tampered_sig);
1004
1005 let result = verifier.verify_proof(&tampered_proof, "POST", "https://example.com/token", None);
1006 assert!(result.is_err(), "Tampered DPoP signature should be rejected");
1007}
1008
1009#[test]
1010fn test_security_dpop_proof_key_substitution() {
1011 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1012 use p256::elliptic_curve::sec1::ToEncodedPoint;
1013
1014 let secret = b"test-dpop-secret-32-bytes-long!!";
1015 let verifier = DPoPVerifier::new(secret);
1016
1017 let signing_key = SigningKey::random(&mut rand::thread_rng());
1018
1019 let attacker_key = SigningKey::random(&mut rand::thread_rng());
1020 let attacker_verifying = attacker_key.verifying_key();
1021 let attacker_point = attacker_verifying.to_encoded_point(false);
1022
1023 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
1024 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
1025
1026 let header = json!({
1027 "typ": "dpop+jwt",
1028 "alg": "ES256",
1029 "jwk": {
1030 "kty": "EC",
1031 "crv": "P-256",
1032 "x": x,
1033 "y": y
1034 }
1035 });
1036
1037 let payload = json!({
1038 "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1039 "htm": "POST",
1040 "htu": "https://example.com/token",
1041 "iat": Utc::now().timestamp()
1042 });
1043
1044 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1045 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1046 let signing_input = format!("{}.{}", header_b64, payload_b64);
1047 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1048 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1049
1050 let mismatched_proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1051
1052 let result = verifier.verify_proof(&mismatched_proof, "POST", "https://example.com/token", None);
1053 assert!(result.is_err(), "DPoP proof with mismatched key should be rejected");
1054}
1055
1056#[test]
1057fn test_security_jwk_thumbprint_consistency() {
1058 let jwk = DPoPJwk {
1059 kty: "EC".to_string(),
1060 crv: Some("P-256".to_string()),
1061 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
1062 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
1063 };
1064
1065 let mut results = Vec::new();
1066 for _ in 0..100 {
1067 results.push(compute_jwk_thumbprint(&jwk).unwrap());
1068 }
1069
1070 let first = &results[0];
1071 for (i, result) in results.iter().enumerate() {
1072 assert_eq!(first, result, "Thumbprint should be deterministic, but iteration {} differs", i);
1073 }
1074}
1075
1076#[test]
1077fn test_security_dpop_iat_clock_skew_limits() {
1078 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1079 use p256::elliptic_curve::sec1::ToEncodedPoint;
1080
1081 let secret = b"test-dpop-secret-32-bytes-long!!";
1082 let verifier = DPoPVerifier::new(secret);
1083
1084 let test_offsets = vec![
1085 (-600, true),
1086 (-301, true),
1087 (-299, false),
1088 (0, false),
1089 (299, false),
1090 (301, true),
1091 (600, true),
1092 ];
1093
1094 for (offset_secs, should_fail) in test_offsets {
1095 let signing_key = SigningKey::random(&mut rand::thread_rng());
1096 let verifying_key = signing_key.verifying_key();
1097 let point = verifying_key.to_encoded_point(false);
1098
1099 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1100 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1101
1102 let header = json!({
1103 "typ": "dpop+jwt",
1104 "alg": "ES256",
1105 "jwk": {
1106 "kty": "EC",
1107 "crv": "P-256",
1108 "x": x,
1109 "y": y
1110 }
1111 });
1112
1113 let payload = json!({
1114 "jti": format!("clock-{}-{}", offset_secs, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1115 "htm": "POST",
1116 "htu": "https://example.com/token",
1117 "iat": Utc::now().timestamp() + offset_secs
1118 });
1119
1120 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1121 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1122 let signing_input = format!("{}.{}", header_b64, payload_b64);
1123 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1124 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1125
1126 let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1127
1128 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1129
1130 if should_fail {
1131 assert!(result.is_err(), "iat offset {} should be rejected", offset_secs);
1132 } else {
1133 assert!(result.is_ok(), "iat offset {} should be accepted", offset_secs);
1134 }
1135 }
1136}
1137
1138#[test]
1139fn test_security_dpop_method_case_insensitivity() {
1140 use p256::ecdsa::{SigningKey, Signature, signature::Signer};
1141 use p256::elliptic_curve::sec1::ToEncodedPoint;
1142
1143 let secret = b"test-dpop-secret-32-bytes-long!!";
1144 let verifier = DPoPVerifier::new(secret);
1145
1146 let signing_key = SigningKey::random(&mut rand::thread_rng());
1147 let verifying_key = signing_key.verifying_key();
1148 let point = verifying_key.to_encoded_point(false);
1149
1150 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1151 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1152
1153 let header = json!({
1154 "typ": "dpop+jwt",
1155 "alg": "ES256",
1156 "jwk": {
1157 "kty": "EC",
1158 "crv": "P-256",
1159 "x": x,
1160 "y": y
1161 }
1162 });
1163
1164 let payload = json!({
1165 "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1166 "htm": "post",
1167 "htu": "https://example.com/token",
1168 "iat": Utc::now().timestamp()
1169 });
1170
1171 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1172 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1173 let signing_input = format!("{}.{}", header_b64, payload_b64);
1174 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1175 let signature_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1176
1177 let proof = format!("{}.{}.{}", header_b64, payload_b64, signature_b64);
1178
1179 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1180 assert!(result.is_ok(), "HTTP method comparison should be case-insensitive");
1181}
1182
1183#[tokio::test]
1184async fn test_security_invalid_grant_type_rejected() {
1185 let url = base_url().await;
1186 let http_client = client();
1187
1188 let grant_types = vec![
1189 "client_credentials",
1190 "password",
1191 "implicit",
1192 "urn:ietf:params:oauth:grant-type:jwt-bearer",
1193 "urn:ietf:params:oauth:grant-type:device_code",
1194 "",
1195 "AUTHORIZATION_CODE",
1196 "Authorization_Code",
1197 ];
1198
1199 for grant_type in grant_types {
1200 let res = http_client
1201 .post(format!("{}/oauth/token", url))
1202 .form(&[
1203 ("grant_type", grant_type),
1204 ("client_id", "https://example.com"),
1205 ])
1206 .send()
1207 .await
1208 .unwrap();
1209
1210 assert_eq!(
1211 res.status(),
1212 StatusCode::BAD_REQUEST,
1213 "Grant type '{}' should be rejected",
1214 grant_type
1215 );
1216 }
1217}
1218
1219#[tokio::test]
1220async fn test_security_token_with_wrong_typ_rejected() {
1221 let url = base_url().await;
1222 let http_client = client();
1223
1224 let wrong_types = vec![
1225 "JWT",
1226 "jwt",
1227 "at+JWT",
1228 "access_token",
1229 "",
1230 ];
1231
1232 for typ in wrong_types {
1233 let header = json!({
1234 "alg": "HS256",
1235 "typ": typ
1236 });
1237 let payload = json!({
1238 "iss": "https://test.pds",
1239 "sub": "did:plc:test",
1240 "aud": "https://test.pds",
1241 "iat": Utc::now().timestamp(),
1242 "exp": Utc::now().timestamp() + 3600,
1243 "jti": "wrong-typ-token"
1244 });
1245
1246 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1247 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1248 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
1249 let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
1250
1251 let res = http_client
1252 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1253 .header("Authorization", format!("Bearer {}", token))
1254 .send()
1255 .await
1256 .unwrap();
1257
1258 assert_eq!(
1259 res.status(),
1260 StatusCode::UNAUTHORIZED,
1261 "Token with typ='{}' should be rejected",
1262 typ
1263 );
1264 }
1265}
1266
1267#[tokio::test]
1268async fn test_security_missing_required_claims_rejected() {
1269 let url = base_url().await;
1270 let http_client = client();
1271
1272 let tokens_missing_claims = vec![
1273 (json!({"iss": "x", "sub": "x", "aud": "x", "iat": 0}), "exp"),
1274 (json!({"iss": "x", "sub": "x", "aud": "x", "exp": 9999999999i64}), "iat"),
1275 (json!({"iss": "x", "aud": "x", "iat": 0, "exp": 9999999999i64}), "sub"),
1276 ];
1277
1278 for (payload, missing_claim) in tokens_missing_claims {
1279 let header = json!({
1280 "alg": "HS256",
1281 "typ": "at+jwt"
1282 });
1283
1284 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1285 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1286 let fake_sig = URL_SAFE_NO_PAD.encode(&[1u8; 32]);
1287 let token = format!("{}.{}.{}", header_b64, payload_b64, fake_sig);
1288
1289 let res = http_client
1290 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1291 .header("Authorization", format!("Bearer {}", token))
1292 .send()
1293 .await
1294 .unwrap();
1295
1296 assert_eq!(
1297 res.status(),
1298 StatusCode::UNAUTHORIZED,
1299 "Token missing '{}' claim should be rejected",
1300 missing_claim
1301 );
1302 }
1303}
1304
1305#[tokio::test]
1306async fn test_security_malformed_tokens_rejected() {
1307 let url = base_url().await;
1308 let http_client = client();
1309
1310 let malformed_tokens = vec![
1311 "",
1312 "not-a-token",
1313 "one.two",
1314 "one.two.three.four",
1315 "....",
1316 "eyJhbGciOiJIUzI1NiJ9",
1317 "eyJhbGciOiJIUzI1NiJ9.",
1318 "eyJhbGciOiJIUzI1NiJ9..",
1319 ".eyJzdWIiOiJ0ZXN0In0.",
1320 "!!invalid-base64!!.eyJzdWIiOiJ0ZXN0In0.sig",
1321 "eyJhbGciOiJIUzI1NiJ9.!!invalid!!.sig",
1322 ];
1323
1324 for token in malformed_tokens {
1325 let res = http_client
1326 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1327 .header("Authorization", format!("Bearer {}", token))
1328 .send()
1329 .await
1330 .unwrap();
1331
1332 assert_eq!(
1333 res.status(),
1334 StatusCode::UNAUTHORIZED,
1335 "Malformed token '{}' should be rejected",
1336 if token.len() > 50 { &token[..50] } else { token }
1337 );
1338 }
1339}
1340
1341#[tokio::test]
1342async fn test_security_authorization_header_formats() {
1343 let url = base_url().await;
1344 let http_client = client();
1345
1346 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
1347
1348 let valid_case_variants = vec![
1349 format!("bearer {}", access_token),
1350 format!("BEARER {}", access_token),
1351 format!("Bearer {}", access_token),
1352 ];
1353
1354 for auth_header in valid_case_variants {
1355 let res = http_client
1356 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1357 .header("Authorization", &auth_header)
1358 .send()
1359 .await
1360 .unwrap();
1361
1362 assert_eq!(
1363 res.status(),
1364 StatusCode::OK,
1365 "Auth header '{}...' should be accepted (RFC 7235 case-insensitivity)",
1366 if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
1367 );
1368 }
1369
1370 let invalid_formats = vec![
1371 format!("Basic {}", access_token),
1372 format!("Digest {}", access_token),
1373 access_token.clone(),
1374 format!("Bearer{}", access_token),
1375 ];
1376
1377 for auth_header in invalid_formats {
1378 let res = http_client
1379 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1380 .header("Authorization", &auth_header)
1381 .send()
1382 .await
1383 .unwrap();
1384
1385 assert_eq!(
1386 res.status(),
1387 StatusCode::UNAUTHORIZED,
1388 "Auth header '{}...' should be rejected",
1389 if auth_header.len() > 30 { &auth_header[..30] } else { &auth_header }
1390 );
1391 }
1392}
1393
1394#[tokio::test]
1395async fn test_security_no_authorization_header() {
1396 let url = base_url().await;
1397 let http_client = client();
1398
1399 let res = http_client
1400 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1401 .send()
1402 .await
1403 .unwrap();
1404
1405 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Missing auth header should return 401");
1406}
1407
1408#[tokio::test]
1409async fn test_security_empty_authorization_header() {
1410 let url = base_url().await;
1411 let http_client = client();
1412
1413 let res = http_client
1414 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
1415 .header("Authorization", "")
1416 .send()
1417 .await
1418 .unwrap();
1419
1420 assert_eq!(res.status(), StatusCode::UNAUTHORIZED, "Empty auth header should return 401");
1421}
1422
1423#[tokio::test]
1424async fn test_security_revoked_token_rejected() {
1425 let url = base_url().await;
1426 let http_client = client();
1427
1428 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
1429
1430 let revoke_res = http_client
1431 .post(format!("{}/oauth/revoke", url))
1432 .form(&[("token", &refresh_token)])
1433 .send()
1434 .await
1435 .unwrap();
1436
1437 assert_eq!(revoke_res.status(), StatusCode::OK);
1438
1439 let introspect_res = http_client
1440 .post(format!("{}/oauth/introspect", url))
1441 .form(&[("token", &access_token)])
1442 .send()
1443 .await
1444 .unwrap();
1445
1446 let introspect_body: Value = introspect_res.json().await.unwrap();
1447 assert_eq!(introspect_body["active"], false, "Revoked token should be inactive");
1448}