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}
104
105/// Representation of a user's credentials.
106/// They are the same as a user needs to
107/// log in to Materialize's console.
108pub struct Credentials {
109    /// User's email to authenticate with Materialize console
110    pub email: String,
111    /// User's password to authenticate with Materialize console
112    pub password: String,
113}
114
115/// An API client for Frontegg.
116///
117/// The API client is designed to be wrapped in an [`Arc`] and used from
118/// multiple threads simultaneously. A successful authentication response is
119/// shared by all threads.
120///
121/// [`Arc`]: std::sync::Arc
122pub struct Client {
123    pub(crate) inner: reqwest::Client,
124    pub(crate) authentication: Authentication,
125    pub(crate) endpoint: Url,
126    pub(crate) auth: Mutex<Option<Auth>>,
127}
128
129impl Client {
130    /// Creates a new `Client` from its required configuration parameters.
131    pub fn new(config: ClientConfig) -> Client {
132        ClientBuilder::default().build(config)
133    }
134
135    /// Creates a builder for a `Client` that allows for customization of
136    /// optional parameters.
137    pub fn builder() -> ClientBuilder {
138        ClientBuilder::default()
139    }
140
141    /// Builds a request towards the `Client`'s endpoint
142    fn build_request<P>(&self, method: Method, path: P) -> RequestBuilder
143    where
144        P: IntoIterator,
145        P::Item: AsRef<str>,
146    {
147        let mut url = self.endpoint.clone();
148        url.path_segments_mut()
149            .expect("builder validated URL can be a base")
150            .clear()
151            .extend(path);
152        self.inner.request(method, url)
153    }
154
155    /// Sends a requests and adds the authorization bearer token.
156    async fn send_request<T>(&self, req: RequestBuilder) -> Result<T, Error>
157    where
158        T: DeserializeOwned,
159    {
160        let token = self.auth().await?;
161        let req = req.bearer_auth(token);
162        self.send_unauthenticated_request(req).await
163    }
164
165    async fn send_unauthenticated_request<T>(&self, req: RequestBuilder) -> Result<T, Error>
166    where
167        T: DeserializeOwned,
168    {
169        #[derive(Deserialize)]
170        #[serde(rename_all = "camelCase")]
171        struct ErrorResponse {
172            #[serde(default)]
173            message: Option<String>,
174            #[serde(default)]
175            errors: Vec<String>,
176        }
177
178        let res = req.send().await?;
179        let status_code = res.status();
180        if status_code.is_success() {
181            Ok(res.json().await?)
182        } else {
183            match res.json::<ErrorResponse>().await {
184                Ok(e) => {
185                    let mut messages = e.errors;
186                    messages.extend(e.message);
187                    Err(Error::Api(ApiError {
188                        status_code,
189                        messages,
190                    }))
191                }
192                Err(_) => Err(Error::Api(ApiError {
193                    status_code,
194                    messages: vec!["unable to decode error details".into()],
195                })),
196            }
197        }
198    }
199
200    /// Authenticates with the server, if not already authenticated,
201    /// and returns the authentication token.
202    pub async fn auth(&self) -> Result<String, Error> {
203        let mut auth = self.auth.lock().await;
204        let mut req;
205
206        match &*auth {
207            Some(auth) => {
208                if SystemTime::now() < auth.refresh_at {
209                    return Ok(auth.token.clone());
210                } else {
211                    // Auth is available in the client but needs a refresh request.
212                    req = self.build_request(Method::POST, REFRESH_AUTH_PATH);
213                    let refresh_request = RefreshRequest {
214                        refresh_token: auth.refresh_token.as_str(),
215                    };
216                    req = req.json(&refresh_request);
217                }
218            }
219            None => {
220                match &self.authentication {
221                    Authentication::Credentials(credentials) => {
222                        // No auth available in the client, request a new one.
223                        req = self.build_request(Method::POST, CREDENTIALS_AUTH_PATH);
224
225                        let authentication_request = CredentialsAuthenticationRequest {
226                            email: &credentials.email,
227                            password: &credentials.password,
228                        };
229                        req = req.json(&authentication_request);
230                    }
231                    Authentication::AppPassword(app_password) => {
232                        req = self.build_request(Method::POST, APP_PASSWORD_AUTH_PATH);
233
234                        let authentication_request = AppPasswordAuthenticationRequest {
235                            client_id: &app_password.client_id.to_string(),
236                            secret: &app_password.secret_key.to_string(),
237                        };
238                        req = req.json(&authentication_request);
239                    }
240                }
241            }
242        }
243
244        // Do the request.
245        let res: AuthenticationResponse = self.send_unauthenticated_request(req).await?;
246
247        *auth = Some(Auth {
248            token: res.access_token.clone(),
249            // Refresh twice as frequently as we need to, to be safe.
250            refresh_at: SystemTime::now()
251                + (Duration::from_secs(res.expires_in.try_into().unwrap()) / 2),
252            refresh_token: res.refresh_token,
253        });
254        Ok(res.access_token)
255    }
256
257    /// Returns the JSON Web Key Set (JWKS) from the well known endpoint: `/.well-known/jwks.json`
258    async fn get_jwks(&self) -> Result<JwkSet, Error> {
259        let well_known = vec![".well-known", "jwks.json"];
260        let req = self.build_request(Method::GET, well_known);
261        let jwks: JwkSet = self.send_request(req).await?;
262        Ok(jwks)
263    }
264
265    /// Verifies the JWT signature using a JWK from the well-known endpoint and
266    /// returns the user claims.
267    pub async fn claims(&self) -> Result<Claims, Error> {
268        let jwks = self.get_jwks().await.map_err(|_| Error::FetchingJwks)?;
269        let jwk = jwks.keys.first().ok_or_else(|| Error::EmptyJwks)?;
270        let token = self.auth().await?;
271
272        let mut validation = Validation::new(Algorithm::RS256);
273
274        // We don't validate the audience because:
275        //
276        //   1. We don't have easy access to the expected audience ID here.
277        //
278        //   2. There is no meaningful security improvement to doing so, because
279        //      Frontegg always sets the audience to the ID of the workspace
280        //      that issued the token. Since we only trust the signing keys from
281        //      a single Frontegg workspace, the audience is redundant.
282        //
283        // For details, see this conversation [0] from the Materialize–Frontegg
284        // shared Slack channel on 1 January 2024.
285        //
286        // [0]: https://materializeinc.slack.com/archives/C02940WNMRQ/p1704131331041669
287        validation.validate_aud = false;
288
289        let token_data = decode::<Claims>(
290            &token,
291            &DecodingKey::from_jwk(jwk).map_err(|_| Error::ConvertingJwks)?,
292            &validation,
293        )
294        .map_err(Error::DecodingClaims)?;
295
296        Ok(token_data.claims)
297    }
298}