Skip to main content

tablo_core/auth/
session.rs

1//! Server-side sessions: the [`AuthSession`] rows, their lookup, revocation
2//! and sweep.
3
4use std::{
5    sync::Arc,
6    time::{Duration, SystemTime},
7};
8
9use jiff::Timestamp;
10use topcoat::{
11    context::Cx,
12    session::{self, TokenHash, TokenStore, cookie::CookieTokenStore},
13};
14
15use super::{
16    DynAuthenticator, PanelUser, SignedIn, infrastructure_failure,
17    token::{CREDENTIALS, REVOCATION, TokenPurpose},
18};
19use crate::{
20    TenantId,
21    auth::realm::{Realm, current_realm, realms},
22};
23
24/// How long a session stays valid: seven days, fixed.
25pub const SESSION_LIFETIME: Duration = Duration::from_hours(24 * 7);
26
27/// What every panel's [`session_cookie`] starts with.
28#[doc(hidden)]
29pub const SESSION_COOKIE_PREFIX: &str = "__Host-tablo_session_";
30
31/// The cookie that carries a session's token on the panel `Panel::new(panel)` mounts; `panel` is
32/// spelled as for [`Panel::new`](crate::Panel::new). Each panel has its own, so a browser stays
33/// signed in to several. Tablo keeps them apart from the router's
34/// [`SessionTokenStore`](topcoat::session::SessionTokenStore), so a host app's own Topcoat sessions
35/// and a panel's never replace each other's token.
36#[must_use]
37pub fn session_cookie(panel: &str) -> String {
38    format!(
39        "__Host-{}",
40        store_name(&crate::naming::normalize_prefix(panel))
41    )
42}
43
44/// The session cookie's name for `prefix`, as the store takes it: the store adds the `__Host-`
45/// prefix, which fixes `Path=/`, so panels differ by name alone. A byte a cookie name may not hold
46/// becomes `_` and its hex, and so does `_`, so no two prefixes share a name.
47fn store_name(prefix: &str) -> String {
48    use std::fmt::Write as _;
49
50    let mut name = SESSION_COOKIE_PREFIX
51        .trim_start_matches("__Host-")
52        .to_owned();
53    for byte in prefix.trim_start_matches('/').bytes() {
54        if byte.is_ascii_alphanumeric() || byte == b'-' {
55            name.push(char::from(byte));
56        } else {
57            write!(name, "_{byte:02x}").expect("writing to a String cannot fail");
58        }
59    }
60    name
61}
62
63/// The store of `panel`'s [`session_cookie`].
64fn token_store(panel: &Realm) -> CookieTokenStore {
65    CookieTokenStore::new().name(store_name(&panel.prefix))
66}
67
68/// The panel whose route is answering: only a panel's own routes start or end a session.
69fn session_panel(cx: &Cx) -> topcoat::Result<&Arc<Realm>> {
70    current_realm(cx).ok_or_else(|| {
71        crate::error::declaration("a panel session changes only under a panel".to_owned())
72    })
73}
74
75/// Shipped server-side session record: the SHA-256 hash of the client token,
76/// the user it authenticates, the panel that signed the user in, and its
77/// expiry. The raw token is never stored.
78///
79/// A session rides in its panel's [`session_cookie`], lasts [`SESSION_LIFETIME`] from login, is
80/// replaced on each login and deleted on logout; each successful login also deletes up to 500
81/// expired sessions. It belongs to the panel
82/// that signed the user in: another panel's gate treats the request as anonymous, and
83/// [`auth::user`](crate::auth::user) answers `None` there. A browser holds one session on each
84/// panel, so signing in to a second panel leaves the first alone, and renaming a panel's prefix
85/// ends the sessions it issued. Sessions stay valid until they expire unless
86/// [`revoke_sessions_for_user`](crate::auth::revoke_sessions_for_user) ends them.
87///
88/// The row records the panel's prefix in a non-null `panel` column and the selected tenant in a
89/// nullable `tenant` column, a [`TenantId`] Toasty stores as a `Uuid` on the database. A table
90/// created without them gains them by migration: add `panel` with the panel's prefix as its
91/// default (`ALTER TABLE auth_sessions ADD COLUMN panel TEXT NOT NULL DEFAULT '/admin'`), then drop
92/// the default once every row names its panel. [`Auth::impersonation`](crate::Auth::impersonation)
93/// reads two more nullable columns, `impersonating` (`TEXT`) and `impersonating_until`, a
94/// timestamp: a table created without them gains them by `ALTER TABLE ... ADD COLUMN`.
95#[derive(Debug, Clone, toasty::Model)]
96pub struct AuthSession {
97    /// Hex-encoded SHA-256 of the session token.
98    #[key]
99    pub token_hash: String,
100    /// [`PanelUser::user_id`] of the authenticated user.
101    #[index]
102    pub user_id: String,
103    /// The prefix of the panel that signed the user in (e.g. `/admin`): the
104    /// only panel the session authenticates.
105    pub panel: String,
106    /// The tenant the user selected; `None` acts for the first.
107    pub tenant: Option<TenantId>,
108    /// Indexed for the login sweep's range scan on this column.
109    #[index]
110    pub expires_at: Timestamp,
111    pub created_at: Timestamp,
112    /// [`PanelUser::user_id`] of the user the session's user acts as, through
113    /// [`Auth::impersonation`](crate::Auth::impersonation); `None` while they act as themselves.
114    pub impersonating: Option<String>,
115    /// When acting as `impersonating` ends on its own.
116    pub impersonating_until: Option<Timestamp>,
117}
118
119/// Starts a new session, issuing a fresh token in the request's panel's [`session_cookie`].
120pub(super) async fn start(cx: &Cx) -> topcoat::Result<session::Session> {
121    let token = session::Token::random();
122    token_store(session_panel(cx)?)
123        .write(cx, token.clone(), SESSION_LIFETIME)
124        .await?;
125    Ok(session::Session {
126        token_hash: token.hash(),
127        expires_at: SystemTime::now() + SESSION_LIFETIME,
128    })
129}
130
131/// Discards the request's token, returning its hash so the caller can delete the row.
132pub(super) async fn stop(cx: &Cx) -> topcoat::Result<Option<TokenHash>> {
133    let panel = session_panel(cx)?;
134    let hash = token_hash(cx, panel).await?;
135    token_store(panel).delete(cx).await?;
136    Ok(hash)
137}
138
139/// The hash of the token the request presents to `panel`; `None` when it presents none or a
140/// malformed one.
141async fn token_hash(cx: &Cx, panel: &Realm) -> topcoat::Result<Option<TokenHash>> {
142    let token = token_store(panel).read(cx).await?;
143    Ok(token.map(|token| token.hash()))
144}
145
146pub(super) fn token_key(hash: &TokenHash) -> String {
147    use std::fmt::Write as _;
148
149    let mut key = String::with_capacity(64);
150    for byte in hash.iter() {
151        write!(key, "{byte:02x}").expect("writing to a String cannot fail");
152    }
153    key
154}
155
156pub(super) async fn record(
157    cx: &Cx,
158    session: &session::Session,
159    user: &dyn PanelUser,
160    panel: &Realm,
161) -> topcoat::Result<()> {
162    let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
163    insert(
164        &mut crate::db::db(cx)?,
165        &session.token_hash,
166        user.user_id(),
167        panel.prefix.clone(),
168        None,
169        expires_at,
170    )
171    .await
172    .map_err(infrastructure_failure)
173}
174
175/// The realms whose authenticator loads `user_type`, or `own` outside a router: the panels that
176/// share a user's credentials, so a credential change ends the user's sessions and reset links on
177/// each.
178pub(super) fn credential_panels(
179    cx: &Cx,
180    user_type: std::any::TypeId,
181    own: &Arc<Realm>,
182) -> Vec<Arc<Realm>> {
183    let realms = realms(cx);
184    if realms.is_empty() {
185        return vec![Arc::clone(own)];
186    }
187    realms
188        .into_iter()
189        .filter(|realm| {
190            realm
191                .auth
192                .authenticator()
193                .is_some_and(|auth| auth.user_type() == user_type)
194        })
195        .collect()
196}
197
198/// Deletes every session `user_id` holds on `panels`, and every [`AuthToken`](super::AuthToken)
199/// for one of `purposes` it holds on those that issue them, through `ex`: what a credential
200/// change ends, with [`CREDENTIALS`], or a revocation, with [`REVOCATION`].
201pub(super) async fn end_credentials(
202    ex: &mut dyn toasty::Executor,
203    user_id: &str,
204    panels: &[Arc<Realm>],
205    purposes: &[TokenPurpose],
206) -> topcoat::Result<()> {
207    let prefixes: Vec<String> = panels.iter().map(|panel| panel.prefix.clone()).collect();
208    AuthSession::filter(
209        AuthSession::fields()
210            .user_id()
211            .eq(user_id.to_string())
212            .and(AuthSession::fields().panel().in_list(prefixes)),
213    )
214    .delete()
215    .exec(&mut *ex)
216    .await
217    .map_err(infrastructure_failure)?;
218    let issuing: Vec<String> = panels
219        .iter()
220        .filter(|panel| panel.auth.issues_tokens())
221        .map(|panel| panel.prefix.clone())
222        .collect();
223    if issuing.is_empty() {
224        // A panel that issues no token may leave its model unregistered.
225        return Ok(());
226    }
227    super::token::end_all(ex, user_id, Some(issuing), purposes).await
228}
229
230/// Replaces every session `user_id` holds, on every panel whose authenticator loads `user_type`,
231/// with `session` on the panel and tenant `signed` acts under, and ends its tokens there,
232/// through `ex`: what a credential change does inside the transaction that writes it, so the new
233/// credential and the revocation commit together.
234pub(super) async fn replace_sessions(
235    cx: &Cx,
236    ex: &mut dyn toasty::Executor,
237    signed: &SignedIn,
238    user_type: std::any::TypeId,
239    session: &session::Session,
240) -> topcoat::Result<()> {
241    let panels = credential_panels(cx, user_type, &signed.realm);
242    let user_id = signed.user.user_id();
243    end_credentials(&mut *ex, &user_id, &panels, CREDENTIALS).await?;
244    let expires_at = Timestamp::try_from(session.expires_at).map_err(topcoat::Error::from)?;
245    insert(
246        ex,
247        &session.token_hash,
248        user_id,
249        signed.realm.prefix.clone(),
250        signed.tenant,
251        expires_at,
252    )
253    .await
254    .map_err(infrastructure_failure)
255}
256
257/// Records a session for `user` on the panel `Panel::new(panel)` mounts, as a successful login
258/// does, and returns the token its session cookie carries. No credential is checked.
259///
260/// `tablo::testing`'s `TestClient::sign_in` is the caller: it lets a test act as a signed-in user
261/// without a password hash.
262#[doc(hidden)]
263pub async fn mint_session(
264    ex: &mut dyn toasty::Executor,
265    panel: &str,
266    user: &dyn PanelUser,
267) -> topcoat::Result<String> {
268    let token = session::Token::random();
269    let expires_at = Timestamp::try_from(std::time::SystemTime::now() + SESSION_LIFETIME)
270        .map_err(topcoat::Error::from)?;
271    insert(
272        ex,
273        &token.hash(),
274        user.user_id(),
275        crate::naming::normalize_prefix(panel),
276        None,
277        expires_at,
278    )
279    .await?;
280    Ok(token.encode())
281}
282
283async fn insert(
284    ex: &mut dyn toasty::Executor,
285    hash: &TokenHash,
286    user_id: String,
287    panel: String,
288    tenant: Option<TenantId>,
289    expires_at: Timestamp,
290) -> toasty::Result<()> {
291    toasty::create!(AuthSession {
292        token_hash: token_key(hash),
293        user_id,
294        panel,
295        tenant,
296        expires_at,
297        created_at: Timestamp::now(),
298        impersonating: None,
299        impersonating_until: None,
300    })
301    .exec(ex)
302    .await?;
303    Ok(())
304}
305
306pub(super) async fn select_tenant(cx: &Cx, tenant: TenantId) -> topcoat::Result<()> {
307    let Some(hash) = token_hash(cx, session_panel(cx)?).await? else {
308        return Err(topcoat::router::error::forbidden().into());
309    };
310    let mut db = crate::db::db(cx)?;
311    AuthSession::filter(AuthSession::fields().token_hash().eq(token_key(&hash)))
312        .update()
313        .tenant(Some(tenant))
314        .exec(&mut db)
315        .await
316        .map_err(infrastructure_failure)?;
317    Ok(())
318}
319
320/// Makes the request's session act as `target` until `until`, for no tenant yet, or, with `None`,
321/// as its own user again, through `ex`.
322pub(super) async fn impersonate(
323    cx: &Cx,
324    ex: &mut dyn toasty::Executor,
325    target: Option<(String, Timestamp)>,
326) -> topcoat::Result<()> {
327    let Some(hash) = token_hash(cx, session_panel(cx)?).await? else {
328        return Err(topcoat::router::error::forbidden().into());
329    };
330    set_impersonation(ex, &token_key(&hash), target).await
331}
332
333/// Sets what the session row keyed `key` acts as, through `ex`: see [`impersonate`].
334pub(super) async fn set_impersonation(
335    ex: &mut dyn toasty::Executor,
336    key: &str,
337    target: Option<(String, Timestamp)>,
338) -> topcoat::Result<()> {
339    let (user, until) = target.unzip();
340    AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_owned()))
341        .update()
342        .impersonating(user)
343        .impersonating_until(until)
344        .tenant(None)
345        .exec(ex)
346        .await
347        .map_err(infrastructure_failure)?;
348    Ok(())
349}
350
351pub(super) async fn delete_session(cx: &Cx, hash: &TokenHash) -> topcoat::Result<()> {
352    delete_session_row(cx, &token_key(hash)).await
353}
354
355/// The session `started` holds, or a new one it then holds: a transaction
356/// [`run_serializable`](super::token::run_serializable) runs again records the session the first
357/// run gave the browser, rather than one more cookie.
358pub(super) async fn start_once(
359    cx: &Cx,
360    started: &mut Option<session::Session>,
361) -> topcoat::Result<session::Session> {
362    if let Some(session) = started {
363        return Ok(session.clone());
364    }
365    let session = start(cx).await?;
366    *started = Some(session.clone());
367    Ok(session)
368}
369
370/// Ends the session the request presents and starts a new one, so a token presented before a
371/// sign-in cannot be replayed after it.
372///
373/// # Errors
374///
375/// The opaque sign-in outage when the database cannot answer; any other failure keeps its mapping.
376pub(super) async fn fresh_session(cx: &Cx) -> topcoat::Result<session::Session> {
377    let panel = session_panel(cx)?;
378    if let Some(hash) = token_hash(cx, panel)
379        .await
380        .map_err(infrastructure_failure)?
381    {
382        delete_session(cx, &hash).await?;
383    }
384    start(cx).await.map_err(infrastructure_failure)
385}
386
387async fn delete_session_row(cx: &Cx, key: &str) -> topcoat::Result<()> {
388    let mut db = crate::db::db(cx)?;
389    AuthSession::filter(AuthSession::fields().token_hash().eq(key.to_string()))
390        .delete()
391        .exec(&mut db)
392        .await
393        .map_err(infrastructure_failure)?;
394    Ok(())
395}
396
397/// Revokes every live session of `user_id`, its pending sign-ins, its reset links and its pending
398/// email change, on the current panel and on each panel sharing its users, whose authenticator
399/// loads the same user type, or on every panel outside any panel; call it whenever a credential
400/// changes. A link that proves an address or undoes an email change survives.
401///
402/// # Errors
403///
404/// The opaque sign-in outage when the database cannot answer.
405pub async fn revoke_sessions_for_user(cx: &Cx, user_id: &str) -> topcoat::Result<()> {
406    let mut db = crate::db::db(cx)?;
407    if let Some(panel) = current_realm(cx) {
408        let panels = match panel.auth.authenticator() {
409            Some(auth) => credential_panels(cx, auth.user_type(), panel),
410            None => vec![Arc::clone(panel)],
411        };
412        end_credentials(&mut db, user_id, &panels, REVOCATION).await?;
413        wake_bells(
414            cx,
415            user_id,
416            panels.iter().map(|panel| panel.prefix.as_str()),
417        );
418        return Ok(());
419    }
420    AuthSession::filter(AuthSession::fields().user_id().eq(user_id.to_string()))
421        .delete()
422        .exec(&mut db)
423        .await
424        .map_err(infrastructure_failure)?;
425    let realms = realms(cx);
426    if realms.iter().any(|realm| realm.auth.issues_tokens()) {
427        super::token::end_all(&mut db, user_id, None, REVOCATION).await?;
428    }
429    wake_bells(
430        cx,
431        user_id,
432        realms.iter().map(|realm| realm.prefix.as_str()),
433    );
434    Ok(())
435}
436
437/// Wakes the bells `user_id` has open on `panels`, so each checks its session again and stops.
438fn wake_bells<'a>(cx: &Cx, user_id: &str, panels: impl Iterator<Item = &'a str>) {
439    let bus = crate::bus::bus(cx);
440    for panel in panels {
441        bus.wake(panel, user_id);
442    }
443}
444
445/// How many expired session rows one sweep drops: the bound that keeps one
446/// login from deleting an unbounded number of rows.
447pub(super) const SESSION_SWEEP_BATCH: usize = 500;
448
449/// Drops up to [`SESSION_SWEEP_BATCH`] expired session rows on successful login.
450pub(super) async fn sweep_expired_sessions(cx: &Cx) -> topcoat::Result<()> {
451    let now = Timestamp::now();
452    let mut db = crate::db::db(cx)?;
453    let expired: Vec<String> = AuthSession::filter(AuthSession::fields().expires_at().le(now))
454        .limit(SESSION_SWEEP_BATCH)
455        .exec(&mut db)
456        .await
457        .map_err(infrastructure_failure)?
458        .into_iter()
459        .map(|row| row.token_hash)
460        .collect();
461    if expired.is_empty() {
462        return Ok(());
463    }
464    // The expiry is re-checked: the select and the delete are two statements.
465    AuthSession::filter(
466        AuthSession::fields()
467            .token_hash()
468            .in_list(expired)
469            .and(AuthSession::fields().expires_at().le(now)),
470    )
471    .delete()
472    .exec(&mut db)
473    .await
474    .map_err(infrastructure_failure)?;
475    Ok(())
476}
477
478/// Returns the live session row the request presents to `panel`, purging it when expired and
479/// reading nothing when `panel`'s cookie is absent. A token moved over from another panel's cookie
480/// finds no row here, since a row serves only the panel that issued it.
481pub(super) async fn session_row(cx: &Cx, panel: &Realm) -> topcoat::Result<Option<AuthSession>> {
482    let Some(hash) = token_hash(cx, panel).await? else {
483        return Ok(None);
484    };
485    let key = token_key(&hash);
486    let mut db = crate::db::db(cx)?;
487    let row = AuthSession::filter(AuthSession::fields().token_hash().eq(key))
488        .first()
489        .exec(&mut db)
490        .await
491        .map_err(infrastructure_failure)?;
492    let Some(row) = row else {
493        return Ok(None);
494    };
495    if row.expires_at <= Timestamp::now() {
496        delete_session_row(cx, &row.token_hash).await?;
497        return Ok(None);
498    }
499    Ok((row.panel == panel.prefix).then_some(row))
500}
501
502/// Resolves `row` to its user, purging the row when the user no longer authenticates.
503///
504/// A panel behind [`ExternalAuth`](crate::ExternalAuth) keeps it: there `None` speaks for this
505/// request's copy of the app's cookies, which a live connection opened before a rotation still
506/// holds, and the browser's fresh tab goes on using the row. It serves no one the app does not
507/// name, and the next entry for another user replaces it.
508pub(super) async fn session_user(
509    cx: &Cx,
510    row: AuthSession,
511    panel: &Arc<Realm>,
512    authenticator: &dyn DynAuthenticator,
513) -> topcoat::Result<Option<SignedIn>> {
514    match authenticator
515        .find_by_id(cx, &row.user_id)
516        .await
517        .map_err(infrastructure_failure)?
518    {
519        Some(user) if row.impersonating.is_some() => {
520            super::impersonation::resolve(cx, row, user, panel, authenticator)
521                .await
522                .map(Some)
523        }
524        Some(user) => Ok(Some(SignedIn {
525            user,
526            realm: Arc::clone(panel),
527            tenant: row.tenant,
528            impersonator: None,
529        })),
530        None => {
531            if panel.auth.external().is_none() {
532                delete_session_row(cx, &row.token_hash).await?;
533            }
534            Ok(None)
535        }
536    }
537}
538
539/// Resolves the request's session on `panel` into its user.
540pub(super) async fn resolve(
541    cx: &Cx,
542    panel: &Arc<Realm>,
543    authenticator: &dyn DynAuthenticator,
544) -> topcoat::Result<Option<SignedIn>> {
545    match session_row(cx, panel).await? {
546        Some(row) => session_user(cx, row, panel, authenticator).await,
547        None => Ok(None),
548    }
549}