loco_oauth2/grants/
authorization_code.rs

1use std::{collections::HashMap, time::Instant};
2
3use crate::error::{OAuth2ClientError, OAuth2ClientResult};
4use async_trait::async_trait;
5use oauth2::basic::{
6    BasicErrorResponse, BasicRevocationErrorResponse, BasicTokenIntrospectionResponse,
7};
8use oauth2::{
9    basic::{BasicClient, BasicTokenResponse},
10    url,
11    url::Url,
12    AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointMaybeSet,
13    EndpointNotSet, EndpointSet, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, Scope,
14    StandardRevocableToken, TokenResponse, TokenUrl,
15};
16use reqwest::Response;
17use serde::{Deserialize, Serialize};
18use subtle::ConstantTimeEq;
19
20/// A credentials struct that holds the `OAuth2` client credentials. - For
21/// [`Client`]
22#[derive(Debug, Clone, Deserialize, Serialize)]
23pub struct Credentials {
24    pub client_id: String,
25    pub client_secret: String,
26}
27
28/// A url config struct that holds the `OAuth2` client related URLs. - For
29/// [`Client`]
30#[derive(Debug, Clone, Deserialize, Serialize)]
31pub struct UrlConfig {
32    pub auth_url: String,
33    pub token_url: String,
34    pub redirect_url: String,
35    pub profile_url: String,
36    pub scopes: Vec<String>,
37}
38
39/// An url config struct that holds the Cookie related URLs. - For
40/// [`Client`]
41#[derive(Debug, Clone, Deserialize, Serialize)]
42pub struct CookieConfig {
43    pub protected_url: Option<String>,
44}
45
46/// [`Client`] that acts as a client for the Authorization Code
47/// Grant flow.
48pub struct Client {
49    /// [`BasicClient`] instance for the `OAuth2` client.
50    pub oauth2: oauth2::Client<
51        BasicErrorResponse,
52        BasicTokenResponse,
53        BasicTokenIntrospectionResponse,
54        StandardRevocableToken,
55        BasicRevocationErrorResponse,
56        EndpointSet,
57        EndpointNotSet,
58        EndpointNotSet,
59        EndpointNotSet,
60        EndpointMaybeSet,
61    >,
62    /// [`Url`] instance for the `OAuth2` client's profile URL.
63    pub profile_url: url::Url,
64    /// [`reqwest::Client`] instance for the `OAuth2` client's HTTP client.
65    pub http_client: reqwest::Client,
66    /// A flow states hashMap <CSRF Token, (PKCE Code Verifier, Created time)>
67    /// for managing the expiration of the CSRF tokens and PKCE code verifiers.
68    pub flow_states: HashMap<String, (PkceCodeVerifier, Instant)>,
69    /// A vector of [`Scope`] for the getting the user's profile.
70    pub scopes: Vec<Scope>,
71    /// A [`std::time::Duration`] for the `OAuth2` client's CSRF token timeout
72    /// which defaults to 10 minutes (600s).
73    pub csrf_token_timeout: std::time::Duration,
74    /// An optional [`CookieConfig`] for the `OAuth2` client's
75    /// cookie during middleware
76    pub cookie_config: CookieConfig,
77}
78
79impl Client {
80    /// Create a new instance of [`OAuth2Client`].
81    /// # Arguments
82    /// * `credentials` - A [`Credentials`] struct that holds
83    ///   the `OAuth2` client credentials.
84    /// * `config` - A [`UrlConfig`] struct that holds the
85    ///   `OAuth2` client related URLs.
86    /// * `timeout_seconds` - An optional timeout in seconds for the csrf token.
87    ///   Defaults to 10 minutes (600s).
88    /// # Returns
89    /// A [`Client`] instance
90    /// # Errors
91    /// [`OAuth2ClientError::UrlError`] if the `auth_url`, `token_url`,
92    /// `redirect_url` or `profile_url` is invalid.
93    ///
94    /// # Example
95    /// ```rust,ignore
96    /// let credentials = AuthorizationCodeCredentials {
97    ///    client_id: "test_client_id".to_string(),
98    ///   client_secret: Some("test_client_secret".to_string()),
99    /// };
100    /// let config = AuthorizationCodeUrlConfig {
101    ///     auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
102    ///     token_url: Some("https://www.googleapis.com/oauth2/v3/token".to_string()),
103    ///     redirect_url: "http://localhost:8000/api/auth/google_callback".to_string(),
104    ///     profile_url: "https://openidconnect.googleapis.com/v1/userinfo".to_string(),
105    ///     scopes: vec!["https://www.googleapis.com/auth/userinfo.email".to_string()],
106    /// };
107    /// let client = AuthorizationCodeClient::new(credentials, config, None)?;
108    /// ```
109    pub fn new(
110        credentials: Credentials,
111        config: UrlConfig,
112        cookie_config: CookieConfig,
113        timeout_seconds: Option<u64>,
114    ) -> OAuth2ClientResult<Self> {
115        let client_id = ClientId::new(credentials.client_id);
116        let client_secret = ClientSecret::new(credentials.client_secret);
117        let auth_url = AuthUrl::new(config.auth_url)?;
118        let token_url = Some(TokenUrl::new(config.token_url)?);
119        let redirect_url = RedirectUrl::new(config.redirect_url)?;
120        let oauth2 = BasicClient::new(client_id)
121            .set_client_secret(client_secret)
122            .set_auth_uri(auth_url)
123            .set_token_uri_option(token_url)
124            .set_redirect_uri(redirect_url);
125        let profile_url = url::Url::parse(&config.profile_url)?;
126        let scopes = config
127            .scopes
128            .iter()
129            .map(|scope| Scope::new(scope.to_owned()))
130            .collect();
131        Ok(Self {
132            oauth2,
133            profile_url,
134            http_client: reqwest::Client::new(),
135            flow_states: HashMap::new(),
136            scopes,
137            csrf_token_timeout: std::time::Duration::from_secs(timeout_seconds.unwrap_or(10 * 60)),
138            cookie_config,
139        })
140    }
141    /// Remove expired flow states within the [`Client`].
142    /// # Example
143    /// ```rust,ignore
144    /// client.remove_expire_flow(); // Clear outdated states within client.flow_states
145    /// ```
146    fn remove_expire_flow(&mut self) {
147        // Remove expired tokens
148        self.flow_states
149            .retain(|_, (_, created_at)| created_at.elapsed() < self.csrf_token_timeout);
150    }
151    /// Compare two strings in constant time to prevent timing attacks.
152    /// # Arguments
153    /// * `a` - A string to compare.
154    /// * `b` - A string to compare.
155    /// # Returns
156    /// A boolean value indicating if the strings are equal.
157    /// # Example
158    /// ```rust,ignore
159    /// AuthorizationCodeClient::constant_time_compare("test", "test"); // true
160    /// AuthorizationCodeClient::constant_time_compare("test", "test1"); // false
161    /// ```
162    fn constant_time_compare(a: &str, b: &str) -> bool {
163        // Convert the strings to bytes for comparison.
164        a.as_bytes().ct_eq(b.as_bytes()).into()
165    }
166}
167
168#[async_trait]
169pub trait GrantTrait: Send + Sync {
170    /// Get authorization code client
171    /// # Returns
172    /// A mutable reference to the [`Client`] instance.
173    fn get_authorization_code_client(&mut self) -> &mut Client;
174
175    /// Get `AuthorizationCodeCookieConfig` instance
176    /// # Returns
177    /// A reference to the `AuthorizationCodeCookieConfig` instance.
178    fn get_cookie_config(&self) -> &CookieConfig;
179
180    /// Get authorization URL
181    /// # Returns
182    /// A tuple containing the authorization URL and the CSRF token.
183    /// [`Url`] is used to redirect the user to the `OAuth2`
184    /// provider's login page.
185    /// [`CsrfToken`] is used to verify the user
186    /// when they return to the application. Needs to be stored in the session
187    /// or other temporary storage.
188    /// # Example
189    /// ```rust,ignore
190    /// use oauth2::CsrfToken;
191    /// use oauth2::url::Url;
192    /// use oauth2::basic::BasicClient;
193    /// use oauth2::reqwest::async_http_client;
194    ///
195    ///  // Create a new instance of session store - from tower-sessions
196    ///  let session_store = MemoryStore::default();
197    ///  // Create a new instance of `SessionManagerLayer` with the session store for axum layer
198    ///  let session_layer = SessionManagerLayer::new(session_store)
199    ///     // This is needed because the oauth2 client callback request is coming from a different domain, but be careful with this in production since it can be a security risk.
200    ///     .with_same_site(SameSite::Lax);
201    ///  // Create a new instance of `OAuth2ClientStore` with the `AuthorizationCodeClient` instance
202    ///  let client = AuthorizationCodeClient::new();
203    ///  let mut clients = BTreeMap::new();
204    ///  clients.insert(
205    ///    "google".to_string(),
206    ///    OAuth2ClientGrantEnum::AuthorizationCode(Arc::new(Mutex::new(authorization_code_client))),
207    ///  );
208    ///  let mut client_store = OAuth2ClientStore::new(clients);
209    ///  let app = Router::new().route("/auth/google", get(get_auth_url)).layer(Extension(Arc::new(client_store))).layer(session_layer);
210    ///
211    ///  pub async fn get_auth_url(Extension(session_store): Extension<Session>, Extension(oauth_client_store): Extension<Arc<OAuth2ClientStore>>,) -> Url {
212    ///     let client = oauth_client_store.get("google").unwrap();
213    ///     // Get the authorization URL and the CSRF token
214    ///     let (auth_url, csrf_token) = client.get_authorization_url();
215    ///
216    ///     // Save the CSRF token in the session store
217    ///     session_store
218    ///         .insert("csrf_token", saved_csrf_token)
219    ///         .await
220    ///         .unwrap();
221    ///     // redirect the user to the authorization URL
222    ///     Ok(auth_url)
223    /// }
224    /// ```
225    fn get_authorization_url(&mut self) -> (Url, CsrfToken) {
226        let client = self.get_authorization_code_client();
227        // Clear outdated flow states
228        client.remove_expire_flow();
229
230        // Generate a PKCE challenge.
231        let (pkce_challenge, pkce_verifier) = PkceCodeChallenge::new_random_sha256();
232
233        let mut auth_request = client.oauth2.authorize_url(CsrfToken::new_random);
234        // Add scopes
235        for scope in &client.scopes {
236            auth_request = auth_request.add_scope(scope.clone());
237        }
238        // Generate the full authorization URL.
239        let (auth_url, csrf_token) = auth_request
240            // Set the PKCE code challenge.
241            .set_pkce_challenge(pkce_challenge)
242            .url();
243        // Store the CSRF token, PKCE Verifier and the time it was created.
244        client
245            .flow_states
246            .insert(csrf_token.secret().clone(), (pkce_verifier, Instant::now()));
247        (auth_url, csrf_token)
248    }
249    /// Verify code from the provider callback request after returns from the
250    /// `OAuth2` provider's login page.
251    /// # Arguments
252    /// * `code` - A string containing the code returned from the `OAuth2`
253    ///   provider callback request query.
254    /// * `state` - A string containing the state returned from the `OAuth2`
255    ///   provider response which extracted from the provider callback request
256    ///   query.
257    /// * `csrf_token` - A string containing the CSRF token saved in the
258    ///   temporary session after the
259    ///   [`Client::get_authorization_url`] method.
260    /// # Returns
261    /// A tuple containing the token response and the profile response.
262    /// [`BasicTokenResponse`] is the token response from the `OAuth2` provider.
263    /// [`Response`] is the profile response from the `OAuth2` provider which
264    /// describes the user's profile. This response json information will be
265    /// determined by [`Client::scopes`] # Errors
266    /// An [`OAuth2ClientError::CsrfTokenError`] if the csrf token is invalid.
267    /// An [`OAuth2ClientError::BasicTokenError`] if the token
268    /// exchange fails.
269    /// An [`OAuth2ClientError::ProfileError`] if the profile request fails.
270    /// # Example
271    /// ```rust,ignore
272    /// use std::collections::BTreeMap;
273    /// use std::sync::Arc;
274    /// use std::time::Duration;
275    /// use axum::{Extension, Router};
276    /// use axum::extract::Query;
277    /// use axum::response::Redirect;
278    /// use axum::routing::get;use oauth2::CsrfToken;
279    /// use oauth2::url::Url;
280    /// use oauth2::basic::BasicClient;
281    /// use oauth2::reqwest::async_http_client;
282    /// use serde::Deserialize;
283    /// use tokio::sync::Mutex;
284    /// use super::*;
285    ///
286    ///  // Create a new instance of session store - from tower-sessions
287    ///  let session_store = MemoryStore::default();
288    ///  // Create a new instance of `SessionManagerLayer` with the session store for axum layer
289    ///  let session_layer = SessionManagerLayer::new(session_store)
290    ///     // This is needed because the oauth2 client callback request is coming from a different domain, but be careful with this in production since it can be a security risk.
291    ///     .with_same_site(SameSite::Lax);
292    ///  // Create a new instance of `OAuth2ClientStore` with the `AuthorizationCodeClient` instance
293    ///  let client = AuthorizationCodeClient::new();
294    ///  let mut clients = BTreeMap::new();
295    ///  clients.insert(
296    ///    "google".to_string(),
297    ///    OAuth2ClientGrantEnum::AuthorizationCode(Arc::new(Mutex::new(authorization_code_client))),
298    ///  );
299    ///  let mut client_store = OAuth2ClientStore::new(clients);
300    ///  let app = Router::new().route("/auth/google_callback", get(google_callback)).layer(Extension(Arc::new(client_store))).layer(session_layer);
301    ///
302    ///  #[derive(Debug, Deserialize)]
303    ///  pub struct AuthRequest {
304    ///     code: String,
305    ///     state: String,
306    ///  }
307    ///  #[derive(Deserialize, Clone, Debug)]
308    ///  pub struct UserProfile {
309    ///     email: String,
310    ///  }
311    ///  pub async fn google_callback(Extension(session_store): Extension<Session>, Extension(oauth_client_store): Extension<Arc<OAuth2ClientStore>>, Query(query): Query<AuthRequest>, jar: PrivateCookieJar) -> String {
312    ///     // Get the previous stored csrf_token from the store
313    ///     let csrf_token = session_store.get::<String>("csrf_token").await.unwrap();
314    ///     // Get the client from the store
315    ///     let client = oauth_client_store.get("google").unwrap();
316    ///     // Get the token and profile from the client   
317    ///     let (token, profile) = client.verify_code_from_callback(query.code, query.state, csrf_token).await.unwrap();
318    ///     // Parse the user's profile
319    ///     let profile = profile.json::<UserProfile>().await.unwrap();
320    ///     let secs: i64 = token.access_token().expires_in().as_secs().try_into().unwrap();
321    ///     // Create a new user based on user's profile into your database
322    ///     // Create a cookie for the user's session
323    ///     let cookie = axum_extra::extract::cookie::Cookie::build(("sid", db_user_id))
324    ///                                 .domain("localhost")
325    ///                                 // only for testing purposes, toggle this to true in production
326    ///                                 .secure(false)
327    ///                                 .http_only(true)
328    ///                                 .max_age(Duration::seconds(secs));
329    ///      // Redirect the user to the protected route
330    ///      let jar = jar.add(cookie);
331    ///      Ok((jar, Redirect::to("/protected")))
332    /// }
333    ///     
334    async fn verify_code_from_callback(
335        &mut self,
336        code: String,
337        state: String,
338        csrf_token: String,
339    ) -> OAuth2ClientResult<(BasicTokenResponse, Response)> {
340        let client = self.get_authorization_code_client();
341        // Clear outdated flow states
342        client.remove_expire_flow();
343        // Compare csrf token, use subtle to prevent time attack
344        if !Client::constant_time_compare(&csrf_token, &state) {
345            return Err(OAuth2ClientError::CsrfTokenError);
346        }
347        // Get the pkce_verifier for exchanging code
348        let Some((pkce_verifier, _)) = client.flow_states.remove(&csrf_token) else {
349            return Err(OAuth2ClientError::CsrfTokenError);
350        };
351        // Exchange the code with a token
352        let token = client
353            .oauth2
354            .exchange_code(AuthorizationCode::new(code))?
355            .set_pkce_verifier(pkce_verifier)
356            .request_async(&oauth2::reqwest::Client::new())
357            .await?;
358        let profile = client
359            .http_client
360            .get(client.profile_url.clone())
361            .bearer_auth(token.access_token().secret().to_owned())
362            .send()
363            .await
364            .map_err(OAuth2ClientError::ProfileError)?;
365        Ok((token, profile))
366    }
367}
368
369impl GrantTrait for Client {
370    fn get_authorization_code_client(&mut self) -> &mut Client {
371        self
372    }
373    fn get_cookie_config(&self) -> &CookieConfig {
374        &self.cookie_config
375    }
376}
377
378#[cfg(test)]
379mod tests {
380    use oauth2::url::form_urlencoded;
381    use serde::{Deserialize, Serialize};
382    use wiremock::{
383        matchers::{basic_auth, bearer_token, body_string_contains, method, path},
384        Mock, MockServer, ResponseTemplate,
385    };
386
387    use super::*;
388
389    struct Settings {
390        client_id: String,
391        client_secret: String,
392        code: String,
393        auth_url: String,
394        token_url: String,
395        redirect_url: String,
396        profile_url: String,
397        scope: String,
398        exchange_mock_body: ExchangeMockBody,
399        profile_mock_body: UserProfile,
400        mock_server: MockServer,
401    }
402
403    #[derive(Deserialize, Serialize, Clone, Debug)]
404    struct ExchangeMockBody {
405        access_token: String,
406        token_type: String,
407        expires_in: u64,
408        refresh_token: String,
409    }
410
411    #[derive(Deserialize, Serialize, Clone, Debug)]
412    struct UserProfile {
413        email: String,
414    }
415
416    impl Settings {
417        async fn new() -> Self {
418            // Request a new server from the pool
419            let server = MockServer::start().await;
420
421            // Use one of these addresses to configure your client
422            let url = server.uri();
423            let exchange_mock_body = ExchangeMockBody {
424                access_token: "test_access_token".to_string(),
425                token_type: "bearer".to_string(),
426                expires_in: 3600,
427                refresh_token: "test_refresh_token".to_string(),
428            };
429            let user_profile = UserProfile {
430                email: "test_email".to_string(),
431            };
432            Self {
433                client_id: "test_client_id".to_string(),
434                client_secret: "test_client_secret".to_string(),
435                code: "test_code".to_string(),
436                auth_url: format!("{url}/auth_url",),
437                token_url: format!("{url}/token_url",),
438                redirect_url: format!("{url}/redirect_url",),
439                profile_url: format!("{url}/profile_url",),
440                scope: format!("{url}/scope_1",),
441                exchange_mock_body,
442                profile_mock_body: user_profile,
443                mock_server: server,
444            }
445        }
446    }
447
448    fn get_base_url_with_path(url: &Url) -> String {
449        let scheme = url.scheme();
450        let host = url.host_str().unwrap_or_default(); // Get the host as a str, default to empty string if not present
451
452        let path = url.path();
453        url.port().map_or_else(
454            || format!("{scheme}://{host}{path}"),
455            |port| format!("{scheme}://{host}:{port}{path}"),
456        )
457    }
458
459    async fn create_client() -> OAuth2ClientResult<(Client, Settings)> {
460        let settings = Settings::new().await;
461        let credentials = Credentials {
462            client_id: settings.client_id.to_string(),
463            client_secret: settings.client_secret.to_string(),
464        };
465        let url_config = UrlConfig {
466            auth_url: settings.auth_url.to_string(),
467            token_url: settings.token_url.to_string(),
468            redirect_url: settings.redirect_url.to_string(),
469            profile_url: settings.profile_url.to_string(),
470            scopes: vec![settings.scope.to_string()],
471        };
472        let cookie_config = CookieConfig {
473            protected_url: None,
474        };
475        let client = Client::new(credentials, url_config, cookie_config, None)?;
476        Ok((client, settings))
477    }
478
479    #[derive(thiserror::Error, Debug)]
480    enum TestError {
481        #[error(transparent)]
482        OAuth2Client(#[from] OAuth2ClientError),
483        #[error(transparent)]
484        #[allow(dead_code)]
485        Reqwest(reqwest::Error),
486        #[error("Couldnt find {0}")]
487        QueryMap(String),
488        #[error("Unable to deserialize profile")]
489        Profile,
490        #[error("Mock json data parse Error")]
491        MockJsonData(#[from] serde_json::Error),
492        #[error("Mock form data error")]
493        MockFormData(#[from] serde_urlencoded::ser::Error),
494    }
495
496    #[tokio::test]
497    async fn test_get_authorization_url() -> Result<(), TestError> {
498        let (mut client, settings) = create_client().await?;
499        let (url, csrf_token) = client.get_authorization_url();
500        let base_url_with_path = get_base_url_with_path(&url);
501        // compare between the auth_url with the base url
502        assert_eq!(settings.auth_url, base_url_with_path);
503        let query_map_multi: HashMap<String, Vec<String>> =
504            form_urlencoded::parse(url.query().unwrap_or("").as_bytes())
505                .into_owned()
506                .fold(std::collections::HashMap::new(), |mut acc, (key, value)| {
507                    acc.entry(key).or_default().push(value);
508                    acc
509                });
510        // Check response type
511        let response_type = query_map_multi
512            .get("response_type")
513            .ok_or(TestError::QueryMap(
514                "Couldnt find response type".to_string(),
515            ))?;
516        assert_eq!(response_type[0], "code");
517        let client_id = query_map_multi
518            .get("client_id")
519            .ok_or(TestError::QueryMap("Couldnt find client id".to_string()))?;
520        assert_eq!(client_id[0], settings.client_id);
521        // Check redirect url
522        let redirect_url = query_map_multi
523            .get("redirect_uri")
524            .ok_or(TestError::QueryMap("Couldnt find redirect url".to_string()))?;
525        assert_eq!(redirect_url[0], settings.redirect_url);
526        // Check scopes
527        let scopes = query_map_multi
528            .get("scope")
529            .ok_or(TestError::QueryMap("Couldnt find scopes".to_string()))?;
530        assert_eq!(scopes[0], settings.scope);
531        // Check state
532        let state = query_map_multi
533            .get("state")
534            .ok_or(TestError::QueryMap("Couldnt find state".to_string()))?;
535        assert_eq!(state[0], csrf_token.secret().to_owned());
536        Ok(())
537    }
538
539    #[tokio::test]
540    async fn test_cookie_config() -> Result<(), TestError> {
541        let (client, _) = create_client().await?;
542        let cookie_config = client.get_cookie_config();
543        assert_eq!(cookie_config.protected_url, None);
544        Ok(())
545    }
546
547    #[tokio::test]
548    async fn test_exchange_code() -> Result<(), TestError> {
549        let (mut client, settings) = create_client().await?;
550        let token_form_body = vec![
551            serde_urlencoded::to_string([("code", &settings.code)])?,
552            serde_urlencoded::to_string([("redirect_uri", &settings.redirect_url)])?,
553            serde_urlencoded::to_string([("grant_type", "authorization_code")])?,
554        ];
555        // Create a mock for the token exchange - https://www.oauth.com/oauth2-servers/access-tokens/authorization-code-request/
556        let mut token_mock = Mock::given(method("POST"))
557            .and(path("/token_url"))
558            // Client Authorization Auth Header from RFC6749(OAuth2) - https://datatracker.ietf.org/doc/html/rfc6749#section-2.3
559            .and(basic_auth(
560                settings.client_id.clone(),
561                settings.client_secret.clone(),
562            ));
563        // Access Token Request Body from RFC6749(OAuth2) - https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3
564        for url in token_form_body {
565            token_mock = token_mock.and(body_string_contains(url));
566        }
567        token_mock
568            .respond_with(
569                ResponseTemplate::new(200).set_body_json(settings.exchange_mock_body.clone()),
570            )
571            .expect(1)
572            .mount(&settings.mock_server)
573            .await;
574        // Create a mock for getting profile - https://www.oauth.com/oauth2-servers/access-tokens/access-token-response/
575        Mock::given(method("GET"))
576            .and(path("/profile_url"))
577            .and(bearer_token(
578                settings.exchange_mock_body.access_token.clone(),
579            ))
580            .respond_with(ResponseTemplate::new(200).set_body_json(settings.profile_mock_body))
581            .expect(1)
582            .mount(&settings.mock_server)
583            .await;
584        let (_url, csrf_token) = client.get_authorization_url();
585
586        let state = csrf_token.secret().to_string();
587        let csrf_token = csrf_token.secret().to_string();
588        let (_token, profile) = client
589            .verify_code_from_callback(settings.code, state, csrf_token)
590            .await?;
591
592        // Parse the user's profile
593        let profile = profile
594            .json::<UserProfile>()
595            .await
596            .map_err(|_| TestError::Profile)?;
597        assert_eq!(profile.email, "test_email");
598        Ok(())
599    }
600}