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 bspds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint};
6use chrono::Utc;
7use common::{base_url, client};
8use helpers::verify_new_account;
9use reqwest::{StatusCode, redirect};
10use serde_json::{Value, json};
11use sha2::{Digest, Sha256};
12use wiremock::matchers::{method, path};
13use wiremock::{Mock, MockServer, ResponseTemplate};
14
15fn no_redirect_client() -> reqwest::Client {
16 reqwest::Client::builder().redirect(redirect::Policy::none()).build().unwrap()
17}
18
19fn generate_pkce() -> (String, String) {
20 let verifier_bytes: [u8; 32] = rand::random();
21 let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
22 let mut hasher = Sha256::new();
23 hasher.update(code_verifier.as_bytes());
24 let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize());
25 (code_verifier, code_challenge)
26}
27
28async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
29 let mock_server = MockServer::start().await;
30 let metadata = json!({
31 "client_id": mock_server.uri(),
32 "client_name": "Security Test Client",
33 "redirect_uris": [redirect_uri],
34 "grant_types": ["authorization_code", "refresh_token"],
35 "response_types": ["code"],
36 "token_endpoint_auth_method": "none",
37 "dpop_bound_access_tokens": false
38 });
39 Mock::given(method("GET")).and(path("/"))
40 .respond_with(ResponseTemplate::new(200).set_body_json(metadata))
41 .mount(&mock_server).await;
42 mock_server
43}
44
45async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) {
46 let ts = Utc::now().timestamp_millis();
47 let handle = format!("sec-test-{}", ts);
48 http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
49 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "security-test-password" }))
50 .send().await.unwrap();
51 let redirect_uri = "https://example.com/sec-callback";
52 let mock_client = setup_mock_client_metadata(redirect_uri).await;
53 let client_id = mock_client.uri();
54 let (code_verifier, code_challenge) = generate_pkce();
55 let par_body: Value = http_client.post(format!("{}/oauth/par", url))
56 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
57 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
58 .send().await.unwrap().json().await.unwrap();
59 let request_uri = par_body["request_uri"].as_str().unwrap();
60 let auth_client = no_redirect_client();
61 let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
62 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "security-test-password"), ("remember_device", "false")])
63 .send().await.unwrap();
64 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
65 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
66 let token_body: Value = http_client.post(format!("{}/oauth/token", url))
67 .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
68 ("code_verifier", &code_verifier), ("client_id", &client_id)])
69 .send().await.unwrap().json().await.unwrap();
70 (token_body["access_token"].as_str().unwrap().to_string(),
71 token_body["refresh_token"].as_str().unwrap().to_string(), client_id)
72}
73
74#[tokio::test]
75async fn test_token_tampering_attacks() {
76 let url = base_url().await;
77 let http_client = client();
78 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
79 let parts: Vec<&str> = access_token.split('.').collect();
80 assert_eq!(parts.len(), 3);
81 let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
82 let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig);
83 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
84 .bearer_auth(&forged_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Forged signature should be rejected");
85 let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
86 let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
87 payload["sub"] = json!("did:plc:attacker");
88 let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
89 let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
90 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
91 .bearer_auth(&modified_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Modified payload should be rejected");
92 let none_header = json!({ "alg": "none", "typ": "at+jwt" });
93 let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds",
94 "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" });
95 let none_token = format!("{}.{}.", URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()),
96 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()));
97 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
98 .bearer_auth(&none_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "alg=none should be rejected");
99 let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" });
100 let rs256_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()),
101 URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 64]));
102 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
103 .bearer_auth(&rs256_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Algorithm substitution should be rejected");
104 let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds",
105 "iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" });
106 let expired_token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()),
107 URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32]));
108 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
109 .bearer_auth(&expired_token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "Expired token should be rejected");
110}
111
112#[tokio::test]
113async fn test_pkce_security() {
114 let url = base_url().await;
115 let http_client = client();
116 let redirect_uri = "https://example.com/pkce-callback";
117 let mock_client = setup_mock_client_metadata(redirect_uri).await;
118 let client_id = mock_client.uri();
119 let res = http_client.post(format!("{}/oauth/par", url))
120 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
121 ("code_challenge", "plain-text-challenge"), ("code_challenge_method", "plain")])
122 .send().await.unwrap();
123 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "PKCE plain method should be rejected");
124 let body: Value = res.json().await.unwrap();
125 assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("s256"));
126 let res = http_client.post(format!("{}/oauth/par", url))
127 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri)])
128 .send().await.unwrap();
129 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Missing PKCE challenge should be rejected");
130 let ts = Utc::now().timestamp_millis();
131 let handle = format!("pkce-attack-{}", ts);
132 http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
133 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "pkce-password" }))
134 .send().await.unwrap();
135 let (_, code_challenge) = generate_pkce();
136 let (attacker_verifier, _) = generate_pkce();
137 let par_body: Value = http_client.post(format!("{}/oauth/par", url))
138 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
139 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
140 .send().await.unwrap().json().await.unwrap();
141 let request_uri = par_body["request_uri"].as_str().unwrap();
142 let auth_client = no_redirect_client();
143 let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
144 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "pkce-password"), ("remember_device", "false")])
145 .send().await.unwrap();
146 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
147 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap();
148 let token_res = http_client.post(format!("{}/oauth/token", url))
149 .form(&[("grant_type", "authorization_code"), ("code", code), ("redirect_uri", redirect_uri),
150 ("code_verifier", &attacker_verifier), ("client_id", &client_id)])
151 .send().await.unwrap();
152 assert_eq!(token_res.status(), StatusCode::BAD_REQUEST, "Wrong PKCE verifier should be rejected");
153}
154
155#[tokio::test]
156async fn test_replay_attacks() {
157 let url = base_url().await;
158 let http_client = client();
159 let ts = Utc::now().timestamp_millis();
160 let handle = format!("replay-{}", ts);
161 http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
162 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "replay-password" }))
163 .send().await.unwrap();
164 let redirect_uri = "https://example.com/replay-callback";
165 let mock_client = setup_mock_client_metadata(redirect_uri).await;
166 let client_id = mock_client.uri();
167 let (code_verifier, code_challenge) = generate_pkce();
168 let par_body: Value = http_client.post(format!("{}/oauth/par", url))
169 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", redirect_uri),
170 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
171 .send().await.unwrap().json().await.unwrap();
172 let request_uri = par_body["request_uri"].as_str().unwrap();
173 let auth_client = no_redirect_client();
174 let auth_res = auth_client.post(format!("{}/oauth/authorize", url))
175 .form(&[("request_uri", request_uri), ("username", &handle), ("password", "replay-password"), ("remember_device", "false")])
176 .send().await.unwrap();
177 let location = auth_res.headers().get("location").unwrap().to_str().unwrap();
178 let code = location.split("code=").nth(1).unwrap().split('&').next().unwrap().to_string();
179 let first = http_client.post(format!("{}/oauth/token", url))
180 .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri),
181 ("code_verifier", &code_verifier), ("client_id", &client_id)])
182 .send().await.unwrap();
183 assert_eq!(first.status(), StatusCode::OK, "First use should succeed");
184 let first_body: Value = first.json().await.unwrap();
185 let replay = http_client.post(format!("{}/oauth/token", url))
186 .form(&[("grant_type", "authorization_code"), ("code", &code), ("redirect_uri", redirect_uri),
187 ("code_verifier", &code_verifier), ("client_id", &client_id)])
188 .send().await.unwrap();
189 assert_eq!(replay.status(), StatusCode::BAD_REQUEST, "Auth code replay should fail");
190 let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string();
191 let first_refresh: Value = http_client.post(format!("{}/oauth/token", url))
192 .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)])
193 .send().await.unwrap().json().await.unwrap();
194 assert!(first_refresh["access_token"].is_string(), "First refresh should succeed");
195 let new_rt = first_refresh["refresh_token"].as_str().unwrap();
196 let rt_replay = http_client.post(format!("{}/oauth/token", url))
197 .form(&[("grant_type", "refresh_token"), ("refresh_token", &stolen_rt), ("client_id", &client_id)])
198 .send().await.unwrap();
199 assert_eq!(rt_replay.status(), StatusCode::BAD_REQUEST, "Refresh token replay should fail");
200 let body: Value = rt_replay.json().await.unwrap();
201 assert!(body["error_description"].as_str().unwrap().to_lowercase().contains("reuse"));
202 let family_revoked = http_client.post(format!("{}/oauth/token", url))
203 .form(&[("grant_type", "refresh_token"), ("refresh_token", new_rt), ("client_id", &client_id)])
204 .send().await.unwrap();
205 assert_eq!(family_revoked.status(), StatusCode::BAD_REQUEST, "Token family should be revoked");
206}
207
208#[tokio::test]
209async fn test_oauth_security_boundaries() {
210 let url = base_url().await;
211 let http_client = client();
212 let registered_redirect = "https://legitimate-app.com/callback";
213 let mock_client = setup_mock_client_metadata(registered_redirect).await;
214 let client_id = mock_client.uri();
215 let (_, code_challenge) = generate_pkce();
216 let res = http_client.post(format!("{}/oauth/par", url))
217 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", "https://attacker.com/steal"),
218 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
219 .send().await.unwrap();
220 assert_eq!(res.status(), StatusCode::BAD_REQUEST, "Unregistered redirect_uri should be rejected");
221 let ts = Utc::now().timestamp_millis();
222 let handle = format!("deact-{}", ts);
223 let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
224 .json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "deact-password" }))
225 .send().await.unwrap();
226 let account: Value = create_res.json().await.unwrap();
227 let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
228 http_client.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
229 .bearer_auth(&access_jwt).json(&json!({})).send().await.unwrap();
230 let deact_par: Value = http_client.post(format!("{}/oauth/par", url))
231 .form(&[("response_type", "code"), ("client_id", &client_id), ("redirect_uri", registered_redirect),
232 ("code_challenge", &code_challenge), ("code_challenge_method", "S256")])
233 .send().await.unwrap().json().await.unwrap();
234 let auth_res = http_client.post(format!("{}/oauth/authorize", url))
235 .header("Accept", "application/json")
236 .form(&[("request_uri", deact_par["request_uri"].as_str().unwrap()), ("username", &handle), ("password", "deact-password"), ("remember_device", "false")])
237 .send().await.unwrap();
238 assert_eq!(auth_res.status(), StatusCode::FORBIDDEN, "Deactivated account should be blocked");
239 let redirect_uri_a = "https://app-a.com/callback";
240 let mock_a = setup_mock_client_metadata(redirect_uri_a).await;
241 let client_id_a = mock_a.uri();
242 let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await;
243 let client_id_b = mock_b.uri();
244 let ts2 = Utc::now().timestamp_millis();
245 let handle2 = format!("cross-{}", ts2);
246 http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
247 .json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "cross-password" }))
248 .send().await.unwrap();
249 let (code_verifier2, code_challenge2) = generate_pkce();
250 let par_a: Value = http_client.post(format!("{}/oauth/par", url))
251 .form(&[("response_type", "code"), ("client_id", &client_id_a), ("redirect_uri", redirect_uri_a),
252 ("code_challenge", &code_challenge2), ("code_challenge_method", "S256")])
253 .send().await.unwrap().json().await.unwrap();
254 let auth_client = no_redirect_client();
255 let auth_a = auth_client.post(format!("{}/oauth/authorize", url))
256 .form(&[("request_uri", par_a["request_uri"].as_str().unwrap()), ("username", &handle2), ("password", "cross-password"), ("remember_device", "false")])
257 .send().await.unwrap();
258 let loc_a = auth_a.headers().get("location").unwrap().to_str().unwrap();
259 let code_a = loc_a.split("code=").nth(1).unwrap().split('&').next().unwrap();
260 let cross_client = http_client.post(format!("{}/oauth/token", url))
261 .form(&[("grant_type", "authorization_code"), ("code", code_a), ("redirect_uri", redirect_uri_a),
262 ("code_verifier", &code_verifier2), ("client_id", &client_id_b)])
263 .send().await.unwrap();
264 assert_eq!(cross_client.status(), StatusCode::BAD_REQUEST, "Cross-client code exchange must be rejected");
265}
266
267#[tokio::test]
268async fn test_malformed_tokens_and_headers() {
269 let url = base_url().await;
270 let http_client = client();
271 let malformed = vec!["", "not-a-token", "one.two", "one.two.three.four", "....", "eyJhbGciOiJIUzI1NiJ9",
272 "eyJhbGciOiJIUzI1NiJ9.", "eyJhbGciOiJIUzI1NiJ9..", ".eyJzdWIiOiJ0ZXN0In0.", "!!invalid!!.eyJ9.sig"];
273 for token in &malformed {
274 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
275 .bearer_auth(token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
276 }
277 let wrong_types = vec!["JWT", "jwt", "at+JWT", ""];
278 for typ in wrong_types {
279 let header = json!({ "alg": "HS256", "typ": typ });
280 let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" });
281 let token = format!("{}.{}.{}", URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
282 URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()), URL_SAFE_NO_PAD.encode(&[1u8; 32]));
283 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
284 .bearer_auth(&token).send().await.unwrap().status(), StatusCode::UNAUTHORIZED, "typ='{}' should be rejected", typ);
285 }
286 let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
287 let invalid_formats = vec![format!("Basic {}", access_token), format!("Digest {}", access_token),
288 access_token.clone(), format!("Bearer{}", access_token)];
289 for auth in &invalid_formats {
290 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
291 .header("Authorization", auth).send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
292 }
293 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
294 .send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
295 assert_eq!(http_client.get(format!("{}/xrpc/com.atproto.server.getSession", url))
296 .header("Authorization", "").send().await.unwrap().status(), StatusCode::UNAUTHORIZED);
297 let grants = vec!["client_credentials", "password", "implicit", "", "AUTHORIZATION_CODE"];
298 for grant in grants {
299 assert_eq!(http_client.post(format!("{}/oauth/token", url))
300 .form(&[("grant_type", grant), ("client_id", "https://example.com")])
301 .send().await.unwrap().status(), StatusCode::BAD_REQUEST, "Grant '{}' should be rejected", grant);
302 }
303}
304
305#[tokio::test]
306async fn test_token_revocation() {
307 let url = base_url().await;
308 let http_client = client();
309 let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
310 assert_eq!(http_client.post(format!("{}/oauth/revoke", url))
311 .form(&[("token", &refresh_token)]).send().await.unwrap().status(), StatusCode::OK);
312 let introspect: Value = http_client.post(format!("{}/oauth/introspect", url))
313 .form(&[("token", &access_token)]).send().await.unwrap().json().await.unwrap();
314 assert_eq!(introspect["active"], false, "Revoked token should be inactive");
315}
316
317fn create_dpop_proof(method: &str, uri: &str, _nonce: Option<&str>, ath: Option<&str>, iat_offset: i64) -> String {
318 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
319 use p256::elliptic_curve::sec1::ToEncodedPoint;
320 let signing_key = SigningKey::random(&mut rand::thread_rng());
321 let point = signing_key.verifying_key().to_encoded_point(false);
322 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
323 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
324 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
325 let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
326 "htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset });
327 if let Some(a) = ath { payload["ath"] = json!(a); }
328 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
329 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
330 let signing_input = format!("{}.{}", header_b64, payload_b64);
331 let signature: Signature = signing_key.sign(signing_input.as_bytes());
332 format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()))
333}
334
335#[test]
336fn test_dpop_nonce_security() {
337 let secret1 = b"test-dpop-secret-32-bytes-long!!";
338 let secret2 = b"different-secret-32-bytes-long!!";
339 let v1 = DPoPVerifier::new(secret1);
340 let v2 = DPoPVerifier::new(secret2);
341 let nonce = v1.generate_nonce();
342 assert!(!nonce.is_empty());
343 assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass");
344 assert!(v2.validate_nonce(&nonce).is_err(), "Nonce from different secret should fail");
345 let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
346 let mut tampered = nonce_bytes.clone();
347 if !tampered.is_empty() { tampered[0] ^= 0xFF; }
348 assert!(v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered)).is_err(), "Tampered nonce should fail");
349 assert!(v1.validate_nonce("invalid").is_err());
350 assert!(v1.validate_nonce("").is_err());
351 assert!(v1.validate_nonce("!!!not-base64!!!").is_err());
352}
353
354#[test]
355fn test_dpop_proof_validation() {
356 let secret = b"test-dpop-secret-32-bytes-long!!";
357 let verifier = DPoPVerifier::new(secret);
358 assert!(verifier.verify_proof("not.enough", "POST", "https://example.com", None).is_err());
359 assert!(verifier.verify_proof("invalid", "POST", "https://example.com", None).is_err());
360 let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
361 assert!(verifier.verify_proof(&proof, "GET", "https://example.com/token", None).is_err(), "Method mismatch");
362 assert!(verifier.verify_proof(&proof, "POST", "https://other.com/token", None).is_err(), "URI mismatch");
363 assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None).is_ok(), "Query params should be ignored");
364 let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
365 assert!(verifier.verify_proof(&old_proof, "POST", "https://example.com/token", None).is_err(), "iat too old");
366 let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
367 assert!(verifier.verify_proof(&future_proof, "POST", "https://example.com/token", None).is_err(), "iat in future");
368 let ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, Some("wrong"), 0);
369 assert!(verifier.verify_proof(&ath_proof, "GET", "https://example.com/resource", Some("correct")).is_err(), "ath mismatch");
370 let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
371 assert!(verifier.verify_proof(&no_ath_proof, "GET", "https://example.com/resource", Some("expected")).is_err(), "Missing ath");
372}
373
374#[test]
375fn test_dpop_proof_signature_attacks() {
376 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
377 use p256::elliptic_curve::sec1::ToEncodedPoint;
378 let secret = b"test-dpop-secret-32-bytes-long!!";
379 let verifier = DPoPVerifier::new(secret);
380 let signing_key = SigningKey::random(&mut rand::thread_rng());
381 let attacker_key = SigningKey::random(&mut rand::thread_rng());
382 let attacker_point = attacker_key.verifying_key().to_encoded_point(false);
383 let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
384 let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
385 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
386 let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
387 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
388 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
389 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
390 let signing_input = format!("{}.{}", header_b64, payload_b64);
391 let signature: Signature = signing_key.sign(signing_input.as_bytes());
392 let mismatched = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
393 assert!(verifier.verify_proof(&mismatched, "POST", "https://example.com/token", None).is_err(), "Mismatched key should fail");
394 let point = signing_key.verifying_key().to_encoded_point(false);
395 let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256",
396 "x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } });
397 let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap());
398 let good_input = format!("{}.{}", good_header_b64, payload_b64);
399 let good_sig: Signature = signing_key.sign(good_input.as_bytes());
400 let mut sig_bytes = good_sig.to_bytes().to_vec();
401 sig_bytes[0] ^= 0xFF;
402 let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes));
403 assert!(verifier.verify_proof(&tampered, "POST", "https://example.com/token", None).is_err(), "Tampered sig should fail");
404}
405
406#[test]
407fn test_jwk_thumbprint() {
408 let jwk = DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()),
409 x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
410 y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()) };
411 let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
412 let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
413 assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
414 assert!(!tp1.is_empty());
415 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("secp256k1".to_string()),
416 x: Some("x".to_string()), y: Some("y".to_string()) }).is_ok());
417 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "OKP".to_string(), crv: Some("Ed25519".to_string()),
418 x: Some("x".to_string()), y: None }).is_ok());
419 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: None, x: Some("x".to_string()), y: Some("y".to_string()) }).is_err());
420 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: None, y: Some("y".to_string()) }).is_err());
421 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "EC".to_string(), crv: Some("P-256".to_string()), x: Some("x".to_string()), y: None }).is_err());
422 assert!(compute_jwk_thumbprint(&DPoPJwk { kty: "RSA".to_string(), crv: None, x: None, y: None }).is_err());
423}
424
425#[test]
426fn test_dpop_clock_skew() {
427 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
428 use p256::elliptic_curve::sec1::ToEncodedPoint;
429 let secret = b"test-dpop-secret-32-bytes-long!!";
430 let verifier = DPoPVerifier::new(secret);
431 let test_cases = vec![(-600, true), (-301, true), (-299, false), (0, false), (299, false), (301, true), (600, true)];
432 for (offset, should_fail) in test_cases {
433 let signing_key = SigningKey::random(&mut rand::thread_rng());
434 let point = signing_key.verifying_key().to_encoded_point(false);
435 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
436 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
437 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
438 let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
439 "htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset });
440 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
441 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
442 let signing_input = format!("{}.{}", header_b64, payload_b64);
443 let signature: Signature = signing_key.sign(signing_input.as_bytes());
444 let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
445 let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
446 if should_fail { assert!(result.is_err(), "offset {} should fail", offset); }
447 else { assert!(result.is_ok(), "offset {} should pass", offset); }
448 }
449}
450
451#[test]
452fn test_dpop_http_method_case() {
453 use p256::ecdsa::{Signature, SigningKey, signature::Signer};
454 use p256::elliptic_curve::sec1::ToEncodedPoint;
455 let secret = b"test-dpop-secret-32-bytes-long!!";
456 let verifier = DPoPVerifier::new(secret);
457 let signing_key = SigningKey::random(&mut rand::thread_rng());
458 let point = signing_key.verifying_key().to_encoded_point(false);
459 let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
460 let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
461 let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
462 let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
463 "htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
464 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
465 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
466 let signing_input = format!("{}.{}", header_b64, payload_b64);
467 let signature: Signature = signing_key.sign(signing_input.as_bytes());
468 let proof = format!("{}.{}", signing_input, URL_SAFE_NO_PAD.encode(signature.to_bytes()));
469 assert!(verifier.verify_proof(&proof, "POST", "https://example.com/token", None).is_ok(), "HTTP method should be case-insensitive");
470}