use acme_proxy_core::config::Config;
use acme_proxy_store::account::Account;
use acme_proxy_store::eab::Eab;
use axum::Router;
use axum::body::Body;
use axum::http::{Request, StatusCode};
use axum::response::Response;
use serde_json::{Value, json};
use tower::ServiceExt;
mod common;
use common::{
EcSigner, TestSigner, body_json, build_eab, default_challenges, fetch_nonce, p, test_app,
test_app_with_challenges,
};
const NEW_ACCOUNT_URL: &str = "http://localhost:3000/profile/default/newAccount";
const DEFAULT_BASE: &str = common::BASE;
fn eab_enabled_config() -> Config {
let mut config = Config::default();
config.eab.enabled = true;
config
}
async fn get_directory(app: &Router) -> Value {
let res = app
.clone()
.oneshot(Request::get(p("/directory")).body(Body::empty()).unwrap())
.await
.unwrap();
body_json(res).await
}
async fn post_new_account(app: &Router, body: String) -> Response {
app.clone()
.oneshot(
Request::post(p("/newAccount"))
.header("content-type", "application/jose+json")
.body(Body::from(body))
.unwrap(),
)
.await
.unwrap()
}
fn account_id_from_location(res: &Response) -> String {
res.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.expect("response must carry a Location header")
.rsplit('/')
.next()
.unwrap()
.to_string()
}
#[tokio::test]
async fn directory_advertises_meta_when_eab_enabled() {
let (app, _db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let directory = get_directory(&app).await;
assert_eq!(directory["meta"]["externalAccountRequired"], true);
}
#[tokio::test]
async fn directory_omits_meta_when_eab_disabled() {
let app = test_app().await;
let directory = get_directory(&app).await;
assert!(directory.get("meta").is_none());
}
#[tokio::test]
async fn missing_eab_is_rejected_when_required() {
let (app, _db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(
problem["type"],
"urn:ietf:params:acme:error:externalAccountRequired"
);
}
#[tokio::test]
async fn valid_eab_creates_account_and_records_kid() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let key = Eab::create(Some("customer-1".to_string()), None, &db)
.await
.unwrap();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
NEW_ACCOUNT_URL,
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::CREATED);
let account_id = {
let location = res
.headers()
.get("location")
.and_then(|v| v.to_str().ok())
.unwrap()
.to_string();
location.rsplit('/').next().unwrap().to_string()
};
let account = Account::find_by_id(common::PROFILE, &account_id, &db)
.await
.unwrap()
.unwrap();
assert_eq!(account.eab_kid, Some(key.kid));
}
#[tokio::test]
async fn revoked_kid_is_rejected() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let key = Eab::create(None, None, &db).await.unwrap();
Eab::revoke(&key.kid.to_string(), &db).await.unwrap();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
NEW_ACCOUNT_URL,
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:unauthorized");
}
#[tokio::test]
async fn unknown_kid_is_rejected() {
let (app, _db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
"never-created-kid",
b"some-32-byte-long-secret-value!!",
NEW_ACCOUNT_URL,
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:unauthorized");
}
#[tokio::test]
async fn bad_hmac_signature_is_rejected() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let key = Eab::create(None, None, &db).await.unwrap();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
b"wrong-secret-wrong-secret-wrong!",
NEW_ACCOUNT_URL,
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::UNAUTHORIZED);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:unauthorized");
}
#[tokio::test]
async fn url_mismatch_is_rejected() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let key = Eab::create(None, None, &db).await.unwrap();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
"http://localhost:3000/profile/default/somewhereElse",
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
}
#[tokio::test]
async fn payload_jwk_mismatch_is_rejected() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let account_signer = EcSigner::new();
let other_signer = EcSigner::new();
let key = Eab::create(None, None, &db).await.unwrap();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
NEW_ACCOUNT_URL,
&other_signer.jwk(),
);
let body = account_signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(problem["type"], "urn:ietf:params:acme:error:malformed");
}
#[tokio::test]
async fn same_kid_binds_two_distinct_accounts() {
let (app, db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let key = Eab::create(Some("team-a".to_string()), None, &db)
.await
.unwrap();
let first_signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
NEW_ACCOUNT_URL,
&first_signer.jwk(),
);
let body = first_signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let first_res = post_new_account(&app, body).await;
assert_eq!(first_res.status(), StatusCode::CREATED);
let first_id = account_id_from_location(&first_res);
let second_signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let eab = build_eab(
&key.kid.to_string(),
&key.secret,
NEW_ACCOUNT_URL,
&second_signer.jwk(),
);
let body = second_signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": eab }),
);
let second_res = post_new_account(&app, body).await;
assert_eq!(second_res.status(), StatusCode::CREATED);
let second_id = account_id_from_location(&second_res);
assert_ne!(first_id, second_id);
}
#[tokio::test]
async fn only_return_existing_is_exempt_from_eab() {
let (app, _db) = test_app_with_challenges(eab_enabled_config(), default_challenges()).await;
let signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "onlyReturnExisting": true }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::BAD_REQUEST);
let problem = body_json(res).await;
assert_eq!(
problem["type"],
"urn:ietf:params:acme:error:accountDoesNotExist"
);
}
#[tokio::test]
async fn eab_field_is_ignored_when_disabled() {
let app = test_app().await;
let signer = EcSigner::new();
let nonce = fetch_nonce(&app).await;
let garbage_eab = build_eab(
"nonexistent-kid",
b"irrelevant-secret-irrelevant-sec",
DEFAULT_BASE,
&signer.jwk(),
);
let body = signer.sign(
NEW_ACCOUNT_URL,
&nonce,
&json!({ "termsOfServiceAgreed": true, "externalAccountBinding": garbage_eab }),
);
let res = post_new_account(&app, body).await;
assert_eq!(res.status(), StatusCode::CREATED);
}