Skip to main content

tablo_core/auth/
oidc.rs

1//! Provider sign-in: the [`IdentityLinker`] trait that turns a provider's identity into the app's
2//! user, the [`Oidc`] configuration, and the `{prefix}/sign-in/{key}` routes that leave for an
3//! OpenID Connect provider and come back from it.
4
5mod linker;
6mod provider;
7
8use std::{marker::PhantomData, time::Duration};
9
10use topcoat::{
11    context::Cx,
12    cookie::{Cookie, Cookies, SameSite, cookies, try_private_cookies},
13    router::{
14        Body, RouteFuture,
15        error::{not_found, see_other},
16        request::{client_ip, uri},
17        response::{IntoResponse, Response},
18    },
19    view::{BoxView, ViewExt, view},
20};
21
22use self::{
23    linker::DynLinker,
24    provider::{Failure, Pending},
25};
26pub use self::{
27    linker::{IdentityLinker, ProviderIdentity},
28    provider::OidcProvider,
29};
30use super::{
31    Auth, PanelUser, infrastructure_failure,
32    login::{admit, login_url, next_from_query},
33    mail::private,
34    realm::{Realm, current_realm, panel_prefix},
35    registration::with_next,
36};
37use crate::{flash::set_notification, outcome::Notification};
38
39#[cfg(test)]
40mod tests;
41
42/// How long a browser has to come back from the provider.
43const PENDING_LIFETIME: Duration = Duration::from_mins(10);
44
45/// The cookie a pending sign-in travels in, sealed under the router's cookie `Key`. One per
46/// browser: a sign-in started in a second tab replaces the first's.
47const COOKIE_NAME: &str = "__Host-tablo-oidc";
48
49/// What the sign-in throttle counts provider callbacks under, apart from password sign-ins.
50const THROTTLE_SCOPE: &str = "provider-sign-in";
51
52/// A panel's provider sign-in, installed with [`Auth::oidc`]: a button on the login and sign-up
53/// pages for each [`OidcProvider`], which leaves for the provider and comes back signed in as the
54/// user the [`IdentityLinker`] names.
55///
56/// A provider sign-in ends as a password one does: an account with a second factor answers the
57/// two-factor challenge, and a new session replaces the browser's. It needs the router's cookie
58/// `Key`, which seals the sign-in's secrets while the browser is at the provider.
59///
60/// ```rust
61/// # use tablo_core::{Auth, IdentityLinker, Oidc, OidcProvider, ProviderIdentity, auth::AdminUser};
62/// # struct Linker;
63/// # impl IdentityLinker for Linker {
64/// #     type User = AdminUser;
65/// #     async fn sign_in(
66/// #         &self,
67/// #         _cx: &topcoat::context::Cx,
68/// #         _identity: &ProviderIdentity,
69/// #         _ex: &mut dyn toasty::Executor,
70/// #     ) -> topcoat::Result<Option<AdminUser>> {
71/// #         Ok(None)
72/// #     }
73/// # }
74/// let auth = Auth::password().oidc(
75///     Oidc::new("https://app.example.com", Linker)
76///         .provider(OidcProvider::google("client-id", "client-secret")),
77/// );
78/// ```
79pub struct Oidc<U> {
80    /// The scheme and host every redirect URI starts with, without a trailing `/`.
81    base_url: String,
82    linker: Box<dyn DynLinker>,
83    pub(super) providers: Vec<OidcProvider>,
84    user: PhantomData<fn() -> U>,
85}
86
87impl<U: PanelUser> Oidc<U> {
88    /// Signs users in through `linker`, with redirect URIs that start with `base_url`, such as
89    /// `https://app.example.com`: the app's public origin, never one a request names. Add each
90    /// provider with [`provider`](Self::provider).
91    ///
92    /// The pending sign-in's cookie belongs to the host the browser started on, and the provider
93    /// sends it back to `base_url`'s: serve the panel on that host alone, redirecting any other,
94    /// such as `www.`, to it, or a sign-in started there comes back without its cookie and
95    /// fails.
96    #[must_use]
97    pub fn new(base_url: impl Into<String>, linker: impl IdentityLinker<User = U>) -> Self {
98        Self {
99            base_url: base_url.into().trim_end_matches('/').to_string(),
100            linker: Box::new(linker),
101            providers: Vec::new(),
102            user: PhantomData,
103        }
104    }
105
106    /// Adds `provider`'s button, after the providers added before it.
107    #[must_use]
108    pub fn provider(mut self, provider: OidcProvider) -> Self {
109        self.providers.push(provider);
110        self
111    }
112
113    fn erase(self) -> Oidc<()> {
114        Oidc {
115            base_url: self.base_url,
116            linker: self.linker,
117            providers: self.providers,
118            user: PhantomData,
119        }
120    }
121}
122
123impl Oidc<()> {
124    /// The provider whose key is `key`.
125    fn find(&self, key: &str) -> Option<&OidcProvider> {
126        self.providers.iter().find(|provider| provider.key == key)
127    }
128
129    /// The URI the provider sends the browser back to: `{base_url}{prefix}/sign-in/{key}/callback`.
130    fn redirect_uri(&self, prefix: &str, key: &str) -> String {
131        format!("{}{prefix}/sign-in/{key}/callback", self.base_url)
132    }
133}
134
135impl<U> std::fmt::Debug for Oidc<U> {
136    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137        f.debug_struct("Oidc")
138            .field("base_url", &self.base_url)
139            .field("providers", &self.providers)
140            .finish_non_exhaustive()
141    }
142}
143
144impl<U: PanelUser> Auth<U> {
145    /// Adds provider sign-in: a button on the login page, and on the sign-up page when the panel
146    /// offers one, for each of `oidc`'s providers. See [`Oidc`].
147    ///
148    /// A pending sign-in lasts ten minutes, in a cookie sealed under the router's cookie `Key`;
149    /// the provider's answer must carry its state, and the ID token its nonce. A failed sign-in,
150    /// whether the provider refused, its answer failed a check or the linker returned `None`,
151    /// lands on the login page with one message.
152    ///
153    /// Mounting refuses it on [`Auth::disabled`] and beside [`Auth::external`], on a router
154    /// without a cookie `Key`, with a base URL that is not an `https` origin, without a provider,
155    /// and with a provider whose key is not lowercase ASCII letters, digits and `-`, whose key
156    /// another provider holds, or whose issuer is neither `https` nor `http` on a loopback host.
157    #[must_use]
158    pub fn oidc(mut self, oidc: Oidc<U>) -> Self {
159        self.config.oidc = Some(oidc.erase());
160        self
161    }
162}
163
164/// Where the sign-in through `key` starts: `{prefix}/sign-in/{key}`.
165fn start_url(cx: &Cx, key: &str) -> String {
166    format!("{}/sign-in/{key}", panel_prefix(cx))
167}
168
169/// The provider whose start or callback route `path` is on the request's panel, and whether it is
170/// the callback.
171fn route_of<'a>(cx: &'a Cx, path: &str) -> Option<(&'a OidcProvider, bool)> {
172    let oidc = current_realm(cx)?.auth.oidc()?;
173    let rest = path
174        .strip_prefix(&panel_prefix(cx))?
175        .strip_prefix("/sign-in/")?;
176    let (key, callback) = match rest.strip_suffix("/callback") {
177        Some(key) => (key, true),
178        None => (rest, false),
179    };
180    Some((oidc.find(key)?, callback))
181}
182
183/// Whether `path` is a provider sign-in route of the request's panel, which the gate serves
184/// while signed out.
185pub(super) fn serves(cx: &Cx, path: &str) -> bool {
186    route_of(cx, path).is_some()
187}
188
189/// The provider buttons the login and sign-up pages show under their form, carrying `next`;
190/// `None` when the panel installs no provider.
191pub(super) fn buttons<'a>(cx: &'a Cx, next: &str) -> Option<BoxView<'a>> {
192    let oidc = current_realm(cx)?.auth.oidc()?;
193    let links: Vec<(String, String, String)> = oidc
194        .providers
195        .iter()
196        .map(|provider| {
197            let label = crate::t!(
198                cx,
199                "tablo-oidc-continue",
200                provider = provider.label.localize(cx)
201            );
202            let href = with_next(start_url(cx, &provider.key), next);
203            (provider.key.clone(), href, label)
204        })
205        .collect();
206    let or = crate::t!(cx, "tablo-oidc-or");
207    Some(
208        view! {
209            <div class="flex flex-col gap-3">
210                <div
211                    class="flex items-center gap-3 text-xs text-muted-foreground uppercase"
212                >
213                    <span class="h-px flex-1 bg-border"></span>
214                    (or)
215                    <span class="h-px flex-1 bg-border"></span>
216                </div>
217                for (key, href, label) in links {
218                    // A plain link: the browser leaves for the provider, which the runtime's
219                    // fetch and the panel's `form-action` would both stop.
220                    <a
221                        href=(href)
222                        data-oidc-provider=(key)
223                        class=(tablo_ui::button_variants(
224                            tablo_ui::ButtonVariant::Outline,
225                            tablo_ui::ButtonSize::Md,
226                        ))
227                    >
228                        (label)
229                    </a>
230                }
231            </div>
232        }
233        .boxed(),
234    )
235}
236
237/// `GET {prefix}/sign-in/{key}` — remember a new sign-in in a sealed cookie and leave for the
238/// provider.
239pub(super) fn start(cx: &Cx, _body: Body) -> RouteFuture<'_> {
240    Box::pin(async move {
241        let panel = current_realm(cx).ok_or_else(not_found)?;
242        let oidc = panel.auth.oidc().ok_or_else(not_found)?;
243        let (provider, false) = route_of(cx, uri(cx).path()).ok_or_else(not_found)? else {
244            return Err(not_found().into());
245        };
246        let next = next_from_query(cx).unwrap_or_default();
247        let pending = Pending::new(panel.prefix.clone(), provider.key.clone(), next.clone());
248        let redirect_uri = oidc.redirect_uri(&panel.prefix, &provider.key);
249        let url = match provider.authorization_url(&redirect_uri, &pending).await {
250            Ok(url) => url,
251            Err(failure) => return refused(cx, provider, failure, &next),
252        };
253        let sealed = serde_json::to_string(&pending).map_err(topcoat::Error::from)?;
254        sealed_cookies(cx)?.add(
255            Cookie::build((COOKIE_NAME, sealed))
256                .path("/")
257                .http_only(true)
258                .secure(true)
259                // The provider sends the browser back with a top-level GET, which `Lax` admits.
260                .same_site(SameSite::Lax)
261                .max_age(topcoat::cookie::time::Duration::seconds(
262                    i64::try_from(PENDING_LIFETIME.as_secs()).unwrap_or(i64::MAX),
263                ))
264                .build(),
265        );
266        private(see_other(url).into_response(cx)?)
267    })
268}
269
270/// `GET {prefix}/sign-in/{key}/callback` — check the provider's answer against the pending
271/// sign-in, trade its code for the identity, and sign the user it links in.
272pub(super) fn callback(cx: &Cx, _body: Body) -> RouteFuture<'_> {
273    Box::pin(async move {
274        let panel = current_realm(cx).ok_or_else(not_found)?;
275        let oidc = panel.auth.oidc().ok_or_else(not_found)?;
276        let (provider, true) = route_of(cx, uri(cx).path()).ok_or_else(not_found)? else {
277            return Err(not_found().into());
278        };
279        // One sign-in, one answer: the cookie goes whatever the answer says.
280        let pending = sealed_cookies(cx)?
281            .get(COOKIE_NAME)
282            .and_then(|cookie| serde_json::from_str::<Pending>(cookie.value()).ok());
283        cookies(cx).remove(Cookie::build((COOKIE_NAME, "")).path("/").secure(true));
284        let query = query(cx);
285        let read = |key: &str| query.iter().find(|(name, _)| name == key).map(|(_, v)| v);
286        let now = jiff::Timestamp::now().as_second();
287        let lifetime = i64::try_from(PENDING_LIFETIME.as_secs()).unwrap_or(i64::MAX);
288        let Some(pending) = pending.filter(|pending| {
289            (0..lifetime).contains(&(now - pending.started))
290                && pending.panel == panel.prefix
291                && pending.provider == provider.key
292                && read("state").is_some_and(|state| {
293                    subtle::ConstantTimeEq::ct_eq(state.as_bytes(), pending.state.as_bytes()).into()
294                })
295        }) else {
296            return refused(cx, provider, Failure::Refused, "");
297        };
298        let next = pending.next.clone();
299        let Some(code) = read("code").filter(|code| !code.is_empty()) else {
300            // The provider's `error`, such as `access_denied` when the user cancels.
301            return refused(cx, provider, Failure::Refused, &next);
302        };
303        // The cookie stays good for its lifetime, so the throttle bounds the token requests one
304        // client sends the provider by replaying it.
305        let throttle = panel.auth.login_throttle();
306        let client = client_ip(cx);
307        if !throttle
308            .attempt(cx, THROTTLE_SCOPE, &provider.key, client)
309            .await?
310        {
311            tracing::warn!(panel = %panel.prefix, client = ?client, "provider sign-in throttled");
312            return refused(cx, provider, Failure::Refused, &next);
313        }
314        let redirect_uri = oidc.redirect_uri(&panel.prefix, &provider.key);
315        let identity = match provider.redeem(code, &redirect_uri, &pending).await {
316            Ok(identity) => identity,
317            Err(failure) => return refused(cx, provider, failure, &next),
318        };
319        throttle
320            .clear(cx, THROTTLE_SCOPE, &provider.key, client)
321            .await?;
322        let user = match oidc.linker.sign_in(cx, &identity).await {
323            Ok(user) => user,
324            Err(error) => {
325                let error = infrastructure_failure(error);
326                if crate::error::TabloError::is_infrastructure(&error) {
327                    return refused(cx, provider, Failure::Unavailable, &next);
328                }
329                return Err(error);
330            }
331        };
332        let Some(user) = user.filter(|user| user.can_access_panel(&panel.prefix)) else {
333            return refused(cx, provider, Failure::Refused, &next);
334        };
335        admit(cx, panel, &*user, next).await
336    })
337}
338
339/// The request's query pairs.
340fn query(cx: &Cx) -> Vec<(String, String)> {
341    uri(cx)
342        .query()
343        .map(|query| {
344            form_urlencoded::parse(query.as_bytes())
345                .into_owned()
346                .collect()
347        })
348        .unwrap_or_default()
349}
350
351/// The request's cookies sealed under the router's cookie `Key`, which mounting requires.
352fn sealed_cookies(
353    cx: &Cx,
354) -> topcoat::Result<topcoat::cookie::PrivateJar<'_, &topcoat::cookie::CookieJar>> {
355    try_private_cookies(cx).ok_or_else(|| {
356        crate::error::declaration("provider sign-in needs the router's cookie `Key`".to_owned())
357    })
358}
359
360/// Back to the login page, carrying `next`, saying the sign-in through `provider` failed.
361fn refused(
362    cx: &Cx,
363    provider: &OidcProvider,
364    failure: Failure,
365    next: &str,
366) -> topcoat::Result<Response> {
367    let message = match failure {
368        Failure::Refused => "tablo-oidc-failed",
369        Failure::Unavailable => "tablo-oidc-unavailable",
370    };
371    set_notification(
372        cx,
373        Notification::error(crate::t!(
374            cx,
375            message,
376            provider = provider.label.localize(cx)
377        )),
378    );
379    private(see_other(with_next(login_url(cx), next)).into_response(cx)?)
380}
381
382/// Mounts `realm`'s provider routes: a start and a callback per provider.
383pub(super) fn routes(
384    mut builder: topcoat::router::RouterBuilder,
385    realm: &Realm,
386) -> topcoat::router::RouterBuilder {
387    use topcoat::router::RouteFn;
388
389    let Some(oidc) = realm.auth.oidc() else {
390        return builder;
391    };
392    for provider in &oidc.providers {
393        let path = format!("{}/sign-in/{}", realm.prefix, provider.key);
394        builder = builder
395            .route(RouteFn::new(
396                http::Method::GET,
397                crate::naming::route_path(&path),
398                start,
399            ))
400            .route(RouteFn::new(
401                http::Method::GET,
402                crate::naming::route_path(&format!("{path}/callback")),
403                callback,
404            ));
405    }
406    builder
407}
408
409/// The mistakes in a panel's [`Auth::oidc`]: provider sign-in without auth or beside external
410/// auth, a router without the cookie key, a base URL that is not an absolute `https` origin, no
411/// provider, a provider key that cannot name a route or names two providers, and an issuer the
412/// panel may not send requests to.
413pub(super) fn declaration_errors(
414    auth: &super::AuthConfig,
415    has_key: bool,
416) -> Vec<crate::DeclarationErrorKind> {
417    use crate::{DeclarationErrorKind, declaration::OidcFault};
418
419    let Some(oidc) = &auth.oidc else {
420        return Vec::new();
421    };
422    if auth.authenticator().is_none() {
423        return vec![DeclarationErrorKind::Oidc(OidcFault::AuthDisabled)];
424    }
425    let mut faults = Vec::new();
426    if auth.external().is_some() {
427        faults.push(OidcFault::ExternalAuth);
428    }
429    if !has_key {
430        faults.push(OidcFault::MissingKey);
431    }
432    let origin = url::Url::parse(&oidc.base_url)
433        .ok()
434        .filter(|url| url.path() == "/" && url.query().is_none() && url.fragment().is_none());
435    if !origin.is_some_and(|_| provider::secure_url(&oidc.base_url)) {
436        faults.push(OidcFault::BaseUrl(oidc.base_url.clone()));
437    }
438    if oidc.providers.is_empty() {
439        faults.push(OidcFault::NoProvider);
440    }
441    for (index, provider) in oidc.providers.iter().enumerate() {
442        if !provider.key_is_valid() {
443            faults.push(OidcFault::ProviderKey(provider.key.clone()));
444        } else if oidc.providers[..index]
445            .iter()
446            .any(|earlier| earlier.key == provider.key)
447        {
448            faults.push(OidcFault::DuplicateProvider(provider.key.clone()));
449        }
450        if !provider.issuer_is_valid() {
451            faults.push(OidcFault::Issuer(provider.key.clone()));
452        }
453        for (name, _) in &provider.params {
454            if provider::RESERVED_PARAMS.contains(&name.as_str()) {
455                faults.push(OidcFault::ReservedParam(provider.key.clone(), name.clone()));
456            }
457        }
458    }
459    faults.into_iter().map(DeclarationErrorKind::Oidc).collect()
460}