Skip to main content

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