Skip to main content

mz_frontegg_client/
client.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
10//! # Frontegg API client
11//!
12//! The `client` module provides an API client with typed methods for
13//! interacting with the Frontegg API. This client implements authentication,
14//! token management, and basic requests against the API.
15//!
16//! The [`Client`] requires an [`AppPassword`] as a parameter. The
17//! app password is used to manage an access token. _Manage_ means issuing a new
18//! access token or refreshing when half of its lifetime has passed.
19//!
20//! [`AppPassword`]: mz_frontegg_auth::AppPassword
21
22use std::time::{Duration, SystemTime};
23
24use jsonwebtoken::jwk::JwkSet;
25use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode};
26use mz_frontegg_auth::{AppPassword, Claims};
27use reqwest::{Method, RequestBuilder};
28use serde::de::DeserializeOwned;
29use serde::{Deserialize, Serialize};
30use tokio::sync::Mutex;
31use url::Url;
32
33use crate::config::{ClientBuilder, ClientConfig};
34use crate::error::{ApiError, Error};
35
36pub mod app_password;
37pub mod role;
38pub mod user;
39
40const CREDENTIALS_AUTH_PATH: [&str; 5] = ["identity", "resources", "auth", "v1", "user"];
41const APP_PASSWORD_AUTH_PATH: [&str; 5] = ["identity", "resources", "auth", "v1", "api-token"];
42
43const REFRESH_AUTH_PATH: [&str; 7] = [
44    "identity",
45    "resources",
46    "auth",
47    "v1",
48    "api-token",
49    "token",
50    "refresh",
51];
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54#[serde(rename_all = "camelCase")]
55struct AuthenticationResponse {
56    access_token: String,
57    expires: String,
58    expires_in: i64,
59    refresh_token: String,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63#[serde(rename_all = "camelCase")]
64struct CredentialsAuthenticationRequest<'a> {
65    email: &'a str,
66    password: &'a str,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize)]
70#[serde(rename_all = "camelCase")]
71struct AppPasswordAuthenticationRequest<'a> {
72    client_id: &'a str,
73    secret: &'a str,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78struct RefreshRequest<'a> {
79    refresh_token: &'a str,
80}
81
82#[derive(Debug, Clone)]
83pub(crate) struct Auth {
84    token: String,
85    /// The time after which the token must be refreshed.
86    refresh_at: SystemTime,
87    refresh_token: String,
88}
89
90/// Representation of the two possible ways to authenticate a user.
91///
92/// The usage of [Authentication::AppPassword] is more favorable
93/// and recommended. App-passwords can be created for a particular
94/// usage and be removed at any time. While the user credentials
95/// may have a broader impact.
96pub enum Authentication {
97    /// Legitimate email and password that will remain in use to identify
98    /// the user throughout the client's existence.
99    Credentials(Credentials),
100    /// A singular, legitimate app password that will remain in use to identify
101    /// the user throughout the client's existence.
102    AppPassword(AppPassword),
103    /// An access token obtained elsewhere, such as from an OAuth token
104    /// endpoint. It is sent as is and never refreshed, so requests fail once it
105    /// expires.
106    AccessToken(String),
107}
108
109/// Representation of a user's credentials.
110/// They are the same as a user needs to
111/// log in to Materialize's console.
112pub struct Credentials {
113    /// User's email to authenticate with Materialize console
114    pub email: String,
115    /// User's password to authenticate with Materialize console
116    pub password: String,
117}
118
119/// An API client for Frontegg.
120///
121/// The API client is designed to be wrapped in an [`Arc`] and used from
122/// multiple threads simultaneously. A successful authentication response is
123/// shared by all threads.
124///
125/// [`Arc`]: std::sync::Arc
126pub struct Client {
127    pub(crate) inner: reqwest::Client,
128    pub(crate) authentication: Authentication,
129    pub(crate) endpoint: Url,
130    pub(crate) auth: Mutex<Option<Auth>>,
131}
132
133impl Client {
134    /// Creates a new `Client` from its required configuration parameters.
135    pub fn new(config: ClientConfig) -> Client {
136        ClientBuilder::default().build(config)
137    }
138
139    /// Creates a builder for a `Client` that allows for customization of
140    /// optional parameters.
141    pub fn builder() -> ClientBuilder {
142        ClientBuilder::default()
143    }
144
145    /// Builds a request towards the `Client`'s endpoint
146    fn build_request<P>(&self, method: Method, path: P) -> RequestBuilder
147    where
148        P: IntoIterator,
149        P::Item: AsRef<str>,
150    {
151        let mut url = self.endpoint.clone();
152        url.path_segments_mut()
153            .expect("builder validated URL can be a base")
154            .clear()
155            .extend(path);
156        self.inner.request(method, url)
157    }
158
159    /// Sends a requests and adds the authorization bearer token.
160    async fn send_request<T>(&self, req: RequestBuilder) -> Result<T, Error>
161    where
162        T: DeserializeOwned,
163    {
164        let token = self.auth().await?;
165        let req = req.bearer_auth(token);
166        self.send_unauthenticated_request(req).await
167    }
168
169    async fn send_unauthenticated_request<T>(&self, req: RequestBuilder) -> Result<T, Error>
170    where
171        T: DeserializeOwned,
172    {
173        #[derive(Deserialize)]
174        #[serde(rename_all = "camelCase")]
175        struct ErrorResponse {
176            #[serde(default)]
177            message: Option<String>,
178            #[serde(default)]
179            errors: Vec<String>,
180        }
181
182        let res = req.send().await?;
183        let status_code = res.status();
184        if status_code.is_success() {
185            Ok(res.json().await?)
186        } else {
187            match res.json::<ErrorResponse>().await {
188                Ok(e) => {
189                    let mut messages = e.errors;
190                    messages.extend(e.message);
191                    Err(Error::Api(ApiError {
192                        status_code,
193                        messages,
194                    }))
195                }
196                Err(_) => Err(Error::Api(ApiError {
197                    status_code,
198                    messages: vec!["unable to decode error details".into()],
199                })),
200            }
201        }
202    }
203
204    /// Authenticates with the server, if not already authenticated,
205    /// and returns the authentication token.
206    pub async fn auth(&self) -> Result<String, Error> {
207        if let Authentication::AccessToken(token) = &self.authentication {
208            return Ok(token.clone());
209        }
210
211        let mut auth = self.auth.lock().await;
212        let mut req;
213
214        match &*auth {
215            Some(auth) => {
216                if SystemTime::now() < auth.refresh_at {
217                    return Ok(auth.token.clone());
218                } else {
219                    // Auth is available in the client but needs a refresh request.
220                    req = self.build_request(Method::POST, REFRESH_AUTH_PATH);
221                    let refresh_request = RefreshRequest {
222                        refresh_token: auth.refresh_token.as_str(),
223                    };
224                    req = req.json(&refresh_request);
225                }
226            }
227            None => {
228                match &self.authentication {
229                    Authentication::Credentials(credentials) => {
230                        // No auth available in the client, request a new one.
231                        req = self.build_request(Method::POST, CREDENTIALS_AUTH_PATH);
232
233                        let authentication_request = CredentialsAuthenticationRequest {
234                            email: &credentials.email,
235                            password: &credentials.password,
236                        };
237                        req = req.json(&authentication_request);
238                    }
239                    Authentication::AppPassword(app_password) => {
240                        req = self.build_request(Method::POST, APP_PASSWORD_AUTH_PATH);
241
242                        let authentication_request = AppPasswordAuthenticationRequest {
243                            client_id: &app_password.client_id.to_string(),
244                            secret: &app_password.secret_key.to_string(),
245                        };
246                        req = req.json(&authentication_request);
247                    }
248                    Authentication::AccessToken(_) => unreachable!("returned above"),
249                }
250            }
251        }
252
253        // Do the request.
254        let res: AuthenticationResponse = self.send_unauthenticated_request(req).await?;
255
256        *auth = Some(Auth {
257            token: res.access_token.clone(),
258            // Refresh twice as frequently as we need to, to be safe.
259            refresh_at: SystemTime::now()
260                + (Duration::from_secs(res.expires_in.try_into().unwrap()) / 2),
261            refresh_token: res.refresh_token,
262        });
263        Ok(res.access_token)
264    }
265
266    /// Returns the JSON Web Key Set (JWKS) from the well known endpoint: `/.well-known/jwks.json`
267    async fn get_jwks(&self) -> Result<JwkSet, Error> {
268        let well_known = vec![".well-known", "jwks.json"];
269        let req = self.build_request(Method::GET, well_known);
270        let jwks: JwkSet = self.send_request(req).await?;
271        Ok(jwks)
272    }
273
274    /// Verifies the JWT signature using a JWK from the well-known endpoint and
275    /// returns the user claims.
276    pub async fn claims(&self) -> Result<Claims, Error> {
277        let jwks = self.get_jwks().await.map_err(|_| Error::FetchingJwks)?;
278        let jwk = jwks.keys.first().ok_or_else(|| Error::EmptyJwks)?;
279        let token = self.auth().await?;
280
281        let mut validation = Validation::new(Algorithm::RS256);
282
283        // We don't validate the audience because:
284        //
285        //   1. We don't have easy access to the expected audience ID here.
286        //
287        //   2. There is no meaningful security improvement to doing so, because
288        //      Frontegg always sets the audience to the ID of the workspace
289        //      that issued the token. Since we only trust the signing keys from
290        //      a single Frontegg workspace, the audience is redundant.
291        //
292        // For details, see this conversation [0] from the Materialize–Frontegg
293        // shared Slack channel on 1 January 2024.
294        //
295        // [0]: https://materializeinc.slack.com/archives/C02940WNMRQ/p1704131331041669
296        validation.validate_aud = false;
297
298        let token_data = decode::<Claims>(
299            &token,
300            &DecodingKey::from_jwk(jwk).map_err(|_| Error::ConvertingJwks)?,
301            &validation,
302        )
303        .map_err(Error::DecodingClaims)?;
304
305        Ok(token_data.claims)
306    }
307}