this repo has no description
1#![allow(unused_imports)]
2mod common;
3mod helpers;
4use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
5use chrono::Utc;
6use common::{base_url, client};
7use helpers::verify_new_account;
8use reqwest::StatusCode;
9use serde_json::{Value, json};
10use sha2::{Digest, Sha256};
11use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint};
12use wiremock::matchers::{method, path};
13use wiremock::{Mock, MockServer, ResponseTemplate};
14
15fn generate_pkce() -> (String, String) {
16 let verifier_bytes: [u8; 32] = rand::random();
17 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
18 let mut hasher = Sha256::new();
19 hasher.update(code_verifier.as_bytes());
20 let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize());
21 (code_verifier, code_challenge)
22}
23
24async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
25 let mock_server = MockServer::start().await;
26 let metadata = json!({
27 "client_id": mock_server.uri(),
28 "client_name": "Security Test Client",
29 "redirect_uris": [redirect_uri],
30 "grant_types": ["authorization_code", "refresh_token"],
31 "response_types": ["code"],
32 "token_endpoint_auth_method": "none",
33 "dpop_bound_access_tokens": false
34 });
35 Mock::given(method("GET"))
36 .and(path("/"))
37 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
38 .mount(&mock_server)
39 .await;
40 mock_server
41}
42
43async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) {
44 let ts = Utc::now().timestamp_millis();
45 let handle = format!("sec-test-{}", ts);
46 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
47 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" }))
48 .send().await.unwrap();
49 let account: Value = create_res.json().await.unwrap();
50 let did = account["did"].as_str().unwrap();
51 verify_new_account(http_client, did).await;
52 let redirect_uri = "https://example.com/sec-callback";
53 let mock_client = setup_mock_client_metadata(redirect_uri).await;
54 let client_id = mock_client.uri();
55 let (code_verifier, code_challenge) = generate_pkce();
56 let par_body: Value = http_client
57 .post(format!("{}/oauth/par", url))
58 .form(&[
59 ("response_type", "code"),
60 ("client_id", &client_id),
61 ("redirect_uri", redirect_uri),
62 ("code_challenge", &code_challenge),
63 ("code_challenge_method", "S256"),
64 ])
65 .send()
66 .await
67 .unwrap()
68 .json()
69 .await
70 .unwrap();
71 let request_uri = par_body["request_uri"].as_str().unwrap();
72 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
73 .header("Content-Type", "application/json")
74 .header("Accept", "application/json")
75 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false}))
76 .send().await.unwrap();
77 let auth_body: Value = auth_res.json().await.unwrap();
78 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
79 if location.contains("/oauth/consent") {
80 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
81 .header("Content-Type", "application/json")
82 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
83 .send().await.unwrap();
84 let consent_body: Value = consent_res.json().await.unwrap();
85 location = consent_body["redirect_uri"].as_str().unwrap().to_string();
86 }
87 let code = location
88 .split("code=")
89 .nth(1)
90 .unwrap()
91 .split('&')
92 .next()
93 .unwrap();
94 let token_body: Value = http_client
95 .post(format!("{}/oauth/token", url))
96 .form(&[
97 ("grant_type", "authorization_code"),
98 ("code", code),
99 ("redirect_uri", redirect_uri),
100 ("code_verifier", &code_verifier),
101 ("client_id", &client_id),
102 ])
103 .send()
104 .await
105 .unwrap()
106 .json()
107 .await
108 .unwrap();
109 (
110 token_body["access_token"].as_str().unwrap().to_string(),
111 token_body["refresh_token"].as_str().unwrap().to_string(),
112 client_id,
113 )
114}
115
116#[tokio::test]
117async fn test_token_tampering_attacks() {
118 let url = base_url().await;
119 let http_client = client();
120 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
121 let parts: Vec<&str> = access_token.split('.').collect();
122 assert_eq!(parts.len(), 3);
123 let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
124 let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig);
125 assert_eq!(
126 http_client
127 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
128 .bearer_auth(&forged_token)
129 .send()
130 .await
131 .unwrap()
132 .status(),
133 StatusCode::UNAUTHORIZED,
134 "Forged signature should be rejected"
135 );
136 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
137 let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
138 payload["sub"] = json!("did:plc:attacker");
139 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
140 let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
141 assert_eq!(
142 http_client
143 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
144 .bearer_auth(&modified_token)
145 .send()
146 .await
147 .unwrap()
148 .status(),
149 StatusCode::UNAUTHORIZED,
150 "Modified payload should be rejected"
151 );
152 let none_header = json!({ "alg": "none", "typ": "at+jwt" });
153 let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds",
154 "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" });
155 let none_token = format!(
156 "{}.{}.",
157 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()),
158 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap())
159 );
160 assert_eq!(
161 http_client
162 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
163 .bearer_auth(&none_token)
164 .send()
165 .await
166 .unwrap()
167 .status(),
168 StatusCode::UNAUTHORIZED,
169 "alg=none should be rejected"
170 );
171 let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" });
172 let rs256_token = format!(
173 "{}.{}.{}",
174 URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()),
175 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()),
176 URL_SAFE_NO_PAD.encode(&[1u8; 64])
177 );
178 assert_eq!(
179 http_client
180 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
181 .bearer_auth(&rs256_token)
182 .send()
183 .await
184 .unwrap()
185 .status(),
186 StatusCode::UNAUTHORIZED,
187 "Algorithm substitution should be rejected"
188 );
189 let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds",
190 "iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" });
191 let expired_token = format!(
192 "{}.{}.{}",
193 URL_SAFE_NO_PAD
194 .encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()),
195 URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()),
196 URL_SAFE_NO_PAD.encode(&[1u8; 32])
197 );
198 assert_eq!(
199 http_client
200 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
201 .bearer_auth(&expired_token)
202 .send()
203 .await
204 .unwrap()
205 .status(),
206 StatusCode::UNAUTHORIZED,
207 "Expired token should be rejected"
208 );
209}
210
211#[tokio::test]
212async fn test_pkce_security() {
213 let url = base_url().await;
214 let http_client = client();
215 let redirect_uri = "https://example.com/pkce-callback";
216 let mock_client = setup_mock_client_metadata(redirect_uri).await;
217 let client_id = mock_client.uri();
218 let res = http_client
219 .post(format!("{}/oauth/par", url))
220 .form(&[
221 ("response_type", "code"),
222 ("client_id", &client_id),
223 ("redirect_uri", redirect_uri),
224 ("code_challenge", "plain-text-challenge"),
225 ("code_challenge_method", "plain"),
226 ])
227 .send()
228 .await
229 .unwrap();
230 assert_eq!(
231 res.status(),
232 StatusCode::BAD_REQUEST,
233 "PKCE plain method should be rejected"
234 );
235 let body: Value = res.json().await.unwrap();
236 assert!(
237 body["error_description"]
238 .as_str()
239 .unwrap()
240 .to_lowercase()
241 .contains("s256")
242 );
243 let res = http_client
244 .post(format!("{}/oauth/par", url))
245 .form(&[
246 ("response_type", "code"),
247 ("client_id", &client_id),
248 ("redirect_uri", redirect_uri),
249 ])
250 .send()
251 .await
252 .unwrap();
253 assert_eq!(
254 res.status(),
255 StatusCode::BAD_REQUEST,
256 "Missing PKCE challenge should be rejected"
257 );
258 let ts = Utc::now().timestamp_millis();
259 let handle = format!("pkce-attack-{}", ts);
260 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
261 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" }))
262 .send().await.unwrap();
263 let account: Value = create_res.json().await.unwrap();
264 verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
265 let (_, code_challenge) = generate_pkce();
266 let (attacker_verifier, _) = generate_pkce();
267 let par_body: Value = http_client
268 .post(format!("{}/oauth/par", url))
269 .form(&[
270 ("response_type", "code"),
271 ("client_id", &client_id),
272 ("redirect_uri", redirect_uri),
273 ("code_challenge", &code_challenge),
274 ("code_challenge_method", "S256"),
275 ])
276 .send()
277 .await
278 .unwrap()
279 .json()
280 .await
281 .unwrap();
282 let request_uri = par_body["request_uri"].as_str().unwrap();
283 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
284 .header("Content-Type", "application/json")
285 .header("Accept", "application/json")
286 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false}))
287 .send().await.unwrap();
288 assert_eq!(auth_res.status(), StatusCode::OK);
289 let auth_body: Value = auth_res.json().await.unwrap();
290 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
291 if location.contains("/oauth/consent") {
292 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
293 .header("Content-Type", "application/json")
294 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
295 .send().await.unwrap();
296 let consent_body: Value = consent_res.json().await.unwrap();
297 location = consent_body["redirect_uri"].as_str().unwrap().to_string();
298 }
299 let code = location
300 .split("code=")
301 .nth(1)
302 .unwrap()
303 .split('&')
304 .next()
305 .unwrap();
306 let token_res = http_client
307 .post(format!("{}/oauth/token", url))
308 .form(&[
309 ("grant_type", "authorization_code"),
310 ("code", code),
311 ("redirect_uri", redirect_uri),
312 ("code_verifier", &attacker_verifier),
313 ("client_id", &client_id),
314 ])
315 .send()
316 .await
317 .unwrap();
318 assert_eq!(
319 token_res.status(),
320 StatusCode::BAD_REQUEST,
321 "Wrong PKCE verifier should be rejected"
322 );
323}
324
325#[tokio::test]
326async fn test_replay_attacks() {
327 let url = base_url().await;
328 let http_client = client();
329 let ts = Utc::now().timestamp_millis();
330 let handle = format!("replay-{}", ts);
331 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
332 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" }))
333 .send().await.unwrap();
334 let account: Value = create_res.json().await.unwrap();
335 verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
336 let redirect_uri = "https://example.com/replay-callback";
337 let mock_client = setup_mock_client_metadata(redirect_uri).await;
338 let client_id = mock_client.uri();
339 let (code_verifier, code_challenge) = generate_pkce();
340 let par_body: Value = http_client
341 .post(format!("{}/oauth/par", url))
342 .form(&[
343 ("response_type", "code"),
344 ("client_id", &client_id),
345 ("redirect_uri", redirect_uri),
346 ("code_challenge", &code_challenge),
347 ("code_challenge_method", "S256"),
348 ])
349 .send()
350 .await
351 .unwrap()
352 .json()
353 .await
354 .unwrap();
355 let request_uri = par_body["request_uri"].as_str().unwrap();
356 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
357 .header("Content-Type", "application/json")
358 .header("Accept", "application/json")
359 .json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false}))
360 .send().await.unwrap();
361 assert_eq!(auth_res.status(), StatusCode::OK);
362 let auth_body: Value = auth_res.json().await.unwrap();
363 let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
364 if location.contains("/oauth/consent") {
365 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
366 .header("Content-Type", "application/json")
367 .json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
368 .send().await.unwrap();
369 let consent_body: Value = consent_res.json().await.unwrap();
370 location = consent_body["redirect_uri"].as_str().unwrap().to_string();
371 }
372 let code = location
373 .split("code=")
374 .nth(1)
375 .unwrap()
376 .split('&')
377 .next()
378 .unwrap()
379 .to_string();
380 let first = http_client
381 .post(format!("{}/oauth/token", url))
382 .form(&[
383 ("grant_type", "authorization_code"),
384 ("code", &code),
385 ("redirect_uri", redirect_uri),
386 ("code_verifier", &code_verifier),
387 ("client_id", &client_id),
388 ])
389 .send()
390 .await
391 .unwrap();
392 assert_eq!(first.status(), StatusCode::OK, "First use should succeed");
393 let first_body: Value = first.json().await.unwrap();
394 let replay = http_client
395 .post(format!("{}/oauth/token", url))
396 .form(&[
397 ("grant_type", "authorization_code"),
398 ("code", &code),
399 ("redirect_uri", redirect_uri),
400 ("code_verifier", &code_verifier),
401 ("client_id", &client_id),
402 ])
403 .send()
404 .await
405 .unwrap();
406 assert_eq!(
407 replay.status(),
408 StatusCode::BAD_REQUEST,
409 "Auth code replay should fail"
410 );
411 let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string();
412 let first_refresh: Value = http_client
413 .post(format!("{}/oauth/token", url))
414 .form(&[
415 ("grant_type", "refresh_token"),
416 ("refresh_token", &stolen_rt),
417 ("client_id", &client_id),
418 ])
419 .send()
420 .await
421 .unwrap()
422 .json()
423 .await
424 .unwrap();
425 assert!(
426 first_refresh["access_token"].is_string(),
427 "First refresh should succeed"
428 );
429 let new_rt = first_refresh["refresh_token"].as_str().unwrap();
430 let rt_replay = http_client
431 .post(format!("{}/oauth/token", url))
432 .form(&[
433 ("grant_type", "refresh_token"),
434 ("refresh_token", &stolen_rt),
435 ("client_id", &client_id),
436 ])
437 .send()
438 .await
439 .unwrap();
440 assert_eq!(
441 rt_replay.status(),
442 StatusCode::BAD_REQUEST,
443 "Refresh token replay should fail"
444 );
445 let body: Value = rt_replay.json().await.unwrap();
446 assert!(
447 body["error_description"]
448 .as_str()
449 .unwrap()
450 .to_lowercase()
451 .contains("reuse")
452 );
453 let family_revoked = http_client
454 .post(format!("{}/oauth/token", url))
455 .form(&[
456 ("grant_type", "refresh_token"),
457 ("refresh_token", new_rt),
458 ("client_id", &client_id),
459 ])
460 .send()
461 .await
462 .unwrap();
463 assert_eq!(
464 family_revoked.status(),
465 StatusCode::BAD_REQUEST,
466 "Token family should be revoked"
467 );
468}
469
470#[tokio::test]
471async fn test_oauth_security_boundaries() {
472 let url = base_url().await;
473 let http_client = client();
474 let registered_redirect = "https://legitimate-app.com/callback";
475 let mock_client = setup_mock_client_metadata(registered_redirect).await;
476 let client_id = mock_client.uri();
477 let (_, code_challenge) = generate_pkce();
478 let res = http_client
479 .post(format!("{}/oauth/par", url))
480 .form(&[
481 ("response_type", "code"),
482 ("client_id", &client_id),
483 ("redirect_uri", "https://attacker.com/steal"),
484 ("code_challenge", &code_challenge),
485 ("code_challenge_method", "S256"),
486 ])
487 .send()
488 .await
489 .unwrap();
490 assert_eq!(
491 res.status(),
492 StatusCode::BAD_REQUEST,
493 "Unregistered redirect_uri should be rejected"
494 );
495 let ts = Utc::now().timestamp_millis();
496 let handle = format!("deact-{}", ts);
497 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
498 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" }))
499 .send().await.unwrap();
500 let account: Value = create_res.json().await.unwrap();
501 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
502 http_client
503 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
504 .bearer_auth(&access_jwt)
505 .json(&json!({}))
506 .send()
507 .await
508 .unwrap();
509 let deact_par: Value = http_client
510 .post(format!("{}/oauth/par", url))
511 .form(&[
512 ("response_type", "code"),
513 ("client_id", &client_id),
514 ("redirect_uri", registered_redirect),
515 ("code_challenge", &code_challenge),
516 ("code_challenge_method", "S256"),
517 ])
518 .send()
519 .await
520 .unwrap()
521 .json()
522 .await
523 .unwrap();
524 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
525 .header("Content-Type", "application/json")
526 .header("Accept", "application/json")
527 .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false}))
528 .send().await.unwrap();
529 assert_eq!(
530 auth_res.status(),
531 StatusCode::FORBIDDEN,
532 "Deactivated account should be blocked"
533 );
534 let redirect_uri_a = "https://app-a.com/callback";
535 let mock_a = setup_mock_client_metadata(redirect_uri_a).await;
536 let client_id_a = mock_a.uri();
537 let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await;
538 let client_id_b = mock_b.uri();
539 let ts2 = Utc::now().timestamp_millis();
540 let handle2 = format!("cross-{}", ts2);
541 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
542 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" }))
543 .send().await.unwrap();
544 let account2: Value = create_res2.json().await.unwrap();
545 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
546 let (code_verifier2, code_challenge2) = generate_pkce();
547 let par_a: Value = http_client
548 .post(format!("{}/oauth/par", url))
549 .form(&[
550 ("response_type", "code"),
551 ("client_id", &client_id_a),
552 ("redirect_uri", redirect_uri_a),
553 ("code_challenge", &code_challenge2),
554 ("code_challenge_method", "S256"),
555 ])
556 .send()
557 .await
558 .unwrap()
559 .json()
560 .await
561 .unwrap();
562 let request_uri_a = par_a["request_uri"].as_str().unwrap();
563 let auth_a = http_client.post(format!("{}/oauth/authorize", url))
564 .header("Content-Type", "application/json")
565 .header("Accept", "application/json")
566 .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false}))
567 .send().await.unwrap();
568 assert_eq!(auth_a.status(), StatusCode::OK);
569 let auth_body_a: Value = auth_a.json().await.unwrap();
570 let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string();
571 if loc_a.contains("/oauth/consent") {
572 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
573 .header("Content-Type", "application/json")
574 .json(&json!({"request_uri": request_uri_a, "approved_scopes": ["atproto"], "remember": false}))
575 .send().await.unwrap();
576 let consent_body: Value = consent_res.json().await.unwrap();
577 loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string();
578 }
579 let code_a = loc_a
580 .split("code=")
581 .nth(1)
582 .unwrap()
583 .split('&')
584 .next()
585 .unwrap();
586 let cross_client = http_client
587 .post(format!("{}/oauth/token", url))
588 .form(&[
589 ("grant_type", "authorization_code"),
590 ("code", code_a),
591 ("redirect_uri", redirect_uri_a),
592 ("code_verifier", &code_verifier2),
593 ("client_id", &client_id_b),
594 ])
595 .send()
596 .await
597 .unwrap();
598 assert_eq!(
599 cross_client.status(),
600 StatusCode::BAD_REQUEST,
601 "Cross-client code exchange must be rejected"
602 );
603}
604
605#[tokio::test]
606async fn test_malformed_tokens_and_headers() {
607 let url = base_url().await;
608 let http_client = client();
609 let malformed = vec![
610 "",
611 "not-a-token",
612 "one.two",
613 "one.two.three.four",
614 "....",
615 "eyJhbGciOiJIUzI1NiJ9",
616 "eyJhbGciOiJIUzI1NiJ9.",
617 "eyJhbGciOiJIUzI1NiJ9..",
618 ".eyJzdWIiOiJ0ZXN0In0.",
619 "!!invalid!!.eyJ9.sig",
620 ];
621 for token in &malformed {
622 assert_eq!(
623 http_client
624 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
625 .bearer_auth(token)
626 .send()
627 .await
628 .unwrap()
629 .status(),
630 StatusCode::UNAUTHORIZED
631 );
632 }
633 let wrong_types = vec!["JWT", "jwt", "at+JWT", ""];
634 for typ in wrong_types {
635 let header = json!({ "alg": "HS256", "typ": typ });
636 let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" });
637 let token = format!(
638 "{}.{}.{}",
639 URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
640 URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
641 URL_SAFE_NO_PAD.encode(&[1u8; 32])
642 );
643 assert_eq!(
644 http_client
645 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
646 .bearer_auth(&token)
647 .send()
648 .await
649 .unwrap()
650 .status(),
651 StatusCode::UNAUTHORIZED,
652 "typ='{}' should be rejected",
653 typ
654 );
655 }
656 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
657 let invalid_formats = vec![
658 format!("Basic {}", access_token),
659 format!("Digest {}", access_token),
660 access_token.clone(),
661 format!("Bearer{}", access_token),
662 ];
663 for auth in &invalid_formats {
664 assert_eq!(
665 http_client
666 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
667 .header("Authorization", auth)
668 .send()
669 .await
670 .unwrap()
671 .status(),
672 StatusCode::UNAUTHORIZED
673 );
674 }
675 assert_eq!(
676 http_client
677 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
678 .send()
679 .await
680 .unwrap()
681 .status(),
682 StatusCode::UNAUTHORIZED
683 );
684 assert_eq!(
685 http_client
686 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
687 .header("Authorization", "")
688 .send()
689 .await
690 .unwrap()
691 .status(),
692 StatusCode::UNAUTHORIZED
693 );
694 let grants = vec![
695 "client_credentials",
696 "password",
697 "implicit",
698 "",
699 "AUTHORIZATION_CODE",
700 ];
701 for grant in grants {
702 assert_eq!(
703 http_client
704 .post(format!("{}/oauth/token", url))
705 .form(&[("grant_type", grant), ("client_id", "https://example.com")])
706 .send()
707 .await
708 .unwrap()
709 .status(),
710 StatusCode::BAD_REQUEST,
711 "Grant '{}' should be rejected",
712 grant
713 );
714 }
715}
716
717#[tokio::test]
718async fn test_token_revocation() {
719 let url = base_url().await;
720 let http_client = client();
721 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
722 assert_eq!(
723 http_client
724 .post(format!("{}/oauth/revoke", url))
725 .form(&[("token", &refresh_token)])
726 .send()
727 .await
728 .unwrap()
729 .status(),
730 StatusCode::OK
731 );
732 let introspect: Value = http_client
733 .post(format!("{}/oauth/introspect", url))
734 .form(&[("token", &access_token)])
735 .send()
736 .await
737 .unwrap()
738 .json()
739 .await
740 .unwrap();
741 assert_eq!(
742 introspect["active"], false,
743 "Revoked token should be inactive"
744 );
745}
746
747fn create_dpop_proof(
748 method: &str,
749 uri: &str,
750 _nonce: Option<&str>,
751 ath: Option<&str>,
752 iat_offset: i64,
753) -> String {
754 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
755 use p256::elliptic_curve::sec1::ToEncodedPoint;
756 let signing_key = SigningKey::random(&mut rand::thread_rng());
757 let point = signing_key.verifying_key().to_encoded_point(false);
758 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
759 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
760 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
761 let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
762 "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset });
763 if let Some(a) = ath {
764 payload["ath"] = json!(a);
765 }
766 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
767 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
768 let signing_input = format!("{}.{}", header_b64, payload_b64);
769 let signature: Signature = signing_key.sign(signing_input.as_bytes());
770 format!(
771 "{}.{}",
772 signing_input,
773 URL_SAFE_NO_PAD.encode(signature.to_bytes())
774 )
775}
776
777#[test]
778fn test_dpop_nonce_security() {
779 let secret1 = b"test-dpop-secret-32-bytes-long!!";
780 let secret2 = b"different-secret-32-bytes-long!!";
781 let v1 = DPoPVerifier::new(secret1);
782 let v2 = DPoPVerifier::new(secret2);
783 let nonce = v1.generate_nonce();
784 assert!(!nonce.is_empty());
785 assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass");
786 assert!(
787 v2.validate_nonce(&nonce).is_err(),
788 "Nonce from different secret should fail"
789 );
790 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
791 let mut tampered = nonce_bytes.clone();
792 if !tampered.is_empty() {
793 tampered[0] ^= 0xFF;
794 }
795 assert!(
796 v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered))
797 .is_err(),
798 "Tampered nonce should fail"
799 );
800 assert!(v1.validate_nonce("invalid").is_err());
801 assert!(v1.validate_nonce("").is_err());
802 assert!(v1.validate_nonce("!!!not-base64!!!").is_err());
803}
804
805#[test]
806fn test_dpop_proof_validation() {
807 let secret = b"test-dpop-secret-32-bytes-long!!";
808 let verifier = DPoPVerifier::new(secret);
809 assert!(
810 verifier
811 .verify_proof("not.enough", "POST", "https://example.com", None)
812 .is_err()
813 );
814 assert!(
815 verifier
816 .verify_proof("invalid", "POST", "https://example.com", None)
817 .is_err()
818 );
819 let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
820 assert!(
821 verifier
822 .verify_proof(&proof, "GET", "https://example.com/token", None)
823 .is_err(),
824 "Method mismatch"
825 );
826 assert!(
827 verifier
828 .verify_proof(&proof, "POST", "https://other.com/token", None)
829 .is_err(),
830 "URI mismatch"
831 );
832 assert!(
833 verifier
834 .verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None)
835 .is_ok(),
836 "Query params should be ignored"
837 );
838 let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
839 assert!(
840 verifier
841 .verify_proof(&old_proof, "POST", "https://example.com/token", None)
842 .is_err(),
843 "iat too old"
844 );
845 let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
846 assert!(
847 verifier
848 .verify_proof(&future_proof, "POST", "https://example.com/token", None)
849 .is_err(),
850 "iat in future"
851 );
852 let ath_proof = create_dpop_proof(
853 "GET",
854 "https://example.com/resource",
855 None,
856 Some("wrong"),
857 0,
858 );
859 assert!(
860 verifier
861 .verify_proof(
862 &ath_proof,
863 "GET",
864 "https://example.com/resource",
865 Some("correct")
866 )
867 .is_err(),
868 "ath mismatch"
869 );
870 let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
871 assert!(
872 verifier
873 .verify_proof(
874 &no_ath_proof,
875 "GET",
876 "https://example.com/resource",
877 Some("expected")
878 )
879 .is_err(),
880 "Missing ath"
881 );
882}
883
884#[test]
885fn test_dpop_proof_signature_attacks() {
886 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
887 use p256::elliptic_curve::sec1::ToEncodedPoint;
888 let secret = b"test-dpop-secret-32-bytes-long!!";
889 let verifier = DPoPVerifier::new(secret);
890 let signing_key = SigningKey::random(&mut rand::thread_rng());
891 let attacker_key = SigningKey::random(&mut rand::thread_rng());
892 let attacker_point = attacker_key.verifying_key().to_encoded_point(false);
893 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
894 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
895 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
896 let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
897 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
898 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
899 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
900 let signing_input = format!("{}.{}", header_b64, payload_b64);
901 let signature: Signature = signing_key.sign(signing_input.as_bytes());
902 let mismatched = format!(
903 "{}.{}",
904 signing_input,
905 URL_SAFE_NO_PAD.encode(signature.to_bytes())
906 );
907 assert!(
908 verifier
909 .verify_proof(&mismatched, "POST", "https://example.com/token", None)
910 .is_err(),
911 "Mismatched key should fail"
912 );
913 let point = signing_key.verifying_key().to_encoded_point(false);
914 let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256",
915 "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } });
916 let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap());
917 let good_input = format!("{}.{}", good_header_b64, payload_b64);
918 let good_sig: Signature = signing_key.sign(good_input.as_bytes());
919 let mut sig_bytes = good_sig.to_bytes().to_vec();
920 sig_bytes[0] ^= 0xFF;
921 let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes));
922 assert!(
923 verifier
924 .verify_proof(&tampered, "POST", "https://example.com/token", None)
925 .is_err(),
926 "Tampered sig should fail"
927 );
928}
929
930#[test]
931fn test_jwk_thumbprint() {
932 let jwk = DPoPJwk {
933 kty: "EC".to_string(),
934 crv: Some("P-256".to_string()),
935 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
936 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
937 };
938 let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
939 let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
940 assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
941 assert!(!tp1.is_empty());
942 assert!(
943 compute_jwk_thumbprint(&DPoPJwk {
944 kty: "EC".to_string(),
945 crv: Some("secp256k1".to_string()),
946 x: Some("x".to_string()),
947 y: Some("y".to_string())
948 })
949 .is_ok()
950 );
951 assert!(
952 compute_jwk_thumbprint(&DPoPJwk {
953 kty: "OKP".to_string(),
954 crv: Some("Ed25519".to_string()),
955 x: Some("x".to_string()),
956 y: None
957 })
958 .is_ok()
959 );
960 assert!(
961 compute_jwk_thumbprint(&DPoPJwk {
962 kty: "EC".to_string(),
963 crv: None,
964 x: Some("x".to_string()),
965 y: Some("y".to_string())
966 })
967 .is_err()
968 );
969 assert!(
970 compute_jwk_thumbprint(&DPoPJwk {
971 kty: "EC".to_string(),
972 crv: Some("P-256".to_string()),
973 x: None,
974 y: Some("y".to_string())
975 })
976 .is_err()
977 );
978 assert!(
979 compute_jwk_thumbprint(&DPoPJwk {
980 kty: "EC".to_string(),
981 crv: Some("P-256".to_string()),
982 x: Some("x".to_string()),
983 y: None
984 })
985 .is_err()
986 );
987 assert!(
988 compute_jwk_thumbprint(&DPoPJwk {
989 kty: "RSA".to_string(),
990 crv: None,
991 x: None,
992 y: None
993 })
994 .is_err()
995 );
996}
997
998#[test]
999fn test_dpop_clock_skew() {
1000 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
1001 use p256::elliptic_curve::sec1::ToEncodedPoint;
1002 let secret = b"test-dpop-secret-32-bytes-long!!";
1003 let verifier = DPoPVerifier::new(secret);
1004 let test_cases = vec![
1005 (-600, true),
1006 (-301, true),
1007 (-299, false),
1008 (0, false),
1009 (299, false),
1010 (301, true),
1011 (600, true),
1012 ];
1013 for (offset, should_fail) in test_cases {
1014 let signing_key = SigningKey::random(&mut rand::thread_rng());
1015 let point = signing_key.verifying_key().to_encoded_point(false);
1016 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1017 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1018 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
1019 let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1020 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset });
1021 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1022 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1023 let signing_input = format!("{}.{}", header_b64, payload_b64);
1024 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1025 let proof = format!(
1026 "{}.{}",
1027 signing_input,
1028 URL_SAFE_NO_PAD.encode(signature.to_bytes())
1029 );
1030 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1031 if should_fail {
1032 assert!(result.is_err(), "offset {} should fail", offset);
1033 } else {
1034 assert!(result.is_ok(), "offset {} should pass", offset);
1035 }
1036 }
1037}
1038
1039#[test]
1040fn test_dpop_http_method_case() {
1041 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
1042 use p256::elliptic_curve::sec1::ToEncodedPoint;
1043 let secret = b"test-dpop-secret-32-bytes-long!!";
1044 let verifier = DPoPVerifier::new(secret);
1045 let signing_key = SigningKey::random(&mut rand::thread_rng());
1046 let point = signing_key.verifying_key().to_encoded_point(false);
1047 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1048 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1049 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
1050 let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1051 "htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
1052 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1053 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1054 let signing_input = format!("{}.{}", header_b64, payload_b64);
1055 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1056 let proof = format!(
1057 "{}.{}",
1058 signing_input,
1059 URL_SAFE_NO_PAD.encode(signature.to_bytes())
1060 );
1061 assert!(
1062 verifier
1063 .verify_proof(&proof, "POST", "https://example.com/token", None)
1064 .is_ok(),
1065 "HTTP method should be case-insensitive"
1066 );
1067}