Skip to main content

cortex/frontend/
webauthn.rs

1// Copyright 2015-2025 Deyan Ginev. See the LICENSE
2// file at the top-level directory of this distribution.
3//
4// Licensed under the MIT license <LICENSE-MIT or http://opensource.org/licenses/MIT>.
5// This file may not be copied, modified, or distributed
6// except according to those terms.
7
8//! Passkey (**WebAuthn**) sign-in — the relying-party instance built from config
9//! (`docs/archive/WEBAUTHN_DESIGN.md`). This is the **foundation**: the configured
10//! [`webauthn_rs::prelude::Webauthn`] relying party as Rocket managed state. The
11//! registration/authentication ceremonies and the sign-in UI build on this in the following
12//! increments.
13//!
14//! The relying party is the CorTeX server itself — no external IdP, no per-deployment app
15//! registration. Passkeys are the convenient day-to-day human sign-in; the admin token
16//! (`frontend::actor`) remains the bootstrap / break-glass / agent credential. Passkeys never block
17//! the token path: a disabled or misconfigured relying party degrades to `None` (logged), and
18//! sign-in still works via the token.
19
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex};
22use std::time::{Duration, Instant};
23
24use rand::RngExt;
25use rand::distr::Alphanumeric;
26use rocket::http::{Cookie, CookieJar, SameSite, Status};
27use rocket::response::Redirect;
28use rocket::serde::json::Json;
29use rocket::{Route, State};
30use rocket_dyn_templates::{Template, context};
31use serde::Serialize;
32use webauthn_rs::prelude::*;
33
34use crate::backend::DbPool;
35use crate::config::WebauthnConfig;
36use crate::frontend::actor::{
37  ADMIN_COOKIE, AdminReject, AdminSession, ReturnTo, require_admin, require_admin_to,
38};
39use crate::models::{Session, WebauthnCredential, WebauthnUser};
40
41/// The cookie carrying the in-flight ceremony id between a `…/begin` and its `…/finish` (scoped to
42/// the passkey paths, HttpOnly, SameSite=Strict — it is never a credential, just a lookup key).
43const CEREMONY_COOKIE: &str = "cortex_ceremony";
44/// How long an in-flight ceremony is valid (a user taps their authenticator within seconds).
45const CEREMONY_TTL: Duration = Duration::from_secs(300);
46
47/// The configured WebAuthn relying-party instance, shared as Rocket managed state. Present only
48/// when passkeys are **enabled** and the relying party built successfully; absent ⇒ token sign-in
49/// only.
50pub struct WebauthnState {
51  /// The relying-party instance (`Arc` so the ceremony handlers cheaply share one instance).
52  pub webauthn: Arc<Webauthn>,
53}
54
55/// Builds the relying-party [`Webauthn`] from config, or returns `None` (logged, never panics) when
56/// passkeys are disabled or the `rp_id`/`rp_origin` are invalid — token sign-in keeps working
57/// either way (graceful degradation, the robustness mandate).
58pub fn build_state(config: &WebauthnConfig) -> Option<WebauthnState> {
59  if !config.enabled {
60    return None;
61  }
62  let origin = match Url::parse(&config.rp_origin) {
63    Ok(origin) => origin,
64    Err(error) => {
65      tracing::error!(rp_origin = %config.rp_origin, %error, "webauthn: invalid rp_origin (passkeys disabled)");
66      return None;
67    },
68  };
69  match WebauthnBuilder::new(&config.rp_id, &origin)
70    .map(|builder| builder.rp_name("CorTeX"))
71    .and_then(|builder| builder.build())
72  {
73    Ok(webauthn) => Some(WebauthnState {
74      webauthn: Arc::new(webauthn),
75    }),
76    Err(error) => {
77      tracing::error!(rp_id = %config.rp_id, rp_origin = %config.rp_origin, %error, "webauthn: cannot build relying party (passkeys disabled)");
78      None
79    },
80  }
81}
82
83/// In-flight WebAuthn ceremony state, kept **server-side** between the begin and finish requests
84/// (never serialized to the client) and keyed by a random id in the [`CEREMONY_COOKIE`].
85pub enum Ceremony {
86  /// A passkey **enrollment** in progress (the owner is known from the signed-in session).
87  Register(PasskeyRegistration),
88  /// A passkey **sign-in** in progress; carries the claimed `owner` (verified by the assertion at
89  /// finish) so a successful authentication opens a session for the right identity.
90  Authenticate {
91    /// The owner the sign-in is for (its enrolled passkeys seed the challenge).
92    owner: String,
93    /// The in-progress authentication state, paired to the issued challenge.
94    state: PasskeyAuthentication,
95  },
96}
97
98/// A short-lived, process-local store of in-flight ceremonies. Ceremonies live seconds, so an
99/// in-memory map (pruned on insert, capacity-bounded by the TTL) is sufficient and needs no
100/// `danger-allow-state-serialisation`. Mutex poisoning is recovered from, never panicked on (this
101/// is a request path — `docs/DESIGN_PRINCIPLES.md`).
102pub struct CeremonyStore {
103  inner: Mutex<HashMap<String, (Ceremony, Instant)>>,
104}
105
106impl CeremonyStore {
107  /// An empty store (managed by Rocket).
108  pub fn new() -> Self {
109    CeremonyStore {
110      inner: Mutex::new(HashMap::new()),
111    }
112  }
113
114  /// Stores a ceremony, pruning expired entries, and returns its random id (for the cookie).
115  pub fn put(&self, ceremony: Ceremony) -> String {
116    let id: String = rand::rng()
117      .sample_iter(&Alphanumeric)
118      .take(32)
119      .map(char::from)
120      .collect();
121    let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
122    let now = Instant::now();
123    map.retain(|_, (_, expiry)| *expiry > now);
124    map.insert(id.clone(), (ceremony, now + CEREMONY_TTL));
125    id
126  }
127
128  /// Removes and returns a ceremony if present and unexpired (single-use).
129  pub fn take(&self, id: &str) -> Option<Ceremony> {
130    let mut map = self.inner.lock().unwrap_or_else(|p| p.into_inner());
131    match map.remove(id) {
132      Some((ceremony, expiry)) if expiry > Instant::now() => Some(ceremony),
133      _ => None,
134    }
135  }
136}
137
138impl Default for CeremonyStore {
139  fn default() -> Self { Self::new() }
140}
141
142/// Checks out the relying party, mapping "passkeys disabled" to `503` (the caller's UI offers the
143/// token sign-in instead).
144fn relying_party(webauthn: &State<Option<WebauthnState>>) -> Result<&Webauthn, Status> {
145  webauthn
146    .inner()
147    .as_ref()
148    .map(|state| state.webauthn.as_ref())
149    .ok_or(Status::ServiceUnavailable)
150}
151
152/// **Enroll, step 1** (`POST /admin/passkeys/register/begin`): a signed-in admin starts registering
153/// a new passkey for their own identity. Returns the WebAuthn `CreationChallengeResponse` JSON for
154/// `navigator.credentials.create()` and stashes the ceremony state server-side (cookie-keyed).
155/// `401` if not signed in, `503` if passkeys are disabled. Already-enrolled credentials are
156/// excluded so the same authenticator isn't registered twice.
157#[post("/admin/passkeys/register/begin")]
158pub fn register_begin(
159  session: AdminSession,
160  webauthn: &State<Option<WebauthnState>>,
161  store: &State<CeremonyStore>,
162  cookies: &CookieJar<'_>,
163  pool: &State<DbPool>,
164) -> Result<Json<CreationChallengeResponse>, Status> {
165  let webauthn = relying_party(webauthn)?;
166  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
167  let handle = WebauthnUser::ensure(&mut connection, &session.owner)
168    .map_err(|_| Status::InternalServerError)?;
169  let exclude: Vec<CredentialID> = WebauthnCredential::for_owner(&mut connection, &session.owner)
170    .unwrap_or_default()
171    .iter()
172    .filter_map(|row| serde_json::from_value::<Passkey>(row.credential.clone()).ok())
173    .map(|passkey| passkey.cred_id().clone())
174    .collect();
175  let (challenge, state) = webauthn
176    .start_passkey_registration(handle, &session.owner, &session.owner, Some(exclude))
177    .map_err(|error| {
178      tracing::error!(%error, "webauthn: start_passkey_registration failed");
179      Status::InternalServerError
180    })?;
181  let ceremony_id = store.put(Ceremony::Register(state));
182  cookies.add(
183    Cookie::build((CEREMONY_COOKIE, ceremony_id))
184      .http_only(true)
185      .same_site(SameSite::Strict)
186      .path("/admin/passkeys")
187      .build(),
188  );
189  Ok(Json(challenge))
190}
191
192/// **Enroll, step 2** (`POST /admin/passkeys/register/finish?label=`): finishes registration with
193/// the authenticator's response, persisting the new passkey (public key only) under an optional
194/// `label`. `400` if the ceremony cookie/state is missing or the attestation doesn't verify.
195#[post("/admin/passkeys/register/finish?<label>", data = "<credential>")]
196pub fn register_finish(
197  session: AdminSession,
198  label: Option<String>,
199  credential: Json<RegisterPublicKeyCredential>,
200  webauthn: &State<Option<WebauthnState>>,
201  store: &State<CeremonyStore>,
202  cookies: &CookieJar<'_>,
203  pool: &State<DbPool>,
204) -> Result<Status, Status> {
205  let webauthn = relying_party(webauthn)?;
206  let ceremony_id = cookies
207    .get(CEREMONY_COOKIE)
208    .map(|cookie| cookie.value().to_string())
209    .ok_or(Status::BadRequest)?;
210  cookies.remove(
211    Cookie::build(CEREMONY_COOKIE)
212      .path("/admin/passkeys")
213      .build(),
214  );
215  let state = match store.take(&ceremony_id) {
216    Some(Ceremony::Register(state)) => state,
217    _ => return Err(Status::BadRequest),
218  };
219  let passkey = webauthn
220    .finish_passkey_registration(&credential, &state)
221    .map_err(|_| Status::BadRequest)?;
222  let value = serde_json::to_value(&passkey).map_err(|_| Status::InternalServerError)?;
223  let label = label.unwrap_or_default();
224  let label = if label.trim().is_empty() {
225    "passkey"
226  } else {
227    label.trim()
228  };
229  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
230  WebauthnCredential::store(&mut connection, &session.owner, label, &value)
231    .map_err(|_| Status::InternalServerError)?;
232  Ok(Status::Created)
233}
234
235/// **Sign in, step 1** (`POST /admin/passkeys/auth/begin?owner=`): begins a passkey authentication
236/// for the named owner, seeded by that owner's enrolled passkeys. Returns the WebAuthn
237/// `RequestChallengeResponse` for `navigator.credentials.get()` and stashes the ceremony state.
238/// `404` if the owner has no enrolled passkeys (admin identities are not treated as secret — the
239/// public deployment sits behind an Anubis proxy and the admin set is small), `503` if disabled.
240#[post("/admin/passkeys/auth/begin?<owner>")]
241pub fn auth_begin(
242  owner: String,
243  webauthn: &State<Option<WebauthnState>>,
244  store: &State<CeremonyStore>,
245  cookies: &CookieJar<'_>,
246  pool: &State<DbPool>,
247) -> Result<Json<RequestChallengeResponse>, Status> {
248  let webauthn = relying_party(webauthn)?;
249  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
250  let passkeys: Vec<Passkey> = WebauthnCredential::for_owner(&mut connection, &owner)
251    .unwrap_or_default()
252    .iter()
253    .filter_map(|row| serde_json::from_value::<Passkey>(row.credential.clone()).ok())
254    .collect();
255  if passkeys.is_empty() {
256    return Err(Status::NotFound);
257  }
258  let (challenge, state) = webauthn
259    .start_passkey_authentication(&passkeys)
260    .map_err(|error| {
261      tracing::error!(%error, "webauthn: start_passkey_authentication failed");
262      Status::InternalServerError
263    })?;
264  let ceremony_id = store.put(Ceremony::Authenticate { owner, state });
265  cookies.add(
266    Cookie::build((CEREMONY_COOKIE, ceremony_id))
267      .http_only(true)
268      .same_site(SameSite::Strict)
269      .path("/admin/passkeys")
270      .build(),
271  );
272  Ok(Json(challenge))
273}
274
275/// **Sign in, step 2** (`POST /admin/passkeys/auth/finish`): completes the assertion. On success
276/// **opens a `passkey` session** (the unified session model), sets the [`ADMIN_COOKIE`], advances
277/// the matching credential's signature counter (clone detection), and returns `200` — the browser
278/// then navigates to `/admin`. `400` on a missing/expired ceremony, `401` if the assertion doesn't
279/// verify.
280#[post("/admin/passkeys/auth/finish", data = "<credential>")]
281pub fn auth_finish(
282  credential: Json<PublicKeyCredential>,
283  webauthn: &State<Option<WebauthnState>>,
284  store: &State<CeremonyStore>,
285  cookies: &CookieJar<'_>,
286  pool: &State<DbPool>,
287) -> Result<Status, Status> {
288  let webauthn = relying_party(webauthn)?;
289  let ceremony_id = cookies
290    .get(CEREMONY_COOKIE)
291    .map(|cookie| cookie.value().to_string())
292    .ok_or(Status::BadRequest)?;
293  cookies.remove(
294    Cookie::build(CEREMONY_COOKIE)
295      .path("/admin/passkeys")
296      .build(),
297  );
298  let (owner, state) = match store.take(&ceremony_id) {
299    Some(Ceremony::Authenticate { owner, state }) => (owner, state),
300    _ => return Err(Status::BadRequest),
301  };
302  let result = webauthn
303    .finish_passkey_authentication(&credential, &state)
304    .map_err(|_| Status::Unauthorized)?;
305  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
306  // Advance the matching credential's signature counter (best-effort: failure to persist the
307  // counter must not fail an otherwise-valid sign-in).
308  if let Ok(rows) = WebauthnCredential::for_owner(&mut connection, &owner) {
309    for row in rows {
310      if let Ok(mut passkey) = serde_json::from_value::<Passkey>(row.credential.clone()) {
311        match passkey.update_credential(&result) {
312          Some(true) => {
313            if let Ok(value) = serde_json::to_value(&passkey) {
314              let _ = WebauthnCredential::update_after_use(&mut connection, row.id, &value);
315            }
316          },
317          Some(false) => {
318            let _ = WebauthnCredential::touch(&mut connection, row.id);
319          },
320          None => {},
321        }
322      }
323    }
324  }
325  let session_id =
326    Session::open(&mut connection, &owner, "passkey").map_err(|_| Status::InternalServerError)?;
327  cookies.add(
328    Cookie::build((ADMIN_COOKIE, session_id))
329      .http_only(true)
330      .same_site(SameSite::Lax)
331      .path("/")
332      .build(),
333  );
334  Ok(Status::Ok)
335}
336
337/// A passkey as shown on the management page.
338#[derive(Debug, Serialize)]
339pub struct PasskeyDto {
340  /// Row id (for the remove action).
341  pub id: i64,
342  /// The human label.
343  pub label: String,
344  /// When it was enrolled (formatted).
345  pub created_at: String,
346  /// When it was last used to sign in, or "never".
347  pub last_used: String,
348}
349
350/// The "Your passkeys" management screen (`GET /admin/passkeys`): the signed-in admin's enrolled
351/// passkeys, with enroll + remove actions. Signed-in admins only (unauthenticated → sign-in page).
352#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
353#[get("/admin/passkeys")]
354pub fn passkeys_page(
355  session: Option<AdminSession>,
356  return_to: ReturnTo,
357  webauthn: &State<Option<WebauthnState>>,
358  pool: &State<DbPool>,
359) -> Result<Template, AdminReject> {
360  let session = require_admin_to(session, &return_to)?;
361  let passkeys: Vec<PasskeyDto> = pool
362    .get()
363    .ok()
364    .and_then(|mut connection| WebauthnCredential::for_owner(&mut connection, &session.owner).ok())
365    .unwrap_or_default()
366    .into_iter()
367    .map(|row| PasskeyDto {
368      id: row.id,
369      label: row.label,
370      created_at: crate::frontend::helpers::iso_utc(row.created_at),
371      last_used: row
372        .last_used
373        .map(crate::frontend::helpers::iso_utc)
374        .unwrap_or_else(|| "never".to_string()),
375    })
376    .collect();
377  let global = serde_json::json!({
378    "title": "Your passkeys",
379    "description": "Manage the passkeys that sign you in to CorTeX",
380  });
381  Ok(Template::render(
382    "passkeys",
383    context! { global, owner: session.owner, enabled: webauthn.inner().is_some(), passkeys },
384  ))
385}
386
387/// Removes one of the signed-in admin's passkeys (`POST /admin/passkeys/<id>/delete`); the `owner`
388/// filter means a session can only remove its own. Redirects back to the management page.
389#[allow(clippy::result_large_err)] // AdminReject carries a Redirect; see actor::AdminReject.
390#[post("/admin/passkeys/<id>/delete")]
391pub fn passkey_delete(
392  id: i64,
393  session: Option<AdminSession>,
394  pool: &State<DbPool>,
395) -> Result<Redirect, AdminReject> {
396  let session = require_admin(session)?;
397  let mut connection = pool.get().map_err(|_| Status::ServiceUnavailable)?;
398  let _ = WebauthnCredential::delete(&mut connection, id, &session.owner);
399  Ok(Redirect::to("/admin/passkeys"))
400}
401
402/// The passkey routes (the management page + the enrollment ceremony).
403pub fn routes() -> Vec<Route> {
404  routes![
405    passkeys_page,
406    register_begin,
407    register_finish,
408    passkey_delete,
409    auth_begin,
410    auth_finish,
411  ]
412}
413
414#[cfg(test)]
415mod tests {
416  use super::*;
417
418  #[test]
419  fn disabled_config_yields_no_state() {
420    assert!(
421      build_state(&WebauthnConfig::default()).is_none(),
422      "the default config is disabled, so no relying party is built"
423    );
424  }
425
426  #[test]
427  fn localhost_config_builds_a_relying_party() {
428    let config = WebauthnConfig {
429      enabled: true,
430      rp_id: "localhost".to_string(),
431      rp_origin: "http://localhost:8000".to_string(),
432    };
433    assert!(
434      build_state(&config).is_some(),
435      "a valid localhost relying party builds"
436    );
437  }
438
439  #[test]
440  fn invalid_origin_degrades_to_none_not_panic() {
441    let config = WebauthnConfig {
442      enabled: true,
443      rp_id: "localhost".to_string(),
444      rp_origin: "not a url".to_string(),
445    };
446    assert!(
447      build_state(&config).is_none(),
448      "an invalid origin disables passkeys gracefully (token path keeps working), never panics"
449    );
450  }
451}