tablo_core/auth/mod.rs
1//! Authenticates panel users and gates panel routes.
2//!
3//! Gates panels by default; installs [`PasswordAuth`] or a custom [`Authenticator`].
4//! Reads the signed-in user through [`user`] and [`require_user`]. Stores sessions in
5//! [`AuthSession`] for every authenticator, scoped to the issuing panel.
6
7mod gate;
8mod login;
9mod password;
10mod session;
11
12use std::{
13 any::{Any, TypeId},
14 future::Future,
15 pin::Pin,
16 sync::Arc,
17};
18
19use topcoat::{
20 context::{Cx, try_request_context},
21 router::{
22 error::{forbidden, redirect, unauthorized},
23 request::{method, original_headers, original_method, uri},
24 },
25};
26use uuid::Uuid;
27
28pub(crate) use self::{
29 gate::{PanelGate, RuntimeGate},
30 login::{
31 MAX_LOGIN_BYTES, login_page, login_post, logout_post, logout_url, safe_next, tenant_post,
32 tenant_url,
33 },
34};
35pub use self::{
36 login::{LOGIN_FIELD, NEXT_FIELD, PASSWORD_FIELD, TENANT_FIELD},
37 password::{AdminUser, PasswordAuth, hash_password, verify_password},
38 session::{AuthSession, SESSION_LIFETIME, revoke_sessions_for_user},
39};
40use crate::{
41 panel::{
42 panel_prefix,
43 state::{PanelState, Panels, current, panels},
44 },
45 tenancy::Membership,
46};
47
48/// A signed-in user, as the panel knows it (ADR-0013).
49///
50/// Implement it for the app's own user type and return that type from an
51/// [`Authenticator`]; the shipped [`AdminUser`] implements it too. App code
52/// reads the user back with its own type through [`user`].
53///
54/// ```rust
55/// # struct Staff {
56/// # id: uuid::Uuid,
57/// # name: String,
58/// # active: bool,
59/// # memberships: Vec<tablo_core::Membership>,
60/// # }
61/// # use tablo_core::{Membership, PanelUser};
62/// impl PanelUser for Staff {
63/// fn user_id(&self) -> String {
64/// self.id.to_string()
65/// }
66/// fn display_name(&self) -> &str {
67/// &self.name
68/// }
69/// fn can_access_panel(&self) -> bool {
70/// self.active
71/// }
72/// fn tenants(&self) -> &[Membership] {
73/// &self.memberships
74/// }
75/// }
76/// ```
77pub trait PanelUser: Any + Send + Sync {
78 /// The stable key the session stores and
79 /// [`Authenticator::find_by_id`] receives. Any string fits.
80 fn user_id(&self) -> String;
81
82 /// The name the panel's top bar shows.
83 fn display_name(&self) -> &str;
84
85 /// Whether the user may enter the panel. A user who may not answers 403,
86 /// indistinguishable from bad credentials at login. Defaults to `true`.
87 fn can_access_panel(&self) -> bool {
88 true
89 }
90
91 /// The tenants the user may act for, in the order the tenant switcher
92 /// lists them. The first is the default tenant until the user selects
93 /// another. Defaults to none: the user has no tenant.
94 fn tenants(&self) -> &[Membership] {
95 &[]
96 }
97}
98
99/// How a panel loads its users (ADR-0013).
100///
101/// The default [`PasswordAuth`] implements it against the shipped
102/// [`AdminUser`] model; an app with an existing user table implements it and
103/// passes the value to [`Panel::auth`](crate::Panel::auth) via
104/// [`Auth::custom`]. Sessions are the framework's, so an implementation only
105/// maps credentials to a user and a stored id back to one.
106///
107/// Every *credential* failure must return `Ok(None)`, never a distinguishable
108/// error: the login response is one generic message for all of them. An
109/// infrastructure failure is not a credential verdict, so an implementation
110/// that cannot reach its store returns the driver's error instead — the login
111/// handler maps it to the opaque outage page, which keeps a database outage
112/// from rendering as a rejected password. An implementation's own error keeps
113/// its own mapping.
114pub trait Authenticator: Send + Sync + 'static {
115 /// The app's user type.
116 type User: PanelUser;
117
118 /// Verify `login`/`password`, returning the user on success.
119 ///
120 /// Implementations must run comparable work for unknown accounts so
121 /// timing does not leak account existence; [`verify_password`] does, given
122 /// `None` for an unknown account.
123 fn verify(
124 &self,
125 cx: &Cx,
126 login: &str,
127 password: &str,
128 ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
129
130 /// Load the session's user by [`PanelUser::user_id`], with everything the request
131 /// reads from it — its [`tenants`](PanelUser::tenants) included.
132 ///
133 /// It runs on every request, which is what makes deactivation, revocation
134 /// and a removed membership take effect immediately; return `None` when
135 /// the user no longer authenticates.
136 fn find_by_id(
137 &self,
138 cx: &Cx,
139 id: &str,
140 ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
141}
142
143/// The boxed future the erased authenticator returns.
144type UserFuture<'a> =
145 Pin<Box<dyn Future<Output = topcoat::Result<Option<Arc<dyn PanelUser>>>> + Send + 'a>>;
146
147/// [`Authenticator`] with its user type erased, as the panel keeps it.
148pub(crate) trait DynAuthenticator: Send + Sync {
149 fn verify<'a>(&'a self, cx: &'a Cx, login: &'a str, password: &'a str) -> UserFuture<'a>;
150 fn find_by_id<'a>(&'a self, cx: &'a Cx, id: &'a str) -> UserFuture<'a>;
151 /// The [`TypeId`] of [`Authenticator::User`], so mounting knows when the
152 /// shipped [`AdminUser`] must be registered.
153 fn user_type(&self) -> TypeId;
154}
155
156impl<A: Authenticator> DynAuthenticator for A {
157 fn verify<'a>(&'a self, cx: &'a Cx, login: &'a str, password: &'a str) -> UserFuture<'a> {
158 Box::pin(async move {
159 let user = Authenticator::verify(self, cx, login, password).await?;
160 Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
161 })
162 }
163
164 fn find_by_id<'a>(&'a self, cx: &'a Cx, id: &'a str) -> UserFuture<'a> {
165 Box::pin(async move {
166 let user = Authenticator::find_by_id(self, cx, id).await?;
167 Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
168 })
169 }
170
171 fn user_type(&self) -> TypeId {
172 TypeId::of::<A::User>()
173 }
174}
175
176/// The panel's authentication configuration (ADR-0013).
177///
178/// The default is [`Auth::password`]; [`Auth::custom`] installs an app-owned
179/// [`Authenticator`]; [`Auth::disabled`] is the explicit fail-open opt-out.
180pub struct Auth(Option<Box<dyn DynAuthenticator>>);
181
182impl Auth {
183 /// The shipped Argon2id + [`AdminUser`] authenticator.
184 #[must_use]
185 pub fn password() -> Self {
186 Self::custom(PasswordAuth)
187 }
188
189 /// An app-owned authenticator over its own user table.
190 #[must_use]
191 pub fn custom(authenticator: impl Authenticator) -> Self {
192 Self(Some(Box::new(authenticator)))
193 }
194
195 /// Explicit fail-open opt-out for public demos (ADR-0013): no gate, no
196 /// login routes, sessions unused.
197 #[must_use]
198 pub fn disabled() -> Self {
199 Self(None)
200 }
201
202 /// Whether the explicit opt-out is set.
203 #[must_use]
204 pub fn is_disabled(&self) -> bool {
205 self.0.is_none()
206 }
207
208 /// The authenticator, or `None` when auth is disabled.
209 pub(crate) fn authenticator(&self) -> Option<&dyn DynAuthenticator> {
210 self.0.as_deref()
211 }
212}
213
214impl Default for Auth {
215 fn default() -> Self {
216 Self::password()
217 }
218}
219
220impl std::fmt::Debug for Auth {
221 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
222 f.write_str(if self.is_disabled() {
223 "Auth::disabled"
224 } else {
225 "Auth"
226 })
227 }
228}
229
230/// The user a panel's gate resolved, as request `Cx` carries it.
231#[derive(Clone)]
232pub(crate) struct SignedIn {
233 pub(crate) user: Arc<dyn PanelUser>,
234 /// The panel whose session signed the user in: the only panel the user
235 /// is the user of.
236 pub(crate) panel: Arc<PanelState>,
237 /// The tenant the session selected, if any. [`tenant_id`](crate::tenant_id)
238 /// honors it only while it is one of the user's memberships.
239 pub(crate) tenant: Option<Uuid>,
240}
241
242/// The request's signed-in user on the request's panel, whatever their panel
243/// access. Only the logout route reads a user without access.
244pub(crate) fn resolved(cx: &Cx) -> Option<&SignedIn> {
245 let signed = try_request_context::<SignedIn>(cx)?;
246 match current(cx) {
247 Some(panel) if !Arc::ptr_eq(&signed.panel, panel) => None,
248 _ => Some(signed),
249 }
250}
251
252/// The request's signed-in user, erased: on the request's panel, with panel
253/// access.
254pub(crate) fn signed(cx: &Cx) -> Option<&SignedIn> {
255 resolved(cx).filter(|signed| signed.user.can_access_panel())
256}
257
258/// The signed-in user's membership the request acts under: the tenant the
259/// session selected while it is still one of the user's
260/// [`tenants`](PanelUser::tenants), else the first. `None` without a
261/// signed-in user, for a user with no tenant, and when a `Tenant` override
262/// names a tenant the user is not a member of.
263pub fn membership(cx: &Cx) -> Option<&Membership> {
264 let tenant = crate::tenant_id(cx)?;
265 signed(cx)?
266 .user
267 .tenants()
268 .iter()
269 .find(|m| m.tenant == tenant)
270}
271
272/// The tenant of the session's selected membership, else of the user's first: the
273/// [`TenantSource`](crate::tenancy::TenantSource) the panel installs.
274pub(crate) fn session_tenant(cx: &Cx) -> Option<Uuid> {
275 let signed = signed(cx)?;
276 let tenants = signed.user.tenants();
277 signed
278 .tenant
279 .and_then(|tenant| tenants.iter().find(|m| m.tenant == tenant))
280 .or_else(|| tenants.first())
281 .map(|membership| membership.tenant)
282}
283
284/// The signed-in user, as the app's own user type.
285///
286/// `None` when nobody is signed in to the request's panel, and when the
287/// panel's [`Authenticator`] loads another type than `U` — a helper shared by
288/// two panels with different user types answers `None` on the other one.
289///
290/// ```rust
291/// # struct Staff { admin: bool }
292/// # use tablo_core::{PanelUser, auth};
293/// # use topcoat::context::Cx;
294/// # impl PanelUser for Staff {
295/// # fn user_id(&self) -> String { String::new() }
296/// # fn display_name(&self) -> &str { "" }
297/// # }
298/// fn admins_only(cx: &Cx) -> bool {
299/// auth::user::<Staff>(cx).is_some_and(|staff| staff.admin)
300/// }
301/// ```
302pub fn user<U: PanelUser>(cx: &Cx) -> Option<&U> {
303 let user: &dyn Any = &*signed(cx)?.user;
304 user.downcast_ref()
305}
306
307/// Require the signed-in user, as the app's own user type.
308///
309/// # Errors
310///
311/// Answers per request kind (ADR-0013): pages redirect to the login route with
312/// a same-origin-relative `next`; runtime endpoints, non-GET requests, and
313/// page re-runs (marked POSTs the runtime layer rewrites into GETs) answer
314/// 401. A signed-in user of another type than `U` answers 403.
315pub fn require_user<U: PanelUser>(cx: &Cx) -> topcoat::Result<&U> {
316 match signed(cx) {
317 Some(_) => user(cx).ok_or_else(|| forbidden().into()),
318 None => Err(unauthenticated_error(cx)),
319 }
320}
321
322/// Whether a user is signed in to the request's panel.
323pub fn signed_in(cx: &Cx) -> bool {
324 signed(cx).is_some()
325}
326
327/// Whether the request's panel requires a signed-in user.
328///
329/// Outside any panel — an app's own shard or page on a router that mounts
330/// several — it answers whether some mounted panel does, so a check there
331/// fails closed.
332pub fn enforced(cx: &Cx) -> bool {
333 match current(cx) {
334 Some(panel) => panel.gates(),
335 None => panels(cx).is_some_and(Panels::any_gates),
336 }
337}
338
339/// Require a signed-in user when the request's panel requires sign-in.
340///
341/// Every panel handler runs it first, and a page, route or shard the app
342/// serves under a panel runs it to answer exactly as the panel's own pages do.
343/// Shards are the case that needs it: Topcoat serves them at its runtime path,
344/// where no page guard runs. A form route also verifies its CSRF token with
345/// [`csrf::verify`](crate::csrf::verify).
346///
347/// # Errors
348///
349/// 401, or a redirect to the login page, as [`require_user`] answers.
350pub fn guard(cx: &Cx) -> topcoat::Result<()> {
351 if enforced(cx) && !signed_in(cx) {
352 return Err(unauthenticated_error(cx));
353 }
354 Ok(())
355}
356
357/// The error an unauthenticated request answers with, by request kind.
358fn unauthenticated_error(cx: &Cx) -> topcoat::Error {
359 let path = uri(cx).path();
360 let page_method = matches!(*method(cx), http::Method::GET | http::Method::HEAD);
361 // A page re-run reaches the gate as a rewritten GET: Topcoat's runtime
362 // layer rewrites the browser's marked POST into a GET for the page's own
363 // URL. The original request tells it apart from a plain page load, so a
364 // logged-out re-run answers 401 like any other non-page request instead
365 // of redirecting into the login page.
366 let rerun = !matches!(*original_method(cx), http::Method::GET | http::Method::HEAD)
367 && original_headers(cx).get(&topcoat::runtime::RUNTIME_HEADER) == Some(&RERUN_MARKER);
368 if path.starts_with(crate::topcoat_compat::RUNTIME_PREFIX) || !page_method || rerun {
369 unauthorized().into()
370 } else {
371 redirect(login::login_url_with_next(cx)).into()
372 }
373}
374
375/// The panel root: where a completed login or tenant switch lands.
376fn panel_root(cx: &Cx) -> String {
377 panel_prefix(cx)
378}
379
380/// Map a failed auth or session operation to the error the response carries:
381/// [`crate::error::driver_failure`] with the sign-in outage copy.
382///
383/// A driver failure becomes an infrastructure error whose `Display` is
384/// [`UNAVAILABLE_ERROR`], and the driver's own text goes to the log, never to
385/// the page; anything else is app-authored (a custom [`Authenticator`]'s own
386/// error) and keeps its mapping. Every auth and session path maps through
387/// here, which is what keeps a database outage from reading as a rejected
388/// password — and what makes the two distinguishable in the logs: a rejection
389/// is `Ok(None)`, the generic 403 and no error line, while an outage logs the
390/// driver's text.
391fn infrastructure_failure(error: impl Into<topcoat::Error>) -> topcoat::Error {
392 crate::error::driver_failure(error, UNAVAILABLE_ERROR)
393}
394
395/// What a failed login attempt renders when the database behind it could not
396/// answer.
397///
398/// The credential rejection is deliberately non-specific about *why*
399/// credentials were refused, so reusing it for an outage would tell a user
400/// their password was wrong while the database was down — the one place they
401/// would retry uselessly. This copy names the machinery that is down instead,
402/// and like the generic one it never carries driver text: that goes to the
403/// log.
404const UNAVAILABLE_ERROR: &str = "Sign-in is unavailable right now. Try again shortly.";
405
406/// The runtime-header value marking a page re-run POST (`true`, per Topcoat's
407/// page-rerun protocol).
408static RERUN_MARKER: http::HeaderValue = http::HeaderValue::from_static("true");
409
410/// Fail loudly at mount when a required shipped model is missing from the
411/// app's `Db` (ADR-0013): the table is never pushed, and the first login
412/// would otherwise be a confusing runtime error.
413pub(crate) fn check_models_registered(
414 db: &toasty::Db,
415 auth: &Auth,
416) -> Result<(), crate::DeclarationErrorKind> {
417 let Some(authenticator) = auth.authenticator() else {
418 return Ok(());
419 };
420 let registered = |name: &str| {
421 db.schema()
422 .app
423 .models()
424 .any(|model| model.name().upper_camel_case() == name)
425 };
426 let shipped_user = authenticator.user_type() == TypeId::of::<AdminUser>();
427 let models: Vec<&'static str> = [
428 (!registered("AuthSession")).then_some("AuthSession"),
429 (shipped_user && !registered("AdminUser")).then_some("AdminUser"),
430 ]
431 .into_iter()
432 .flatten()
433 .collect();
434 if models.is_empty() {
435 Ok(())
436 } else {
437 Err(crate::DeclarationErrorKind::MissingAuthModels { models })
438 }
439}
440
441#[cfg(test)]
442mod tests;