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}