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, create_account_and_login};
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 suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
45 let handle = format!("se{}", suffix);
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 suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
259 let handle = format!("pa{}", suffix);
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 suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
330 let handle = format!("rp{}", suffix);
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::OK,
443 "Refresh token reuse within grace period should return existing tokens"
444 );
445 let grace_body: Value = rt_replay.json().await.unwrap();
446 assert_eq!(
447 grace_body["refresh_token"].as_str().unwrap(),
448 new_rt,
449 "Grace period response should return the current refresh token"
450 );
451 let second_refresh: Value = http_client
452 .post(format!("{}/oauth/token", url))
453 .form(&[
454 ("grant_type", "refresh_token"),
455 ("refresh_token", new_rt),
456 ("client_id", &client_id),
457 ])
458 .send()
459 .await
460 .unwrap()
461 .json()
462 .await
463 .unwrap();
464 assert!(
465 second_refresh["access_token"].is_string(),
466 "Second refresh with new token should succeed"
467 );
468 let newest_rt = second_refresh["refresh_token"].as_str().unwrap();
469 let replay_after_rotation = http_client
470 .post(format!("{}/oauth/token", url))
471 .form(&[
472 ("grant_type", "refresh_token"),
473 ("refresh_token", &stolen_rt),
474 ("client_id", &client_id),
475 ])
476 .send()
477 .await
478 .unwrap();
479 assert_eq!(
480 replay_after_rotation.status(),
481 StatusCode::BAD_REQUEST,
482 "Replay of original token after another rotation should fail"
483 );
484 let body: Value = replay_after_rotation.json().await.unwrap();
485 assert!(
486 body["error_description"]
487 .as_str()
488 .unwrap()
489 .to_lowercase()
490 .contains("reuse"),
491 "Error should indicate token reuse"
492 );
493 let family_revoked = http_client
494 .post(format!("{}/oauth/token", url))
495 .form(&[
496 ("grant_type", "refresh_token"),
497 ("refresh_token", newest_rt),
498 ("client_id", &client_id),
499 ])
500 .send()
501 .await
502 .unwrap();
503 assert_eq!(
504 family_revoked.status(),
505 StatusCode::BAD_REQUEST,
506 "Token family should be revoked after replay detection"
507 );
508}
509
510#[tokio::test]
511async fn test_oauth_security_boundaries() {
512 let url = base_url().await;
513 let http_client = client();
514 let registered_redirect = "https://legitimate-app.com/callback";
515 let mock_client = setup_mock_client_metadata(registered_redirect).await;
516 let client_id = mock_client.uri();
517 let (_, code_challenge) = generate_pkce();
518 let res = http_client
519 .post(format!("{}/oauth/par", url))
520 .form(&[
521 ("response_type", "code"),
522 ("client_id", &client_id),
523 ("redirect_uri", "https://attacker.com/steal"),
524 ("code_challenge", &code_challenge),
525 ("code_challenge_method", "S256"),
526 ])
527 .send()
528 .await
529 .unwrap();
530 assert_eq!(
531 res.status(),
532 StatusCode::BAD_REQUEST,
533 "Unregistered redirect_uri should be rejected"
534 );
535 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
536 let handle = format!("da{}", suffix);
537 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
538 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" }))
539 .send().await.unwrap();
540 let account: Value = create_res.json().await.unwrap();
541 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
542 http_client
543 .post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
544 .bearer_auth(&access_jwt)
545 .json(&json!({}))
546 .send()
547 .await
548 .unwrap();
549 let deact_par: Value = http_client
550 .post(format!("{}/oauth/par", url))
551 .form(&[
552 ("response_type", "code"),
553 ("client_id", &client_id),
554 ("redirect_uri", registered_redirect),
555 ("code_challenge", &code_challenge),
556 ("code_challenge_method", "S256"),
557 ])
558 .send()
559 .await
560 .unwrap()
561 .json()
562 .await
563 .unwrap();
564 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
565 .header("Content-Type", "application/json")
566 .header("Accept", "application/json")
567 .json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false}))
568 .send().await.unwrap();
569 assert_eq!(
570 auth_res.status(),
571 StatusCode::FORBIDDEN,
572 "Deactivated account should be blocked"
573 );
574 let redirect_uri_a = "https://app-a.com/callback";
575 let mock_a = setup_mock_client_metadata(redirect_uri_a).await;
576 let client_id_a = mock_a.uri();
577 let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await;
578 let client_id_b = mock_b.uri();
579 let suffix2 = &uuid::Uuid::new_v4().simple().to_string()[..8];
580 let handle2 = format!("cr{}", suffix2);
581 let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
582 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" }))
583 .send().await.unwrap();
584 let account2: Value = create_res2.json().await.unwrap();
585 verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
586 let (code_verifier2, code_challenge2) = generate_pkce();
587 let par_a: Value = http_client
588 .post(format!("{}/oauth/par", url))
589 .form(&[
590 ("response_type", "code"),
591 ("client_id", &client_id_a),
592 ("redirect_uri", redirect_uri_a),
593 ("code_challenge", &code_challenge2),
594 ("code_challenge_method", "S256"),
595 ])
596 .send()
597 .await
598 .unwrap()
599 .json()
600 .await
601 .unwrap();
602 let request_uri_a = par_a["request_uri"].as_str().unwrap();
603 let auth_a = http_client.post(format!("{}/oauth/authorize", url))
604 .header("Content-Type", "application/json")
605 .header("Accept", "application/json")
606 .json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false}))
607 .send().await.unwrap();
608 assert_eq!(auth_a.status(), StatusCode::OK);
609 let auth_body_a: Value = auth_a.json().await.unwrap();
610 let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string();
611 if loc_a.contains("/oauth/consent") {
612 let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
613 .header("Content-Type", "application/json")
614 .json(&json!({"request_uri": request_uri_a, "approved_scopes": ["atproto"], "remember": false}))
615 .send().await.unwrap();
616 let consent_body: Value = consent_res.json().await.unwrap();
617 loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string();
618 }
619 let code_a = loc_a
620 .split("code=")
621 .nth(1)
622 .unwrap()
623 .split('&')
624 .next()
625 .unwrap();
626 let cross_client = http_client
627 .post(format!("{}/oauth/token", url))
628 .form(&[
629 ("grant_type", "authorization_code"),
630 ("code", code_a),
631 ("redirect_uri", redirect_uri_a),
632 ("code_verifier", &code_verifier2),
633 ("client_id", &client_id_b),
634 ])
635 .send()
636 .await
637 .unwrap();
638 assert_eq!(
639 cross_client.status(),
640 StatusCode::BAD_REQUEST,
641 "Cross-client code exchange must be rejected"
642 );
643}
644
645#[tokio::test]
646async fn test_malformed_tokens_and_headers() {
647 let url = base_url().await;
648 let http_client = client();
649 let malformed = vec![
650 "",
651 "not-a-token",
652 "one.two",
653 "one.two.three.four",
654 "....",
655 "eyJhbGciOiJIUzI1NiJ9",
656 "eyJhbGciOiJIUzI1NiJ9.",
657 "eyJhbGciOiJIUzI1NiJ9..",
658 ".eyJzdWIiOiJ0ZXN0In0.",
659 "!!invalid!!.eyJ9.sig",
660 ];
661 for token in &malformed {
662 assert_eq!(
663 http_client
664 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
665 .bearer_auth(token)
666 .send()
667 .await
668 .unwrap()
669 .status(),
670 StatusCode::UNAUTHORIZED
671 );
672 }
673 let wrong_types = vec!["JWT", "jwt", "at+JWT", ""];
674 for typ in wrong_types {
675 let header = json!({ "alg": "HS256", "typ": typ });
676 let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" });
677 let token = format!(
678 "{}.{}.{}",
679 URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
680 URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
681 URL_SAFE_NO_PAD.encode([1u8; 32])
682 );
683 assert_eq!(
684 http_client
685 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
686 .bearer_auth(&token)
687 .send()
688 .await
689 .unwrap()
690 .status(),
691 StatusCode::UNAUTHORIZED,
692 "typ='{}' should be rejected",
693 typ
694 );
695 }
696 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
697 let invalid_formats = vec![
698 format!("Basic {}", access_token),
699 format!("Digest {}", access_token),
700 access_token.clone(),
701 format!("Bearer{}", access_token),
702 ];
703 for auth in &invalid_formats {
704 assert_eq!(
705 http_client
706 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
707 .header("Authorization", auth)
708 .send()
709 .await
710 .unwrap()
711 .status(),
712 StatusCode::UNAUTHORIZED
713 );
714 }
715 assert_eq!(
716 http_client
717 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
718 .send()
719 .await
720 .unwrap()
721 .status(),
722 StatusCode::UNAUTHORIZED
723 );
724 assert_eq!(
725 http_client
726 .get(format!("{}/xrpc/com.atproto.server.getSession", url))
727 .header("Authorization", "")
728 .send()
729 .await
730 .unwrap()
731 .status(),
732 StatusCode::UNAUTHORIZED
733 );
734 let grants = vec![
735 "client_credentials",
736 "password",
737 "implicit",
738 "",
739 "AUTHORIZATION_CODE",
740 ];
741 for grant in grants {
742 assert_eq!(
743 http_client
744 .post(format!("{}/oauth/token", url))
745 .form(&[("grant_type", grant), ("client_id", "https://example.com")])
746 .send()
747 .await
748 .unwrap()
749 .status(),
750 StatusCode::BAD_REQUEST,
751 "Grant '{}' should be rejected",
752 grant
753 );
754 }
755}
756
757#[tokio::test]
758async fn test_token_revocation() {
759 let url = base_url().await;
760 let http_client = client();
761 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
762 assert_eq!(
763 http_client
764 .post(format!("{}/oauth/revoke", url))
765 .form(&[("token", &refresh_token)])
766 .send()
767 .await
768 .unwrap()
769 .status(),
770 StatusCode::OK
771 );
772 let introspect: Value = http_client
773 .post(format!("{}/oauth/introspect", url))
774 .form(&[("token", &access_token)])
775 .send()
776 .await
777 .unwrap()
778 .json()
779 .await
780 .unwrap();
781 assert_eq!(
782 introspect["active"], false,
783 "Revoked token should be inactive"
784 );
785}
786
787fn create_dpop_proof(
788 method: &str,
789 uri: &str,
790 _nonce: Option<&str>,
791 ath: Option<&str>,
792 iat_offset: i64,
793) -> String {
794 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
795 use p256::elliptic_curve::sec1::ToEncodedPoint;
796 let signing_key = SigningKey::random(&mut rand::thread_rng());
797 let point = signing_key.verifying_key().to_encoded_point(false);
798 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
799 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
800 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
801 let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
802 "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset });
803 if let Some(a) = ath {
804 payload["ath"] = json!(a);
805 }
806 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
807 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
808 let signing_input = format!("{}.{}", header_b64, payload_b64);
809 let signature: Signature = signing_key.sign(signing_input.as_bytes());
810 format!(
811 "{}.{}",
812 signing_input,
813 URL_SAFE_NO_PAD.encode(signature.to_bytes())
814 )
815}
816
817#[test]
818fn test_dpop_nonce_security() {
819 let secret1 = b"test-dpop-secret-32-bytes-long!!";
820 let secret2 = b"different-secret-32-bytes-long!!";
821 let v1 = DPoPVerifier::new(secret1);
822 let v2 = DPoPVerifier::new(secret2);
823 let nonce = v1.generate_nonce();
824 assert!(!nonce.is_empty());
825 assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass");
826 assert!(
827 v2.validate_nonce(&nonce).is_err(),
828 "Nonce from different secret should fail"
829 );
830 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
831 let mut tampered = nonce_bytes.clone();
832 if !tampered.is_empty() {
833 tampered[0] ^= 0xFF;
834 }
835 assert!(
836 v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered))
837 .is_err(),
838 "Tampered nonce should fail"
839 );
840 assert!(v1.validate_nonce("invalid").is_err());
841 assert!(v1.validate_nonce("").is_err());
842 assert!(v1.validate_nonce("!!!not-base64!!!").is_err());
843}
844
845#[test]
846fn test_dpop_proof_validation() {
847 let secret = b"test-dpop-secret-32-bytes-long!!";
848 let verifier = DPoPVerifier::new(secret);
849 assert!(
850 verifier
851 .verify_proof("not.enough", "POST", "https://example.com", None)
852 .is_err()
853 );
854 assert!(
855 verifier
856 .verify_proof("invalid", "POST", "https://example.com", None)
857 .is_err()
858 );
859 let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
860 assert!(
861 verifier
862 .verify_proof(&proof, "GET", "https://example.com/token", None)
863 .is_err(),
864 "Method mismatch"
865 );
866 assert!(
867 verifier
868 .verify_proof(&proof, "POST", "https://other.com/token", None)
869 .is_err(),
870 "URI mismatch"
871 );
872 assert!(
873 verifier
874 .verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None)
875 .is_ok(),
876 "Query params should be ignored"
877 );
878 let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
879 assert!(
880 verifier
881 .verify_proof(&old_proof, "POST", "https://example.com/token", None)
882 .is_err(),
883 "iat too old"
884 );
885 let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
886 assert!(
887 verifier
888 .verify_proof(&future_proof, "POST", "https://example.com/token", None)
889 .is_err(),
890 "iat in future"
891 );
892 let ath_proof = create_dpop_proof(
893 "GET",
894 "https://example.com/resource",
895 None,
896 Some("wrong"),
897 0,
898 );
899 assert!(
900 verifier
901 .verify_proof(
902 &ath_proof,
903 "GET",
904 "https://example.com/resource",
905 Some("correct")
906 )
907 .is_err(),
908 "ath mismatch"
909 );
910 let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
911 assert!(
912 verifier
913 .verify_proof(
914 &no_ath_proof,
915 "GET",
916 "https://example.com/resource",
917 Some("expected")
918 )
919 .is_err(),
920 "Missing ath"
921 );
922}
923
924#[test]
925fn test_dpop_proof_signature_attacks() {
926 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
927 use p256::elliptic_curve::sec1::ToEncodedPoint;
928 let secret = b"test-dpop-secret-32-bytes-long!!";
929 let verifier = DPoPVerifier::new(secret);
930 let signing_key = SigningKey::random(&mut rand::thread_rng());
931 let attacker_key = SigningKey::random(&mut rand::thread_rng());
932 let attacker_point = attacker_key.verifying_key().to_encoded_point(false);
933 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
934 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
935 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
936 let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
937 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
938 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
939 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
940 let signing_input = format!("{}.{}", header_b64, payload_b64);
941 let signature: Signature = signing_key.sign(signing_input.as_bytes());
942 let mismatched = format!(
943 "{}.{}",
944 signing_input,
945 URL_SAFE_NO_PAD.encode(signature.to_bytes())
946 );
947 assert!(
948 verifier
949 .verify_proof(&mismatched, "POST", "https://example.com/token", None)
950 .is_err(),
951 "Mismatched key should fail"
952 );
953 let point = signing_key.verifying_key().to_encoded_point(false);
954 let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256",
955 "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } });
956 let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap());
957 let good_input = format!("{}.{}", good_header_b64, payload_b64);
958 let good_sig: Signature = signing_key.sign(good_input.as_bytes());
959 let mut sig_bytes = good_sig.to_bytes().to_vec();
960 sig_bytes[0] ^= 0xFF;
961 let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes));
962 assert!(
963 verifier
964 .verify_proof(&tampered, "POST", "https://example.com/token", None)
965 .is_err(),
966 "Tampered sig should fail"
967 );
968}
969
970#[test]
971fn test_jwk_thumbprint() {
972 let jwk = DPoPJwk {
973 kty: "EC".to_string(),
974 crv: Some("P-256".to_string()),
975 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
976 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
977 };
978 let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
979 let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
980 assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
981 assert!(!tp1.is_empty());
982 assert!(
983 compute_jwk_thumbprint(&DPoPJwk {
984 kty: "EC".to_string(),
985 crv: Some("secp256k1".to_string()),
986 x: Some("x".to_string()),
987 y: Some("y".to_string())
988 })
989 .is_ok()
990 );
991 assert!(
992 compute_jwk_thumbprint(&DPoPJwk {
993 kty: "OKP".to_string(),
994 crv: Some("Ed25519".to_string()),
995 x: Some("x".to_string()),
996 y: None
997 })
998 .is_ok()
999 );
1000 assert!(
1001 compute_jwk_thumbprint(&DPoPJwk {
1002 kty: "EC".to_string(),
1003 crv: None,
1004 x: Some("x".to_string()),
1005 y: Some("y".to_string())
1006 })
1007 .is_err()
1008 );
1009 assert!(
1010 compute_jwk_thumbprint(&DPoPJwk {
1011 kty: "EC".to_string(),
1012 crv: Some("P-256".to_string()),
1013 x: None,
1014 y: Some("y".to_string())
1015 })
1016 .is_err()
1017 );
1018 assert!(
1019 compute_jwk_thumbprint(&DPoPJwk {
1020 kty: "EC".to_string(),
1021 crv: Some("P-256".to_string()),
1022 x: Some("x".to_string()),
1023 y: None
1024 })
1025 .is_err()
1026 );
1027 assert!(
1028 compute_jwk_thumbprint(&DPoPJwk {
1029 kty: "RSA".to_string(),
1030 crv: None,
1031 x: None,
1032 y: None
1033 })
1034 .is_err()
1035 );
1036}
1037
1038#[test]
1039fn test_dpop_clock_skew() {
1040 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
1041 use p256::elliptic_curve::sec1::ToEncodedPoint;
1042 let secret = b"test-dpop-secret-32-bytes-long!!";
1043 let verifier = DPoPVerifier::new(secret);
1044 let test_cases = vec![
1045 (-600, true),
1046 (-301, true),
1047 (-299, false),
1048 (0, false),
1049 (299, false),
1050 (301, true),
1051 (600, true),
1052 ];
1053 for (offset, should_fail) in test_cases {
1054 let signing_key = SigningKey::random(&mut rand::thread_rng());
1055 let point = signing_key.verifying_key().to_encoded_point(false);
1056 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1057 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1058 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
1059 let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1060 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset });
1061 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1062 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1063 let signing_input = format!("{}.{}", header_b64, payload_b64);
1064 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1065 let proof = format!(
1066 "{}.{}",
1067 signing_input,
1068 URL_SAFE_NO_PAD.encode(signature.to_bytes())
1069 );
1070 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
1071 if should_fail {
1072 assert!(result.is_err(), "offset {} should fail", offset);
1073 } else {
1074 assert!(result.is_ok(), "offset {} should pass", offset);
1075 }
1076 }
1077}
1078
1079#[test]
1080fn test_dpop_http_method_case() {
1081 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
1082 use p256::elliptic_curve::sec1::ToEncodedPoint;
1083 let secret = b"test-dpop-secret-32-bytes-long!!";
1084 let verifier = DPoPVerifier::new(secret);
1085 let signing_key = SigningKey::random(&mut rand::thread_rng());
1086 let point = signing_key.verifying_key().to_encoded_point(false);
1087 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
1088 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
1089 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
1090 let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
1091 "htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
1092 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
1093 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
1094 let signing_input = format!("{}.{}", header_b64, payload_b64);
1095 let signature: Signature = signing_key.sign(signing_input.as_bytes());
1096 let proof = format!(
1097 "{}.{}",
1098 signing_input,
1099 URL_SAFE_NO_PAD.encode(signature.to_bytes())
1100 );
1101 assert!(
1102 verifier
1103 .verify_proof(&proof, "POST", "https://example.com/token", None)
1104 .is_ok(),
1105 "HTTP method should be case-insensitive"
1106 );
1107}
1108
1109#[tokio::test]
1110async fn test_delegation_viewer_scope_cannot_write() {
1111 let url = base_url().await;
1112 let http_client = client();
1113 let suffix = &uuid::Uuid::new_v4().simple().to_string()[..8];
1114
1115 let (controller_jwt, controller_did) = create_account_and_login(&http_client).await;
1116
1117 let delegated_handle = format!("dg{}", suffix);
1118 let delegated_res = http_client
1119 .post(format!("{}/xrpc/_delegation.createDelegatedAccount", url))
1120 .bearer_auth(controller_jwt)
1121 .json(&json!({
1122 "handle": delegated_handle,
1123 "controllerScopes": ""
1124 }))
1125 .send()
1126 .await
1127 .unwrap();
1128 if delegated_res.status() != StatusCode::OK {
1129 let error_body = delegated_res.text().await.unwrap();
1130 panic!("Failed to create delegated account: {}", error_body);
1131 }
1132 let delegated_account: Value = delegated_res.json().await.unwrap();
1133 let delegated_did = delegated_account["did"].as_str().unwrap();
1134
1135 let redirect_uri = "https://example.com/deleg-callback";
1136 let mock_client = setup_mock_client_metadata(redirect_uri).await;
1137 let client_id = mock_client.uri();
1138 let (code_verifier, code_challenge) = generate_pkce();
1139
1140 let par_body: Value = http_client
1141 .post(format!("{}/oauth/par", url))
1142 .form(&[
1143 ("response_type", "code"),
1144 ("client_id", &client_id),
1145 ("redirect_uri", redirect_uri),
1146 ("code_challenge", &code_challenge),
1147 ("code_challenge_method", "S256"),
1148 ("scope", "atproto"),
1149 ("login_hint", delegated_did),
1150 ])
1151 .send()
1152 .await
1153 .unwrap()
1154 .json()
1155 .await
1156 .unwrap();
1157 let request_uri = par_body["request_uri"].as_str().unwrap();
1158
1159 let auth_res = http_client
1160 .post(format!("{}/oauth/delegation/auth", url))
1161 .header("Content-Type", "application/json")
1162 .json(&json!({
1163 "request_uri": request_uri,
1164 "delegated_did": delegated_did,
1165 "controller_did": controller_did,
1166 "password": "Testpass123!",
1167 "remember_device": false
1168 }))
1169 .send()
1170 .await
1171 .unwrap();
1172 if auth_res.status() != StatusCode::OK {
1173 let error_body = auth_res.text().await.unwrap();
1174 panic!("Delegation auth failed: {}", error_body);
1175 }
1176 let auth_body: Value = auth_res.json().await.unwrap();
1177 assert!(
1178 auth_body["success"].as_bool().unwrap_or(false),
1179 "Delegation auth should succeed: {:?}",
1180 auth_body
1181 );
1182
1183 let consent_res = http_client
1184 .post(format!("{}/oauth/authorize/consent", url))
1185 .header("Content-Type", "application/json")
1186 .json(&json!({
1187 "request_uri": request_uri,
1188 "approved_scopes": ["atproto"],
1189 "remember": false
1190 }))
1191 .send()
1192 .await
1193 .unwrap();
1194 if consent_res.status() != StatusCode::OK {
1195 let error_body = consent_res.text().await.unwrap();
1196 panic!("Consent failed: {}", error_body);
1197 }
1198 let consent_body: Value = consent_res.json().await.unwrap();
1199 let location = consent_body["redirect_uri"].as_str().unwrap();
1200
1201 let code = location
1202 .split("code=")
1203 .nth(1)
1204 .unwrap()
1205 .split('&')
1206 .next()
1207 .unwrap();
1208
1209 let token_res = http_client
1210 .post(format!("{}/oauth/token", url))
1211 .form(&[
1212 ("grant_type", "authorization_code"),
1213 ("code", code),
1214 ("redirect_uri", redirect_uri),
1215 ("code_verifier", &code_verifier),
1216 ("client_id", &client_id),
1217 ])
1218 .send()
1219 .await
1220 .unwrap();
1221 assert_eq!(token_res.status(), StatusCode::OK);
1222 let tokens: Value = token_res.json().await.unwrap();
1223 let access_token = tokens["access_token"].as_str().unwrap();
1224
1225 let create_post_res = http_client
1226 .post(format!("{}/xrpc/com.atproto.repo.createRecord", url))
1227 .bearer_auth(access_token)
1228 .json(&json!({
1229 "repo": delegated_did,
1230 "collection": "app.bsky.feed.post",
1231 "record": {
1232 "$type": "app.bsky.feed.post",
1233 "text": "Test post from viewer",
1234 "createdAt": Utc::now().to_rfc3339()
1235 }
1236 }))
1237 .send()
1238 .await
1239 .unwrap();
1240
1241 assert_eq!(
1242 create_post_res.status(),
1243 StatusCode::FORBIDDEN,
1244 "Viewer scope delegation should not be able to create posts"
1245 );
1246 let error_body: Value = create_post_res.json().await.unwrap();
1247 assert_eq!(
1248 error_body["error"].as_str().unwrap(),
1249 "InsufficientScope",
1250 "Error should be InsufficientScope"
1251 );
1252}