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}