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