Skip to main content

tablo_core/auth/
external.rs

1//! [`ExternalAuth`]: a panel whose users sign in through the host app.
2
3use std::sync::Arc;
4
5use tablo_ui::{ButtonSize, ButtonVariant, button_variants};
6use topcoat::{
7    context::Cx,
8    icon::icon,
9    router::{
10        Body, RouteFuture,
11        error::{redirect, see_other},
12        request::method,
13        response::{IntoResponse, Response},
14    },
15    view::{BoxView, ViewExt, view},
16};
17
18use super::{
19    Authenticator, PanelUser, UNAVAILABLE_ERROR,
20    gate::refused,
21    infrastructure_failure,
22    login::{login_url, next_from_query},
23    page::{AuthPage, render},
24    panel_root,
25    realm::current_realm,
26    registration::with_next,
27    session::{fresh_session, record, session_row, sweep_expired_sessions},
28};
29use crate::{BoxFuture, DeclarationErrorKind, auth::AuthConfig, error::TabloError};
30
31/// A host app's own sign-in, which a panel trusts in place of a login form (ADR-0013).
32///
33/// [`Auth::external`](crate::Auth::external) installs it. The app keeps its login page, its
34/// sessions and its credentials; a panel user is whoever
35/// [`current_user`](Self::current_user) says the request's app session belongs to.
36///
37/// An anonymous request to the panel's `{prefix}/login` goes to
38/// [`login_url`](Self::login_url). Once the app has signed the user in and sent them back, the
39/// panel starts a panel session of its own, which holds what the panel keeps per browser, such as
40/// the selected tenant. On every later request the gate asks `current_user` again and serves a
41/// session only while it names the session's user: signing out of the app, or into another
42/// account, takes effect on the panel's next request.
43///
44/// Each panel keeps its session in a cookie of its own, so entering this panel leaves a session
45/// held on another as it was.
46///
47/// ```rust
48/// use tablo_core::{ExternalAuth, PanelUser, auth::safe_next};
49/// use topcoat::{context::Cx, session};
50///
51/// struct Staff {
52///     id: String,
53///     name: String,
54///     admin: bool,
55/// }
56///
57/// impl PanelUser for Staff {
58///     fn user_id(&self) -> String {
59///         self.id.clone()
60///     }
61///
62///     fn display_name(&self) -> &str {
63///         &self.name
64///     }
65///
66///     fn can_access_panel(&self, _panel: &str) -> bool {
67///         self.admin
68///     }
69/// }
70///
71/// async fn staff_for(_cx: &Cx, _hash: &session::TokenHash) -> topcoat::Result<Option<Staff>> {
72///     Ok(None) // the app's own session lookup
73/// }
74///
75/// struct AppSession;
76///
77/// impl ExternalAuth for AppSession {
78///     type User = Staff;
79///
80///     async fn current_user(&self, cx: &Cx) -> topcoat::Result<Option<Staff>> {
81///         match session::token_hash(cx).await? {
82///             Some(hash) => staff_for(cx, &hash).await,
83///             None => Ok(None),
84///         }
85///     }
86///
87///     fn login_url(&self, _cx: &Cx, next: &str) -> String {
88///         format!("/login?return_to={next}")
89///     }
90///
91///     fn logout_url(&self, _cx: &Cx) -> String {
92///         "/sign-out".to_owned()
93///     }
94/// }
95/// ```
96pub trait ExternalAuth: Send + Sync + 'static {
97    /// The app's user type.
98    type User: PanelUser;
99
100    /// The user the request's app session signs in; `None` when it signs in nobody.
101    ///
102    /// It runs on the gate of every panel request, and again when a live connection checks its
103    /// session, long after the response started: it reads the request and never writes a cookie,
104    /// so a sliding app session refreshes elsewhere. A live connection keeps the cookies of its
105    /// handshake, so to end a session at once it must consult state the server holds: a signed
106    /// cookie alone keeps naming its user until it expires. The app's session cookie must also
107    /// reach a page opened from another site, as `SameSite=Lax` does, or a followed link finds no
108    /// user. A failure to reach the app's store returns the error, which the panel logs and
109    /// answers with an opaque 500 rather than as a signed-out user.
110    fn current_user(
111        &self,
112        cx: &Cx,
113    ) -> impl Future<Output = topcoat::Result<Option<Self::User>>> + Send;
114
115    /// Where a request with no app session signs in. `next` is the URL to come back to,
116    /// percent-encoded to sit in a query string. Check it with
117    /// [`safe_next`](crate::auth::safe_next) before redirecting to it: the browser can send
118    /// anything.
119    fn login_url(&self, cx: &Cx, next: &str) -> String;
120
121    /// Where the shell's Sign out goes, once it has ended the panel's session. That ends only the
122    /// panel's, so while the app's lives the user's next panel request signs them in again. Name
123    /// a route that ends the app's session, or a page of the app's that offers to: the browser
124    /// reaches it by GET, and it may lie on another origin, such as an SSO provider's.
125    fn logout_url(&self, cx: &Cx) -> String;
126}
127
128/// [`ExternalAuth`] with its user type erased, as the panel keeps it.
129pub(crate) trait DynExternal: Send + Sync {
130    fn current_user<'a>(
131        &'a self,
132        cx: &'a Cx,
133    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>>;
134    fn login_url(&self, cx: &Cx, next: &str) -> String;
135    fn logout_url(&self, cx: &Cx) -> String;
136}
137
138/// The app's answer for the request. Any failure is logged and stays opaque, whatever the app
139/// reached for: an identity provider's error is no more a signed-out user than a database's.
140async fn app_user<E: ExternalAuth>(external: &E, cx: &Cx) -> topcoat::Result<Option<E::User>> {
141    external.current_user(cx).await.map_err(|error| {
142        tracing::error!(error = %error, "the app's sign-in failed");
143        TabloError::Infrastructure(UNAVAILABLE_ERROR).into()
144    })
145}
146
147impl<E: ExternalAuth> DynExternal for E {
148    fn current_user<'a>(
149        &'a self,
150        cx: &'a Cx,
151    ) -> BoxFuture<'a, topcoat::Result<Option<Arc<dyn PanelUser>>>> {
152        Box::pin(async move {
153            let user = app_user(self, cx).await?;
154            Ok(user.map(|user| Arc::new(user) as Arc<dyn PanelUser>))
155        })
156    }
157
158    fn login_url(&self, cx: &Cx, next: &str) -> String {
159        ExternalAuth::login_url(self, cx, next)
160    }
161
162    fn logout_url(&self, cx: &Cx) -> String {
163        ExternalAuth::logout_url(self, cx)
164    }
165}
166
167/// The [`Authenticator`] over an [`ExternalAuth`]: no password signs in, and a session loads its
168/// user only while the app's session names the same one, so the gates treat it as any other.
169pub(super) struct ExternalUsers<E>(pub(super) Arc<E>);
170
171impl<E: ExternalAuth> Authenticator for ExternalUsers<E> {
172    type User = E::User;
173
174    async fn verify(
175        &self,
176        _cx: &Cx,
177        _login: &str,
178        _password: &str,
179    ) -> topcoat::Result<Option<E::User>> {
180        Ok(None)
181    }
182
183    async fn find_by_id(&self, cx: &Cx, id: &str) -> topcoat::Result<Option<E::User>> {
184        let user = app_user(&*self.0, cx).await?;
185        Ok(user.filter(|user| user.user_id() == id))
186    }
187}
188
189/// `GET {prefix}/login` on a panel behind [`ExternalAuth`]: lands the app's signed-in user on
190/// `next`, starting a panel session unless the request holds theirs, or sends an anonymous request
191/// to the app's login page.
192///
193/// A link can bring a browser here at any time, so the user's session stands: replacing it would
194/// lose the tenant they act for. A HEAD changes nothing.
195pub(super) fn external_login(cx: &Cx, _body: Body) -> RouteFuture<'_> {
196    Box::pin(async move {
197        let panel = current_realm(cx).ok_or_else(topcoat::router::error::not_found)?;
198        let external = panel
199            .auth
200            .external()
201            .ok_or_else(topcoat::router::error::not_found)?;
202        let next = next_from_query(cx).unwrap_or_default();
203        let user = external.current_user(cx).await?;
204        let Some(user) = user else {
205            let back = with_next(login_url(cx), &next);
206            let back: String = form_urlencoded::byte_serialize(back.as_bytes()).collect();
207            return Err(redirect(external.login_url(cx, &back)).into());
208        };
209        // The app signed this user in, so unlike a login form the page can say why it stops.
210        if !user.can_access_panel(&panel.prefix) {
211            return no_access(cx, external.logout_url(cx)).await;
212        }
213        let holds_theirs = session_row(cx, panel)
214            .await
215            .map_err(infrastructure_failure)?
216            .is_some_and(|row| row.user_id == user.user_id());
217        if !holds_theirs && *method(cx) != http::Method::HEAD {
218            if let Err(error) = sweep_expired_sessions(cx).await {
219                tracing::error!(error = %error, "expired-session sweep failed");
220            }
221            let session = fresh_session(cx).await?;
222            record(cx, &session, &*user, panel).await?;
223        }
224        let target = if next.is_empty() {
225            panel_root(cx)
226        } else {
227            next
228        };
229        see_other(target).into_response(cx)
230    })
231}
232
233/// The button that leaves for the app's sign-out at `url`.
234fn sign_out_link(cx: &Cx, url: String) -> BoxView<'_> {
235    view! {
236        <a
237            href=(url)
238            class=(button_variants(ButtonVariant::Outline, ButtonSize::Md))
239            data-external-sign-out=""
240        >
241            icon(data: tablo_ui::icons::LOG_OUT)
242            (crate::t!(cx, "tablo-shell-sign-out"))
243        </a>
244    }
245    .boxed()
246}
247
248/// The 403 page for an app user the panel refuses: they hold no panel session to sign out of, so
249/// its button leaves for the app.
250async fn no_access(cx: &Cx, leave: String) -> topcoat::Result<Response> {
251    refused(cx, sign_out_link(cx, leave)).await
252}
253
254/// What the shell's Sign out answers: a page that moves on to `url` itself. A redirect would
255/// answer the form's post, and the panel's `form-action 'self'` stops such a redirect from leaving
256/// the origin, with the panel's session already gone and the app's still alive.
257pub(super) async fn leave_for(cx: &Cx, url: String) -> topcoat::Result<Response> {
258    let refresh = format!("0;url={url}");
259    let form = view! {
260        <meta http-equiv="refresh" content=(refresh) />
261        (sign_out_link(cx, url))
262    }
263    .boxed();
264    let page = render(
265        cx,
266        AuthPage {
267            title: crate::t!(cx, "tablo-shell-sign-out"),
268            description: crate::t!(cx, "tablo-external-leaving"),
269            status: None,
270            form,
271            switch: None,
272            note: None,
273        },
274    )
275    .await?;
276    page.single().await?.into_response(cx)
277}
278
279/// What an [`Auth::external`](crate::Auth::external) panel may not declare: the features that need
280/// Tablo to own the account's credentials.
281pub(super) fn declaration_errors(auth: &AuthConfig) -> Vec<DeclarationErrorKind> {
282    if auth.external().is_none() {
283        return Vec::new();
284    }
285    [
286        ("Auth::registration", auth.registrar().is_some()),
287        ("Auth::password_reset", auth.reset().is_some()),
288        ("Auth::email_verification", auth.verification().is_some()),
289        ("Auth::two_factor", auth.two_factor_config().is_some()),
290        ("Auth::impersonation", auth.impersonation().is_some()),
291    ]
292    .into_iter()
293    .filter(|(_, declared)| *declared)
294    .map(|(feature, _)| DeclarationErrorKind::ExternalAuthFeature { feature })
295    .collect()
296}