1use 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
41const CEREMONY_COOKIE: &str = "cortex_ceremony";
44const CEREMONY_TTL: Duration = Duration::from_secs(300);
46
47pub struct WebauthnState {
51 pub webauthn: Arc<Webauthn>,
53}
54
55pub 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
83pub enum Ceremony {
86 Register(PasskeyRegistration),
88 Authenticate {
91 owner: String,
93 state: PasskeyAuthentication,
95 },
96}
97
98pub struct CeremonyStore {
103 inner: Mutex<HashMap<String, (Ceremony, Instant)>>,
104}
105
106impl CeremonyStore {
107 pub fn new() -> Self {
109 CeremonyStore {
110 inner: Mutex::new(HashMap::new()),
111 }
112 }
113
114 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 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
142fn 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#[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#[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#[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#[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 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#[derive(Debug, Serialize)]
339pub struct PasskeyDto {
340 pub id: i64,
342 pub label: String,
344 pub created_at: String,
346 pub last_used: String,
348}
349
350#[allow(clippy::result_large_err)] #[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#[allow(clippy::result_large_err)] #[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
402pub 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}