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