mirror of
https://tangled.org/tranquil.farm/tranquil-pds
synced 2026-02-09 05:40:09 +00:00
1068 lines
38 KiB
Rust
1068 lines
38 KiB
Rust
#![allow(unused_imports)]
|
|
mod common;
|
|
mod helpers;
|
|
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
|
|
use chrono::Utc;
|
|
use common::{base_url, client};
|
|
use helpers::verify_new_account;
|
|
use reqwest::StatusCode;
|
|
use serde_json::{Value, json};
|
|
use sha2::{Digest, Sha256};
|
|
use tranquil_pds::oauth::dpop::{DPoPJwk, DPoPVerifier, compute_jwk_thumbprint};
|
|
use wiremock::matchers::{method, path};
|
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
|
|
|
fn generate_pkce() -> (String, String) {
|
|
let verifier_bytes: [u8; 32] = rand::random();
|
|
let code_verifier = URL_SAFE_NO_PAD.encode(verifier_bytes);
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(code_verifier.as_bytes());
|
|
let code_challenge = URL_SAFE_NO_PAD.encode(&hasher.finalize());
|
|
(code_verifier, code_challenge)
|
|
}
|
|
|
|
async fn setup_mock_client_metadata(redirect_uri: &str) -> MockServer {
|
|
let mock_server = MockServer::start().await;
|
|
let metadata = json!({
|
|
"client_id": mock_server.uri(),
|
|
"client_name": "Security Test Client",
|
|
"redirect_uris": [redirect_uri],
|
|
"grant_types": ["authorization_code", "refresh_token"],
|
|
"response_types": ["code"],
|
|
"token_endpoint_auth_method": "none",
|
|
"dpop_bound_access_tokens": false
|
|
});
|
|
Mock::given(method("GET"))
|
|
.and(path("/"))
|
|
.respond_with(ResponseTemplate::new(200).set_body_json(metadata))
|
|
.mount(&mock_server)
|
|
.await;
|
|
mock_server
|
|
}
|
|
|
|
async fn get_oauth_tokens(http_client: &reqwest::Client, url: &str) -> (String, String, String) {
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("sec-test-{}", ts);
|
|
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Security123!" }))
|
|
.send().await.unwrap();
|
|
let account: Value = create_res.json().await.unwrap();
|
|
let did = account["did"].as_str().unwrap();
|
|
verify_new_account(http_client, did).await;
|
|
let redirect_uri = "https://example.com/sec-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Security123!", "remember_device": false}))
|
|
.send().await.unwrap();
|
|
let auth_body: Value = auth_res.json().await.unwrap();
|
|
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
|
if location.contains("/oauth/consent") {
|
|
let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code = location
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let token_body: Value = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
(
|
|
token_body["access_token"].as_str().unwrap().to_string(),
|
|
token_body["refresh_token"].as_str().unwrap().to_string(),
|
|
client_id,
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_tampering_attacks() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
|
|
let parts: Vec<&str> = access_token.split('.').collect();
|
|
assert_eq!(parts.len(), 3);
|
|
let forged_sig = URL_SAFE_NO_PAD.encode(&[0u8; 32]);
|
|
let forged_token = format!("{}.{}.{}", parts[0], parts[1], forged_sig);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&forged_token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Forged signature should be rejected"
|
|
);
|
|
let payload_bytes = URL_SAFE_NO_PAD.decode(parts[1]).unwrap();
|
|
let mut payload: Value = serde_json::from_slice(&payload_bytes).unwrap();
|
|
payload["sub"] = json!("did:plc:attacker");
|
|
let modified_payload = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let modified_token = format!("{}.{}.{}", parts[0], modified_payload, parts[2]);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&modified_token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Modified payload should be rejected"
|
|
);
|
|
let none_header = json!({ "alg": "none", "typ": "at+jwt" });
|
|
let none_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:attacker", "aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "fake", "scope": "atproto" });
|
|
let none_token = format!(
|
|
"{}.{}.",
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_header).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap())
|
|
);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&none_token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"alg=none should be rejected"
|
|
);
|
|
let rs256_header = json!({ "alg": "RS256", "typ": "at+jwt" });
|
|
let rs256_token = format!(
|
|
"{}.{}.{}",
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&rs256_header).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&none_payload).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(&[1u8; 64])
|
|
);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&rs256_token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Algorithm substitution should be rejected"
|
|
);
|
|
let expired_payload = json!({ "iss": "https://test.pds", "sub": "did:plc:test", "aud": "https://test.pds",
|
|
"iat": Utc::now().timestamp() - 7200, "exp": Utc::now().timestamp() - 3600, "jti": "expired" });
|
|
let expired_token = format!(
|
|
"{}.{}.{}",
|
|
URL_SAFE_NO_PAD
|
|
.encode(serde_json::to_string(&json!({"alg":"HS256","typ":"at+jwt"})).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&expired_payload).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(&[1u8; 32])
|
|
);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&expired_token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"Expired token should be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_pkce_security() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let redirect_uri = "https://example.com/pkce-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", "plain-text-challenge"),
|
|
("code_challenge_method", "plain"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"PKCE plain method should be rejected"
|
|
);
|
|
let body: Value = res.json().await.unwrap();
|
|
assert!(
|
|
body["error_description"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_lowercase()
|
|
.contains("s256")
|
|
);
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Missing PKCE challenge should be rejected"
|
|
);
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("pkce-attack-{}", ts);
|
|
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Pkce123pass!" }))
|
|
.send().await.unwrap();
|
|
let account: Value = create_res.json().await.unwrap();
|
|
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
|
let (_, code_challenge) = generate_pkce();
|
|
let (attacker_verifier, _) = generate_pkce();
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Pkce123pass!", "remember_device": false}))
|
|
.send().await.unwrap();
|
|
assert_eq!(auth_res.status(), StatusCode::OK);
|
|
let auth_body: Value = auth_res.json().await.unwrap();
|
|
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
|
if location.contains("/oauth/consent") {
|
|
let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code = location
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let token_res = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &attacker_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
token_res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Wrong PKCE verifier should be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_replay_attacks() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("replay-{}", ts);
|
|
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Replay123pass!" }))
|
|
.send().await.unwrap();
|
|
let account: Value = create_res.json().await.unwrap();
|
|
verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
|
let redirect_uri = "https://example.com/replay-callback";
|
|
let mock_client = setup_mock_client_metadata(redirect_uri).await;
|
|
let client_id = mock_client.uri();
|
|
let (code_verifier, code_challenge) = generate_pkce();
|
|
let par_body: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", redirect_uri),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let request_uri = par_body["request_uri"].as_str().unwrap();
|
|
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "username": &handle, "password": "Replay123pass!", "remember_device": false}))
|
|
.send().await.unwrap();
|
|
assert_eq!(auth_res.status(), StatusCode::OK);
|
|
let auth_body: Value = auth_res.json().await.unwrap();
|
|
let mut location = auth_body["redirect_uri"].as_str().unwrap().to_string();
|
|
if location.contains("/oauth/consent") {
|
|
let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
location = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code = location
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap()
|
|
.to_string();
|
|
let first = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", &code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(first.status(), StatusCode::OK, "First use should succeed");
|
|
let first_body: Value = first.json().await.unwrap();
|
|
let replay = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", &code),
|
|
("redirect_uri", redirect_uri),
|
|
("code_verifier", &code_verifier),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
replay.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Auth code replay should fail"
|
|
);
|
|
let stolen_rt = first_body["refresh_token"].as_str().unwrap().to_string();
|
|
let first_refresh: Value = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &stolen_rt),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
first_refresh["access_token"].is_string(),
|
|
"First refresh should succeed"
|
|
);
|
|
let new_rt = first_refresh["refresh_token"].as_str().unwrap();
|
|
let rt_replay = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", &stolen_rt),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
rt_replay.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Refresh token replay should fail"
|
|
);
|
|
let body: Value = rt_replay.json().await.unwrap();
|
|
assert!(
|
|
body["error_description"]
|
|
.as_str()
|
|
.unwrap()
|
|
.to_lowercase()
|
|
.contains("reuse")
|
|
);
|
|
let family_revoked = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "refresh_token"),
|
|
("refresh_token", new_rt),
|
|
("client_id", &client_id),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
family_revoked.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Token family should be revoked"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_oauth_security_boundaries() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let registered_redirect = "https://legitimate-app.com/callback";
|
|
let mock_client = setup_mock_client_metadata(registered_redirect).await;
|
|
let client_id = mock_client.uri();
|
|
let (_, code_challenge) = generate_pkce();
|
|
let res = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", "https://attacker.com/steal"),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
res.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Unregistered redirect_uri should be rejected"
|
|
);
|
|
let ts = Utc::now().timestamp_millis();
|
|
let handle = format!("deact-{}", ts);
|
|
let create_res = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({ "handle": handle, "email": format!("{}@example.com", handle), "password": "Deact123pass!" }))
|
|
.send().await.unwrap();
|
|
let account: Value = create_res.json().await.unwrap();
|
|
let access_jwt = verify_new_account(&http_client, account["did"].as_str().unwrap()).await;
|
|
http_client
|
|
.post(format!("{}/xrpc/com.atproto.server.deactivateAccount", url))
|
|
.bearer_auth(&access_jwt)
|
|
.json(&json!({}))
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
let deact_par: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id),
|
|
("redirect_uri", registered_redirect),
|
|
("code_challenge", &code_challenge),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let auth_res = http_client.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({"request_uri": deact_par["request_uri"].as_str().unwrap(), "username": &handle, "password": "Deact123pass!", "remember_device": false}))
|
|
.send().await.unwrap();
|
|
assert_eq!(
|
|
auth_res.status(),
|
|
StatusCode::FORBIDDEN,
|
|
"Deactivated account should be blocked"
|
|
);
|
|
let redirect_uri_a = "https://app-a.com/callback";
|
|
let mock_a = setup_mock_client_metadata(redirect_uri_a).await;
|
|
let client_id_a = mock_a.uri();
|
|
let mock_b = setup_mock_client_metadata("https://app-b.com/callback").await;
|
|
let client_id_b = mock_b.uri();
|
|
let ts2 = Utc::now().timestamp_millis();
|
|
let handle2 = format!("cross-{}", ts2);
|
|
let create_res2 = http_client.post(format!("{}/xrpc/com.atproto.server.createAccount", url))
|
|
.json(&json!({ "handle": handle2, "email": format!("{}@example.com", handle2), "password": "Cross123pass!" }))
|
|
.send().await.unwrap();
|
|
let account2: Value = create_res2.json().await.unwrap();
|
|
verify_new_account(&http_client, account2["did"].as_str().unwrap()).await;
|
|
let (code_verifier2, code_challenge2) = generate_pkce();
|
|
let par_a: Value = http_client
|
|
.post(format!("{}/oauth/par", url))
|
|
.form(&[
|
|
("response_type", "code"),
|
|
("client_id", &client_id_a),
|
|
("redirect_uri", redirect_uri_a),
|
|
("code_challenge", &code_challenge2),
|
|
("code_challenge_method", "S256"),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
let request_uri_a = par_a["request_uri"].as_str().unwrap();
|
|
let auth_a = http_client.post(format!("{}/oauth/authorize", url))
|
|
.header("Content-Type", "application/json")
|
|
.header("Accept", "application/json")
|
|
.json(&json!({"request_uri": request_uri_a, "username": &handle2, "password": "Cross123pass!", "remember_device": false}))
|
|
.send().await.unwrap();
|
|
assert_eq!(auth_a.status(), StatusCode::OK);
|
|
let auth_body_a: Value = auth_a.json().await.unwrap();
|
|
let mut loc_a = auth_body_a["redirect_uri"].as_str().unwrap().to_string();
|
|
if loc_a.contains("/oauth/consent") {
|
|
let consent_res = http_client.post(format!("{}/oauth/authorize/consent", url))
|
|
.header("Content-Type", "application/json")
|
|
.json(&json!({"request_uri": request_uri_a, "approved_scopes": ["atproto"], "remember": false}))
|
|
.send().await.unwrap();
|
|
let consent_body: Value = consent_res.json().await.unwrap();
|
|
loc_a = consent_body["redirect_uri"].as_str().unwrap().to_string();
|
|
}
|
|
let code_a = loc_a
|
|
.split("code=")
|
|
.nth(1)
|
|
.unwrap()
|
|
.split('&')
|
|
.next()
|
|
.unwrap();
|
|
let cross_client = http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[
|
|
("grant_type", "authorization_code"),
|
|
("code", code_a),
|
|
("redirect_uri", redirect_uri_a),
|
|
("code_verifier", &code_verifier2),
|
|
("client_id", &client_id_b),
|
|
])
|
|
.send()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
cross_client.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Cross-client code exchange must be rejected"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_malformed_tokens_and_headers() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let malformed = vec![
|
|
"",
|
|
"not-a-token",
|
|
"one.two",
|
|
"one.two.three.four",
|
|
"....",
|
|
"eyJhbGciOiJIUzI1NiJ9",
|
|
"eyJhbGciOiJIUzI1NiJ9.",
|
|
"eyJhbGciOiJIUzI1NiJ9..",
|
|
".eyJzdWIiOiJ0ZXN0In0.",
|
|
"!!invalid!!.eyJ9.sig",
|
|
];
|
|
for token in &malformed {
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
}
|
|
let wrong_types = vec!["JWT", "jwt", "at+JWT", ""];
|
|
for typ in wrong_types {
|
|
let header = json!({ "alg": "HS256", "typ": typ });
|
|
let payload = json!({ "iss": "x", "sub": "did:plc:x", "aud": "x", "iat": Utc::now().timestamp(), "exp": Utc::now().timestamp() + 3600, "jti": "x" });
|
|
let token = format!(
|
|
"{}.{}.{}",
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap()),
|
|
URL_SAFE_NO_PAD.encode(&[1u8; 32])
|
|
);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.bearer_auth(&token)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED,
|
|
"typ='{}' should be rejected",
|
|
typ
|
|
);
|
|
}
|
|
let (access_token, _, _) = get_oauth_tokens(&http_client, url).await;
|
|
let invalid_formats = vec![
|
|
format!("Basic {}", access_token),
|
|
format!("Digest {}", access_token),
|
|
access_token.clone(),
|
|
format!("Bearer{}", access_token),
|
|
];
|
|
for auth in &invalid_formats {
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", auth)
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
}
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
assert_eq!(
|
|
http_client
|
|
.get(format!("{}/xrpc/com.atproto.server.getSession", url))
|
|
.header("Authorization", "")
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::UNAUTHORIZED
|
|
);
|
|
let grants = vec![
|
|
"client_credentials",
|
|
"password",
|
|
"implicit",
|
|
"",
|
|
"AUTHORIZATION_CODE",
|
|
];
|
|
for grant in grants {
|
|
assert_eq!(
|
|
http_client
|
|
.post(format!("{}/oauth/token", url))
|
|
.form(&[("grant_type", grant), ("client_id", "https://example.com")])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::BAD_REQUEST,
|
|
"Grant '{}' should be rejected",
|
|
grant
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_revocation() {
|
|
let url = base_url().await;
|
|
let http_client = client();
|
|
let (access_token, refresh_token, _) = get_oauth_tokens(&http_client, url).await;
|
|
assert_eq!(
|
|
http_client
|
|
.post(format!("{}/oauth/revoke", url))
|
|
.form(&[("token", &refresh_token)])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.status(),
|
|
StatusCode::OK
|
|
);
|
|
let introspect: Value = http_client
|
|
.post(format!("{}/oauth/introspect", url))
|
|
.form(&[("token", &access_token)])
|
|
.send()
|
|
.await
|
|
.unwrap()
|
|
.json()
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(
|
|
introspect["active"], false,
|
|
"Revoked token should be inactive"
|
|
);
|
|
}
|
|
|
|
fn create_dpop_proof(
|
|
method: &str,
|
|
uri: &str,
|
|
_nonce: Option<&str>,
|
|
ath: Option<&str>,
|
|
iat_offset: i64,
|
|
) -> String {
|
|
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let point = signing_key.verifying_key().to_encoded_point(false);
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
|
|
let mut payload = json!({ "jti": format!("unique-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": method, "htu": uri, "iat": Utc::now().timestamp() + iat_offset });
|
|
if let Some(a) = ath {
|
|
payload["ath"] = json!(a);
|
|
}
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
format!(
|
|
"{}.{}",
|
|
signing_input,
|
|
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn test_dpop_nonce_security() {
|
|
let secret1 = b"test-dpop-secret-32-bytes-long!!";
|
|
let secret2 = b"different-secret-32-bytes-long!!";
|
|
let v1 = DPoPVerifier::new(secret1);
|
|
let v2 = DPoPVerifier::new(secret2);
|
|
let nonce = v1.generate_nonce();
|
|
assert!(!nonce.is_empty());
|
|
assert!(v1.validate_nonce(&nonce).is_ok(), "Valid nonce should pass");
|
|
assert!(
|
|
v2.validate_nonce(&nonce).is_err(),
|
|
"Nonce from different secret should fail"
|
|
);
|
|
let nonce_bytes = URL_SAFE_NO_PAD.decode(&nonce).unwrap();
|
|
let mut tampered = nonce_bytes.clone();
|
|
if !tampered.is_empty() {
|
|
tampered[0] ^= 0xFF;
|
|
}
|
|
assert!(
|
|
v1.validate_nonce(&URL_SAFE_NO_PAD.encode(&tampered))
|
|
.is_err(),
|
|
"Tampered nonce should fail"
|
|
);
|
|
assert!(v1.validate_nonce("invalid").is_err());
|
|
assert!(v1.validate_nonce("").is_err());
|
|
assert!(v1.validate_nonce("!!!not-base64!!!").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn test_dpop_proof_validation() {
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
assert!(
|
|
verifier
|
|
.verify_proof("not.enough", "POST", "https://example.com", None)
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof("invalid", "POST", "https://example.com", None)
|
|
.is_err()
|
|
);
|
|
let proof = create_dpop_proof("POST", "https://example.com/token", None, None, 0);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&proof, "GET", "https://example.com/token", None)
|
|
.is_err(),
|
|
"Method mismatch"
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&proof, "POST", "https://other.com/token", None)
|
|
.is_err(),
|
|
"URI mismatch"
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&proof, "POST", "https://example.com/token?foo=bar", None)
|
|
.is_ok(),
|
|
"Query params should be ignored"
|
|
);
|
|
let old_proof = create_dpop_proof("POST", "https://example.com/token", None, None, -600);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&old_proof, "POST", "https://example.com/token", None)
|
|
.is_err(),
|
|
"iat too old"
|
|
);
|
|
let future_proof = create_dpop_proof("POST", "https://example.com/token", None, None, 600);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&future_proof, "POST", "https://example.com/token", None)
|
|
.is_err(),
|
|
"iat in future"
|
|
);
|
|
let ath_proof = create_dpop_proof(
|
|
"GET",
|
|
"https://example.com/resource",
|
|
None,
|
|
Some("wrong"),
|
|
0,
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(
|
|
&ath_proof,
|
|
"GET",
|
|
"https://example.com/resource",
|
|
Some("correct")
|
|
)
|
|
.is_err(),
|
|
"ath mismatch"
|
|
);
|
|
let no_ath_proof = create_dpop_proof("GET", "https://example.com/resource", None, None, 0);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(
|
|
&no_ath_proof,
|
|
"GET",
|
|
"https://example.com/resource",
|
|
Some("expected")
|
|
)
|
|
.is_err(),
|
|
"Missing ath"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dpop_proof_signature_attacks() {
|
|
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let attacker_key = SigningKey::random(&mut rand::thread_rng());
|
|
let attacker_point = attacker_key.verifying_key().to_encoded_point(false);
|
|
let x = URL_SAFE_NO_PAD.encode(attacker_point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(attacker_point.y().unwrap());
|
|
let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
|
|
let payload = json!({ "jti": format!("key-sub-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let mismatched = format!(
|
|
"{}.{}",
|
|
signing_input,
|
|
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&mismatched, "POST", "https://example.com/token", None)
|
|
.is_err(),
|
|
"Mismatched key should fail"
|
|
);
|
|
let point = signing_key.verifying_key().to_encoded_point(false);
|
|
let good_header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256",
|
|
"x": URL_SAFE_NO_PAD.encode(point.x().unwrap()), "y": URL_SAFE_NO_PAD.encode(point.y().unwrap()) } });
|
|
let good_header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&good_header).unwrap());
|
|
let good_input = format!("{}.{}", good_header_b64, payload_b64);
|
|
let good_sig: Signature = signing_key.sign(good_input.as_bytes());
|
|
let mut sig_bytes = good_sig.to_bytes().to_vec();
|
|
sig_bytes[0] ^= 0xFF;
|
|
let tampered = format!("{}.{}", good_input, URL_SAFE_NO_PAD.encode(&sig_bytes));
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&tampered, "POST", "https://example.com/token", None)
|
|
.is_err(),
|
|
"Tampered sig should fail"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_jwk_thumbprint() {
|
|
let jwk = DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: Some("P-256".to_string()),
|
|
x: Some("WbbXrPhtCg66wuF0NLhzXxF5PFzNZ7wNJm9M_1pCcXY".to_string()),
|
|
y: Some("DubR6_2kU1H5EYhbcNpYZGy1EY6GEKKxv6PYx8VW0rA".to_string()),
|
|
};
|
|
let tp1 = compute_jwk_thumbprint(&jwk).unwrap();
|
|
let tp2 = compute_jwk_thumbprint(&jwk).unwrap();
|
|
assert_eq!(tp1, tp2, "Thumbprint should be deterministic");
|
|
assert!(!tp1.is_empty());
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: Some("secp256k1".to_string()),
|
|
x: Some("x".to_string()),
|
|
y: Some("y".to_string())
|
|
})
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "OKP".to_string(),
|
|
crv: Some("Ed25519".to_string()),
|
|
x: Some("x".to_string()),
|
|
y: None
|
|
})
|
|
.is_ok()
|
|
);
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: None,
|
|
x: Some("x".to_string()),
|
|
y: Some("y".to_string())
|
|
})
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: Some("P-256".to_string()),
|
|
x: None,
|
|
y: Some("y".to_string())
|
|
})
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "EC".to_string(),
|
|
crv: Some("P-256".to_string()),
|
|
x: Some("x".to_string()),
|
|
y: None
|
|
})
|
|
.is_err()
|
|
);
|
|
assert!(
|
|
compute_jwk_thumbprint(&DPoPJwk {
|
|
kty: "RSA".to_string(),
|
|
crv: None,
|
|
x: None,
|
|
y: None
|
|
})
|
|
.is_err()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_dpop_clock_skew() {
|
|
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
let test_cases = vec![
|
|
(-600, true),
|
|
(-301, true),
|
|
(-299, false),
|
|
(0, false),
|
|
(299, false),
|
|
(301, true),
|
|
(600, true),
|
|
];
|
|
for (offset, should_fail) in test_cases {
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let point = signing_key.verifying_key().to_encoded_point(false);
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
|
|
let payload = json!({ "jti": format!("clock-{}-{}", offset, Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "POST", "htu": "https://example.com/token", "iat": Utc::now().timestamp() + offset });
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let proof = format!(
|
|
"{}.{}",
|
|
signing_input,
|
|
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
|
);
|
|
let result = verifier.verify_proof(&proof, "POST", "https://example.com/token", None);
|
|
if should_fail {
|
|
assert!(result.is_err(), "offset {} should fail", offset);
|
|
} else {
|
|
assert!(result.is_ok(), "offset {} should pass", offset);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_dpop_http_method_case() {
|
|
use p256::ecdsa::{Signature, SigningKey, signature::Signer};
|
|
use p256::elliptic_curve::sec1::ToEncodedPoint;
|
|
let secret = b"test-dpop-secret-32-bytes-long!!";
|
|
let verifier = DPoPVerifier::new(secret);
|
|
let signing_key = SigningKey::random(&mut rand::thread_rng());
|
|
let point = signing_key.verifying_key().to_encoded_point(false);
|
|
let x = URL_SAFE_NO_PAD.encode(point.x().unwrap());
|
|
let y = URL_SAFE_NO_PAD.encode(point.y().unwrap());
|
|
let header = json!({ "typ": "dpop+jwt", "alg": "ES256", "jwk": { "kty": "EC", "crv": "P-256", "x": x, "y": y } });
|
|
let payload = json!({ "jti": format!("case-{}", Utc::now().timestamp_nanos_opt().unwrap_or(0)),
|
|
"htm": "post", "htu": "https://example.com/token", "iat": Utc::now().timestamp() });
|
|
let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header).unwrap());
|
|
let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).unwrap());
|
|
let signing_input = format!("{}.{}", header_b64, payload_b64);
|
|
let signature: Signature = signing_key.sign(signing_input.as_bytes());
|
|
let proof = format!(
|
|
"{}.{}",
|
|
signing_input,
|
|
URL_SAFE_NO_PAD.encode(signature.to_bytes())
|
|
);
|
|
assert!(
|
|
verifier
|
|
.verify_proof(&proof, "POST", "https://example.com/token", None)
|
|
.is_ok(),
|
|
"HTTP method should be case-insensitive"
|
|
);
|
|
}
|