├── .devcontainer └── devcontainer.json ├── .env.example ├── .github ├── dependabot.yaml └── workflows │ ├── cd.yaml │ └── ci.yaml ├── .gitignore ├── .pubignore ├── .vscode └── tasks.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── analysis_options.yaml ├── build.yaml ├── dart_test.yaml ├── example └── main.dart ├── firebase.json ├── lib ├── firebase_auth_rest.dart ├── rest.dart └── src │ ├── firebase_account.dart │ ├── firebase_auth.dart │ ├── models │ ├── auth_exception.dart │ ├── delete_request.dart │ ├── emulator_config.dart │ ├── fetch_provider_request.dart │ ├── fetch_provider_response.dart │ ├── idp_provider.dart │ ├── oob_code_request.dart │ ├── oob_code_response.dart │ ├── password_reset_request.dart │ ├── password_reset_response.dart │ ├── provider_user_info.dart │ ├── refresh_response.dart │ ├── signin_request.dart │ ├── signin_response.dart │ ├── update_request.dart │ ├── update_response.dart │ ├── userdata.dart │ ├── userdata_request.dart │ └── userdata_response.dart │ ├── profile_update.dart │ └── rest_api.dart ├── pubspec.yaml ├── test ├── integration │ ├── account_test.dart │ └── test_config.dart └── unit │ ├── fakes.dart │ ├── firebase_account_test.dart │ ├── firebase_auth_test.dart │ ├── models │ └── idp_provider_test.dart │ ├── profile_update_test.dart │ └── rest_api_test.dart └── tool └── init_commit_hooks.sh /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/alpine 3 | { 4 | "name": "Flutter (Stable)", 5 | "image": "skycoder42/devcontainers-flutter:latest", 6 | "customizations": { 7 | "vscode": { 8 | "extensions": [ 9 | "blaxou.freezed", 10 | "dart-code.dart-code", 11 | "Gruntfuggly.todo-tree", 12 | "me-dutour-mathieu.vscode-github-actions", 13 | "mhutchie.git-graph", 14 | "ryanluker.vscode-coverage-gutters", 15 | "streetsidesoftware.code-spell-checker", 16 | "streetsidesoftware.code-spell-checker-german", 17 | "timonwong.shellcheck" 18 | ], 19 | "settings": { 20 | "terminal.integrated.defaultProfile.linux": "zsh" 21 | } 22 | } 23 | }, 24 | "features": { 25 | "ghcr.io/devcontainers-contrib/features/zsh-plugins:0": { 26 | "plugins": "git colorize vscode", 27 | "omzPlugins": "https://github.com/zsh-users/zsh-autosuggestions" 28 | } 29 | }, 30 | "postCreateCommand": "./tool/init_commit_hooks.sh" 31 | } 32 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Required 2 | FIREBASE_API_KEY= 3 | 4 | # Optional 5 | # FIREBASE_EMULATOR_HOST=127.0.0.1 6 | # FIREBASE_EMULATOR_PORT=9099 7 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "pub" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | - package-ecosystem: "github-actions" 8 | directory: "/" 9 | schedule: 10 | interval: "weekly" 11 | -------------------------------------------------------------------------------- /.github/workflows/cd.yaml: -------------------------------------------------------------------------------- 1 | name: CD - Publish to pub.dev 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | publish: 10 | name: Publish 11 | uses: Skycoder42/dart_test_tools/.github/workflows/publish.yml@main 12 | with: 13 | buildRunner: true 14 | permissions: 15 | id-token: write 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI/CD 2 | 3 | on: 4 | push: 5 | branches: 6 | - "**" 7 | workflow_dispatch: 8 | 9 | jobs: 10 | ci: 11 | name: CI 12 | uses: Skycoder42/dart_test_tools/.github/workflows/dart.yml@main 13 | with: 14 | buildRunner: true 15 | panaScoreThreshold: 10 16 | unitTestPaths: -P unit 17 | coverageExclude: >- 18 | "**/*.g.dart" 19 | "**/*.freezed.dart" 20 | integrationTestPaths: -P integration 21 | secrets: 22 | integrationTestEnvVars: | 23 | FIREBASE_API_KEY=${{ secrets.FIREBASE_API_KEY }} 24 | 25 | cd: 26 | name: CD 27 | uses: Skycoder42/dart_test_tools/.github/workflows/release.yml@main 28 | needs: 29 | - ci 30 | secrets: 31 | githubToken: ${{ secrets.GH_PAT }} 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | # If you're building an application, you may want to check-in your pubspec.lock 8 | pubspec.lock 9 | 10 | # Directory created by dartdoc 11 | # If you don't generate documentation locally you can remove this line. 12 | doc/api/ 13 | 14 | # Avoid committing generated Javascript files: 15 | *.dart.js 16 | *.info.json # Produced by the --dump-info flag. 17 | *.js # When generated by dart2js. Don't specify *.js if your 18 | # project includes source files written in JavaScript. 19 | *.js_ 20 | *.js.deps 21 | *.js.map 22 | 23 | # coverage / testing 24 | *.mocks.dart 25 | *.env.dart 26 | coverage 27 | 28 | .env 29 | *.log 30 | 31 | # Generated dart files 32 | *.freezed.dart 33 | *.g.dart 34 | 35 | # firebase 36 | .firebaserc 37 | -------------------------------------------------------------------------------- /.pubignore: -------------------------------------------------------------------------------- 1 | # Unignore 2 | ## Generated build files 3 | !*.freezed.dart 4 | !*.g.dart 5 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "dart", 6 | "command": "dart", 7 | "cwd": "", 8 | "args": [ 9 | "run", 10 | "build_runner", 11 | "watch" 12 | ], 13 | "problemMatcher": [], 14 | "label": "dart: dart run build_runner watch", 15 | "detail": "", 16 | "group": { 17 | "kind": "build", 18 | "isDefault": true 19 | } 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /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 | ## 2.1.1 - 2025-03-16 8 | ### Changed 9 | - Update dependencies 10 | - Update min dart sdk to 3.7.0 11 | 12 | ## 2.1.0 - 2025-01-19 13 | ### Changed 14 | - Added support for running against firebase emulator (#10, #11) 15 | - Update dependencies 16 | - Update min dart sdk to 3.6.0 17 | 18 | ### Fixed 19 | - Fixed wrong payload format for profile updates (#10) 20 | 21 | ## 2.0.6 - 2024-06-04 22 | ### Changed 23 | - Moved `FirebaseAccount.restore` to `FirebaseAuth.restoreAccount` 24 | 25 | ### Deprecated 26 | - Deprecated `FirebaseAccount.restore` and `FirebaseAccount.apiRestore` in favor of `FirebaseAuth.restoreAccount` 27 | 28 | ### Fixed 29 | - Fixed refresh timer not being canceled on `FirebaseAccount.dispose` 30 | 31 | ## 2.0.5 - 2024-05-21 32 | ### Changed 33 | - Update dependencies 34 | - Update min dart sdk to 3.4.0 35 | - Modernize build configuration and ci integration 36 | 37 | ## 2.0.4 - 2023-06-16 38 | ### Changed 39 | - Update dependencies 40 | - Update min dart sdk to 3.0.0 41 | 42 | ## 2.0.3 - 2021-11-11 43 | ### Fixed 44 | - Publishing error 45 | 46 | ## 2.0.2 - 2021-11-11 47 | ### Changed 48 | - Fix some linter issues 49 | 50 | ### Fixed 51 | - Removed errornous localId parameter from custom sign in responses (#5) 52 | 53 | ## 2.0.1 - 2021-05-04 54 | ### Added 55 | - JavaScript/Web CI tests 56 | 57 | ### Changed 58 | - Changed mocking framework to mocktail 59 | - Improve build scripts 60 | 61 | ### Security 62 | - Updated dependency requirements 63 | 64 | ## 2.0.0 - 2021-03-09 65 | ### Added 66 | - Integration tests 67 | 68 | ### Changed 69 | - Migrated to null-safety 70 | - Explicit token refreshes no longer propagate errors to idTokenStream listeners 71 | 72 | ### Removed 73 | - Logging integration 74 | 75 | ## 1.0.2 - 2021-02-02 76 | ### Changed 77 | - Updated dependencies 78 | 79 | ## 1.0.1 - 2020-11-05 80 | ### Fixed 81 | - Repair broken linter config 82 | - Fixup code based on working lints 83 | 84 | ## 1.0.0 - 2020-10-22 85 | ### Added 86 | - Full documentation for REST models 87 | - Extended logging support 88 | 89 | ### Changed 90 | - Use final members instead of read-only getters 91 | 92 | ### Fixed 93 | - Fixed automatic deployments 94 | - Exchange git hooks backend for improved git performance 95 | 96 | ## 0.1.1 - 2020-09-16 97 | ### Added 98 | - Automatic deployment on new releases to pub.dev 99 | - Automatic upload of release-docs to github pages 100 | - Complete README 101 | 102 | ## 0.0.1 - 2020-09-04 103 | ### Added 104 | - Initial release 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2024, Felix Barz 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | 1. Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | 2. Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | 3. Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # firebase_auth_rest 2 | [![CI/CD](https://github.com/Skycoder42/firebase_auth_rest/actions/workflows/ci.yaml/badge.svg)](https://github.com/Skycoder42/firebase_auth_rest/actions/workflows/ci.yaml) 3 | [![Pub Version](https://img.shields.io/pub/v/firebase_auth_rest)](https://pub.dev/packages/firebase_auth_rest) 4 | 5 | A platform independent Dart/Flutter Wrapper for the Firebase Authentication API based on REST 6 | 7 | ## Features 8 | - Pure Dart-based implementation 9 | - works on all platforms supported by dart 10 | - Uses the official REST-API endpoints 11 | - Provides high-level classes to manage authentication and users 12 | - Supports multiple parallel Logins of different users 13 | - Supports automatic background refresh 14 | - Supports all login methods 15 | - Proivides low-level REST classes for direct API access (import `firebase_auth_rest/rest.dart`) 16 | 17 | ## Installation 18 | Simply add `firebase_auth_rest` to your `pubspec.yaml` and run `pub get` (or `flutter pub get`). 19 | 20 | ## Usage 21 | The libary consists of two primary classes - the `FirebaseAuth` and the `FirebaseAccount`. You can use the `FirebaseAuth` class to perform "global" API actions that are not directly tied to a logged in user - This are things like creating accounts and signing in a user, but also functions like resetting a password that a user has forgotten. 22 | 23 | The sign in/up methods of `FirebaseAuth` will provide you with a `FirebaseAccount`. It holds the users authentication data, like an ID-Token, and can be used to perform various account related operations, like changing the users email address or getting the full user profile. It also automatically refreshes the users credentials shortly before timeout - allthough that can be disabled and done manually. 24 | 25 | Thw following code is a simple example, which can be found in full length, including errorhandling, at https://pub.dev/packages/firebase_auth_rest/example. It loggs into firebase as anonymous user, prints credentials and account details and then proceeds to permanently delete the account. 26 | 27 | ```.dart 28 | // Create auth instance and sign up as anonymous user 29 | final fbAuth = FirebaseAuth(Client(), "API-KEY"); 30 | final account = await fbAuth.signUpAnonymous(); 31 | 32 | // print credentials und user details 33 | print("Local-ID: ${account.localId}"); 34 | final userInfo = await account.getDetails(); 35 | print("User-Info: $userInfo"); 36 | 37 | // delete and dispose the account 38 | await account.delete(); 39 | account.dispose(); 40 | ``` 41 | 42 | ## Documentation 43 | The documentation is available at https://pub.dev/documentation/firebase_auth_rest/latest/. A full example can be found at https://pub.dev/packages/firebase_auth_rest/example. 44 | 45 | ## Testing 46 | If you intend to run the integration tests, you will have to create a firebase project with the authentication enabled. You must enable E-Mail and anonymous authentication and disable the E-Mail enumeration protection. **Note:** This is *only* required to run the integration tests. For a normal firebase project you should never disable E-Mail enumeration protection. Finally, you have to create a `.env` file with the `FIREBASE_API_KEY` from the project. 47 | 48 | Alternatively you can use the [firebase emulator](https://firebase.google.com/docs/emulator-suite) for testing. In that case, you will have to set the `FIREBASE_EMULATOR_HOST` and `FIREBASE_EMULATOR_PORT` in the `.env` file as well. 49 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:dart_test_tools/package.yaml 2 | 3 | analyzer: 4 | exclude: 5 | - "**/*.g.dart" 6 | - "**/*.freezed.dart" 7 | - "build/**" 8 | - test/integration/test_config_js.dart 9 | -------------------------------------------------------------------------------- /build.yaml: -------------------------------------------------------------------------------- 1 | targets: 2 | $default: 3 | builders: 4 | freezed: 5 | options: 6 | map: false 7 | when: false 8 | -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | presets: 2 | unit: 3 | paths: 4 | - test/unit 5 | 6 | integration: 7 | paths: 8 | - test/integration 9 | -------------------------------------------------------------------------------- /example/main.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: avoid_print 2 | import 'dart:io'; 3 | 4 | import 'package:firebase_auth_rest/firebase_auth_rest.dart'; 5 | import 'package:http/http.dart'; 6 | 7 | Future main(List arguments) async { 8 | final client = Client(); 9 | 10 | // optional: 11 | // parse the 2nd and 3rd args to get the emulatorHost and emulatorPort 12 | // make sure to run firebase emulators:start before doing this 13 | final emulatorHost = arguments.length >= 2 ? arguments[1] : null; 14 | final emulatorPort = 15 | arguments.length >= 3 ? int.tryParse(arguments[2]) : null; 16 | EmulatorConfig? emulator; 17 | if (emulatorHost != null && emulatorPort != null) { 18 | print( 19 | 'Connecting to firebase auth emulator at http://$emulatorHost:$emulatorPort', 20 | ); 21 | emulator = EmulatorConfig(host: emulatorHost, port: emulatorPort); 22 | } 23 | 24 | try { 25 | // create a firebase auth instance 26 | final fbAuth = 27 | emulator != null 28 | ? FirebaseAuth.emulator( 29 | client, 30 | arguments[0], 31 | emulator, 32 | locale: 'en-US', 33 | ) 34 | : FirebaseAuth(client, arguments[0], 'en-US'); 35 | 36 | // login, set autoRefresh to true to automatically refresh the idToken in 37 | // the background 38 | print('Signing in as anonymous user...'); 39 | final account = await fbAuth.signUpAnonymous(autoRefresh: false); 40 | try { 41 | // print localId and idToken 42 | print('Local-ID: ${account.localId}'); 43 | print('ID-Token: ${account.idToken}'); 44 | 45 | // Do stuff with the account 46 | print('Loading user info...'); 47 | final userInfo = await account.getDetails(); 48 | print('User-Info: $userInfo'); 49 | 50 | // delete the account 51 | print('Deleting account...'); 52 | await account.delete(); 53 | print('Account deleted!'); 54 | } finally { 55 | // dispose of the account instance to clean up resources 56 | await account.dispose(); 57 | } 58 | 59 | // ignore: avoid_catches_without_on_clauses 60 | } catch (e) { 61 | print(e); 62 | print( 63 | 'Pass your API-Key as first parameter and make sure, anonymous ' 64 | 'authentication has been enabled!', 65 | ); 66 | print( 67 | 'Optionally you can also pass in the emulator host and emulator port ' 68 | 'as the second and third parameters', 69 | ); 70 | exitCode = 127; 71 | } finally { 72 | // close the client - fbAuth and all attached accounts will stop working 73 | client.close(); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "emulators": { 3 | "singleProjectMode": true, 4 | "auth": { 5 | "port": 9099 6 | }, 7 | "ui": { 8 | "enabled": true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/firebase_auth_rest.dart: -------------------------------------------------------------------------------- 1 | export 'src/firebase_account.dart'; 2 | export 'src/firebase_auth.dart'; 3 | export 'src/models/auth_exception.dart'; 4 | export 'src/models/emulator_config.dart'; 5 | export 'src/models/idp_provider.dart'; 6 | export 'src/models/provider_user_info.dart'; 7 | export 'src/models/userdata.dart'; 8 | export 'src/profile_update.dart'; 9 | -------------------------------------------------------------------------------- /lib/rest.dart: -------------------------------------------------------------------------------- 1 | export 'src/models/delete_request.dart'; 2 | export 'src/models/fetch_provider_request.dart'; 3 | export 'src/models/fetch_provider_response.dart'; 4 | export 'src/models/oob_code_request.dart'; 5 | export 'src/models/oob_code_response.dart'; 6 | export 'src/models/password_reset_request.dart'; 7 | export 'src/models/password_reset_response.dart'; 8 | export 'src/models/refresh_response.dart'; 9 | export 'src/models/signin_request.dart'; 10 | export 'src/models/signin_response.dart'; 11 | export 'src/models/update_request.dart'; 12 | export 'src/models/update_response.dart'; 13 | export 'src/models/userdata_request.dart'; 14 | export 'src/models/userdata_response.dart'; 15 | export 'src/rest_api.dart'; 16 | -------------------------------------------------------------------------------- /lib/src/firebase_account.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | 3 | import 'package:http/http.dart'; 4 | 5 | import 'firebase_auth.dart'; 6 | import 'models/auth_exception.dart'; 7 | import 'models/delete_request.dart'; 8 | import 'models/emulator_config.dart'; 9 | import 'models/idp_provider.dart'; 10 | import 'models/oob_code_request.dart'; 11 | import 'models/signin_request.dart'; 12 | import 'models/signin_response.dart'; 13 | import 'models/update_request.dart'; 14 | import 'models/userdata.dart'; 15 | import 'models/userdata_request.dart'; 16 | import 'profile_update.dart'; 17 | import 'rest_api.dart'; 18 | 19 | /// A firebase account, representing the identity of a logged in user. 20 | /// 21 | /// Provides account credentials and operations to get more data about the user 22 | /// or to modify the remote account. Also provide automatic refreshing of the 23 | /// idToken. 24 | /// 25 | /// To create an account, either load one from an existing refresh token via 26 | /// [FirebaseAccount.restore()], or use one of the various login methods of the 27 | /// [FirebaseAuth] class. 28 | class FirebaseAccount { 29 | /// The internally used [RestApi] instance. 30 | final RestApi api; 31 | 32 | /// The default locale to be used for E-Mails sent by Firebase. 33 | String? locale; 34 | 35 | String _localId; 36 | String _idToken; 37 | String _refreshToken; 38 | DateTime _expiresAt; 39 | 40 | Timer? _refreshTimer; 41 | final StreamController _refreshController = 42 | StreamController.broadcast(onListen: () {}, onCancel: () {}); 43 | 44 | FirebaseAccount._( 45 | this.api, 46 | this._localId, 47 | this._idToken, 48 | this._refreshToken, 49 | this._expiresAt, 50 | this.locale, 51 | ); 52 | 53 | /// Creates a new account from a successful sign in response. 54 | /// 55 | /// Instead of using this constructor directly, prefer using one of the 56 | /// [FirebaseAuth] classes signIn/signUp methods. 57 | /// 58 | /// The account is created by using the [client] and [apiKey] for accessing 59 | /// the Firebase REST endpoints. The user credentials are extracted from the 60 | /// [signInResponse]. If [autoRefresh] and [locale] are used to initialize 61 | /// these properties. If [emulator] is specified, requests 62 | /// will be made against the Firebase auth emulator instead of the production 63 | /// endpoints using the provided [EmulatorConfig]. 64 | FirebaseAccount.create( 65 | Client client, 66 | String apiKey, 67 | SignInResponse signInResponse, { 68 | String? locale, 69 | bool autoRefresh = true, 70 | EmulatorConfig? emulator, 71 | }) : this.apiCreate( 72 | RestApi(client, apiKey, emulator: emulator), 73 | signInResponse, 74 | autoRefresh: autoRefresh, 75 | locale: locale, 76 | ); 77 | 78 | /// Creates a new account from a successful sign in response and a [RestApi]. 79 | /// 80 | /// Instead of using this constructor directly, prefer using one of the 81 | /// [FirebaseAuth] classes signIn/signUp methods. 82 | /// 83 | /// The account is created by using the [api] for accessing the Firebase REST 84 | /// endpoints. The user credentials are extracted from the [signInResponse]. 85 | /// If [autoRefresh] and [locale] are used to initialize these properties. 86 | FirebaseAccount.apiCreate( 87 | this.api, 88 | SignInResponse signInResponse, { 89 | bool autoRefresh = true, 90 | this.locale, 91 | }) : _localId = signInResponse.localId, 92 | _idToken = signInResponse.idToken, 93 | _refreshToken = signInResponse.refreshToken, 94 | _expiresAt = _expiresInToAt(_durFromString(signInResponse.expiresIn)) { 95 | this.autoRefresh = autoRefresh; 96 | } 97 | 98 | /// @nodoc 99 | @Deprecated('Use FirebaseAuth.restoreAccount instead') 100 | static Future restore( 101 | Client client, 102 | String apiKey, 103 | String refreshToken, { 104 | bool autoRefresh = true, 105 | String? locale, 106 | EmulatorConfig? emulator, 107 | }) => apiRestore( 108 | RestApi(client, apiKey, emulator: emulator), 109 | refreshToken, 110 | autoRefresh: autoRefresh, 111 | locale: locale, 112 | ); 113 | 114 | /// @nodoc 115 | @Deprecated('Use FirebaseAuth.restoreAccount instead') 116 | static Future apiRestore( 117 | RestApi api, 118 | String refreshToken, { 119 | bool autoRefresh = true, 120 | String? locale, 121 | }) async { 122 | final response = await api.token(refresh_token: refreshToken); 123 | return FirebaseAccount._( 124 | api, 125 | response.user_id, 126 | response.id_token, 127 | response.refresh_token, 128 | _expiresInToAt(_durFromString(response.expires_in)), 129 | locale, 130 | )..autoRefresh = autoRefresh; 131 | } 132 | 133 | /// The local id (account-id) of the logged in user. 134 | String get localId => _localId; 135 | 136 | /// The id token of the logged in user. 137 | /// 138 | /// Use [idTokenStream] to get a new token whenever the account credentials 139 | /// have been refreshed via [refresh()] or automatically in the background. 140 | String get idToken => _idToken; 141 | 142 | /// The refresh token token of the logged in user. 143 | /// 144 | /// If you want to log in the user again in the future, even after the app 145 | /// has been restarted, persist this token and use [FirebaseAccount.restore()] 146 | /// to recreate the account without the user having to log in again. 147 | String get refreshToken => _refreshToken; 148 | 149 | /// The point in time when the current [idToken] expires. 150 | /// 151 | /// When [autoRefresh] is enable, the account will automatically request a new 152 | /// idToken via [refresh()] roughly one minute before that timeout. If it is 153 | /// disabled, use this value to do so yourself. 154 | DateTime get expiresAt => _expiresAt; 155 | 156 | /// Specifies if the the account should automatically refresh the [idToken]. 157 | /// 158 | /// When enabled, the account will start an internal timer that will timeout 159 | /// roughly one minute before [expiresAt] and call [refresh()] to renew the 160 | /// [idToken]. This loops indenfinitely, until this property has been set to 161 | /// false or the account was disposed of. 162 | /// 163 | /// **Important:** If you enable auto refreshing, make sure to always call 164 | /// [dispose()] when you don't need the account anymore to stop the automatic 165 | /// refreshing. 166 | bool get autoRefresh => _refreshTimer != null; 167 | set autoRefresh(bool autoRefresh) { 168 | if (autoRefresh != this.autoRefresh) { 169 | if (autoRefresh) { 170 | _scheduleAutoRefresh(_expiresAt.difference(DateTime.now().toUtc())); 171 | } else { 172 | _refreshTimer?.cancel(); 173 | _refreshTimer = null; 174 | } 175 | } 176 | } 177 | 178 | /// A broadcast stream of idTokens. 179 | /// 180 | /// Generates a new token everytime the users credentials are refreshed via 181 | /// [refresh()] or [autoRefresh]. The stream is a broadcast stream, so you can 182 | /// listen to it as you please. 183 | /// 184 | /// Whenever a new token is returned, [idToken] has also been updated. 185 | /// However, no intial event is sent to the stream when you subscribe. If an 186 | /// error happens during a refresh, the [AuthException] is passed as error to 187 | /// the stream, so you can react to these. 188 | /// 189 | /// **Note:** If no stream is connected, refresh errors fail silently. 190 | Stream get idTokenStream => _refreshController.stream; 191 | 192 | /// Refreshes the accounts [idToken] and returns the new token. 193 | /// 194 | /// Sends the [refreshToken] to the firebase server to obtain a new [idToken]. 195 | /// On a success, the [idToken] and [refreshToken] properties are updated and 196 | /// [idTokenStream] provides a new value. If the request fails, an 197 | /// [AuthException] is thrown. 198 | /// 199 | /// **Note:** Instead of manually refreshing whenever [expiresAt] comes close, 200 | /// you can simply set [autoRefresh] to true to enable automatic refreshing of 201 | /// the [idToken] in the background. 202 | Future refresh() async { 203 | await _updateToken(); 204 | return _idToken; 205 | } 206 | 207 | /// Sends a verification email at the users email. 208 | /// 209 | /// You can use this method, if [getDetails()] reveals that the users email 210 | /// has not been verified yet. This method will cause the firebase servers to 211 | /// send a verification email with a verification code. That code must be then 212 | /// sent back to firebase via [confirmEmail()] to complete the process. 213 | /// 214 | /// The language of the email is determined by [locale]. If not specified, the 215 | /// accounts [FirebaseAccount.locale] will be used. 216 | /// 217 | /// If the request fails, an [AuthException] will be thrown. 218 | Future requestEmailConfirmation({String? locale}) async => 219 | await api.sendOobCode( 220 | OobCodeRequest.verifyEmail(idToken: _idToken), 221 | locale ?? this.locale, 222 | ); 223 | 224 | /// Verifies the users email by completing the process. 225 | /// 226 | /// To confirm the users email, you need an [oobCode]. You can obtain that 227 | /// code by using [requestEmailConfirmation()]. That method will send the user 228 | /// an email that contains said [oobCode]. In an application you can extract 229 | /// that code from the email to complete the process with this method. 230 | /// 231 | /// If the request fails, including because an invalid code was passed to the 232 | /// method, an [AuthException] will be thrown. 233 | Future confirmEmail(String oobCode) async => 234 | await api.confirmEmail(ConfirmEmailRequest(oobCode: oobCode)); 235 | 236 | /// Fetches the user profile details of the account. 237 | /// 238 | /// Requests the account details that firebase itself has about the current 239 | /// account and returns them as [UserData]. If the request fails, an 240 | /// [AuthException] is thrown instead. 241 | /// 242 | /// **Note:** If the request succeeds, but there is not user-data associated 243 | /// with the user, null is returned. In theory, this should never happen, but 244 | /// it is not guaranteed to never happen. 245 | Future getDetails() async { 246 | final response = await api.getUserData(UserDataRequest(idToken: _idToken)); 247 | return response.users.isNotEmpty ? response.users.first : null; 248 | } 249 | 250 | /// Updates the users email address. 251 | /// 252 | /// This is the email that is used by the user to login with a password. The 253 | /// current email is replaced by [newEmail]. If the request fails, an 254 | /// [AuthException] will be thrown. 255 | /// 256 | /// Firebase sents a notification email to the old email address to notify the 257 | /// user that his email has changed. The user may revoke the change via that 258 | /// email. The language of the email is determined by [locale]. If not 259 | /// specified, the accounts [FirebaseAccount.locale] will be used. 260 | /// 261 | /// **Note:** If the user has logged in anonymously or via an IDP-Provider, 262 | /// the mail may not be changeable, leading the a failure of this request. You 263 | /// can use [getDetails()] or [FirebaseAuth.fetchProviders] to find out which 264 | /// providers a user has activated for this account. You can instead link the 265 | /// account with an email address and a new password via [linkEmail()]. 266 | Future updateEmail(String newEmail, {String? locale}) => 267 | api.updateEmail( 268 | EmailUpdateRequest(idToken: _idToken, email: newEmail), 269 | locale ?? this.locale, 270 | ); 271 | 272 | /// Updates the users login password. 273 | /// 274 | /// Replaces the users current password with [newPassword]. If the request 275 | /// fails, an [AuthException] will be thrown. 276 | /// 277 | /// This request can only be used, if the user has logged in via 278 | /// email/password. When logged in anonymously or via an IDP-Provider, this 279 | /// request will always fail. You can instead link the account with an email 280 | /// address and a new password via [linkEmail()]. 281 | Future updatePassword(String newPassword) async { 282 | final response = await api.updatePassword( 283 | PasswordUpdateRequest(idToken: _idToken, password: newPassword), 284 | ); 285 | _applyToken( 286 | idToken: response.idToken, 287 | refreshToken: response.refreshToken, 288 | expiresIn: response.expiresIn, 289 | ); 290 | } 291 | 292 | /// Updates certain aspects of the users profile. 293 | /// 294 | /// Only the [displayName] and the [photoUrl] can be updated. For each of 295 | /// these parameters, you have one of three options: 296 | /// - Pass null to (or leave out the) parameter to keep it as it is 297 | /// - Pass [ProfileUpdate.update()] to change the property to a new value 298 | /// - Pass [ProfileUpdate.delete()] to remove the property. This will erase 299 | /// the data on firebase servers and set them to null. 300 | /// 301 | /// If the request fails, an [AuthException] will be thrown. The updated 302 | /// profile can be fetched via [getDetails()]. 303 | Future updateProfile({ 304 | ProfileUpdate? displayName, 305 | ProfileUpdate? photoUrl, 306 | }) => api.updateProfile( 307 | ProfileUpdateRequest( 308 | idToken: _idToken, 309 | displayName: displayName?.updateOr(), 310 | photoUrl: photoUrl?.updateOr(), 311 | deleteAttribute: [ 312 | if (displayName?.isDelete ?? false) DeleteAttribute.DISPLAY_NAME, 313 | if (photoUrl?.isDelete ?? false) DeleteAttribute.PHOTO_URL, 314 | ], 315 | ), 316 | ); 317 | 318 | /// Links a new email address to this account. 319 | /// 320 | /// Linking allows a user to add a new login method to an existing account. 321 | /// After doing so, he can choose any of those methods to perform the login. 322 | /// 323 | /// With this method, an email address can be added to allow login with the 324 | /// given [email] and [password] via [FirebaseAuth.signInWithPassword()]. The 325 | /// method returns, whether the given [email] has already been verified. If 326 | /// the linking fails, an [AuthException] is thrown instead. 327 | /// 328 | /// By default, a verification email is sent automatically to the user, the 329 | /// language of the email is determined by [locale]. If not specified, the 330 | /// accounts [FirebaseAccount.locale] will be used. 331 | /// 332 | /// If you do not want the verification email to be sent immediatly, you can 333 | /// simply set [autoVerify] to false and send the email manually by calling 334 | /// [requestEmailConfirmation()]. 335 | Future linkEmail( 336 | String email, 337 | String password, { 338 | bool autoVerify = true, 339 | String? locale, 340 | }) async { 341 | final response = await api.linkEmail( 342 | LinkEmailRequest(idToken: _idToken, email: email, password: password), 343 | ); 344 | _applyToken( 345 | idToken: response.idToken, 346 | refreshToken: response.refreshToken, 347 | expiresIn: response.expiresIn, 348 | ); 349 | if (!response.emailVerified && autoVerify) { 350 | await requestEmailConfirmation(locale: locale); 351 | } 352 | return response.emailVerified; 353 | } 354 | 355 | /// Links a new IDP-Account to this account. 356 | /// 357 | /// Linking allows a user to add a new login method to an existing account. 358 | /// After doing so, he can choose any of those methods to perform the login. 359 | /// 360 | /// With this method, an IDP-Provider based account (like google, facebook, 361 | /// twitter, etc.) can be added to allow login with the given [provider] and 362 | /// [requestUri] via [FirebaseAuth.signInWithIdp()]. If the linking fails, an 363 | /// [AuthException] is thrown. 364 | Future linkIdp(IdpProvider provider, Uri requestUri) async { 365 | final response = await api.linkIdp( 366 | LinkIdpRequest( 367 | idToken: _idToken, 368 | postBody: provider.postBody, 369 | requestUri: requestUri, 370 | ), 371 | ); 372 | _applyToken( 373 | idToken: response.idToken, 374 | refreshToken: response.refreshToken, 375 | expiresIn: response.expiresIn, 376 | ); 377 | } 378 | 379 | /// Unlinks all specified providers from the account. 380 | /// 381 | /// This removes all login providers from the account that are specified via 382 | /// [providers]. The expected IDs are the same as returned by 383 | /// [IdpProvider.id]. If the unlinking fails, an [AuthException] will be 384 | /// thrown. 385 | /// 386 | /// After a provider has been removed, the user cannot login anymore with that 387 | /// provider. However, you can always re-add providers via [linkEmail()] or 388 | /// [linkIdp()]. 389 | Future unlinkProviders(List providers) => api.unlinkProvider( 390 | UnlinkRequest(idToken: _idToken, deleteProvider: providers), 391 | ); 392 | 393 | /// Delete the account 394 | /// 395 | /// Deletes this firebase account. This is a permanent action and cannot be 396 | /// undone. After deleting, the account cannot be used anymore. Internally 397 | /// [dispose]s the account as well. 398 | /// 399 | /// If you were listening to [idTokenStream], it will be closed. In addition 400 | /// [autoRefresh] will be set to false. This method automatically calls 401 | /// [dispose()], so you don't have to call it again, but it is ok to do so. 402 | /// 403 | /// **Note:** While this operation deletes the firebase account, it does 404 | /// *not* delete the original account, if an IDP-Provider like google was 405 | /// used. The user can always recreate the account by signing in/up again, 406 | /// but he will receive a new [localId] and will be treated as completely 407 | /// different user by firebase. 408 | Future delete() async { 409 | await api.delete(DeleteRequest(idToken: _idToken)); 410 | await dispose(); 411 | } 412 | 413 | /// Disposes the account 414 | /// 415 | /// Disables any ongoing [autoRefresh] and sets it to false. Also disposes of 416 | /// the internally used stream controller for [idTokenStream]. 417 | /// 418 | /// **Important:** Even if you do not use any of the two properties mentioned 419 | /// above, you still have to always dispose of an account. 420 | Future dispose() async { 421 | autoRefresh = false; 422 | _refreshTimer?.cancel(); 423 | if (!_refreshController.isClosed) { 424 | await _refreshController.close(); 425 | } 426 | } 427 | 428 | static Duration _durFromString(String expiresIn) => 429 | Duration(seconds: int.parse(expiresIn)); 430 | 431 | static DateTime _expiresInToAt(Duration expiresIn) => 432 | DateTime.now().toUtc().add(expiresIn); 433 | 434 | void _scheduleAutoRefresh(Duration expiresIn) { 435 | var triggerTimer = expiresIn - const Duration(minutes: 1); 436 | if (triggerTimer < const Duration(seconds: 1)) { 437 | triggerTimer = Duration.zero; 438 | } 439 | _refreshTimer = Timer(triggerTimer, _updateTokenTimeout); 440 | } 441 | 442 | Future _updateToken() async { 443 | try { 444 | final response = await api.token(refresh_token: _refreshToken); 445 | _applyToken( 446 | idToken: response.id_token, 447 | refreshToken: response.refresh_token, 448 | expiresIn: response.expires_in, 449 | ); 450 | // ignore: avoid_catches_without_on_clauses 451 | } catch (_) { 452 | autoRefresh = false; 453 | rethrow; 454 | } 455 | } 456 | 457 | void _applyToken({ 458 | required String? idToken, 459 | required String? refreshToken, 460 | required String? expiresIn, 461 | }) { 462 | try { 463 | _idToken = idToken ?? _idToken; 464 | _refreshToken = refreshToken ?? _refreshToken; 465 | final expiresInDur = expiresIn != null ? _durFromString(expiresIn) : null; 466 | _expiresAt = 467 | expiresInDur != null ? _expiresInToAt(expiresInDur) : _expiresAt; 468 | if (autoRefresh && expiresInDur != null) { 469 | _scheduleAutoRefresh(expiresInDur); 470 | } 471 | if (_refreshController.hasListener) { 472 | _refreshController.add(_idToken); 473 | } 474 | // ignore: avoid_catches_without_on_clauses 475 | } catch (_) { 476 | autoRefresh = false; 477 | rethrow; 478 | } 479 | } 480 | 481 | Future _updateTokenTimeout() async { 482 | try { 483 | await _updateToken(); 484 | // ignore: avoid_catches_without_on_clauses 485 | } catch (e, s) { 486 | // redirect exceptions to listeners, if any 487 | if (_refreshController.hasListener) { 488 | _refreshController.addError(e, s); 489 | } else { 490 | rethrow; 491 | } 492 | } 493 | } 494 | } 495 | -------------------------------------------------------------------------------- /lib/src/firebase_auth.dart: -------------------------------------------------------------------------------- 1 | import 'package:http/http.dart'; 2 | 3 | import 'firebase_account.dart'; 4 | import 'models/auth_exception.dart'; 5 | import 'models/emulator_config.dart'; 6 | import 'models/fetch_provider_request.dart'; 7 | import 'models/idp_provider.dart'; 8 | import 'models/oob_code_request.dart'; 9 | import 'models/password_reset_request.dart'; 10 | import 'models/signin_request.dart'; 11 | import 'rest_api.dart'; 12 | 13 | /// A Firebase Authentication class, that allows you to log into firebase. 14 | /// 15 | /// Provides methods to create new firebase accounts, log a user into 16 | /// firebase and more. Most methods here create an instance of a 17 | /// [FirebaseAccount], which can be used to manage an individual account. All 18 | /// methods provided here are global methods for firebase auth. 19 | class FirebaseAuth { 20 | /// The internally used [RestApi] instance. 21 | final RestApi api; 22 | 23 | /// The default locale to be used for E-Mails sent by Firebase. 24 | String? locale; 25 | 26 | /// Creates a new firebase auth instance. 27 | /// 28 | /// The instance uses [client] and [apiKey] for accessing the Firebase REST 29 | /// endpoints. If [locale] is specified, it is used to initialize 30 | /// the [FirebaseAuth.locale] property. 31 | FirebaseAuth(Client client, String apiKey, [this.locale]) 32 | : api = RestApi(client, apiKey); 33 | 34 | /// Creates a new firebase auth instance. 35 | /// 36 | /// The instance uses the [api] for accessing the Firebase REST endpoints. If 37 | /// [locale] is specified, it is used to initialize the [FirebaseAuth.locale] 38 | /// property. 39 | FirebaseAuth.api(this.api, [this.locale]); 40 | 41 | /// Create a new firebase auth instance that connects to the firebase emulator 42 | /// 43 | /// The instance uses [client], [apiKey], and [emulator] for accessing the 44 | /// Firebase auth emulator REST endpoints. If [locale] is specified, it is 45 | /// used to initialize the [FirebaseAuth.locale] property. 46 | FirebaseAuth.emulator( 47 | Client client, 48 | String apiKey, 49 | EmulatorConfig emulator, { 50 | this.locale, 51 | }) : api = RestApi(client, apiKey, emulator: emulator); 52 | 53 | /// Returns a list of all providers that can be used to login. 54 | /// 55 | /// The given [email] and [continueUri] are sent to firebase to figure out 56 | /// which providers can be used. Returns the provider names as in 57 | /// [IdpProvider.id] or the string `"email"`, if the user can login with the 58 | /// email and a password. 59 | /// 60 | /// If the request fails, an [AuthException] will be thrown. 61 | Future> fetchProviders(String email, [Uri? continueUri]) async { 62 | final response = await api.fetchProviders( 63 | FetchProviderRequest( 64 | identifier: email, 65 | continueUri: continueUri ?? Uri.http('localhost'), 66 | ), 67 | ); 68 | return [if (response.registered) 'email', ...response.allProviders]; 69 | } 70 | 71 | /// Signs up to firebase as an anonymous user. 72 | /// 73 | /// This will return a newly created [FirebaseAccount] with no login method 74 | /// attached. This means, you can only keep using this account by regularly 75 | /// refreshing the idToken. This happens automatically if [autoRefresh] is 76 | /// true or via [FirebaseAccount.refresh()]. 77 | /// 78 | /// If the request fails, an [AuthException] will be thrown. This also happens 79 | /// if anonymous logins have not been enabled in the firebase console. 80 | /// 81 | /// If you ever want to "promote" an anonymous account to a normal account, 82 | /// you can do so by using [FirebaseAccount.linkEmail()] or 83 | /// [FirebaseAccount.linkIdp()] to add credentials to the account. This will 84 | /// preserve any data associated with this account. 85 | Future signUpAnonymous({bool autoRefresh = true}) async => 86 | FirebaseAccount.apiCreate( 87 | api, 88 | await api.signUpAnonymous(const AnonymousSignInRequest()), 89 | autoRefresh: autoRefresh, 90 | locale: locale, 91 | ); 92 | 93 | /// Signs up to firebase with an email and a password. 94 | /// 95 | /// This creates a new firebase account and returns it's credentials as 96 | /// [FirebaseAccount] if the request succeeds, or throws an [AuthException] if 97 | /// it fails. From now on, the user can log into this account by using the 98 | /// same [email] and [password] used for this request via 99 | /// [signInWithPassword()]. 100 | /// 101 | /// If [autoVerify] is true (the default), this method will also send an email 102 | /// confirmation request for that email so the users mail can be verified. See 103 | /// [FirebaseAccount.requestEmailConfirmation()] for more details. The 104 | /// language of that mail is determined by [locale], if specified, 105 | /// [FirebaseAuth.locale] otherwise. 106 | /// 107 | /// If [autoRefresh] is enabled (the default), the created accounts 108 | /// [FirebaseAccount.autoRefresh] is set to true as well, wich will start an 109 | /// automatic token refresh in the background, as soon as the current token 110 | /// comes close to expiring. See [FirebaseAccount.autoRefresh] for more 111 | /// details. 112 | Future signUpWithPassword( 113 | String email, 114 | String password, { 115 | bool autoVerify = true, 116 | bool autoRefresh = true, 117 | String? locale, 118 | }) async { 119 | final response = await api.signUpWithPassword( 120 | PasswordSignInRequest(email: email, password: password), 121 | ); 122 | if (autoVerify) { 123 | await api.sendOobCode( 124 | OobCodeRequest.verifyEmail(idToken: response.idToken), 125 | locale ?? this.locale, 126 | ); 127 | } 128 | return FirebaseAccount.apiCreate( 129 | api, 130 | response, 131 | locale: this.locale, 132 | autoRefresh: autoRefresh, 133 | ); 134 | } 135 | 136 | /// Signs into firebase with an IDP-Provider. 137 | /// 138 | /// This logs the user into firebase by using an [IdpProvider] - aka google, 139 | /// facebook, twitter, etc. As long as the provider has been enabled in the 140 | /// firebase console, it can be used. If the passed [provider] and 141 | /// [requestUri] are valid, the associated firebase account is returned or a 142 | /// new one gets created. On a failure, an [AuthException] is thrown instead. 143 | /// 144 | /// If [autoRefresh] is enabled (the default), the created accounts 145 | /// [FirebaseAccount.autoRefresh] is set to true as well, wich will start an 146 | /// automatic token refresh in the background, as soon as the current token 147 | /// comes close to expiring. See [FirebaseAccount.autoRefresh] for more 148 | /// details. 149 | Future signInWithIdp( 150 | IdpProvider provider, 151 | Uri requestUri, { 152 | bool autoRefresh = true, 153 | }) async => FirebaseAccount.apiCreate( 154 | api, 155 | await api.signInWithIdp( 156 | IdpSignInRequest(postBody: provider.postBody, requestUri: requestUri), 157 | ), 158 | autoRefresh: autoRefresh, 159 | locale: locale, 160 | ); 161 | 162 | /// Signs into firebase with an email and a password. 163 | /// 164 | /// This logs into an exsiting account and returns it's credentials as 165 | /// [FirebaseAccount] if the request succeeds, or throws an [AuthException] if 166 | /// it fails. 167 | /// 168 | /// If [autoRefresh] is enabled (the default), the created accounts 169 | /// [FirebaseAccount.autoRefresh] is set to true as well, wich will start an 170 | /// automatic token refresh in the background, as soon as the current token 171 | /// comes close to expiring. See [FirebaseAccount.autoRefresh] for more 172 | /// details. 173 | /// 174 | /// **Note:** To create a new account, use [signUpWithPassword()]. 175 | Future signInWithPassword( 176 | String email, 177 | String password, { 178 | bool autoRefresh = true, 179 | }) async => FirebaseAccount.apiCreate( 180 | api, 181 | await api.signInWithPassword( 182 | PasswordSignInRequest(email: email, password: password), 183 | ), 184 | autoRefresh: autoRefresh, 185 | locale: locale, 186 | ); 187 | 188 | /// Signs into firebase with a custom token. 189 | /// 190 | /// This logs into an exsiting account and returns it's credentials as 191 | /// [FirebaseAccount] if the request succeeds, or throws an [AuthException] if 192 | /// it fails. 193 | /// 194 | /// If [autoRefresh] is enabled (the default), the created accounts 195 | /// [FirebaseAccount.autoRefresh] is set to true as well, wich will start an 196 | /// automatic token refresh in the background, as soon as the current token 197 | /// comes close to expiring. See [FirebaseAccount.autoRefresh] for more 198 | /// details. 199 | Future signInWithCustomToken( 200 | String token, { 201 | bool autoRefresh = true, 202 | }) async => FirebaseAccount.apiCreate( 203 | api, 204 | await api.signInWithCustomToken(CustomTokenSignInRequest(token: token)), 205 | autoRefresh: autoRefresh, 206 | locale: locale, 207 | ); 208 | 209 | /// Sends a password reset email to a user. 210 | /// 211 | /// This tells firebase to generate a password reset mail and send it to 212 | /// [email]. The language of that mail is determined by [locale], if 213 | /// specified, [FirebaseAuth.locale] otherwise. If the request fails, an 214 | /// [AuthException] is thrown. 215 | Future requestPasswordReset(String email, {String? locale}) async => 216 | await api.sendOobCode( 217 | OobCodeRequest.passwordReset(email: email), 218 | locale ?? this.locale, 219 | ); 220 | 221 | /// Checks, if a password reset code is valid. 222 | /// 223 | /// When using [requestPasswordReset()] to send a mail to the user, that mail 224 | /// contains an [oobCode]. You can use this method to verify if the code is a 225 | /// valid code, before allowing the user to enter a new password. 226 | /// 227 | /// If the check succeeds, the future simply resolves without a value. If it 228 | /// fails instead, an [AuthException] is thrown. 229 | Future validatePasswordReset(String oobCode) async => 230 | await api.resetPassword(PasswordResetRequest.verify(oobCode: oobCode)); 231 | 232 | /// Completes a password reset by setting a new password. 233 | /// 234 | /// When using [requestPasswordReset()] to send a mail to the user, that mail 235 | /// contains an [oobCode]. You can use this method to complete the process and 236 | /// reset the users password to [newPassword]. 237 | /// 238 | /// If this method succeeds, the user must from now on use [newPassword] when 239 | /// signing in via [signInWithPassword()]. If it fails, an [AuthException] is 240 | /// thrown. 241 | Future resetPassword(String oobCode, String newPassword) async => 242 | await api.resetPassword( 243 | PasswordResetRequest.confirm( 244 | oobCode: oobCode, 245 | newPassword: newPassword, 246 | ), 247 | ); 248 | 249 | /// Restores an account by using a refresh token to log the user in again. 250 | /// 251 | /// If a user has logged in once normally, you can store the [refreshToken] 252 | /// and the later use this method to recreate the account instance without the 253 | /// user logging in again. Internally, this method calls 254 | /// [FirebaseAccount.refresh] to obtain user credentials and the returns a 255 | /// newly created account from the result. 256 | /// 257 | /// The account is created by using the [api] for accessing the Firebase REST 258 | /// endpoints. [autoRefresh] and [locale] will be used to initialize the 259 | /// [FirebaseAccount]. If [locale] is not set, the [FirebaseAuth.locale] will 260 | /// be used, or none if that one is `null` as well. 261 | /// 262 | /// If the refreshing fails, an [AuthException] will be thrown. 263 | Future restoreAccount( 264 | String refreshToken, { 265 | bool autoRefresh = true, 266 | String? locale, 267 | }) async => 268 | // ignore: deprecated_member_use_from_same_package 269 | await FirebaseAccount.apiRestore( 270 | api, 271 | refreshToken, 272 | autoRefresh: autoRefresh, 273 | locale: locale ?? this.locale, 274 | ); 275 | } 276 | -------------------------------------------------------------------------------- /lib/src/models/auth_exception.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'auth_exception.freezed.dart'; 6 | part 'auth_exception.g.dart'; 7 | 8 | /// https://firebase.google.com/docs/reference/rest/auth#section-error-format 9 | @freezed 10 | sealed class ErrorDetails with _$ErrorDetails { 11 | /// Default constructor 12 | const factory ErrorDetails({ 13 | /// The domain in which the error occured 14 | String? domain, 15 | 16 | /// The reason for the error 17 | String? reason, 18 | 19 | /// The error message / code 20 | String? message, 21 | }) = _ErrorDetails; 22 | 23 | /// JSON constructor 24 | factory ErrorDetails.fromJson(Map json) => 25 | _$ErrorDetailsFromJson(json); 26 | } 27 | 28 | /// https://firebase.google.com/docs/reference/rest/auth#section-error-format 29 | @freezed 30 | sealed class ErrorData with _$ErrorData { 31 | /// Default constructor 32 | const factory ErrorData({ 33 | /// The error code 34 | @Default(-1) int code, 35 | 36 | /// The error message 37 | String? message, 38 | 39 | /// A list of details about this error 40 | @Default([]) List errors, 41 | }) = _ErrorData; 42 | 43 | /// JSON Constructor 44 | factory ErrorData.fromJson(Map json) => 45 | _$ErrorDataFromJson(json); 46 | } 47 | 48 | /// https://firebase.google.com/docs/reference/rest/auth#section-error-format 49 | @freezed 50 | sealed class AuthException with _$AuthException implements Exception { 51 | /// Default constructor 52 | const factory AuthException( 53 | /// The actual error data 54 | ErrorData error, 55 | ) = _AuthException; 56 | 57 | /// JSON Constructor 58 | factory AuthException.fromJson(Map json) => 59 | _$AuthExceptionFromJson(json); 60 | } 61 | -------------------------------------------------------------------------------- /lib/src/models/delete_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'delete_request.freezed.dart'; 6 | part 'delete_request.g.dart'; 7 | 8 | /// https://firebase.google.com/docs/reference/rest/auth#section-delete-account 9 | @freezed 10 | sealed class DeleteRequest with _$DeleteRequest { 11 | /// Default constructor 12 | const factory DeleteRequest({ 13 | /// The Firebase ID token of the user to delete. 14 | required String idToken, 15 | }) = _DeleteRequest; 16 | 17 | /// JSON constructor 18 | factory DeleteRequest.fromJson(Map json) => 19 | _$DeleteRequestFromJson(json); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/models/emulator_config.dart: -------------------------------------------------------------------------------- 1 | import 'package:freezed_annotation/freezed_annotation.dart'; 2 | 3 | part 'emulator_config.g.dart'; 4 | part 'emulator_config.freezed.dart'; 5 | 6 | /// Config with options for connecting the the Firebase auth emulator 7 | @freezed 8 | sealed class EmulatorConfig with _$EmulatorConfig { 9 | const factory EmulatorConfig({ 10 | /// The URI host. Example: "127.0.0.1" or "localhost" 11 | required String host, 12 | 13 | /// The URI port. Example 4050 14 | required int port, 15 | 16 | /// The URI protocol. Default is "https". 17 | @Default('http') String protocol, 18 | }) = _EmulatorConfig; 19 | 20 | factory EmulatorConfig.fromJson(Map json) => 21 | _$EmulatorConfigFromJson(json); 22 | } 23 | -------------------------------------------------------------------------------- /lib/src/models/fetch_provider_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'fetch_provider_request.freezed.dart'; 6 | part 'fetch_provider_request.g.dart'; 7 | 8 | /// https://firebase.google.com/docs/reference/rest/auth#section-fetch-providers-for-email 9 | @freezed 10 | sealed class FetchProviderRequest with _$FetchProviderRequest { 11 | /// Default constructor 12 | const factory FetchProviderRequest({ 13 | /// User's email address 14 | required String identifier, 15 | 16 | /// The URI to which the IDP redirects the user back. For this use case, 17 | /// this is just the current URL. 18 | required Uri continueUri, 19 | }) = _FetchProviderRequest; 20 | 21 | /// JSON constructor 22 | factory FetchProviderRequest.fromJson(Map json) => 23 | _$FetchProviderRequestFromJson(json); 24 | } 25 | -------------------------------------------------------------------------------- /lib/src/models/fetch_provider_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'fetch_provider_response.freezed.dart'; 6 | part 'fetch_provider_response.g.dart'; 7 | 8 | /// https://firebase.google.com/docs/reference/rest/auth#section-fetch-providers-for-email 9 | @freezed 10 | sealed class FetchProviderResponse with _$FetchProviderResponse { 11 | /// Default constructors 12 | const factory FetchProviderResponse({ 13 | /// The list of providers that the user has previously signed in with. 14 | @Default([]) List allProviders, 15 | 16 | /// Whether the email is for an existing account 17 | @Default(false) bool registered, 18 | }) = _FetchProviderResponse; 19 | 20 | /// JSON constructor 21 | factory FetchProviderResponse.fromJson(Map json) => 22 | _$FetchProviderResponseFromJson(json); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/models/idp_provider.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'idp_provider.freezed.dart'; 6 | 7 | /// Encapsulates IDP-Providers to log into firebase with. 8 | /// 9 | /// The [IdpProvider] class provides various factory constructors for the 10 | /// supported login providers used by [FirebaseAuth.signInWithIdp()]. The 11 | /// currently supported providers are: 12 | /// - google.com: [IdpProvider.google()] 13 | /// - facebook.com: [IdpProvider.facebook()] 14 | /// - twitter.com: [IdpProvider.twitter()] 15 | /// 16 | /// If you want to use a provider other then the ones specified above, you can 17 | /// use [IdpProvider.custom()] to create a custom provider instance. 18 | @freezed 19 | sealed class IdpProvider with _$IdpProvider { 20 | const IdpProvider._(); 21 | 22 | /// Create an IDP-Instance for google.com. 23 | /// 24 | /// Requires you to perform a Google-OAuth flow to obtain an [idToken]. You 25 | /// can then create a google provider with that data. 26 | const factory IdpProvider.google(String idToken) = _GoogleIdpProvider; 27 | 28 | /// Create an IDP-Instance for facebook.com. 29 | /// 30 | /// Requires you to perform a Facebook-OAuth flow to obtain an [accessToken]. 31 | /// You can then create a facebook provider with that data. 32 | const factory IdpProvider.facebook(String accessToken) = _FacebookIdpProvider; 33 | 34 | /// Create an IDP-Instance for twitter.com. 35 | /// 36 | /// Requires you to perform a Twitter-OAuth flow to obtain an [accessToken]. 37 | /// Together with an [oauthTokenSecret] you can then create a facebook 38 | /// provider with that data. 39 | const factory IdpProvider.twitter({ 40 | required String accessToken, 41 | required String oauthTokenSecret, 42 | }) = _TwitterIdpProvider; 43 | 44 | /// Create an IDP-Instance for any provider not explicitly supported. 45 | /// 46 | /// After you have obtained the required credentials for your provider of 47 | /// choice, you can then create a provider by using the [providerId] ( 48 | /// typically the domain of the provider) and additional [parameters], that 49 | /// contain the auth credentials required by firebase to log in the user. 50 | const factory IdpProvider.custom({ 51 | required String providerId, 52 | @Default({}) Map parameters, 53 | }) = _CustomIdpProvider; 54 | 55 | /// Returns the identifier of this provider. 56 | /// 57 | /// The provider id is typically the domain of the provider. 58 | String get id => switch (this) { 59 | _GoogleIdpProvider() => 'google.com', 60 | _FacebookIdpProvider() => 'facebook.com', 61 | _TwitterIdpProvider() => 'twitter.com', 62 | _CustomIdpProvider(providerId: final providerId) => providerId, 63 | }; 64 | 65 | /// Generates a HTTP-POST body to be used by the REST-API. 66 | /// 67 | /// The [postBody] is used by [FirebaseAuth.signInWithIdp()] to convert the 68 | /// provider to what is needed by the REST-API. This typically contains the 69 | /// provider [id] as well as provider-specific parameters as specified in the 70 | /// factory constructors. 71 | String get postBody { 72 | final params = switch (this) { 73 | _GoogleIdpProvider(idToken: final idToken) => {'id_token': idToken}, 74 | _FacebookIdpProvider(accessToken: final accessToken) => { 75 | 'access_token': accessToken, 76 | }, 77 | _TwitterIdpProvider( 78 | accessToken: final accessToken, 79 | oauthTokenSecret: final oauthTokenSecret, 80 | ) => 81 | {'access_token': accessToken, 'oauth_token_secret': oauthTokenSecret}, 82 | _CustomIdpProvider(parameters: final parameters) => parameters, 83 | }; 84 | return Uri( 85 | queryParameters: {...params, 'providerId': id}, 86 | ).query; 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /lib/src/models/oob_code_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // ignore_for_file: constant_identifier_names 3 | 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | 6 | part 'oob_code_request.freezed.dart'; 7 | part 'oob_code_request.g.dart'; 8 | 9 | /// Possible values for [OobCodeRequest.requestType] 10 | enum OobCodeRequestType { 11 | /// VERIFY_EMAIL 12 | VERIFY_EMAIL, 13 | 14 | /// PASSWORD_RESET 15 | PASSWORD_RESET, 16 | } 17 | 18 | /// Meta-Class for multiple API-Endpoints 19 | @freezed 20 | sealed class OobCodeRequest with _$OobCodeRequest { 21 | /// https://firebase.google.com/docs/reference/rest/auth#section-send-email-verification 22 | const factory OobCodeRequest.verifyEmail({ 23 | /// The Firebase ID token of the user to verify. 24 | required String idToken, 25 | 26 | /// The type of confirmation code to send. Should always be 27 | /// [OobCodeRequestType.VERIFY_EMAIL]. 28 | @Default(OobCodeRequestType.VERIFY_EMAIL) OobCodeRequestType requestType, 29 | }) = VerifyEmailRequest; 30 | 31 | /// https://firebase.google.com/docs/reference/rest/auth#section-send-password-reset-email 32 | const factory OobCodeRequest.passwordReset({ 33 | /// User's email address. 34 | required String email, 35 | 36 | /// The kind of OOB code to return. Should be 37 | /// [OobCodeRequestType.PASSWORD_RESET] for password reset. 38 | @Default(OobCodeRequestType.PASSWORD_RESET) OobCodeRequestType requestType, 39 | }) = PasswordRestRequest; 40 | 41 | /// JSON constructor 42 | factory OobCodeRequest.fromJson(Map json) => 43 | _$OobCodeRequestFromJson(json); 44 | } 45 | -------------------------------------------------------------------------------- /lib/src/models/oob_code_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'oob_code_response.freezed.dart'; 6 | part 'oob_code_response.g.dart'; 7 | 8 | /// Meta-Class for multiple API-Endpoints 9 | /// 10 | /// - https://firebase.google.com/docs/reference/rest/auth#section-send-email-verification 11 | /// - https://firebase.google.com/docs/reference/rest/auth#section-send-password-reset-email 12 | @freezed 13 | sealed class OobCodeResponse with _$OobCodeResponse { 14 | /// Default constructor 15 | const factory OobCodeResponse({ 16 | /// User's email address. 17 | String? email, 18 | }) = _OobCodeResponse; 19 | 20 | /// JSON constructor 21 | factory OobCodeResponse.fromJson(Map json) => 22 | _$OobCodeResponseFromJson(json); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/models/password_reset_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'password_reset_request.freezed.dart'; 6 | part 'password_reset_request.g.dart'; 7 | 8 | /// Meta-Class for multiple API-Endpoints 9 | @freezed 10 | sealed class PasswordResetRequest with _$PasswordResetRequest { 11 | /// https://firebase.google.com/docs/reference/rest/auth#section-verify-password-reset-code 12 | const factory PasswordResetRequest.verify({ 13 | /// The email action code sent to the user's email for resetting the 14 | /// password. 15 | required String oobCode, 16 | }) = VerifyPasswordResetRequest; 17 | 18 | /// https://firebase.google.com/docs/reference/rest/auth#section-confirm-reset-password 19 | const factory PasswordResetRequest.confirm({ 20 | /// The email action code sent to the user's email for resetting the 21 | /// password. 22 | required String oobCode, 23 | 24 | /// The user's new password. 25 | required String newPassword, 26 | }) = ConfirmPasswordResetRequest; 27 | 28 | /// JSON constructor 29 | factory PasswordResetRequest.fromJson(Map json) => 30 | _$PasswordResetRequestFromJson(json); 31 | } 32 | -------------------------------------------------------------------------------- /lib/src/models/password_reset_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | import 'oob_code_request.dart'; 6 | 7 | part 'password_reset_response.freezed.dart'; 8 | part 'password_reset_response.g.dart'; 9 | 10 | /// Meta-Class for multiple API-Endpoints 11 | /// 12 | /// - https://firebase.google.com/docs/reference/rest/auth#section-verify-password-reset-code 13 | /// - https://firebase.google.com/docs/reference/rest/auth#section-confirm-reset-password 14 | @freezed 15 | sealed class PasswordResetResponse with _$PasswordResetResponse { 16 | /// Default constructor 17 | const factory PasswordResetResponse({ 18 | /// User's email address. 19 | String? email, 20 | 21 | /// Type of the email action code. Should be "PASSWORD_RESET". 22 | @Default(OobCodeRequestType.PASSWORD_RESET) OobCodeRequestType requestType, 23 | }) = _PasswordResetResponse; 24 | 25 | /// JSON constructor 26 | factory PasswordResetResponse.fromJson(Map json) => 27 | _$PasswordResetResponseFromJson(json); 28 | } 29 | -------------------------------------------------------------------------------- /lib/src/models/provider_user_info.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'provider_user_info.freezed.dart'; 6 | part 'provider_user_info.g.dart'; 7 | 8 | /// Used by multiple responses to list providers of an account. 9 | @freezed 10 | sealed class ProviderUserInfo with _$ProviderUserInfo { 11 | /// Default constructor 12 | const factory ProviderUserInfo({ 13 | /// The linked provider ID (e.g. "google.com" for the Google provider). 14 | required String providerId, 15 | 16 | /// The unique ID identifies the IdP account. 17 | required String federatedId, 18 | }) = _ProviderUserInfo; 19 | 20 | /// JSON constructor 21 | factory ProviderUserInfo.fromJson(Map json) => 22 | _$ProviderUserInfoFromJson(json); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/models/refresh_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // ignore_for_file: non_constant_identifier_names 3 | 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | 6 | part 'refresh_response.freezed.dart'; 7 | part 'refresh_response.g.dart'; 8 | 9 | /// https://firebase.google.com/docs/reference/rest/auth#section-refresh-token 10 | @freezed 11 | sealed class RefreshResponse with _$RefreshResponse { 12 | /// Default constructor 13 | const factory RefreshResponse({ 14 | /// The number of seconds in which the ID token expires. 15 | required String expires_in, 16 | // The type of the refresh token, always "Bearer". 17 | required String token_type, 18 | 19 | /// The Firebase Auth refresh token provided in the request or a new refresh 20 | /// token. 21 | required String refresh_token, 22 | 23 | /// A Firebase Auth ID token. 24 | required String id_token, 25 | 26 | /// The uid corresponding to the provided ID token. 27 | required String user_id, 28 | 29 | /// Your Firebase project ID. 30 | required String project_id, 31 | }) = _RefreshResponse; 32 | 33 | /// JSON constructor 34 | factory RefreshResponse.fromJson(Map json) => 35 | _$RefreshResponseFromJson(json); 36 | } 37 | -------------------------------------------------------------------------------- /lib/src/models/signin_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'signin_request.freezed.dart'; 6 | part 'signin_request.g.dart'; 7 | 8 | /// Meta-Class for multiple API-Endpoints 9 | @freezed 10 | sealed class SignInRequest with _$SignInRequest { 11 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-anonymously 12 | const factory SignInRequest.anonymous({ 13 | /// Whether or not to return an ID and refresh token. Should always be true. 14 | @Default(true) bool returnSecureToken, 15 | }) = AnonymousSignInRequest; 16 | 17 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-with-oauth-credential 18 | const factory SignInRequest.idp({ 19 | /// The URI to which the IDP redirects the user back. 20 | required Uri requestUri, 21 | 22 | /// Contains the OAuth credential (an ID token or access token) and provider 23 | /// ID which issues the credential. 24 | required String postBody, 25 | 26 | /// Whether or not to return an ID and refresh token. Should always be true. 27 | @Default(true) bool returnSecureToken, 28 | 29 | /// Whether to force the return of the OAuth credential on the following 30 | /// errors: FEDERATED_USER_ID_ALREADY_LINKED and EMAIL_EXISTS. 31 | @Default(false) bool returnIdpCredential, 32 | }) = IdpSignInRequest; 33 | 34 | /// Meta-Class for multiple API-Endpoints 35 | /// 36 | /// - https://firebase.google.com/docs/reference/rest/auth#section-create-email-password 37 | /// - https://firebase.google.com/docs/reference/rest/auth#section-sign-in-email-password 38 | const factory SignInRequest.password({ 39 | /// The email the user is signing in with. 40 | required String email, 41 | 42 | /// The password for the account. 43 | required String password, 44 | 45 | /// Whether or not to return an ID and refresh token. Should always be true. 46 | @Default(true) bool returnSecureToken, 47 | }) = PasswordSignInRequest; // TODO split into 2 48 | 49 | /// https://firebase.google.com/docs/reference/rest/auth#section-verify-custom-token 50 | const factory SignInRequest.customToken({ 51 | /// A Firebase Auth custom token from which to create an ID and refresh 52 | /// token pair. 53 | required String token, 54 | 55 | /// Whether or not to return an ID and refresh token. Should always be true. 56 | @Default(true) bool returnSecureToken, 57 | }) = CustomTokenSignInRequest; 58 | 59 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-oauth-credential 60 | const factory SignInRequest.linkIdp({ 61 | /// The Firebase ID token of the account you are trying to link the 62 | /// credential to. 63 | required String idToken, 64 | 65 | /// The URI to which the IDP redirects the user back. 66 | required Uri requestUri, 67 | 68 | /// Contains the OAuth credential (an ID token or access token) and provider 69 | /// ID which issues the credential. 70 | required String postBody, 71 | 72 | /// Whether or not to return an ID and refresh token. Should always be true. 73 | @Default(true) bool returnSecureToken, 74 | 75 | /// Whether to force the return of the OAuth credential on the following 76 | /// errors: FEDERATED_USER_ID_ALREADY_LINKED and EMAIL_EXISTS. 77 | @Default(false) bool returnIdpCredential, 78 | }) = LinkIdpRequest; 79 | 80 | /// JSON constructor 81 | factory SignInRequest.fromJson(Map json) => 82 | _$SignInRequestFromJson(json); 83 | } 84 | -------------------------------------------------------------------------------- /lib/src/models/signin_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'signin_response.freezed.dart'; 6 | part 'signin_response.g.dart'; 7 | 8 | /// Meta-Class for multiple API-Endpoints 9 | @freezed 10 | sealed class SignInResponse with _$SignInResponse { 11 | const SignInResponse._(); 12 | 13 | /// The uid of the newly created user. 14 | String get localId => switch (this) { 15 | CustomTokenSignInResponse() => '', 16 | _ => throw StateError('Unreachable code was reached!'), 17 | }; 18 | 19 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-anonymously 20 | const factory SignInResponse.anonymous({ 21 | /// A Firebase Auth ID token for the newly created user. 22 | required String idToken, 23 | 24 | /// Since the user is anonymous, this should be empty. 25 | String? email, 26 | 27 | /// A Firebase Auth refresh token for the newly created user. 28 | required String refreshToken, 29 | 30 | /// The number of seconds in which the ID token expires. 31 | required String expiresIn, 32 | 33 | /// The uid of the newly created user. 34 | required String localId, 35 | }) = AnonymousSignInResponse; 36 | 37 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-with-oauth-credential 38 | const factory SignInResponse.idp({ 39 | /// The unique ID identifies the IdP account. 40 | required String federatedId, 41 | 42 | /// The linked provider ID (e.g. "google.com" for the Google provider). 43 | required String providerId, 44 | 45 | /// The uid of the authenticated user. 46 | required String localId, 47 | 48 | /// Whether the sign-in email is verified. 49 | @Default(false) bool emailVerified, 50 | 51 | /// The email of the account. 52 | String? email, 53 | 54 | /// The OIDC id token if available. 55 | String? oauthIdToken, 56 | 57 | /// The OAuth access token if available. 58 | String? oauthAccessToken, 59 | 60 | /// The OAuth 1.0 token secret if available. 61 | String? oauthTokenSecret, 62 | 63 | /// The stringified JSON response containing all the IdP data corresponding 64 | /// to the provided OAuth credential. 65 | String? rawUserInfo, 66 | 67 | /// The first name for the account. 68 | String? firstName, 69 | 70 | /// The last name for the account. 71 | String? lastName, 72 | 73 | /// The full name for the account. 74 | String? fullName, 75 | 76 | /// The display name for the account. 77 | String? displayName, 78 | 79 | /// The photo Url for the account. 80 | Uri? photoUrl, 81 | 82 | /// A Firebase Auth ID token for the authenticated user. 83 | required String idToken, 84 | 85 | /// A Firebase Auth refresh token for the authenticated user. 86 | required String refreshToken, 87 | 88 | /// The number of seconds in which the ID token expires. 89 | required String expiresIn, 90 | 91 | /// Whether another account with the same credential already exists. The 92 | /// user will need to sign in to the original account and then link the 93 | /// current credential to it. 94 | @Default(false) bool needConfirmation, 95 | }) = IdpSignInResponse; 96 | 97 | /// Meta-Class for multiple API-Endpoints 98 | /// 99 | /// - https://firebase.google.com/docs/reference/rest/auth#section-create-email-password 100 | /// - https://firebase.google.com/docs/reference/rest/auth#section-sign-in-email-password 101 | const factory SignInResponse.password({ 102 | /// A Firebase Auth ID token for the authenticated user. 103 | required String idToken, 104 | 105 | /// The email for the authenticated user. 106 | String? email, 107 | 108 | /// A Firebase Auth refresh token for the authenticated user. 109 | required String refreshToken, 110 | 111 | /// The number of seconds in which the ID token expires. 112 | required String expiresIn, 113 | 114 | /// The uid of the authenticated user. 115 | required String localId, 116 | 117 | /// Whether the email is for an existing account. 118 | @Default(false) bool registered, 119 | }) = PasswordSignInResponse; // TODO split into two 120 | 121 | /// https://firebase.google.com/docs/reference/rest/auth#section-verify-custom-token 122 | const factory SignInResponse.custom({ 123 | /// A Firebase Auth ID token generated from the provided custom token. 124 | required String idToken, 125 | 126 | /// A Firebase Auth refresh token generated from the provided custom token. 127 | required String refreshToken, 128 | 129 | /// The number of seconds in which the ID token expires. 130 | required String expiresIn, 131 | }) = CustomTokenSignInResponse; 132 | 133 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-oauth-credential 134 | const factory SignInResponse.linkIdp({ 135 | /// The unique ID identifies the IdP account. 136 | required String federatedId, 137 | 138 | /// The linked provider ID (e.g. "google.com" for the Google provider). 139 | required String providerId, 140 | 141 | /// The uid of the authenticated user. 142 | required String localId, 143 | 144 | /// Whether the signin email is verified. 145 | @Default(false) bool emailVerified, 146 | 147 | /// The email of the account. 148 | String? email, 149 | 150 | /// The OIDC id token if available. 151 | String? oauthIdToken, 152 | 153 | /// The OAuth access token if available. 154 | String? oauthAccessToken, 155 | 156 | /// The OAuth 1.0 token secret if available. 157 | String? oauthTokenSecret, 158 | 159 | /// The stringified JSON response containing all the IdP data corresponding 160 | /// to the provided OAuth credential. 161 | String? rawUserInfo, 162 | 163 | /// The first name for the account. 164 | String? firstName, 165 | 166 | /// The last name for the account. 167 | String? lastName, 168 | 169 | /// The full name for the account. 170 | String? fullName, 171 | 172 | /// The display name for the account. 173 | String? displayName, 174 | 175 | /// The photo Url for the account. 176 | Uri? photoUrl, 177 | 178 | /// A Firebase Auth ID token for the authenticated user. 179 | required String idToken, 180 | 181 | /// A Firebase Auth refresh token for the authenticated user. 182 | required String refreshToken, 183 | 184 | /// The number of seconds in which the ID token expires. 185 | required String expiresIn, 186 | }) = LinkIdpResponse; 187 | 188 | /// JSON constructor 189 | factory SignInResponse.fromJson(Map json) => 190 | _$SignInResponseFromJson(json); 191 | } 192 | -------------------------------------------------------------------------------- /lib/src/models/update_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | // ignore_for_file: constant_identifier_names, invalid_annotation_target 3 | 4 | import 'package:freezed_annotation/freezed_annotation.dart'; 5 | 6 | part 'update_request.freezed.dart'; 7 | part 'update_request.g.dart'; 8 | 9 | /// Possible values for [UpdateRequest.profile()] 10 | enum DeleteAttribute { 11 | /// Unsets displayName 12 | DISPLAY_NAME, 13 | 14 | /// Unsets photoUrl 15 | PHOTO_URL, 16 | } 17 | 18 | /// Meta-Class for multiple API-Endpoints 19 | @freezed 20 | sealed class UpdateRequest with _$UpdateRequest { 21 | /// https://firebase.google.com/docs/reference/rest/auth#section-confirm-email-verification 22 | const factory UpdateRequest.confirmEmail({ 23 | /// The action code sent to user's email for email verification. 24 | required String oobCode, 25 | }) = ConfirmEmailRequest; 26 | 27 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-email 28 | const factory UpdateRequest.email({ 29 | /// A Firebase Auth ID token for the user. 30 | required String idToken, 31 | 32 | /// The user's new email. 33 | required String email, 34 | 35 | /// Whether or not to return an ID and refresh token. 36 | @Default(false) bool returnSecureToken, 37 | }) = EmailUpdateRequest; 38 | 39 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-password 40 | const factory UpdateRequest.password({ 41 | /// A Firebase Auth ID token for the user. 42 | required String idToken, 43 | 44 | /// User's new password. 45 | required String password, 46 | 47 | /// Whether or not to return an ID and refresh token. 48 | @Default(true) bool returnSecureToken, 49 | }) = PasswordUpdateRequest; 50 | 51 | /// https://firebase.google.com/docs/reference/rest/auth#section-update-profile 52 | const factory UpdateRequest.profile({ 53 | /// A Firebase Auth ID token for the user. 54 | required String idToken, 55 | 56 | /// User's new display name. 57 | @JsonKey(includeIfNull: false) String? displayName, 58 | 59 | /// User's new photo url. 60 | @JsonKey(includeIfNull: false) Uri? photoUrl, 61 | 62 | /// List of attributes to delete, [DeleteAttribute.DISPLAY_NAME] or 63 | /// [DeleteAttribute.PHOTO_URL]. This will nullify these values. 64 | @Default([]) List deleteAttribute, 65 | 66 | /// Whether or not to return an ID and refresh token. 67 | @Default(false) bool returnSecureToken, 68 | }) = ProfileUpdateRequest; 69 | 70 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-email-password 71 | const factory UpdateRequest.linkEmail({ 72 | /// The Firebase ID token of the account you are trying to link the 73 | /// credential to. 74 | required String idToken, 75 | 76 | /// The email to link to the account. 77 | required String email, 78 | 79 | /// The new password of the account. 80 | required String password, 81 | 82 | /// Whether or not to return an ID and refresh token. Should always be true. 83 | @Default(true) bool returnSecureToken, 84 | }) = LinkEmailRequest; 85 | 86 | /// https://firebase.google.com/docs/reference/rest/auth#section-unlink-provider 87 | const factory UpdateRequest.unlink({ 88 | /// The Firebase ID token of the account. 89 | required String idToken, 90 | 91 | /// The list of provider IDs to unlink, eg: 'google.com', 'password', etc. 92 | required List deleteProvider, 93 | }) = UnlinkRequest; 94 | 95 | /// JSON constructor 96 | factory UpdateRequest.fromJson(Map json) => 97 | _$UpdateRequestFromJson(json); 98 | } 99 | -------------------------------------------------------------------------------- /lib/src/models/update_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | import 'provider_user_info.dart'; 6 | 7 | part 'update_response.freezed.dart'; 8 | part 'update_response.g.dart'; 9 | 10 | /// Meta-Class for multiple API-Endpoints 11 | @freezed 12 | sealed class UpdateResponse with _$UpdateResponse { 13 | /// https://firebase.google.com/docs/reference/rest/auth#section-confirm-email-verification 14 | const factory UpdateResponse.confirmEmail({ 15 | /// The email of the account. 16 | String? email, 17 | 18 | /// The display name for the account. 19 | String? displayName, 20 | 21 | /// The photo Url for the account. 22 | Uri? photoUrl, 23 | 24 | /// The password hash. 25 | String? passwordHash, 26 | 27 | /// List of all linked [ProviderUserInfo]s. 28 | @Default([]) List providerUserInfo, 29 | 30 | /// Whether or not the account's email has been verified. 31 | @Default(false) bool emailVerified, 32 | }) = ConfirmEmailResponse; 33 | 34 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-email 35 | const factory UpdateResponse.email({ 36 | /// The uid of the current user. 37 | required String localId, 38 | 39 | /// User's email address. 40 | String? email, 41 | 42 | /// Hash version of the password. 43 | String? passwordHash, 44 | 45 | /// List of all linked [ProviderUserInfo]s. 46 | @Default([]) List providerUserInfo, 47 | 48 | /// New Firebase Auth ID token for user. 49 | String? idToken, 50 | 51 | /// A Firebase Auth refresh token. 52 | String? refreshToken, 53 | 54 | /// The number of seconds in which the ID token expires. 55 | String? expiresIn, 56 | }) = EmailUpdateResponse; 57 | 58 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-password 59 | const factory UpdateResponse.password({ 60 | /// The uid of the current user. 61 | required String localId, 62 | 63 | /// User's email address. 64 | String? email, 65 | 66 | /// Hash version of password. 67 | String? passwordHash, 68 | 69 | /// List of all linked [ProviderUserInfo]s. 70 | @Default([]) List providerUserInfo, 71 | 72 | /// New Firebase Auth ID token for user. 73 | String? idToken, 74 | 75 | /// A Firebase Auth refresh token. 76 | String? refreshToken, 77 | 78 | /// The number of seconds in which the ID token expires. 79 | String? expiresIn, 80 | }) = PasswordUpdateResponse; 81 | 82 | /// https://firebase.google.com/docs/reference/rest/auth#section-update-profile 83 | const factory UpdateResponse.profile({ 84 | /// The uid of the current user. 85 | required String localId, 86 | 87 | /// User's email address. 88 | String? email, 89 | 90 | /// User's new display name. 91 | String? displayName, 92 | 93 | /// User's new photo url. 94 | Uri? photoUrl, 95 | 96 | /// Hash version of password. 97 | String? passwordHash, 98 | 99 | /// List of all linked [ProviderUserInfo]s. 100 | @Default([]) List providerUserInfo, 101 | 102 | /// New Firebase Auth ID token for user. 103 | String? idToken, 104 | 105 | /// A Firebase Auth refresh token. 106 | String? refreshToken, 107 | 108 | /// The number of seconds in which the ID token expires. 109 | String? expiresIn, 110 | }) = ProfileUpdateResponse; 111 | 112 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-email-password 113 | const factory UpdateResponse.linkEmail({ 114 | /// The uid of the current user. 115 | required String localId, 116 | 117 | /// The email of the account. 118 | String? email, 119 | 120 | /// The display name for the account. 121 | String? displayName, 122 | 123 | /// The photo Url for the account. 124 | Uri? photoUrl, 125 | 126 | /// Hash version of password. 127 | String? passwordHash, 128 | 129 | /// List of all linked [ProviderUserInfo]s. 130 | @Default([]) List providerUserInfo, 131 | 132 | /// Whether or not the account's email has been verified. 133 | @Default(false) bool emailVerified, 134 | 135 | /// New Firebase Auth ID token for user. 136 | String? idToken, 137 | 138 | /// A Firebase Auth refresh token. 139 | String? refreshToken, 140 | 141 | /// The number of seconds in which the ID token expires. 142 | String? expiresIn, 143 | }) = LinkEmailResponse; 144 | 145 | /// https://firebase.google.com/docs/reference/rest/auth#section-unlink-provider 146 | const factory UpdateResponse.unlink({ 147 | /// The uid of the current user. 148 | required String localId, 149 | 150 | /// The email of the account. 151 | String? email, 152 | 153 | /// The display name for the account. 154 | String? displayName, 155 | 156 | /// The photo Url for the account. 157 | Uri? photoUrl, 158 | 159 | /// Hash version of the password. 160 | String? passwordHash, 161 | 162 | /// List of all linked [ProviderUserInfo]s. 163 | @Default([]) List providerUserInfo, 164 | 165 | /// Whether or not the account's email has been verified. 166 | @Default(false) bool emailVerified, 167 | }) = UnlinkResponse; 168 | 169 | /// JSON constructor 170 | factory UpdateResponse.fromJson(Map json) => 171 | _$UpdateResponseFromJson(json); 172 | } 173 | -------------------------------------------------------------------------------- /lib/src/models/userdata.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | import 'provider_user_info.dart'; 6 | 7 | part 'userdata.freezed.dart'; 8 | part 'userdata.g.dart'; 9 | 10 | /// A user data object as defined by the firebase REST-API endpoints. 11 | /// 12 | /// Check https://firebase.google.com/docs/reference/rest/auth#section-get-account-info 13 | /// for more details about the underlying REST request. 14 | @freezed 15 | sealed class UserData with _$UserData { 16 | /// Default constructor 17 | const factory UserData({ 18 | /// The uid of the current user. 19 | required String localId, 20 | 21 | /// The email of the account. 22 | String? email, 23 | 24 | /// Whether or not the account's [email] has been verified. 25 | @Default(false) bool emailVerified, 26 | 27 | /// The display name for the account. 28 | String? displayName, 29 | 30 | /// List of all linked [ProviderUserInfo]s. 31 | @Default([]) List providerUserInfo, 32 | 33 | /// The photo Url for the account. 34 | Uri? photoUrl, 35 | 36 | /// Hash version of password. 37 | String? passwordHash, 38 | 39 | /// The timestamp, in milliseconds, that the account password was last 40 | /// changed. 41 | int? passwordUpdatedAt, 42 | 43 | /// The timestamp, in seconds, which marks a boundary, before which Firebase 44 | /// ID token are considered revoked. 45 | String? validSince, 46 | 47 | /// Whether the account is disabled or not. 48 | @Default(false) bool disabled, 49 | 50 | /// The timestamp, in milliseconds, that the account last logged in at. 51 | String? lastLoginAt, 52 | 53 | /// The timestamp, in milliseconds, that the account was created at. 54 | String? createdAt, 55 | 56 | /// Whether the account is authenticated by the developer. 57 | @Default(false) bool customAuth, 58 | }) = _UserData; 59 | 60 | /// JSON constructor 61 | factory UserData.fromJson(Map json) => 62 | _$UserDataFromJson(json); 63 | } 64 | -------------------------------------------------------------------------------- /lib/src/models/userdata_request.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | part 'userdata_request.freezed.dart'; 6 | part 'userdata_request.g.dart'; 7 | 8 | /// https://firebase.google.com/docs/reference/rest/auth#section-get-account-info 9 | @freezed 10 | sealed class UserDataRequest with _$UserDataRequest { 11 | /// Default constructor 12 | const factory UserDataRequest({ 13 | /// The Firebase ID token of the account. 14 | required String idToken, 15 | }) = _UserDataRequest; 16 | 17 | /// JSON constructor 18 | factory UserDataRequest.fromJson(Map json) => 19 | _$UserDataRequestFromJson(json); 20 | } 21 | -------------------------------------------------------------------------------- /lib/src/models/userdata_response.dart: -------------------------------------------------------------------------------- 1 | // coverage:ignore-file 2 | 3 | import 'package:freezed_annotation/freezed_annotation.dart'; 4 | 5 | import 'userdata.dart'; 6 | 7 | part 'userdata_response.freezed.dart'; 8 | part 'userdata_response.g.dart'; 9 | 10 | /// https://firebase.google.com/docs/reference/rest/auth#section-get-account-info 11 | @freezed 12 | sealed class UserDataResponse with _$UserDataResponse { 13 | /// Default constructor 14 | const factory UserDataResponse({ 15 | /// The account associated with the given Firebase ID token. Check 16 | /// [UserData] for more details. 17 | @Default([]) List users, 18 | }) = _UserDataResponse; 19 | 20 | /// JSON constructor 21 | factory UserDataResponse.fromJson(Map json) => 22 | _$UserDataResponseFromJson(json); 23 | } 24 | -------------------------------------------------------------------------------- /lib/src/profile_update.dart: -------------------------------------------------------------------------------- 1 | /// A helper class to create profile updates. 2 | class ProfileUpdate { 3 | /// Returns the data associated with this update. 4 | /// 5 | /// If [isUpdate] is true, this will return the actual data to be used by the 6 | /// update. If [isDelete] is true instead, [data] will always be null. 7 | final T? data; 8 | 9 | /// Specifies, if this is an update with new data. 10 | bool get isUpdate => data != null; 11 | 12 | /// Specifies, if this is an update to delete data. 13 | bool get isDelete => data == null; 14 | 15 | /// Returns the [data] value, if [isUpdate], otherwise [defaultValue]. 16 | T? updateOr([T? defaultValue]) => data ?? defaultValue; 17 | 18 | /// Creates a new profile update to update data. 19 | /// 20 | /// This method sets [this.data] to [data], [isUpdate] to true and [isDelete] 21 | /// to false. 22 | const ProfileUpdate.update(this.data); 23 | 24 | /// Creates a new profile update to delete data. 25 | /// 26 | /// This method sets [data] to null, [isUpdate] to false and [isDelete] to 27 | /// true. 28 | const ProfileUpdate.delete() : data = null; 29 | } 30 | -------------------------------------------------------------------------------- /lib/src/rest_api.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: non_constant_identifier_names 2 | import 'dart:convert'; 3 | 4 | import 'package:http/http.dart'; 5 | 6 | import 'models/auth_exception.dart'; 7 | import 'models/delete_request.dart'; 8 | import 'models/emulator_config.dart'; 9 | import 'models/fetch_provider_request.dart'; 10 | import 'models/fetch_provider_response.dart'; 11 | import 'models/oob_code_request.dart'; 12 | import 'models/oob_code_response.dart'; 13 | import 'models/password_reset_request.dart'; 14 | import 'models/password_reset_response.dart'; 15 | import 'models/refresh_response.dart'; 16 | import 'models/signin_request.dart'; 17 | import 'models/signin_response.dart'; 18 | import 'models/update_request.dart'; 19 | import 'models/update_response.dart'; 20 | import 'models/userdata_request.dart'; 21 | import 'models/userdata_response.dart'; 22 | 23 | /// A client wrapper class for the firebase authentication REST-Api. 24 | /// 25 | /// The class methods itself are not extensively documented, instead all members 26 | /// link to their endpoint documentation of the Firebase API itself. 27 | /// 28 | /// See https://firebase.google.com/docs/reference/rest/auth for more details. 29 | class RestApi { 30 | static const _firebaseLocaleHeader = 'X-Firebase-Locale'; 31 | static const _authHost = 'identitytoolkit.googleapis.com'; 32 | static const _tokenHost = 'securetoken.googleapis.com'; 33 | 34 | /// The HTTP-Client used to access the remote api 35 | final Client client; 36 | 37 | /// The Firebase Web-API-Key to authenticate to the remote api 38 | final String apiKey; 39 | 40 | /// Config to connect to the Firebase auth emulator 41 | /// 42 | /// When set, requests will be sent to the Firebase auth emulator 43 | /// instead of the production endpoints 44 | final EmulatorConfig? emulator; 45 | 46 | /// Create a new api instance 47 | /// 48 | /// The api is created with [client] and [apiKey] to initialize the 49 | /// equivalent members. They are used to access the firebase servers. If 50 | /// [emulator] is specified, requests will be made against the Firebase auth 51 | /// emulator instead of the production endpoints using the provided 52 | /// [EmulatorConfig]. 53 | const RestApi(this.client, this.apiKey, {this.emulator}); 54 | 55 | /// https://firebase.google.com/docs/reference/rest/auth#section-refresh-token 56 | Future token({ 57 | required String refresh_token, 58 | String grant_type = 'refresh_token', 59 | }) async => RefreshResponse.fromJson( 60 | await _postQuery(_buildUri('token', isTokenRequest: true), { 61 | 'refresh_token': refresh_token, 62 | 'grant_type': grant_type, 63 | }), 64 | ); 65 | 66 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-anonymously 67 | Future signUpAnonymous( 68 | AnonymousSignInRequest request, 69 | ) async => AnonymousSignInResponse.fromJson( 70 | await _post(_buildUri('accounts:signUp'), request.toJson()), 71 | ); 72 | 73 | /// https://firebase.google.com/docs/reference/rest/auth#section-create-email-password 74 | Future signUpWithPassword( 75 | PasswordSignInRequest request, 76 | ) async => PasswordSignInResponse.fromJson( 77 | await _post(_buildUri('accounts:signUp'), request.toJson()), 78 | ); 79 | 80 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-with-oauth-credential 81 | Future signInWithIdp(IdpSignInRequest request) async => 82 | IdpSignInResponse.fromJson( 83 | await _post(_buildUri('accounts:signInWithIdp'), request.toJson()), 84 | ); 85 | 86 | /// https://firebase.google.com/docs/reference/rest/auth#section-sign-in-email-password 87 | Future signInWithPassword( 88 | PasswordSignInRequest request, 89 | ) async => PasswordSignInResponse.fromJson( 90 | await _post(_buildUri('accounts:signInWithPassword'), request.toJson()), 91 | ); 92 | 93 | /// https://firebase.google.com/docs/reference/rest/auth#section-verify-custom-token 94 | Future signInWithCustomToken( 95 | CustomTokenSignInRequest request, 96 | ) async => CustomTokenSignInResponse.fromJson( 97 | await _post(_buildUri('accounts:signInWithCustomToken'), request.toJson()), 98 | ); 99 | 100 | /// https://firebase.google.com/docs/reference/rest/auth#section-get-account-info 101 | Future getUserData(UserDataRequest request) async => 102 | UserDataResponse.fromJson( 103 | await _post(_buildUri('accounts:lookup'), request.toJson()), 104 | ); 105 | 106 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-email 107 | Future updateEmail( 108 | EmailUpdateRequest request, [ 109 | String? locale, 110 | ]) async => EmailUpdateResponse.fromJson( 111 | await _post( 112 | _buildUri('accounts:update'), 113 | request.toJson(), 114 | headers: locale != null ? {_firebaseLocaleHeader: locale} : null, 115 | ), 116 | ); 117 | 118 | /// https://firebase.google.com/docs/reference/rest/auth#section-change-password 119 | Future updatePassword( 120 | PasswordUpdateRequest request, 121 | ) async => PasswordUpdateResponse.fromJson( 122 | await _post(_buildUri('accounts:update'), request.toJson()), 123 | ); 124 | 125 | /// https://firebase.google.com/docs/reference/rest/auth#section-update-profile 126 | Future updateProfile( 127 | ProfileUpdateRequest request, 128 | ) async => ProfileUpdateResponse.fromJson( 129 | await _post(_buildUri('accounts:update'), request.toJson()), 130 | ); 131 | 132 | /// Meta-Method for multiple API-Methods 133 | /// 134 | /// - https://firebase.google.com/docs/reference/rest/auth#section-send-email-verification 135 | /// - https://firebase.google.com/docs/reference/rest/auth#section-send-password-reset-email 136 | Future sendOobCode( 137 | OobCodeRequest request, [ 138 | String? locale, 139 | ]) async => OobCodeResponse.fromJson( 140 | await _post( 141 | _buildUri('accounts:sendOobCode'), 142 | request.toJson(), 143 | headers: locale != null ? {_firebaseLocaleHeader: locale} : null, 144 | ), 145 | ); 146 | 147 | /// Meta-Method for multiple API-Methods 148 | /// 149 | /// - https://firebase.google.com/docs/reference/rest/auth#section-verify-password-reset-code 150 | /// - https://firebase.google.com/docs/reference/rest/auth#section-confirm-reset-password 151 | Future resetPassword( 152 | PasswordResetRequest request, 153 | ) async => PasswordResetResponse.fromJson( 154 | await _post(_buildUri('accounts:resetPassword'), request.toJson()), 155 | ); 156 | 157 | /// https://firebase.google.com/docs/reference/rest/auth#section-confirm-email-verification 158 | Future confirmEmail( 159 | ConfirmEmailRequest request, 160 | ) async => ConfirmEmailResponse.fromJson( 161 | await _post(_buildUri('accounts:update'), request.toJson()), 162 | ); 163 | 164 | /// https://firebase.google.com/docs/reference/rest/auth#section-fetch-providers-for-email 165 | Future fetchProviders( 166 | FetchProviderRequest request, 167 | ) async => FetchProviderResponse.fromJson( 168 | await _post(_buildUri('accounts:createAuthUri'), request.toJson()), 169 | ); 170 | 171 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-email-password 172 | Future linkEmail(LinkEmailRequest request) async => 173 | LinkEmailResponse.fromJson( 174 | await _post(_buildUri('accounts:update'), request.toJson()), 175 | ); 176 | 177 | /// https://firebase.google.com/docs/reference/rest/auth#section-link-with-oauth-credential 178 | Future linkIdp(LinkIdpRequest request) async => 179 | LinkIdpResponse.fromJson( 180 | await _post(_buildUri('accounts:signInWithIdp'), request.toJson()), 181 | ); 182 | 183 | /// https://firebase.google.com/docs/reference/rest/auth#section-unlink-provider 184 | Future unlinkProvider(UnlinkRequest request) async => 185 | UnlinkResponse.fromJson( 186 | await _post(_buildUri('accounts:update'), request.toJson()), 187 | ); 188 | 189 | /// https://firebase.google.com/docs/reference/rest/auth#section-delete-account 190 | Future delete(DeleteRequest request) => 191 | _post(_buildUri('accounts:delete'), request.toJson(), noContent: true); 192 | 193 | Uri _buildUri( 194 | String path, { 195 | bool isTokenRequest = false, 196 | Map? queryParameters, 197 | }) { 198 | String host; 199 | var protocol = 'https'; 200 | int? port; 201 | final targetDomain = isTokenRequest ? _tokenHost : _authHost; 202 | if (emulator != null) { 203 | host = emulator!.host; 204 | protocol = emulator!.protocol; 205 | port = emulator!.port; 206 | } else { 207 | host = targetDomain; 208 | } 209 | return Uri( 210 | scheme: protocol, 211 | host: host, 212 | port: port, 213 | pathSegments: [if (emulator != null) targetDomain, 'v1', path], 214 | queryParameters: {'key': apiKey, ...?queryParameters}, 215 | ); 216 | } 217 | 218 | Future> _post( 219 | Uri url, 220 | Map body, { 221 | Map? headers, 222 | bool noContent = false, 223 | }) async { 224 | final allHeaders = { 225 | 'Content-Type': 'application/json', 226 | 'Accept': 'application/json', 227 | ...?headers, 228 | }; 229 | body.remove('runtimeType'); 230 | final response = await client.post( 231 | url, 232 | body: json.encode(body), 233 | headers: allHeaders, 234 | ); 235 | return _parseResponse(response, noContent); 236 | } 237 | 238 | Future> _postQuery( 239 | Uri url, 240 | Map query, 241 | ) async { 242 | const allHeaders = { 243 | 'Content-Type': 'application/x-www-form-urlencoded', 244 | 'Accept': 'application/json', 245 | }; 246 | return _parseResponse( 247 | await client.post(url, body: query, headers: allHeaders), 248 | ); 249 | } 250 | 251 | Map _parseResponse( 252 | Response response, [ 253 | bool noContent = false, 254 | ]) { 255 | if (response.statusCode >= 300) { 256 | final body = json.decode(response.body) as Map; 257 | throw AuthException.fromJson(body); 258 | } else if (response.statusCode == 204 || noContent) { 259 | return const {}; 260 | } else { 261 | final body = json.decode(response.body) as Map; 262 | return body; 263 | } 264 | } 265 | } 266 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: firebase_auth_rest 2 | description: A platform independent Dart/Flutter Wrapper for the Firebase Authentication API based on REST 3 | version: 2.1.1 4 | homepage: https://github.com/Skycoder42/firebase_auth_rest 5 | 6 | environment: 7 | sdk: ^3.7.0 8 | 9 | dependencies: 10 | freezed_annotation: ^3.0.0 11 | http: ^1.3.0 12 | json_annotation: ^4.9.0 13 | 14 | dev_dependencies: 15 | build_runner: ^2.4.15 16 | coverage: ^1.11.1 17 | custom_lint: ^0.7.5 18 | dart_pre_commit: ^5.4.4 19 | dart_test_tools: ^6.1.1 20 | freezed: ^3.0.3 21 | json_serializable: ^6.9.4 22 | meta: ^1.16.0 23 | mocktail: ^1.0.4 24 | test: ^1.25.15 25 | -------------------------------------------------------------------------------- /test/integration/account_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | // ignore: no_self_package_imports 4 | import 'package:firebase_auth_rest/firebase_auth_rest.dart'; 5 | import 'package:http/http.dart'; 6 | import 'package:test/test.dart'; 7 | 8 | import 'test_config.dart'; 9 | 10 | /// Running the integration tests requires the following settings: 11 | /// - Anonymous Authentication 12 | /// - E-Mail/Password based authentication 13 | /// - Account Registration 14 | /// - Email Enumeration 15 | void main() { 16 | late final Client client; 17 | late final FirebaseAuth auth; 18 | 19 | String generateRnd(int length) => 20 | List.generate( 21 | length ~/ 2, 22 | (i) => Random.secure().nextInt(256).toRadixString(16), 23 | ).join(); 24 | 25 | setUpAll(() async { 26 | client = Client(); 27 | final apiKey = await TestConfig.apiKey; 28 | final emulatorHost = await TestConfig.emulatorHost; 29 | final emulatorPort = await TestConfig.emulatorPort; 30 | EmulatorConfig? emulator; 31 | if (emulatorPort != null && emulatorHost != null) { 32 | emulator = EmulatorConfig(host: emulatorHost, port: emulatorPort); 33 | } 34 | auth = 35 | emulator != null 36 | ? FirebaseAuth.emulator(client, apiKey, emulator) 37 | : FirebaseAuth(client, apiKey); 38 | if (auth.api.emulator != null) { 39 | // ignore: avoid_print 40 | print( 41 | 'running tests against firebase emulator located at http://${auth.api.emulator?.host}:${auth.api.emulator?.port}', 42 | ); 43 | } 44 | }); 45 | 46 | tearDownAll(() { 47 | client.close(); 48 | }); 49 | 50 | group('anonymous', () { 51 | late FirebaseAccount account; 52 | late DateTime createdAt; 53 | var deleted = false; 54 | 55 | setUp(() async { 56 | createdAt = DateTime.now().toUtc().subtract(const Duration(seconds: 2)); 57 | account = await auth.signUpAnonymous(autoRefresh: false); 58 | }); 59 | 60 | tearDown(() async { 61 | if (!deleted) { 62 | await account.delete(); 63 | } 64 | await account.dispose(); 65 | }); 66 | 67 | test('setUp and tearDown work', () { 68 | expect(account.localId, isNotNull); 69 | expect(account.idToken, isNotNull); 70 | expect(account.refreshToken, isNotNull); 71 | expect(account.expiresAt, isNotNull); 72 | }); 73 | 74 | test('refresh requests a new id token', () async { 75 | final oldId = account.localId; 76 | final oldExpires = account.expiresAt; 77 | 78 | final result = await account.refresh(); 79 | 80 | expect(result, isNotNull); 81 | expect(account.idToken, result); 82 | expect(account.localId, oldId); 83 | expect(account.expiresAt, _isAfter(oldExpires)); 84 | }); 85 | 86 | test('getDetails returns account details', () async { 87 | final details = await account.getDetails(); 88 | printOnFailure(details.toString()); 89 | 90 | expect(details, isNotNull); 91 | expect(details!.localId, account.localId); 92 | expect(details.email, isNull); 93 | expect(details.emailVerified, false); 94 | expect(details.displayName, isNull); 95 | expect(details.providerUserInfo, isEmpty); 96 | expect(details.photoUrl, isNull); 97 | expect(details.passwordHash, isNull); 98 | expect(details.passwordUpdatedAt, isNull); 99 | expect(details.validSince, isNull); 100 | expect(details.disabled, false); 101 | expect(details.createdAt, isNotNull); 102 | expect( 103 | DateTime.fromMillisecondsSinceEpoch( 104 | int.parse(details.createdAt!), 105 | isUtc: true, 106 | ), 107 | _isAfter(createdAt), 108 | ); 109 | expect(details.lastLoginAt, details.createdAt); 110 | expect(details.customAuth, false); 111 | }); 112 | 113 | test('can update profile details', () async { 114 | var details = await account.getDetails(); 115 | expect(details, isNotNull); 116 | expect(details!.displayName, isNull); 117 | expect(details.photoUrl, isNull); 118 | 119 | await account.updateProfile( 120 | displayName: const ProfileUpdate.update('testName'), 121 | ); 122 | details = await account.getDetails(); 123 | expect(details, isNotNull); 124 | expect(details!.displayName, 'testName'); 125 | expect(details.photoUrl, isNull); 126 | 127 | await account.updateProfile( 128 | displayName: const ProfileUpdate.delete(), 129 | photoUrl: ProfileUpdate.update( 130 | Uri.parse('https://example.org/profile.png'), 131 | ), 132 | ); 133 | details = await account.getDetails(); 134 | expect(details, isNotNull); 135 | expect(details!.displayName, isNull); 136 | expect(details.photoUrl, Uri.parse('https://example.org/profile.png')); 137 | 138 | await account.updateProfile(photoUrl: const ProfileUpdate.delete()); 139 | details = await account.getDetails(); 140 | expect(details, isNotNull); 141 | expect(details!.displayName, isNull); 142 | expect(details.photoUrl, isNull); 143 | }); 144 | 145 | test('can restore account from refresh token', () async { 146 | final restoredAccount = await auth.restoreAccount(account.refreshToken); 147 | try { 148 | expect(restoredAccount, isNotNull); 149 | expect(restoredAccount.refreshToken, account.refreshToken); 150 | 151 | expect(restoredAccount.getDetails(), completes); 152 | } finally { 153 | await restoredAccount.dispose(); 154 | } 155 | }); 156 | 157 | test('account linking with fake email works', () async { 158 | final fakeMail = '${generateRnd(32)}@example.org'; 159 | final fakePassword = generateRnd(64); 160 | 161 | final ok = await account.linkEmail( 162 | fakeMail, 163 | fakePassword, 164 | autoVerify: false, 165 | ); 166 | expect(ok, false); // eMail is not verified. 167 | 168 | final details = await account.getDetails(); 169 | expect(details, isNotNull); 170 | expect(details!.email, fakeMail); 171 | expect(details.emailVerified, false); 172 | expect(details.passwordHash, isNotNull); 173 | expect(details.providerUserInfo, hasLength(1)); 174 | expect( 175 | details.providerUserInfo, 176 | contains( 177 | predicate((p) => p.providerId == 'password'), 178 | ), 179 | ); 180 | 181 | await account.updatePassword(fakePassword + fakePassword); 182 | final newDetails = await account.getDetails(); 183 | expect(newDetails, isNotNull); 184 | expect(newDetails!.email, fakeMail); 185 | // expect(newDetails.passwordHash, isNot(details.passwordHash)); 186 | expect( 187 | newDetails.passwordUpdatedAt, 188 | greaterThan(details.passwordUpdatedAt!), 189 | ); 190 | 191 | await account.unlinkProviders(['password']); 192 | final clearedDetails = await account.getDetails(); 193 | expect(clearedDetails, isNotNull); 194 | expect(clearedDetails!.providerUserInfo, isEmpty); 195 | }); 196 | 197 | test('requests fail after account was deleted', () async { 198 | await account.delete(); 199 | deleted = true; 200 | 201 | expect(() => account.getDetails(), throwsA(isA())); 202 | }); 203 | }); 204 | 205 | test('email', () async { 206 | final fakeMail = '${generateRnd(32)}@example.org'; 207 | final fakePassword = generateRnd(64); 208 | 209 | FirebaseAccount? account1; 210 | FirebaseAccount? account2; 211 | try { 212 | // create the account 213 | account1 = await auth.signUpWithPassword( 214 | fakeMail, 215 | fakePassword, 216 | autoVerify: false, 217 | autoRefresh: false, 218 | ); 219 | expect(account1.localId, isNotNull); 220 | expect(account1.idToken, isNotNull); 221 | expect(account1.refreshToken, isNotNull); 222 | expect(account1.expiresAt, isNotNull); 223 | 224 | // list providers 225 | final providers = await auth.fetchProviders(fakeMail); 226 | expect(providers, hasLength(2)); 227 | expect(providers, contains('email')); 228 | expect(providers, contains('password')); 229 | 230 | // sign in 231 | account2 = await auth.signInWithPassword( 232 | fakeMail, 233 | fakePassword, 234 | autoRefresh: false, 235 | ); 236 | expect(account2.localId, account1.localId); 237 | expect(account2.idToken, isNotNull); 238 | expect(account2.refreshToken, isNotNull); 239 | expect(account2.expiresAt, _isAfter(account1.expiresAt)); 240 | } finally { 241 | await account1?.dispose(); 242 | await account2?.dispose(); 243 | } 244 | }); 245 | } 246 | 247 | Matcher _isAfter(DateTime after) => 248 | predicate((e) => e.isAfter(after), 'is after $after'); 249 | -------------------------------------------------------------------------------- /test/integration/test_config.dart: -------------------------------------------------------------------------------- 1 | import 'package:dart_test_tools/test.dart'; 2 | 3 | abstract class TestConfig { 4 | TestConfig._(); 5 | 6 | static Future get apiKey => 7 | TestEnv.load().then((c) => c['FIREBASE_API_KEY']!); 8 | 9 | static Future get emulatorHost => 10 | TestEnv.load().then((c) => c['FIREBASE_EMULATOR_HOST']); 11 | 12 | static Future get emulatorPort => TestEnv.load().then( 13 | (c) => int.tryParse(c['FIREBASE_EMULATOR_PORT'] ?? ''), 14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /test/unit/fakes.dart: -------------------------------------------------------------------------------- 1 | import 'dart:convert'; 2 | 3 | import 'package:http/http.dart'; 4 | import 'package:mocktail/mocktail.dart'; 5 | 6 | class FakeRequest extends Fake implements BaseRequest { 7 | @override 8 | Uri get url => Uri.http('localhost', '/'); 9 | } 10 | 11 | class FakeResponse extends Fake implements Response { 12 | FakeResponse({this.body = '{"error": {}}', this.statusCode = 200}); 13 | 14 | static FakeResponse forModel( 15 | T model, [ 16 | Map? overwrites, 17 | ]) => FakeResponse( 18 | body: json.encode( 19 | overwrites != null 20 | ? { 21 | // ignore: avoid_dynamic_calls 22 | ...(model as dynamic).toJson() as Map, 23 | ...overwrites, 24 | } 25 | // ignore: avoid_dynamic_calls 26 | : (model as dynamic).toJson(), 27 | ), 28 | ); 29 | 30 | @override 31 | BaseRequest get request => FakeRequest(); 32 | 33 | @override 34 | Map get headers => {}; 35 | 36 | @override 37 | final int statusCode; 38 | 39 | @override 40 | final String body; 41 | } 42 | -------------------------------------------------------------------------------- /test/unit/firebase_account_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors, discarded_futures 2 | 3 | import 'package:dart_test_tools/test.dart'; 4 | import 'package:firebase_auth_rest/src/firebase_account.dart'; 5 | import 'package:firebase_auth_rest/src/models/auth_exception.dart'; 6 | import 'package:firebase_auth_rest/src/models/delete_request.dart'; 7 | import 'package:firebase_auth_rest/src/models/idp_provider.dart'; 8 | import 'package:firebase_auth_rest/src/models/oob_code_request.dart'; 9 | import 'package:firebase_auth_rest/src/models/oob_code_response.dart'; 10 | import 'package:firebase_auth_rest/src/models/refresh_response.dart'; 11 | import 'package:firebase_auth_rest/src/models/signin_request.dart'; 12 | import 'package:firebase_auth_rest/src/models/signin_response.dart'; 13 | import 'package:firebase_auth_rest/src/models/update_request.dart'; 14 | import 'package:firebase_auth_rest/src/models/update_response.dart'; 15 | import 'package:firebase_auth_rest/src/models/userdata.dart'; 16 | import 'package:firebase_auth_rest/src/models/userdata_request.dart'; 17 | import 'package:firebase_auth_rest/src/models/userdata_response.dart'; 18 | import 'package:firebase_auth_rest/src/profile_update.dart'; 19 | import 'package:firebase_auth_rest/src/rest_api.dart'; 20 | import 'package:http/http.dart'; 21 | import 'package:mocktail/mocktail.dart'; 22 | import 'package:test/test.dart'; 23 | 24 | class MockClient extends Mock implements Client {} 25 | 26 | class MockRestApi extends Mock implements RestApi {} 27 | 28 | void main() { 29 | final mockApi = MockRestApi(); 30 | const defaultSignInResponse = SignInResponse.anonymous( 31 | localId: 'localId', 32 | idToken: 'idToken', 33 | refreshToken: 'refreshToken', 34 | expiresIn: '5', 35 | ); 36 | const defaultLinkEmailResponse = LinkEmailResponse(localId: 'localId'); 37 | const defaultRefreshResponse = RefreshResponse( 38 | expires_in: '5', 39 | token_type: 'token_type', 40 | refresh_token: 'refresh_token', 41 | id_token: 'id_token', 42 | user_id: 'user_id', 43 | project_id: 'project_id', 44 | ); 45 | const defaultLinkIdpResponse = LinkIdpResponse( 46 | federatedId: 'federatedId', 47 | providerId: 'providerId', 48 | localId: 'localId', 49 | idToken: 'idToken', 50 | refreshToken: 'refreshToken', 51 | expiresIn: '5', 52 | ); 53 | const defaultUserData = UserData(localId: 'localId'); 54 | 55 | late FirebaseAccount account; 56 | 57 | setUpAll(() { 58 | registerFallbackValue(Uri()); 59 | registerFallbackValue(const DeleteRequest(idToken: '')); 60 | registerFallbackValue(const UnlinkRequest(idToken: '', deleteProvider: [])); 61 | registerFallbackValue( 62 | LinkIdpRequest(idToken: '', requestUri: Uri(), postBody: ''), 63 | ); 64 | registerFallbackValue( 65 | const LinkEmailRequest(idToken: '', email: '', password: ''), 66 | ); 67 | registerFallbackValue(const OobCodeRequest.verifyEmail(idToken: '')); 68 | registerFallbackValue(const ProfileUpdateRequest(idToken: '')); 69 | registerFallbackValue(const EmailUpdateRequest(idToken: '', email: '')); 70 | registerFallbackValue(const UserDataRequest(idToken: '')); 71 | registerFallbackValue(const ConfirmEmailRequest(oobCode: '')); 72 | registerFallbackValue( 73 | const PasswordUpdateRequest(idToken: '', password: ''), 74 | ); 75 | }); 76 | 77 | setUp(() { 78 | reset(mockApi); 79 | }); 80 | 81 | tearDown(() async { 82 | await account.dispose(); 83 | }); 84 | 85 | group('create', () { 86 | final mockClient = MockClient(); 87 | 88 | setUp(() { 89 | reset(mockClient); 90 | }); 91 | 92 | test('apiCreate initializes account correctly', () { 93 | final expiresAt = DateTime.now().toUtc().add(Duration(seconds: 5)); 94 | account = FirebaseAccount.apiCreate( 95 | mockApi, 96 | defaultSignInResponse, 97 | autoRefresh: false, 98 | locale: 'de-DE', 99 | ); 100 | 101 | expect(account.api, mockApi); 102 | expect(account.localId, 'localId'); 103 | expect(account.idToken, 'idToken'); 104 | expect(account.refreshToken, 'refreshToken'); 105 | expect(account.expiresAt.difference(expiresAt).inSeconds, 0); 106 | expect(account.autoRefresh, false); 107 | expect(account.locale, 'de-DE'); 108 | }); 109 | 110 | test('apiCreate starts refresh timer', () { 111 | final expiresAt = DateTime.now().toUtc().add(Duration(seconds: 5)); 112 | account = FirebaseAccount.apiCreate(mockApi, defaultSignInResponse); 113 | 114 | expect(account.autoRefresh, true); 115 | expect(account.expiresAt.difference(expiresAt).inSeconds, 0); 116 | }); 117 | 118 | test('create initializes api with correct client and key', () { 119 | const apiKey = 'API-KEY'; 120 | account = FirebaseAccount.create( 121 | mockClient, 122 | apiKey, 123 | defaultSignInResponse, 124 | ); 125 | 126 | expect(account.api.client, mockClient); 127 | expect(account.api.apiKey, apiKey); 128 | }); 129 | }); 130 | 131 | group('restore', () { 132 | final mockClient = MockClient(); 133 | 134 | setUp(() { 135 | reset(mockClient); 136 | 137 | when( 138 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 139 | ).thenAnswer((i) async => defaultRefreshResponse); 140 | }); 141 | 142 | test('apiRestore calls api.token with refreshToken', () async { 143 | const refreshToken = 'refreshToken'; 144 | 145 | // ignore: deprecated_member_use_from_same_package 146 | account = await FirebaseAccount.apiRestore( 147 | mockApi, 148 | refreshToken, 149 | autoRefresh: false, 150 | ); 151 | 152 | verify(() => mockApi.token(refresh_token: refreshToken)); 153 | }); 154 | 155 | test('apiRestore initializes account correctly', () async { 156 | const refreshToken1 = 'refreshToken1'; 157 | const refreshToken2 = 'refreshToken2'; 158 | when(() => mockApi.token(refresh_token: refreshToken1)).thenAnswer( 159 | (i) async => 160 | defaultRefreshResponse.copyWith(refresh_token: refreshToken2), 161 | ); 162 | 163 | final expiresAt = DateTime.now().toUtc().add(Duration(seconds: 5)); 164 | // ignore: deprecated_member_use_from_same_package 165 | account = await FirebaseAccount.apiRestore( 166 | mockApi, 167 | refreshToken1, 168 | autoRefresh: false, 169 | locale: 'de-DE', 170 | ); 171 | 172 | expect(account.api, mockApi); 173 | expect(account.localId, defaultRefreshResponse.user_id); 174 | expect(account.idToken, defaultRefreshResponse.id_token); 175 | expect(account.refreshToken, refreshToken2); 176 | expect(account.expiresAt.difference(expiresAt).inSeconds, 0); 177 | expect(account.autoRefresh, false); 178 | expect(account.locale, 'de-DE'); 179 | }); 180 | 181 | test('apiRestore starts refresh timer', () async { 182 | final expiresAt = DateTime.now().toUtc().add(Duration(seconds: 5)); 183 | // ignore: deprecated_member_use_from_same_package 184 | account = await FirebaseAccount.apiRestore(mockApi, 'refreshToken'); 185 | 186 | expect(account.autoRefresh, true); 187 | expect(account.expiresAt.difference(expiresAt).inSeconds, 0); 188 | }); 189 | }); 190 | 191 | group('autoRefresh', () { 192 | setUp(() { 193 | when( 194 | () => mockApi.token( 195 | refresh_token: any(named: 'refresh_token'), 196 | grant_type: any(named: 'grant_type'), 197 | ), 198 | ).thenAnswer((i) async => defaultRefreshResponse); 199 | }); 200 | 201 | test('does nothing if disabled', () async { 202 | account = FirebaseAccount.apiCreate( 203 | mockApi, 204 | defaultSignInResponse.copyWith(expiresIn: '61'), 205 | autoRefresh: false, 206 | ); 207 | 208 | await _wait(3); 209 | 210 | verifyZeroInteractions(mockApi); 211 | }); 212 | 213 | test('sends token request one minute before timeout', () async { 214 | account = FirebaseAccount.apiCreate( 215 | mockApi, 216 | defaultSignInResponse.copyWith(expiresIn: '62'), 217 | ); 218 | 219 | await _wait(3); 220 | 221 | verify(() => mockApi.token(refresh_token: 'refreshToken')).called(1); 222 | }); 223 | 224 | test('sends token request again after timeout', () async { 225 | when( 226 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 227 | ).thenAnswer( 228 | (i) async => defaultRefreshResponse.copyWith( 229 | refresh_token: 'refreshToken2', 230 | expires_in: '62', 231 | ), 232 | ); 233 | 234 | account = FirebaseAccount.apiCreate( 235 | mockApi, 236 | defaultSignInResponse.copyWith(expiresIn: '62'), 237 | ); 238 | 239 | await _wait(7); 240 | 241 | verify(() => mockApi.token(refresh_token: 'refreshToken')).called(1); 242 | verify(() => mockApi.token(refresh_token: 'refreshToken2')).called(2); 243 | }); 244 | 245 | test( 246 | 'sends token request immediately if timeout is below 60 seconds', 247 | () async { 248 | account = FirebaseAccount.apiCreate(mockApi, defaultSignInResponse); 249 | 250 | await _wait(1); 251 | 252 | verify(() => mockApi.token(refresh_token: 'refreshToken')).called(1); 253 | }, 254 | ); 255 | 256 | test('token update errors are streamed', () { 257 | when( 258 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 259 | ).thenThrow(AuthException(ErrorData())); 260 | 261 | account = FirebaseAccount.apiCreate(mockApi, defaultSignInResponse); 262 | 263 | expect(() => account.idTokenStream.first, throwsA(isA())); 264 | }); 265 | }); 266 | 267 | group('methods', () { 268 | setUp(() { 269 | account = FirebaseAccount.apiCreate( 270 | mockApi, 271 | defaultSignInResponse, 272 | autoRefresh: false, 273 | locale: 'ab-CD', 274 | ); 275 | }); 276 | 277 | group('refresh', () { 278 | test('updates all properties', () async { 279 | const idToken = 'id'; 280 | const refreshToken = 'refresh'; 281 | when( 282 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 283 | ).thenAnswer( 284 | (i) async => defaultRefreshResponse.copyWith( 285 | id_token: idToken, 286 | refresh_token: refreshToken, 287 | expires_in: '6000', 288 | ), 289 | ); 290 | 291 | final expiresAt = DateTime.now().toUtc().add(Duration(seconds: 6000)); 292 | final token = await account.refresh(); 293 | 294 | expect(token, idToken); 295 | expect(account.idToken, idToken); 296 | expect(account.refreshToken, refreshToken); 297 | expect(account.expiresAt.difference(expiresAt).inSeconds, 0); 298 | }); 299 | 300 | test('forwards auth exceptions', () { 301 | when( 302 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 303 | ).thenThrow(AuthException(ErrorData())); 304 | 305 | expect(() => account.refresh(), throwsA(isA())); 306 | }); 307 | 308 | test('token updates are streamed', () async { 309 | const idToken = 'nextId'; 310 | when( 311 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 312 | ).thenAnswer( 313 | (i) async => defaultRefreshResponse.copyWith( 314 | id_token: idToken, 315 | expires_in: '5', 316 | ), 317 | ); 318 | 319 | expect(account.idTokenStream.isBroadcast, true); 320 | 321 | final firstElement = account.idTokenStream.first; 322 | await account.refresh(); 323 | expect(await firstElement, idToken); 324 | }); 325 | }); 326 | 327 | testData<(String?, String)>( 328 | 'requestEmailConfirmation sends oobCode request', 329 | const [('ee-EE', 'ee-EE'), (null, 'ab-CD')], 330 | (fixture) async { 331 | when( 332 | () => mockApi.sendOobCode(any(), any()), 333 | ).thenAnswer((i) async => OobCodeResponse()); 334 | 335 | await account.requestEmailConfirmation(locale: fixture.$1); 336 | 337 | verify( 338 | () => mockApi.sendOobCode( 339 | OobCodeRequest.verifyEmail(idToken: 'idToken'), 340 | fixture.$2, 341 | ), 342 | ); 343 | }, 344 | ); 345 | 346 | test('confirmEmail sends confirm email request', () async { 347 | const code = 'code'; 348 | when( 349 | () => mockApi.confirmEmail(any()), 350 | ).thenAnswer((i) async => ConfirmEmailResponse()); 351 | 352 | await account.confirmEmail(code); 353 | 354 | verify(() => mockApi.confirmEmail(ConfirmEmailRequest(oobCode: code))); 355 | }); 356 | 357 | group('getDetails', () { 358 | test('sends user data request', () async { 359 | when( 360 | () => mockApi.getUserData(any()), 361 | ).thenAnswer((i) async => UserDataResponse(users: [])); 362 | 363 | final result = await account.getDetails(); 364 | 365 | verify(() => mockApi.getUserData(UserDataRequest(idToken: 'idToken'))); 366 | expect(result, null); 367 | }); 368 | 369 | test('returns first user data element', () async { 370 | final userData = defaultUserData.copyWith(displayName: 'Max Muster'); 371 | when(() => mockApi.getUserData(any())).thenAnswer( 372 | (i) async => UserDataResponse(users: [userData, defaultUserData]), 373 | ); 374 | 375 | final result = await account.getDetails(); 376 | expect(result, userData); 377 | }); 378 | }); 379 | 380 | testData<(String?, String)>( 381 | 'updateEmail sends email update request', 382 | const [('ee-EE', 'ee-EE'), (null, 'ab-CD')], 383 | (fixture) async { 384 | const newEmail = 'new@mail.de'; 385 | when( 386 | () => mockApi.updateEmail(any(), any()), 387 | ).thenAnswer((i) async => EmailUpdateResponse(localId: '')); 388 | 389 | await account.updateEmail(newEmail, locale: fixture.$1); 390 | 391 | verify( 392 | () => mockApi.updateEmail( 393 | EmailUpdateRequest(idToken: 'idToken', email: newEmail), 394 | fixture.$2, 395 | ), 396 | ); 397 | }, 398 | ); 399 | 400 | test('updatePassword sends password update request', () async { 401 | const newPassword = 'pw'; 402 | when( 403 | () => mockApi.updatePassword(any()), 404 | ).thenAnswer((i) async => PasswordUpdateResponse(localId: '')); 405 | 406 | await account.updatePassword(newPassword); 407 | 408 | verify( 409 | () => mockApi.updatePassword( 410 | PasswordUpdateRequest(idToken: 'idToken', password: newPassword), 411 | ), 412 | ); 413 | }); 414 | 415 | testData< 416 | ( 417 | ProfileUpdate?, 418 | ProfileUpdate?, 419 | String?, 420 | Uri?, 421 | List, 422 | ) 423 | >( 424 | 'updateProfile sends profile update', 425 | [ 426 | (null, null, null, null, const []), 427 | ( 428 | null, 429 | ProfileUpdate.update(Uri.parse('http://example.com/image.jpg')), 430 | null, 431 | Uri.parse('http://example.com/image.jpg'), 432 | const [], 433 | ), 434 | ( 435 | null, 436 | ProfileUpdate.delete(), 437 | null, 438 | null, 439 | const [DeleteAttribute.PHOTO_URL], 440 | ), 441 | (ProfileUpdate.update('name'), null, 'name', null, const []), 442 | ( 443 | ProfileUpdate.update('name'), 444 | ProfileUpdate.update(Uri.parse('http://example.com/image.jpg')), 445 | 'name', 446 | Uri.parse('http://example.com/image.jpg'), 447 | const [], 448 | ), 449 | ( 450 | ProfileUpdate.update('name'), 451 | ProfileUpdate.delete(), 452 | 'name', 453 | null, 454 | const [DeleteAttribute.PHOTO_URL], 455 | ), 456 | ( 457 | ProfileUpdate.delete(), 458 | null, 459 | null, 460 | null, 461 | const [DeleteAttribute.DISPLAY_NAME], 462 | ), 463 | ( 464 | ProfileUpdate.delete(), 465 | ProfileUpdate.update(Uri.parse('http://example.com/image.jpg')), 466 | null, 467 | Uri.parse('http://example.com/image.jpg'), 468 | const [DeleteAttribute.DISPLAY_NAME], 469 | ), 470 | ( 471 | ProfileUpdate.delete(), 472 | ProfileUpdate.delete(), 473 | null, 474 | null, 475 | const [ 476 | DeleteAttribute.DISPLAY_NAME, 477 | DeleteAttribute.PHOTO_URL, 478 | ], 479 | ), 480 | ], 481 | (fixture) async { 482 | when( 483 | () => mockApi.updateProfile(any()), 484 | ).thenAnswer((i) async => ProfileUpdateResponse(localId: '')); 485 | 486 | await account.updateProfile( 487 | displayName: fixture.$1, 488 | photoUrl: fixture.$2, 489 | ); 490 | 491 | verify( 492 | () => mockApi.updateProfile( 493 | ProfileUpdateRequest( 494 | idToken: 'idToken', 495 | displayName: fixture.$3, 496 | photoUrl: fixture.$4, 497 | deleteAttribute: fixture.$5, 498 | ), 499 | ), 500 | ); 501 | }, 502 | ); 503 | 504 | group('linkEmail', () { 505 | test('sends link email request', () async { 506 | when( 507 | () => mockApi.linkEmail(any()), 508 | ).thenAnswer((i) async => defaultLinkEmailResponse); 509 | when( 510 | () => mockApi.sendOobCode(any(), any()), 511 | ).thenAnswer((i) async => OobCodeResponse()); 512 | 513 | const mail = 'mail'; 514 | const password = 'password'; 515 | final result = await account.linkEmail( 516 | mail, 517 | password, 518 | autoVerify: false, 519 | ); 520 | 521 | verify( 522 | () => mockApi.linkEmail( 523 | LinkEmailRequest( 524 | idToken: 'idToken', 525 | email: mail, 526 | password: password, 527 | ), 528 | ), 529 | ); 530 | expect(result, false); 531 | }); 532 | 533 | test('returns email verified as result', () async { 534 | when(() => mockApi.linkEmail(any())).thenAnswer( 535 | (i) async => defaultLinkEmailResponse.copyWith(emailVerified: true), 536 | ); 537 | 538 | final result = await account.linkEmail( 539 | 'mail', 540 | 'password', 541 | autoVerify: false, 542 | ); 543 | 544 | expect(result, true); 545 | }); 546 | 547 | testData<(String?, String)>( 548 | 'requests email confirmation if not verified and enabled', 549 | const [('ee-EE', 'ee-EE'), (null, 'ab-CD')], 550 | (fixture) async { 551 | when( 552 | () => mockApi.linkEmail(any()), 553 | ).thenAnswer((i) async => defaultLinkEmailResponse); 554 | when( 555 | () => mockApi.sendOobCode(any(), any()), 556 | ).thenAnswer((i) async => OobCodeResponse()); 557 | 558 | final result = await account.linkEmail( 559 | 'mail', 560 | 'password', 561 | locale: fixture.$1, 562 | ); 563 | expect(result, false); 564 | 565 | verify( 566 | () => mockApi.sendOobCode( 567 | OobCodeRequest.verifyEmail(idToken: 'idToken'), 568 | fixture.$2, 569 | ), 570 | ); 571 | }, 572 | ); 573 | 574 | test( 575 | 'does not request email confirmation if verified and enabled', 576 | () async { 577 | when(() => mockApi.linkEmail(any())).thenAnswer( 578 | (i) async => defaultLinkEmailResponse.copyWith(emailVerified: true), 579 | ); 580 | 581 | final result = await account.linkEmail('mail', 'password'); 582 | expect(result, true); 583 | 584 | verifyNever(() => mockApi.sendOobCode(any())); 585 | }, 586 | ); 587 | }); 588 | 589 | test('linkIdp sends link idp request', () async { 590 | when( 591 | () => mockApi.linkIdp(any()), 592 | ).thenAnswer((i) async => defaultLinkIdpResponse); 593 | 594 | final provider = IdpProvider.google('token'); 595 | final uri = Uri.parse('https://localhost:4242'); 596 | await account.linkIdp(provider, uri); 597 | 598 | verify( 599 | () => mockApi.linkIdp( 600 | LinkIdpRequest( 601 | idToken: 'idToken', 602 | postBody: provider.postBody, 603 | requestUri: uri, 604 | ), 605 | ), 606 | ); 607 | }); 608 | 609 | test('unlinkProvider sends unlink request', () async { 610 | when( 611 | () => mockApi.unlinkProvider(any()), 612 | ).thenAnswer((i) async => UnlinkResponse(localId: '')); 613 | 614 | const providers = ['a', 'b']; 615 | await account.unlinkProviders(providers); 616 | 617 | verify( 618 | () => mockApi.unlinkProvider( 619 | UnlinkRequest(idToken: 'idToken', deleteProvider: providers), 620 | ), 621 | ); 622 | }); 623 | 624 | group('delete', () { 625 | test('sends delete request', () async { 626 | when(() => mockApi.delete(any())).thenAnswer((i) async {}); 627 | 628 | await account.delete(); 629 | 630 | verify(() => mockApi.delete(DeleteRequest(idToken: 'idToken'))); 631 | }); 632 | 633 | test('sends null to idToken stream', () async { 634 | when(() => mockApi.delete(any())).thenAnswer((i) async {}); 635 | 636 | expect(account.idTokenStream.isEmpty, completion(true)); 637 | await account.delete(); 638 | }); 639 | }); 640 | 641 | test('dispose disables auto refresh and clears controller', () async { 642 | account.autoRefresh = true; 643 | await account.dispose(); 644 | 645 | expect(account.autoRefresh, false); 646 | expect(await account.idTokenStream.length, 0); 647 | }); 648 | }); 649 | } 650 | 651 | Future _wait(int seconds) => 652 | Future.delayed(Duration(seconds: seconds)); 653 | -------------------------------------------------------------------------------- /test/unit/firebase_auth_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors, discarded_futures 2 | import 'package:dart_test_tools/test.dart'; 3 | import 'package:firebase_auth_rest/src/firebase_account.dart'; 4 | import 'package:firebase_auth_rest/src/firebase_auth.dart'; 5 | import 'package:firebase_auth_rest/src/models/fetch_provider_request.dart'; 6 | import 'package:firebase_auth_rest/src/models/fetch_provider_response.dart'; 7 | import 'package:firebase_auth_rest/src/models/idp_provider.dart'; 8 | import 'package:firebase_auth_rest/src/models/oob_code_request.dart'; 9 | import 'package:firebase_auth_rest/src/models/oob_code_response.dart'; 10 | import 'package:firebase_auth_rest/src/models/password_reset_request.dart'; 11 | import 'package:firebase_auth_rest/src/models/password_reset_response.dart'; 12 | import 'package:firebase_auth_rest/src/models/refresh_response.dart'; 13 | import 'package:firebase_auth_rest/src/models/signin_request.dart'; 14 | import 'package:firebase_auth_rest/src/models/signin_response.dart'; 15 | import 'package:firebase_auth_rest/src/rest_api.dart'; 16 | import 'package:http/http.dart'; 17 | import 'package:mocktail/mocktail.dart'; 18 | import 'package:test/test.dart'; 19 | 20 | class MockClient extends Mock implements Client {} 21 | 22 | class MockRestApi extends Mock implements RestApi {} 23 | 24 | void main() { 25 | final mockApi = MockRestApi(); 26 | 27 | setUpAll(() { 28 | registerFallbackValue(Uri()); 29 | registerFallbackValue( 30 | FetchProviderRequest(identifier: '', continueUri: Uri()), 31 | ); 32 | registerFallbackValue(const AnonymousSignInRequest()); 33 | registerFallbackValue(const OobCodeRequest.verifyEmail(idToken: '')); 34 | registerFallbackValue(IdpSignInRequest(requestUri: Uri(), postBody: '')); 35 | registerFallbackValue(const PasswordSignInRequest(email: '', password: '')); 36 | registerFallbackValue(const CustomTokenSignInRequest(token: '')); 37 | registerFallbackValue(const PasswordResetRequest.verify(oobCode: '')); 38 | }); 39 | 40 | setUp(() { 41 | reset(mockApi); 42 | }); 43 | 44 | test('constructor sets api and locale correctly', () { 45 | final mockClient = MockClient(); 46 | const apiKey = 'key'; 47 | const locale = 'locale'; 48 | 49 | final auth = FirebaseAuth(mockClient, apiKey, locale); 50 | 51 | expect(auth.api, isNotNull); 52 | expect(auth.api.client, mockClient); 53 | expect(auth.api.apiKey, apiKey); 54 | expect(auth.locale, locale); 55 | }); 56 | 57 | test('Api constructor sets api and locale correctly', () { 58 | const locale = 'locale'; 59 | final auth = FirebaseAuth.api(mockApi, locale); 60 | 61 | expect(auth.api, mockApi); 62 | expect(auth.locale, locale); 63 | }); 64 | 65 | group('methods:', () { 66 | const idToken = 'id'; 67 | const localId = 'local'; 68 | const expiresIn = '60'; 69 | const refreshToken = 'refresh'; 70 | 71 | late FirebaseAuth auth; 72 | FirebaseAccount? account; 73 | 74 | setUp(() { 75 | auth = FirebaseAuth.api(mockApi, 'ab-CD'); 76 | }); 77 | 78 | tearDown(() async { 79 | await account?.dispose(); 80 | account = null; 81 | }); 82 | 83 | group('fetchProviders', () { 84 | testData<(Uri?, Uri)>( 85 | 'sends fetch providers request', 86 | [ 87 | (null, Uri.parse('http://localhost')), 88 | (Uri.parse('http://example.com'), Uri.parse('http://example.com')), 89 | ], 90 | (fixture) async { 91 | when( 92 | () => mockApi.fetchProviders(any()), 93 | ).thenAnswer((i) async => FetchProviderResponse(allProviders: [])); 94 | 95 | const mail = 'mail'; 96 | await auth.fetchProviders(mail, fixture.$1); 97 | 98 | verify( 99 | () => mockApi.fetchProviders( 100 | FetchProviderRequest(identifier: mail, continueUri: fixture.$2), 101 | ), 102 | ); 103 | }, 104 | ); 105 | 106 | testData<(bool, List)>( 107 | 'returns providers with mail', 108 | const [ 109 | (false, ['a', 'b', 'c']), 110 | (true, ['email', 'a', 'b', 'c']), 111 | ], 112 | (fixture) async { 113 | const providers = ['a', 'b', 'c']; 114 | when(() => mockApi.fetchProviders(any())).thenAnswer( 115 | (i) async => FetchProviderResponse( 116 | registered: fixture.$1, 117 | allProviders: providers, 118 | ), 119 | ); 120 | 121 | final result = await auth.fetchProviders('email'); 122 | expect(result, fixture.$2); 123 | }, 124 | ); 125 | }); 126 | 127 | group('signUpAnonymous', () { 128 | test('sends anonymous sign up request', () async { 129 | when(() => mockApi.signUpAnonymous(any())).thenAnswer( 130 | (i) async => AnonymousSignInResponse( 131 | idToken: '', 132 | localId: '', 133 | expiresIn: '1', 134 | refreshToken: '', 135 | ), 136 | ); 137 | 138 | account = await auth.signUpAnonymous(); 139 | 140 | verify(() => mockApi.signUpAnonymous(AnonymousSignInRequest())); 141 | }); 142 | 143 | test('creates account from reply', () async { 144 | when(() => mockApi.signUpAnonymous(any())).thenAnswer( 145 | (i) async => AnonymousSignInResponse( 146 | idToken: idToken, 147 | localId: localId, 148 | expiresIn: expiresIn, 149 | refreshToken: refreshToken, 150 | ), 151 | ); 152 | 153 | final expiresAt = DateTime.now().toUtc().add( 154 | const Duration(minutes: 1), 155 | ); 156 | account = await auth.signUpAnonymous(autoRefresh: false); 157 | 158 | expect(account!.localId, localId); 159 | expect(account!.idToken, idToken); 160 | expect(account!.refreshToken, refreshToken); 161 | expect(account!.autoRefresh, false); 162 | expect(account!.expiresAt.difference(expiresAt).inSeconds, 0); 163 | }); 164 | }); 165 | 166 | group('signUpWithPassword', () { 167 | setUp(() { 168 | when( 169 | () => mockApi.sendOobCode(any(), any()), 170 | ).thenAnswer((i) async => const OobCodeResponse()); 171 | }); 172 | 173 | test('sends password sign up request', () async { 174 | const mail = 'mail'; 175 | const password = 'password'; 176 | when(() => mockApi.signUpWithPassword(any())).thenAnswer( 177 | (i) async => PasswordSignInResponse( 178 | idToken: '', 179 | localId: '', 180 | expiresIn: '1', 181 | refreshToken: '', 182 | ), 183 | ); 184 | 185 | account = await auth.signUpWithPassword( 186 | mail, 187 | password, 188 | autoVerify: false, 189 | ); 190 | 191 | verify( 192 | () => mockApi.signUpWithPassword( 193 | PasswordSignInRequest(email: mail, password: password), 194 | ), 195 | ); 196 | verifyNoMoreInteractions(mockApi); 197 | }); 198 | 199 | testData<(String?, String)>( 200 | 'sends oob code request if enabled', 201 | const [('ee-EE', 'ee-EE'), (null, 'ab-CD')], 202 | (fixture) async { 203 | when(() => mockApi.signUpWithPassword(any())).thenAnswer( 204 | (i) async => PasswordSignInResponse( 205 | idToken: idToken, 206 | localId: '', 207 | expiresIn: '1', 208 | refreshToken: '', 209 | ), 210 | ); 211 | 212 | account = await auth.signUpWithPassword( 213 | 'email', 214 | 'password', 215 | autoRefresh: false, 216 | locale: fixture.$1, 217 | ); 218 | 219 | verify( 220 | () => mockApi.sendOobCode( 221 | OobCodeRequest.verifyEmail(idToken: idToken), 222 | fixture.$2, 223 | ), 224 | ); 225 | }, 226 | ); 227 | 228 | test('creates account from reply', () async { 229 | when(() => mockApi.signUpWithPassword(any())).thenAnswer( 230 | (i) async => PasswordSignInResponse( 231 | idToken: idToken, 232 | localId: localId, 233 | expiresIn: expiresIn, 234 | refreshToken: refreshToken, 235 | ), 236 | ); 237 | 238 | final expiresAt = DateTime.now().toUtc().add( 239 | const Duration(minutes: 1), 240 | ); 241 | account = await auth.signUpWithPassword( 242 | 'email', 243 | 'password', 244 | autoVerify: false, 245 | autoRefresh: false, 246 | ); 247 | 248 | expect(account!.localId, localId); 249 | expect(account!.idToken, idToken); 250 | expect(account!.refreshToken, refreshToken); 251 | expect(account!.autoRefresh, false); 252 | expect(account!.expiresAt.difference(expiresAt).inSeconds, 0); 253 | }); 254 | }); 255 | 256 | group('signInWithIdp', () { 257 | test('sends idp sign in request', () async { 258 | final provider = IdpProvider.google('token'); 259 | final uri = Uri.parse('http://localhost'); 260 | when(() => mockApi.signInWithIdp(any())).thenAnswer( 261 | (i) async => IdpSignInResponse( 262 | idToken: '', 263 | localId: '', 264 | expiresIn: '1', 265 | refreshToken: '', 266 | federatedId: '', 267 | providerId: '', 268 | ), 269 | ); 270 | 271 | account = await auth.signInWithIdp(provider, uri); 272 | 273 | verify( 274 | () => mockApi.signInWithIdp( 275 | IdpSignInRequest(postBody: provider.postBody, requestUri: uri), 276 | ), 277 | ); 278 | }); 279 | 280 | test('creates account from reply', () async { 281 | when(() => mockApi.signInWithIdp(any())).thenAnswer( 282 | (i) async => IdpSignInResponse( 283 | idToken: idToken, 284 | localId: localId, 285 | expiresIn: expiresIn, 286 | refreshToken: refreshToken, 287 | federatedId: '', 288 | providerId: '', 289 | ), 290 | ); 291 | 292 | final expiresAt = DateTime.now().toUtc().add( 293 | const Duration(minutes: 1), 294 | ); 295 | account = await auth.signInWithIdp( 296 | IdpProvider.google('idToken'), 297 | Uri(), 298 | autoRefresh: false, 299 | ); 300 | 301 | expect(account!.localId, localId); 302 | expect(account!.idToken, idToken); 303 | expect(account!.refreshToken, refreshToken); 304 | expect(account!.autoRefresh, false); 305 | expect(account!.expiresAt.difference(expiresAt).inSeconds, 0); 306 | }); 307 | }); 308 | 309 | group('signInWithPassword', () { 310 | test('sends password sign in request', () async { 311 | const mail = 'mail'; 312 | const password = 'password'; 313 | when(() => mockApi.signInWithPassword(any())).thenAnswer( 314 | (i) async => PasswordSignInResponse( 315 | idToken: '', 316 | localId: '', 317 | expiresIn: '1', 318 | refreshToken: '', 319 | ), 320 | ); 321 | 322 | account = await auth.signInWithPassword(mail, password); 323 | 324 | verify( 325 | () => mockApi.signInWithPassword( 326 | PasswordSignInRequest(email: mail, password: password), 327 | ), 328 | ); 329 | verifyNoMoreInteractions(mockApi); 330 | }); 331 | 332 | test('creates account from reply', () async { 333 | when(() => mockApi.signInWithPassword(any())).thenAnswer( 334 | (i) async => PasswordSignInResponse( 335 | idToken: idToken, 336 | localId: localId, 337 | expiresIn: expiresIn, 338 | refreshToken: refreshToken, 339 | ), 340 | ); 341 | 342 | final expiresAt = DateTime.now().toUtc().add( 343 | const Duration(minutes: 1), 344 | ); 345 | account = await auth.signInWithPassword( 346 | 'email', 347 | 'password', 348 | autoRefresh: false, 349 | ); 350 | 351 | expect(account!.localId, localId); 352 | expect(account!.idToken, idToken); 353 | expect(account!.refreshToken, refreshToken); 354 | expect(account!.autoRefresh, false); 355 | expect(account!.expiresAt.difference(expiresAt).inSeconds, 0); 356 | }); 357 | }); 358 | 359 | group('signInWithCustomToken', () { 360 | test('sends custom tken sign in request', () async { 361 | const token = 'token'; 362 | when(() => mockApi.signInWithCustomToken(any())).thenAnswer( 363 | (i) async => CustomTokenSignInResponse( 364 | idToken: '', 365 | expiresIn: '1', 366 | refreshToken: '', 367 | ), 368 | ); 369 | 370 | account = await auth.signInWithCustomToken(token); 371 | 372 | verify( 373 | () => mockApi.signInWithCustomToken( 374 | CustomTokenSignInRequest(token: token), 375 | ), 376 | ); 377 | }); 378 | 379 | test('creates account from reply', () async { 380 | when(() => mockApi.signInWithCustomToken(any())).thenAnswer( 381 | (i) async => CustomTokenSignInResponse( 382 | idToken: idToken, 383 | expiresIn: expiresIn, 384 | refreshToken: refreshToken, 385 | ), 386 | ); 387 | 388 | final expiresAt = DateTime.now().toUtc().add( 389 | const Duration(minutes: 1), 390 | ); 391 | account = await auth.signInWithCustomToken('token', autoRefresh: false); 392 | 393 | expect(account!.localId, isEmpty); 394 | expect(account!.idToken, idToken); 395 | expect(account!.refreshToken, refreshToken); 396 | expect(account!.autoRefresh, false); 397 | expect(account!.expiresAt.difference(expiresAt).inSeconds, 0); 398 | }); 399 | }); 400 | 401 | testData<(String?, String)>( 402 | 'requestPasswordReset sends oob code request', 403 | const [('ee-EE', 'ee-EE'), (null, 'ab-CD')], 404 | (fixture) async { 405 | when( 406 | () => mockApi.sendOobCode(any(), any()), 407 | ).thenAnswer((i) async => const OobCodeResponse()); 408 | const mail = 'email'; 409 | await auth.requestPasswordReset(mail, locale: fixture.$1); 410 | 411 | verify( 412 | () => mockApi.sendOobCode( 413 | OobCodeRequest.passwordReset(email: mail), 414 | fixture.$2, 415 | ), 416 | ); 417 | }, 418 | ); 419 | 420 | test('validatePasswordReset send reset password request', () async { 421 | when( 422 | () => mockApi.resetPassword(any()), 423 | ).thenAnswer((i) async => const PasswordResetResponse()); 424 | const code = 'oob-code'; 425 | await auth.validatePasswordReset(code); 426 | 427 | verify( 428 | () => mockApi.resetPassword(PasswordResetRequest.verify(oobCode: code)), 429 | ); 430 | }); 431 | 432 | test('resetPassword send reset password request', () async { 433 | when( 434 | () => mockApi.resetPassword(any()), 435 | ).thenAnswer((i) async => const PasswordResetResponse()); 436 | const code = 'oob-code'; 437 | const password = 'password'; 438 | await auth.resetPassword(code, password); 439 | 440 | verify( 441 | () => mockApi.resetPassword( 442 | PasswordResetRequest.confirm(oobCode: code, newPassword: password), 443 | ), 444 | ); 445 | }); 446 | 447 | test('restoreAccount restores account with api', () async { 448 | when( 449 | () => mockApi.token(refresh_token: any(named: 'refresh_token')), 450 | ).thenAnswer( 451 | (i) async => RefreshResponse( 452 | expires_in: '5', 453 | token_type: 'token_type', 454 | refresh_token: 'refresh_token', 455 | id_token: 'id_token', 456 | user_id: 'user_id', 457 | project_id: 'project_id', 458 | ), 459 | ); 460 | 461 | account = await auth.restoreAccount(refreshToken, autoRefresh: false); 462 | 463 | expect(account, isNotNull); 464 | expect(account!.api, mockApi); 465 | verify(() => mockApi.token(refresh_token: refreshToken)); 466 | }); 467 | }); 468 | } 469 | -------------------------------------------------------------------------------- /test/unit/models/idp_provider_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors 2 | import 'package:dart_test_tools/test.dart'; 3 | import 'package:firebase_auth_rest/src/models/idp_provider.dart'; 4 | import 'package:test/test.dart'; 5 | 6 | void main() { 7 | testData<(IdpProvider, String, String)>( 8 | 'IdpProvider returns correct id and postBody', 9 | [ 10 | ( 11 | IdpProvider.google('idToken'), 12 | 'google.com', 13 | 'id_token=idToken&providerId=google.com', 14 | ), 15 | ( 16 | IdpProvider.facebook('accessToken'), 17 | 'facebook.com', 18 | 'access_token=accessToken&providerId=facebook.com', 19 | ), 20 | ( 21 | IdpProvider.twitter( 22 | accessToken: 'accessToken', 23 | oauthTokenSecret: 'oauthTokenSecret', 24 | ), 25 | 'twitter.com', 26 | 'access_token=accessToken' 27 | '&oauth_token_secret=oauthTokenSecret' 28 | '&providerId=twitter.com', 29 | ), 30 | ( 31 | IdpProvider.custom( 32 | providerId: 'custom', 33 | parameters: {'a': 'b'}, 34 | ), 35 | 'custom', 36 | 'a=b&providerId=custom', 37 | ), 38 | ], 39 | (fixture) { 40 | expect(fixture.$1.id, fixture.$2); 41 | expect(fixture.$1.postBody, fixture.$3); 42 | }, 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /test/unit/profile_update_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors 2 | import 'package:firebase_auth_rest/src/profile_update.dart'; 3 | import 'package:test/test.dart'; 4 | 5 | void main() { 6 | test('update works as expected', () { 7 | final update = ProfileUpdate.update(42); 8 | expect(update.isUpdate, true); 9 | expect(update.isDelete, false); 10 | expect(update.data, 42); 11 | expect(update.updateOr(55), 42); 12 | }); 13 | 14 | test('delete works as expected', () { 15 | final update = ProfileUpdate.delete(); 16 | expect(update.isUpdate, false); 17 | expect(update.isDelete, true); 18 | expect(update.data, null); 19 | expect(update.updateOr(), null); 20 | expect(update.updateOr(55), 55); 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /test/unit/rest_api_test.dart: -------------------------------------------------------------------------------- 1 | // ignore_for_file: prefer_const_constructors, discarded_futures 2 | import 'dart:convert'; 3 | 4 | import 'package:firebase_auth_rest/src/models/auth_exception.dart'; 5 | import 'package:firebase_auth_rest/src/models/delete_request.dart'; 6 | import 'package:firebase_auth_rest/src/models/emulator_config.dart'; 7 | import 'package:firebase_auth_rest/src/models/fetch_provider_request.dart'; 8 | import 'package:firebase_auth_rest/src/models/fetch_provider_response.dart'; 9 | import 'package:firebase_auth_rest/src/models/oob_code_request.dart'; 10 | import 'package:firebase_auth_rest/src/models/oob_code_response.dart'; 11 | import 'package:firebase_auth_rest/src/models/password_reset_request.dart'; 12 | import 'package:firebase_auth_rest/src/models/password_reset_response.dart'; 13 | import 'package:firebase_auth_rest/src/models/refresh_response.dart'; 14 | import 'package:firebase_auth_rest/src/models/signin_request.dart'; 15 | import 'package:firebase_auth_rest/src/models/signin_response.dart'; 16 | import 'package:firebase_auth_rest/src/models/update_request.dart'; 17 | import 'package:firebase_auth_rest/src/models/update_response.dart'; 18 | import 'package:firebase_auth_rest/src/models/userdata_request.dart'; 19 | import 'package:firebase_auth_rest/src/models/userdata_response.dart'; 20 | import 'package:firebase_auth_rest/src/rest_api.dart'; 21 | import 'package:http/http.dart'; 22 | import 'package:mocktail/mocktail.dart'; 23 | import 'package:test/test.dart'; 24 | 25 | import 'fakes.dart'; 26 | 27 | class MockClient extends Mock implements Client {} 28 | 29 | extension FakeResponseX on When> { 30 | void thenFake(T model, [Map? overwrites]) => 31 | thenAnswer((i) async => FakeResponse.forModel(model, overwrites)); 32 | } 33 | 34 | void main() { 35 | const apiKey = 'apiKey'; 36 | final mockClient = MockClient(); 37 | final api = RestApi(mockClient, apiKey); 38 | 39 | When> whenPost() => when( 40 | () => mockClient.post( 41 | any(), 42 | body: any(named: 'body'), 43 | headers: any(named: 'headers'), 44 | encoding: any(named: 'encoding'), 45 | ), 46 | ); 47 | 48 | void whenError() => 49 | whenPost().thenAnswer((i) => Future.value(FakeResponse(statusCode: 400))); 50 | 51 | setUpAll(() { 52 | registerFallbackValue(Uri()); 53 | }); 54 | 55 | setUp(() { 56 | reset(mockClient); 57 | whenPost().thenAnswer((i) async => FakeResponse()); 58 | }); 59 | 60 | test('Constructor initializes properties as expected', () { 61 | expect(api.client, mockClient); 62 | expect(api.apiKey, apiKey); 63 | }); 64 | 65 | group('token', () { 66 | test('should send a post request with correct data', () async { 67 | whenPost().thenFake( 68 | const RefreshResponse( 69 | expires_in: '', 70 | token_type: '', 71 | refresh_token: '', 72 | id_token: '', 73 | user_id: '', 74 | project_id: '', 75 | ), 76 | ); 77 | 78 | const token = 'token'; 79 | await api.token(refresh_token: token); 80 | verify( 81 | () => mockClient.post( 82 | Uri.parse('https://securetoken.googleapis.com/v1/token?key=apiKey'), 83 | headers: { 84 | 'Accept': 'application/json', 85 | 'Content-Type': 'application/x-www-form-urlencoded', 86 | }, 87 | body: {'refresh_token': token, 'grant_type': 'refresh_token'}, 88 | ), 89 | ); 90 | }); 91 | 92 | test('should throw AuthException on failure', () { 93 | whenError(); 94 | expect( 95 | () => api.token(refresh_token: 'token'), 96 | throwsA(isA()), 97 | ); 98 | }); 99 | }); 100 | 101 | group('signUpAnonymous', () { 102 | test('should send a post request with correct data', () async { 103 | whenPost().thenFake( 104 | AnonymousSignInResponse( 105 | idToken: '', 106 | refreshToken: '', 107 | expiresIn: '', 108 | localId: '', 109 | ), 110 | ); 111 | 112 | await api.signUpAnonymous(AnonymousSignInRequest()); 113 | verify( 114 | () => mockClient.post( 115 | Uri.parse( 116 | 'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=apiKey', 117 | ), 118 | body: json.encode({'returnSecureToken': true}), 119 | headers: { 120 | 'Accept': 'application/json', 121 | 'Content-Type': 'application/json', 122 | }, 123 | ), 124 | ); 125 | }); 126 | 127 | test('should throw AuthException on failure', () { 128 | whenError(); 129 | expect( 130 | () => api.signUpAnonymous(AnonymousSignInRequest()), 131 | throwsA(isA()), 132 | ); 133 | }); 134 | }); 135 | 136 | group('signUpWithPassword', () { 137 | test('should send a post request with correct data', () async { 138 | whenPost().thenFake( 139 | const PasswordSignInResponse( 140 | idToken: '', 141 | refreshToken: '', 142 | expiresIn: '', 143 | localId: '', 144 | ), 145 | ); 146 | 147 | await api.signUpWithPassword( 148 | PasswordSignInRequest(email: 'email', password: 'password'), 149 | ); 150 | verify( 151 | () => mockClient.post( 152 | Uri.parse( 153 | 'https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=apiKey', 154 | ), 155 | body: json.encode({ 156 | 'email': 'email', 157 | 'password': 'password', 158 | 'returnSecureToken': true, 159 | }), 160 | headers: { 161 | 'Accept': 'application/json', 162 | 'Content-Type': 'application/json', 163 | }, 164 | ), 165 | ); 166 | }); 167 | 168 | test('should throw AuthException on failure', () { 169 | whenError(); 170 | expect( 171 | () => api.signUpWithPassword( 172 | PasswordSignInRequest(email: '', password: ''), 173 | ), 174 | throwsA(isA()), 175 | ); 176 | }); 177 | }); 178 | 179 | group('signInWithIdp', () { 180 | test('should send a post request with correct data', () async { 181 | whenPost().thenFake( 182 | const IdpSignInResponse( 183 | federatedId: '', 184 | providerId: '', 185 | localId: '', 186 | idToken: '', 187 | refreshToken: '', 188 | expiresIn: '', 189 | ), 190 | ); 191 | 192 | await api.signInWithIdp( 193 | IdpSignInRequest( 194 | requestUri: Uri.parse('http://localhost'), 195 | postBody: 'postBody', 196 | ), 197 | ); 198 | verify( 199 | () => mockClient.post( 200 | Uri.parse( 201 | 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=apiKey', 202 | ), 203 | body: json.encode({ 204 | 'requestUri': 'http://localhost', 205 | 'postBody': 'postBody', 206 | 'returnSecureToken': true, 207 | 'returnIdpCredential': false, 208 | }), 209 | headers: { 210 | 'Accept': 'application/json', 211 | 'Content-Type': 'application/json', 212 | }, 213 | ), 214 | ); 215 | }); 216 | 217 | test('should throw AuthException on failure', () { 218 | whenError(); 219 | expect( 220 | () => api.signInWithIdp( 221 | IdpSignInRequest(requestUri: Uri(), postBody: 'postBody'), 222 | ), 223 | throwsA(isA()), 224 | ); 225 | }); 226 | }); 227 | 228 | group('signInWithPassword', () { 229 | test('should send a post request with correct data', () async { 230 | whenPost().thenFake( 231 | const PasswordSignInResponse( 232 | idToken: '', 233 | refreshToken: '', 234 | expiresIn: '', 235 | localId: '', 236 | ), 237 | ); 238 | 239 | await api.signInWithPassword( 240 | const PasswordSignInRequest(email: 'email', password: 'password'), 241 | ); 242 | verify( 243 | () => mockClient.post( 244 | Uri.parse( 245 | 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=apiKey', 246 | ), 247 | body: json.encode({ 248 | 'email': 'email', 249 | 'password': 'password', 250 | 'returnSecureToken': true, 251 | }), 252 | headers: { 253 | 'Accept': 'application/json', 254 | 'Content-Type': 'application/json', 255 | }, 256 | ), 257 | ); 258 | }); 259 | 260 | test('should throw AuthException on failure', () { 261 | whenError(); 262 | expect( 263 | () => api.signInWithPassword( 264 | const PasswordSignInRequest(email: '', password: ''), 265 | ), 266 | throwsA(isA()), 267 | ); 268 | }); 269 | }); 270 | 271 | group('signInWithCustomToken', () { 272 | test('should send a post request with correct data', () async { 273 | whenPost().thenFake( 274 | const CustomTokenSignInResponse( 275 | idToken: '', 276 | refreshToken: '', 277 | expiresIn: '', 278 | ), 279 | ); 280 | 281 | await api.signInWithCustomToken( 282 | const CustomTokenSignInRequest(token: 'token'), 283 | ); 284 | verify( 285 | () => mockClient.post( 286 | Uri.parse( 287 | 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithCustomToken?key=apiKey', 288 | ), 289 | body: json.encode({'token': 'token', 'returnSecureToken': true}), 290 | headers: { 291 | 'Accept': 'application/json', 292 | 'Content-Type': 'application/json', 293 | }, 294 | ), 295 | ); 296 | }); 297 | 298 | test('should throw AuthException on failure', () { 299 | whenError(); 300 | expect( 301 | () => api.signInWithCustomToken( 302 | const CustomTokenSignInRequest(token: 'token'), 303 | ), 304 | throwsA(isA()), 305 | ); 306 | }); 307 | }); 308 | 309 | group('getUserData', () { 310 | test('should send a post request with correct data', () async { 311 | whenPost().thenFake(const UserDataResponse()); 312 | 313 | await api.getUserData(UserDataRequest(idToken: 'idToken')); 314 | verify( 315 | () => mockClient.post( 316 | Uri.parse( 317 | 'https://identitytoolkit.googleapis.com/v1/accounts:lookup?key=apiKey', 318 | ), 319 | body: json.encode({'idToken': 'idToken'}), 320 | headers: { 321 | 'Accept': 'application/json', 322 | 'Content-Type': 'application/json', 323 | }, 324 | ), 325 | ); 326 | }); 327 | 328 | test('should throw AuthException on failure', () { 329 | whenError(); 330 | expect( 331 | () => api.getUserData(UserDataRequest(idToken: 'idToken')), 332 | throwsA(isA()), 333 | ); 334 | }); 335 | }); 336 | 337 | group('updateEmail', () { 338 | test('should send a post request with correct data', () async { 339 | whenPost().thenFake(const EmailUpdateResponse(localId: '')); 340 | 341 | await api.updateEmail( 342 | const EmailUpdateRequest(idToken: 'token', email: 'mail'), 343 | 'de-DE', 344 | ); 345 | verify( 346 | () => mockClient.post( 347 | Uri.parse( 348 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 349 | ), 350 | body: json.encode({ 351 | 'idToken': 'token', 352 | 'email': 'mail', 353 | 'returnSecureToken': false, 354 | }), 355 | headers: { 356 | 'X-Firebase-Locale': 'de-DE', 357 | 'Accept': 'application/json', 358 | 'Content-Type': 'application/json', 359 | }, 360 | ), 361 | ); 362 | }); 363 | 364 | test('should throw AuthException on failure', () { 365 | whenError(); 366 | expect( 367 | () => api.updateEmail( 368 | const EmailUpdateRequest(idToken: 'token', email: 'mail'), 369 | ), 370 | throwsA(isA()), 371 | ); 372 | }); 373 | }); 374 | 375 | group('updatePassword', () { 376 | test('should send a post request with correct data', () async { 377 | whenPost().thenFake(const PasswordUpdateResponse(localId: '')); 378 | 379 | await api.updatePassword( 380 | const PasswordUpdateRequest(idToken: 'token', password: 'password'), 381 | ); 382 | verify( 383 | () => mockClient.post( 384 | Uri.parse( 385 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 386 | ), 387 | body: json.encode({ 388 | 'idToken': 'token', 389 | 'password': 'password', 390 | 'returnSecureToken': true, 391 | }), 392 | headers: { 393 | 'Accept': 'application/json', 394 | 'Content-Type': 'application/json', 395 | }, 396 | ), 397 | ); 398 | }); 399 | 400 | test('should throw AuthException on failure', () { 401 | whenError(); 402 | expect( 403 | () => api.updatePassword( 404 | const PasswordUpdateRequest(idToken: 'token', password: 'password'), 405 | ), 406 | throwsA(isA()), 407 | ); 408 | }); 409 | }); 410 | 411 | group('updateProfile', () { 412 | test('should send a post request with correct data', () async { 413 | whenPost().thenFake(const ProfileUpdateResponse(localId: '')); 414 | 415 | await api.updateProfile( 416 | ProfileUpdateRequest( 417 | idToken: 'token', 418 | displayName: 'name', 419 | photoUrl: Uri.http('example.com'), 420 | ), 421 | ); 422 | verify( 423 | () => mockClient.post( 424 | Uri.parse( 425 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 426 | ), 427 | body: json.encode({ 428 | 'idToken': 'token', 429 | 'displayName': 'name', 430 | 'photoUrl': 'http://example.com', 431 | 'deleteAttribute': const [], 432 | 'returnSecureToken': false, 433 | }), 434 | headers: { 435 | 'Accept': 'application/json', 436 | 'Content-Type': 'application/json', 437 | }, 438 | ), 439 | ); 440 | }); 441 | 442 | test('should throw AuthException on failure', () { 443 | whenError(); 444 | expect( 445 | () => api.updateProfile(const ProfileUpdateRequest(idToken: 'token')), 446 | throwsA(isA()), 447 | ); 448 | }); 449 | }); 450 | 451 | group('sendOobCode', () { 452 | group('verifyEmail', () { 453 | test('should send a post request with correct data', () async { 454 | whenPost().thenFake(const OobCodeResponse()); 455 | 456 | await api.sendOobCode( 457 | const OobCodeRequest.verifyEmail(idToken: 'token'), 458 | 'de-DE', 459 | ); 460 | verify( 461 | () => mockClient.post( 462 | Uri.parse( 463 | 'https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=apiKey', 464 | ), 465 | body: json.encode({ 466 | 'idToken': 'token', 467 | 'requestType': 'VERIFY_EMAIL', 468 | }), 469 | headers: { 470 | 'Accept': 'application/json', 471 | 'Content-Type': 'application/json', 472 | 'X-Firebase-Locale': 'de-DE', 473 | }, 474 | ), 475 | ); 476 | }); 477 | 478 | test('should throw AuthException on failure', () { 479 | whenError(); 480 | 481 | expect( 482 | () => api.sendOobCode( 483 | const OobCodeRequest.verifyEmail(idToken: 'token'), 484 | ), 485 | throwsA(isA()), 486 | ); 487 | }); 488 | }); 489 | 490 | group('passwordReset', () { 491 | test('should send a post request with correct data', () async { 492 | whenPost().thenFake(const OobCodeResponse()); 493 | 494 | await api.sendOobCode( 495 | const OobCodeRequest.passwordReset(email: 'email'), 496 | 'de-DE', 497 | ); 498 | verify( 499 | () => mockClient.post( 500 | Uri.parse( 501 | 'https://identitytoolkit.googleapis.com/v1/accounts:sendOobCode?key=apiKey', 502 | ), 503 | body: json.encode({ 504 | 'email': 'email', 505 | 'requestType': 'PASSWORD_RESET', 506 | }), 507 | headers: { 508 | 'Accept': 'application/json', 509 | 'Content-Type': 'application/json', 510 | 'X-Firebase-Locale': 'de-DE', 511 | }, 512 | ), 513 | ); 514 | }); 515 | 516 | test('should throw AuthException on failure', () { 517 | whenError(); 518 | 519 | expect( 520 | () => api.sendOobCode( 521 | const OobCodeRequest.passwordReset(email: 'email'), 522 | ), 523 | throwsA(isA()), 524 | ); 525 | }); 526 | }); 527 | }); 528 | 529 | group('resetPassword', () { 530 | group('verify', () { 531 | test('should send a post request with correct data', () async { 532 | whenPost().thenFake(const PasswordResetResponse()); 533 | 534 | await api.resetPassword( 535 | const PasswordResetRequest.verify(oobCode: 'code'), 536 | ); 537 | verify( 538 | () => mockClient.post( 539 | Uri.parse( 540 | 'https://identitytoolkit.googleapis.com/v1/accounts:resetPassword?key=apiKey', 541 | ), 542 | body: json.encode({'oobCode': 'code'}), 543 | headers: { 544 | 'Accept': 'application/json', 545 | 'Content-Type': 'application/json', 546 | }, 547 | ), 548 | ); 549 | }); 550 | 551 | test('should throw AuthException on failure', () { 552 | whenError(); 553 | 554 | expect( 555 | () => api.resetPassword( 556 | const PasswordResetRequest.verify(oobCode: 'code'), 557 | ), 558 | throwsA(isA()), 559 | ); 560 | }); 561 | }); 562 | 563 | group('confirm', () { 564 | test('should send a post request with correct data', () async { 565 | whenPost().thenFake(const PasswordResetResponse()); 566 | 567 | await api.resetPassword( 568 | const PasswordResetRequest.confirm( 569 | oobCode: 'code', 570 | newPassword: 'password', 571 | ), 572 | ); 573 | verify( 574 | () => mockClient.post( 575 | Uri.parse( 576 | 'https://identitytoolkit.googleapis.com/v1/accounts:resetPassword?key=apiKey', 577 | ), 578 | body: json.encode({'oobCode': 'code', 'newPassword': 'password'}), 579 | headers: { 580 | 'Accept': 'application/json', 581 | 'Content-Type': 'application/json', 582 | }, 583 | ), 584 | ); 585 | }); 586 | 587 | test('should throw AuthException on failure', () { 588 | whenError(); 589 | 590 | expect( 591 | () => api.resetPassword( 592 | const PasswordResetRequest.confirm( 593 | oobCode: 'code', 594 | newPassword: 'password', 595 | ), 596 | ), 597 | throwsA(isA()), 598 | ); 599 | }); 600 | }); 601 | }); 602 | 603 | group('confirmEmail', () { 604 | test('should send a post request with correct data', () async { 605 | whenPost().thenFake(const ConfirmEmailResponse()); 606 | 607 | await api.confirmEmail(const ConfirmEmailRequest(oobCode: 'code')); 608 | verify( 609 | () => mockClient.post( 610 | Uri.parse( 611 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 612 | ), 613 | body: json.encode({'oobCode': 'code'}), 614 | headers: { 615 | 'Accept': 'application/json', 616 | 'Content-Type': 'application/json', 617 | }, 618 | ), 619 | ); 620 | }); 621 | 622 | test('should throw AuthException on failure', () { 623 | whenError(); 624 | expect( 625 | () => api.confirmEmail(const ConfirmEmailRequest(oobCode: 'code')), 626 | throwsA(isA()), 627 | ); 628 | }); 629 | }); 630 | 631 | group('fetchProviders', () { 632 | test('should send a post request with correct data', () async { 633 | whenPost().thenFake(const FetchProviderResponse()); 634 | 635 | await api.fetchProviders( 636 | FetchProviderRequest( 637 | identifier: 'id', 638 | continueUri: Uri.parse('http://localhost:8080'), 639 | ), 640 | ); 641 | verify( 642 | () => mockClient.post( 643 | Uri.parse( 644 | 'https://identitytoolkit.googleapis.com/v1/accounts:createAuthUri?key=apiKey', 645 | ), 646 | body: json.encode({ 647 | 'identifier': 'id', 648 | 'continueUri': 'http://localhost:8080', 649 | }), 650 | headers: { 651 | 'Accept': 'application/json', 652 | 'Content-Type': 'application/json', 653 | }, 654 | ), 655 | ); 656 | }); 657 | 658 | test('should throw AuthException on failure', () { 659 | whenError(); 660 | expect( 661 | () => api.fetchProviders( 662 | FetchProviderRequest(identifier: 'id', continueUri: Uri()), 663 | ), 664 | throwsA(isA()), 665 | ); 666 | }); 667 | }); 668 | 669 | group('linkEmail', () { 670 | test('should send a post request with correct data', () async { 671 | whenPost().thenFake(const LinkEmailResponse(localId: '')); 672 | 673 | await api.linkEmail( 674 | const LinkEmailRequest( 675 | idToken: 'token', 676 | email: 'mail', 677 | password: 'password', 678 | ), 679 | ); 680 | verify( 681 | () => mockClient.post( 682 | Uri.parse( 683 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 684 | ), 685 | body: json.encode({ 686 | 'idToken': 'token', 687 | 'email': 'mail', 688 | 'password': 'password', 689 | 'returnSecureToken': true, 690 | }), 691 | headers: { 692 | 'Accept': 'application/json', 693 | 'Content-Type': 'application/json', 694 | }, 695 | ), 696 | ); 697 | }); 698 | 699 | test('should throw AuthException on failure', () { 700 | whenError(); 701 | expect( 702 | () => api.linkEmail( 703 | const LinkEmailRequest( 704 | idToken: 'token', 705 | email: 'mail', 706 | password: 'password', 707 | ), 708 | ), 709 | throwsA(isA()), 710 | ); 711 | }); 712 | }); 713 | 714 | group('linkIdp', () { 715 | test('should send a post request with correct data', () async { 716 | whenPost().thenFake( 717 | const LinkIdpResponse( 718 | federatedId: '', 719 | providerId: '', 720 | localId: '', 721 | idToken: '', 722 | refreshToken: '', 723 | expiresIn: '', 724 | ), 725 | ); 726 | 727 | await api.linkIdp( 728 | LinkIdpRequest( 729 | idToken: 'token', 730 | requestUri: Uri.parse('http://localhost'), 731 | postBody: 'post', 732 | ), 733 | ); 734 | verify( 735 | () => mockClient.post( 736 | Uri.parse( 737 | 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithIdp?key=apiKey', 738 | ), 739 | body: json.encode({ 740 | 'idToken': 'token', 741 | 'requestUri': 'http://localhost', 742 | 'postBody': 'post', 743 | 'returnSecureToken': true, 744 | 'returnIdpCredential': false, 745 | }), 746 | headers: { 747 | 'Accept': 'application/json', 748 | 'Content-Type': 'application/json', 749 | }, 750 | ), 751 | ); 752 | }); 753 | 754 | test('should throw AuthException on failure', () { 755 | whenError(); 756 | expect( 757 | () => api.linkIdp( 758 | LinkIdpRequest(idToken: 'token', requestUri: Uri(), postBody: ''), 759 | ), 760 | throwsA(isA()), 761 | ); 762 | }); 763 | }); 764 | 765 | group('unlinkProvider', () { 766 | test('should send a post request with correct data', () async { 767 | whenPost().thenFake(const UnlinkResponse(localId: '')); 768 | 769 | await api.unlinkProvider( 770 | const UnlinkRequest(idToken: 'token', deleteProvider: ['a', 'b', 'c']), 771 | ); 772 | verify( 773 | () => mockClient.post( 774 | Uri.parse( 775 | 'https://identitytoolkit.googleapis.com/v1/accounts:update?key=apiKey', 776 | ), 777 | body: json.encode({ 778 | 'idToken': 'token', 779 | 'deleteProvider': ['a', 'b', 'c'], 780 | }), 781 | headers: { 782 | 'Accept': 'application/json', 783 | 'Content-Type': 'application/json', 784 | }, 785 | ), 786 | ); 787 | }); 788 | 789 | test('should throw AuthException on failure', () { 790 | whenError(); 791 | expect( 792 | () => api.unlinkProvider( 793 | const UnlinkRequest(idToken: 'token', deleteProvider: []), 794 | ), 795 | throwsA(isA()), 796 | ); 797 | }); 798 | }); 799 | 800 | group('delete', () { 801 | test('should send a post request with correct data', () async { 802 | await api.delete(const DeleteRequest(idToken: 'token')); 803 | verify( 804 | () => mockClient.post( 805 | Uri.parse( 806 | 'https://identitytoolkit.googleapis.com/v1/accounts:delete?key=apiKey', 807 | ), 808 | body: json.encode({'idToken': 'token'}), 809 | headers: { 810 | 'Accept': 'application/json', 811 | 'Content-Type': 'application/json', 812 | }, 813 | ), 814 | ); 815 | }); 816 | 817 | test('should throw AuthException on failure', () { 818 | whenError(); 819 | expect( 820 | () => api.delete(const DeleteRequest(idToken: 'token')), 821 | throwsA(isA()), 822 | ); 823 | }); 824 | }); 825 | 826 | group('emulator', () { 827 | // Random IP just to ensure we don't accidentally hard code 828 | // 127.0.0.1 or localhost 829 | const emulatorHost = '192.123.1.123'; 830 | const emulatorPort = 3124; 831 | final apiWithEmulator = RestApi( 832 | mockClient, 833 | apiKey, 834 | emulator: EmulatorConfig(host: emulatorHost, port: emulatorPort), 835 | ); 836 | test('should construct emulator version of auth endpoints', () async { 837 | whenPost().thenFake( 838 | AnonymousSignInResponse( 839 | idToken: '', 840 | refreshToken: '', 841 | expiresIn: '', 842 | localId: '', 843 | ), 844 | ); 845 | 846 | await apiWithEmulator.signUpAnonymous(AnonymousSignInRequest()); 847 | verify( 848 | () => mockClient.post( 849 | Uri.parse( 850 | 'http://$emulatorHost:$emulatorPort/identitytoolkit.googleapis.com/v1/accounts:signUp?key=apiKey', 851 | ), 852 | body: json.encode({'returnSecureToken': true}), 853 | headers: { 854 | 'Accept': 'application/json', 855 | 'Content-Type': 'application/json', 856 | }, 857 | ), 858 | ); 859 | }); 860 | 861 | test('should construct emulator version of the token endpoints', () async { 862 | whenPost().thenFake( 863 | const RefreshResponse( 864 | expires_in: '', 865 | token_type: '', 866 | refresh_token: '', 867 | id_token: '', 868 | user_id: '', 869 | project_id: '', 870 | ), 871 | ); 872 | 873 | const token = 'token'; 874 | await apiWithEmulator.token(refresh_token: token); 875 | verify( 876 | () => mockClient.post( 877 | Uri.parse( 878 | 'http://$emulatorHost:$emulatorPort/securetoken.googleapis.com/v1/token?key=apiKey', 879 | ), 880 | headers: { 881 | 'Accept': 'application/json', 882 | 'Content-Type': 'application/x-www-form-urlencoded', 883 | }, 884 | body: {'refresh_token': token, 'grant_type': 'refresh_token'}, 885 | ), 886 | ); 887 | }); 888 | }); 889 | } 890 | -------------------------------------------------------------------------------- /tool/init_commit_hooks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo '#!/bin/bash' > .git/hooks/pre-commit 4 | echo 'exec dart run dart_pre_commit' >> .git/hooks/pre-commit 5 | chmod +x .git/hooks/pre-commit 6 | --------------------------------------------------------------------------------