Skip to main content

acme_proxy_protocol/handlers/
authz.rs

1//! Authorizations (RFC 8555 §7.5) and challenges (§7.5.1).
2//!
3//! A challenge URL, like an authorization's, serves two operations told apart
4//! by whether a payload arrived. A POST-as-GET (§6.3) reads the challenge and
5//! never claims it or queues work. A payload (the client's `{}`) does not
6//! validate it either: it claims the challenge and queues the validation for
7//! the worker (ADR 0006), answering `processing`. The client learns the verdict
8//! by polling, which is why a `pending` or `processing` answer carries
9//! `Retry-After` — but never a `pending` one no trigger could start any more.
10
11use axum::{
12    Extension, Json,
13    extract::{Path, State},
14    http::{HeaderValue, StatusCode, header},
15    response::{IntoResponse, Response},
16};
17use serde::Deserialize;
18use serde_json::Value;
19use std::net::IpAddr;
20use tracing::{error, info, instrument, warn};
21
22use crate::acme::access::{load_owned_authz, load_owned_challenge, signer_account};
23use crate::acme::order::{OrderService, challenge_can_be_triggered};
24use crate::extractors::acme::AcmeOptionalPayload;
25use crate::router::AppState;
26use acme_proxy_core::client::ClientIp;
27use acme_proxy_core::error::Problem;
28use acme_proxy_jobs::jobs::JobQueue;
29use acme_proxy_store::authz::Authorization;
30use acme_proxy_store::authz::Challenge;
31use acme_proxy_store::authz::ValidationClaim;
32use acme_proxy_store::nonce::now_secs;
33use acme_proxy_store::order::Order;
34use acme_proxy_store::status::ChallengeStatus;
35
36/// The one payload RFC 8555 §7.5.2 defines for the authorization resource:
37/// "sending POST requests with the static object `{"status": "deactivated"}`".
38#[derive(Debug, Deserialize)]
39pub struct AuthzUpdatePayload {
40    pub status: Option<String>,
41}
42
43/// Reads an authorization via POST-as-GET (RFC 8555 §7.5), or deactivates it
44/// (§7.5.2) — one URL, two operations, told apart by whether a payload arrived.
45///
46/// Both answer with the authorization object and its challenges, so the client
47/// sees the state it just read or just caused.
48#[instrument(name = "post_authz", skip_all, fields(authz_id = %id))]
49pub async fn post_authz(
50    State(state): State<AppState>,
51    Path(id): Path<String>,
52    AcmeOptionalPayload {
53        payload,
54        pubkey,
55        account,
56        ..
57    }: AcmeOptionalPayload<AuthzUpdatePayload>,
58) -> Result<Response, Problem> {
59    info!(
60        event = "authz_lookup_requested",
61        outcome = "progress",
62        authz_id = %id,
63        deactivating = payload.is_some(),
64    );
65    let AppState {
66        database,
67        profile,
68        audit,
69        ..
70    } = state;
71    let base = &profile.base_url;
72
73    // `load_owned_authz` walks up to the order and checks `account_id`, which is
74    // §7.5.2's "the server MUST verify that the request is signed by the account
75    // key corresponding to the account that owns the authorization".
76    let account = signer_account(account, &profile.name, &pubkey, &database).await?;
77    let (mut authz, mut order) = load_owned_authz(&id, &account, &database).await?;
78
79    if let Some(update) = payload {
80        // §7.5.2 defines exactly one payload; anything else is a client sending
81        // us something we would otherwise silently ignore.
82        if update.status.as_deref() != Some("deactivated") {
83            warn!(event = "authz_update_unsupported", outcome = "failure", authz_id = %id, status = ?update.status);
84            return Err(Problem::malformed(
85                "Only {\"status\": \"deactivated\"} is supported on an authorization",
86            ));
87        }
88        let orders = OrderService {
89            database: &database,
90            audit: &audit,
91            profile: &profile,
92        };
93        orders.deactivate_authz(&mut authz, &mut order).await?;
94    }
95
96    let challenges = Challenge::find_by_authz(authz.id, &database)
97        .await
98        .map_err(|error| {
99            error!(
100                event = "challenge_list_failed",
101                outcome = "failure",
102                authz_id = %id,
103                error = %error
104            );
105            Problem::server_internal("Challenge lookup failed")
106        })?;
107
108    let mut response = Json(authz.to_json(base, &challenges)).into_response();
109    add_pending_retry_after(&mut response, authz.status.as_str());
110    Ok(response)
111}
112
113/// Adds `Retry-After` while a resource is still undecided.
114///
115/// RFC 8555 §7.5.1: "The server SHOULD provide information about its retry
116/// state to the client via the `Retry-After` HTTP header field" — the same
117/// pacing courtesy `order_response` extends to a `processing` order. Any other
118/// status is decided, so there is nothing to come back for.
119///
120/// `processing` is here because §8.2 says so in as many words: "While the
121/// server is still trying, the status of the challenge remains `processing`",
122/// and the `Retry-After` on requests to the challenge resource is a `MUST` in
123/// that paragraph. Only challenges ever carry it — an authorization has no such
124/// state — so this stays one helper for both.
125fn add_pending_retry_after(response: &mut Response, status: &str) {
126    if status == "pending" || status == "processing" {
127        response.headers_mut().insert(
128            header::RETRY_AFTER,
129            HeaderValue::from_static(super::POLL_RETRY_AFTER),
130        );
131    }
132}
133
134/// Reads a challenge via POST-as-GET (RFC 8555 §6.3), or triggers its
135/// validation (§7.5.1) — one URL, two operations, told apart by whether a
136/// payload arrived, as on the authorization.
137///
138/// A zero-byte payload only reads the owned challenge; it never claims it or
139/// queues work. Any nonempty payload (the client's `{}`) is the acknowledgement.
140#[instrument(name = "post_challenge", skip_all, fields(challenge_id = %id))]
141pub async fn post_challenge(
142    State(state): State<AppState>,
143    Path(id): Path<String>,
144    Extension(ClientIp(client_ip)): Extension<ClientIp>,
145    AcmeOptionalPayload {
146        payload,
147        pubkey,
148        account,
149        ..
150    }: AcmeOptionalPayload<Value>,
151) -> Result<Response, Problem> {
152    info!(
153        event = "challenge_trigger_requested",
154        outcome = "progress",
155        challenge_id = %id,
156        triggering = payload.is_some(),
157    );
158    let AppState {
159        database,
160        profile,
161        audit,
162        jobs,
163        ..
164    } = state;
165    let base = &profile.base_url;
166    let orders = OrderService {
167        database: &database,
168        audit: &audit,
169        profile: &profile,
170    };
171
172    let account = signer_account(account, &profile.name, &pubkey, &database).await?;
173    let (mut challenge, authz, order) = load_owned_challenge(&id, &account, &database).await?;
174
175    // An empty payload only reads the owned challenge; it never starts work.
176    if payload.is_some()
177        && let Some(early) = trigger_validation(
178            &orders,
179            &jobs,
180            &mut challenge,
181            &authz,
182            &order,
183            &id,
184            client_ip,
185        )
186        .await?
187    {
188        return Ok(early);
189    }
190
191    info!(
192        event = "challenge_answered",
193        outcome = "success",
194        challenge_id = %id,
195        authz_id = %authz.id,
196        order_id = %order.id,
197        status = %challenge.status
198    );
199    let up_link = format!("<{base}/authz/{}>;rel=\"up\"", authz.id);
200    let mut response = (
201        StatusCode::OK,
202        [(header::LINK, up_link)],
203        Json(challenge.to_json(base)),
204    )
205        .into_response();
206    // A `pending` challenge no trigger could start any more — its authorization
207    // expired, was deactivated or failed, or a sibling already decided it — is
208    // read without a refusal, but must not invite the client to poll an object
209    // that can never move. A `processing` one has a job that owes it a verdict.
210    if challenge.status != ChallengeStatus::Pending
211        || challenge_can_be_triggered(&authz, &order, now_secs())
212    {
213        add_pending_retry_after(&mut response, challenge.status.as_str());
214    }
215    Ok(response)
216}
217
218/// Claims `challenge` and queues its validation — the trigger half of
219/// [`post_challenge`].
220///
221/// `Some` is an answer that replaces the challenge object (the §6.6 rate
222/// limit); `None` means answer with the challenge as it now stands.
223async fn trigger_validation(
224    orders: &OrderService<'_>,
225    jobs: &JobQueue,
226    challenge: &mut Challenge,
227    authz: &Authorization,
228    order: &Order,
229    id: &str,
230    client_ip: Option<IpAddr>,
231) -> Result<Option<Response>, Problem> {
232    // Claimed, then queued — never awaited. The check reaches an address the
233    // client named, so awaiting it here held an admission permit for the length
234    // of `challenge.timeout_ms`. The challenge now answers `processing`, which
235    // §7.1.6 defines for exactly this ("transitions to the `processing` state
236    // when the client responds to the challenge") and §8.2 pairs with the
237    // `Retry-After` below.
238    let claim = orders.claim_challenge(challenge, authz, order).await?;
239    if claim == ValidationClaim::Limited {
240        // §6.6's answer, with the header §6.6 recommends: the limit is on work
241        // in flight, so waiting is exactly what clears it. The challenge is
242        // untouched and still `pending`, so the retry is a plain re-trigger.
243        let mut response = Problem::rate_limited(
244            "Too many validations are already running for this account; retry shortly",
245        )
246        .into_response();
247        response.headers_mut().insert(
248            header::RETRY_AFTER,
249            HeaderValue::from_static(super::POLL_RETRY_AFTER),
250        );
251        return Ok(Some(response));
252    }
253    if claim == ValidationClaim::Claimed {
254        let queued = jobs
255            .enqueue(crate::acme::validate::challenge_validate_spec(
256                id,
257                client_ip,
258                authz.expires,
259            ))
260            .await;
261
262        // The claim is on the row and the work is not queued, so nothing is
263        // coming for it: give the claim back rather than leave the client
264        // polling a `processing` challenge until its authorization expires.
265        // `Ok(false)` needs no release — a live job already holds this
266        // challenge's identity, which is the same fact the claim asserts.
267        if let Err(error) = queued {
268            error!(
269                event = "challenge_validation_enqueue_failed",
270                outcome = "failure",
271                challenge_id = %id,
272                error = %error
273            );
274            let _ = challenge.release_validation_claim(orders.database).await;
275            return Err(Problem::server_internal(
276                "Challenge validation could not be queued",
277            ));
278        }
279    }
280    Ok(None)
281}