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}