1use crate::json::ApiJson;
5use crate::operator::{Authenticated, Operator, PublicUrl, SetupOutcome, cleared_cookie, session_cookie};
6use crate::passkeys::{PasskeyRecord, Refused, Start};
7use crate::secret::{rfc3339, unix_now};
8use axum::extract::{DefaultBodyLimit, Extension, Path, State};
9use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{delete, get, post};
12use axum::{Json, Router, middleware};
13use hennery_proto::rest::{
14 ApiError, AuthSessionItem, LoginRequest, PasskeyCeremony, PasskeyFinishRequest, PasskeyItem,
15 PasskeyRegisterRequest, SetupRequest, SetupResponse, StepUpRequest,
16};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20pub const MAX_BODY_BYTES: usize = 16 * 1024;
25
26pub fn router(operator: Arc<Operator>) -> Router {
30 let browser = Router::new()
31 .route("/api/auth/login", post(login))
32 .route("/api/auth/logout", post(logout))
33 .route("/api/auth/passkeys/login/start", post(passkey_login_start))
34 .route("/api/auth/passkeys/login/finish", post(passkey_login_finish))
35 .layer(middleware::from_fn_with_state(
36 operator.clone(),
37 crate::origin::browser_rules,
38 ));
39 let step_up_first = || middleware::from_fn(crate::auth::require_step_up);
40 let signed_in = crate::auth::operator_only(
41 Router::new()
42 .route("/api/auth/step-up/password", post(step_up))
43 .route("/api/auth/step-up/passkey/start", post(passkey_step_up_start))
44 .route("/api/auth/step-up/passkey/finish", post(passkey_step_up_finish))
45 .route("/api/auth/sessions", get(list_sessions))
46 .route(
47 "/api/auth/sessions/{id}",
48 delete(revoke_session).route_layer(step_up_first()),
49 )
50 .route("/api/auth/passkeys", get(list_passkeys))
51 .route(
52 "/api/auth/passkeys/{id}",
53 delete(remove_passkey).route_layer(step_up_first()),
54 )
55 .route(
56 "/api/auth/passkeys/register/start",
57 post(passkey_register_start).route_layer(step_up_first()),
58 )
59 .route(
60 "/api/auth/passkeys/register/finish",
61 post(passkey_register_finish).route_layer(step_up_first()),
62 ),
63 operator.clone(),
64 );
65 Router::new()
66 .route(
67 "/api/setup",
68 post(setup).layer(middleware::map_response(private_headers)),
69 )
70 .merge(browser)
71 .merge(signed_in)
72 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
73 .with_state(operator)
74}
75
76async fn private_headers(mut response: Response) -> Response {
79 let headers = response.headers_mut();
80 headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
81 headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
82 response
83}
84
85pub(crate) fn error(status: StatusCode, code: &str, message: impl Into<String>) -> Response {
86 (
87 status,
88 Json(ApiError {
89 code: code.into(),
90 message: message.into(),
91 session_id: None,
92 }),
93 )
94 .into_response()
95}
96
97pub(crate) fn internal(err: anyhow::Error) -> Response {
98 tracing::error!(error = %err, "internal error");
99 error(StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal error")
100}
101
102pub(crate) fn user_agent(headers: &HeaderMap) -> String {
104 headers
105 .get(header::USER_AGENT)
106 .and_then(|v| v.to_str().ok())
107 .unwrap_or_default()
108 .to_string()
109}
110
111pub(crate) fn rate_limited(retry_after: Duration, message: &str) -> Response {
113 let mut response = error(StatusCode::TOO_MANY_REQUESTS, "rate_limited", message);
114 let secs = retry_after.as_secs() + u64::from(retry_after.subsec_nanos() > 0);
115 response
116 .headers_mut()
117 .insert(header::RETRY_AFTER, HeaderValue::from(secs));
118 response
119}
120
121pub(crate) fn secure_cookies(operator: &Operator) -> bool {
124 operator.public_url().is_none_or(|u| u.is_https())
125}
126
127pub(crate) fn with_cookie(mut response: Response, cookie: &str) -> Response {
129 if let Ok(value) = HeaderValue::from_str(cookie) {
130 response.headers_mut().append(header::SET_COOKIE, value);
131 }
132 response
133}
134
135async fn setup(
140 State(operator): State<Arc<Operator>>,
141 headers: HeaderMap,
142 ApiJson(req): ApiJson<SetupRequest>,
143) -> Response {
144 let public_url = match PublicUrl::parse(&req.public_url) {
145 Ok(url) => url,
146 Err(why) => return error(StatusCode::BAD_REQUEST, "invalid", why),
147 };
148 let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
149 if origin != Some(public_url.origin()) {
150 tracing::debug!(expected = %public_url.origin(), received = ?origin, "setup refused: origin_mismatch");
151 return error(
152 StatusCode::FORBIDDEN,
153 "origin_mismatch",
154 "Origin must be the public_url being set up",
155 );
156 }
157 let op = operator.clone();
158 let outcome = tokio::task::spawn_blocking(move || {
159 op.set_up_naming_hat(
160 &req.token,
161 &req.password,
162 &req.public_url,
163 req.default_hat_name.as_deref(),
164 unix_now(),
165 )
166 })
167 .await;
168 let phc = match outcome {
169 Ok(Ok(SetupOutcome::Done { owner_id, phc })) => {
170 tracing::info!(%owner_id, public_url = %public_url.origin(), "hennery set up");
171 phc
172 }
173 Ok(Ok(SetupOutcome::AlreadySetUp)) => {
174 return error(StatusCode::CONFLICT, "already_set_up", "hennery is set up already");
175 }
176 Ok(Ok(SetupOutcome::InvalidToken)) => {
177 return error(
178 StatusCode::UNAUTHORIZED,
179 "invalid_setup_token",
180 "the setup link is unknown, used or expired; restart the collector for a new one",
181 );
182 }
183 Ok(Ok(SetupOutcome::Invalid(why))) => return error(StatusCode::BAD_REQUEST, "invalid", why),
184 Ok(Err(err)) => return internal(err),
185 Err(err) => return internal(err.into()),
186 };
187 let token = match operator.open_session(&user_agent(&headers), &phc, unix_now()) {
188 Ok(Some(token)) => token,
189 Ok(None) => return internal(anyhow::anyhow!("the password changed right after setup")),
190 Err(err) => return internal(err),
191 };
192 let response = (
193 StatusCode::CREATED,
194 Json(SetupResponse {
195 public_url: public_url.origin().to_string(),
196 }),
197 )
198 .into_response();
199 with_cookie(response, &session_cookie(&token, public_url.is_https()))
200}
201
202async fn login(
207 State(operator): State<Arc<Operator>>,
208 address: crate::client_address::ClientAddress,
209 headers: HeaderMap,
210 ApiJson(req): ApiJson<LoginRequest>,
211) -> Response {
212 if let Err(retry_after) = operator.login_limiter.attempt(address.effective, Instant::now()) {
213 return rate_limited(
214 retry_after,
215 "too many wrong passwords from this address; try again later",
216 );
217 }
218 let phc = match operator.check_password(req.password).await {
219 Ok(Some(phc)) => phc,
220 Ok(None) => return error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
221 Err(err) => return internal(err),
222 };
223 operator.login_limiter.succeeded(address.effective);
224 match operator.open_session(&user_agent(&headers), &phc, unix_now()) {
226 Ok(Some(token)) => with_cookie(
227 StatusCode::NO_CONTENT.into_response(),
228 &session_cookie(&token, secure_cookies(&operator)),
229 ),
230 Ok(None) => error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
231 Err(err) => internal(err),
232 }
233}
234
235async fn logout(State(operator): State<Arc<Operator>>, headers: HeaderMap) -> Response {
238 match operator.authenticate_cookies(&headers, unix_now()) {
239 Ok(Some((_, session))) => {
240 if let Err(err) = operator.revoke_session(&session.session_id, unix_now()) {
241 return internal(err);
242 }
243 }
244 Ok(None) => {}
245 Err(err) => return internal(err),
246 }
247 with_cookie(
248 StatusCode::NO_CONTENT.into_response(),
249 &cleared_cookie(secure_cookies(&operator)),
250 )
251}
252
253async fn step_up(
257 State(operator): State<Arc<Operator>>,
258 address: crate::client_address::ClientAddress,
259 Extension(session): Extension<Authenticated>,
260 ApiJson(req): ApiJson<StepUpRequest>,
261) -> Response {
262 if let Err(retry_after) = operator.step_up_limiter.attempt(address.effective, Instant::now()) {
263 return rate_limited(
264 retry_after,
265 "too many wrong passwords from this address; try again later",
266 );
267 }
268 match operator.check_password(req.password).await {
269 Ok(Some(_)) => {}
270 Ok(None) => return error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
271 Err(err) => return internal(err),
272 }
273 operator.step_up_limiter.succeeded(address.effective);
274 match operator.step_up(&session.session_id, unix_now()) {
275 Ok(true) => StatusCode::NO_CONTENT.into_response(),
276 Ok(false) => error(StatusCode::UNAUTHORIZED, "unauthenticated", "sign in first"),
277 Err(err) => internal(err),
278 }
279}
280
281async fn list_sessions(
284 State(operator): State<Arc<Operator>>,
285 Extension(session): Extension<Authenticated>,
286) -> Response {
287 match operator.sessions(unix_now()) {
288 Ok(sessions) => {
289 let items: Vec<AuthSessionItem> = sessions
290 .into_iter()
291 .map(|s| AuthSessionItem {
292 current: s.id == session.session_id,
293 id: s.id,
294 user_agent: s.user_agent,
295 created_at: rfc3339(s.created_at),
296 last_seen_at: rfc3339(s.last_seen_at),
297 expires_at: rfc3339(s.expires_at),
298 })
299 .collect();
300 Json(items).into_response()
301 }
302 Err(err) => internal(err),
303 }
304}
305
306async fn revoke_session(
310 State(operator): State<Arc<Operator>>,
311 Extension(session): Extension<Authenticated>,
312 Path(id): Path<String>,
313) -> Response {
314 match operator.revoke_session(&id, unix_now()) {
315 Ok(true) if id == session.session_id => with_cookie(
316 StatusCode::NO_CONTENT.into_response(),
317 &cleared_cookie(secure_cookies(&operator)),
318 ),
319 Ok(true) => StatusCode::NO_CONTENT.into_response(),
320 Ok(false) => error(StatusCode::NOT_FOUND, "not_found", "no such session"),
321 Err(err) => internal(err),
322 }
323}
324
325fn started(outcome: anyhow::Result<Start>) -> Response {
328 match outcome {
329 Ok(Start::Begun { ceremony_id, options }) => Json(PasskeyCeremony { ceremony_id, options }).into_response(),
330 Ok(Start::Unavailable) => passkeys_unavailable(),
331 Ok(Start::NoPasskeys) => error(StatusCode::CONFLICT, "no_passkeys", "there is no passkey to use"),
332 Ok(Start::Invalid(why)) => error(StatusCode::BAD_REQUEST, "invalid", why),
333 Err(err) => internal(err),
334 }
335}
336
337fn passkeys_unavailable() -> Response {
338 error(
339 StatusCode::CONFLICT,
340 "passkeys_unavailable",
341 "passkeys need public_url to name a host, not an IP address",
342 )
343}
344
345fn refused(why: Refused) -> Response {
347 match why {
348 Refused::Ceremony => error(
349 StatusCode::BAD_REQUEST,
350 "invalid_ceremony",
351 "the passkey ceremony is unknown, used or expired; start again",
352 ),
353 Refused::Credential(why) => {
354 tracing::debug!(?why, "passkey refused: the answer did not verify");
357 error(
358 StatusCode::UNAUTHORIZED,
359 "passkey_refused",
360 "the passkey was not accepted",
361 )
362 }
363 Refused::Passkey | Refused::CounterWentBack => error(
368 StatusCode::UNAUTHORIZED,
369 "passkey_refused",
370 "the passkey was not accepted",
371 ),
372 Refused::AlreadyRegistered => error(
373 StatusCode::CONFLICT,
374 "already_registered",
375 "this passkey is registered already",
376 ),
377 Refused::Unavailable => passkeys_unavailable(),
378 }
379}
380
381fn passkey_item(record: PasskeyRecord) -> PasskeyItem {
382 PasskeyItem {
383 id: record.id,
384 label: record.label,
385 created_at: rfc3339(record.created_at),
386 last_used_at: record.last_used_at.map(rfc3339),
387 }
388}
389
390async fn passkey_login_start(
394 State(operator): State<Arc<Operator>>,
395 address: crate::client_address::ClientAddress,
396) -> Response {
397 if let Err(retry_after) = operator.passkey_limiter.attempt(address.effective, Instant::now()) {
398 return rate_limited(
399 retry_after,
400 "too many passkey logins begun from this address; try again later",
401 );
402 }
403 started(operator.start_passkey_login(unix_now()))
404}
405
406async fn passkey_login_finish(
409 State(operator): State<Arc<Operator>>,
410 address: crate::client_address::ClientAddress,
411 headers: HeaderMap,
412 ApiJson(req): ApiJson<PasskeyFinishRequest>,
413) -> Response {
414 match operator.finish_passkey_login(&req.ceremony_id, &req.credential, &user_agent(&headers), unix_now()) {
415 Ok(Ok(token)) => {
416 operator.passkey_limiter.succeeded(address.effective);
417 with_cookie(
418 StatusCode::NO_CONTENT.into_response(),
419 &session_cookie(&token, secure_cookies(&operator)),
420 )
421 }
422 Ok(Err(why)) => refused(why),
423 Err(err) => internal(err),
424 }
425}
426
427async fn passkey_step_up_start(
430 State(operator): State<Arc<Operator>>,
431 Extension(session): Extension<Authenticated>,
432) -> Response {
433 started(operator.start_passkey_step_up(&session.session_id, unix_now()))
434}
435
436async fn passkey_step_up_finish(
439 State(operator): State<Arc<Operator>>,
440 Extension(session): Extension<Authenticated>,
441 ApiJson(req): ApiJson<PasskeyFinishRequest>,
442) -> Response {
443 match operator.finish_passkey_step_up(&session.session_id, &req.ceremony_id, &req.credential, unix_now()) {
444 Ok(Ok(true)) => StatusCode::NO_CONTENT.into_response(),
445 Ok(Ok(false)) => error(StatusCode::UNAUTHORIZED, "unauthenticated", "sign in first"),
446 Ok(Err(why)) => refused(why),
447 Err(err) => internal(err),
448 }
449}
450
451async fn list_passkeys(State(operator): State<Arc<Operator>>) -> Response {
453 match operator.passkeys() {
454 Ok(records) => Json(records.into_iter().map(passkey_item).collect::<Vec<_>>()).into_response(),
455 Err(err) => internal(err),
456 }
457}
458
459async fn remove_passkey(State(operator): State<Arc<Operator>>, Path(id): Path<String>) -> Response {
462 match operator.remove_passkey(&id) {
463 Ok(true) => StatusCode::NO_CONTENT.into_response(),
464 Ok(false) => error(StatusCode::NOT_FOUND, "not_found", "no such passkey"),
465 Err(err) => internal(err),
466 }
467}
468
469async fn passkey_register_start(
472 State(operator): State<Arc<Operator>>,
473 Extension(session): Extension<Authenticated>,
474 ApiJson(req): ApiJson<PasskeyRegisterRequest>,
475) -> Response {
476 started(operator.start_passkey_registration(&session.session_id, &req.label, unix_now()))
477}
478
479async fn passkey_register_finish(
482 State(operator): State<Arc<Operator>>,
483 Extension(session): Extension<Authenticated>,
484 ApiJson(req): ApiJson<PasskeyFinishRequest>,
485) -> Response {
486 match operator.finish_passkey_registration(&session.session_id, &req.ceremony_id, &req.credential, unix_now()) {
487 Ok(Ok(record)) => (StatusCode::CREATED, Json(passkey_item(record))).into_response(),
488 Ok(Err(why)) => refused(why),
489 Err(err) => internal(err),
490 }
491}