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 inner.metrics.refresh_tasks_active.inc();
445
446 loop {
447 let valid_for = Duration::try_from_secs_i64(claims.exp - inner.now.as_secs())
448 .unwrap_or(Duration::from_secs(60));
449
450 // If we have no outstanding handling to this session, but a handle was dropped
451 // within this window, then we'll still refresh.
452 let drop_window = valid_for
453 .saturating_mul_f64(inner.refresh_drop_factor)
454 .max(Duration::from_secs(1));
455 // Scale the validity duration by 0.8. The Frontegg Python
456 // SDK scales the expires_in this way.
457 //
458 // <https://github.com/frontegg/python-sdk/blob/840f8318aced35cea6a41d83270597edfceb4019/frontegg/common/frontegg_authenticator.py#L45>
459 let valid_for = valid_for.saturating_mul_f64(0.8);
460
461 if valid_for < Duration::from_secs(60) {
462 tracing::warn!(?valid_for, "unexpectedly low token validity");
463 }
464
465 tracing::debug!(
466 ?valid_for,
467 ?drop_window,
468 "waiting for token validity period"
469 );
470
471 // Wait out validity duration.
472 time::sleep(valid_for).await;
473
474 // Check to see if all external metadata receivers have gone away, or if a
475 // session created with this password was recently dropped. If no one is
476 // listening nor any recent handles were dropped we can clean up the session.
477 let receiver_count = external_metadata_tx.receiver_count();
478 let last_drop = inner.last_dropped_session(&password);
479 let recent_drop = last_drop
480 .map(|dropped_at| dropped_at.elapsed() <= drop_window)
481 .unwrap_or(false);
482 if receiver_count == 0 && !recent_drop {
483 tracing::debug!(
484 ?last_drop,
485 ?password.client_id,
486 "all listeners have dropped and none of them were recent!"
487 );
488 break;
489 }
490
491 let outstanding_receivers = bool_as_str(receiver_count > 0);
492 inner
493 .metrics
494 .session_refresh_count
495 .with_label_values(&[outstanding_receivers, bool_as_str(recent_drop)])
496 .inc();
497 tracing::debug!(
498 receiver_count,
499 ?last_drop,
500 ?password.client_id,
501 "refreshing due to interest in the session"
502 );
503
504 // We still have interest, attempt to refresh the session.
505 let res = inner.exchange_app_password(&expected_user, password).await;
506 claims = match res {
507 Ok(claims) => {
508 tracing::debug!("refresh successful");
509 claims
510 }
511 Err(e) => {
512 tracing::warn!(error = ?e, "refresh failed");
513 break;
514 }
515 };
516 external_metadata_tx.send_replace(ExternalUserMetadata {
517 admin: claims.is_admin,
518 user_id: claims.user_id,
519 });
520 }
521
522 // The session has expired. Clean up the state.
523 {
524 let mut sessions = inner.active_sessions.lock().expect("lock poisoned");
525 sessions.remove(&password);
526 }
527 {
528 let mut dropped_session = inner.dropped_sessions.lock().expect("lock poisoned");
529 dropped_session.pop(&password);
530 }
531
532 tracing::debug!(?password.client_id, "shutting down refresh task");
533 inner.metrics.refresh_tasks_active.dec();
534 }
535 });
536
537 // Return handle to session.
538 Ok(AuthSessionHandle {
539 ident,
540 external_metadata_rx,
541 authenticator: Arc::clone(self),
542 app_password: password,
543 })
544 }
545
546 #[instrument]
547 async fn exchange_app_password(
548 &self,
549 expected_user: &str,
550 password: AppPassword,
551 ) -> Result<ValidatedClaims, Error> {
552 let req = ApiTokenArgs {
553 client_id: password.client_id,
554 secret: password.secret_key,
555 };
556 let res = self
557 .client
558 .exchange_client_secret_for_token(req, &self.admin_api_token_url, &self.metrics)
559 .await?;
560 self.validate_access_token(&res.access_token, Some(expected_user))
561 }
562
563 fn validate_access_token(
564 &self,
565 token: &str,
566 expected_user: Option<&str>,
567 ) -> Result<ValidatedClaims, Error> {
568 let msg = jsonwebtoken::decode::<Claims>(token, &self.decoding_key, &self.validation)?;
569 if msg.claims.exp < self.now.as_secs() {
570 return Err(Error::TokenExpired);
571 }
572 if let Some(expected_tenant_id) = self.tenant_id {
573 if msg.claims.tenant_id != expected_tenant_id {
574 return Err(Error::UnauthorizedTenant);
575 }
576 }
577
578 let user = msg.claims.user()?;
579
580 if let Some(expected_user) = expected_user {
581 validate_user(user, expected_user)?;
582 }
583
584 Ok(ValidatedClaims {
585 exp: msg.claims.exp,
586 user: user.to_string(),
587 user_id: msg.claims.user_id()?,
588 tenant_id: msg.claims.tenant_id,
589 // The user is an administrator if they have the admin role that the
590 // `Authenticator` has been configured with.
591 is_admin: msg.claims.roles.contains(&self.admin_role),
592 _private: (),
593 })
594 }
595
596 /// Records an [`AuthSessionHandle`] that was recently dropped.
597 fn record_dropped_session(&self, app_password: AppPassword) {
598 let now = Instant::now();
599 let Ok(mut dropped_sessions) = self.dropped_sessions.lock() else {
600 return;
601 };
602 dropped_sessions.push(app_password, now);
603 }
604
605 /// Returns the instant that an [`AuthSessionHandle`] created with the provided [`AppPassword`]
606 /// was last dropped.
607 fn last_dropped_session(&self, app_password: &AppPassword) -> Option<Instant> {
608 let Ok(dropped_sessions) = self.dropped_sessions.lock() else {
609 return None;
610 };
611 dropped_sessions.peek(app_password).copied()
612 }
613}
614
615type AuthFuture = dyn Future<Output = Result<AuthSessionHandle, Error>> + Send;
616
617#[derive(Derivative)]
618#[derivative(Debug)]
619enum AuthSession {
620 Pending(Shared<Pin<Box<AuthFuture>>>),
621 Active {
622 ident: Arc<AuthSessionIdent>,
623 external_metadata_tx: Arc<watch::Sender<ExternalUserMetadata>>,
624 },
625}
626
627#[derive(Debug)]
628struct AuthSessionIdent {
629 user: String,
630 tenant_id: Uuid,
631}
632
633/// The type of a JWT issued by Frontegg.
634#[derive(Clone, Debug, Serialize, Deserialize)]
635#[serde(rename_all = "camelCase")]
636pub enum ClaimTokenType {
637 /// A user token.
638 ///
639 /// This type of token is issued when logging in via username and password
640 /// This does *not* include app passwords--those are API tokens under the
641 /// hood. This type of token is typically only used by the Materialize
642 /// console, as it requires SSO.
643 UserToken,
644 /// A user API token.
645 UserApiToken,
646 /// A tenant API token.
647 TenantApiToken,
648}
649
650/// Metadata embedded in a Frontegg JWT.
651#[derive(Clone, Debug, Serialize, Deserialize)]
652#[serde(rename_all = "camelCase")]
653pub struct ClaimMetadata {
654 /// The user name to use, for tokens of type `TenantApiToken`.
655 pub user: Option<String>,
656}
657
658/// The raw claims encoded in a Frontegg access token.
659///
660/// Consult the JSON Web Token specification and the Frontegg documentation to
661/// determine the precise semantics of these fields.
662#[derive(Clone, Debug, Serialize, Deserialize)]
663#[serde(rename_all = "camelCase")]
664pub struct Claims {
665 /// The "subject" of the token.
666 ///
667 /// For tokens of type `UserToken`, this is the ID of the Frontegg user
668 /// itself. For tokens of type `UserApiToken` and `TenantApiToken`, this
669 /// is the client ID of the API token.
670 pub sub: Uuid,
671 /// The time at which the claims expire, represented in seconds since the
672 /// Unix epoch.
673 pub exp: i64,
674 /// The "issuer" of the token.
675 ///
676 /// This is always the domain associated with the Frontegg workspace.
677 pub iss: String,
678 /// The type of API token.
679 #[serde(rename = "type")]
680 pub token_type: ClaimTokenType,
681 /// For tokens of type `UserToken` and `UserApiToken`, the email address
682 /// of the authenticated user.
683 pub email: Option<String>,
684 /// For tokens of type `UserApiToken`, the ID of the authenticated user.
685 pub user_id: Option<Uuid>,
686 /// The ID of the authenticated tenant.
687 pub tenant_id: Uuid,
688 /// The IDs of the roles granted by the token.
689 pub roles: Vec<String>,
690 /// The IDs of the permissions granted by the token.
691 pub permissions: Vec<String>,
692 /// Metadata embedded in the JWT.
693 pub metadata: Option<ClaimMetadata>,
694}
695
696impl Claims {
697 /// Returns the name of the user associated with the token.
698 pub fn user(&self) -> Result<&str, Error> {
699 match self.token_type {
700 // Use the email as the username for user tokens.
701 ClaimTokenType::UserToken | ClaimTokenType::UserApiToken => {
702 self.email.as_deref().ok_or(Error::MissingClaims)
703 }
704 // The user associated with a tenant API token is configured when
705 // the token is created and passed in the `metadata.user` claim.
706 ClaimTokenType::TenantApiToken => {
707 let user = self
708 .metadata
709 .as_ref()
710 .and_then(|m| m.user.as_deref())
711 .ok_or(Error::MissingClaims)?;
712 if is_email(user) {
713 return Err(Error::InvalidTenantApiTokenUser);
714 }
715 Ok(user)
716 }
717 }
718 }
719
720 /// Returns the ID of the user associated with the token.
721 pub fn user_id(&self) -> Result<Uuid, Error> {
722 match self.token_type {
723 // The `sub` claim stores the ID of the user.
724 ClaimTokenType::UserToken => Ok(self.sub),
725 // Unlike user tokens, the `sub` claim stores the client ID of the
726 // API token. The user ID is passed in the dedicated `user_id`
727 // claim.
728 ClaimTokenType::UserApiToken => self.user_id.ok_or(Error::MissingClaims),
729 // The best user ID for a tenant API token is the client ID of the
730 // tenant API token, as the tokens are not associated with a
731 // Frontegg user.
732 ClaimTokenType::TenantApiToken => Ok(self.sub),
733 }
734 }
735}
736
737/// [`Claims`] that have been validated by
738/// [`Authenticator::validate_access_token`].
739#[derive(Clone, Debug)]
740pub struct ValidatedClaims {
741 /// The time at which the claims expire, represented in seconds since the
742 /// Unix epoch.
743 pub exp: i64,
744 /// The ID of the authenticated user.
745 pub user_id: Uuid,
746 /// The name of the authenticated user.
747 ///
748 /// For tokens of type `UserToken` or `UserApiToken`, this is the email
749 /// address of the authenticated user. For tokens of type `TenantApiToken`,
750 /// this is the `serviceUser` field in the token's metadata.
751 pub user: String,
752 /// The ID of the tenant the user is authenticated for.
753 pub tenant_id: Uuid,
754 /// Whether the authenticated user is an administrator.
755 pub is_admin: bool,
756 // Prevent construction outside of `Authenticator::validate_access_token`.
757 _private: (),
758}
759
760impl ValidatedClaims {
761 /// Constructs an [`ExternalUserMetadata`] from the claims data.
762 fn to_external_user_metadata(&self) -> ExternalUserMetadata {
763 ExternalUserMetadata {
764 admin: self.is_admin,
765 user_id: self.user_id,
766 }
767 }
768}
769
770/// Reports whether a username is an email address.
771fn is_email(user: &str) -> bool {
772 // We don't need a sophisticated test here. We need a test that will return
773 // `true` for anything that can possibly be an email address, while also
774 // returning `false` for a large class of strings that can be used as names
775 // for service users.
776 //
777 // Checking for `@` balances the concerns. Every email address MUST have an
778 // `@` character. Disallowing `@` characters in service user names is an
779 // acceptable restriction.
780 user.contains('@')
781}
782
783fn validate_user(user: &str, expected_user: &str) -> Result<(), Error> {
784 // Impose a maximum length on user names for sanity.
785 if user.len() > MAX_USER_NAME_LENGTH {
786 return Err(Error::UserNameTooLong);
787 }
788
789 let valid = match is_email(expected_user) {
790 false => user == expected_user,
791 // To match Frontegg, email addresses are compared case insensitively.
792 //
793 // NOTE(benesch): we could save some allocations by using `unicase::eq`
794 // here, but the `unicase` crate has had some critical correctness bugs that
795 // make it scary to use in such security-sensitive code.
796 //
797 // See: https://github.com/seanmonstar/unicase/pull/39
798 true => user.to_lowercase() == expected_user.to_lowercase(),
799 };
800 match valid {
801 false => Err(Error::WrongUser),
802 true => Ok(()),
803 }
804}
805
806const fn bool_as_str(x: bool) -> &'static str {
807 if x { "true" } else { "false" }
808}