feat: add domain onboarding API with ACME-style challenges

This commit is contained in:
Till Wegmueller 2026-04-03 19:32:13 +02:00
parent 7aa5a6738c
commit 9829f84034
No known key found for this signature in database
3 changed files with 433 additions and 0 deletions

274
src/handler/domains.rs Normal file
View file

@ -0,0 +1,274 @@
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use axum::{Json, Router};
use sea_orm::*;
use serde::Deserialize;
use serde_json::json;
use crate::auth;
use crate::entity::domains;
use crate::error::{AppError, AppResult};
use crate::state::AppState;
#[derive(Deserialize)]
pub struct CreateDomainRequest {
domain: String,
challenge_type: String,
}
async fn create_domain(
State(state): State<AppState>,
Json(req): Json<CreateDomainRequest>,
) -> AppResult<(StatusCode, Json<serde_json::Value>)> {
if req.challenge_type != "dns-01" && req.challenge_type != "http-01" {
return Err(AppError::BadRequest(
"challenge_type must be dns-01 or http-01".into(),
));
}
// Check for duplicate
let existing = domains::Entity::find()
.filter(domains::Column::Domain.eq(&req.domain))
.one(&state.db)
.await?;
if existing.is_some() {
return Err(AppError::Conflict("domain already registered".into()));
}
let id = uuid::Uuid::new_v4().to_string();
let challenge_token = auth::generate_secret();
let registration_secret = auth::generate_secret();
let registration_secret_hash = auth::hash_token(&registration_secret)
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
let domain = domains::ActiveModel {
id: Set(id.clone()),
domain: Set(req.domain.clone()),
owner_token_hash: Set(String::new()), // Set on verification
registration_secret: Set(registration_secret_hash),
challenge_type: Set(req.challenge_type.clone()),
challenge_token: Set(Some(challenge_token.clone())),
verified: Set(false),
created_at: Set(chrono::Utc::now().naive_utc()),
verified_at: Set(None),
};
domain.insert(&state.db).await?;
let instructions = match req.challenge_type.as_str() {
"dns-01" => format!(
"Create a TXT record at {}.{} with value: {}",
state.settings.challenge.dns_txt_prefix, req.domain, challenge_token
),
"http-01" => format!(
"Serve the challenge at https://{}/{}/{}",
req.domain, state.settings.challenge.http_well_known_path, challenge_token
),
_ => unreachable!(),
};
Ok((
StatusCode::CREATED,
Json(json!({
"id": id,
"challenge_token": challenge_token,
"challenge_type": req.challenge_type,
"registration_secret": registration_secret,
"instructions": instructions,
})),
))
}
#[derive(Deserialize)]
pub struct VerifyRequest {
registration_secret: String,
}
async fn verify_domain(
State(state): State<AppState>,
Path(id): Path<String>,
Json(req): Json<VerifyRequest>,
) -> AppResult<Json<serde_json::Value>> {
let domain = domains::Entity::find_by_id(&id)
.one(&state.db)
.await?
.ok_or(AppError::NotFound)?;
// Verify registration secret
if !auth::verify_token(&req.registration_secret, &domain.registration_secret) {
return Err(AppError::Unauthorized);
}
if domain.verified {
return Err(AppError::Conflict("domain already verified".into()));
}
let challenge_token = domain
.challenge_token
.as_deref()
.ok_or_else(|| AppError::BadRequest("no pending challenge".into()))?;
// Check challenge TTL
let challenge_age = chrono::Utc::now().naive_utc() - domain.created_at;
if challenge_age.num_seconds() > state.settings.challenge.challenge_ttl_secs as i64 {
return Err(AppError::BadRequest("challenge expired".into()));
}
// Verify the challenge via the injected verifier (mockable in tests)
let verified = match domain.challenge_type.as_str() {
"dns-01" => state.challenge_verifier
.verify_dns(&domain.domain, challenge_token, &state.settings.challenge)
.await
.map_err(|e| AppError::Internal(e))?,
"http-01" => state.challenge_verifier
.verify_http(&domain.domain, challenge_token, &state.settings.challenge)
.await
.map_err(|e| AppError::Internal(e))?,
_ => return Err(AppError::Internal("unknown challenge type".into())),
};
if !verified {
return Err(AppError::BadRequest("challenge verification failed".into()));
}
// Generate owner token (prefixed with domain ID for O(1) lookup)
let owner_token = auth::generate_token(&id);
let owner_token_hash = auth::hash_token(&owner_token)
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
// Update domain
let mut active: domains::ActiveModel = domain.into();
active.verified = Set(true);
active.verified_at = Set(Some(chrono::Utc::now().naive_utc()));
active.owner_token_hash = Set(owner_token_hash);
active.challenge_token = Set(None);
active.registration_secret = Set(String::new()); // Invalidate
active.update(&state.db).await?;
Ok(Json(json!({
"verified": true,
"owner_token": owner_token,
})))
}
/// Extract and verify owner token from Authorization header.
/// The token format is `{domain_id}.{secret}` -- the domain_id from the token
/// must match the `id` path parameter to prevent cross-domain access.
pub async fn authenticate_owner(
db: &DatabaseConnection,
id: &str,
auth_header: Option<&str>,
) -> AppResult<domains::Model> {
let full_token = auth_header
.and_then(|h| h.strip_prefix("Bearer "))
.ok_or(AppError::Unauthorized)?;
// Verify the token's embedded ID matches the requested domain
let (token_domain_id, _) = auth::split_token(full_token)
.ok_or(AppError::Unauthorized)?;
if token_domain_id != id {
return Err(AppError::Unauthorized);
}
let domain = domains::Entity::find_by_id(id)
.one(db)
.await?
.ok_or(AppError::NotFound)?;
if !domain.verified {
return Err(AppError::Forbidden("domain not verified".into()));
}
if !auth::verify_token(full_token, &domain.owner_token_hash) {
return Err(AppError::Unauthorized);
}
Ok(domain)
}
async fn get_domain(
State(state): State<AppState>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
) -> AppResult<Json<serde_json::Value>> {
let auth_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok());
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
Ok(Json(json!({
"id": domain.id,
"domain": domain.domain,
"verified": domain.verified,
"challenge_type": domain.challenge_type,
"created_at": domain.created_at.to_string(),
"verified_at": domain.verified_at.map(|v| v.to_string()),
})))
}
async fn rotate_token(
State(state): State<AppState>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
) -> AppResult<Json<serde_json::Value>> {
let auth_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok());
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
let new_token = auth::generate_token(&domain.id);
let new_hash = auth::hash_token(&new_token)
.map_err(|e| AppError::Internal(format!("hash error: {e}")))?;
let mut active: domains::ActiveModel = domain.into();
active.owner_token_hash = Set(new_hash);
active.update(&state.db).await?;
Ok(Json(json!({
"owner_token": new_token,
})))
}
async fn delete_domain(
State(state): State<AppState>,
Path(id): Path<String>,
headers: axum::http::HeaderMap,
) -> AppResult<StatusCode> {
let auth_header = headers
.get("authorization")
.and_then(|v| v.to_str().ok());
let domain = authenticate_owner(&state.db, &id, auth_header).await?;
// Query all resource URIs for this domain before deleting
use crate::entity::resources;
let resource_uris: Vec<String> = resources::Entity::find()
.filter(resources::Column::DomainId.eq(&domain.id))
.all(&state.db)
.await?
.into_iter()
.map(|r| r.resource_uri)
.collect();
// Cascade: delete domain (FK cascades handle DB rows)
domains::Entity::delete_by_id(&domain.id)
.exec(&state.db)
.await?;
// Evict cache entries for all affected resources
state.cache.remove_many(&resource_uris);
Ok(StatusCode::NO_CONTENT)
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/api/v1/domains", post(create_domain))
.route("/api/v1/domains/{id}", get(get_domain).delete(delete_domain))
.route("/api/v1/domains/{id}/verify", post(verify_domain))
.route("/api/v1/domains/{id}/rotate-token", post(rotate_token))
}

View file

@ -1,3 +1,4 @@
pub mod domains;
mod health;
mod host_meta;
mod webfinger;
@ -9,6 +10,7 @@ pub fn router(state: AppState) -> Router {
Router::new()
.merge(webfinger::router())
.merge(host_meta::router())
.merge(domains::router())
.merge(health::router())
.with_state(state)
}

157
tests/test_domains.rs Normal file
View file

@ -0,0 +1,157 @@
mod common;
use axum_test::TestServer;
use serde_json::json;
use webfingerd::handler;
#[tokio::test]
async fn test_register_domain() {
let state = common::test_state().await;
let app = handler::router(state);
let server = TestServer::new(app);
let response = server
.post("/api/v1/domains")
.json(&json!({
"domain": "example.com",
"challenge_type": "dns-01"
}))
.await;
response.assert_status(axum::http::StatusCode::CREATED);
let body: serde_json::Value = response.json();
assert!(body["id"].is_string());
assert!(body["challenge_token"].is_string());
assert!(body["registration_secret"].is_string());
assert_eq!(body["challenge_type"], "dns-01");
}
#[tokio::test]
async fn test_register_duplicate_domain_returns_409() {
let state = common::test_state().await;
let app = handler::router(state);
let server = TestServer::new(app);
server
.post("/api/v1/domains")
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
.await;
let response = server
.post("/api/v1/domains")
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
.await;
response.assert_status(axum::http::StatusCode::CONFLICT);
}
#[tokio::test]
async fn test_get_domain_requires_auth() {
let state = common::test_state().await;
let app = handler::router(state);
let server = TestServer::new(app);
let create_resp = server
.post("/api/v1/domains")
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
.await;
let id = create_resp.json::<serde_json::Value>()["id"]
.as_str()
.unwrap()
.to_string();
// No auth header
let response = server.get(&format!("/api/v1/domains/{id}")).await;
response.assert_status_unauthorized();
}
#[tokio::test]
async fn test_get_domain_with_valid_owner_token() {
let state = common::test_state().await;
let app = handler::router(state.clone());
let server = TestServer::new(app);
// Register domain
let create_resp = server
.post("/api/v1/domains")
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
.await;
let body: serde_json::Value = create_resp.json();
let id = body["id"].as_str().unwrap();
let reg_secret = body["registration_secret"].as_str().unwrap();
// Verify (MockChallengeVerifier always succeeds)
let verify_resp = server
.post(&format!("/api/v1/domains/{id}/verify"))
.json(&json!({"registration_secret": reg_secret}))
.await;
verify_resp.assert_status_ok();
let owner_token = verify_resp.json::<serde_json::Value>()["owner_token"]
.as_str()
.unwrap()
.to_string();
// Use owner token to get domain
let response = server
.get(&format!("/api/v1/domains/{id}"))
.add_header("Authorization", format!("Bearer {owner_token}"))
.await;
response.assert_status_ok();
let body: serde_json::Value = response.json();
assert_eq!(body["domain"], "example.com");
assert_eq!(body["verified"], true);
}
#[tokio::test]
async fn test_rotate_token() {
let state = common::test_state().await;
let app = handler::router(state.clone());
let server = TestServer::new(app);
// Register domain
let create_resp = server
.post("/api/v1/domains")
.json(&json!({"domain": "example.com", "challenge_type": "dns-01"}))
.await;
let body: serde_json::Value = create_resp.json();
let id = body["id"].as_str().unwrap();
let reg_secret = body["registration_secret"].as_str().unwrap();
// Verify (MockChallengeVerifier always succeeds)
let verify_resp = server
.post(&format!("/api/v1/domains/{id}/verify"))
.json(&json!({"registration_secret": reg_secret}))
.await;
let old_token = verify_resp.json::<serde_json::Value>()["owner_token"]
.as_str()
.unwrap()
.to_string();
// Rotate
let rotate_resp = server
.post(&format!("/api/v1/domains/{id}/rotate-token"))
.add_header("Authorization", format!("Bearer {old_token}"))
.await;
rotate_resp.assert_status_ok();
let new_token = rotate_resp.json::<serde_json::Value>()["owner_token"]
.as_str()
.unwrap()
.to_string();
// Old token should fail
let response = server
.get(&format!("/api/v1/domains/{id}"))
.add_header("Authorization", format!("Bearer {old_token}"))
.await;
response.assert_status_unauthorized();
// New token should work
let response = server
.get(&format!("/api/v1/domains/{id}"))
.add_header("Authorization", format!("Bearer {new_token}"))
.await;
response.assert_status_ok();
}