Skip to main content

acme_proxy/webadmin/pages/
eab.rs

1//! `/ui/eab` — External Account Binding credentials.
2//!
3//! The one page in this tree that ever renders a secret, and it renders it
4//! exactly once: `render_eab_created_json` is the only renderer carrying
5//! `hmacKey`, the list and the detail read the same row through
6//! `render_eab_json`, and `Eab::to_json` has no such member. A lost credential
7//! is replaced, never recovered.
8
9use axum::extract::{Path, State};
10use axum::http::StatusCode;
11use axum::response::{Html, IntoResponse, Response};
12use serde::Deserialize;
13use serde_json::{Map, Value};
14
15use crate::admin;
16use crate::sqlite::eab::Eab;
17use crate::webadmin::AdminState;
18use crate::webadmin::pages::auth::{PageSession, PageSessionWrite};
19use crate::webadmin::pages::error::PageError;
20use crate::webadmin::pages::{chrome, flash, respond, respond_fragment};
21
22#[derive(Debug, Deserialize, Default)]
23pub struct CreateForm {
24    /// A human label, free text. Rendered into the list and the detail, which
25    /// is why every page template is `.html` and auto-escaped.
26    #[serde(default)]
27    pub label: String,
28    /// Empty means the credential is valid at every endpoint — the `NULL` the
29    /// column stores, not a profile named "".
30    #[serde(default)]
31    pub profile: String,
32}
33
34/// `GET /ui/eab`
35///
36/// Unpaginated, over `Eab::list_all`: an operator mints these by hand, a few at
37/// a time, so the whole table is a page. `GET /api/eab` is the one that windows
38/// -- it answers the envelope every list endpoint in that API answers, and a
39/// script has no scroll bar to reach the rest with. Both read the table in the
40/// same order (oldest first), which is what keeps the two surfaces describing
41/// one listing.
42pub async fn list_eab(
43    State(state): State<AdminState>,
44    session: PageSession,
45) -> Result<Html<String>, PageError> {
46    let mut context = chrome(&session, "eab", "External Account Binding");
47    context.insert("items".to_string(), Value::Array(rows(&state).await?));
48    context.insert(
49        "profiles".to_string(),
50        Value::Array(crate::webadmin::handlers::misc::profile_rows(&state)),
51    );
52
53    respond(
54        &state,
55        session.hx,
56        "eab/list.html",
57        "eab/_table.html",
58        context,
59    )
60}
61
62/// `GET /ui/eab/{kid}`
63pub async fn get_eab(
64    State(state): State<AdminState>,
65    Path(kid): Path<String>,
66    session: PageSession,
67) -> Result<Html<String>, PageError> {
68    let eab = load(&kid, &state).await?;
69
70    let mut context = chrome(&session, "eab", "Credential");
71    context.insert("eab".to_string(), eab);
72
73    respond(
74        &state,
75        session.hx,
76        "eab/detail.html",
77        "eab/_card.html",
78        context,
79    )
80}
81
82/// `POST /ui/eab`
83///
84/// Answers `201` with the one-time secret, and refreshes the list underneath
85/// out of band — the new row would otherwise only appear on a reload, which is
86/// exactly when the secret would be gone.
87pub async fn create_eab(
88    State(state): State<AdminState>,
89    session: PageSessionWrite,
90    // See `pages::orders::revoke_order`: `Option<Form<_>>` is not an axum
91    // extractor, and this is only ever reached from a browser form.
92    axum::Form(form): axum::Form<CreateForm>,
93) -> Result<Response, PageError> {
94    let label = non_empty(&form.label);
95    let profile = non_empty(&form.profile);
96
97    super::super::handlers::eab::require_mounted_profile(
98        &state,
99        profile.as_deref(),
100        "leave it unset",
101    )?;
102
103    let eab = Eab::create(label, profile, &state.database).await?;
104    tracing::info!(event = "admin_eab_created",
105                   outcome = "success",
106                   surface = "ui",
107                   kid = %eab.kid,
108                   username = %session.auth.user.username);
109
110    let mut context = Map::new();
111    context.insert("eab".to_string(), admin::render_eab_created_json(&eab));
112    context.insert("items".to_string(), Value::Array(rows(&state).await?));
113    // Read by `eab/_table.html`'s root element: this response carries the table
114    // as well as the new credential, and htmx matches an out-of-band swap on
115    // the id of the element carrying the attribute.
116    context.insert("oob".to_string(), Value::Bool(true));
117
118    let body = respond_fragment(&state, "eab/_created.html", context)?;
119    Ok((StatusCode::CREATED, body).into_response())
120}
121
122/// `POST /ui/eab/{kid}/revoke`
123pub async fn revoke_eab(
124    State(state): State<AdminState>,
125    Path(kid): Path<String>,
126    session: PageSessionWrite,
127) -> Result<Html<String>, PageError> {
128    // Idempotent, so a second revoke is not an error — but the row still has to
129    // exist, or the operator is being told something happened to nothing.
130    if !Eab::revoke(&kid, &state.database).await? {
131        return Err(not_found(&kid));
132    }
133
134    tracing::info!(event = "admin_eab_revoked",
135                   outcome = "success",
136                   surface = "ui",
137                   kid = %kid,
138                   username = %session.auth.user.username);
139
140    let eab = load(&kid, &state).await?;
141    let mut context = Map::new();
142    context.insert(
143        "csrf_token".to_string(),
144        Value::String(session.auth.session.csrf_token.clone()),
145    );
146    context.insert("eab".to_string(), eab);
147    context.insert(
148        "flash".to_string(),
149        flash(
150            "ok",
151            "Credential revoked. Registrations using it fail from now on.",
152        ),
153    );
154    respond_fragment(&state, "eab/_card.html", context)
155}
156
157async fn rows(state: &AdminState) -> Result<Vec<Value>, PageError> {
158    Ok(Eab::list_all(&state.database)
159        .await?
160        .iter()
161        .map(admin::render_eab_json)
162        .collect())
163}
164
165async fn load(kid: &str, state: &AdminState) -> Result<Value, PageError> {
166    let eab = Eab::find_any_by_kid(kid, &state.database)
167        .await?
168        .ok_or_else(|| not_found(kid))?;
169    Ok(admin::render_eab_json(&eab))
170}
171
172/// A form field left blank is absent, not the empty string.
173fn non_empty(raw: &str) -> Option<String> {
174    let trimmed = raw.trim();
175    (!trimmed.is_empty()).then(|| trimmed.to_string())
176}
177
178fn not_found(kid: &str) -> PageError {
179    PageError::not_found(format!("no such EAB credential: {kid}"))
180}