├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── disabled_workflows │ └── musl.yml └── workflows │ ├── integration.yml │ ├── style.yml │ └── with_rocket.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE ├── doc ├── auth_background.md ├── integration_tests.md ├── logo.png ├── own_auth.md └── rocket_integration.md ├── examples ├── create_read_write_document.rs ├── firebase_user.rs ├── own_auth.rs ├── readme.md ├── rocket_http_protected_route.rs ├── session_cookie.rs ├── test_user_id.txt └── utils │ └── mod.rs ├── readme.md ├── rustfmt.toml ├── src ├── credentials.rs ├── documents │ ├── delete.rs │ ├── list.rs │ ├── mod.rs │ ├── query.rs │ ├── read.rs │ └── write.rs ├── dto.rs ├── errors.rs ├── firebase_rest_to_rust.rs ├── jwt.rs ├── lib.rs ├── rocket │ └── mod.rs ├── sessions.rs └── users.rs └── tests ├── extract_test_credentials.sh ├── left_over_println.sh ├── service-account-test.json └── service-account-test.jwks /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Compile with '...' 16 | 2. Use documents like '....' 17 | 3. With Firestore Access Rules '....' 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/disabled_workflows/musl.yml: -------------------------------------------------------------------------------- 1 | name: StaticLibMusl 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | os: [ubuntu-latest] 12 | rust: [stable] 13 | 14 | steps: 15 | - uses: davidgraeff/rust-musl-action@master 16 | - uses: actions/checkout@master 17 | - name: Check for leftover println 18 | env: 19 | SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} 20 | run: ./ci.sh 21 | - name: Build and Test 22 | run: cargo test --features rustls-tls --no-default-features 23 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | # Branches from forks have the form 'user:branch-name' so we only run 8 | # this job on pull_request events for branches that look like fork 9 | # branches. Without this we would end up running this job twice for non 10 | # forked PRs, once for the push and then once for opening the PR. 11 | - '**:**' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ${{ matrix.os }} 17 | strategy: 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macOS-latest] 20 | rust: [stable] 21 | 22 | steps: 23 | - uses: hecrj/setup-rust-action@master 24 | with: 25 | rust-version: ${{ matrix.rust }} 26 | - uses: actions/checkout@master 27 | - name: Extract Test Credentials 28 | if: matrix.os != 'windows-latest' 29 | env: 30 | SERVICE_ACCOUNT_JSON: ${{ secrets.SERVICE_ACCOUNT_JSON }} 31 | run: ./tests/extract_test_credentials.sh 32 | - name: Build And Test 33 | if: matrix.os != 'windows-latest' 34 | run: cargo test 35 | - name: Build on Windows 36 | if: matrix.os == 'windows-latest' 37 | run: cargo build 38 | -------------------------------------------------------------------------------- /.github/workflows/style.yml: -------------------------------------------------------------------------------- 1 | name: Code Style 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: 7 | # Branches from forks have the form 'user:branch-name' so we only run 8 | # this job on pull_request events for branches that look like fork 9 | # branches. Without this we would end up running this job twice for non 10 | # forked PRs, once for the push and then once for opening the PR. 11 | - '**:**' 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@master 18 | - name: Check for leftover println 19 | run: ./tests/left_over_println.sh 20 | - name: Check formatting 21 | run: cargo fmt --all -- --check 22 | -------------------------------------------------------------------------------- /.github/workflows/with_rocket.yml: -------------------------------------------------------------------------------- 1 | name: With Rocket 2 | 3 | on: 4 | push: 5 | paths: 6 | - 'src' 7 | pull_request: 8 | branches: 9 | # Branches from forks have the form 'user:branch-name' so we only run 10 | # this job on pull_request events for branches that look like fork 11 | # branches. Without this we would end up running this job twice for non 12 | # forked PRs, once for the push and then once for opening the PR. 13 | - '**:**' 14 | paths: 15 | - 'src' 16 | 17 | jobs: 18 | build: 19 | 20 | runs-on: ${{ matrix.os }} 21 | strategy: 22 | matrix: 23 | os: [ubuntu-latest] 24 | rust: [nightly] 25 | 26 | steps: 27 | - uses: hecrj/setup-rust-action@master 28 | with: 29 | rust-version: ${{ matrix.rust }} 30 | - uses: actions/checkout@master 31 | - name: Build 32 | run: cargo build --features=rocket_support 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | firebase-service-account.json 2 | firebase-service-account.jwks 3 | refresh-token-for-tests.txt 4 | target/ 5 | Cargo.lock 6 | .idea -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [0.8.0] - 2024-01-22 10 | 11 | ### Added 12 | 13 | - Credentials::download_google_jwks(): Update/replace public keys. Useful for long running services. 14 | - Added/Improved tests with doctest_credentials() + deserialize_credentials() 15 | - JWKSet::new() 16 | 17 | ### Changed 18 | 19 | - [Breaking] Async only API 20 | - [Breaking] Rocket example uses the Rocket 0.5 release 21 | - [Breaking] Migrated to Rust edition 2021 22 | 23 | ## [0.6.1] - 2020-11-12 24 | 25 | ### Changed 26 | 27 | - Change type signature for `MapValue` to allow for empty fields 28 | - Update dependency biscuit to 0.5 29 | 30 | ## [0.6] - 2020-01-22 31 | 32 | ### Changed 33 | 34 | - Update dependencies 35 | - Support for reqwest 0.10 with async/await. 36 | - Delete operation also available as async variant (unstable API for now) 37 | 38 | ## [0.5] - 2019-09-12 39 | 40 | ### Changed 41 | 42 | - Remove lifetime from FirebaseAuthBearer trait. Turns out this can be elided by modern Rust versions. 43 | - Add two examples: own_auth and rocket_http_protected_route 44 | - dto::Document: "name" is no longer wrapped in an option. This field is always set, if no error occurred. 45 | 46 | ## [0.4] - 2019-09-05 47 | 48 | ### Added 49 | - The user-session now also refreshes expired access tokens (if a refresh token is known). 50 | - Added "access_token_unchecked()" to the auth trait as a way to access the token without 51 | invoking the refresh check. 52 | - Add user-by-email management methods "sign_up" and "sign_in". 53 | 54 | ### Changed 55 | - User sessions Session::by_user_id now requires a 2nd parameter: "with_refresh_token" 56 | - Accept String and &str in some places 57 | - Store the reqwest client in session objects and reuse it. 58 | This also allows the library user to replace the client with a more specific one, that 59 | for example handle proxies or certain ssl situations. 60 | Successive document calls are way faster now. 61 | - Rename "bearer" to "access_token". Added "access_token_unchecked()" to the auth trait. 62 | - documents::write: Add options argument. The only option right now is "merge": Only update 63 | given fields and not replace the entire document. 64 | - documents::query: Return an iterator and lazy fetch documents. 65 | Take a serde_json::Value as value parameter. This allows to query for numbers and other 66 | fields with other data types than string as well. 67 | 68 | ### Removed 69 | - Dependency on regex. A custom method is in place instead. 70 | - Dependency on the deprecated rustc_serialize. Use base64 instead. 71 | - Dependency on untrusted: The ring crate interface takes slices directly now without untrusted wrappers. 72 | - Dependency on url. Unused. 73 | 74 | ## [0.3.1] - 2019-09-05 75 | 76 | ### Changed 77 | - The documents::list iterator now iterates over tuples (document, metadata). Metadata 78 | contains the document name, created and updated fields. 79 | 80 | ## [0.3.0] - 2019-09-04 81 | 82 | ### Added 83 | - Improved error handling! 84 | New error type for FirebaseError: APIError. 85 | Contains the numeric Google error code, the error string code and an optional context. 86 | The context is set to the document path if APIError has been returned by any of the 87 | document APIs. 88 | 89 | ### Changed 90 | - Renamed userinfo to user_info, userremove to user_remove 91 | - The session object does not need to be mutable anymore 92 | - The credentials API changed. Credentials::new introduced as preferred way to 93 | construct a Credentials object. 94 | 95 | ### Fixed 96 | - user_info and user_remove now work as expected. Tests added. 97 | 98 | ## [0.2] - 2019-09-02 99 | 100 | ### Added 101 | - Add jwks public keys via Credentials::add_jwks_public_keys(JWKSetDTO). 102 | This avoids downloading them on each start. 103 | - Add new delete(auth, path, **fail_if_not_existing**) boolean argument 104 | including a test 105 | 106 | ### Changed 107 | - FirebaseAuthBearer trait, bearer method now returns a String 108 | and works on a non mutable reference, thanks to RefCell. 109 | - Credentials module: JWKSetDTO and JWSEntry are now public 110 | - Renamed the rocket guard struct from ApiKey to FirestoreAuthSessionGuard -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "firestore-db-and-auth" 3 | version = "0.8.0" 4 | authors = ["David Gräff "] 5 | edition = "2021" 6 | license = "MIT" 7 | description = "This crate allows easy access to your Google Firestore DB via service account or OAuth impersonated Google Firebase Auth credentials." 8 | readme = "readme.md" 9 | keywords = ["firestore", "auth"] 10 | categories = ["api-bindings","authentication"] 11 | maintenance = { status = "passively-maintained" } 12 | repository = "https://github.com/davidgraeff/firestore-db-and-auth-rs" 13 | rust-version = "1.64" 14 | 15 | [dependencies] 16 | bytes = "1.1" 17 | cache_control = "0.2" 18 | reqwest = { version = "0.11", default-features = false, features = ["json", "blocking", "hyper-rustls"] } 19 | serde = { version = "1.0", features = ["derive"] } 20 | serde_json = "1.0" 21 | chrono = { version = "0.4", features = ["serde"] } 22 | biscuit = "0.7" 23 | ring = "0.17" 24 | base64 = "0.21" 25 | async-trait = "0.1" 26 | tokio = { version = "1.13", features = ["macros"] } 27 | futures = "0.3" 28 | pin-project = "1.0" 29 | http = "1.0" 30 | 31 | [dev-dependencies] 32 | tokio-test = "0.4" 33 | 34 | [dependencies.rocket] 35 | version = "0.5.0" 36 | default-features = false 37 | optional = true 38 | 39 | # Render the readme file on doc.rs 40 | [package.metadata.docs.rs] 41 | features = [ "external_doc", "rocket_support" ] 42 | 43 | [features] 44 | default = ["rustls-tls", "unstable"] 45 | rocket_support = ["rocket"] 46 | rustls-tls = ["reqwest/rustls-tls"] 47 | default-tls = ["reqwest/default-tls"] 48 | native-tls = ["reqwest/native-tls"] 49 | native-tls-vendored = ["reqwest/native-tls-vendored"] 50 | unstable = [] 51 | external_doc = [] 52 | 53 | [[example]] 54 | name = "create_read_write_document" 55 | test = true 56 | 57 | [[example]] 58 | name = "firebase_user" 59 | test = true 60 | 61 | [[example]] 62 | name = "own_auth" 63 | test = true 64 | 65 | [[example]] 66 | name = "rocket_http_protected_route" 67 | test = true 68 | required-features = ["rustls-tls","rocket_support"] 69 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2019-2021 The firestore-db-and-auth-rs authors 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /doc/auth_background.md: -------------------------------------------------------------------------------- 1 | ### Firestore Auth: Background information 2 | 3 | **JWT**: Firestore Auth makes use of the *OAuth Grant Code Flow* and uses *JWT*s (Json web Tokens) 4 | as access tokens. Such a token is signed by Google and consists of a few encoded fields including 5 | a valid-until field. This allows to verify access tokens locally without any database access. 6 | 7 | The Firebase API requires an access token, it accepts two types: 8 | 9 | 1. A custom created JWT, signed with the private key of a Google service account 10 | 2. An access token from Firestore Auth, bound to a user (in this crate called "user session") 11 | 12 | If you do not have an user session access token, but you need to perform an action 13 | impersonated, this crate offers `Session::by_user_id`. This will again create a custom, signed JWT, 14 | like with option 1, but exchanges this JWT for a refresh token and access token tuple. 15 | The actual database operation will be performed with those tokens. 16 | 17 | About token validation: 18 | 19 | Validation happens via the public keys of the corresponding Google service account (https://www.googleapis.com/service_accounts/v1/jwk/service.account@address). 20 | The public keys are downloaded and cached the very first time you create a `credentials::Credentials` object. 21 | 22 | To avoid this roundtrip on start it is **strongly** recommended to use the `Credentials::new(..)` function 23 | to create the credentials object. Find more information further down. 24 | -------------------------------------------------------------------------------- /doc/integration_tests.md: -------------------------------------------------------------------------------- 1 | # Integration Tests 2 | 3 | To perform a full integration test, you need a valid "firebase-service-account.json" file. 4 | 5 | 1. Create and download one of the type "firebase-adminsdk" at [Google Clound console](https://console.cloud.google.com/apis/credentials/serviceaccountkey) and store it as "firebase-service-account.json". 6 | The file should contain `"private_key_id": ...`. Store the file in the repository root. 7 | 2. Add another field `"api_key" : "YOUR_API_KEY"` and replace YOUR_API_KEY with your *Web API key*, to be found in the [Google Firebase console](https://console.firebase.google.com) in "Project Overview -> Settings - > General". 8 | 3. The tests will create a Firebase user with the ID "Io2cPph06rUWM3ABcIHguR3CIw6v1" and write and read a document to/from "tests/test". 9 | Ensure read/write access via firebase rules ("Cloud Firestore -> Rules - > Edit rules"). For example use the rule snippet below: 10 | 11 | ``` 12 | service cloud.firestore { 13 | match /databases/{database}/documents { 14 | // Integration test user: Allow access to tests collection 15 | match /tests/{documents=**} { 16 | allow read, write: if request.auth != null && request.auth.uid == "Io2cPph06rUWM3ABcIHguR3CIw6v1" 17 | } 18 | } 19 | } 20 | ``` 21 | 22 | 23 | 24 | A refresh and access token is generated. 25 | The refresh token is stored in "refresh-token-for-tests.txt" and will be reused for further tests. 26 | The reason being that Google allows only about [50 simultaneous refresh tokens at any time](https://developers.google.com/identity/protocols/OAuth2#expiration), so we do not want to create a new one for each test run. 27 | 28 | The original repository of this crate uses a "firebase-service-account.json" 29 | that is stored in base64 as Github CI secret. 30 | Have a look at "tests/extract_test_credentials.sh" to see how the secret environment variable is 31 | base64 decoded and stored as file again. -------------------------------------------------------------------------------- /doc/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgraeff/firestore-db-and-auth-rs/0b8cfe5476f396d8f85e2d4aaee4155a6bc27eb8/doc/logo.png -------------------------------------------------------------------------------- /doc/own_auth.md: -------------------------------------------------------------------------------- 1 | ### Use your own authentication implementation 2 | 3 | You do not need the `sessions` module for using the Firestore API of this crate. 4 | All Firestore methods in `documents` expect an object that implements the `FirebaseAuthBearer` trait. 5 | 6 | That trait looks like this: 7 | 8 | ```rust 9 | pub trait FirebaseAuthBearer<'a> { 10 | fn projectid(&'a self) -> &'a str; 11 | fn bearer(&'a self) -> &'a str; 12 | } 13 | ``` 14 | 15 | Just implement this trait for your own data structure and provide the Firestore project id and a valid access token. 16 | -------------------------------------------------------------------------------- /doc/rocket_integration.md: -------------------------------------------------------------------------------- 1 | ### Http Rocket Server integration 2 | 3 | Because the `sessions` module of this crate is already able to verify access tokens, 4 | it was not much more work to turn this into a Rocket 0.4+ Guard. 5 | 6 | The implemented Guard (enabled by the feature "rocket_support") allows access to http paths 7 | if the provided http "Authorization" header contains a valid "Bearer" token. 8 | The above mentioned validations on the token are performed. 9 | 10 | See the rust documentation for `firestore_db_and_auth::rocket::FirestoreAuthSessionGuard`. -------------------------------------------------------------------------------- /examples/create_read_write_document.rs: -------------------------------------------------------------------------------- 1 | use firestore_db_and_auth::{documents, dto, errors, sessions, Credentials, FirebaseAuthBearer, ServiceSession}; 2 | 3 | use firestore_db_and_auth::documents::WriteResult; 4 | use serde::{Deserialize, Serialize}; 5 | 6 | use futures::stream::StreamExt; 7 | 8 | mod utils; 9 | 10 | #[derive(Debug, Serialize, Deserialize)] 11 | struct DemoDTO { 12 | a_string: String, 13 | an_int: u32, 14 | a_timestamp: String, 15 | } 16 | 17 | /// Test if merge works. a_timestamp is not defined here, 18 | /// as well as an Option is used. 19 | #[derive(Debug, Serialize, Deserialize)] 20 | struct DemoDTOPartial { 21 | #[serde(skip_serializing_if = "Option::is_none")] 22 | a_string: Option, 23 | an_int: u32, 24 | } 25 | 26 | async fn write_document(session: &mut ServiceSession, doc_id: &str) -> errors::Result { 27 | println!("Write document"); 28 | 29 | let obj = DemoDTO { 30 | a_string: "abcd".to_owned(), 31 | an_int: 14, 32 | a_timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true), 33 | }; 34 | 35 | documents::write(session, "tests", Some(doc_id), &obj, documents::WriteOptions::default()).await 36 | } 37 | 38 | async fn write_partial_document(session: &mut ServiceSession, doc_id: &str) -> errors::Result { 39 | println!("Partial write document"); 40 | 41 | let obj = DemoDTOPartial { 42 | a_string: None, 43 | an_int: 16, 44 | }; 45 | 46 | documents::write( 47 | session, 48 | "tests", 49 | Some(doc_id), 50 | &obj, 51 | documents::WriteOptions { merge: true }, 52 | ) 53 | .await 54 | } 55 | 56 | fn check_write(result: WriteResult, doc_id: &str) { 57 | assert_eq!(result.document_id, doc_id); 58 | let duration = chrono::Utc::now().signed_duration_since(result.update_time.unwrap()); 59 | assert!( 60 | duration.num_seconds() < 60, 61 | "now = {}, updated: {}, created: {}", 62 | chrono::Utc::now(), 63 | result.update_time.unwrap(), 64 | result.create_time.unwrap() 65 | ); 66 | } 67 | 68 | async fn service_account_session(cred: Credentials) -> errors::Result<()> { 69 | let mut session = ServiceSession::new(cred).await.unwrap(); 70 | let b = session.access_token().await.to_owned(); 71 | 72 | let doc_id = "service_test"; 73 | check_write(write_document(&mut session, doc_id).await?, doc_id); 74 | 75 | // Check if cached value is used 76 | assert_eq!(session.access_token().await, b); 77 | 78 | println!("Read and compare document"); 79 | let read: DemoDTO = documents::read(&mut session, "tests", doc_id).await?; 80 | 81 | assert_eq!(read.a_string, "abcd"); 82 | assert_eq!(read.an_int, 14); 83 | 84 | check_write(write_partial_document(&mut session, doc_id).await?, doc_id); 85 | println!("Read and compare document"); 86 | let read: DemoDTOPartial = documents::read(&mut session, "tests", doc_id).await?; 87 | 88 | // Should be updated 89 | assert_eq!(read.an_int, 16); 90 | // Should still exist, because of the merge 91 | assert_eq!(read.a_string, Some("abcd".to_owned())); 92 | 93 | Ok(()) 94 | } 95 | 96 | async fn user_account_session(cred: Credentials) -> errors::Result<()> { 97 | let user_session = utils::user_session_with_cached_refresh_token(&cred).await?; 98 | 99 | assert_eq!(user_session.user_id, utils::TEST_USER_ID); 100 | assert_eq!(user_session.project_id(), cred.project_id); 101 | 102 | println!("user::Session::by_access_token"); 103 | let user_session = 104 | sessions::user::Session::by_access_token(&cred, &user_session.access_token_unchecked().await).await?; 105 | 106 | assert_eq!(user_session.user_id, utils::TEST_USER_ID); 107 | 108 | let obj = DemoDTO { 109 | a_string: "abc".to_owned(), 110 | an_int: 12, 111 | a_timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true), 112 | }; 113 | 114 | // Test writing 115 | println!("user::Session documents::write"); 116 | let doc_id = "user_doc"; 117 | check_write( 118 | documents::write( 119 | &user_session, 120 | "tests", 121 | Some(doc_id), 122 | &obj, 123 | documents::WriteOptions::default(), 124 | ) 125 | .await?, 126 | doc_id, 127 | ); 128 | 129 | // Test reading 130 | println!("user::Session documents::read"); 131 | let read: DemoDTO = documents::read(&user_session, "tests", doc_id).await?; 132 | 133 | assert_eq!(read.a_string, "abc"); 134 | assert_eq!(read.an_int, 12); 135 | 136 | // Query for all documents with field "a_string" and value "abc" 137 | let results: Vec = documents::query( 138 | &user_session, 139 | "tests", 140 | "abc".into(), 141 | dto::FieldOperator::EQUAL, 142 | "a_string", 143 | ) 144 | .await? 145 | .collect(); 146 | assert_eq!(results.len(), 1); 147 | let doc: DemoDTO = documents::read_by_name(&user_session, &results.get(0).unwrap().name).await?; 148 | assert_eq!(doc.a_string, "abc"); 149 | 150 | let mut count = 0; 151 | let list_it = documents::list(&user_session, "tests".to_owned()) 152 | .collect::>>() 153 | .await; 154 | for _doc in list_it { 155 | count += 1; 156 | } 157 | assert_eq!(count, 2); 158 | 159 | // test if the call fails for a non existing document 160 | println!("user::Session documents::delete"); 161 | let r = documents::delete(&user_session, "tests/non_existing", true).await; 162 | assert!(r.is_err()); 163 | match r.err().unwrap() { 164 | errors::FirebaseError::APIError(code, message, context) => { 165 | assert_eq!(code, 404); 166 | assert!(message.contains("No document to update")); 167 | assert_eq!(context, "tests/non_existing"); 168 | } 169 | _ => panic!("Expected an APIError"), 170 | }; 171 | 172 | documents::delete(&user_session, &("tests/".to_owned() + doc_id), false).await?; 173 | 174 | // Check if document is indeed removed 175 | println!("user::Session documents::query"); 176 | let count = documents::query( 177 | &user_session, 178 | "tests", 179 | "abc".into(), 180 | dto::FieldOperator::EQUAL, 181 | "a_string", 182 | ) 183 | .await? 184 | .count(); 185 | assert_eq!(count, 0); 186 | 187 | println!("user::Session documents::query for f64"); 188 | let f: f64 = 13.37; 189 | 190 | let count = documents::query(&user_session, "tests", f.into(), dto::FieldOperator::EQUAL, "a_float").await?; 191 | 192 | let count = count.count(); 193 | assert_eq!(count, 0); 194 | 195 | Ok(()) 196 | } 197 | 198 | #[tokio::main] 199 | async fn main() -> errors::Result<()> { 200 | // Search for a credentials file in the root directory 201 | use std::path::PathBuf; 202 | let mut credential_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 203 | credential_file.push("firebase-service-account.json"); 204 | let cred = Credentials::from_file(credential_file.to_str().unwrap()).await?; 205 | 206 | // Only download the public keys once, and cache them. 207 | let jwkset = utils::from_cache_file(credential_file.with_file_name("cached_jwks.jwks").as_path(), &cred).await?; 208 | cred.add_jwks_public_keys(&jwkset).await; 209 | cred.verify().await?; 210 | 211 | // Perform some db operations via a service account session 212 | service_account_session(cred.clone()).await?; 213 | 214 | // Perform some db operations via a firebase user session 215 | user_account_session(cred).await?; 216 | 217 | Ok(()) 218 | } 219 | 220 | /// For integration tests and doc code snippets: Create a Credentials instance. 221 | /// Necessary public jwk sets are downloaded or re-used if already present. 222 | #[cfg(test)] 223 | async fn valid_test_credentials() -> errors::Result { 224 | use std::path::PathBuf; 225 | let mut jwks_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 226 | jwks_path.push("firebase-service-account.jwks"); 227 | 228 | let cred: Credentials = Credentials::new(include_str!("../firebase-service-account.json")).await?; 229 | 230 | // Only download the public keys once, and cache them. 231 | let jwkset = utils::from_cache_file(jwks_path.as_path(), &cred).await?; 232 | cred.add_jwks_public_keys(&jwkset).await; 233 | cred.verify().await?; 234 | 235 | Ok(cred) 236 | } 237 | 238 | #[tokio::test] 239 | async fn valid_test_credentials_test() -> errors::Result<()> { 240 | valid_test_credentials().await?; 241 | Ok(()) 242 | } 243 | 244 | #[tokio::test] 245 | async fn service_account_session_test() -> errors::Result<()> { 246 | service_account_session(valid_test_credentials().await?).await?; 247 | Ok(()) 248 | } 249 | 250 | #[tokio::test] 251 | async fn user_account_session_test() -> errors::Result<()> { 252 | user_account_session(valid_test_credentials().await?).await?; 253 | Ok(()) 254 | } 255 | -------------------------------------------------------------------------------- /examples/firebase_user.rs: -------------------------------------------------------------------------------- 1 | use firestore_db_and_auth::Credentials; 2 | use firestore_db_and_auth::*; 3 | 4 | const TEST_USER_ID: &str = include_str!("test_user_id.txt"); 5 | 6 | #[tokio::main] 7 | async fn main() -> errors::Result<()> { 8 | let cred = Credentials::from_file("firebase-service-account.json") 9 | .await 10 | .expect("Read credentials file"); 11 | 12 | let user_session = UserSession::by_user_id(&cred, TEST_USER_ID, false).await?; 13 | 14 | println!("users::user_info"); 15 | let user_info_container = users::user_info(&user_session).await?; 16 | assert_eq!(user_info_container.users[0].localId.as_ref().unwrap(), TEST_USER_ID); 17 | 18 | Ok(()) 19 | } 20 | 21 | #[test] 22 | fn firebase_user_test() { 23 | main().unwrap(); 24 | } 25 | -------------------------------------------------------------------------------- /examples/own_auth.rs: -------------------------------------------------------------------------------- 1 | use firestore_db_and_auth::errors::FirebaseError::APIError; 2 | use firestore_db_and_auth::{documents, errors, Credentials, FirebaseAuthBearer}; 3 | 4 | /// Define your own structure that will implement the FirebaseAuthBearer trait 5 | struct MyOwnSession { 6 | /// The google credentials 7 | pub credentials: Credentials, 8 | pub client: reqwest::Client, 9 | access_token: String, 10 | } 11 | 12 | #[async_trait::async_trait] 13 | impl FirebaseAuthBearer for MyOwnSession { 14 | fn project_id(&self) -> &str { 15 | &self.credentials.project_id 16 | } 17 | /// An access token. If a refresh token is known and the access token expired, 18 | /// the implementation should try to refresh the access token before returning. 19 | async fn access_token(&self) -> String { 20 | self.access_token.clone() 21 | } 22 | /// The access token, unchecked. Might be expired or in other ways invalid. 23 | async fn access_token_unchecked(&self) -> String { 24 | self.access_token.clone() 25 | } 26 | /// The reqwest http client. 27 | /// The `Client` holds a connection pool internally, so it is advised that it is reused for multiple, successive connections. 28 | fn client(&self) -> &reqwest::Client { 29 | &self.client 30 | } 31 | } 32 | 33 | async fn run() -> errors::Result<()> { 34 | let credentials = Credentials::from_file("firebase-service-account.json").await?; 35 | #[derive(serde::Serialize)] 36 | struct TestData { 37 | an_int: u32, 38 | } 39 | let t = TestData { an_int: 12 }; 40 | 41 | let session = MyOwnSession { 42 | credentials, 43 | client: reqwest::Client::new(), 44 | access_token: "The access token".to_owned(), 45 | }; 46 | 47 | // Use any of the document functions with your own session object 48 | documents::write( 49 | &session, 50 | "tests", 51 | Some("test_doc"), 52 | &t, 53 | documents::WriteOptions::default(), 54 | ) 55 | .await?; 56 | Ok(()) 57 | } 58 | 59 | #[tokio::main] 60 | async fn main() -> errors::Result<()> { 61 | run().await 62 | } 63 | 64 | #[tokio::test] 65 | async fn own_auth_test() { 66 | if let Err(APIError(code, str_code, context)) = run().await { 67 | assert_eq!(str_code, "Request had invalid authentication credentials. Expected OAuth 2 access token, login cookie or other valid authentication credential. See https://developers.google.com/identity/sign-in/web/devconsole-project."); 68 | assert_eq!(context, "test_doc"); 69 | assert_eq!(code, 401); 70 | return; 71 | } 72 | panic!("Expected a failure with invalid access token"); 73 | } 74 | -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | All examples expects a "firebase-service-account.json" file in the working directory. 4 | 5 | ## Create/Read/Write document example 6 | 7 | A document is created / read / and writen to in full and partially in this example. 8 | A service account session is used as well as a firebase impersonated user session. 9 | 10 | * Build and run with `cargo run --example create_read_write_document`. 11 | 12 | ## Own authentication mechanism example 13 | 14 | This example shows how to use your own implementation of FirebaseAuthBearer and avoid using any of the `session` 15 | types. 16 | 17 | * Build and run with `cargo run --example own_auth`. 18 | 19 | ## Firebase user interaction example 20 | 21 | This example shows how to print all available information about a firebase user, 22 | identified by the firebase user id. 23 | 24 | * Build and run with `cargo run --example firebase_user`. 25 | 26 | ## Session cookie example 27 | 28 | This example shows how to exchange a ID token, usually given by the firebase web framework on the client side, 29 | into a server-side session cookie. 30 | 31 | Firebase Auth provides server-side session cookie management for traditional websites that rely on session cookies. 32 | This solution has several advantages over client-side short-lived ID tokens, 33 | which may require a redirect mechanism each time to update the session cookie on expiration. 34 | 35 | * Build and run with `cargo run --example session_cookie`. 36 | 37 | ## Rocket Protected Route example 38 | 39 | [Rocket](https://rocket.rs) is a an easy to use web-framework for Rust. 40 | 41 | This example shows how to protect an http route by only allowing logged in users. 42 | 43 | * Build and run with `cargo run --example rocket_http_protected_route`. 44 | * Surf to http://127.0.0.1:8000/create_test_user. A firebase user "_test" will be created and an access token is printed. 45 | * Surf to http://127.0.0.1:8000/hello?auth=A_FIREBASE_ACCESS_TOKEN and to http://127.0.0.1:8000/hello 46 | * Surf to http://127.0.0.1:8000/remove_test_user to remove the created test user again. 47 | -------------------------------------------------------------------------------- /examples/rocket_http_protected_route.rs: -------------------------------------------------------------------------------- 1 | #![feature(proc_macro_hygiene, decl_macro)] 2 | 3 | use firestore_db_and_auth::{rocket::FirestoreAuthSessionGuard, Credentials}; 4 | use rocket::config::Environment; 5 | use rocket::{get, routes, Config}; 6 | 7 | fn main() { 8 | let credentials = Credentials::from_file("firebase-service-account.json").unwrap(); 9 | 10 | let config = Config::build(Environment::Staging).port(8000).finalize()?; 11 | 12 | rocket::custom(config) 13 | .manage(credentials) 14 | .mount("/", routes![hello, hello_not_logged_in]) 15 | .launch(); 16 | } 17 | 18 | /// Example route. Try with /hello?auth=THE_TOKEN. This works because the auth guard 19 | /// either accepts an "Authorization: Bearer THE_TOKEN" http header or an url parameter "auth". 20 | #[get("/hello")] 21 | fn hello<'r>(auth: FirestoreAuthSessionGuard) -> String { 22 | // ApiKey is a single value tuple with a sessions::user::Session object inside 23 | format!("you are logged in. user_id: {}", auth.0.user_id) 24 | } 25 | 26 | #[get("/hello")] 27 | fn hello_not_logged_in<'r>() -> &'r str { 28 | "you are not logged in" 29 | } 30 | -------------------------------------------------------------------------------- /examples/session_cookie.rs: -------------------------------------------------------------------------------- 1 | use firestore_db_and_auth::{errors::FirebaseError, sessions::session_cookie, Credentials, FirebaseAuthBearer}; 2 | 3 | use chrono::Duration; 4 | 5 | mod utils; 6 | 7 | #[tokio::main] 8 | async fn main() -> Result<(), FirebaseError> { 9 | // Search for a credentials file in the root directory 10 | use std::path::PathBuf; 11 | 12 | let mut credential_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 13 | credential_file.push("firebase-service-account.json"); 14 | let cred = Credentials::from_file(credential_file.to_str().unwrap()).await?; 15 | 16 | // Only download the public keys once, and cache them. 17 | let jwkset = utils::from_cache_file(credential_file.with_file_name("cached_jwks.jwks").as_path(), &cred).await?; 18 | cred.add_jwks_public_keys(&jwkset).await; 19 | cred.verify().await?; 20 | 21 | let user_session = utils::user_session_with_cached_refresh_token(&cred).await?; 22 | 23 | let cookie = session_cookie::create(&cred, user_session.access_token().await, Duration::seconds(3600)).await?; 24 | println!("Created session cookie: {}", cookie); 25 | 26 | Ok(()) 27 | } 28 | 29 | #[test] 30 | fn create_session_cookie_test() -> Result<(), FirebaseError> { 31 | let cred = utils::valid_test_credentials()?; 32 | let user_session = utils::user_session_with_cached_refresh_token(&cred)?; 33 | 34 | assert_eq!(user_session.user_id, utils::TEST_USER_ID); 35 | assert_eq!(user_session.project_id(), cred.project_id); 36 | 37 | use chrono::Duration; 38 | let cookie = session_cookie::create(&cred, user_session.access_token(), Duration::seconds(3600))?; 39 | 40 | assert!(cookie.len() > 0); 41 | Ok(()) 42 | } 43 | -------------------------------------------------------------------------------- /examples/test_user_id.txt: -------------------------------------------------------------------------------- 1 | Io2cPph06rUWM3ABcIHguR3CIw6v1 -------------------------------------------------------------------------------- /examples/utils/mod.rs: -------------------------------------------------------------------------------- 1 | use firestore_db_and_auth::{errors, sessions, Credentials, JWKSet}; 2 | 3 | use firestore_db_and_auth::jwt::download_google_jwks; 4 | 5 | #[allow(dead_code)] 6 | pub const TEST_USER_ID: &str = include_str!("../test_user_id.txt"); 7 | 8 | pub async fn user_session_with_cached_refresh_token(cred: &Credentials) -> errors::Result { 9 | println!("Refresh token from file"); 10 | // Read refresh token from file if possible instead of generating a new refresh token each time 11 | let refresh_token: String = match std::fs::read_to_string("refresh-token-for-tests.txt") { 12 | Ok(v) => v, 13 | Err(e) => { 14 | if e.kind() != std::io::ErrorKind::NotFound { 15 | return Err(errors::FirebaseError::IO(e)); 16 | } 17 | String::new() 18 | } 19 | }; 20 | 21 | // Generate a new refresh token if necessary 22 | println!("Generate new user auth token"); 23 | let user_session: sessions::user::Session = if refresh_token.is_empty() { 24 | let session = sessions::user::Session::by_user_id(&cred, TEST_USER_ID, true).await?; 25 | std::fs::write("refresh-token-for-tests.txt", &session.refresh_token.as_ref().unwrap())?; 26 | session 27 | } else { 28 | println!("user::Session::by_refresh_token"); 29 | sessions::user::Session::by_refresh_token(&cred, &refresh_token).await? 30 | }; 31 | 32 | Ok(user_session) 33 | } 34 | 35 | /// Download the two public key JWKS files if necessary and cache the content at the given file path. 36 | /// Only use this option in cloud functions if the given file path is persistent. 37 | /// You can use [`Credentials::add_jwks_public_keys`] to manually add more public keys later on. 38 | pub async fn from_cache_file(cache_file: &std::path::Path, c: &Credentials) -> errors::Result { 39 | use std::fs::File; 40 | use std::io::BufReader; 41 | 42 | Ok(if cache_file.exists() { 43 | let f = BufReader::new(File::open(cache_file)?); 44 | let jwks_set: JWKSet = serde_json::from_reader(f)?; 45 | jwks_set 46 | } else { 47 | // If not present, download the two jwks (specific service account + google system account), 48 | // merge them into one set of keys and store them in the cache file. 49 | let jwk_set_1 = download_google_jwks(&c.client_email).await?; 50 | let jwk_set_2 = download_google_jwks("securetoken@system.gserviceaccount.com").await?; 51 | 52 | let mut jwks = JWKSet::new(&jwk_set_1.0)?; 53 | jwks.keys.append(&mut JWKSet::new(&jwk_set_2.0)?.keys); 54 | let f = File::create(cache_file)?; 55 | serde_json::to_writer_pretty(f, &jwks)?; 56 | jwks 57 | }) 58 | } 59 | 60 | /// For integration tests and doc code snippets: Create a Credentials instance. 61 | /// Necessary public jwk sets are downloaded or re-used if already present. 62 | #[cfg(test)] 63 | #[allow(dead_code)] 64 | pub async fn valid_test_credentials() -> errors::Result { 65 | use std::path::PathBuf; 66 | let mut jwks_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 67 | jwks_path.push("firebase-service-account.jwks"); 68 | 69 | let cred: Credentials = Credentials::new(include_str!("../../firebase-service-account.json")).await?; 70 | 71 | // Only download the public keys once, and cache them. 72 | let jwkset = from_cache_file(jwks_path.as_path(), &cred).await?; 73 | cred.add_jwks_public_keys(&jwkset).await; 74 | cred.verify().await?; 75 | 76 | Ok(cred) 77 | } 78 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Firestore API and Auth 2 | 3 | Firestore Logo, Copyright by Google 4 | 5 | [![Integration](https://github.com/davidgraeff/firestore-db-and-auth-rs/actions/workflows/integration.yml/badge.svg)](https://github.com/davidgraeff/firestore-db-and-auth-rs/actions/workflows/integration.yml) 6 | [![With Rocket](https://github.com/davidgraeff/firestore-db-and-auth-rs/actions/workflows/with_rocket.yml/badge.svg)](https://github.com/davidgraeff/firestore-db-and-auth-rs/actions/workflows/with_rocket.yml) 7 | [![](https://meritbadge.herokuapp.com/firestore-db-and-auth)](https://crates.io/crates/firestore-db-and-auth) 8 | [![](https://img.shields.io/badge/license-MIT-blue.svg)](http://opensource.org/licenses/MIT) 9 | ![docs.rs](https://img.shields.io/docsrs/firestore-db-and-auth) 10 | 11 | This crate allows easy access to your Google Firestore DB via service account or OAuth impersonated Google Firebase Auth credentials. 12 | Minimum Rust version: 1.64 13 | 14 | Features: 15 | * Asynchronous API 16 | * Subset of the Firestore v1 API 17 | * Optionally handles authentication and token refreshing for you 18 | * Support for the downloadable Google service account json file from [Google Clound console](https://console.cloud.google.com/apis/credentials/serviceaccountkey). 19 | (See https://cloud.google.com/storage/docs/reference/libraries#client-libraries-install-cpp) 20 | 21 | Use-cases: 22 | * Strictly typed document read/write/query access 23 | * Cloud functions (Google Compute, AWS Lambda) access to Firestore 24 | 25 | Limitations: 26 | * Listening to document / collection changes is not yet possible 27 | 28 | ### Cargo features 29 | 30 | * **native-tls**, **default-tls**, **rustls-tls**: Choose any of those features for encrypted connections (https). 31 | rustls-tls is the default (the rustls crate will be used). 32 | 33 | * **rocket_support**: [Rocket](https://rocket.rs/) is a web framework. 34 | This feature enables rocket integration and adds a [Request Guard](https://rocket.rs/v0.4/guide/requests/#request-guards). 35 | Only Firestore Auth authorized requests can pass this guard. 36 | 37 | ### Document operations 38 | 39 | This crate operates on DTOs (Data transfer objects) for type-safe operations on your Firestore DB. 40 | 41 | ```rust,no_run 42 | use firestore_db_and_auth::{Credentials, ServiceSession, documents, errors::Result}; 43 | use serde::{Serialize,Deserialize}; 44 | 45 | #[derive(Serialize, Deserialize)] 46 | struct DemoDTO { 47 | a_string: String, 48 | an_int: u32, 49 | another_int: u32, 50 | } 51 | #[derive(Serialize, Deserialize)] 52 | struct DemoPartialDTO { 53 | #[serde(skip_serializing_if = "Option::is_none")] 54 | a_string: Option, 55 | an_int: u32, 56 | } 57 | 58 | /// Write the given object with the document id "service_test" to the "tests" collection. 59 | /// You do not need to provide a document id (use "None" instead) and let Firestore generate one for you. 60 | /// 61 | /// In either way a document is created or updated (overwritten). 62 | /// 63 | /// The write method will return document metadata (including a possible generated document id) 64 | fn write(session: &ServiceSession) -> Result<()> { 65 | let obj = DemoDTO { a_string: "abcd".to_owned(), an_int: 14, another_int: 10 }; 66 | let result = documents::write(session, "tests", Some("service_test"), &obj, documents::WriteOptions::default())?; 67 | println!("id: {}, created: {}, updated: {}", result.document_id, result.create_time.unwrap(), result.update_time.unwrap()); 68 | Ok(()) 69 | } 70 | 71 | /// Only write some fields and do not overwrite the entire document. 72 | /// Either via Option<> or by not having the fields in the structure, see DemoPartialDTO. 73 | fn write_partial(session: &ServiceSession) -> Result<()> { 74 | let obj = DemoPartialDTO { a_string: None, an_int: 16 }; 75 | let result = documents::write(session, "tests", Some("service_test"), &obj, documents::WriteOptions{merge:true})?; 76 | println!("id: {}, created: {}, updated: {}", result.document_id, result.create_time.unwrap(), result.update_time.unwrap()); 77 | Ok(()) 78 | } 79 | ``` 80 | 81 | Read the document with the id "service_test" from the Firestore "tests" collection: 82 | 83 | ```rust,no_run 84 | use firestore_db_and_auth::{documents}; 85 | let obj : DemoDTO = documents::read(&session, "tests", "service_test")?; 86 | ``` 87 | 88 | For listing all documents of the "tests" collection you want to use the `list` method which implements an async stream. 89 | This hide the complexity of the paging API and fetches new documents when necessary: 90 | 91 | ```rust,no_run 92 | use firestore_db_and_auth::{documents}; 93 | 94 | let mut stream = documents::list(&session, "tests"); 95 | while let Some(Ok(doc_result)) = stream.next().await { 96 | // The document is wrapped in a Result<> because fetching new data could have failed 97 | let (doc, _metadata) = doc_result; 98 | let doc: DemoDTO = doc; 99 | println!("{:?}", doc); 100 | } 101 | ``` 102 | 103 | *Note:* The resulting list or list cursor is a snapshot view with a limited lifetime. 104 | You cannot keep the iterator/stream for long or expect new documents to appear in an ongoing iteration. 105 | 106 | For querying the database you would use the `query` method. 107 | In the following example the collection "tests" is queried for document(s) with the "id" field equal to "Sam Weiss". 108 | 109 | ```rust,no_run 110 | use firestore_db_and_auth::{documents, dto}; 111 | 112 | let values = documents::query(&session, "tests", "Sam Weiss".into(), dto::FieldOperator::EQUAL, "id").await?; 113 | for metadata in values { 114 | println!("id: {}, created: {}, updated: {}", metadata.name.as_ref().unwrap(), metadata.create_time.as_ref().unwrap(), metadata.update_time.as_ref().unwrap()); 115 | // Fetch the actual document 116 | // The data is wrapped in a Result<> because fetching new data could have failed 117 | let doc : DemoDTO = documents::read_by_name(&session, metadata.name.as_ref().unwrap())?; 118 | println!("{:?}", doc); 119 | } 120 | ``` 121 | 122 | Did you notice the `into` on `"Sam Weiss".into()`? 123 | Firestore stores document fields strongly typed. 124 | The query value can be a string, an integer, a floating point number and potentially even an array or object (not tested). 125 | 126 | *Note:* The query method returns a vector, because a query potentially returns multiple matching documents. 127 | 128 | ### Error handling 129 | 130 | The returned `Result` will have a `FirebaseError` set in any error case. 131 | This custom error type wraps all possible errors (IO, Reqwest, JWT errors etc) 132 | and Google REST API errors. If you want to specifically check for an API error, 133 | you could do so: 134 | 135 | ```rust,no_run 136 | use firestore_db_and_auth::{documents, errors::FirebaseError}; 137 | 138 | let r = documents::delete(&session, "tests/non_existing", true).await; 139 | if let Err(e) = r.err() { 140 | if let FirebaseError::APIError(code, message, context) = e { 141 | assert_eq!(code, 404); 142 | assert!(message.contains("No document to update")); 143 | assert_eq!(context, "tests/non_existing"); 144 | } 145 | } 146 | ``` 147 | 148 | The code is numeric, the message is what the Google server returned as message. 149 | The context string depends on the called method. 150 | It may be the collection or document id or any other context information. 151 | 152 | ### Document access via service account 153 | 154 | 1. Download the service accounts credentials file and store it as "firebase-service-account.json". 155 | The file should contain `"private_key_id": ...`. 156 | 2. Add another field `"api_key" : "YOUR_API_KEY"` and replace YOUR_API_KEY with your *Web API key*, to be found in the [Google Firebase console](https://console.firebase.google.com) in "Project Overview -> Settings - > General". 157 | 158 | ```rust,no_run 159 | use firestore_db_and_auth::{Credentials, ServiceSession}; 160 | 161 | /// Create credentials object. You may as well do that programmatically. 162 | let cred = Credentials::from_file("firebase-service-account.json").await 163 | .expect("Read credentials file") 164 | .download_jwkset().await 165 | .expect("Failed to download public keys"); 166 | /// To use any of the Firestore methods, you need a session first. You either want 167 | /// an impersonated session bound to a Firebase Auth user or a service account session. 168 | let session = ServiceSession::new(&cred) 169 | .expect("Create a service account session"); 170 | ``` 171 | 172 | ### Document access via a firebase user access / refresh token or via user_id 173 | 174 | You can create a user session in various ways. 175 | If you just have the firebase Auth user_id, you would follow these steps: 176 | 177 | ```rust,no_run 178 | use firestore_db_and_auth::{Credentials, sessions}; 179 | 180 | /// Create credentials object. You may as well do that programmatically. 181 | let cred = Credentials::from_file("firebase-service-account.json").await 182 | .expect("Read credentials file") 183 | .download_jwkset().await 184 | .expect("Failed to download public keys"); 185 | 186 | /// To use any of the Firestore methods, you need a session first. 187 | /// Create an impersonated session bound to a Firebase Auth user via your service account credentials. 188 | let session = UserSession::by_user_id(&cred, "the_user_id").await 189 | .expect("Create a user session"); 190 | ``` 191 | 192 | If you already have a valid refresh token and want to generate an access token (and a session object), you do this instead: 193 | 194 | ```rust,no_run 195 | let refresh_token = "fkjandsfbajsbfd;asbfdaosa.asduabsifdabsda,fd,a,sdbasfadfasfas.dasdasbfadusbflansf"; 196 | let session = UserSession::by_refresh_token(&cred, &refresh_token).await?; 197 | ``` 198 | 199 | Another way of retrieving a session object is by providing a valid access token like so: 200 | 201 | ```rust,no_run 202 | let access_token = "fkjandsfbajsbfd;asbfdaosa.asduabsifdabsda,fd,a,sdbasfadfasfas.dasdasbfadusbflansf"; 203 | let session = UserSession::by_access_token(&cred, &access_token).await?; 204 | ``` 205 | 206 | The `by_access_token` method will fail if the token is not valid anymore. 207 | Please note that a session created this way is not able to automatically refresh its access token. 208 | (There is no *refresh_token* associated with it.) 209 | 210 | ## Cloud functions: Improve cold-start time 211 | 212 | The usual start up procedure includes three IO operations: 213 | 214 | * downloading two public jwks keys from a Google server, 215 | * and read in the json credentials file. 216 | 217 | Avoid those by embedding the credentials and public key files into your application. 218 | 219 | First download the 2 public key files: 220 | 221 | * https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com -> Store as `securetoken.jwks` 222 | * https://www.googleapis.com/service_accounts/v1/jwk/{your-service-account-email} -> Store as `service-account.jwks` 223 | * Merge the two files into one `firebase-service-account.jwks` 224 | 225 | Create a `Credentials` object like so: 226 | 227 | ```rust,no_run 228 | use firestore_db_and_auth::Credentials; 229 | let c = Credentials::new(include_str!("firebase-service-account.json")).await? 230 | .with_jwkset(&JWKSet::new(include_str!("firebase-service-account.jwks"))?).await?; 231 | ``` 232 | 233 | > Please note though, that Googles JWK keys change periodically. 234 | > You probably want to redeploy your service with fresh public keys about every three weeks. 235 | > 236 | > For long-running service you want to call Credentials::download_google_jwks() periodically. 237 | 238 | ### More information 239 | 240 | * [Firestore Auth: Background information](doc/auth_background.md) 241 | * [Use your own authentication implementation](doc/own_auth.md) 242 | * [Http Rocket Server integration](doc/rocket_integration.md) 243 | * Build the documentation locally with `cargo +nightly doc --features external_doc,rocket_support` 244 | 245 | ## Testing 246 | 247 | To perform a full integration test (`cargo test`), you need a valid "firebase-service-account.json" file. 248 | The tests expect a Firebase user with the ID given in `examples/test_user_id.txt` to exist. 249 | [More Information](/doc/integration_tests.md) 250 | 251 | #### What can be done to make this crate more awesome 252 | 253 | This library does not have the ambition to mirror the http/gRPC API 1:1. 254 | There are auto-generated libraries for this purpose. But the following fits into the crates schema: 255 | 256 | * Nice to have: Transactions, batch_get support for Firestore 257 | 258 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 120 -------------------------------------------------------------------------------- /src/credentials.rs: -------------------------------------------------------------------------------- 1 | //! # Credentials for accessing the Firebase REST API 2 | //! This module contains the [`crate::credentials::Credentials`] type, used by [`crate::sessions`] to create and maintain 3 | //! authentication tokens for accessing the Firebase REST API. 4 | 5 | use base64::prelude::BASE64_STANDARD; 6 | use base64::Engine; 7 | use chrono::{offset, DateTime, Duration}; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json; 10 | use std::collections::BTreeMap; 11 | use std::fmt; 12 | use std::fs::File; 13 | use std::io::BufReader; 14 | use std::sync::Arc; 15 | use tokio::sync::RwLock; 16 | 17 | use super::jwt::{create_jwt_encoded, download_google_jwks, verify_access_token, JWKSet, JWT_AUDIENCE_IDENTITY}; 18 | use crate::{errors::FirebaseError, jwt::TokenValidationResult}; 19 | 20 | type Error = super::errors::FirebaseError; 21 | 22 | /// This is not defined in the json file and computed 23 | #[derive(Default, Clone)] 24 | pub(crate) struct Keys { 25 | pub pub_key: BTreeMap>, 26 | pub pub_key_expires_at: Option>, 27 | pub secret: Option>, 28 | } 29 | 30 | impl fmt::Debug for Keys { 31 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 32 | f.debug_struct("Keys") 33 | .field("pub_key_expires_at", &self.pub_key_expires_at) 34 | .field("pub_key", &self.pub_key.keys().collect::>()) 35 | .field("secret", &self.secret.is_some()) 36 | .finish() 37 | } 38 | } 39 | 40 | /// Service account credentials 41 | /// 42 | /// Especially the service account email is required to retrieve the public json web key set (jwks) 43 | /// for verifying Google Firestore tokens. 44 | /// 45 | /// The api_key is necessary for interacting with the Firestore REST API. 46 | /// 47 | /// Internals: 48 | /// 49 | /// The private key is used for signing JWTs (javascript web token). 50 | /// A signed jwt, encoded as a base64 string, can be exchanged into a refresh and access token. 51 | #[derive(Serialize, Deserialize, Default, Clone, Debug)] 52 | pub struct Credentials { 53 | pub project_id: String, 54 | pub private_key_id: String, 55 | pub private_key: String, 56 | pub client_email: String, 57 | pub client_id: String, 58 | pub api_key: String, 59 | /// The public keys. Those will rotate over time. 60 | /// Altering the keys is still a rare operation, so access should 61 | /// be optimized for reading, hence the RwLock. 62 | #[serde(default, skip)] 63 | pub(crate) keys: Arc>, 64 | } 65 | 66 | /// Converts a PEM (ascii base64) encoded private key into the binary der representation 67 | pub fn pem_to_der(pem_file_contents: &str) -> Result, Error> { 68 | let pem_file_contents = pem_file_contents 69 | .find("-----BEGIN") 70 | // Cut off the first BEGIN part 71 | .and_then(|i| Some(&pem_file_contents[i + 10..])) 72 | // Find the trailing ---- after BEGIN and cut that off 73 | .and_then(|str| str.find("-----").and_then(|i| Some(&str[i + 5..]))) 74 | // Cut off -----END 75 | .and_then(|str| str.rfind("-----END").and_then(|i| Some(&str[..i]))); 76 | if pem_file_contents.is_none() { 77 | return Err(FirebaseError::Generic( 78 | "Invalid private key in credentials file. Must be valid PEM.", 79 | )); 80 | } 81 | 82 | let base64_body = pem_file_contents.unwrap().replace("\n", ""); 83 | Ok(BASE64_STANDARD 84 | .decode(&base64_body) 85 | .map_err(|_| FirebaseError::Generic("Invalid private key in credentials file. Expected Base64 data."))?) 86 | } 87 | 88 | #[test] 89 | fn pem_to_der_test() { 90 | const INPUT: &str = r#"-----BEGIN PRIVATE KEY----- 91 | MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCTbt9Rs2niyIRE 92 | FIdrhIN757eq/1Ry/VhZALBXAveg+lt+ui/9EHtYPJH1A9NyyAwChs0UCRWqkkEo 93 | Amtz4dJQ1YlGi0/BGhK2lg== 94 | -----END PRIVATE KEY----- 95 | "#; 96 | const EXPECTED: [u8; 112] = [ 97 | 48, 130, 4, 188, 2, 1, 0, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 4, 130, 4, 166, 48, 130, 4, 98 | 162, 2, 1, 0, 2, 130, 1, 1, 0, 147, 110, 223, 81, 179, 105, 226, 200, 132, 68, 20, 135, 107, 132, 131, 123, 99 | 231, 183, 170, 255, 84, 114, 253, 88, 89, 0, 176, 87, 2, 247, 160, 250, 91, 126, 186, 47, 253, 16, 123, 88, 60, 100 | 145, 245, 3, 211, 114, 200, 12, 2, 134, 205, 20, 9, 21, 170, 146, 65, 40, 2, 107, 115, 225, 210, 80, 213, 137, 101 | 70, 139, 79, 193, 26, 18, 182, 150, 102 | ]; 103 | 104 | assert_eq!(&EXPECTED[..], &pem_to_der(INPUT).unwrap()[..]); 105 | } 106 | 107 | impl Credentials { 108 | /// Create a [`Credentials`] object by parsing a google-service-account json string 109 | /// 110 | /// Example: 111 | /// 112 | /// Assuming that your firebase service account credentials file is called "service-account-test.json" and 113 | /// a downloaded jwk-set file is called "service-account-test.jwks" this example embeds 114 | /// the file content during compile time. This avoids and http or io calls. 115 | /// 116 | /// ``` 117 | /// use firestore_db_and_auth::{Credentials}; 118 | /// use firestore_db_and_auth::jwt::JWKSet; 119 | /// 120 | /// # tokio_test::block_on(async { 121 | /// let c: Credentials = Credentials::new(include_str!("../tests/service-account-test.json")).await.unwrap() 122 | /// .with_jwkset(&JWKSet::new(include_str!("../tests/service-account-test.jwks")).unwrap()).await.unwrap(); 123 | /// # }) 124 | /// ``` 125 | /// 126 | /// You need two JWKS files for this crate to work: 127 | /// * https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com 128 | /// * https://www.googleapis.com/service_accounts/v1/jwk/{your-service-account-email} 129 | pub async fn new(credentials_file_content: &str) -> Result { 130 | let mut credentials: Credentials = serde_json::from_str(credentials_file_content)?; 131 | credentials.compute_secret().await?; 132 | Ok(credentials) 133 | } 134 | 135 | /// Create a [`Credentials`] object by reading and parsing a google-service-account json file. 136 | /// 137 | /// This is a convenience method, that reads in the given credentials file and acts otherwise the same as 138 | /// the [`Credentials::new`] method. 139 | pub async fn from_file(credential_file: &str) -> Result { 140 | let f = BufReader::new(File::open(credential_file)?); 141 | let mut credentials: Credentials = serde_json::from_reader(f)?; 142 | credentials.compute_secret().await?; 143 | Ok(credentials) 144 | } 145 | 146 | /// Adds public-key JWKs to a credentials instance and returns it. 147 | /// 148 | /// This method will also verify that the given JWKs files allow verification of Google access tokens. 149 | /// This is a convenience method, you may also just use [`Credentials::add_jwks_public_keys`]. 150 | pub async fn with_jwkset(self, jwks: &JWKSet) -> Result { 151 | self.add_jwks_public_keys(jwks).await; 152 | self.verify().await?; 153 | Ok(self) 154 | } 155 | 156 | /// The public keys to verify generated tokens will be downloaded, for the given service account as well as 157 | /// for "securetoken@system.gserviceaccount.com". 158 | /// Do not use this option if additional downloads are not desired, 159 | /// for example in cloud functions that require fast cold boot start times. 160 | /// 161 | /// You can use [`Credentials::add_jwks_public_keys`] to manually add/replace public keys later on. 162 | /// 163 | /// Example: 164 | /// 165 | /// Assuming that your firebase service account credentials file is called "service-account-test.json". 166 | /// 167 | /// ```no_run 168 | /// use firestore_db_and_auth::{Credentials}; 169 | /// 170 | /// # tokio_test::block_on(async { 171 | /// let c: Credentials = Credentials::new(include_str!("../tests/service-account-test.json")).await.unwrap() 172 | /// .download_jwkset().await.unwrap(); 173 | /// # }) 174 | /// ``` 175 | pub async fn download_jwkset(self) -> Result { 176 | self.download_google_jwks().await?; 177 | self.verify().await?; 178 | Ok(self) 179 | } 180 | 181 | /// Verifies that creating access tokens is possible with the given credentials and public keys. 182 | /// Returns an empty result type on success. 183 | pub async fn verify(&self) -> Result<(), Error> { 184 | let access_token = create_jwt_encoded( 185 | &self, 186 | Some(["admin"].iter()), 187 | Duration::hours(1), 188 | Some(self.client_id.clone()), 189 | None, 190 | JWT_AUDIENCE_IDENTITY, 191 | ) 192 | .await?; 193 | verify_access_token(&self, &access_token).await?; 194 | Ok(()) 195 | } 196 | 197 | pub async fn verify_token(&self, token: &str) -> Result { 198 | verify_access_token(&self, token).await 199 | } 200 | 201 | /// Find the secret in the jwt set that matches the given key id, if any. 202 | /// Used for jws validation 203 | pub async fn decode_secret(&self, kid: &str) -> Result>, Error> { 204 | let should_refresh = { 205 | let keys = self.keys.read().await; 206 | keys.pub_key_expires_at 207 | .map(|expires_at| expires_at - offset::Utc::now() < Duration::minutes(10)) 208 | .unwrap_or(false) 209 | }; 210 | 211 | if should_refresh { 212 | self.download_google_jwks().await?; 213 | } 214 | 215 | Ok(self.keys.read().await.pub_key.get(kid).and_then(|f| Some(f.clone()))) 216 | } 217 | 218 | /// Add a JSON Web Key Set (JWKS) to allow verification of Google access tokens. 219 | /// 220 | /// Example: 221 | /// 222 | /// ``` 223 | /// use firestore_db_and_auth::credentials::Credentials; 224 | /// use firestore_db_and_auth::JWKSet; 225 | /// 226 | /// # tokio_test::block_on(async { 227 | /// let mut c : Credentials = serde_json::from_str(include_str!("../tests/service-account-test.json")).unwrap(); 228 | /// c.add_jwks_public_keys(&JWKSet::new(include_str!("../tests/service-account-test.jwks")).unwrap()).await; 229 | /// c.compute_secret().await.unwrap(); 230 | /// c.verify().await.unwrap(); 231 | /// # }) 232 | /// ``` 233 | pub async fn add_jwks_public_keys(&self, jwkset: &JWKSet) { 234 | let key_lock = self.keys.write(); 235 | let keys = &mut key_lock.await.pub_key; 236 | 237 | for entry in jwkset.keys.iter() { 238 | if !entry.headers.key_id.is_some() { 239 | continue; 240 | } 241 | 242 | let key_id = entry.headers.key_id.as_ref().unwrap().to_owned(); 243 | keys.insert(key_id, Arc::new(entry.ne.jws_public_key_secret())); 244 | } 245 | } 246 | 247 | /// If you haven't called [`Credentials::add_jwks_public_keys`] to manually add public keys, 248 | /// this method will download one for your google service account and one for the oauth related 249 | /// securetoken@system.gserviceaccount.com service account. 250 | pub async fn download_google_jwks(&self) -> Result<(), Error> { 251 | { 252 | let mut keys = self.keys.write().await; 253 | keys.pub_key = BTreeMap::new(); 254 | } 255 | 256 | let (jwks, max_age_client) = download_google_jwks(&self.client_email).await?; 257 | self.add_jwks_public_keys(&JWKSet::new(&jwks)?).await; 258 | let (jwks, max_age_public) = download_google_jwks("securetoken@system.gserviceaccount.com").await?; 259 | self.add_jwks_public_keys(&JWKSet::new(&jwks)?).await; 260 | 261 | let default_expiration = Duration::hours(2); 262 | let max_age_client = max_age_client.unwrap_or(default_expiration); 263 | let max_age_public = max_age_public.unwrap_or(default_expiration); 264 | 265 | let expires_at = if max_age_client < max_age_public { 266 | max_age_client 267 | } else { 268 | max_age_public 269 | }; 270 | 271 | { 272 | let mut keys = self.keys.write().await; 273 | keys.pub_key_expires_at = Some(offset::Utc::now() + expires_at); 274 | } 275 | 276 | Ok(()) 277 | } 278 | 279 | /// Compute the Rsa keypair by using the private_key of the credentials file. 280 | /// You must call this if you have manually created a credentials object. 281 | /// 282 | /// This is automatically invoked if you use [`Credentials::new`] or [`Credentials::from_file`]. 283 | pub async fn compute_secret(&mut self) -> Result<(), Error> { 284 | use biscuit::jws::Secret; 285 | use ring::signature; 286 | 287 | let vec = pem_to_der(&self.private_key)?; 288 | let key_pair = signature::RsaKeyPair::from_pkcs8(&vec)?; 289 | self.keys.write().await.secret = Some(Arc::new(Secret::RsaKeyPair(Arc::new(key_pair)))); 290 | Ok(()) 291 | } 292 | } 293 | 294 | #[doc(hidden)] 295 | #[allow(dead_code)] 296 | pub async fn doctest_credentials() -> Credentials { 297 | let jwk_list = JWKSet::new(include_str!("../tests/service-account-test.jwks")).unwrap(); 298 | Credentials::new(include_str!("../tests/service-account-test.json")) 299 | .await 300 | .expect("Failed to deserialize credentials") 301 | .with_jwkset(&jwk_list) 302 | .await 303 | .expect("JWK public keys verification failed") 304 | } 305 | 306 | #[tokio::test] 307 | async fn deserialize_credentials() { 308 | let jwk_list = JWKSet::new(include_str!("../tests/service-account-test.jwks")).unwrap(); 309 | let c: Credentials = Credentials::new(include_str!("../tests/service-account-test.json")) 310 | .await 311 | .expect("Failed to deserialize credentials") 312 | .with_jwkset(&jwk_list) 313 | .await 314 | .expect("JWK public keys verification failed"); 315 | assert_eq!(c.api_key, "api_key"); 316 | 317 | use std::path::PathBuf; 318 | let mut credential_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")); 319 | credential_file.push("tests/service-account-test.json"); 320 | 321 | let c = Credentials::from_file(credential_file.to_str().unwrap()) 322 | .await 323 | .expect("Failed to open credentials file") 324 | .with_jwkset(&jwk_list) 325 | .await 326 | .expect("JWK public keys verification failed"); 327 | assert_eq!(c.api_key, "api_key"); 328 | } 329 | -------------------------------------------------------------------------------- /src/documents/delete.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use crate::errors::extract_google_api_error_async; 3 | 4 | /// 5 | /// Deletes the document at the given path. 6 | /// 7 | /// You cannot use this directly with paths from [`list`] and [`query`] document metadata objects. 8 | /// Those contain an absolute document path. Use [`abs_to_rel`] to convert to a relative path. 9 | /// 10 | /// ## Arguments 11 | /// * 'auth' The authentication token 12 | /// * 'path' The relative collection path and document id, for example "my_collection/document_id" 13 | /// * 'fail_if_not_existing' If true this method will return an error if the document does not exist. 14 | pub async fn delete(auth: &impl FirebaseAuthBearer, path: &str, fail_if_not_existing: bool) -> Result<()> { 15 | let url = firebase_url(auth.project_id(), path); 16 | 17 | let query_request = dto::Write { 18 | current_document: Some(dto::Precondition { 19 | exists: match fail_if_not_existing { 20 | true => Some(true), 21 | false => None, 22 | }, 23 | ..Default::default() 24 | }), 25 | ..Default::default() 26 | }; 27 | 28 | let resp = auth 29 | .client() 30 | .delete(&url) 31 | .bearer_auth(auth.access_token().await.to_owned()) 32 | .json(&query_request) 33 | .send() 34 | .await?; 35 | 36 | extract_google_api_error_async(resp, || path.to_owned()).await?; 37 | 38 | Ok({}) 39 | } 40 | -------------------------------------------------------------------------------- /src/documents/list.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use bytes::Bytes; 3 | use core::pin::Pin; 4 | use futures::{ 5 | stream::{self, Stream}, 6 | task::{Context, Poll}, 7 | Future, 8 | }; 9 | use std::boxed::Box; 10 | 11 | /// List all documents of a given collection. 12 | /// 13 | /// Please note that this API acts as an iterator of same-like documents. 14 | /// This type is not suitable if you want to list documents of different types. 15 | /// 16 | /// Example: 17 | /// ```no_run 18 | /// # use futures::{FutureExt, StreamExt}; 19 | /// use serde::{Serialize, Deserialize}; 20 | /// #[derive(Debug, Serialize, Deserialize)] 21 | /// struct DemoDTO { a_string: String, an_int: u32, } 22 | /// 23 | /// use firestore_db_and_auth::documents; 24 | /// # tokio_test::block_on(async { 25 | /// # use firestore_db_and_auth::{credentials::Credentials, ServiceSession, errors::Result}; 26 | /// # use firestore_db_and_auth::credentials::doctest_credentials; 27 | /// # let session = ServiceSession::new(doctest_credentials().await).await.unwrap(); 28 | /// 29 | /// let mut stream = documents::list(&session, "tests"); 30 | /// while let Some(Ok(doc_result)) = stream.next().await { 31 | /// // The data is wrapped in a Result<> because fetching new data could have failed 32 | /// // A tuple is returned on success with the document itself and and metadata 33 | /// // with .name, .create_time, .update_time fields. 34 | /// let (doc, _metadata) = doc_result; 35 | /// let doc: DemoDTO = doc; 36 | /// println!("{:?}", doc); 37 | /// } 38 | /// # }) 39 | /// ``` 40 | /// 41 | /// ## Arguments 42 | /// * 'auth' The authentication token 43 | /// * 'collection_id' The document path / collection; For example "my_collection" or "a/nested/collection" 44 | pub fn list( 45 | auth: &AUTH, 46 | collection_id: impl Into, 47 | ) -> Pin> + Send>> 48 | where 49 | for<'b> T: Deserialize<'b> + 'static, 50 | AUTH: FirebaseAuthBearer + Clone + Send + Sync + 'static, 51 | { 52 | let auth = auth.clone(); 53 | let collection_id = collection_id.into(); 54 | 55 | Box::pin(stream::unfold( 56 | ListInner { 57 | url: firebase_url(auth.project_id(), &collection_id), 58 | auth, 59 | next_page_token: None, 60 | documents: vec![], 61 | current: 0, 62 | done: false, 63 | collection_id: collection_id.to_string(), 64 | }, 65 | |this| async move { 66 | let mut this = this.clone(); 67 | if this.done { 68 | return None; 69 | } 70 | 71 | if this.documents.len() <= this.current { 72 | let url = match &this.next_page_token { 73 | Some(next_page_token) => format!("{}pageToken={}", this.url, next_page_token), 74 | None => this.url.clone(), 75 | }; 76 | 77 | let result = get_new_data(&this.collection_id, &url, &this.auth).await; 78 | match result { 79 | Err(e) => { 80 | this.done = true; 81 | return Some((Err(e), this)); 82 | } 83 | Ok(v) => match v.documents { 84 | None => return None, 85 | Some(documents) => { 86 | this.documents = documents; 87 | this.current = 0; 88 | this.next_page_token = v.next_page_token; 89 | } 90 | }, 91 | } 92 | } 93 | 94 | let doc = this.documents.get(this.current).unwrap().clone(); 95 | 96 | this.current += 1; 97 | 98 | if this.documents.len() <= this.current && this.next_page_token.is_none() { 99 | this.done = true; 100 | } 101 | 102 | let result = document_to_pod(&doc, None); 103 | match result { 104 | Err(e) => Some((Err(e), this)), 105 | Ok(pod) => Some(( 106 | Ok(( 107 | pod, 108 | dto::Document { 109 | update_time: doc.update_time.clone(), 110 | create_time: doc.create_time.clone(), 111 | name: doc.name.clone(), 112 | fields: None, 113 | }, 114 | )), 115 | this, 116 | )), 117 | } 118 | }, 119 | )) 120 | } 121 | 122 | async fn get_new_data<'a>( 123 | collection_id: &str, 124 | url: &str, 125 | auth: &'a impl FirebaseAuthBearer, 126 | ) -> Result { 127 | let resp = auth 128 | .client() 129 | .get(url) 130 | .bearer_auth(auth.access_token().await) 131 | .send() 132 | .await?; 133 | 134 | let resp = extract_google_api_error_async(resp, || collection_id.to_owned()).await?; 135 | 136 | let json: dto::ListDocumentsResponse = resp.json().await?; 137 | Ok(json) 138 | } 139 | 140 | #[derive(Clone)] 141 | struct ListInner { 142 | auth: AUTH, 143 | next_page_token: Option, 144 | documents: Vec, 145 | current: usize, 146 | done: bool, 147 | url: String, 148 | collection_id: String, 149 | } 150 | -------------------------------------------------------------------------------- /src/documents/mod.rs: -------------------------------------------------------------------------------- 1 | //! # Firestore Document Access 2 | //! 3 | //! Interact with Firestore documents. 4 | //! Please check the root page of this documentation for examples. 5 | #![allow(unused_imports, dead_code)] 6 | use super::dto; 7 | use super::errors::{extract_google_api_error, extract_google_api_error_async, FirebaseError, Result}; 8 | use super::firebase_rest_to_rust::{document_to_pod, pod_to_document}; 9 | use super::FirebaseAuthBearer; 10 | 11 | use serde::{Deserialize, Serialize}; 12 | use std::path::Path; 13 | 14 | mod delete; 15 | mod list; 16 | mod query; 17 | mod read; 18 | mod write; 19 | 20 | pub use delete::*; 21 | pub use list::*; 22 | pub use query::*; 23 | pub use read::*; 24 | pub use write::*; 25 | 26 | /// An [`Iterator`] implementation that provides a join method 27 | /// 28 | /// [`Iterator`]: https://doc.rust-lang.org/std/iter/trait.Iterator.html 29 | pub trait JoinableIterator: Iterator { 30 | fn join(&mut self, sep: &str) -> String 31 | where 32 | Self::Item: std::fmt::Display, 33 | { 34 | use std::fmt::Write; 35 | match self.next() { 36 | None => String::new(), 37 | Some(first_elt) => { 38 | // estimate lower bound of capacity needed 39 | let (lower, _) = self.size_hint(); 40 | let mut result = String::with_capacity(sep.len() * lower); 41 | write!(&mut result, "{}", first_elt).unwrap(); 42 | for elt in self { 43 | result.push_str(sep); 44 | write!(&mut result, "{}", elt).unwrap(); 45 | } 46 | result 47 | } 48 | } 49 | } 50 | } 51 | 52 | impl<'a, VALUE> JoinableIterator for std::collections::hash_map::Keys<'a, String, VALUE> {} 53 | 54 | #[inline] 55 | fn firebase_url_query(v1: &str) -> String { 56 | format!( 57 | "https://firestore.googleapis.com/v1/projects/{}/databases/(default)/documents:runQuery", 58 | v1 59 | ) 60 | } 61 | 62 | #[inline] 63 | fn firebase_url_base(v1: &str) -> String { 64 | format!("https://firestore.googleapis.com/v1/{}", v1) 65 | } 66 | 67 | #[inline] 68 | fn firebase_url_extended(v1: &str, v2: &str, v3: &str) -> String { 69 | format!( 70 | "https://firestore.googleapis.com/v1/projects/{}/databases/(default)/documents/{}/{}", 71 | v1, v2, v3 72 | ) 73 | } 74 | 75 | #[inline] 76 | fn firebase_url(v1: &str, v2: &str) -> String { 77 | format!( 78 | "https://firestore.googleapis.com/v1/projects/{}/databases/(default)/documents/{}?", 79 | v1, v2 80 | ) 81 | } 82 | 83 | /// Converts an absolute path like "projects/{PROJECT_ID}/databases/(default)/documents/my_collection/document_id" 84 | /// into a relative document path like "my_collection/document_id" 85 | /// 86 | /// This is usually used to get a suitable path for [`delete`]. 87 | pub fn abs_to_rel(path: &str) -> &str { 88 | &path[path.find("(default)").unwrap() + 20..] 89 | } 90 | 91 | #[test] 92 | fn abs_to_rel_test() { 93 | assert_eq!( 94 | abs_to_rel("projects/{PROJECT_ID}/databases/(default)/documents/my_collection/document_id"), 95 | "my_collection/document_id" 96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/documents/query.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use std::vec::IntoIter; 3 | 4 | /// 5 | /// Queries the database for specific documents, for example all documents in a collection of 'type' == "car". 6 | /// 7 | /// Example: 8 | /// ```no_run 9 | /// # use serde::{Serialize, Deserialize}; 10 | /// #[derive(Debug, Serialize, Deserialize)] 11 | /// struct DemoDTO { a_string: String, an_int: u32, } 12 | /// 13 | /// use firestore_db_and_auth::{documents, dto}; 14 | /// # use firestore_db_and_auth::{credentials::Credentials, ServiceSession, errors::Result}; 15 | /// # use firestore_db_and_auth::credentials::doctest_credentials; 16 | /// # tokio_test::block_on(async { 17 | /// # let session = ServiceSession::new(doctest_credentials().await).await.unwrap(); 18 | /// 19 | /// let values: documents::Query = documents::query(&session, "tests", "Sam Weiss".into(), dto::FieldOperator::EQUAL, "id").await.unwrap(); 20 | /// for metadata in values { 21 | /// println!("id: {}, created: {}, updated: {}", &metadata.name, metadata.create_time.as_ref().unwrap(), metadata.update_time.as_ref().unwrap()); 22 | /// // Fetch the actual document 23 | /// // The data is wrapped in a Result<> because fetching new data could have failed 24 | /// let doc : DemoDTO = documents::read_by_name(&session, &metadata.name).await.unwrap(); 25 | /// println!("{:?}", doc); 26 | /// } 27 | /// # }) 28 | /// ``` 29 | /// 30 | /// ## Arguments 31 | /// * 'auth' The authentication token 32 | /// * 'collectionid' The collection id; "my_collection" or "a/nested/collection" 33 | /// * 'value' The query / filter value. For example "car". 34 | /// * 'operator' The query operator. For example "EQUAL". 35 | /// * 'field' The query / filter field. For example "type". 36 | pub async fn query( 37 | auth: &impl FirebaseAuthBearer, 38 | collection_id: &str, 39 | value: serde_json::Value, 40 | operator: dto::FieldOperator, 41 | field: &str, 42 | ) -> Result { 43 | let url = firebase_url_query(auth.project_id()); 44 | let value = crate::firebase_rest_to_rust::serde_value_to_firebase_value(&value); 45 | 46 | let query_request = dto::RunQueryRequest { 47 | structured_query: Some(dto::StructuredQuery { 48 | select: Some(dto::Projection { fields: None }), 49 | where_: Some(dto::Filter { 50 | field_filter: Some(dto::FieldFilter { 51 | value, 52 | op: operator, 53 | field: dto::FieldReference { 54 | field_path: field.to_owned(), 55 | }, 56 | }), 57 | ..Default::default() 58 | }), 59 | from: Some(vec![dto::CollectionSelector { 60 | collection_id: Some(collection_id.to_owned()), 61 | ..Default::default() 62 | }]), 63 | ..Default::default() 64 | }), 65 | ..Default::default() 66 | }; 67 | 68 | let resp = auth 69 | .client() 70 | .post(&url) 71 | .bearer_auth(auth.access_token().await) 72 | .json(&query_request) 73 | .send() 74 | .await?; 75 | 76 | let resp = extract_google_api_error_async(resp, || collection_id.to_owned()).await?; 77 | 78 | let json: Option> = resp.json().await?; 79 | 80 | Ok(Query(json.unwrap_or_default().into_iter())) 81 | } 82 | 83 | /// This type is returned as a result by [`query`]. 84 | /// Use it as an iterator. The query API returns a list of document references, not the documents itself. 85 | /// 86 | /// If you just need the meta data like the document name or update time, you are already settled. 87 | /// To fetch the document itself, use [`read_by_name`]. 88 | /// 89 | /// Please note that this API acts as an iterator of same-like documents. 90 | /// This type is not suitable if you want to list documents of different types. 91 | #[derive(Debug)] 92 | pub struct Query(IntoIter); 93 | 94 | impl Iterator for Query { 95 | type Item = dto::Document; 96 | 97 | // Skip empty entries 98 | fn next(&mut self) -> Option { 99 | while let Some(r) = self.0.next() { 100 | if let Some(document) = r.document { 101 | return Some(document); 102 | } 103 | } 104 | return None; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/documents/read.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | use std::io::Read; 3 | 4 | /// 5 | /// Read a document of a specific type from a collection by its Firestore document name 6 | /// 7 | /// ## Arguments 8 | /// * `auth` The authentication token 9 | /// * `document_name` The document path / collection and document id; For example `projects/my_project/databases/(default)/documents/tests/test` 10 | pub async fn read_by_name(auth: &impl FirebaseAuthBearer, document_name: &str) -> Result 11 | where 12 | for<'b> T: Deserialize<'b>, 13 | { 14 | let resp = request_document(auth, document_name).await?; 15 | 16 | // We take the raw response first in order to provide 17 | // more complete errors on deserialization failure 18 | let full = resp.bytes().await?; 19 | let json = serde_json::from_slice(&full).map_err(|e| FirebaseError::SerdeVerbose { 20 | doc: Some(String::from(document_name)), 21 | input_doc: String::from_utf8_lossy(&full).to_string(), 22 | ser: e, 23 | })?; 24 | 25 | Ok(document_to_pod(&json, Some(&full))?) 26 | } 27 | 28 | /// 29 | /// Read a document of a specific type from a collection 30 | /// 31 | /// ## Arguments 32 | /// * `auth` The authentication token 33 | /// * `path` The document path / collection; For example `my_collection` or `a/nested/collection` 34 | /// * `document_id` The document id. Make sure that you do not include the document id to the path argument. 35 | pub async fn read(auth: &impl FirebaseAuthBearer, path: &str, document_id: &str) -> Result 36 | where 37 | for<'b> T: Deserialize<'b>, 38 | { 39 | let document_name = document_name(&auth.project_id(), path, document_id); 40 | read_by_name(auth, &document_name).await 41 | } 42 | 43 | /// Return the raw unparsed content of the Firestore document. Methods like 44 | /// [`read()`](../documents/fn.read.html) will deserialize the JSON-encoded 45 | /// response into a known type `T` 46 | /// 47 | /// Note that this leverages [`std::io::Read`](https://doc.rust-lang.org/std/io/trait.Read.html) and the `read_to_string()` method to chunk the 48 | /// response. This will raise `FirebaseError::IO` if there are errors reading the stream. Please 49 | /// see [`read_to_end()`](https://doc.rust-lang.org/std/io/trait.Read.html#method.read_to_end) 50 | pub async fn contents(auth: &impl FirebaseAuthBearer, path: &str, document_id: &str) -> Result { 51 | let document_name = document_name(&auth.project_id(), path, document_id); 52 | let resp = request_document(auth, &document_name).await?; 53 | resp.text().await.map_err(|e| FirebaseError::Request(e)) 54 | } 55 | 56 | /// Executes the request to retrieve the document. Returns the response from `reqwest` 57 | async fn request_document(auth: &impl FirebaseAuthBearer, document_name: &str) -> Result { 58 | let url = firebase_url_base(document_name.as_ref()); 59 | 60 | let resp = auth 61 | .client() 62 | .get(&url) 63 | .bearer_auth(auth.access_token().await) 64 | .send() 65 | .await?; 66 | 67 | extract_google_api_error_async(resp, || document_name.to_owned()).await 68 | } 69 | 70 | /// Simple method to join the path and document identifier in correct format 71 | fn document_name(project_id: &str, path: &str, document_id: &str) -> String { 72 | format!( 73 | "projects/{}/databases/(default)/documents/{}/{}", 74 | project_id, path, document_id 75 | ) 76 | } 77 | 78 | #[test] 79 | fn it_document_name_joins_paths() { 80 | let project_id = "firebase-project"; 81 | let path = "one/two/three"; 82 | let document_id = "my-document"; 83 | assert_eq!( 84 | document_name(&project_id, &path, &document_id), 85 | "projects/firebase-project/databases/(default)/documents/one/two/three/my-document" 86 | ); 87 | } 88 | 89 | #[test] 90 | fn it_document_name_joins_invalid_path_fragments() { 91 | let project_id = "firebase-project"; 92 | let path = "one/two//three/"; 93 | let document_id = "///my-document"; 94 | assert_eq!( 95 | document_name(&project_id, &path, &document_id), 96 | "projects/firebase-project/databases/(default)/documents/one/two//three/////my-document" 97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /src/documents/write.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// This is returned by the write() method in a successful case. 4 | /// 5 | /// This structure contains the document id of the written document. 6 | #[derive(Serialize, Deserialize)] 7 | pub struct WriteResult { 8 | /// 9 | pub create_time: Option>, 10 | pub update_time: Option>, 11 | pub document_id: String, 12 | } 13 | 14 | /// Write options. The default will overwrite a target document and not merge fields. 15 | #[derive(Default)] 16 | pub struct WriteOptions { 17 | /// If this is set instead of overwriting all fields of a target document, only the given fields will be merged. 18 | /// This only works if your document type has Option fields. 19 | /// The write will fail, if no document_id is given or the target document does not exist yet. 20 | pub merge: bool, 21 | } 22 | 23 | /// 24 | /// Write a document to a given collection. 25 | /// 26 | /// If no document_id is given, Firestore will generate an ID. Check the [`WriteResult`] return value. 27 | /// 28 | /// If a document_id is given, the document will be created if it does not yet exist. 29 | /// Except if the "merge" option (see [`WriteOptions::merge`]) is set. 30 | /// 31 | /// Example: 32 | ///```no_run 33 | ///use firestore_db_and_auth::{Credentials, ServiceSession, documents, errors::Result, FirebaseAuthBearer}; 34 | ///use serde::{Serialize,Deserialize}; 35 | ///# use firestore_db_and_auth::credentials::doctest_credentials; 36 | /// 37 | /// #[derive(Serialize, Deserialize)] 38 | /// struct DemoDTO { 39 | /// a_string: String, 40 | /// an_int: u32, 41 | /// another_int: u32, 42 | /// } 43 | /// #[derive(Serialize, Deserialize)] 44 | /// struct DemoPartialDTO { 45 | /// #[serde(skip_serializing_if = "Option::is_none")] 46 | /// a_string: Option, 47 | /// an_int: u32, 48 | /// } 49 | /// 50 | /// async fn write(session: &impl FirebaseAuthBearer) -> Result<()> { 51 | /// let obj = DemoDTO { a_string: "abcd".to_owned(), an_int: 14, another_int: 10 }; 52 | /// let result = documents::write(session, "tests", Some("service_test"), &obj, documents::WriteOptions::default()).await?; 53 | /// println!("id: {}, created: {}, updated: {}", result.document_id, result.create_time.unwrap(), result.update_time.unwrap()); 54 | /// Ok(()) 55 | /// } 56 | /// /// Only write some fields and do not overwrite the entire document. 57 | /// /// Either via Option<> or by not having the fields in the structure, see DemoPartialDTO. 58 | /// async fn write_partial(session: &impl FirebaseAuthBearer) -> Result<()> { 59 | /// let obj = DemoPartialDTO { a_string: None, an_int: 16 }; 60 | /// let result = documents::write(session, "tests", Some("service_test"), &obj, documents::WriteOptions{merge:true}).await?; 61 | /// println!("id: {}, created: {}, updated: {}", result.document_id, result.create_time.unwrap(), result.update_time.unwrap()); 62 | /// Ok(()) 63 | /// } 64 | /// 65 | /// # #[tokio::main] 66 | /// # async fn main() -> Result<()> { 67 | /// # let session = ServiceSession::new(doctest_credentials().await).await?; 68 | /// # write(&session).await?; 69 | /// # write_partial(&session).await?; 70 | /// # 71 | /// # Ok(()) 72 | /// # } 73 | ///``` 74 | 75 | /// 76 | /// ## Arguments 77 | /// * 'auth' The authentication token 78 | /// * 'path' The document path / collection; For example "my_collection" or "a/nested/collection" 79 | /// * 'document_id' The document id. Make sure that you do not include the document id in the path argument. 80 | /// * 'document' The document 81 | /// * 'options' Write options 82 | pub async fn write( 83 | auth: &impl FirebaseAuthBearer, 84 | path: &str, 85 | document_id: Option>, 86 | document: &T, 87 | options: WriteOptions, 88 | ) -> Result 89 | where 90 | T: Serialize, 91 | { 92 | let mut url = match document_id.as_ref() { 93 | Some(document_id) => firebase_url_extended(auth.project_id(), path, document_id.as_ref()), 94 | None => firebase_url(auth.project_id(), path), 95 | }; 96 | 97 | let firebase_document = pod_to_document(&document)?; 98 | 99 | if options.merge && firebase_document.fields.is_some() { 100 | let fields = firebase_document.fields.as_ref().unwrap().keys().join(","); 101 | url = format!("{}?currentDocument.exists=true&updateMask.fieldPaths={}", url, fields); 102 | } 103 | 104 | let builder = if document_id.is_some() { 105 | auth.client().patch(&url) 106 | } else { 107 | auth.client().post(&url) 108 | }; 109 | 110 | let resp = builder 111 | .bearer_auth(auth.access_token().await.to_owned()) 112 | .json(&firebase_document) 113 | .send() 114 | .await?; 115 | 116 | let resp = extract_google_api_error_async(resp, || { 117 | document_id 118 | .as_ref() 119 | .and_then(|f| Some(f.as_ref().to_owned())) 120 | .or(Some(String::new())) 121 | .unwrap() 122 | }) 123 | .await?; 124 | 125 | let result_document: dto::Document = resp.json().await?; 126 | let document_id = Path::new(&result_document.name) 127 | .file_name() 128 | .ok_or_else(|| FirebaseError::Generic("Resulting documents 'name' field is not a valid path"))? 129 | .to_str() 130 | .ok_or_else(|| FirebaseError::Generic("No valid unicode in 'name' field"))? 131 | .to_owned(); 132 | 133 | let create_time = match result_document.create_time { 134 | Some(f) => Some( 135 | chrono::DateTime::parse_from_rfc3339(&f) 136 | .map_err(|_| FirebaseError::Generic("Failed to parse rfc3339 date from 'create_time' field"))? 137 | .with_timezone(&chrono::Utc), 138 | ), 139 | None => None, 140 | }; 141 | let update_time = match result_document.update_time { 142 | Some(f) => Some( 143 | chrono::DateTime::parse_from_rfc3339(&f) 144 | .map_err(|_| FirebaseError::Generic("Failed to parse rfc3339 date from 'update_time' field"))? 145 | .with_timezone(&chrono::Utc), 146 | ), 147 | None => None, 148 | }; 149 | 150 | Ok(WriteResult { 151 | document_id, 152 | create_time, 153 | update_time, 154 | }) 155 | } 156 | -------------------------------------------------------------------------------- /src/dto.rs: -------------------------------------------------------------------------------- 1 | //! # Data Transfer Object definitions 2 | //! In this module only those Data Transfer Objects (DTO) are defined, which are used by the firebase API 3 | //! to access, alter documents or firebase users. 4 | //! 5 | //! Domain specific DTOs for OAuth or session management are defined in [`crate::session`]. 6 | 7 | use std::collections::HashMap; 8 | 9 | use serde::{Deserialize, Serialize}; 10 | 11 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 12 | pub struct GoogleFirestoreAdminv1IndexField { 13 | #[serde(rename = "fieldPath")] 14 | pub field_path: Option, 15 | pub mode: Option, 16 | } 17 | 18 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 19 | pub struct ListenResponse { 20 | pub filter: Option, 21 | #[serde(rename = "targetChange")] 22 | pub target_change: Option, 23 | #[serde(rename = "documentDelete")] 24 | pub document_delete: Option, 25 | #[serde(rename = "documentChange")] 26 | pub document_change: Option, 27 | #[serde(rename = "documentRemove")] 28 | pub document_remove: Option, 29 | } 30 | 31 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 32 | pub struct BeginTransactionResponse { 33 | pub transaction: Option, 34 | } 35 | 36 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 37 | pub struct Write { 38 | #[serde(skip_serializing_if = "Option::is_none")] 39 | pub delete: Option, 40 | #[serde(skip_serializing_if = "Option::is_none")] 41 | #[serde(rename = "currentDocument")] 42 | pub current_document: Option, 43 | #[serde(skip_serializing_if = "Option::is_none")] 44 | pub update: Option, 45 | #[serde(skip_serializing_if = "Option::is_none")] 46 | pub transform: Option, 47 | #[serde(rename = "updateMask")] 48 | #[serde(skip_serializing_if = "Option::is_none")] 49 | pub update_mask: Option, 50 | } 51 | 52 | #[derive(Clone, Debug, Serialize, Deserialize)] 53 | #[allow(non_camel_case_types)] 54 | pub enum FieldOperator { 55 | OPERATOR_UNSPECIFIED, // Unspecified. This value must not be used. 56 | LESS_THAN, // Less than. Requires that the field come first in orderBy. 57 | LESS_THAN_OR_EQUAL, // Less than or equal. Requires that the field come first in orderBy. 58 | GREATER_THAN, // Greater than. Requires that the field come first in orderBy. 59 | GREATER_THAN_OR_EQUAL, // Greater than or equal. Requires that the field come first in orderBy. 60 | EQUAL, // Equal. 61 | ARRAY_CONTAINS, // Contains. Requires that the field is an array. 62 | } 63 | 64 | impl Default for FieldOperator { 65 | fn default() -> Self { 66 | FieldOperator::OPERATOR_UNSPECIFIED 67 | } 68 | } 69 | 70 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 71 | pub struct FieldFilter { 72 | pub field: FieldReference, 73 | pub value: Value, 74 | pub op: FieldOperator, 75 | } 76 | 77 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 78 | pub struct GoogleFirestoreAdminv1ImportDocumentsRequest { 79 | #[serde(rename = "inputUriPrefix")] 80 | pub input_uri_prefix: Option, 81 | #[serde(rename = "collectionIds")] 82 | pub collection_ids: Option>, 83 | } 84 | 85 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 86 | pub struct Document { 87 | pub fields: Option>, 88 | #[serde(rename = "updateTime")] 89 | #[serde(skip_serializing_if = "Option::is_none")] 90 | pub update_time: Option, 91 | #[serde(rename = "createTime")] 92 | #[serde(skip_serializing_if = "Option::is_none")] 93 | pub create_time: Option, 94 | pub name: String, 95 | } 96 | 97 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 98 | pub struct GoogleFirestoreAdminv1ListIndexesResponse { 99 | #[serde(rename = "nextPageToken")] 100 | pub next_page_token: Option, 101 | pub indexes: Option>, 102 | } 103 | 104 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 105 | pub struct BatchGetDocumentsResponse { 106 | pub found: Option, 107 | pub transaction: Option, 108 | #[serde(rename = "readTime")] 109 | pub read_time: Option, 110 | pub missing: Option, 111 | } 112 | 113 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 114 | pub struct Status { 115 | pub message: Option, 116 | pub code: Option, 117 | pub details: Option>>, 118 | } 119 | 120 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 121 | pub struct ListenRequest { 122 | pub labels: Option>, 123 | #[serde(rename = "addTarget")] 124 | pub add_target: Option, 125 | #[serde(rename = "removeTarget")] 126 | pub remove_target: Option, 127 | } 128 | 129 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 130 | pub struct RunQueryRequest { 131 | #[serde(rename = "newTransaction")] 132 | #[serde(skip_serializing_if = "Option::is_none")] 133 | pub new_transaction: Option, 134 | pub transaction: Option, 135 | #[serde(rename = "structuredQuery")] 136 | #[serde(skip_serializing_if = "Option::is_none")] 137 | pub structured_query: Option, 138 | #[serde(rename = "readTime")] 139 | #[serde(skip_serializing_if = "Option::is_none")] 140 | pub read_time: Option, 141 | } 142 | 143 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 144 | pub struct FieldReference { 145 | #[serde(rename = "fieldPath")] 146 | pub field_path: String, 147 | } 148 | 149 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 150 | pub struct UnaryFilter { 151 | pub field: FieldReference, 152 | pub op: String, 153 | } 154 | 155 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 156 | pub struct ArrayValue { 157 | pub values: Option>, 158 | } 159 | 160 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 161 | pub struct DocumentMask { 162 | #[serde(rename = "fieldPaths")] 163 | pub field_paths: Vec, 164 | } 165 | 166 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 167 | pub struct CompositeFilter { 168 | pub filters: Vec, 169 | pub op: String, 170 | } 171 | 172 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 173 | pub struct Empty { 174 | _never_set: Option, 175 | } 176 | 177 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 178 | pub struct Filter { 179 | #[serde(rename = "unaryFilter")] 180 | #[serde(skip_serializing_if = "Option::is_none")] 181 | pub unary_filter: Option, 182 | #[serde(rename = "fieldFilter")] 183 | #[serde(skip_serializing_if = "Option::is_none")] 184 | pub field_filter: Option, 185 | #[serde(rename = "compositeFilter")] 186 | #[serde(skip_serializing_if = "Option::is_none")] 187 | pub composite_filter: Option, 188 | } 189 | 190 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 191 | pub struct WriteResponse { 192 | #[serde(rename = "writeResults")] 193 | pub write_results: Option>, 194 | #[serde(rename = "streamToken")] 195 | pub stream_token: Option, 196 | #[serde(rename = "commitTime")] 197 | pub commit_time: Option, 198 | #[serde(rename = "streamId")] 199 | pub stream_id: Option, 200 | } 201 | 202 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 203 | pub struct ListCollectionIdsRequest { 204 | #[serde(rename = "pageToken")] 205 | pub page_token: Option, 206 | #[serde(rename = "pageSize")] 207 | pub page_size: Option, 208 | } 209 | 210 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 211 | pub struct BatchGetDocumentsRequest { 212 | #[serde(rename = "newTransaction")] 213 | #[serde(skip_serializing_if = "Option::is_none")] 214 | pub new_transaction: Option, 215 | #[serde(skip_serializing_if = "Option::is_none")] 216 | pub transaction: Option, 217 | #[serde(skip_serializing_if = "Option::is_none")] 218 | pub mask: Option, 219 | #[serde(skip_serializing_if = "Option::is_none")] 220 | pub documents: Option>, 221 | #[serde(rename = "readTime")] 222 | #[serde(skip_serializing_if = "Option::is_none")] 223 | pub read_time: Option, 224 | } 225 | 226 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 227 | pub struct MapValue { 228 | pub fields: Option>, 229 | } 230 | 231 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 232 | pub struct TransactionOptions { 233 | #[serde(rename = "readWrite")] 234 | pub read_write: Option, 235 | #[serde(rename = "readOnly")] 236 | pub read_only: Option, 237 | } 238 | 239 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 240 | pub struct CommitResponse { 241 | #[serde(rename = "writeResults")] 242 | pub write_results: Option>, 243 | #[serde(rename = "commitTime")] 244 | pub commit_time: Option, 245 | } 246 | 247 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 248 | pub struct Target { 249 | pub documents: Option, 250 | pub once: Option, 251 | pub query: Option, 252 | #[serde(rename = "resumeToken")] 253 | pub resume_token: Option, 254 | #[serde(rename = "targetId")] 255 | pub target_id: Option, 256 | #[serde(rename = "readTime")] 257 | pub read_time: Option, 258 | } 259 | 260 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 261 | pub struct ExistenceFilter { 262 | pub count: Option, 263 | #[serde(rename = "targetId")] 264 | pub target_id: Option, 265 | } 266 | 267 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 268 | pub struct DocumentsTarget { 269 | pub documents: Option>, 270 | } 271 | 272 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 273 | pub struct Precondition { 274 | #[serde(rename = "updateTime")] 275 | #[serde(skip_serializing_if = "Option::is_none")] 276 | pub update_time: Option, 277 | #[serde(skip_serializing_if = "Option::is_none")] 278 | pub exists: Option, 279 | } 280 | 281 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 282 | pub struct Value { 283 | #[serde(rename = "bytesValue")] 284 | #[serde(skip_serializing_if = "Option::is_none")] 285 | pub bytes_value: Option, 286 | 287 | #[serde(rename = "timestampValue")] 288 | #[serde(skip_serializing_if = "Option::is_none")] 289 | pub timestamp_value: Option, 290 | 291 | #[serde(rename = "geoPointValue")] 292 | #[serde(skip_serializing_if = "Option::is_none")] 293 | pub geo_point_value: Option, 294 | 295 | #[serde(rename = "referenceValue")] 296 | #[serde(skip_serializing_if = "Option::is_none")] 297 | pub reference_value: Option, 298 | 299 | #[serde(rename = "doubleValue")] 300 | #[serde(skip_serializing_if = "Option::is_none")] 301 | pub double_value: Option, 302 | 303 | #[serde(rename = "mapValue")] 304 | #[serde(skip_serializing_if = "Option::is_none")] 305 | pub map_value: Option, 306 | 307 | #[serde(rename = "stringValue")] 308 | #[serde(skip_serializing_if = "Option::is_none")] 309 | pub string_value: Option, 310 | 311 | #[serde(rename = "booleanValue")] 312 | #[serde(skip_serializing_if = "Option::is_none")] 313 | pub boolean_value: Option, 314 | 315 | #[serde(rename = "arrayValue")] 316 | #[serde(skip_serializing_if = "Option::is_none")] 317 | pub array_value: Option, 318 | 319 | #[serde(rename = "integerValue")] 320 | #[serde(skip_serializing_if = "Option::is_none")] 321 | pub integer_value: Option, 322 | 323 | #[serde(rename = "nullValue")] 324 | #[serde(skip_serializing_if = "Option::is_none")] 325 | pub null_value: Option, 326 | } 327 | 328 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 329 | pub struct Cursor { 330 | pub values: Option>, 331 | pub before: Option, 332 | } 333 | 334 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 335 | pub struct CollectionSelector { 336 | #[serde(rename = "allDescendants")] 337 | pub all_descendants: Option, 338 | #[serde(rename = "collectionId")] 339 | pub collection_id: Option, 340 | } 341 | 342 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 343 | pub struct GoogleFirestoreAdminv1Index { 344 | pub fields: Option>, 345 | pub state: Option, 346 | pub name: Option, 347 | #[serde(rename = "collectionId")] 348 | pub collection_id: Option, 349 | } 350 | 351 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 352 | pub struct StructuredQuery { 353 | #[serde(rename = "orderBy")] 354 | #[serde(skip_serializing_if = "Option::is_none")] 355 | pub order_by: Option>, 356 | #[serde(rename = "startAt")] 357 | #[serde(skip_serializing_if = "Option::is_none")] 358 | pub start_at: Option, 359 | #[serde(rename = "endAt")] 360 | #[serde(skip_serializing_if = "Option::is_none")] 361 | pub end_at: Option, 362 | pub limit: Option, 363 | #[serde(skip_serializing_if = "Option::is_none")] 364 | pub offset: Option, 365 | #[serde(skip_serializing_if = "Option::is_none")] 366 | pub from: Option>, 367 | #[serde(rename = "where")] 368 | #[serde(skip_serializing_if = "Option::is_none")] 369 | pub where_: Option, 370 | #[serde(skip_serializing_if = "Option::is_none")] 371 | pub select: Option, 372 | } 373 | 374 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 375 | pub struct FieldTransform { 376 | #[serde(rename = "fieldPath")] 377 | pub field_path: Option, 378 | #[serde(rename = "appendMissingElements")] 379 | pub append_missing_elements: Option, 380 | #[serde(rename = "setToServerValue")] 381 | pub set_to_server_value: Option, 382 | #[serde(rename = "removeAllFromArray")] 383 | pub remove_all_from_array: Option, 384 | } 385 | 386 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 387 | pub struct DocumentDelete { 388 | #[serde(rename = "removedTargetIds")] 389 | #[serde(skip_serializing_if = "Option::is_none")] 390 | pub removed_target_ids: Option>, 391 | #[serde(skip_serializing_if = "Option::is_none")] 392 | pub document: Option, 393 | #[serde(rename = "readTime")] 394 | #[serde(skip_serializing_if = "Option::is_none")] 395 | pub read_time: Option, 396 | } 397 | 398 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 399 | pub struct GoogleFirestoreAdminv1ExportDocumentsRequest { 400 | #[serde(rename = "outputUriPrefix")] 401 | pub output_uri_prefix: Option, 402 | #[serde(rename = "collectionIds")] 403 | pub collection_ids: Option>, 404 | } 405 | 406 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 407 | pub struct Order { 408 | #[serde(skip_serializing_if = "Option::is_none")] 409 | pub field: Option, 410 | #[serde(skip_serializing_if = "Option::is_none")] 411 | pub direction: Option, 412 | } 413 | 414 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 415 | pub struct TargetChange { 416 | #[serde(rename = "resumeToken")] 417 | pub resume_token: Option, 418 | #[serde(rename = "targetChangeType")] 419 | pub target_change_type: Option, 420 | pub cause: Option, 421 | #[serde(rename = "targetIds")] 422 | pub target_ids: Option>, 423 | #[serde(rename = "readTime")] 424 | pub read_time: Option, 425 | } 426 | 427 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 428 | pub struct RunQueryResponse { 429 | #[serde(rename = "skippedResults")] 430 | pub skipped_results: Option, 431 | pub transaction: Option, 432 | pub document: Option, 433 | #[serde(rename = "readTime")] 434 | pub read_time: Option, 435 | } 436 | 437 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 438 | pub struct ListCollectionIdsResponse { 439 | #[serde(rename = "nextPageToken")] 440 | pub next_page_token: Option, 441 | #[serde(rename = "collectionIds")] 442 | pub collection_ids: Option>, 443 | } 444 | 445 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 446 | pub struct CommitRequest { 447 | pub writes: Option>, 448 | pub transaction: Option, 449 | } 450 | 451 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 452 | pub struct Projection { 453 | pub fields: Option>, 454 | } 455 | 456 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 457 | pub struct ListDocumentsResponse { 458 | #[serde(rename = "nextPageToken")] 459 | pub next_page_token: Option, 460 | pub documents: Option>, 461 | } 462 | 463 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 464 | pub struct ReadWrite { 465 | #[serde(rename = "retryTransaction")] 466 | pub retry_transaction: Option, 467 | } 468 | 469 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 470 | pub struct GoogleLongrunningOperation { 471 | pub error: Option, 472 | pub done: Option, 473 | pub response: Option>, 474 | pub name: Option, 475 | pub metadata: Option>, 476 | } 477 | 478 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 479 | pub struct LatLng { 480 | pub latitude: Option, 481 | pub longitude: Option, 482 | } 483 | 484 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 485 | pub struct DocumentChange { 486 | #[serde(rename = "removedTargetIds")] 487 | pub removed_target_ids: Option>, 488 | pub document: Option, 489 | #[serde(rename = "targetIds")] 490 | pub target_ids: Option>, 491 | } 492 | 493 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 494 | pub struct DocumentRemove { 495 | #[serde(rename = "removedTargetIds")] 496 | pub removed_target_ids: Option>, 497 | pub document: Option, 498 | #[serde(rename = "readTime")] 499 | pub read_time: Option, 500 | } 501 | 502 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 503 | pub struct RollbackRequest { 504 | pub transaction: Option, 505 | } 506 | 507 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 508 | pub struct ReadOnly { 509 | #[serde(rename = "readTime")] 510 | pub read_time: Option, 511 | } 512 | 513 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 514 | pub struct BeginTransactionRequest { 515 | pub options: Option, 516 | } 517 | 518 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 519 | pub struct DocumentTransform { 520 | pub document: Option, 521 | #[serde(rename = "fieldTransforms")] 522 | pub field_transforms: Option>, 523 | } 524 | 525 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 526 | pub struct WriteResult { 527 | #[serde(rename = "updateTime")] 528 | pub update_time: Option, 529 | #[serde(rename = "transformResults")] 530 | pub transform_results: Option>, 531 | } 532 | 533 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 534 | pub struct QueryTarget { 535 | #[serde(rename = "structuredQuery")] 536 | pub structured_query: Option, 537 | pub parent: Option, 538 | } 539 | 540 | #[derive(Default, Clone, Debug, Serialize, Deserialize)] 541 | pub struct WriteRequest { 542 | pub writes: Option>, 543 | pub labels: Option>, 544 | #[serde(rename = "streamToken")] 545 | pub stream_token: Option, 546 | #[serde(rename = "streamId")] 547 | pub stream_id: Option, 548 | } 549 | 550 | #[derive(Serialize, Debug)] 551 | pub struct SignInWithIdpRequest { 552 | pub post_body: String, 553 | pub request_uri: String, 554 | pub return_idp_credential: bool, 555 | pub return_secure_token: bool, 556 | } 557 | 558 | #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize)] 559 | #[serde(rename_all = "camelCase")] 560 | pub struct OAuthResponse { 561 | pub federated_id: String, 562 | pub provider_id: String, 563 | pub local_id: String, 564 | pub email_verified: bool, 565 | pub email: String, 566 | pub oauth_access_token: String, 567 | pub first_name: String, 568 | pub last_name: String, 569 | pub full_name: String, 570 | pub display_name: String, 571 | pub id_token: String, 572 | pub photo_url: String, 573 | pub refresh_token: String, 574 | pub expires_in: String, 575 | pub raw_user_info: String, 576 | } 577 | 578 | #[cfg(test)] 579 | mod tests { 580 | #[test] 581 | fn it_deserializes_a_document_with_empty_mapvalue() { 582 | let doc = r#"{ 583 | "name": "projects/firestore-db-and-auth/databases/(default)/documents/user/1", 584 | "fields": { 585 | "gender": { 586 | "stringValue": "male" 587 | }, 588 | "age": { 589 | "integerValue": "35" 590 | }, 591 | "profile": { 592 | "mapValue": {} 593 | } 594 | }, 595 | "createTime": "2020-04-28T14:52:51.250511Z", 596 | "updateTime": "2020-04-28T14:52:51.250511Z" 597 | }"#; 598 | let document: Result = serde_json::from_str(&doc); 599 | assert!(document.is_ok()); 600 | } 601 | 602 | #[test] 603 | fn it_deserializes_a_document_with_every_data_type() { 604 | let doc = r#"{ 605 | "name": "projects/firestore-db-and-auth/databases/(default)/documents/user/1", 606 | "fields": { 607 | "exampleArray": { 608 | "arrayValue": { 609 | "values": [ 610 | {"stringValue": "string-example"}, 611 | {"integerValue": "456"} 612 | ] 613 | } 614 | }, 615 | "exampleBytes": { 616 | "bytesValue": "YWJj" 617 | }, 618 | "exampleBoolean": { 619 | "booleanValue": false 620 | }, 621 | "exampleDoubleValue": { 622 | "doubleValue": 3.85185988877447170611195588516985463707620329643077639047987759113311767578125 623 | }, 624 | "exampleInteger": { 625 | "integerValue": "1024" 626 | }, 627 | "exampleMap": { 628 | "mapValue": { 629 | "fields": { 630 | "age": { 631 | "integerValue": "1" 632 | }, 633 | "name": { 634 | "stringValue": "Bobby Seale" 635 | } 636 | } 637 | } 638 | }, 639 | "exampleNull": { 640 | "nullValue": null 641 | }, 642 | "exampleTimestamp": { 643 | "timestampValue": "2014-10-02T15:01:23Z" 644 | }, 645 | "exampleString": { 646 | "stringValue": "abc-def" 647 | }, 648 | "exampleReferenceValue": { 649 | "referenceValue": "projects/firestore-db-and-auth/databases/(default)/documents/test" 650 | }, 651 | "exampleGeoPointValue": { 652 | "geoPointValue": { 653 | "latitude": 48.830108, 654 | "longitude": 2.367104 655 | } 656 | } 657 | }, 658 | "createTime": "2020-04-28T14:52:51.250511Z", 659 | "updateTime": "2020-04-28T14:52:51.250511Z" 660 | }"#; 661 | let document: Result = serde_json::from_str(&doc); 662 | assert!(document.is_ok()); 663 | } 664 | } 665 | -------------------------------------------------------------------------------- /src/errors.rs: -------------------------------------------------------------------------------- 1 | //! # Error and Result Type 2 | 3 | use std::error; 4 | use std::fmt; 5 | 6 | use reqwest; 7 | use reqwest::StatusCode; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | /// A result type that uses [`FirebaseError`] as an error type 11 | pub type Result = std::result::Result; 12 | 13 | /// The main error type used throughout this crate. It wraps / converts from a few other error 14 | /// types and implements [error::Error] so that you can use it in any situation where the 15 | /// standard error type is expected. 16 | #[derive(Debug)] 17 | pub enum FirebaseError { 18 | /// Generic errors are very rarely used and only used if no other error type matches 19 | Generic(&'static str), 20 | /// If the http status code is != 200 and no Google error response is attached 21 | /// (see https://firebase.google.com/docs/reference/rest/auth#section-error-format) 22 | /// then this error type will be returned 23 | UnexpectedResponse(&'static str, reqwest::StatusCode, String, String), 24 | /// An error returned by the Firestore API - Contains the numeric code, a string code and 25 | /// a context. If the APIError happened on a document query or mutation, the document 26 | /// path will be set as context. 27 | /// If the APIError happens on a user_* method, the user id will be set as context. 28 | /// For example: 400, CREDENTIAL_TOO_OLD_LOGIN_AGAIN 29 | APIError(usize, String, String), 30 | /// An error caused by the http library. This only happens if the http request is badly 31 | /// formatted (too big, invalid characters) or if the server did strange things 32 | /// (connection abort, ssl verification error). 33 | Request(reqwest::Error), 34 | /// Should not happen. If jwt encoding / decoding fails or an value cannot be extracted or 35 | /// a jwt is badly formatted or corrupted 36 | JWT(biscuit::errors::Error), 37 | JWTValidation(biscuit::errors::ValidationError), 38 | /// Serialisation failed 39 | Ser { 40 | doc: Option, 41 | ser: serde_json::Error, 42 | }, 43 | /// Verbose deserialization failure 44 | SerdeVerbose { 45 | doc: Option, 46 | input_doc: String, 47 | ser: serde_json::Error, 48 | }, 49 | /// When the credentials.json file contains an invalid private key this error is returned 50 | RSA(ring::error::KeyRejected), 51 | /// Disk access errors 52 | IO(std::io::Error), 53 | } 54 | 55 | impl std::convert::From for FirebaseError { 56 | fn from(error: std::io::Error) -> Self { 57 | FirebaseError::IO(error) 58 | } 59 | } 60 | 61 | impl std::convert::From for FirebaseError { 62 | fn from(error: ring::error::KeyRejected) -> Self { 63 | FirebaseError::RSA(error) 64 | } 65 | } 66 | 67 | impl std::convert::From for FirebaseError { 68 | fn from(error: serde_json::Error) -> Self { 69 | FirebaseError::Ser { doc: None, ser: error } 70 | } 71 | } 72 | 73 | impl std::convert::From for FirebaseError { 74 | fn from(error: biscuit::errors::Error) -> Self { 75 | FirebaseError::JWT(error) 76 | } 77 | } 78 | 79 | impl std::convert::From for FirebaseError { 80 | fn from(error: biscuit::errors::ValidationError) -> Self { 81 | FirebaseError::JWTValidation(error) 82 | } 83 | } 84 | 85 | impl std::convert::From for FirebaseError { 86 | fn from(error: reqwest::Error) -> Self { 87 | FirebaseError::Request(error) 88 | } 89 | } 90 | 91 | impl fmt::Display for FirebaseError { 92 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 93 | match self { 94 | FirebaseError::Generic(m) => write!(f, "{}", m), 95 | FirebaseError::APIError(code, m, context) => { 96 | write!(f, "API Error! Code {} - {}. Context: {}", code, m, context) 97 | } 98 | FirebaseError::UnexpectedResponse(m, status, text, source) => { 99 | writeln!(f, "{} - {}", &m, status)?; 100 | writeln!(f, "{}", text)?; 101 | writeln!(f, "{}", source)?; 102 | Ok(()) 103 | } 104 | FirebaseError::Request(e) => e.fmt(f), 105 | FirebaseError::JWT(e) => e.fmt(f), 106 | FirebaseError::JWTValidation(e) => e.fmt(f), 107 | FirebaseError::RSA(e) => e.fmt(f), 108 | FirebaseError::IO(e) => e.fmt(f), 109 | FirebaseError::Ser { doc, ser } => { 110 | if let Some(doc) = doc { 111 | writeln!(f, "{} in document {}", ser, doc) 112 | } else { 113 | ser.fmt(f) 114 | } 115 | } 116 | FirebaseError::SerdeVerbose { doc, input_doc, ser } => { 117 | let doc = doc.clone().unwrap_or("Unknown document".to_string()); 118 | writeln!( 119 | f, 120 | "Serde deserialization failed for document '{}' with error '{}' on input: '{}'", 121 | doc, ser, input_doc 122 | ) 123 | } 124 | } 125 | } 126 | } 127 | 128 | impl error::Error for FirebaseError { 129 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 130 | match *self { 131 | FirebaseError::Generic(ref _m) => None, 132 | FirebaseError::UnexpectedResponse(_, _, _, _) => None, 133 | FirebaseError::APIError(_, _, _) => None, 134 | FirebaseError::Request(ref e) => Some(e), 135 | FirebaseError::JWT(ref e) => Some(e), 136 | FirebaseError::JWTValidation(ref e) => Some(e), 137 | FirebaseError::RSA(_) => None, 138 | FirebaseError::IO(ref e) => Some(e), 139 | FirebaseError::Ser { ref ser, .. } => Some(ser), 140 | FirebaseError::SerdeVerbose { ref ser, .. } => Some(ser), 141 | } 142 | } 143 | } 144 | 145 | #[derive(Default, Serialize, Deserialize)] 146 | struct GoogleRESTApiError { 147 | pub message: String, 148 | pub domain: String, 149 | pub reason: String, 150 | } 151 | 152 | #[derive(Default, Serialize, Deserialize)] 153 | struct GoogleRESTApiErrorInfo { 154 | pub code: usize, 155 | pub message: String, 156 | pub errors: Option>, 157 | } 158 | 159 | #[derive(Default, Serialize, Deserialize)] 160 | struct GoogleRESTApiErrorWrapper { 161 | pub error: Option, 162 | } 163 | 164 | /// If the given reqwest response is status code 200, nothing happens 165 | /// Otherwise the response will be analysed if it contains a Google API Error response. 166 | /// See https://firebase.google.com/docs/reference/rest/auth#section-error-response 167 | /// 168 | /// Arguments: 169 | /// - response: The http requests response. Must be mutable, because the contained value will be extracted in an error case 170 | /// - context: A function that will be called in an error case that returns a context string 171 | pub(crate) fn extract_google_api_error( 172 | response: reqwest::blocking::Response, 173 | context: impl Fn() -> String, 174 | ) -> Result { 175 | if response.status() == 200 { 176 | return Ok(response); 177 | } 178 | 179 | Err(extract_google_api_error_intern( 180 | response.status().clone(), 181 | response.text()?, 182 | context, 183 | )) 184 | } 185 | 186 | /// If the given reqwest response is status code 200, nothing happens 187 | /// Otherwise the response will be analysed if it contains a Google API Error response. 188 | /// See https://firebase.google.com/docs/reference/rest/auth#section-error-response 189 | /// 190 | /// Arguments: 191 | /// - response: The http requests response. Must be mutable, because the contained value will be extracted in an error case 192 | /// - context: A function that will be called in an error case that returns a context string 193 | pub(crate) async fn extract_google_api_error_async( 194 | response: reqwest::Response, 195 | context: impl Fn() -> String, 196 | ) -> Result { 197 | if response.status() == 200 { 198 | return Ok(response); 199 | } 200 | 201 | Err(extract_google_api_error_intern( 202 | response.status().clone(), 203 | response.text().await?, 204 | context, 205 | )) 206 | } 207 | 208 | fn extract_google_api_error_intern( 209 | status: StatusCode, 210 | http_body: String, 211 | context: impl Fn() -> String, 212 | ) -> FirebaseError { 213 | let google_api_error_wrapper: std::result::Result = 214 | serde_json::from_str(&http_body); 215 | if let Ok(google_api_error_wrapper) = google_api_error_wrapper { 216 | if let Some(google_api_error) = google_api_error_wrapper.error { 217 | return FirebaseError::APIError(google_api_error.code, google_api_error.message.to_owned(), context()); 218 | } 219 | }; 220 | 221 | FirebaseError::UnexpectedResponse("", status, http_body, context()) 222 | } 223 | -------------------------------------------------------------------------------- /src/firebase_rest_to_rust.rs: -------------------------------------------------------------------------------- 1 | //! # Low Level API to convert between rust types and the Firebase REST API 2 | //! Low level API to convert between generated rust types (see [`crate::dto`]) and 3 | //! the data types of the Firebase REST API. Those are 1:1 translations of the grpc API 4 | //! and deeply nested and wrapped. 5 | 6 | use bytes::Bytes; 7 | use serde::{Deserialize, Serialize}; 8 | use serde_json::Value; 9 | use std::collections::HashMap; 10 | 11 | use super::dto; 12 | use super::errors::{FirebaseError, Result}; 13 | 14 | #[derive(Debug, Serialize, Deserialize)] 15 | struct Wrapper { 16 | #[serde(flatten)] 17 | extra: HashMap, 18 | } 19 | 20 | use serde_json::{map::Map, Number}; 21 | 22 | /// Converts a firebase google-rpc-api inspired heavily nested and wrapped response value 23 | /// of the Firebase REST API into a flattened serde json value. 24 | /// 25 | /// This is a low level API. You probably want to use [`crate::documents`] instead. 26 | /// 27 | /// This method works recursively! 28 | pub(crate) fn firebase_value_to_serde_value(v: &dto::Value) -> serde_json::Value { 29 | if let Some(timestamp_value) = v.timestamp_value.as_ref() { 30 | return Value::String(timestamp_value.clone()); 31 | } else if let Some(integer_value) = v.integer_value.as_ref() { 32 | if let Ok(four) = integer_value.parse::() { 33 | return Value::Number(four.into()); 34 | } 35 | } else if let Some(double_value) = v.double_value { 36 | if let Some(dd) = Number::from_f64(double_value) { 37 | return Value::Number(dd); 38 | } 39 | } else if let Some(map_value) = v.map_value.as_ref() { 40 | let mut map: Map = Map::new(); 41 | if let Some(map_fields) = &map_value.fields { 42 | for (map_key, map_v) in map_fields { 43 | map.insert(map_key.clone(), firebase_value_to_serde_value(&map_v)); 44 | } 45 | } 46 | return Value::Object(map); 47 | } else if let Some(string_value) = v.string_value.as_ref() { 48 | return Value::String(string_value.clone()); 49 | } else if let Some(boolean_value) = v.boolean_value { 50 | return Value::Bool(boolean_value); 51 | } else if let Some(array_value) = v.array_value.as_ref() { 52 | let mut vec: Vec = Vec::new(); 53 | if let Some(values) = &array_value.values { 54 | for k in values { 55 | vec.push(firebase_value_to_serde_value(&k)); 56 | } 57 | } 58 | return Value::Array(vec); 59 | } 60 | Value::Null 61 | } 62 | 63 | /// Converts a flat serde json value into a firebase google-rpc-api inspired heavily nested and wrapped type 64 | /// to be consumed by the Firebase REST API. 65 | /// 66 | /// This is a low level API. You probably want to use [`crate::documents`] instead. 67 | /// 68 | /// This method works recursively! 69 | pub(crate) fn serde_value_to_firebase_value(v: &serde_json::Value) -> dto::Value { 70 | if v.is_f64() { 71 | return dto::Value { 72 | double_value: Some(v.as_f64().unwrap()), 73 | ..Default::default() 74 | }; 75 | } else if let Some(integer_value) = v.as_i64() { 76 | return dto::Value { 77 | integer_value: Some(integer_value.to_string()), 78 | ..Default::default() 79 | }; 80 | } else if let Some(map_value) = v.as_object() { 81 | let mut map: HashMap = HashMap::new(); 82 | for (map_key, map_v) in map_value { 83 | map.insert(map_key.to_owned(), serde_value_to_firebase_value(&map_v)); 84 | } 85 | return dto::Value { 86 | map_value: Some(dto::MapValue { fields: Some(map) }), 87 | ..Default::default() 88 | }; 89 | } else if let Some(string_value) = v.as_str() { 90 | return dto::Value { 91 | string_value: Some(string_value.to_owned()), 92 | ..Default::default() 93 | }; 94 | } else if let Some(boolean_value) = v.as_bool() { 95 | return dto::Value { 96 | boolean_value: Some(boolean_value), 97 | ..Default::default() 98 | }; 99 | } else if let Some(array_value) = v.as_array() { 100 | let mut vec: Vec = Vec::new(); 101 | for k in array_value { 102 | vec.push(serde_value_to_firebase_value(&k)); 103 | } 104 | return dto::Value { 105 | array_value: Some(dto::ArrayValue { values: Some(vec) }), 106 | ..Default::default() 107 | }; 108 | } 109 | Default::default() 110 | } 111 | 112 | /// Converts a firebase google-rpc-api inspired heavily nested and wrapped response document 113 | /// of the Firebase REST API into a given custom type. 114 | /// 115 | /// This is a low level API. You probably want to use [`crate::documents`] instead. 116 | /// 117 | /// Arguments: 118 | /// * document: The document to convert 119 | /// * input_doc: Optional. The input bytes. Those will be part of the result in case of a parsing error. 120 | /// 121 | /// Internals: 122 | /// 123 | /// This method uses recursion to decode the given firebase type. 124 | pub fn document_to_pod(document: &dto::Document, input_doc: Option<&Bytes>) -> Result 125 | where 126 | for<'de> T: Deserialize<'de>, 127 | { 128 | // The firebase document has a field called "fields" that contain all top-level fields. 129 | // We want those to be flattened to our custom data structure. To not reinvent the wheel, 130 | // perform the firebase-value to serde-values conversion for all fields first and wrap those 131 | // Wrapper struct with a HashMap. Use #[serde(flatten)] on that map. 132 | let r = Wrapper { 133 | extra: document 134 | .fields 135 | .as_ref() 136 | .unwrap() 137 | .iter() 138 | .map(|(k, v)| { 139 | return (k.to_owned(), firebase_value_to_serde_value(&v)); 140 | }) 141 | .collect(), 142 | }; 143 | 144 | let v = serde_json::to_value(r)?; 145 | let r: T = serde_json::from_value(v).map_err(|e| FirebaseError::SerdeVerbose { 146 | doc: Some(document.name.clone()), 147 | input_doc: String::from_utf8_lossy(input_doc.unwrap_or(&Bytes::new())) 148 | .replace("\n", " ") 149 | .to_string(), 150 | ser: e, 151 | })?; 152 | Ok(r) 153 | } 154 | 155 | /// Converts a custom data type into a firebase google-rpc-api inspired heavily nested and wrapped type 156 | /// to be consumed by the Firebase REST API. 157 | /// 158 | /// This is a low level API. You probably want to use [`crate::documents`] instead. 159 | /// 160 | /// Internals: 161 | /// 162 | /// This method uses recursion to decode the given firebase type. 163 | pub fn pod_to_document(pod: &T) -> Result 164 | where 165 | T: Serialize, 166 | { 167 | let v = serde_json::to_value(pod)?; 168 | Ok(dto::Document { 169 | fields: serde_value_to_firebase_value(&v).map_value.unwrap().fields, 170 | ..Default::default() 171 | }) 172 | } 173 | 174 | #[cfg(test)] 175 | mod tests { 176 | use super::*; 177 | 178 | use super::Result; 179 | use serde::{Deserialize, Serialize}; 180 | use std::collections::HashMap; 181 | 182 | #[derive(Serialize, Deserialize)] 183 | struct DemoPod { 184 | integer_test: u32, 185 | boolean_test: bool, 186 | string_test: String, 187 | } 188 | 189 | #[test] 190 | fn test_document_to_pod() -> Result<()> { 191 | let mut map: HashMap = HashMap::new(); 192 | map.insert( 193 | "integer_test".to_owned(), 194 | dto::Value { 195 | integer_value: Some("12".to_owned()), 196 | ..Default::default() 197 | }, 198 | ); 199 | map.insert( 200 | "boolean_test".to_owned(), 201 | dto::Value { 202 | boolean_value: Some(true), 203 | ..Default::default() 204 | }, 205 | ); 206 | map.insert( 207 | "string_test".to_owned(), 208 | dto::Value { 209 | string_value: Some("abc".to_owned()), 210 | ..Default::default() 211 | }, 212 | ); 213 | let t = dto::Document { 214 | fields: Some(map), 215 | ..Default::default() 216 | }; 217 | let firebase_doc: DemoPod = document_to_pod(&t, None)?; 218 | assert_eq!(firebase_doc.string_test, "abc"); 219 | assert_eq!(firebase_doc.integer_test, 12); 220 | assert_eq!(firebase_doc.boolean_test, true); 221 | 222 | Ok(()) 223 | } 224 | 225 | #[test] 226 | fn test_pod_to_document() -> Result<()> { 227 | let t = DemoPod { 228 | integer_test: 12, 229 | boolean_test: true, 230 | string_test: "abc".to_owned(), 231 | }; 232 | let firebase_doc = pod_to_document(&t)?; 233 | let map = firebase_doc.fields; 234 | assert_eq!( 235 | map.unwrap() 236 | .get("integer_test") 237 | .expect("a value in the map for integer_test") 238 | .integer_value 239 | .as_ref() 240 | .expect("an integer value"), 241 | "12" 242 | ); 243 | 244 | Ok(()) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /src/jwt.rs: -------------------------------------------------------------------------------- 1 | //! # A Firestore Auth Session token is a Javascript Web Token (JWT). This module contains JWT helper functions. 2 | 3 | use super::credentials::Credentials; 4 | 5 | use serde::{Deserialize, Serialize}; 6 | 7 | use chrono::{Duration, Utc}; 8 | use std::collections::HashSet; 9 | use std::ops::Add; 10 | use std::slice::Iter; 11 | 12 | use crate::errors::FirebaseError; 13 | use biscuit::jwa::SignatureAlgorithm; 14 | use biscuit::{ClaimPresenceOptions, SingleOrMultiple, ValidationOptions}; 15 | use cache_control::CacheControl; 16 | use std::ops::Deref; 17 | 18 | type Error = super::errors::FirebaseError; 19 | 20 | pub static JWT_AUDIENCE_FIRESTORE: &str = "https://firestore.googleapis.com/google.firestore.v1.Firestore"; 21 | pub static JWT_AUDIENCE_IDENTITY: &str = 22 | "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; 23 | 24 | #[derive(Debug, Serialize, Deserialize, Clone, Default)] 25 | pub struct JwtOAuthPrivateClaims { 26 | #[serde(skip_serializing_if = "Option::is_none")] 27 | pub scope: Option, 28 | #[serde(skip_serializing_if = "Option::is_none")] 29 | pub client_id: Option, 30 | #[serde(skip_serializing_if = "Option::is_none")] 31 | pub uid: Option, // Probably the firebase User ID if set 32 | } 33 | 34 | pub(crate) type AuthClaimsJWT = biscuit::JWT; 35 | 36 | #[derive(Serialize, Deserialize, Default, Clone)] 37 | pub struct JWSEntry { 38 | #[serde(flatten)] 39 | pub(crate) headers: biscuit::jws::RegisteredHeader, 40 | #[serde(flatten)] 41 | pub(crate) ne: biscuit::jwk::RSAKeyParameters, 42 | } 43 | 44 | #[derive(Serialize, Deserialize, Default, Clone)] 45 | pub struct JWKSet { 46 | pub keys: Vec, 47 | } 48 | 49 | impl JWKSet { 50 | /// Create a new JWKSetDTO instance from a given json string 51 | /// You can use [`Credentials::add_jwks_public_keys`] to manually add more public keys later on. 52 | /// You need two JWKS files for this crate to work: 53 | /// * https://www.googleapis.com/service_accounts/v1/jwk/securetoken@system.gserviceaccount.com 54 | /// * https://www.googleapis.com/service_accounts/v1/jwk/{your-service-account-email} 55 | pub fn new(jwk_content: &str) -> Result { 56 | let jwk_set: JWKSet = serde_json::from_str(jwk_content).map_err(|e| FirebaseError::Ser { 57 | doc: Option::from(format!("Failed to parse jwkset. Return value: {}", jwk_content)), 58 | ser: e, 59 | })?; 60 | Ok(jwk_set) 61 | } 62 | } 63 | 64 | /// Download the Google JWK Set for a given service account. 65 | /// Returns the JWKS alongside the maximum time the JWKS is valid for. 66 | /// The resulting set of JWKs need to be added to a credentials object 67 | /// for jwk verifications. 68 | pub async fn download_google_jwks(account_mail: &str) -> Result<(String, Option), Error> { 69 | let url = format!("https://www.googleapis.com/service_accounts/v1/jwk/{}", account_mail); 70 | let resp = reqwest::Client::new().get(&url).send().await?; 71 | let max_age = resp 72 | .headers() 73 | .get("cache-control") 74 | .and_then(|cache_control| cache_control.to_str().ok()) 75 | .and_then(|cache_control| CacheControl::from_value(cache_control)) 76 | .and_then(|cache_control| cache_control.max_age) 77 | .and_then(|max_age| Duration::from_std(max_age).ok()); 78 | 79 | let text = resp.text().await?; 80 | Ok((text, max_age)) 81 | } 82 | 83 | pub(crate) async fn create_jwt_encoded>( 84 | credentials: &Credentials, 85 | scope: Option>, 86 | duration: chrono::Duration, 87 | client_id: Option, 88 | user_id: Option, 89 | audience: &str, 90 | ) -> Result { 91 | let jwt = create_jwt(credentials, scope, duration, client_id, user_id, audience)?; 92 | let secret_lock = credentials.keys.read().await; 93 | let secret = secret_lock 94 | .secret 95 | .as_ref() 96 | .ok_or(Error::Generic("No private key added via add_keypair_key!"))?; 97 | Ok(jwt.encode(&secret.deref())?.encoded()?.encode()) 98 | } 99 | 100 | /// Returns true if the access token (assumed to be a jwt) has expired 101 | /// 102 | /// An error is returned if the given access token string is not a jwt 103 | pub(crate) fn is_expired(access_token: &str, tolerance_in_minutes: i64) -> Result { 104 | let token = AuthClaimsJWT::new_encoded(&access_token); 105 | let claims = token.unverified_payload()?; 106 | if let Some(expiry) = claims.registered.expiry.as_ref() { 107 | let diff: Duration = Utc::now().signed_duration_since(expiry.deref().clone()); 108 | return Ok(diff.num_minutes() - tolerance_in_minutes > 0); 109 | } 110 | 111 | Ok(true) 112 | } 113 | 114 | /// Returns true if the jwt was updated and needs signing 115 | pub(crate) fn jwt_update_expiry_if(jwt: &mut AuthClaimsJWT, expire_in_minutes: i64) -> bool { 116 | let ref mut claims = jwt.payload_mut().unwrap().registered; 117 | 118 | let now = biscuit::Timestamp::from(Utc::now()); 119 | let now_plus_hour = biscuit::Timestamp::from(Utc::now().add(Duration::hours(1))); 120 | 121 | if let Some(issued_at) = claims.issued_at.as_ref() { 122 | let diff: Duration = Utc::now().signed_duration_since(issued_at.deref().clone()); 123 | if diff.num_minutes() > expire_in_minutes { 124 | claims.issued_at = Some(now); 125 | claims.expiry = Some(now_plus_hour); 126 | } else { 127 | return false; 128 | } 129 | } else { 130 | claims.issued_at = Some(now); 131 | claims.expiry = Some(now_plus_hour); 132 | } 133 | 134 | true 135 | } 136 | 137 | pub(crate) fn create_jwt( 138 | credentials: &Credentials, 139 | scope: Option>, 140 | duration: chrono::Duration, 141 | client_id: Option, 142 | user_id: Option, 143 | audience: &str, 144 | ) -> Result 145 | where 146 | S: AsRef, 147 | { 148 | use biscuit::{ 149 | jws::{Header, RegisteredHeader}, 150 | ClaimsSet, Empty, RegisteredClaims, JWT, 151 | }; 152 | 153 | let header: Header = Header::from(RegisteredHeader { 154 | algorithm: SignatureAlgorithm::RS256, 155 | key_id: Some(credentials.private_key_id.to_owned()), 156 | ..Default::default() 157 | }); 158 | let expected_claims = ClaimsSet:: { 159 | registered: RegisteredClaims { 160 | issuer: Some(credentials.client_email.clone()), 161 | audience: Some(SingleOrMultiple::Single(audience.to_string())), 162 | subject: Some(credentials.client_email.clone()), 163 | expiry: Some(biscuit::Timestamp::from(Utc::now().add(duration))), 164 | issued_at: Some(biscuit::Timestamp::from(Utc::now())), 165 | ..Default::default() 166 | }, 167 | private: JwtOAuthPrivateClaims { 168 | scope: scope.and_then(|f| { 169 | Some(f.fold(String::new(), |acc, x| { 170 | let x: &str = x.as_ref(); 171 | return acc + x + " "; 172 | })) 173 | }), 174 | client_id, 175 | uid: user_id, 176 | }, 177 | }; 178 | Ok(JWT::new_decoded(header, expected_claims)) 179 | } 180 | 181 | #[derive(Debug)] 182 | pub struct TokenValidationResult { 183 | pub claims: JwtOAuthPrivateClaims, 184 | pub audience: String, 185 | pub subject: String, 186 | } 187 | 188 | impl TokenValidationResult { 189 | pub fn get_scopes(&self) -> HashSet { 190 | match self.claims.scope { 191 | Some(ref v) => v.split(" ").map(|f| f.to_owned()).collect(), 192 | None => HashSet::new(), 193 | } 194 | } 195 | } 196 | 197 | pub(crate) async fn verify_access_token( 198 | credentials: &Credentials, 199 | access_token: &str, 200 | ) -> Result { 201 | let token = AuthClaimsJWT::new_encoded(&access_token); 202 | 203 | let header = token.unverified_header()?; 204 | let kid = header 205 | .registered 206 | .key_id 207 | .as_ref() 208 | .ok_or(FirebaseError::Generic("No jwt kid"))?; 209 | let secret = credentials 210 | .decode_secret(kid) 211 | .await? 212 | .ok_or(FirebaseError::Generic("No secret for kid"))?; 213 | 214 | let token = token.into_decoded(&secret.deref(), SignatureAlgorithm::RS256)?; 215 | 216 | use biscuit::Presence::*; 217 | 218 | let o = ValidationOptions { 219 | claim_presence_options: ClaimPresenceOptions { 220 | issued_at: Required, 221 | not_before: Optional, 222 | expiry: Required, 223 | issuer: Required, 224 | audience: Required, 225 | subject: Required, 226 | id: Optional, 227 | }, 228 | // audience: Validation::Validate(StringOrUri::from_str(JWT_SUBJECT)?), 229 | ..Default::default() 230 | }; 231 | 232 | let claims = token.payload()?; 233 | claims.registered.validate(o)?; 234 | 235 | let audience = match claims.registered.audience.as_ref().unwrap() { 236 | SingleOrMultiple::Single(v) => v.to_string(), 237 | SingleOrMultiple::Multiple(v) => v.get(0).unwrap().to_string(), 238 | }; 239 | 240 | Ok(TokenValidationResult { 241 | claims: claims.private.clone(), 242 | subject: claims.registered.subject.as_ref().unwrap().to_string(), 243 | audience, 244 | }) 245 | } 246 | 247 | pub mod session_cookie { 248 | use super::*; 249 | use std::ops::Add; 250 | 251 | pub(crate) async fn create_jwt_encoded( 252 | credentials: &Credentials, 253 | duration: chrono::Duration, 254 | ) -> Result { 255 | let scope = [ 256 | "https://www.googleapis.com/auth/cloud-platform", 257 | "https://www.googleapis.com/auth/firebase.database", 258 | "https://www.googleapis.com/auth/firebase.messaging", 259 | "https://www.googleapis.com/auth/identitytoolkit", 260 | "https://www.googleapis.com/auth/userinfo.email", 261 | ]; 262 | 263 | const AUDIENCE: &str = "https://accounts.google.com/o/oauth2/token"; 264 | 265 | use biscuit::{ 266 | jws::{Header, RegisteredHeader}, 267 | ClaimsSet, Empty, RegisteredClaims, JWT, 268 | }; 269 | 270 | let header: Header = Header::from(RegisteredHeader { 271 | algorithm: SignatureAlgorithm::RS256, 272 | key_id: Some(credentials.private_key_id.to_owned()), 273 | ..Default::default() 274 | }); 275 | let expected_claims = ClaimsSet:: { 276 | registered: RegisteredClaims { 277 | issuer: Some(credentials.client_email.clone()), 278 | audience: Some(SingleOrMultiple::Single(AUDIENCE.to_string())), 279 | subject: Some(credentials.client_email.clone()), 280 | expiry: Some(biscuit::Timestamp::from(Utc::now().add(duration))), 281 | issued_at: Some(biscuit::Timestamp::from(Utc::now())), 282 | ..Default::default() 283 | }, 284 | private: JwtOAuthPrivateClaims { 285 | scope: Some(scope.join(" ")), 286 | client_id: None, 287 | uid: None, 288 | }, 289 | }; 290 | let jwt = JWT::new_decoded(header, expected_claims); 291 | 292 | let secret_lock = credentials.keys.read().await; 293 | let secret = secret_lock 294 | .secret 295 | .as_ref() 296 | .ok_or(Error::Generic("No private key added via add_keypair_key!"))?; 297 | Ok(jwt.encode(&secret.deref())?.encoded()?.encode()) 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![deny(warnings)] 2 | #![cfg_attr(not(doctest), doc = include_str!("../readme.md"))] 3 | 4 | pub mod credentials; 5 | pub mod documents; 6 | pub mod dto; 7 | pub mod errors; 8 | pub mod firebase_rest_to_rust; 9 | pub mod jwt; 10 | pub mod sessions; 11 | pub mod users; 12 | 13 | #[cfg(feature = "rocket_support")] 14 | pub mod rocket; 15 | 16 | // Forward declarations 17 | pub use credentials::Credentials; 18 | pub use jwt::JWKSet; 19 | pub use sessions::service_account::Session as ServiceSession; 20 | pub use sessions::user::Session as UserSession; 21 | 22 | /// Authentication trait. 23 | /// 24 | /// This trait is implemented by [`crate::sessions`]. 25 | /// 26 | /// Firestore document methods in [`crate::documents`] expect an object that implements this `FirebaseAuthBearer` trait. 27 | /// 28 | /// Implement this trait for your own data structure and provide the Firestore project id and a valid access token. 29 | #[async_trait::async_trait] 30 | pub trait FirebaseAuthBearer { 31 | /// Return the project ID. This is required for the firebase REST API. 32 | fn project_id(&self) -> &str; 33 | 34 | /// An access token. If a refresh token is known and the access token expired, 35 | /// the implementation should try to refresh the access token before returning. 36 | async fn access_token(&self) -> String; 37 | 38 | /// The access token, unchecked. Might be expired or in other ways invalid. 39 | async fn access_token_unchecked(&self) -> String; 40 | 41 | /// The reqwest http client. 42 | /// The `Client` holds a connection pool internally, so it is advised that it is reused for multiple, successive connections. 43 | fn client(&self) -> &reqwest::Client; 44 | } 45 | -------------------------------------------------------------------------------- /src/rocket/mod.rs: -------------------------------------------------------------------------------- 1 | //! # Rocket Authentication Guard 2 | //! 3 | //! Because the `sessions` module of this crate is already able to verify access tokens, 4 | //! it was not much more work to turn this into a Rocket 0.4+ Guard. 5 | //! 6 | //! The implemented Guard (enabled by the feature "rocket_support") allows access to http paths 7 | //! if the provided http "Authorization" header contains a valid "Bearer" token. 8 | //! The above mentioned validations on the token are performed. 9 | //! 10 | //! Example: 11 | //! 12 | //! ``` 13 | //! use firestore_db_and_auth::{Credentials, rocket::FirestoreAuthSessionGuard}; 14 | //! use rocket::get; 15 | //! 16 | //! fn main() { 17 | //! use rocket::routes; 18 | //! let credentials = Credentials::from_file("firebase-service-account.json").unwrap(); 19 | //! rocket::build().ignite().manage(credentials).mount("/", routes![hello, hello_not_logged_in]).launch(); 20 | //! } 21 | //! 22 | //! /// And an example route could be: 23 | //! #[get("/hello")] 24 | //! fn hello<'r>(auth: FirestoreAuthSessionGuard) -> String { 25 | //! // ApiKey is a single value tuple with a sessions::user::Session object inside 26 | //! format!("you are logged in. user_id: {}", auth.0.user_id) 27 | //! } 28 | //! 29 | //! #[get("/hello")] 30 | //! fn hello_not_logged_in<'r>() -> &'r str { 31 | //! "you are not logged in" 32 | //! } 33 | //! ``` 34 | use super::credentials::Credentials; 35 | use super::errors::FirebaseError; 36 | use super::sessions; 37 | use rocket::request::Outcome; 38 | use rocket::{http::Status, request, State}; 39 | 40 | /// Use this Rocket guard to secure a route for authenticated users only. 41 | /// Will return the associated session, that contains the used access token for further use 42 | /// and access to the Firestore database. 43 | pub struct FirestoreAuthSessionGuard(pub sessions::user::Session); 44 | 45 | #[rocket::async_trait] 46 | impl<'a> request::FromRequest<'a> for FirestoreAuthSessionGuard { 47 | type Error = FirebaseError; 48 | 49 | async fn from_request(request: &'a request::Request<'_>) -> request::Outcome { 50 | let r = request 51 | .headers() 52 | .get_one("Authorization") 53 | .map(|f| f.to_owned()) 54 | .or(request.query_value("auth").and_then(|r| r.ok())); 55 | if r.is_none() { 56 | return Outcome::Forward(Status::BadRequest); 57 | } 58 | let bearer = r.unwrap(); 59 | if !bearer.starts_with("Bearer ") { 60 | return Outcome::Forward(Status::BadRequest); 61 | } 62 | let bearer = &bearer[7..]; 63 | 64 | // You MUST make the credentials object available as managed state to rocket! 65 | let db = match request.guard::<&State>().await { 66 | Outcome::Success(db) => db, 67 | _ => { 68 | return Outcome::Error(( 69 | Status::InternalServerError, 70 | FirebaseError::Generic("Firestore credentials not set!"), 71 | )) 72 | } 73 | }; 74 | 75 | let session = sessions::user::Session::by_access_token(&db, bearer).await; 76 | if session.is_err() { 77 | return Outcome::Forward(Status::BadRequest); 78 | } 79 | Outcome::Success(FirestoreAuthSessionGuard(session.unwrap())) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/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)] 6 | use super::credentials; 7 | use super::errors::{extract_google_api_error, extract_google_api_error_async, FirebaseError}; 8 | use super::jwt::{ 9 | create_jwt, is_expired, jwt_update_expiry_if, verify_access_token, AuthClaimsJWT, JWT_AUDIENCE_FIRESTORE, 10 | JWT_AUDIENCE_IDENTITY, 11 | }; 12 | use super::FirebaseAuthBearer; 13 | 14 | use chrono::Duration; 15 | use serde::{Deserialize, Serialize}; 16 | use std::cell::RefCell; 17 | use std::ops::Deref; 18 | use std::slice::Iter; 19 | use std::sync::Arc; 20 | use tokio::sync::RwLock; 21 | 22 | pub 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, 79 | /// The firebase projects API key, as defined in the credentials object 80 | pub api_key: String, 81 | 82 | access_token_: Arc>, 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 { 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, 160 | idToken: String, 161 | refreshToken: Option, 162 | expiresIn: Option, 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 { 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 { 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 { 264 | let scope: Option> = 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 { 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 { 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 | 363 | pub 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, 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 { 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 460 | pub 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>, 478 | access_token_: Arc>, 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 { 538 | let scope: Option> = 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 { 566 | self.credentials.verify_token(token).await 567 | } 568 | } 569 | } 570 | -------------------------------------------------------------------------------- /src/users.rs: -------------------------------------------------------------------------------- 1 | //! # Firebase Auth API - User information 2 | //! 3 | //! Retrieve firebase user information 4 | 5 | use super::errors::{extract_google_api_error_async, Result}; 6 | 7 | use super::sessions::{service_account, user}; 8 | use serde::{Deserialize, Serialize}; 9 | 10 | use crate::FirebaseAuthBearer; 11 | 12 | /// A federated services like Facebook, Github etc that the user has used to 13 | /// authenticated himself and that he associated with this firebase auth account. 14 | #[allow(non_snake_case)] 15 | #[derive(Debug, Default, Deserialize, Serialize)] 16 | pub struct ProviderUserInfo { 17 | pub providerId: String, 18 | pub federatedId: String, 19 | pub displayName: Option, 20 | pub photoUrl: Option, 21 | } 22 | 23 | /// Users id, email, display name and a few more information 24 | #[allow(non_snake_case)] 25 | #[derive(Debug, Default, Deserialize, Serialize)] 26 | pub struct FirebaseAuthUser { 27 | pub localId: Option, 28 | pub email: Option, 29 | /// True if the user has verified his email address 30 | pub emailVerified: Option, 31 | pub displayName: Option, 32 | /// Find all federated services like Facebook, Github etc that the user has used to 33 | /// authenticated himself and that he associated with this firebase auth account. 34 | pub providerUserInfo: Option>, 35 | pub photoUrl: Option, 36 | /// True if the account is disabled. A disabled account cannot login anymore. 37 | pub disabled: Option, 38 | /// Last login datetime in UTC 39 | pub lastLoginAt: Option, 40 | /// Created datetime in UTC 41 | pub createdAt: Option, 42 | /// True if email/password login have been used 43 | pub customAuth: Option, 44 | } 45 | 46 | /// Your user information query might return zero, one or more [`FirebaseAuthUser`] structures. 47 | #[derive(Debug, Default, Deserialize, Serialize)] 48 | pub struct FirebaseAuthUserResponse { 49 | pub kind: String, 50 | pub users: Vec, 51 | } 52 | 53 | #[allow(non_snake_case)] 54 | #[derive(Serialize)] 55 | struct UserRequest { 56 | pub idToken: String, 57 | } 58 | 59 | #[inline] 60 | fn firebase_auth_url(v: &str, v2: &str) -> String { 61 | format!("https://identitytoolkit.googleapis.com/v1/accounts:{}?key={}", v, v2) 62 | } 63 | 64 | /// Retrieve information about the firebase auth user associated with the given user session 65 | /// 66 | /// Error codes: 67 | /// - INVALID_ID_TOKEN 68 | /// - USER_NOT_FOUND 69 | pub async fn user_info(session: &user::Session) -> Result { 70 | let url = firebase_auth_url("lookup", &session.api_key); 71 | 72 | let resp = session 73 | .client() 74 | .post(&url) 75 | .json(&UserRequest { 76 | idToken: session.access_token().await, 77 | }) 78 | .send() 79 | .await?; 80 | 81 | let resp = extract_google_api_error_async(resp, || session.user_id.to_owned()).await?; 82 | 83 | Ok(resp.json().await?) 84 | } 85 | 86 | /// Removes the firebase auth user associated with the given user session 87 | /// 88 | /// Error codes: 89 | /// - INVALID_ID_TOKEN 90 | /// - USER_NOT_FOUND 91 | pub async fn user_remove(session: &user::Session) -> Result<()> { 92 | let url = firebase_auth_url("delete", &session.api_key); 93 | let resp = session 94 | .client() 95 | .post(&url) 96 | .json(&UserRequest { 97 | idToken: session.access_token().await, 98 | }) 99 | .send() 100 | .await?; 101 | 102 | extract_google_api_error_async(resp, || session.user_id.to_owned()).await?; 103 | Ok({}) 104 | } 105 | 106 | #[allow(non_snake_case)] 107 | #[derive(Default, Deserialize)] 108 | struct SignInUpUserResponse { 109 | localId: String, 110 | idToken: String, 111 | refreshToken: String, 112 | } 113 | 114 | #[allow(non_snake_case)] 115 | #[derive(Serialize)] 116 | struct SignInUpUserRequest { 117 | pub email: String, 118 | pub password: String, 119 | pub returnSecureToken: bool, 120 | } 121 | 122 | async fn sign_up_in( 123 | session: &service_account::Session, 124 | email: &str, 125 | password: &str, 126 | action: &str, 127 | ) -> Result { 128 | let url = firebase_auth_url(action, &session.credentials.api_key); 129 | let resp = session 130 | .client() 131 | .post(&url) 132 | .json(&SignInUpUserRequest { 133 | email: email.to_owned(), 134 | password: password.to_owned(), 135 | returnSecureToken: true, 136 | }) 137 | .send() 138 | .await?; 139 | 140 | let resp = extract_google_api_error_async(resp, || email.to_owned()).await?; 141 | 142 | let resp: SignInUpUserResponse = resp.json().await?; 143 | 144 | Ok(user::Session::new( 145 | &session.credentials, 146 | Some(&resp.localId), 147 | Some(&resp.idToken), 148 | Some(&resp.refreshToken), 149 | ) 150 | .await?) 151 | } 152 | 153 | /// Creates the firebase auth user with the given email and password and returns 154 | /// a user session. 155 | /// 156 | /// Error codes: 157 | /// EMAIL_EXISTS: The email address is already in use by another account. 158 | /// OPERATION_NOT_ALLOWED: Password sign-in is disabled for this project. 159 | /// TOO_MANY_ATTEMPTS_TRY_LATER: We have blocked all requests from this device due to unusual activity. Try again later. 160 | pub async fn sign_up(session: &service_account::Session, email: &str, password: &str) -> Result { 161 | sign_up_in(session, email, password, "signUp").await 162 | } 163 | 164 | /// Signs in with the given email and password and returns a user session. 165 | /// 166 | /// Error codes: 167 | /// EMAIL_NOT_FOUND: There is no user record corresponding to this identifier. The user may have been deleted. 168 | /// INVALID_PASSWORD: The password is invalid or the user does not have a password. 169 | /// USER_DISABLED: The user account has been disabled by an administrator. 170 | pub async fn sign_in(session: &service_account::Session, email: &str, password: &str) -> Result { 171 | sign_up_in(session, email, password, "signInWithPassword").await 172 | } 173 | -------------------------------------------------------------------------------- /tests/extract_test_credentials.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | # Github Actions has a secret variable for this repository called "SERVICE_ACCOUNT_JSON" 3 | # that contains the base64 encoded credentials json. Create such a secret with "cat file.json | base64 -w 0". 4 | if [ "$(uname)" == "Darwin" ]; then 5 | echo "$SERVICE_ACCOUNT_JSON" | base64 -D > firebase-service-account.json 6 | else 7 | echo "$SERVICE_ACCOUNT_JSON" | base64 -d > firebase-service-account.json 8 | fi 9 | # Print the first few lines 10 | head -n 3 firebase-service-account.json 11 | # Sanity check 12 | [[ $(jq -r ".auth_uri" firebase-service-account.json) == "https://accounts.google.com/o/oauth2/auth" ]] || { echo >&2 'Failed to extract firebase-service-account.json'; exit 1; } 13 | jq -e '.api_key' firebase-service-account.json > /dev/null || { echo >&2 'Provided firebase-service-account.json does not have api_key set'; exit 1; } 14 | 15 | # Test if the service account user is still existing 16 | URL=https://www.googleapis.com/service_accounts/v1/jwk/$(jq -r ".client_email" firebase-service-account.json) 17 | echo "Test $URL" 18 | curl -s $URL | jq -e '.keys' > /dev/null || { echo >&2 'Test service account does not exist anymore'; exit 1; } -------------------------------------------------------------------------------- /tests/left_over_println.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | [[ $(grep -n -r -F "println" src/ | grep -v -c "///") -eq 0 ]] || { echo >&2 'Please remove all debug println'; grep -n -r -F "println" src/ | grep -v "///"; exit 1; } 3 | -------------------------------------------------------------------------------- /tests/service-account-test.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "service_account", 3 | "project_id":"project_id", 4 | "private_key_id":"test_kid", 5 | "private_key":"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDG7fLXGssDfkpU\nK3v0oEggl3mVw5rVVl9nXvI38r22WDJCdlSi1Kd3KrvxaN+Mt9FOgzDqoKoYH2b9\nNYf60J9TE0YGcX6G9XrRRii2oyaO2sYiJAjzlPwZo3vR54+hmeg0GjyObBOITGx+\nZDjJPchkDs36tcjX8JOGBrBaPY+oPrLr7hFFzgp0SHmM4/lZ8MJzfpcEtpHvKzy8\npHUJlX8NCR86GYqN5cLDu7HsvcqtqmIML76auMgcWPea5Wjk98JieEEXN5lK0DW6\nsN2Oi3MLbZu7rKZJ1gLylC6uypa9LRJOVFgj82fOse7Bp0NBgGnjr3XN1QUOAGCS\nsInSKYU/AgMBAAECggEAWyMes2/h/Jq6YO9/HablBJGBMZzo7b5hfRFhtUIvqj+j\n3xEpW2RDyPO6ITKj4GtCqE6wdX3gD6crXuxMfRthMwVMep06k4gZmZEkC/CZNK+E\nQJXzx+zExtZAXv1Qr3+8g0pV3gYjuLkSp6Ew5vm4OicSNT9dYZklbSzZVK6Jh1FL\nhsPk8Jp32xcBUcFnFqr45ay9yfsOvfViUhdNWXFgJKL0G3OCChFY2kce+fbj7lDk\nOh5FWWa7dCYAmYNZy0HNbVx/ww/CFFamiYphBU0Gg+0uvTyoLEuF1T3ssM3E5vzb\n3jUCjnRrnsTdUnqWWDTKYJWaWLYR/bngeTsEpkSYiQKBgQDllOBDD9eNNIg3yFbj\nBmGbuxEkO/WFx3/hyAd1LkhdmApJY/reVymOfeOBosCGVDC3Qt8wflUE6Y6Nzn0y\nKCEbrfc9gTOL69O0LfnFy0WikNMCzqUJAFEAQRhyMp25hvAtfrxTbIeN0WNLjwDS\n9jHoYJHOS6aumzOV2lB8GYQdxQKBgQDd0hwBEvNfjlJGCp0O5BQHF1PZQ2e6pOJs\nf5pAD7KC1cARQJKztBWgwVaufCimOpYUlpiu2rqARL424ktZlEP+hhrX2aZj8g68\ndtrLvuPMZTBHNPqtNu9baSPvChT1ha/yrIslVXjhA3NRCUO9k+F53pXbkSZGQBZL\ndyDZhXGrMwKBgQDTgzy2LDM/0cUp93YtROTKkCczxdXnAa0M+7f7Or/LEtdvVCB9\nlbogoFTpS2OqnogfXwm3aLy1gOQoO7RWcGhIUxd038L4xzVNTApRM3ydUGZHsNCV\noWf/HvoBxCZSFkLS929UQKoGe4HKzB5LPi0u1UGf2UzgkvaMugzquKtirQKBgAla\nEVouPVp6+pb/XY3CKeH+psdTIy1oRC+E1bD7uG/xqQ5RJZ4z6nGDlS74BxKzB57I\nlplQmWpM+6P+uGSHbrJtXvZYs74k92AUtra+ToZQgc+OuT7SQRcegKhUSXvsbfWS\nNq+VQepipdr1xAhbErd7nd/K580waIA3/oeNK7SnAoGASTffZsSi+XyogHOTrLCT\nFHL8i9O3LF/I9wZsGgdqMq+XtBYG1cMhn4g4GBNB7b4GgIEhe5Fzw4opnA9jyNzz\nRITrkpuvmJathvim4pPophEfcARtlu9RJzELZDV8SRbXRgVtLT2XS16bDyFS1n+R\nqSTMvJOdu+3RWf7LGKNIrNE=\n-----END PRIVATE KEY-----", 6 | "client_email":"firebase-adminsdk-fhm5e@kompoundsolution-1534247861326.iam.gserviceaccount.com", 7 | "client_id":"client_id", 8 | "api_key":"api_key" 9 | } -------------------------------------------------------------------------------- /tests/service-account-test.jwks: -------------------------------------------------------------------------------- 1 | { 2 | "keys": [ 3 | { 4 | "alg": "RS256", 5 | "kid": "test_kid", 6 | "kty": "RSA", 7 | "use": "sig", 8 | "n": "xu3y1xrLA35KVCt79KBIIJd5lcOa1VZfZ17yN_K9tlgyQnZUotSndyq78WjfjLfRToMw6qCqGB9m_TWH-tCfUxNGBnF-hvV60UYotqMmjtrGIiQI85T8GaN70eePoZnoNBo8jmwTiExsfmQ4yT3IZA7N-rXI1_CThgawWj2PqD6y6-4RRc4KdEh5jOP5WfDCc36XBLaR7ys8vKR1CZV_DQkfOhmKjeXCw7ux7L3KrapiDC--mrjIHFj3muVo5PfCYnhBFzeZStA1urDdjotzC22bu6ymSdYC8pQursqWvS0STlRYI_NnzrHuwadDQYBp4691zdUFDgBgkrCJ0imFPw", 9 | "e": "AQAB" 10 | } 11 | ] 12 | } --------------------------------------------------------------------------------