mz_frontegg_auth/
auth.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10use std::collections::BTreeMap;
11use std::future::Future;
12use std::num::NonZeroUsize;
13use std::pin::Pin;
14use std::sync::{Arc, Mutex};
15use std::time::{Duration, Instant};
16
17use anyhow::Context as _;
18use derivative::Derivative;
19use futures::FutureExt;
20use futures::future::Shared;
21use jsonwebtoken::{Algorithm, DecodingKey, Validation};
22use lru::LruCache;
23use mz_ore::instrument;
24use mz_ore::metrics::MetricsRegistry;
25use mz_ore::now::NowFn;
26use mz_ore::time::DurationExt;
27use mz_repr::user::ExternalUserMetadata;
28use serde::{Deserialize, Serialize};
29use tokio::sync::watch;
30use tokio::time;
31use uuid::Uuid;
32
33use crate::metrics::Metrics;
34use crate::{ApiTokenArgs, AppPassword, Client, Error, FronteggCliArgs};
35
36/// SAFETY: Value is known to be non-zero.
37pub const DEFAULT_REFRESH_DROP_LRU_CACHE_SIZE: NonZeroUsize =
38    unsafe { NonZeroUsize::new_unchecked(1024) };
39
40/// If a session is dropped within [`DEFAULT_REFRESH_DROP_FACTOR`] `* valid_for` seconds of an
41/// authentication token expiring, then we'll continue to refresh the auth token, with the
42/// assumption that a new instance of this session will be started soon.
43pub const DEFAULT_REFRESH_DROP_FACTOR: f64 = 0.05;
44
45/// The maximum length of a user name.
46pub const MAX_USER_NAME_LENGTH: usize = 255;
47
48/// Configures an [`Authenticator`].
49#[derive(Clone, Derivative)]
50#[derivative(Debug)]
51pub struct AuthenticatorConfig {
52    /// URL for the token endpoint, including full path.
53    pub admin_api_token_url: String,
54    /// JWK used to validate JWTs.
55    #[derivative(Debug = "ignore")]
56    pub decoding_key: DecodingKey,
57    /// Optional tenant id used to validate JWTs.
58    pub tenant_id: Option<Uuid>,
59    /// Function to provide system time to validate exp (expires at) field of JWTs.
60    pub now: NowFn,
61    /// Name of admin role.
62    pub admin_role: String,
63    /// How many [`AppPassword`]s we'll track the last dropped time for.
64    ///
65    /// TODO(parkmycar): Wire this up to LaunchDarkly.
66    pub refresh_drop_lru_size: NonZeroUsize,
67    /// How large of a window we'll use for determining if a session was dropped "recently", and if
68    /// we should refresh the session, even if there are not any active handles to it.
69    ///
70    /// TODO(parkmycar): Wire this up to LaunchDarkly.
71    pub refresh_drop_factor: f64,
72}
73
74/// Facilitates authenticating users via Frontegg, and verifying returned JWTs.
75#[derive(Clone, Debug)]
76pub struct Authenticator {
77    inner: Arc<AuthenticatorInner>,
78}
79
80impl Authenticator {
81    /// Creates a new authenticator.
82    pub fn new(config: AuthenticatorConfig, client: Client, registry: &MetricsRegistry) -> Self {
83        let mut validation = Validation::new(Algorithm::RS256);
84
85        // We validate the token expiration with our own now function.
86        validation.validate_exp = false;
87
88        // We don't validate the audience because:
89        //
90        //   1. We don't have easy access to the expected audience ID here.
91        //
92        //   2. There is no meaningful security improvement to doing so, because
93        //      Frontegg always sets the audience to the ID of the workspace
94        //      that issued the token. Since we only trust the signing keys from
95        //      a single Frontegg workspace, the audience is redundant.
96        //
97        // See this conversation [0] from the Materialize–Frontegg shared Slack
98        // channel on 1 January 2024.
99        //
100        // [0]: https://materializeinc.slack.com/archives/C02940WNMRQ/p1704131331041669
101        validation.validate_aud = false;
102
103        let metrics = Metrics::register_into(registry);
104        let active_sessions = Mutex::new(BTreeMap::new());
105        let dropped_sessions = Mutex::new(LruCache::new(config.refresh_drop_lru_size));
106
107        Authenticator {
108            inner: Arc::new(AuthenticatorInner {
109                admin_api_token_url: config.admin_api_token_url,
110                client,
111                validation,
112                decoding_key: config.decoding_key,
113                tenant_id: config.tenant_id,
114                admin_role: config.admin_role,
115                now: config.now,
116                active_sessions,
117                dropped_sessions,
118                refresh_drop_factor: config.refresh_drop_factor,
119                metrics,
120            }),
121        }
122    }
123
124    /// Create an [`Authenticator`] from [`FronteggCliArgs`].
125    pub fn from_args(
126        args: FronteggCliArgs,
127        registry: &MetricsRegistry,
128    ) -> Result<Option<Self>, Error> {
129        let config = match (
130            args.frontegg_tenant,
131            args.frontegg_api_token_url,
132            args.frontegg_admin_role,
133        ) {
134            (None, None, None) => {
135                return Ok(None);
136            }
137            (Some(tenant_id), Some(admin_api_token_url), Some(admin_role)) => {
138                let decoding_key = match (args.frontegg_jwk, args.frontegg_jwk_file) {
139                    (None, Some(path)) => {
140                        let jwk = std::fs::read(&path)
141                            .with_context(|| format!("reading {path:?} for --frontegg-jwk-file"))?;
142                        DecodingKey::from_rsa_pem(&jwk)?
143                    }
144                    (Some(jwk), None) => DecodingKey::from_rsa_pem(jwk.as_bytes())?,
145                    _ => {
146                        return Err(anyhow::anyhow!(
147                            "expected exactly one of --frontegg-jwk or --frontegg-jwk-file"
148                        )
149                        .into());
150                    }
151                };
152                AuthenticatorConfig {
153                    admin_api_token_url,
154                    decoding_key,
155                    tenant_id: Some(tenant_id),
156                    now: mz_ore::now::SYSTEM_TIME.clone(),
157                    admin_role,
158                    refresh_drop_lru_size: DEFAULT_REFRESH_DROP_LRU_CACHE_SIZE,
159                    refresh_drop_factor: DEFAULT_REFRESH_DROP_FACTOR,
160                }
161            }
162            _ => unreachable!("clap enforced"),
163        };
164        let client = Client::environmentd_default();
165
166        Ok(Some(Self::new(config, client, registry)))
167    }
168
169    /// Establishes a new authentication session.
170    ///
171    /// If successful, returns a handle to the authentication session.
172    /// Otherwise, returns the authentication error.
173    pub async fn authenticate(
174        &self,
175        expected_user: &str,
176        password: &str,
177    ) -> Result<AuthSessionHandle, Error> {
178        let password: AppPassword = password.parse()?;
179        match self.authenticate_inner(expected_user, password).await {
180            Ok(handle) => {
181                tracing::debug!("authentication successful");
182                Ok(handle)
183            }
184            Err(e) => {
185                tracing::debug!(error = ?e, "authentication failed");
186                Err(e)
187            }
188        }
189    }
190
191    #[instrument(level = "debug", fields(client_id = %password.client_id))]
192    async fn authenticate_inner(
193        &self,
194        expected_user: &str,
195        password: AppPassword,
196    ) -> Result<AuthSessionHandle, Error> {
197        let request = {
198            let mut sessions = self.inner.active_sessions.lock().expect("lock poisoned");
199            match sessions.get_mut(&password) {
200                // We have an existing session for this app password.
201                Some(AuthSession::Active {
202                    ident,
203                    external_metadata_tx,
204                    ..
205                }) => {
206                    tracing::debug!(?password.client_id, "joining active session");
207
208                    validate_user(&ident.user, expected_user)?;
209                    self.inner
210                        .metrics
211                        .session_request_count
212                        .with_label_values(&["active"])
213                        .inc();
214
215                    // Return a handle to the existing session.
216                    return Ok(AuthSessionHandle {
217                        ident: Arc::clone(ident),
218                        external_metadata_rx: external_metadata_tx.subscribe(),
219                        authenticator: Arc::clone(&self.inner),
220                        app_password: password,
221                    });
222                }
223
224                // We have an in flight request to establish a session.
225                Some(AuthSession::Pending(request)) => {
226                    // Latch on to the existing session.
227                    tracing::debug!(?password.client_id, "joining pending session");
228                    self.inner
229                        .metrics
230                        .session_request_count
231                        .with_label_values(&["pending"])
232                        .inc();
233                    request.clone()
234                }
235
236                // We do not have an existing session for this API key.
237                None => {
238                    tracing::debug!(?password.client_id, "starting new session");
239
240                    // Prepare the request to create a new session.
241                    let request: Pin<Box<AuthFuture>> = Box::pin({
242                        let inner = Arc::clone(&self.inner);
243                        let expected_user = String::from(expected_user);
244                        async move {
245                            let result = inner.authenticate(expected_user, password).await;
246
247                            // Make sure our AuthSession state is correct.
248                            //
249                            // Note: We're quite defensive here because this has been a source of
250                            // bugs in the past.
251                            let mut sessions = inner.active_sessions.lock().expect("lock poisoned");
252                            if let Err(err) = &result {
253                                let session = sessions.remove(&password);
254                                tracing::debug!(?err, ?session, "removing failed auth session");
255                            } else {
256                                // If the request succeeds, make sure our state is what we expect.
257                                match sessions.get(&password) {
258                                    // Expected State.
259                                    Some(AuthSession::Active { .. }) => (),
260                                    // Invalid! The AuthSession should have become Active.
261                                    None | Some(AuthSession::Pending(_)) => {
262                                        tracing::error!(
263                                            ?password.client_id,
264                                            "failed to make auth session active!"
265                                        );
266                                        sessions.remove(&password);
267                                    }
268                                }
269                            }
270
271                            result
272                        }
273                    });
274
275                    // Store the future so that future requests can latch on.
276                    let request = request.shared();
277                    sessions.insert(password, AuthSession::Pending(request.clone()));
278                    self.inner
279                        .metrics
280                        .session_request_count
281                        .with_label_values(&["new"])
282                        .inc();
283
284                    // Make sure there is always something driving the request to completion
285                    // incase the client goes away.
286                    mz_ore::task::spawn(|| "auth-session-listener", {
287                        let request = request.clone();
288                        async move {
289                            // We don't care about the result here, someone else handles it.
290                            let _ = request.await;
291                        }
292                    });
293
294                    // Wait for the request to complete.
295                    request
296                }
297            }
298        };
299        request.await
300    }
301
302    /// Validates an access token, returning the validated claims.
303    ///
304    /// The following validations are always performed:
305    ///
306    ///   * The token is not expired, according to the `Authentication`'s clock.
307    ///
308    ///   * The tenant ID in the token matches the `Authentication`'s tenant ID.
309    ///
310    /// If `expected_user` is provided, the token's user name is additionally
311    /// validated to match `expected_user`.
312    pub fn validate_access_token(
313        &self,
314        token: &str,
315        expected_user: Option<&str>,
316    ) -> Result<ValidatedClaims, Error> {
317        self.inner.validate_access_token(token, expected_user)
318    }
319}
320
321/// A handle to an authentication session.
322///
323/// An authentication session represents a duration of time during which a
324/// user's authentication is known to be valid.
325///
326/// An authentication session begins with a successful API key exchange with
327/// Frontegg. While there is at least one outstanding handle to the session, the
328/// session's metadata and validity are refreshed with Frontegg at a regular
329/// interval. The session ends when all outstanding handles are dropped and the
330/// refresh interval is reached.
331///
332/// [`AuthSessionHandle::external_metadata_rx`] can be used to receive events if
333/// the session's metadata is updated.
334///
335/// [`AuthSessionHandle::expired`] can be used to learn if the session has
336/// failed to refresh the validity of the API key.
337#[derive(Debug, Clone)]
338pub struct AuthSessionHandle {
339    ident: Arc<AuthSessionIdent>,
340    external_metadata_rx: watch::Receiver<ExternalUserMetadata>,
341    /// Hold a handle to the [`AuthenticatorInner`] so we can record when this session was dropped.
342    authenticator: Arc<AuthenticatorInner>,
343    /// Used to record when the session linked with this [`AppPassword`] was dropped.
344    app_password: AppPassword,
345}
346
347impl AuthSessionHandle {
348    /// Returns the name of the user that created the session.
349    pub fn user(&self) -> &str {
350        &self.ident.user
351    }
352
353    /// Returns the ID of the tenant that created the session.
354    pub fn tenant_id(&self) -> Uuid {
355        self.ident.tenant_id
356    }
357
358    /// Mints a receiver for updates to the session user's external metadata.
359    pub fn external_metadata_rx(&self) -> watch::Receiver<ExternalUserMetadata> {
360        self.external_metadata_rx.clone()
361    }
362
363    /// Completes when the authentication session has expired.
364    pub async fn expired(&mut self) {
365        // We piggyback on the external metadata channel to determine session
366        // expiration. The external metadata channel is closed when the session
367        // expires.
368        let _ = self.external_metadata_rx.wait_for(|_| false).await;
369    }
370}
371
372impl Drop for AuthSessionHandle {
373    fn drop(&mut self) {
374        self.authenticator.record_dropped_session(self.app_password);
375    }
376}
377
378#[derive(Derivative)]
379#[derivative(Debug)]
380struct AuthenticatorInner {
381    /// Frontegg API fields.
382    admin_api_token_url: String,
383    client: Client,
384    /// JWT decoding and validation fields.
385    validation: Validation,
386    #[derivative(Debug = "ignore")]
387    decoding_key: DecodingKey,
388    tenant_id: Option<Uuid>,
389    admin_role: String,
390    now: NowFn,
391    /// Session tracking.
392    active_sessions: Mutex<BTreeMap<AppPassword, AuthSession>>,
393    /// Most recent time at which a session created with an [`AppPassword`] was dropped.
394    ///
395    /// We track when a session was dropped to handle the case of many one-shot queries being
396    /// issued in rapid succession. If it comes time to refresh an auth token, and there are no
397    /// currently alive sessions, but one was recently dropped, we'll pre-emptively refresh to get
398    /// ahead of another session being created with the same [`AppPassword`].
399    dropped_sessions: Mutex<LruCache<AppPassword, Instant>>,
400    /// How large of a window we'll use for determining if a session was dropped "recently", and if
401    /// we should refresh the session, even if there are not any active handles to it.
402    refresh_drop_factor: f64,
403    /// Metrics.
404    metrics: Metrics,
405}
406
407impl AuthenticatorInner {
408    async fn authenticate(
409        self: &Arc<Self>,
410        expected_user: String,
411        password: AppPassword,
412    ) -> Result<AuthSessionHandle, Error> {
413        // Attempt initial app password exchange.
414        let mut claims = self.exchange_app_password(&expected_user, password).await?;
415
416        // Prep session information.
417        let ident = Arc::new(AuthSessionIdent {
418            user: claims.user.clone(),
419            tenant_id: claims.tenant_id,
420        });
421        let external_metadata = claims.to_external_user_metadata();
422        let (external_metadata_tx, external_metadata_rx) = watch::channel(external_metadata);
423        let external_metadata_tx = Arc::new(external_metadata_tx);
424
425        // Store session to make it available for future requests to latch on
426        // to.
427        {
428            let mut sessions = self.active_sessions.lock().expect("lock poisoned");
429            sessions.insert(
430                password,
431                AuthSession::Active {
432                    ident: Arc::clone(&ident),
433                    external_metadata_tx: Arc::clone(&external_metadata_tx),
434                },
435            );
436        }
437
438        // Start background refresh task.
439        let name = format!("frontegg-auth-refresh-{}", password.client_id);
440        mz_ore::task::spawn(|| name, {
441            let inner = Arc::clone(self);
442            async move {
443                tracing::debug!(?password.client_id, "starting refresh task");
444                let gauge = inner.metrics.refresh_tasks_active.with_label_values(&[]);
445                gauge.inc();
446
447                loop {
448                    let valid_for = Duration::try_from_secs_i64(claims.exp - inner.now.as_secs())
449                        .unwrap_or(Duration::from_secs(60));
450
451                    // If we have no outstanding handling to this session, but a handle was dropped
452                    // within this window, then we'll still refresh.
453                    let drop_window = valid_for
454                        .saturating_mul_f64(inner.refresh_drop_factor)
455                        .max(Duration::from_secs(1));
456                    // Scale the validity duration by 0.8. The Frontegg Python
457                    // SDK scales the expires_in this way.
458                    //
459                    // <https://github.com/frontegg/python-sdk/blob/840f8318aced35cea6a41d83270597edfceb4019/frontegg/common/frontegg_authenticator.py#L45>
460                    let valid_for = valid_for.saturating_mul_f64(0.8);
461
462                    if valid_for < Duration::from_secs(60) {
463                        tracing::warn!(?valid_for, "unexpectedly low token validity");
464                    }
465
466                    tracing::debug!(
467                        ?valid_for,
468                        ?drop_window,
469                        "waiting for token validity period"
470                    );
471
472                    // Wait out validity duration.
473                    time::sleep(valid_for).await;
474
475                    // Check to see if all external metadata receivers have gone away, or if a
476                    // session created with this password was recently dropped. If no one is
477                    // listening nor any recent handles were dropped we can clean up the session.
478                    let receiver_count = external_metadata_tx.receiver_count();
479                    let last_drop = inner.last_dropped_session(&password);
480                    let recent_drop = last_drop
481                        .map(|dropped_at| dropped_at.elapsed() <= drop_window)
482                        .unwrap_or(false);
483                    if receiver_count == 0 && !recent_drop {
484                        tracing::debug!(
485                            ?last_drop,
486                            ?password.client_id,
487                            "all listeners have dropped and none of them were recent!"
488                        );
489                        break;
490                    }
491
492                    let outstanding_receivers = bool_as_str(receiver_count > 0);
493                    inner
494                        .metrics
495                        .session_refresh_count
496                        .with_label_values(&[outstanding_receivers, bool_as_str(recent_drop)])
497                        .inc();
498                    tracing::debug!(
499                        receiver_count,
500                        ?last_drop,
501                        ?password.client_id,
502                        "refreshing due to interest in the session"
503                    );
504
505                    // We still have interest, attempt to refresh the session.
506                    let res = inner.exchange_app_password(&expected_user, password).await;
507                    claims = match res {
508                        Ok(claims) => {
509                            tracing::debug!("refresh successful");
510                            claims
511                        }
512                        Err(e) => {
513                            tracing::warn!(error = ?e, "refresh failed");
514                            break;
515                        }
516                    };
517                    external_metadata_tx.send_replace(ExternalUserMetadata {
518                        admin: claims.is_admin,
519                        user_id: claims.user_id,
520                    });
521                }
522
523                // The session has expired. Clean up the state.
524                {
525                    let mut sessions = inner.active_sessions.lock().expect("lock poisoned");
526                    sessions.remove(&password);
527                }
528                {
529                    let mut dropped_session = inner.dropped_sessions.lock().expect("lock poisoned");
530                    dropped_session.pop(&password);
531                }
532
533                tracing::debug!(?password.client_id, "shutting down refresh task");
534                gauge.dec();
535            }
536        });
537
538        // Return handle to session.
539        Ok(AuthSessionHandle {
540            ident,
541            external_metadata_rx,
542            authenticator: Arc::clone(self),
543            app_password: password,
544        })
545    }
546
547    #[instrument]
548    async fn exchange_app_password(
549        &self,
550        expected_user: &str,
551        password: AppPassword,
552    ) -> Result<ValidatedClaims, Error> {
553        let req = ApiTokenArgs {
554            client_id: password.client_id,
555            secret: password.secret_key,
556        };
557        let res = self
558            .client
559            .exchange_client_secret_for_token(req, &self.admin_api_token_url, &self.metrics)
560            .await?;
561        self.validate_access_token(&res.access_token, Some(expected_user))
562    }
563
564    fn validate_access_token(
565        &self,
566        token: &str,
567        expected_user: Option<&str>,
568    ) -> Result<ValidatedClaims, Error> {
569        let msg = jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &self.validation)?;
570        if msg.claims.exp < self.now.as_secs() {
571            return Err(Error::TokenExpired);
572        }
573        if let Some(expected_tenant_id) = self.tenant_id {
574            if msg.claims.tenant_id != expected_tenant_id {
575                return Err(Error::UnauthorizedTenant);
576            }
577        }
578
579        let user = msg.claims.user()?;
580
581        if let Some(expected_user) = expected_user {
582            validate_user(user, expected_user)?;
583        }
584
585        Ok(ValidatedClaims {
586            exp: msg.claims.exp,
587            user: user.to_string(),
588            user_id: msg.claims.user_id()?,
589            tenant_id: msg.claims.tenant_id,
590            // The user is an administrator if they have the admin role that the
591            // `Authenticator` has been configured with.
592            is_admin: msg.claims.roles.iter().any(|r| *r == self.admin_role),
593            _private: (),
594        })
595    }
596
597    /// Records an [`AuthSessionHandle`] that was recently dropped.
598    fn record_dropped_session(&self, app_password: AppPassword) {
599        let now = Instant::now();
600        let Ok(mut dropped_sessions) = self.dropped_sessions.lock() else {
601            return;
602        };
603        dropped_sessions.push(app_password, now);
604    }
605
606    /// Returns the instant that an [`AuthSessionHandle`] created with the provided [`AppPassword`]
607    /// was last dropped.
608    fn last_dropped_session(&self, app_password: &AppPassword) -> Option<Instant> {
609        let Ok(dropped_sessions) = self.dropped_sessions.lock() else {
610            return None;
611        };
612        dropped_sessions.peek(app_password).copied()
613    }
614}
615
616type AuthFuture = dyn Future<Output = Result<AuthSessionHandle, Error>> + Send;
617
618#[derive(Derivative)]
619#[derivative(Debug)]
620enum AuthSession {
621    Pending(Shared<Pin<Box<AuthFuture>>>),
622    Active {
623        ident: Arc<AuthSessionIdent>,
624        external_metadata_tx: Arc<watch::Sender<ExternalUserMetadata>>,
625    },
626}
627
628#[derive(Debug)]
629struct AuthSessionIdent {
630    user: String,
631    tenant_id: Uuid,
632}
633
634/// The type of a JWT issued by Frontegg.
635#[derive(Clone, Debug, Serialize, Deserialize)]
636#[serde(rename_all = "camelCase")]
637pub enum ClaimTokenType {
638    /// A user token.
639    ///
640    /// This type of token is issued when logging in via username and password
641    /// This does *not* include app passwords--those are API tokens under the
642    /// hood. This type of token is typically only used by the Materialize
643    /// console, as it requires SSO.
644    UserToken,
645    /// A user API token.
646    UserApiToken,
647    /// A tenant API token.
648    TenantApiToken,
649}
650
651/// Metadata embedded in a Frontegg JWT.
652#[derive(Clone, Debug, Serialize, Deserialize)]
653#[serde(rename_all = "camelCase")]
654pub struct ClaimMetadata {
655    /// The user name to use, for tokens of type `TenantApiToken`.
656    pub user: Option<String>,
657}
658
659/// The raw claims encoded in a Frontegg access token.
660///
661/// Consult the JSON Web Token specification and the Frontegg documentation to
662/// determine the precise semantics of these fields.
663#[derive(Clone, Debug, Serialize, Deserialize)]
664#[serde(rename_all = "camelCase")]
665pub struct Claims {
666    /// The "subject" of the token.
667    ///
668    /// For tokens of type `UserToken`, this is the ID of the Frontegg user
669    /// itself. For tokens of type `UserApiToken` and `TenantApiToken`, this
670    /// is the client ID of the API token.
671    pub sub: Uuid,
672    /// The time at which the claims expire, represented in seconds since the
673    /// Unix epoch.
674    pub exp: i64,
675    /// The "issuer" of the token.
676    ///
677    /// This is always the domain associated with the Frontegg workspace.
678    pub iss: String,
679    /// The type of API token.
680    #[serde(rename = "type")]
681    pub token_type: ClaimTokenType,
682    /// For tokens of type `UserToken` and `UserApiToken`, the email address
683    /// of the authenticated user.
684    pub email: Option<String>,
685    /// For tokens of type `UserApiToken`, the ID of the authenticated user.
686    pub user_id: Option<Uuid>,
687    /// The ID of the authenticated tenant.
688    pub tenant_id: Uuid,
689    /// The IDs of the roles granted by the token.
690    pub roles: Vec<String>,
691    /// The IDs of the permissions granted by the token.
692    pub permissions: Vec<String>,
693    /// Metadata embedded in the JWT.
694    pub metadata: Option<ClaimMetadata>,
695}
696
697impl Claims {
698    /// Returns the name of the user associated with the token.
699    pub fn user(&self) -> Result<&str, Error> {
700        match self.token_type {
701            // Use the email as the username for user tokens.
702            ClaimTokenType::UserToken | ClaimTokenType::UserApiToken => {
703                self.email.as_deref().ok_or(Error::MissingClaims)
704            }
705            // The user associated with a tenant API token is configured when
706            // the token is created and passed in the `metadata.user` claim.
707            ClaimTokenType::TenantApiToken => {
708                let user = self
709                    .metadata
710                    .as_ref()
711                    .and_then(|m| m.user.as_deref())
712                    .ok_or(Error::MissingClaims)?;
713                if is_email(user) {
714                    return Err(Error::InvalidTenantApiTokenUser);
715                }
716                Ok(user)
717            }
718        }
719    }
720
721    /// Returns the ID of the user associated with the token.
722    pub fn user_id(&self) -> Result<Uuid, Error> {
723        match self.token_type {
724            // The `sub` claim stores the ID of the user.
725            ClaimTokenType::UserToken => Ok(self.sub),
726            // Unlike user tokens, the `sub` claim stores the client ID of the
727            // API token. The user ID is passed in the dedicated `user_id`
728            // claim.
729            ClaimTokenType::UserApiToken => self.user_id.ok_or(Error::MissingClaims),
730            // The best user ID for a tenant API token is the client ID of the
731            // tenant API token, as the tokens are not associated with a
732            // Frontegg user.
733            ClaimTokenType::TenantApiToken => Ok(self.sub),
734        }
735    }
736}
737
738/// [`Claims`] that have been validated by
739/// [`Authenticator::validate_access_token`].
740#[derive(Clone, Debug)]
741pub struct ValidatedClaims {
742    /// The time at which the claims expire, represented in seconds since the
743    /// Unix epoch.
744    pub exp: i64,
745    /// The ID of the authenticated user.
746    pub user_id: Uuid,
747    /// The name of the authenticated user.
748    ///
749    /// For tokens of type `UserToken` or `UserApiToken`, this is the email
750    /// address of the authenticated user. For tokens of type `TenantApiToken`,
751    /// this is the `serviceUser` field in the token's metadata.
752    pub user: String,
753    /// The ID of the tenant the user is authenticated for.
754    pub tenant_id: Uuid,
755    /// Whether the authenticated user is an administrator.
756    pub is_admin: bool,
757    // Prevent construction outside of `Authenticator::validate_access_token`.
758    _private: (),
759}
760
761impl ValidatedClaims {
762    /// Constructs an [`ExternalUserMetadata`] from the claims data.
763    fn to_external_user_metadata(&self) -> ExternalUserMetadata {
764        ExternalUserMetadata {
765            admin: self.is_admin,
766            user_id: self.user_id,
767        }
768    }
769}
770
771/// Reports whether a username is an email address.
772fn is_email(user: &str) -> bool {
773    // We don't need a sophisticated test here. We need a test that will return
774    // `true` for anything that can possibly be an email address, while also
775    // returning `false` for a large class of strings that can be used as names
776    // for service users.
777    //
778    // Checking for `@` balances the concerns. Every email address MUST have an
779    // `@` character. Disallowing `@` characters in service user names is an
780    // acceptable restriction.
781    user.contains('@')
782}
783
784fn validate_user(user: &str, expected_user: &str) -> Result<(), Error> {
785    // Impose a maximum length on user names for sanity.
786    if user.len() > MAX_USER_NAME_LENGTH {
787        return Err(Error::UserNameTooLong);
788    }
789
790    let valid = match is_email(expected_user) {
791        false => user == expected_user,
792        // To match Frontegg, email addresses are compared case insensitively.
793        //
794        // NOTE(benesch): we could save some allocations by using `unicase::eq`
795        // here, but the `unicase` crate has had some critical correctness bugs that
796        // make it scary to use in such security-sensitive code.
797        //
798        // See: https://github.com/seanmonstar/unicase/pull/39
799        true => user.to_lowercase() == expected_user.to_lowercase(),
800    };
801    match valid {
802        false => Err(Error::WrongUser),
803        true => Ok(()),
804    }
805}
806
807const fn bool_as_str(x: bool) -> &'static str {
808    if x { "true" } else { "false" }
809}