firestore_db_and_auth/
sessions.rs

1//! # Authentication Session - Contains non-persistent access tokens
2//!
3//! A session can be either for a service-account or impersonated via a firebase auth user id.
4
5#![allow(unused_imports)]
6use super::credentials;
7use super::errors::{extract_google_api_error, extract_google_api_error_async, FirebaseError};
8use super::jwt::{
9    create_jwt, is_expired, jwt_update_expiry_if, verify_access_token, AuthClaimsJWT, JWT_AUDIENCE_FIRESTORE,
10    JWT_AUDIENCE_IDENTITY,
11};
12use super::FirebaseAuthBearer;
13
14use chrono::Duration;
15use serde::{Deserialize, Serialize};
16use std::cell::RefCell;
17use std::ops::Deref;
18use std::slice::Iter;
19use std::sync::Arc;
20use tokio::sync::RwLock;
21
22pub mod user {
23    use super::*;
24    use crate::dto::{OAuthResponse, SignInWithIdpRequest};
25    use credentials::Credentials;
26
27    #[inline]
28    fn token_endpoint(v: &str) -> String {
29        format!(
30            "https://www.googleapis.com/identitytoolkit/v3/relyingparty/verifyCustomToken?key={}",
31            v
32        )
33    }
34
35    #[inline]
36    fn refresh_to_access_endpoint(v: &str) -> String {
37        format!("https://securetoken.googleapis.com/v1/token?key={}", v)
38    }
39
40    /// Default OAuth2 Providers supported by Firebase.
41    /// see: * https://firebase.google.com/docs/projects/provisioning/configure-oauth?hl=en#add-idp
42    pub enum OAuth2Provider {
43        Apple,
44        AppleGameCenter,
45        Facebook,
46        GitHub,
47        Google,
48        GooglePlayGames,
49        LinkedIn,
50        Microsoft,
51        Twitter,
52        Yahoo,
53    }
54
55    fn get_provider(provider: OAuth2Provider) -> String {
56        match provider {
57            OAuth2Provider::Apple => "apple.com".to_string(),
58            OAuth2Provider::AppleGameCenter => "gc.apple.com".to_string(),
59            OAuth2Provider::Facebook => "facebook.com".to_string(),
60            OAuth2Provider::GitHub => "github.com".to_string(),
61            OAuth2Provider::Google => "google.com".to_string(),
62            OAuth2Provider::GooglePlayGames => "playgames.google.com".to_string(),
63            OAuth2Provider::LinkedIn => "linkedin.com".to_string(),
64            OAuth2Provider::Microsoft => "microsoft.com".to_string(),
65            OAuth2Provider::Twitter => "twitter.com".to_string(),
66            OAuth2Provider::Yahoo => "yahoo.com".to_string(),
67        }
68    }
69
70    /// An impersonated session.
71    /// Firestore rules will restrict your access.
72    #[derive(Clone)]
73    pub struct Session {
74        /// The firebase auth user id
75        pub user_id: String,
76        /// The refresh token, if any. Such a token allows you to generate new, valid access tokens.
77        /// This library will handle this for you, if for example your current access token expired.
78        pub refresh_token: Option<String>,
79        /// The firebase projects API key, as defined in the credentials object
80        pub api_key: String,
81
82        access_token_: Arc<RwLock<String>>,
83
84        project_id_: String,
85        /// The http client for async operations. Replace or modify the client if you have special demands like proxy support
86        pub client: reqwest::Client,
87    }
88
89    #[async_trait::async_trait]
90    impl super::FirebaseAuthBearer for Session {
91        fn project_id(&self) -> &str {
92            &self.project_id_
93        }
94
95        async fn access_token_unchecked(&self) -> String {
96            self.access_token_.read().await.clone()
97        }
98
99        /// Returns the current access token.
100        /// This method will automatically refresh your access token, if it has expired.
101        ///
102        /// If the refresh failed, this will return an empty string.
103        async fn access_token(&self) -> String {
104            // Let's keep the access token locked for writes for the entirety of this function,
105            // so we don't have multiple refreshes going on at the same time
106            let mut jwt = self.access_token_.write().await;
107
108            if is_expired(&jwt, 0).unwrap() {
109                // Unwrap: the token is always valid at this point
110                if let Ok(response) = get_new_access_token(&self.api_key, &jwt).await {
111                    *jwt = response.id_token.clone();
112                    return response.id_token;
113                } else {
114                    // Failed to refresh access token. Return an empty string
115                    return String::new();
116                }
117            }
118
119            jwt.clone()
120        }
121
122        fn client(&self) -> &reqwest::Client {
123            &self.client
124        }
125    }
126
127    /// Gets a new access token via an api_key and a refresh_token.
128    async fn get_new_access_token(
129        api_key: &str,
130        refresh_token: &str,
131    ) -> Result<RefreshTokenToAccessTokenResponse, FirebaseError> {
132        let request_body = vec![("grant_type", "refresh_token"), ("refresh_token", refresh_token)];
133
134        let url = refresh_to_access_endpoint(api_key);
135        let client = reqwest::Client::new();
136        let response = client.post(&url).form(&request_body).send().await?;
137        Ok(response.json().await?)
138    }
139
140    #[allow(non_snake_case)]
141    #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
142    struct CustomJwtToFirebaseID {
143        token: String,
144        returnSecureToken: bool,
145    }
146
147    impl CustomJwtToFirebaseID {
148        fn new(token: String, with_refresh_token: bool) -> Self {
149            CustomJwtToFirebaseID {
150                token,
151                returnSecureToken: with_refresh_token,
152            }
153        }
154    }
155
156    #[allow(non_snake_case)]
157    #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
158    struct CustomJwtToFirebaseIDResponse {
159        kind: Option<String>,
160        idToken: String,
161        refreshToken: Option<String>,
162        expiresIn: Option<String>,
163    }
164
165    #[derive(Debug, PartialEq, Clone, Serialize, Deserialize)]
166    struct RefreshTokenToAccessTokenResponse {
167        expires_in: String,
168        token_type: String,
169        refresh_token: String,
170        id_token: String,
171        user_id: String,
172        project_id: String,
173    }
174
175    impl Session {
176        /// Create an impersonated session
177        ///
178        /// If the optionally provided access token is still valid, it will be used.
179        /// If the access token is not valid anymore, but the given refresh token is, it will be used to retrieve a new access token.
180        ///
181        /// If neither refresh token nor access token work are provided or valid, the service account credentials will be used to generate
182        /// a new impersonated refresh and access token for the given user.
183        ///
184        /// If none of the parameters are given, the function will error out.
185        ///
186        /// Async support: This is a blocking operation.
187        ///
188        /// See:
189        /// * https://firebase.google.com/docs/reference/rest/auth#section-refresh-token
190        /// * https://firebase.google.com/docs/auth/admin/create-custom-tokens#create_custom_tokens_using_a_third-party_jwt_library
191        pub async fn new(
192            credentials: &Credentials,
193            user_id: Option<&str>,
194            firebase_tokenid: Option<&str>,
195            refresh_token: Option<&str>,
196        ) -> Result<Session, FirebaseError> {
197            // Check if current tokenid is still valid
198            if let Some(firebase_tokenid) = firebase_tokenid {
199                let r = Session::by_access_token(credentials, firebase_tokenid).await;
200                if r.is_ok() {
201                    let mut r = r.unwrap();
202                    r.refresh_token = refresh_token.and_then(|f| Some(f.to_owned()));
203                    return Ok(r);
204                }
205            }
206
207            // Check if refresh_token is already sufficient
208            if let Some(refresh_token) = refresh_token {
209                let r = Session::by_refresh_token(credentials, refresh_token).await;
210                if r.is_ok() {
211                    return r;
212                }
213            }
214
215            // Neither refresh token nor access token worked or are provided.
216            // Try to get new new tokens for the given user_id via the REST API and the service-account credentials.
217            if let Some(user_id) = user_id {
218                let r = Session::by_user_id(credentials, user_id, true).await;
219                if r.is_ok() {
220                    return r;
221                }
222            }
223
224            Err(FirebaseError::Generic("No parameter given"))
225        }
226
227        /// Create a new firestore user session via a valid refresh_token
228        ///
229        /// Arguments:
230        /// - `credentials` The credentials
231        /// - `refresh_token` A refresh token.
232        ///
233        /// Async support: This is a blocking operation.
234        pub async fn by_refresh_token(
235            credentials: &Credentials,
236            refresh_token: &str,
237        ) -> Result<Session, FirebaseError> {
238            let r: RefreshTokenToAccessTokenResponse =
239                get_new_access_token(&credentials.api_key, refresh_token).await?;
240            Ok(Session {
241                user_id: r.user_id,
242                access_token_: Arc::new(RwLock::new(r.id_token)),
243                refresh_token: Some(r.refresh_token),
244                project_id_: credentials.project_id.to_owned(),
245                api_key: credentials.api_key.clone(),
246                client: reqwest::Client::new(),
247            })
248        }
249
250        /// Create a new firestore user session with a fresh access token.
251        ///
252        /// Arguments:
253        /// - `credentials` The credentials
254        /// - `user_id` The firebase Authentication user id. Usually a string of about 30 characters like "Io2cPph06rUWM3ABcIHguR3CIw6v1".
255        /// - `with_refresh_token` A refresh token is returned as well. This should be persisted somewhere for later reuse.
256        ///    Google generates only a few dozens of refresh tokens before it starts to invalidate already generated ones.
257        ///    For short lived, immutable, non-persisting services you do not want a refresh token.
258        ///
259        pub async fn by_user_id(
260            credentials: &Credentials,
261            user_id: &str,
262            with_refresh_token: bool,
263        ) -> Result<Session, FirebaseError> {
264            let scope: Option<Iter<String>> = None;
265            let jwt = create_jwt(
266                &credentials,
267                scope,
268                Duration::hours(1),
269                None,
270                Some(user_id.to_owned()),
271                JWT_AUDIENCE_IDENTITY,
272            )?;
273            let secret_lock = credentials.keys.read().await;
274            let secret = secret_lock
275                .secret
276                .as_ref()
277                .ok_or(FirebaseError::Generic("No private key added via add_keypair_key!"))?;
278            let encoded = jwt.encode(&secret.deref())?.encoded()?.encode();
279
280            let resp = reqwest::Client::new()
281                .post(&token_endpoint(&credentials.api_key))
282                .json(&CustomJwtToFirebaseID::new(encoded, with_refresh_token))
283                .send()
284                .await?;
285            let resp = extract_google_api_error_async(resp, || user_id.to_owned()).await?;
286            let r: CustomJwtToFirebaseIDResponse = resp.json().await?;
287
288            Ok(Session {
289                user_id: user_id.to_owned(),
290                access_token_: Arc::new(RwLock::new(r.idToken)),
291                refresh_token: r.refreshToken,
292                project_id_: credentials.project_id.to_owned(),
293                api_key: credentials.api_key.clone(),
294                client: reqwest::Client::new(),
295            })
296        }
297
298        /// Create a new firestore user session by a valid access token
299        ///
300        /// Remember that such a session cannot renew itself. As soon as the access token expired,
301        /// no further operations can be issued by this session.
302        ///
303        /// No network operation is performed, the access token is only checked for its validity.
304        ///
305        /// Arguments:
306        /// - `credentials` The credentials
307        /// - `access_token` An access token, sometimes called a firebase id token.
308        ///
309        pub async fn by_access_token(credentials: &Credentials, access_token: &str) -> Result<Session, FirebaseError> {
310            let result = verify_access_token(&credentials, access_token).await?;
311            Ok(Session {
312                user_id: result.subject,
313                project_id_: result.audience,
314                access_token_: Arc::new(RwLock::new(access_token.to_owned())),
315                refresh_token: None,
316                api_key: credentials.api_key.clone(),
317                client: reqwest::Client::new(),
318            })
319        }
320
321        /// Creates a new user session with OAuth2 provider token.
322        /// If user don't exist it's create new user in firestore
323        ///
324        /// Arguments:
325        /// - `credentials` The credentials.
326        /// - `access_token` access_token provided by OAuth2 provider.
327        /// - `request_uri` The URI to which the provider redirects the user back same as from .
328        /// - `provider` OAuth2Provider enum: Apple, AppleGameCenter, Facebook, GitHub, Google, GooglePlayGames, LinkedIn, Microsoft, Twitter, Yahoo.
329        /// - `with_refresh_token` A refresh token is returned as well. This should be persisted somewhere for later reuse.
330        ///    Google generates only a few dozens of refresh tokens before it starts to invalidate already generated ones.
331        ///    For short lived, immutable, non-persisting services you do not want a refresh token.
332        ///
333        pub async fn by_oauth2(
334            credentials: &Credentials,
335            access_token: String,
336            provider: OAuth2Provider,
337            request_uri: String,
338            with_refresh_token: bool,
339        ) -> Result<Session, FirebaseError> {
340            let uri = "https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=".to_owned()
341                + &credentials.api_key;
342
343            let post_body = format!("access_token={}&providerId={}", access_token, get_provider(provider));
344            let return_idp_credential = true;
345            let return_secure_token = true;
346
347            let json = &SignInWithIdpRequest {
348                post_body,
349                request_uri,
350                return_idp_credential,
351                return_secure_token,
352            };
353
354            let response = reqwest::Client::new().post(&uri).json(&json).send().await?;
355
356            let oauth_response: OAuthResponse = response.json().await?;
357
358            self::Session::by_user_id(&credentials, &oauth_response.local_id, with_refresh_token).await
359        }
360    }
361}
362
363pub mod session_cookie {
364    use super::*;
365
366    pub static GOOGLE_OAUTH2_URL: &str = "https://accounts.google.com/o/oauth2/token";
367
368    /// See https://cloud.google.com/identity-platform/docs/reference/rest/v1/projects/createSessionCookie
369    #[inline]
370    fn identitytoolkit_url(project_id: &str) -> String {
371        format!(
372            "https://identitytoolkit.googleapis.com/v1/projects/{}:createSessionCookie",
373            project_id
374        )
375    }
376
377    /// See https://cloud.google.com/identity-platform/docs/reference/rest/v1/CreateSessionCookieResponse
378    #[derive(Debug, Deserialize)]
379    struct CreateSessionCookieResponseDTO {
380        #[serde(rename = "sessionCookie")]
381        session_cookie_jwk: String,
382    }
383
384    /// https://cloud.google.com/identity-platform/docs/reference/rest/v1/projects/createSessionCookie
385    #[derive(Debug, PartialEq, Eq, Deserialize, Serialize, Clone)]
386    struct SessionLoginDTO {
387        /// Required. A valid Identity Platform ID token.
388        #[serde(rename = "idToken")]
389        id_token: String,
390        /// The number of seconds until the session cookie expires. Specify a duration in seconds, between five minutes and fourteen days, inclusively.
391        #[serde(rename = "validDuration")]
392        valid_duration: u64,
393        #[serde(rename = "tenantId")]
394        #[serde(skip_serializing_if = "Option::is_none")]
395        tenant_id: Option<String>,
396    }
397
398    #[derive(Debug, Deserialize)]
399    struct Oauth2ResponseDTO {
400        access_token: String,
401    }
402
403    /// Firebase Auth provides server-side session cookie management for traditional websites that rely on session cookies.
404    /// This solution has several advantages over client-side short-lived ID tokens,
405    /// which may require a redirect mechanism each time to update the session cookie on expiration:
406    ///
407    /// * Improved security via JWT-based session tokens that can only be generated using authorized service accounts.
408    /// * Stateless session cookies that come with all the benefit of using JWTs for authentication.
409    ///   The session cookie has the same claims (including custom claims) as the ID token, making the same permissions checks enforceable on the session cookies.
410    /// * Ability to create session cookies with custom expiration times ranging from 5 minutes to 2 weeks.
411    /// * Flexibility to enforce cookie policies based on application requirements: domain, path, secure, httpOnly, etc.
412    /// * Ability to revoke session cookies when token theft is suspected using the existing refresh token revocation API.
413    /// * Ability to detect session revocation on major account changes.
414    ///
415    /// See https://firebase.google.com/docs/auth/admin/manage-cookies
416    ///
417    /// The generated session cookie is a JWT that includes the firebase user id in the "sub" (subject) field.
418    ///
419    /// Arguments:
420    /// - `credentials` The credentials
421    /// - `id_token` An access token, sometimes called a firebase id token.
422    /// - `duration` The cookie duration
423    ///
424    pub async fn create(
425        credentials: &credentials::Credentials,
426        id_token: String,
427        duration: chrono::Duration,
428    ) -> Result<String, FirebaseError> {
429        // Generate the assertion from the admin credentials
430        let assertion = crate::jwt::session_cookie::create_jwt_encoded(credentials, duration).await?;
431
432        // Request Google Oauth2 to retrieve the access token in order to create a session cookie
433        let client = reqwest::blocking::Client::new();
434        let response_oauth2: Oauth2ResponseDTO = client
435            .post(GOOGLE_OAUTH2_URL)
436            .form(&[
437                ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"),
438                ("assertion", &assertion),
439            ])
440            .send()?
441            .json()?;
442
443        // Create a session cookie with the access token previously retrieved
444        let response_session_cookie_json: CreateSessionCookieResponseDTO = client
445            .post(&identitytoolkit_url(&credentials.project_id))
446            .bearer_auth(&response_oauth2.access_token)
447            .json(&SessionLoginDTO {
448                id_token,
449                valid_duration: duration.num_seconds() as u64,
450                tenant_id: None,
451            })
452            .send()?
453            .json()?;
454
455        Ok(response_session_cookie_json.session_cookie_jwk)
456    }
457}
458
459/// Find the service account session defined in here
460pub mod service_account {
461    use crate::jwt::TokenValidationResult;
462
463    use super::*;
464    use credentials::Credentials;
465
466    use chrono::Duration;
467    use std::cell::RefCell;
468    use std::ops::Deref;
469
470    /// Service account session
471    #[derive(Clone, Debug)]
472    pub struct Session {
473        /// The google credentials
474        pub credentials: Credentials,
475        /// The http client for async operations. Replace or modify the client if you have special demands like proxy support
476        pub client: reqwest::Client,
477        jwt: Arc<RwLock<AuthClaimsJWT>>,
478        access_token_: Arc<RwLock<String>>,
479    }
480
481    #[async_trait::async_trait]
482    impl super::FirebaseAuthBearer for Session {
483        fn project_id(&self) -> &str {
484            &self.credentials.project_id
485        }
486
487        /// Return the encoded jwt to be used as bearer token. If the jwt
488        /// issue_at is older than 50 minutes, it will be updated to the current time.
489        async fn access_token(&self) -> String {
490            // Keeping the JWT and the access token in write mode so this area is
491            // a single-entrace critical section for refreshes sake
492            let mut access_token = self.access_token_.write().await;
493            let maybe_jwt = {
494                let mut jwt = self.jwt.write().await;
495
496                if jwt_update_expiry_if(&mut jwt, 50) {
497                    self.credentials
498                        .keys
499                        .read()
500                        .await
501                        .secret
502                        .as_ref()
503                        .and_then(|secret| jwt.clone().encode(&secret.deref()).ok())
504                } else {
505                    None
506                }
507            };
508
509            if let Some(v) = maybe_jwt {
510                if let Ok(v) = v.encoded() {
511                    *access_token = v.encode();
512                }
513            }
514
515            access_token.clone()
516        }
517
518        async fn access_token_unchecked(&self) -> String {
519            self.access_token_.read().await.clone()
520        }
521
522        fn client(&self) -> &reqwest::Client {
523            &self.client
524        }
525    }
526
527    impl Session {
528        /// You need a service account credentials file, provided by the Google Cloud console.
529        ///
530        /// The service account session can be used to interact with the FireStore API as well as
531        /// FireBase Auth.
532        ///
533        /// A custom jwt is created and signed with the service account private key. This jwt is used
534        /// as bearer token.
535        ///
536        /// See https://developers.google.com/identity/protocols/OAuth2ServiceAccount
537        pub async fn new(credentials: Credentials) -> Result<Session, FirebaseError> {
538            let scope: Option<Iter<String>> = None;
539            let jwt = create_jwt(
540                &credentials,
541                scope,
542                Duration::hours(1),
543                None,
544                None,
545                JWT_AUDIENCE_FIRESTORE,
546            )?;
547            let encoded = {
548                let secret_lock = credentials.keys.read().await;
549                let secret = secret_lock
550                    .secret
551                    .as_ref()
552                    .ok_or(FirebaseError::Generic("No private key added via add_keypair_key!"))?;
553                jwt.encode(&secret.deref())?.encoded()?.encode()
554            };
555
556            Ok(Session {
557                access_token_: Arc::new(RwLock::new(encoded)),
558                jwt: Arc::new(RwLock::new(jwt)),
559
560                credentials,
561                client: reqwest::Client::new(),
562            })
563        }
564
565        pub async fn verify_token(&self, token: &str) -> Result<TokenValidationResult, FirebaseError> {
566            self.credentials.verify_token(token).await
567        }
568    }
569}