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}