diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000..23fc90b --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "dart-firebase-admin" + } +} diff --git a/README.md b/README.md deleted file mode 100644 index e7db164..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Dart Firebase Admin - -A Firebase Admin SDK implementation for Dart. \ No newline at end of file diff --git a/README.md b/README.md new file mode 120000 index 0000000..a65ba2f --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +packages/dart_firebase_admin/README.md \ No newline at end of file diff --git a/database.rules.json b/database.rules.json new file mode 100644 index 0000000..f54493d --- /dev/null +++ b/database.rules.json @@ -0,0 +1,7 @@ +{ + /* Visit https://firebase.google.com/docs/database/security to learn more about security rules. */ + "rules": { + ".read": false, + ".write": false + } +} \ No newline at end of file diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..6e0fb1f --- /dev/null +++ b/firebase.json @@ -0,0 +1,23 @@ +{ + "emulators": { + "auth": { + "port": 9099 + }, + "functions": { + "port": 5001 + }, + "firestore": { + "port": 8080 + }, + "database": { + "port": 9000 + }, + "pubsub": { + "port": 8085 + }, + "ui": { + "enabled": true + }, + "singleProjectMode": true + } +} diff --git a/firestore.indexes.json b/firestore.indexes.json new file mode 100644 index 0000000..415027e --- /dev/null +++ b/firestore.indexes.json @@ -0,0 +1,4 @@ +{ + "indexes": [], + "fieldOverrides": [] +} diff --git a/firestore.rules b/firestore.rules new file mode 100644 index 0000000..921d4e7 --- /dev/null +++ b/firestore.rules @@ -0,0 +1,19 @@ +rules_version = '2'; + +service cloud.firestore { + match /databases/{database}/documents { + + // This rule allows anyone with your Firestore database reference to view, edit, + // and delete all data in your Firestore database. It is useful for getting + // started, but it is configured to expire after 30 days because it + // leaves your app open to attackers. At that time, all client + // requests to your Firestore database will be denied. + // + // Make sure to write security rules for your app before that time, or else + // all client requests to your Firestore database will be denied until you Update + // your rules + match /{document=**} { + allow read, write: if request.time < timestamp.date(2023, 8, 30); + } + } +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/README.md b/packages/dart_firebase_admin/README.md index e69de29..8bf9656 100644 --- a/packages/dart_firebase_admin/README.md +++ b/packages/dart_firebase_admin/README.md @@ -0,0 +1,157 @@ +## Dart Firebase Admin + +Welcome! This project is a port of [Node's Fireabse Admin SDK](https://github.com/firebase/firebase-admin-node) to Dart. + +⚠️ This project is still in its early stages, and some features may be missing or bugged. +Currently, only Firestore is available, with more to come (auth next). + +- [Dart Firebase Admin](#dart-firebase-admin) +- [Usage](#usage) + - [Connecting to the SDK](#connecting-to-the-sdk) + - [Connecting using the environment](#connecting-using-the-environment) + - [Connecting using a \`service-account.json\`\` file](#connecting-using-a-service-accountjson-file) + - [Using Firestore](#using-firestore) + +## Usage + +### Connecting to the SDK + +Before using Firebase, we must first authenticate. + +There are currently two options: + +- You can connect using environment variables +- Alternatively, you can specify a `service-account.json` file + +#### Connecting using the environment + +To connect using environment variables, you will need to have +the [Firebase CLI](https://firebaseopensource.com/projects/firebase/firebase-tools/) installed. + +Once done, you can run: + +```sh +firebase login +``` + +And log-in to the project of your choice. + +From there, you can have your Dart program authenticate +using the environment with: + +```dart +import 'package:dart_firebase_admin/dart_firebase_admin.dart'; + +void main() { + final admin = FirebaseAdminApp.initializeApp( + '', + // This will obtain authentication informations from the environment + Credential.fromApplicationDefaultCredentials(), + ); + + // TODO use the Admin SDK + final firestore = Firestore(admin); + firestore.doc('hello/world').get(); +} +``` + +#### Connecting using a `service-account.json`` file + +Alternatively, you can choose to use a `service-account.json` file. +This file can be obtained in your firebase console by going to: + +``` +https://console.firebase.google.com/u/0/project//settings/serviceaccounts/adminsdk +``` + +Make sure to replace `` with the name of your project. +One there, follow the steps and download the file. Place it anywhere you want in your project. + +**⚠️ Note**: +This file should be kept private. Do not commit it on public repositories. + +After all of that is done, you can now authenticate in your Dart program using: + +```dart +import 'package:dart_firebase_admin/dart_firebase_admin.dart'; + +void main() { + final admin = FirebaseAdminApp.initializeApp( + '', + // Log-in using the newly downloaded file. + Credential.fromServiceAccount( + File(''), + ), + ); + + // TODO use the Admin SDK + final firestore = Firestore(admin); + firestore.doc('hello/world').get(); +} +``` + +### Using Firestore + +First, make sure to follow the steps on [how to authenticate](#connecting-to-the-sdk). +You should now have an instance of a `FirebaseAdminApp` object. + +You can now use this object to create a `Firestore` object as followed: + +```dart +// Obtained in the previous steps +FirebaseAdminApp admin; +final firestore = Firestore(admin); +``` + +From this point onwards, using Firestore with the admin ADK +is roughtly equivalent to using [FlutterFire](https://github.com/firebase/flutterfire). + +Using this `Firestore` object, you'll find your usual collection/query/document +objects. + +For example you can perform a `where` query: + +```dart +// The following lists all users above 18 years old +final collection = firestore.collection('users'); +final adults = collection.where('age', WhereFilter.greaterThan, 18); + +final adultsSnapshot = await adults.get(); + +for (final adult in adultsSnapshot.docs) { + print(adult.data()['age']); +} +``` + +Composite queries are also supported: + +```dart +// List users with either John or Jack as first name. +firestore + .collection('users') + .whereFilter( + Filter.or([ + Filter.where('firstName', WhereFilter.equal, 'John'), + Filter.where('firstName', WhereFilter.equal, 'Jack'), + ]), + ); +``` + +Alternatively, you can fetch a specific document too: + +```dart +// Print the age of the user with ID "123" +final user = await firestore.doc('users/123').get(); +print(user.data()?['age']); +``` + +--- + +

+ + + +

+ Built and maintained by Invertase. +

+

diff --git a/packages/dart_firebase_admin/example/lib/main.dart b/packages/dart_firebase_admin/example/lib/main.dart index 5355abf..1a9c52f 100644 --- a/packages/dart_firebase_admin/example/lib/main.dart +++ b/packages/dart_firebase_admin/example/lib/main.dart @@ -1,4 +1,5 @@ import 'package:dart_firebase_admin/dart_firebase_admin.dart'; +import 'package:dart_firebase_admin/firestore.dart'; Future main() async { final admin = FirebaseAdminApp.initializeApp( @@ -6,12 +7,20 @@ Future main() async { Credential.fromApplicationDefaultCredentials(), ); - final auth = FirebaseAdminAuth(admin); + admin.useEmulator(); - // await auth.deleteUser('867gK70vkJNjOzlj4uQoMcg7a1d2'); - // await auth.createSessionCookie('867gK70vkJNjOzlj4uQoMcg7a1d2'); - final d = await auth.deleteUsers(['p9bj9If2i4eQlr7NxnaxWGZsmgq1']); - print(d.errors); - print(d.failureCount); - print('Deleted!'); + final firestore = Firestore(admin); + + final collection = firestore.collection('users'); + + await collection.doc('123').set({ + 'name': 'John Doe', + 'age': 30, + }); + + final snapshot = await collection.get(); + + for (final doc in snapshot.docs) { + print(doc.data()); + } } diff --git a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart index 2e9ccde..c1a7e50 100644 --- a/packages/dart_firebase_admin/lib/dart_firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/dart_firebase_admin.dart @@ -1,21 +1 @@ -library dart_firebase_admin; - -import 'dart:convert'; -import 'dart:io'; - -import 'package:firebaseapis/identitytoolkit/v1.dart' as firebase_auth_v1; -import 'package:firebaseapis/identitytoolkit/v2.dart' as firebase_auth_v2; -import 'package:firebaseapis/identitytoolkit/v3.dart' as firebase_auth_v3; -import 'package:googleapis_auth/auth_io.dart' as auth; - -part 'src/auth/auth_exception.dart'; -part 'src/auth/create_request.dart'; -part 'src/auth/delete_users_result.dart'; -part 'src/auth/firebase_admin_auth.dart'; -part 'src/auth/update_request.dart'; -part 'src/auth/user_info.dart'; -part 'src/auth/user_metadata.dart'; -part 'src/auth/user_record.dart'; -part 'src/credential.dart'; -part 'src/exception.dart'; -part 'src/firebase_admin.dart'; +export 'src/dart_firebase_admin.dart' show FirebaseAdminApp, Credential; diff --git a/packages/dart_firebase_admin/lib/firestore.dart b/packages/dart_firebase_admin/lib/firestore.dart new file mode 100644 index 0000000..d260942 --- /dev/null +++ b/packages/dart_firebase_admin/lib/firestore.dart @@ -0,0 +1,2 @@ +export 'src/google_cloud_firestore/firestore.dart' + hide $SettingsCopyWith, DocumentReader, ApiMapValue; diff --git a/packages/dart_firebase_admin/lib/src/app/core.dart b/packages/dart_firebase_admin/lib/src/app/core.dart new file mode 100644 index 0000000..ddd3774 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/app/core.dart @@ -0,0 +1,150 @@ +import 'dart:io'; + +import 'credential.dart'; + +/// Available options to pass to {@link firebase-admin.app#initializeApp}. +class AppOptions { + AppOptions({ + required this.credential, + required this.databaseAuthVariableOverride, + required this.databaseURL, + required this.serviceAccountId, + required this.storageBucket, + required this.projectId, + required this.httpClient, + }); + + /// A {@link firebase-admin.app#Credential} object used to + /// authenticate the Admin SDK. + /// + /// See {@link https://firebase.google.com/docs/admin/setup#initialize_the_sdk | Initialize the SDK} + /// for detailed documentation and code samples. + final Credential? credential; + + /// The object to use as the {@link https://firebase.google.com/docs/reference/security/database/#auth | auth} + /// variable in your Realtime Database Rules when the Admin SDK reads from or + /// writes to the Realtime Database. This allows you to downscope the Admin SDK + /// from its default full read and write privileges. + /// + /// You can pass `null` to act as an unauthenticated client. + /// + /// See + /// {@link https://firebase.google.com/docs/database/admin/start#authenticate-with-limited-privileges | + /// Authenticate with limited privileges} + /// for detailed documentation and code samples. + final Object? databaseAuthVariableOverride; + + /// The URL of the Realtime Database from which to read and write data. + final String? databaseURL; + + /// The ID of the service account to be used for signing custom tokens. This + /// can be found in the `client_email` field of a service account JSON file. + final String? serviceAccountId; + + /// The name of the Google Cloud Storage bucket used for storing application data. + /// Use only the bucket name without any prefixes or additions (do *not* prefix + /// the name with "gs://"). + final String? storageBucket; + + /// The ID of the Google Cloud project associated with the App. + final String? projectId; + + /// An {@link https://nodejs.org/api/http.html#http_class_http_agent | HTTP Agent} + /// to be used when making outgoing HTTP calls. This Agent instance is used + /// by all services that make REST calls (e.g. `auth`, `messaging`, + /// `projectManagement`). + /// + /// Realtime Database and Firestore use other means of communicating with + /// the backend servers, so they do not use this HTTP Agent. `Credential` + /// instances also do not use this HTTP Agent, but instead support + /// specifying an HTTP Agent in the corresponding factory methods. + final HttpClient? httpClient; +} + +/// A Firebase app holds the initialization information for a collection of +/// services. +class App { + App({required this.name, required this.options}); + + /// The (read-only) name for this app. + /// + /// The default app's name is `"[DEFAULT]"`. + /// + /// @example + /// ```javascript + /// // The default app's name is "[DEFAULT]" + /// initializeApp(defaultAppConfig); + /// console.log(admin.app().name); // "[DEFAULT]" + /// ``` + /// + /// @example + /// ```javascript + /// // A named app's name is what you provide to initializeApp() + /// const otherApp = initializeApp(otherAppConfig, "other"); + /// console.log(otherApp.name); // "other" + /// ``` + final String name; + + /// The (read-only) configuration options for this app. These are the original + /// parameters given in {@link firebase-admin.app#initializeApp}. + /// + /// @example + /// ```javascript + /// const app = initializeApp(config); + /// console.log(app.options.credential === config.credential); // true + /// console.log(app.options.databaseURL === config.databaseURL); // true + /// ``` + final AppOptions options; +} + +/// `FirebaseError` is a subclass of the standard JavaScript `Error` object. In +/// addition to a message String and stack trace, it contains a String code. +abstract class FirebaseException implements Exception { + /// Error codes are strings using the following format: `"service/String-code"`. + /// Some examples include `"auth/invalid-uid"` and + /// `"messaging/invalid-recipient"`. + /// + /// While the message for a given error can change, the code will remain the same + /// between backward-compatible versions of the Firebase SDK. + String get code; + + /// An explanatory message for the error that just occurred. + /// + /// This message is designed to be helpful to you, the developer. Because + /// it generally does not convey meaningful information to end users, + /// this message should not be displayed in your application. + String get message; +} + +/// Composite type which includes both a `FirebaseError` object and an index +/// which can be used to get the errored item. +/// +/// @example +/// ```javascript +/// var registrationTokens = [token1, token2, token3]; +/// admin.messaging().subscribeToTopic(registrationTokens, 'topic-name') +/// .then(function(response) { +/// if (response.failureCount > 0) { +/// console.log("Following devices unsucessfully subscribed to topic:"); +/// response.errors.forEach(function(error) { +/// var invalidToken = registrationTokens[error.index]; +/// console.log(invalidToken, error.error); +/// }); +/// } else { +/// console.log("All devices successfully subscribed to topic:", response); +/// } +/// }) +/// .catch(function(error) { +/// console.log("Error subscribing to topic:", error); +/// }); +///``` +class FirebaseArrayIndexError { + FirebaseArrayIndexError({required this.index, required this.error}); + + /// The index of the errored item within the original array passed as part of the + /// called API method. + final int index; + + /// The error object. + final FirebaseException error; +} diff --git a/packages/dart_firebase_admin/lib/src/app/core.ts b/packages/dart_firebase_admin/lib/src/app/core.ts new file mode 100644 index 0000000..440fad2 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/app/core.ts @@ -0,0 +1,207 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Agent } from 'http'; + +import { Credential } from './credential'; + +/** + * Available options to pass to {@link firebase-admin.app#initializeApp}. + */ +export interface AppOptions { + + /** + * A {@link firebase-admin.app#Credential} object used to + * authenticate the Admin SDK. + * + * See {@link https://firebase.google.com/docs/admin/setup#initialize_the_sdk | Initialize the SDK} + * for detailed documentation and code samples. + */ + credential?: Credential; + + /** + * The object to use as the {@link https://firebase.google.com/docs/reference/security/database/#auth | auth} + * variable in your Realtime Database Rules when the Admin SDK reads from or + * writes to the Realtime Database. This allows you to downscope the Admin SDK + * from its default full read and write privileges. + * + * You can pass `null` to act as an unauthenticated client. + * + * See + * {@link https://firebase.google.com/docs/database/admin/start#authenticate-with-limited-privileges | + * Authenticate with limited privileges} + * for detailed documentation and code samples. + */ + databaseAuthVariableOverride?: object | null; + + /** + * The URL of the Realtime Database from which to read and write data. + */ + databaseURL?: string; + + /** + * The ID of the service account to be used for signing custom tokens. This + * can be found in the `client_email` field of a service account JSON file. + */ + serviceAccountId?: string; + + /** + * The name of the Google Cloud Storage bucket used for storing application data. + * Use only the bucket name without any prefixes or additions (do *not* prefix + * the name with "gs://"). + */ + storageBucket?: string; + + /** + * The ID of the Google Cloud project associated with the App. + */ + projectId?: string; + + /** + * An {@link https://nodejs.org/api/http.html#http_class_http_agent | HTTP Agent} + * to be used when making outgoing HTTP calls. This Agent instance is used + * by all services that make REST calls (e.g. `auth`, `messaging`, + * `projectManagement`). + * + * Realtime Database and Firestore use other means of communicating with + * the backend servers, so they do not use this HTTP Agent. `Credential` + * instances also do not use this HTTP Agent, but instead support + * specifying an HTTP Agent in the corresponding factory methods. + */ + httpAgent?: Agent; +} + +/** + * A Firebase app holds the initialization information for a collection of + * services. + */ +export interface App { + + /** + * The (read-only) name for this app. + * + * The default app's name is `"[DEFAULT]"`. + * + * @example + * ```javascript + * // The default app's name is "[DEFAULT]" + * initializeApp(defaultAppConfig); + * console.log(admin.app().name); // "[DEFAULT]" + * ``` + * + * @example + * ```javascript + * // A named app's name is what you provide to initializeApp() + * const otherApp = initializeApp(otherAppConfig, "other"); + * console.log(otherApp.name); // "other" + * ``` + */ + name: string; + + /** + * The (read-only) configuration options for this app. These are the original + * parameters given in {@link firebase-admin.app#initializeApp}. + * + * @example + * ```javascript + * const app = initializeApp(config); + * console.log(app.options.credential === config.credential); // true + * console.log(app.options.databaseURL === config.databaseURL); // true + * ``` + */ + options: AppOptions; +} + +/** + * `FirebaseError` is a subclass of the standard JavaScript `Error` object. In + * addition to a message string and stack trace, it contains a string code. + */ +export interface FirebaseError { + + /** + * Error codes are strings using the following format: `"service/string-code"`. + * Some examples include `"auth/invalid-uid"` and + * `"messaging/invalid-recipient"`. + * + * While the message for a given error can change, the code will remain the same + * between backward-compatible versions of the Firebase SDK. + */ + code: string; + + /** + * An explanatory message for the error that just occurred. + * + * This message is designed to be helpful to you, the developer. Because + * it generally does not convey meaningful information to end users, + * this message should not be displayed in your application. + */ + message: string; + + /** + * A string value containing the execution backtrace when the error originally + * occurred. + * + * This information can be useful for troubleshooting the cause of the error with + * {@link https://firebase.google.com/support | Firebase Support}. + */ + stack?: string; + + /** + * Returns a JSON-serializable object representation of this error. + * + * @returns A JSON-serializable representation of this object. + */ + toJSON(): object; +} + +/** + * Composite type which includes both a `FirebaseError` object and an index + * which can be used to get the errored item. + * + * @example + * ```javascript + * var registrationTokens = [token1, token2, token3]; + * admin.messaging().subscribeToTopic(registrationTokens, 'topic-name') + * .then(function(response) { + * if (response.failureCount > 0) { + * console.log("Following devices unsucessfully subscribed to topic:"); + * response.errors.forEach(function(error) { + * var invalidToken = registrationTokens[error.index]; + * console.log(invalidToken, error.error); + * }); + * } else { + * console.log("All devices successfully subscribed to topic:", response); + * } + * }) + * .catch(function(error) { + * console.log("Error subscribing to topic:", error); + * }); + *``` + */ +export interface FirebaseArrayIndexError { + + /** + * The index of the errored item within the original array passed as part of the + * called API method. + */ + index: number; + + /** + * The error object. + */ + error: FirebaseError; +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/app/credential.dart b/packages/dart_firebase_admin/lib/src/app/credential.dart new file mode 100644 index 0000000..dc697e2 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/app/credential.dart @@ -0,0 +1,32 @@ +class ServiceAccount { + ServiceAccount({ + required this.projectId, + required this.clientEmail, + required this.privateKey, + }); + + final String? projectId; + final String? clientEmail; + final String? privateKey; +} + +/// Interface for Google OAuth 2.0 access tokens. +class GoogleOAuthAccessToken { + GoogleOAuthAccessToken({required this.accessToken, required this.expiresIn}); + + final String accessToken; + final int expiresIn; +} + +/// Interface that provides Google OAuth2 access tokens used to authenticate +/// with Firebase services. +/// +/// In most cases, you will not need to implement this yourself and can instead +/// use the default implementations provided by the `firebase-admin/app` module. +abstract class Credential { + /// Returns a Google OAuth2 access token object used to authenticate with + /// Firebase services. + /// + /// @returns A Google OAuth2 access token object. + Future getAccessToken(); +} diff --git a/packages/dart_firebase_admin/lib/src/app/credential.ts b/packages/dart_firebase_admin/lib/src/app/credential.ts new file mode 100644 index 0000000..b585790 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/app/credential.ts @@ -0,0 +1,47 @@ +/*! + * @license + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface ServiceAccount { + projectId?: string; + clientEmail?: string; + privateKey?: string; +} + +/** + * Interface for Google OAuth 2.0 access tokens. + */ +export interface GoogleOAuthAccessToken { + access_token: string; + expires_in: number; +} + +/** + * Interface that provides Google OAuth2 access tokens used to authenticate + * with Firebase services. + * + * In most cases, you will not need to implement this yourself and can instead + * use the default implementations provided by the `firebase-admin/app` module. + */ +export interface Credential { + /** + * Returns a Google OAuth2 access token object used to authenticate with + * Firebase services. + * + * @returns A Google OAuth2 access token object. + */ + getAccessToken(): Promise; +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/auth.dart b/packages/dart_firebase_admin/lib/src/auth/auth.dart new file mode 100644 index 0000000..e69de29 diff --git a/packages/dart_firebase_admin/lib/src/auth/auth.ts b/packages/dart_firebase_admin/lib/src/auth/auth.ts new file mode 100644 index 0000000..67a64af --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/auth.ts @@ -0,0 +1,72 @@ +/*! + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App } from '../app/index'; +import { AuthRequestHandler } from './auth-api-request'; +import { TenantManager } from './tenant-manager'; +import { BaseAuth } from './base-auth'; +import { ProjectConfigManager } from './project-config-manager'; + +/** + * Auth service bound to the provided app. + * An Auth instance can have multiple tenants. + */ +export class Auth extends BaseAuth { + + private readonly tenantManager_: TenantManager; + private readonly projectConfigManager_: ProjectConfigManager; + private readonly app_: App; + + /** + * @param app - The app for this Auth service. + * @constructor + * @internal + */ + constructor(app: App) { + super(app, new AuthRequestHandler(app)); + this.app_ = app; + this.tenantManager_ = new TenantManager(app); + this.projectConfigManager_ = new ProjectConfigManager(app); + } + + /** + * Returns the app associated with this Auth instance. + * + * @returns The app associated with this Auth instance. + */ + get app(): App { + return this.app_; + } + + /** + * Returns the tenant manager instance associated with the current project. + * + * @returns The tenant manager instance associated with the current project. + */ + public tenantManager(): TenantManager { + return this.tenantManager_; + } + + /** + * Returns the project config manager instance associated with the current project. + * + * @returns The project config manager instance associated with the current project. + */ + public projectConfigManager(): ProjectConfigManager { + return this.projectConfigManager_; + } +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart new file mode 100644 index 0000000..8b5fe8e --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart @@ -0,0 +1,504 @@ +import 'package:firebaseapis/identitytoolkit/v1.dart' as auth1; +import 'package:firebaseapis/identitytoolkit/v2.dart' as auth2; +import 'package:firebaseapis/identitytoolkit/v3.dart' as auth3; +import 'package:googleapis_auth/googleapis_auth.dart' as auth; + +import '../dart_firebase_admin.dart'; +import '../utils/validator.dart'; +import 'auth_config.dart'; +import 'base_auth.dart'; +import 'identifier.dart'; +import 'user_import_builder.dart'; + +/// Maximum allowed number of users to batch get at one time. +const maxGetAccountsBatchSize = 100; + +/// Maximum allowed number of users to batch download at one time. +const maxDownloadAccountPageSize = 1000; + +/// Maximum allowed number of users to batch delete at one time. +const maxDeleteAccountsBatchSize = 1000; + +/// Maximum allowed number of users to batch upload at one time. +const maxUploadAccountBatchSize = 1000; + +abstract class AbstractAuthRequestHandler { + AbstractAuthRequestHandler(this.app) : _httpClient = _AuthHttpClient(app); + + final FirebaseAdminApp app; + final _AuthHttpClient _httpClient; + + /// Imports the list of users provided to Firebase Auth. This is useful when + /// migrating from an external authentication system without having to use the Firebase CLI SDK. + /// At most, 1000 users are allowed to be imported one at a time. + /// When importing a list of password users, UserImportOptions are required to be specified. + /// + /// - users - The list of user records to import to Firebase Auth. + /// - options - The user import options, required when the users provided + /// include password credentials. + /// + /// Returns a Future that resolves when the operation completes + /// with the result of the import. This includes the number of successful imports, the number + /// of failed uploads and their corresponding errors. + Future uploadAccount( + List users, + UserImportOptions? options, + ) async { + // This will throw if any error is detected in the hash options. + // For errors in the list of users, this will not throw and will report the errors and the + // corresponding user index in the user import generated response below. + // No need to validate raw request or raw response as this is done in UserImportBuilder. + final userImportBuilder = UserImportBuilder( + users: users, + options: options, + userRequestValidator: (userRequest) { + // Pass true to validate the uploadAccount specific fields. + // TODO validateCreateEditRequest + }, + ); + + final request = userImportBuilder.buildRequest(); + final requestUsers = request.users; + // Fail quickly if more users than allowed are to be imported. + if (requestUsers != null && + requestUsers.length > maxUploadAccountBatchSize) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.maximumUserCountExceeded, + 'A maximum of $maxUploadAccountBatchSize users can be imported at once.', + ); + } + // If no remaining user in request after client side processing, there is no need + // to send the request to the server. + if (requestUsers == null || requestUsers.isEmpty) { + return userImportBuilder.buildResponse([]); + } + + return _httpClient.v1((client) async { + final response = await client.projects.accounts_1.batchCreate( + request, + app.projectId, + ); + // No error object is returned if no error encountered. + // Rewrite response as UserImportResult and re-insert client previously detected errors. + return userImportBuilder.buildResponse(response.error ?? const []); + }); + } + + /// Exports the users (single batch only) with a size of maxResults and starting from + /// the offset as specified by pageToken. + /// + /// maxResults - The page size, 1000 if undefined. This is also the maximum + /// allowed limit. + /// + /// pageToken - The next page token. If not specified, returns users starting + /// without any offset. Users are returned in the order they were created from oldest to + /// newest, relative to the page token offset. + /// + /// Returns a Future that resolves with the current batch of downloaded + /// users and the next page token if available. For the last page, an empty list of users + /// and no page token are returned. + Future + downloadAccount({ + required int? maxResults, + required String? pageToken, + }) { + maxResults ??= maxDownloadAccountPageSize; + if (pageToken != null && pageToken.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidPageToken); + } + if (maxResults <= 0 || maxResults > maxDownloadAccountPageSize) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'Required "maxResults" must be a positive integer that does not exceed ' + '$maxDownloadAccountPageSize.', + ); + } + + return _httpClient.v1((client) async { + // TODO handle tenants + return client.projects.accounts_1.batchGet( + app.projectId, + maxResults: maxResults, + nextPageToken: pageToken, + ); + }); + } + + /// Deletes an account identified by a uid. + Future deleteAccount( + String uid, + ) async { + assertIsUid(uid); + + // TODO handle tenants + return _httpClient.v1((client) async { + return client.projects.accounts_1.delete( + auth1.GoogleCloudIdentitytoolkitV1DeleteAccountRequest(localId: uid), + app.projectId, + ); + }); + } + + Future + deleteAccounts( + List uids, { + required bool force, + }) async { + if (uids.isEmpty) { + return auth1.GoogleCloudIdentitytoolkitV1BatchDeleteAccountsResponse(); + } else if (uids.length > maxDeleteAccountsBatchSize) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.maximumUserCountExceeded, + '`uids` parameter must have <= $maxDeleteAccountsBatchSize entries.', + ); + } + + return _httpClient.v1((client) async { + // TODO handle tenants + return client.projects.accounts_1.batchDelete( + auth1.GoogleCloudIdentitytoolkitV1BatchDeleteAccountsRequest( + localIds: uids, + force: force, + ), + app.projectId, + ); + }); + } + + /// Create a new user with the properties supplied. + /// + /// A [Future] that resolves when the operation completes + /// with the user id that was created. + Future createNewAccount(CreateRequest properties) async { + return _httpClient.v1((client) async { + var mfaInfo = properties.multiFactor?.enrolledFactors + .map((info) => info.toGoogleCloudIdentitytoolkitV1MfaFactor()) + .toList(); + if (mfaInfo != null && mfaInfo.isEmpty) mfaInfo = null; + + // TODO support tenants + final response = await client.projects.accounts( + auth1.GoogleCloudIdentitytoolkitV1SignUpRequest( + captchaChallenge: null, + captchaResponse: null, + disabled: properties.disabled, + displayName: properties.displayName?.value, + email: properties.email, + emailVerified: properties.emailVerified, + idToken: null, + instanceId: null, + localId: properties.uid, + mfaInfo: mfaInfo, + password: properties.password, + phoneNumber: properties.phoneNumber?.value, + photoUrl: properties.photoURL?.value, + targetProjectId: null, + tenantId: null, + ), + app.projectId, + ); + + final localId = response.localId; + if (localId == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'INTERNAL ASSERT FAILED: Unable to create new user', + ); + } + + return localId; + }); + } + + Future + _accountsLookup( + auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest request, + ) async { + // TODO handle tenants + return _httpClient.v1((client) async { + final response = await client.accounts.lookup(request); + final users = response.users; + if (users == null || users.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.userNotFound); + } + return response; + }); + } + + /// Looks up a user by uid. + /// + /// Returns a Future that resolves with the user information. + Future getAccountInfoByUid( + String uid, + ) async { + final response = await _accountsLookup( + auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest(localId: [uid]), + ); + + return response.users!.single; + } + + /// Looks up a user by email. + Future getAccountInfoByEmail( + String email, + ) async { + assertIsEmail(email); + + final response = await _accountsLookup( + auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest(email: [email]), + ); + + return response.users!.single; + } + + /// Looks up a user by phone number. + Future + getAccountInfoByPhoneNumber( + String phoneNumber, + ) async { + assertIsPhoneNumber(phoneNumber); + + final response = await _accountsLookup( + auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( + phoneNumber: [phoneNumber], + ), + ); + + return response.users!.single; + } + + Future + getAccountInfoByFederatedUid({ + required String providerId, + required String rawId, + }) async { + if (providerId.isEmpty || rawId.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); + } + + final response = await _accountsLookup( + auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( + federatedUserId: [ + auth1.GoogleCloudIdentitytoolkitV1FederatedUserIdentifier( + providerId: providerId, + rawId: rawId, + ), + ], + ), + ); + + return response.users!.single; + } + + /// Looks up multiple users by their identifiers (uid, email, etc). + Future + getAccountInfoByIdentifiers( + List identifiers, + ) async { + if (identifiers.isEmpty) { + return auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoResponse( + users: [], + ); + } else if (identifiers.length > maxGetAccountsBatchSize) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.maximumUserCountExceeded, + '`identifiers` parameter must have <= $maxGetAccountsBatchSize entries.', + ); + } + + final request = auth1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest(); + + for (final id in identifiers) { + switch (id) { + case UidIdentifier(): + final localIds = request.localId ?? []; + localIds.add(id.uid); + case EmailIdentifier(): + final emails = request.email ?? []; + emails.add(id.email); + case PhoneIdentifier(): + final phoneNumbers = request.phoneNumber ?? []; + phoneNumbers.add(id.phoneNumber); + case ProviderIdentifier(): + final providerIds = request.federatedUserId ?? []; + providerIds.add(id.providerId); + } + } + + // TODO handle tenants + return _httpClient.v1((client) => client.accounts.lookup(request)); + } + + /// Edits an existing user. + /// + /// - uid - The user to edit. + /// - properties - The properties to set on the user. + /// + /// Returns a [Future] that resolves when the operation completes + /// with the user id that was edited. + Future updateExistingAccount( + String uid, + UpdateRequest properties, + ) async { + assertIsUid(uid); + + final providerToLink = properties.providerToLink; + if (providerToLink != null) { + if (providerToLink.providerId?.isNotEmpty ?? false) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'providerToLink.providerId of properties argument must be a non-empty string.', + ); + } + if (providerToLink.uid?.isNotEmpty ?? false) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'providerToLink.uid of properties argument must be a non-empty string.', + ); + } + } + final providersToUnlink = properties.providersToUnlink; + if (providersToUnlink != null) { + for (final provider in providersToUnlink) { + if (provider.isEmpty) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'providersToUnlink of properties argument must be a non-empty string.', + ); + } + } + } + + // For deleting some attributes, these values must be passed as Box(null). + final isPhotoDeleted = + properties.photoURL != null && properties.photoURL?.value == null; + final isDisplayNameDeleted = + properties.displayName != null && properties.displayName?.value == null; + final isPhoneNumberDeleted = + properties.phoneNumber != null && properties.phoneNumber?.value == null; + + // They will be removed from the backend request and an additional parameter + // deleteAttribute: ['PHOTO_URL', 'DISPLAY_NAME'] + // with an array of the parameter names to delete will be passed. + final deleteAttribute = [ + if (isPhotoDeleted) 'PHOTO_URL', + if (isDisplayNameDeleted) 'DISPLAY_NAME', + ]; + + // Phone will be removed from the backend request and an additional parameter + // deleteProvider: ['phone'] with an array of providerIds (phone in this case), + // will be passed. + List? deleteProvider; + if (isPhoneNumberDeleted) deleteProvider = ['phone']; + + final linkProviderUserInfo = + properties.providerToLink?.toProviderUserInfo(); + + final providerToUnlink = properties.providersToUnlink; + if (providerToUnlink != null) { + deleteProvider ??= []; + deleteProvider.addAll(providerToUnlink); + } + + final mfa = properties.multiFactor?.toMfaInfo(); + + return _httpClient.v1((client) async { + final response = await client.accounts.update( + auth1.GoogleCloudIdentitytoolkitV1SetAccountInfoRequest( + captchaChallenge: null, + captchaResponse: null, + createdAt: null, + customAttributes: null, + delegatedProjectNumber: null, + deleteAttribute: deleteAttribute.isEmpty ? null : deleteAttribute, + deleteProvider: deleteProvider, + disableUser: properties.disabled, + // Will be null if deleted or set to null. "deleteAttribute" will take over + displayName: properties.displayName?.value, + email: properties.email, + emailVerified: properties.emailVerified, + idToken: null, + instanceId: null, + lastLoginAt: null, + linkProviderUserInfo: linkProviderUserInfo, + localId: null, + mfa: mfa, + oobCode: null, + password: properties.password, + // Will be null if deleted or set to null. "deleteProvider" will take over + phoneNumber: properties.phoneNumber?.value, + // Will be null if deleted or set to null. "deleteAttribute" will take over + photoUrl: properties.photoURL?.value, + provider: null, + returnSecureToken: null, + targetProjectId: null, + tenantId: null, + upgradeToFederatedLogin: null, + validSince: null, + ), + ); + + final localId = response.localId; + if (localId == null) { + throw FirebaseAuthAdminException(AuthClientErrorCode.userNotFound); + } + + return localId; + }); + } +} + +class _AuthHttpClient { + _AuthHttpClient(this.app); + + // TODO needs to send "owner" as bearer token when using the emulator + final FirebaseAdminApp app; + + auth.AuthClient? _client; + + Future _getClient() async { + return _client ??= await app.credential.getAuthClient([ + auth3.IdentityToolkitApi.cloudPlatformScope, + auth3.IdentityToolkitApi.firebaseScope, + ]); + } + + Future v1( + Future Function(auth1.IdentityToolkitApi client) fn, + ) { + return authGuard( + () async => fn( + auth1.IdentityToolkitApi( + await _getClient(), + rootUrl: app.authApiHost.toString(), + ), + ), + ); + } + + Future v2( + Future Function(auth2.IdentityToolkitApi client) fn, + ) async { + return authGuard( + () async => fn( + auth2.IdentityToolkitApi( + await _getClient(), + rootUrl: app.authApiHost.toString(), + ), + ), + ); + } + + Future v3( + Future Function(auth3.IdentityToolkitApi client) fn, + ) async { + return authGuard( + () async => fn( + auth3.IdentityToolkitApi( + await _getClient(), + rootUrl: app.authApiHost.toString(), + ), + ), + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_api_request.ts b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.ts new file mode 100644 index 0000000..b3029d9 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.ts @@ -0,0 +1,2323 @@ +/*! + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from '../utils/validator'; + +import { App } from '../app/index'; +import { FirebaseApp } from '../app/firebase-app'; +import { deepCopy, deepExtend } from '../utils/deep-copy'; +import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; +import { + ApiSettings, AuthorizedHttpClient, HttpRequestConfig, HttpError, +} from '../utils/api-request'; +import * as utils from '../utils/index'; + +import { + UserImportOptions, UserImportRecord, UserImportResult, + UserImportBuilder, AuthFactorInfo, convertMultiFactorInfoToServerFormat, +} from './user-import-builder'; +import { ActionCodeSettings, ActionCodeSettingsBuilder } from './action-code-settings-builder'; +import { Tenant, TenantServerResponse, CreateTenantRequest, UpdateTenantRequest } from './tenant'; +import { + isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, + UserIdentifier, UidIdentifier, EmailIdentifier,PhoneIdentifier, ProviderIdentifier, +} from './identifier'; +import { + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, + OIDCConfigServerRequest, SAMLConfigServerRequest, CreateRequest, UpdateRequest, + OIDCAuthProviderConfig, SAMLAuthProviderConfig, OIDCUpdateAuthProviderRequest, + SAMLUpdateAuthProviderRequest +} from './auth-config'; +import { ProjectConfig, ProjectConfigServerResponse, UpdateProjectConfigRequest } from './project-config'; + +/** Firebase Auth request header. */ +const FIREBASE_AUTH_HEADER = { + 'X-Client-Version': `Node/Admin/${utils.getSdkVersion()}`, +}; +/** Firebase Auth request timeout duration in milliseconds. */ +const FIREBASE_AUTH_TIMEOUT = 25000; + + +/** List of reserved claims which cannot be provided when creating a custom token. */ +export const RESERVED_CLAIMS = [ + 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'iat', + 'iss', 'jti', 'nbf', 'nonce', 'sub', 'firebase', +]; + +/** List of supported email action request types. */ +export const EMAIL_ACTION_REQUEST_TYPES = [ + 'PASSWORD_RESET', 'VERIFY_EMAIL', 'EMAIL_SIGNIN', 'VERIFY_AND_CHANGE_EMAIL', +]; + +/** Maximum allowed number of characters in the custom claims payload. */ +const MAX_CLAIMS_PAYLOAD_SIZE = 1000; + +/** Maximum allowed number of users to batch download at one time. */ +const MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE = 1000; + +/** Maximum allowed number of users to batch upload at one time. */ +const MAX_UPLOAD_ACCOUNT_BATCH_SIZE = 1000; + +/** Maximum allowed number of users to batch get at one time. */ +const MAX_GET_ACCOUNTS_BATCH_SIZE = 100; + +/** Maximum allowed number of users to batch delete at one time. */ +const MAX_DELETE_ACCOUNTS_BATCH_SIZE = 1000; + +/** Minimum allowed session cookie duration in seconds (5 minutes). */ +const MIN_SESSION_COOKIE_DURATION_SECS = 5 * 60; + +/** Maximum allowed session cookie duration in seconds (2 weeks). */ +const MAX_SESSION_COOKIE_DURATION_SECS = 14 * 24 * 60 * 60; + +/** Maximum allowed number of provider configurations to batch download at one time. */ +const MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE = 100; + +/** The Firebase Auth backend base URL format. */ +const FIREBASE_AUTH_BASE_URL_FORMAT = + 'https://identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; + +/** Firebase Auth base URlLformat when using the auth emultor. */ +const FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT = + 'http://{host}/identitytoolkit.googleapis.com/{version}/projects/{projectId}{api}'; + +/** The Firebase Auth backend multi-tenancy base URL format. */ +const FIREBASE_AUTH_TENANT_URL_FORMAT = FIREBASE_AUTH_BASE_URL_FORMAT.replace( + 'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}'); + +/** Firebase Auth base URL format when using the auth emultor with multi-tenancy. */ +const FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT = FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT.replace( + 'projects/{projectId}', 'projects/{projectId}/tenants/{tenantId}'); + +/** Maximum allowed number of tenants to download at one time. */ +const MAX_LIST_TENANT_PAGE_SIZE = 1000; + + +/** + * Enum for the user write operation type. + */ +enum WriteOperationType { + Create = 'create', + Update = 'update', + Upload = 'upload', +} + + +/** Defines a base utility to help with resource URL construction. */ +class AuthResourceUrlBuilder { + + protected urlFormat: string; + private projectId: string; + + /** + * The resource URL builder constructor. + * + * @param projectId - The resource project ID. + * @param version - The endpoint API version. + * @constructor + */ + constructor(protected app: App, protected version: string = 'v1') { + if (useEmulator()) { + this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_BASE_URL_FORMAT, { + host: emulatorHost() + }); + } else { + this.urlFormat = FIREBASE_AUTH_BASE_URL_FORMAT; + } + } + + /** + * Returns the resource URL corresponding to the provided parameters. + * + * @param api - The backend API name. + * @param params - The optional additional parameters to substitute in the + * URL path. + * @returns The corresponding resource URL. + */ + public getUrl(api?: string, params?: object): Promise { + return this.getProjectId() + .then((projectId) => { + const baseParams = { + version: this.version, + projectId, + api: api || '', + }; + const baseUrl = utils.formatString(this.urlFormat, baseParams); + // Substitute additional api related parameters. + return utils.formatString(baseUrl, params || {}); + }); + } + + private getProjectId(): Promise { + if (this.projectId) { + return Promise.resolve(this.projectId); + } + + return utils.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'Failed to determine project ID for Auth. Initialize the ' + + 'SDK with service account credentials or set project ID as an app option. ' + + 'Alternatively set the GOOGLE_CLOUD_PROJECT environment variable.', + ); + } + + this.projectId = projectId; + return projectId; + }); + } +} + + +/** Tenant aware resource builder utility. */ +class TenantAwareAuthResourceUrlBuilder extends AuthResourceUrlBuilder { + /** + * The tenant aware resource URL builder constructor. + * + * @param projectId - The resource project ID. + * @param version - The endpoint API version. + * @param tenantId - The tenant ID. + * @constructor + */ + constructor(protected app: App, protected version: string, protected tenantId: string) { + super(app, version); + if (useEmulator()) { + this.urlFormat = utils.formatString(FIREBASE_AUTH_EMULATOR_TENANT_URL_FORMAT, { + host: emulatorHost() + }); + } else { + this.urlFormat = FIREBASE_AUTH_TENANT_URL_FORMAT; + } + } + + /** + * Returns the resource URL corresponding to the provided parameters. + * + * @param api - The backend API name. + * @param params - The optional additional parameters to substitute in the + * URL path. + * @returns The corresponding resource URL. + */ + public getUrl(api?: string, params?: object): Promise { + return super.getUrl(api, params) + .then((url) => { + return utils.formatString(url, { tenantId: this.tenantId }); + }); + } +} + +/** + * Auth-specific HTTP client which uses the special "owner" token + * when communicating with the Auth Emulator. + */ +class AuthHttpClient extends AuthorizedHttpClient { + + protected getToken(): Promise { + if (useEmulator()) { + return Promise.resolve('owner'); + } + + return super.getToken(); + } + +} + +/** + * Validates an AuthFactorInfo object. All unsupported parameters + * are removed from the original request. If an invalid field is passed + * an error is thrown. + * + * @param request - The AuthFactorInfo request object. + */ +function validateAuthFactorInfo(request: AuthFactorInfo): void { + const validKeys = { + mfaEnrollmentId: true, + displayName: true, + phoneInfo: true, + enrolledAt: true, + }; + // Remove unsupported keys from the original request. + for (const key in request) { + if (!(key in validKeys)) { + delete request[key]; + } + } + // No enrollment ID is available for signupNewUser. Use another identifier. + const authFactorInfoIdentifier = + request.mfaEnrollmentId || request.phoneInfo || JSON.stringify(request); + // Enrollment uid may or may not be specified for update operations. + if (typeof request.mfaEnrollmentId !== 'undefined' && + !validator.isNonEmptyString(request.mfaEnrollmentId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + 'The second factor "uid" must be a valid non-empty string.', + ); + } + if (typeof request.displayName !== 'undefined' && + !validator.isString(request.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The second factor "displayName" for "${authFactorInfoIdentifier}" must be a valid string.`, + ); + } + // enrolledAt must be a valid UTC date string. + if (typeof request.enrolledAt !== 'undefined' && + !validator.isISODateString(request.enrolledAt)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${authFactorInfoIdentifier}" must be a valid ` + + 'UTC date string.'); + } + // Validate required fields depending on second factor type. + if (typeof request.phoneInfo !== 'undefined') { + // phoneNumber should be a string and a valid phone number. + if (!validator.isPhoneNumber(request.phoneInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHONE_NUMBER, + `The second factor "phoneNumber" for "${authFactorInfoIdentifier}" must be a non-empty ` + + 'E.164 standard compliant identifier string.'); + } + } else { + // Invalid second factor. For example, a phone second factor may have been provided without + // a phone number. A TOTP based second factor may require a secret key, etc. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLED_FACTORS, + 'MFAInfo object provided is invalid.'); + } +} + + +/** + * Validates a providerUserInfo object. All unsupported parameters + * are removed from the original request. If an invalid field is passed + * an error is thrown. + * + * @param request - The providerUserInfo request object. + */ +function validateProviderUserInfo(request: any): void { + const validKeys = { + rawId: true, + providerId: true, + email: true, + displayName: true, + photoUrl: true, + }; + // Remove invalid keys from original request. + for (const key in request) { + if (!(key in validKeys)) { + delete request[key]; + } + } + if (!validator.isNonEmptyString(request.providerId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + } + if (typeof request.displayName !== 'undefined' && + typeof request.displayName !== 'string') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_DISPLAY_NAME, + `The provider "displayName" for "${request.providerId}" must be a valid string.`, + ); + } + if (!validator.isNonEmptyString(request.rawId)) { + // This is called localId on the backend but the developer specifies this as + // uid externally. So the error message should use the client facing name. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_UID, + `The provider "uid" for "${request.providerId}" must be a valid non-empty string.`, + ); + } + // email should be a string and a valid email. + if (typeof request.email !== 'undefined' && !validator.isEmail(request.email)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_EMAIL, + `The provider "email" for "${request.providerId}" must be a valid email string.`, + ); + } + // photoUrl should be a URL. + if (typeof request.photoUrl !== 'undefined' && + !validator.isURL(request.photoUrl)) { + // This is called photoUrl on the backend but the developer specifies this as + // photoURL externally. So the error message should use the client facing name. + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PHOTO_URL, + `The provider "photoURL" for "${request.providerId}" must be a valid URL string.`, + ); + } +} + + +/** + * Validates a create/edit request object. All unsupported parameters + * are removed from the original request. If an invalid field is passed + * an error is thrown. + * + * @param request - The create/edit request object. + * @param writeOperationType - The write operation type. + */ +function validateCreateEditRequest(request: any, writeOperationType: WriteOperationType): void { + const uploadAccountRequest = writeOperationType === WriteOperationType.Upload; + // Hash set of whitelisted parameters. + const validKeys = { + displayName: true, + localId: true, + email: true, + password: true, + rawPassword: true, + emailVerified: true, + photoUrl: true, + disabled: true, + disableUser: true, + deleteAttribute: true, + deleteProvider: true, + sanityCheck: true, + phoneNumber: true, + customAttributes: true, + validSince: true, + // Pass linkProviderUserInfo only for updates (i.e. not for uploads.) + linkProviderUserInfo: !uploadAccountRequest, + // Pass tenantId only for uploadAccount requests. + tenantId: uploadAccountRequest, + passwordHash: uploadAccountRequest, + salt: uploadAccountRequest, + createdAt: uploadAccountRequest, + lastLoginAt: uploadAccountRequest, + providerUserInfo: uploadAccountRequest, + mfaInfo: uploadAccountRequest, + // Only for non-uploadAccount requests. + mfa: !uploadAccountRequest, + }; + // Remove invalid keys from original request. + for (const key in request) { + if (!(key in validKeys)) { + delete request[key]; + } + } + if (typeof request.tenantId !== 'undefined' && + !validator.isNonEmptyString(request.tenantId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID); + } + // For any invalid parameter, use the external key name in the error description. + // displayName should be a string. + if (typeof request.displayName !== 'undefined' && + !validator.isString(request.displayName)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISPLAY_NAME); + } + if ((typeof request.localId !== 'undefined' || uploadAccountRequest) && + !validator.isUid(request.localId)) { + // This is called localId on the backend but the developer specifies this as + // uid externally. So the error message should use the client facing name. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } + // email should be a string and a valid email. + if (typeof request.email !== 'undefined' && !validator.isEmail(request.email)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); + } + // phoneNumber should be a string and a valid phone number. + if (typeof request.phoneNumber !== 'undefined' && + !validator.isPhoneNumber(request.phoneNumber)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + } + // password should be a string and a minimum of 6 chars. + if (typeof request.password !== 'undefined' && + !validator.isPassword(request.password)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD); + } + // rawPassword should be a string and a minimum of 6 chars. + if (typeof request.rawPassword !== 'undefined' && + !validator.isPassword(request.rawPassword)) { + // This is called rawPassword on the backend but the developer specifies this as + // password externally. So the error message should use the client facing name. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD); + } + // emailVerified should be a boolean. + if (typeof request.emailVerified !== 'undefined' && + typeof request.emailVerified !== 'boolean') { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL_VERIFIED); + } + // photoUrl should be a URL. + if (typeof request.photoUrl !== 'undefined' && + !validator.isURL(request.photoUrl)) { + // This is called photoUrl on the backend but the developer specifies this as + // photoURL externally. So the error message should use the client facing name. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHOTO_URL); + } + // disabled should be a boolean. + if (typeof request.disabled !== 'undefined' && + typeof request.disabled !== 'boolean') { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD); + } + // validSince should be a number. + if (typeof request.validSince !== 'undefined' && + !validator.isNumber(request.validSince)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_TOKENS_VALID_AFTER_TIME); + } + // createdAt should be a number. + if (typeof request.createdAt !== 'undefined' && + !validator.isNumber(request.createdAt)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CREATION_TIME); + } + // lastSignInAt should be a number. + if (typeof request.lastLoginAt !== 'undefined' && + !validator.isNumber(request.lastLoginAt)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_LAST_SIGN_IN_TIME); + } + // disableUser should be a boolean. + if (typeof request.disableUser !== 'undefined' && + typeof request.disableUser !== 'boolean') { + // This is called disableUser on the backend but the developer specifies this as + // disabled externally. So the error message should use the client facing name. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_DISABLED_FIELD); + } + // customAttributes should be stringified JSON with no blacklisted claims. + // The payload should not exceed 1KB. + if (typeof request.customAttributes !== 'undefined') { + let developerClaims: object; + try { + developerClaims = JSON.parse(request.customAttributes); + } catch (error) { + // JSON parsing error. This should never happen as we stringify the claims internally. + // However, we still need to check since setAccountInfo via edit requests could pass + // this field. + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_CLAIMS, error.message); + } + const invalidClaims: string[] = []; + // Check for any invalid claims. + RESERVED_CLAIMS.forEach((blacklistedClaim) => { + if (Object.prototype.hasOwnProperty.call(developerClaims, blacklistedClaim)) { + invalidClaims.push(blacklistedClaim); + } + }); + // Throw an error if an invalid claim is detected. + if (invalidClaims.length > 0) { + throw new FirebaseAuthError( + AuthClientErrorCode.FORBIDDEN_CLAIM, + invalidClaims.length > 1 ? + `Developer claims "${invalidClaims.join('", "')}" are reserved and cannot be specified.` : + `Developer claim "${invalidClaims[0]}" is reserved and cannot be specified.`, + ); + } + // Check claims payload does not exceed maxmimum size. + if (request.customAttributes.length > MAX_CLAIMS_PAYLOAD_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.CLAIMS_TOO_LARGE, + `Developer claims payload should not exceed ${MAX_CLAIMS_PAYLOAD_SIZE} characters.`, + ); + } + } + // passwordHash has to be a base64 encoded string. + if (typeof request.passwordHash !== 'undefined' && + !validator.isString(request.passwordHash)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH); + } + // salt has to be a base64 encoded string. + if (typeof request.salt !== 'undefined' && + !validator.isString(request.salt)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT); + } + // providerUserInfo has to be an array of valid UserInfo requests. + if (typeof request.providerUserInfo !== 'undefined' && + !validator.isArray(request.providerUserInfo)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_DATA); + } else if (validator.isArray(request.providerUserInfo)) { + request.providerUserInfo.forEach((providerUserInfoEntry: any) => { + validateProviderUserInfo(providerUserInfoEntry); + }); + } + + // linkProviderUserInfo must be a (single) UserProvider value. + if (typeof request.linkProviderUserInfo !== 'undefined') { + validateProviderUserInfo(request.linkProviderUserInfo); + } + + // mfaInfo is used for importUsers. + // mfa.enrollments is used for setAccountInfo. + // enrollments has to be an array of valid AuthFactorInfo requests. + let enrollments: AuthFactorInfo[] | null = null; + if (request.mfaInfo) { + enrollments = request.mfaInfo; + } else if (request.mfa && request.mfa.enrollments) { + enrollments = request.mfa.enrollments; + } + if (enrollments) { + if (!validator.isArray(enrollments)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ENROLLED_FACTORS); + } + enrollments.forEach((authFactorInfoEntry: AuthFactorInfo) => { + validateAuthFactorInfo(authFactorInfoEntry); + }); + } +} + + +/** + * Instantiates the createSessionCookie endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_CREATE_SESSION_COOKIE = + new ApiSettings(':createSessionCookie', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate the ID token is a non-empty string. + if (!validator.isNonEmptyString(request.idToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ID_TOKEN); + } + // Validate the custom session cookie duration. + if (!validator.isNumber(request.validDuration) || + request.validDuration < MIN_SESSION_COOKIE_DURATION_SECS || + request.validDuration > MAX_SESSION_COOKIE_DURATION_SECS) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the session cookie. + if (!validator.isNonEmptyString(response.sessionCookie)) { + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR); + } + }); + + +/** + * Instantiates the uploadAccount endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_UPLOAD_ACCOUNT = new ApiSettings('/accounts:batchCreate', 'POST'); + + +/** + * Instantiates the downloadAccount endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_DOWNLOAD_ACCOUNT = new ApiSettings('/accounts:batchGet', 'GET') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate next page token. + if (typeof request.nextPageToken !== 'undefined' && + !validator.isNonEmptyString(request.nextPageToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); + } + // Validate max results. + if (!validator.isNumber(request.maxResults) || + request.maxResults <= 0 || + request.maxResults > MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Required "maxResults" must be a positive integer that does not exceed ' + + `${MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE}.`, + ); + } + }); + +interface GetAccountInfoRequest { + localId?: string[]; + email?: string[]; + phoneNumber?: string[]; + federatedUserId?: Array<{ + providerId: string; + rawId: string; + }>; +} + +/** + * Instantiates the getAccountInfo endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_GET_ACCOUNT_INFO = new ApiSettings('/accounts:lookup', 'POST') + // Set request validator. + .setRequestValidator((request: GetAccountInfoRequest) => { + if (!request.localId && !request.email && !request.phoneNumber && !request.federatedUserId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier'); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + if (!response.users || !response.users.length) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + +/** + * Instantiates the getAccountInfo endpoint settings for use when fetching info + * for multiple accounts. + * + * @internal + */ +export const FIREBASE_AUTH_GET_ACCOUNTS_INFO = new ApiSettings('/accounts:lookup', 'POST') + // Set request validator. + .setRequestValidator((request: GetAccountInfoRequest) => { + if (!request.localId && !request.email && !request.phoneNumber && !request.federatedUserId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier'); + } + }); + + +/** + * Instantiates the deleteAccount endpoint settings. + * + * @internal + */ +export const FIREBASE_AUTH_DELETE_ACCOUNT = new ApiSettings('/accounts:delete', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + if (!request.localId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier'); + } + }); + +interface BatchDeleteAccountsRequest { + localIds?: string[]; + force?: boolean; +} + +interface BatchDeleteErrorInfo { + index?: number; + localId?: string; + message?: string; +} + +export interface BatchDeleteAccountsResponse { + errors?: BatchDeleteErrorInfo[]; +} + +/** + * @internal + */ +export const FIREBASE_AUTH_BATCH_DELETE_ACCOUNTS = new ApiSettings('/accounts:batchDelete', 'POST') + .setRequestValidator((request: BatchDeleteAccountsRequest) => { + if (!request.localIds) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifiers'); + } + if (typeof request.force === 'undefined' || request.force !== true) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing force=true field'); + } + }) + .setResponseValidator((response: BatchDeleteAccountsResponse) => { + const errors = response.errors || []; + errors.forEach((batchDeleteErrorInfo) => { + if (typeof batchDeleteErrorInfo.index === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server BatchDeleteAccountResponse is missing an errors.index field'); + } + if (!batchDeleteErrorInfo.localId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server BatchDeleteAccountResponse is missing an errors.localId field'); + } + // Allow the (error) message to be missing/undef. + }); + }); + +/** + * Instantiates the setAccountInfo endpoint settings for updating existing accounts. + * + * @internal + */ +export const FIREBASE_AUTH_SET_ACCOUNT_INFO = new ApiSettings('/accounts:update', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // localId is a required parameter. + if (typeof request.localId === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Server request is missing user identifier'); + } + // Throw error when tenantId is passed in POST body. + if (typeof request.tenantId !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"tenantId" is an invalid "UpdateRequest" property.'); + } + validateCreateEditRequest(request, WriteOperationType.Update); + }) + // Set response validator. + .setResponseValidator((response: any) => { + // If the localId is not returned, then the request failed. + if (!response.localId) { + throw new FirebaseAuthError(AuthClientErrorCode.USER_NOT_FOUND); + } + }); + +/** + * Instantiates the signupNewUser endpoint settings for creating a new user with or without + * uid being specified. The backend will create a new one if not provided and return it. + * + * @internal + */ +export const FIREBASE_AUTH_SIGN_UP_NEW_USER = new ApiSettings('/accounts', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + // signupNewUser does not support customAttributes. + if (typeof request.customAttributes !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"customAttributes" cannot be set when creating a new user.', + ); + } + // signupNewUser does not support validSince. + if (typeof request.validSince !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"validSince" cannot be set when creating a new user.', + ); + } + // Throw error when tenantId is passed in POST body. + if (typeof request.tenantId !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"tenantId" is an invalid "CreateRequest" property.'); + } + validateCreateEditRequest(request, WriteOperationType.Create); + }) + // Set response validator. + .setResponseValidator((response: any) => { + // If the localId is not returned, then the request failed. + if (!response.localId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new user'); + } + }); + +const FIREBASE_AUTH_GET_OOB_CODE = new ApiSettings('/accounts:sendOobCode', 'POST') + // Set request validator. + .setRequestValidator((request: any) => { + if (!validator.isEmail(request.email)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_EMAIL, + ); + } + if (typeof request.newEmail !== 'undefined' && !validator.isEmail(request.newEmail)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_NEW_EMAIL, + ); + } + if (EMAIL_ACTION_REQUEST_TYPES.indexOf(request.requestType) === -1) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${request.requestType}" is not a supported email action request type.`, + ); + } + }) + // Set response validator. + .setResponseValidator((response: any) => { + // If the oobLink is not returned, then the request failed. + if (!response.oobLink) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create the email action link'); + } + }); + +/** + * Instantiates the retrieve OIDC configuration endpoint settings. + * + * @internal + */ +const GET_OAUTH_IDP_CONFIG = new ApiSettings('/oauthIdpConfigs/{providerId}', 'GET') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the OIDC provider resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get OIDC configuration', + ); + } + }); + +/** + * Instantiates the delete OIDC configuration endpoint settings. + * + * @internal + */ +const DELETE_OAUTH_IDP_CONFIG = new ApiSettings('/oauthIdpConfigs/{providerId}', 'DELETE'); + +/** + * Instantiates the create OIDC configuration endpoint settings. + * + * @internal + */ +const CREATE_OAUTH_IDP_CONFIG = new ApiSettings('/oauthIdpConfigs?oauthIdpConfigId={providerId}', 'POST') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the OIDC provider resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new OIDC configuration', + ); + } + }); + +/** + * Instantiates the update OIDC configuration endpoint settings. + * + * @internal + */ +const UPDATE_OAUTH_IDP_CONFIG = new ApiSettings('/oauthIdpConfigs/{providerId}?updateMask={updateMask}', 'PATCH') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the configuration resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update OIDC configuration', + ); + } + }); + +/** + * Instantiates the list OIDC configuration endpoint settings. + * + * @internal + */ +const LIST_OAUTH_IDP_CONFIGS = new ApiSettings('/oauthIdpConfigs', 'GET') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate next page token. + if (typeof request.pageToken !== 'undefined' && + !validator.isNonEmptyString(request.pageToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); + } + // Validate max results. + if (!validator.isNumber(request.pageSize) || + request.pageSize <= 0 || + request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Required "maxResults" must be a positive integer that does not exceed ' + + `${MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE}.`, + ); + } + }); + +/** + * Instantiates the retrieve SAML configuration endpoint settings. + * + * @internal + */ +const GET_INBOUND_SAML_CONFIG = new ApiSettings('/inboundSamlConfigs/{providerId}', 'GET') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the SAML provider resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get SAML configuration', + ); + } + }); + +/** + * Instantiates the delete SAML configuration endpoint settings. + * + * @internal + */ +const DELETE_INBOUND_SAML_CONFIG = new ApiSettings('/inboundSamlConfigs/{providerId}', 'DELETE'); + +/** + * Instantiates the create SAML configuration endpoint settings. + * + * @internal + */ +const CREATE_INBOUND_SAML_CONFIG = new ApiSettings('/inboundSamlConfigs?inboundSamlConfigId={providerId}', 'POST') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the SAML provider resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new SAML configuration', + ); + } + }); + +/** + * Instantiates the update SAML configuration endpoint settings. + * + * @internal + */ +const UPDATE_INBOUND_SAML_CONFIG = new ApiSettings('/inboundSamlConfigs/{providerId}?updateMask={updateMask}', 'PATCH') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain the configuration resource name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update SAML configuration', + ); + } + }); + +/** + * Instantiates the list SAML configuration endpoint settings. + * + * @internal + */ +const LIST_INBOUND_SAML_CONFIGS = new ApiSettings('/inboundSamlConfigs', 'GET') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate next page token. + if (typeof request.pageToken !== 'undefined' && + !validator.isNonEmptyString(request.pageToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); + } + // Validate max results. + if (!validator.isNumber(request.pageSize) || + request.pageSize <= 0 || + request.pageSize > MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Required "maxResults" must be a positive integer that does not exceed ' + + `${MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE}.`, + ); + } + }); + +/** + * Class that provides the mechanism to send requests to the Firebase Auth backend endpoints. + * + * @internal + */ +export abstract class AbstractAuthRequestHandler { + + protected readonly httpClient: AuthorizedHttpClient; + private authUrlBuilder: AuthResourceUrlBuilder; + private projectConfigUrlBuilder: AuthResourceUrlBuilder; + + /** + * @param response - The response to check for errors. + * @returns The error code if present; null otherwise. + */ + private static getErrorCode(response: any): string | null { + return (validator.isNonNullObject(response) && response.error && response.error.message) || null; + } + + private static addUidToRequest(id: UidIdentifier, request: GetAccountInfoRequest): GetAccountInfoRequest { + if (!validator.isUid(id.uid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } + request.localId ? request.localId.push(id.uid) : request.localId = [id.uid]; + return request; + } + + private static addEmailToRequest(id: EmailIdentifier, request: GetAccountInfoRequest): GetAccountInfoRequest { + if (!validator.isEmail(id.email)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL); + } + request.email ? request.email.push(id.email) : request.email = [id.email]; + return request; + } + + private static addPhoneToRequest(id: PhoneIdentifier, request: GetAccountInfoRequest): GetAccountInfoRequest { + if (!validator.isPhoneNumber(id.phoneNumber)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER); + } + request.phoneNumber ? request.phoneNumber.push(id.phoneNumber) : request.phoneNumber = [id.phoneNumber]; + return request; + } + + private static addProviderToRequest(id: ProviderIdentifier, request: GetAccountInfoRequest): GetAccountInfoRequest { + if (!validator.isNonEmptyString(id.providerId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + } + if (!validator.isNonEmptyString(id.providerUid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_UID); + } + const federatedUserId = { + providerId: id.providerId, + rawId: id.providerUid, + }; + request.federatedUserId + ? request.federatedUserId.push(federatedUserId) + : request.federatedUserId = [federatedUserId]; + return request; + } + + /** + * @param app - The app used to fetch access tokens to sign API requests. + * @constructor + */ + constructor(protected readonly app: App) { + if (typeof app !== 'object' || app === null || !('options' in app)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'First argument passed to admin.auth() must be a valid Firebase app instance.', + ); + } + + this.httpClient = new AuthHttpClient(app as FirebaseApp); + } + + /** + * Creates a new Firebase session cookie with the specified duration that can be used for + * session management (set as a server side session cookie with custom cookie policy). + * The session cookie JWT will have the same payload claims as the provided ID token. + * + * @param idToken - The Firebase ID token to exchange for a session cookie. + * @param expiresIn - The session cookie duration in milliseconds. + * + * @returns A promise that resolves on success with the created session cookie. + */ + public createSessionCookie(idToken: string, expiresIn: number): Promise { + const request = { + idToken, + // Convert to seconds. + validDuration: expiresIn / 1000, + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_CREATE_SESSION_COOKIE, request) + .then((response: any) => response.sessionCookie); + } + + /** + * Looks up a user by uid. + * + * @param uid - The uid of the user to lookup. + * @returns A promise that resolves with the user information. + */ + public getAccountInfoByUid(uid: string): Promise { + if (!validator.isUid(uid)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)); + } + + const request = { + localId: [uid], + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + } + + /** + * Looks up a user by email. + * + * @param email - The email of the user to lookup. + * @returns A promise that resolves with the user information. + */ + public getAccountInfoByEmail(email: string): Promise { + if (!validator.isEmail(email)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_EMAIL)); + } + + const request = { + email: [email], + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + } + + /** + * Looks up a user by phone number. + * + * @param phoneNumber - The phone number of the user to lookup. + * @returns A promise that resolves with the user information. + */ + public getAccountInfoByPhoneNumber(phoneNumber: string): Promise { + if (!validator.isPhoneNumber(phoneNumber)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PHONE_NUMBER)); + } + + const request = { + phoneNumber: [phoneNumber], + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + } + + public getAccountInfoByFederatedUid(providerId: string, rawId: string): Promise { + if (!validator.isNonEmptyString(providerId) || !validator.isNonEmptyString(rawId)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID); + } + + const request = { + federatedUserId: [{ + providerId, + rawId, + }], + }; + + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNT_INFO, request); + } + + /** + * Looks up multiple users by their identifiers (uid, email, etc). + * + * @param identifiers - The identifiers indicating the users + * to be looked up. Must have <= 100 entries. + * @param A - promise that resolves with the set of successfully + * looked up users. Possibly empty if no users were looked up. + */ + public getAccountInfoByIdentifiers(identifiers: UserIdentifier[]): Promise { + if (identifiers.length === 0) { + return Promise.resolve({ users: [] }); + } else if (identifiers.length > MAX_GET_ACCOUNTS_BATCH_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.MAXIMUM_USER_COUNT_EXCEEDED, + '`identifiers` parameter must have <= ' + MAX_GET_ACCOUNTS_BATCH_SIZE + ' entries.'); + } + + let request: GetAccountInfoRequest = {}; + + for (const id of identifiers) { + if (isUidIdentifier(id)) { + request = AbstractAuthRequestHandler.addUidToRequest(id, request); + } else if (isEmailIdentifier(id)) { + request = AbstractAuthRequestHandler.addEmailToRequest(id, request); + } else if (isPhoneIdentifier(id)) { + request = AbstractAuthRequestHandler.addPhoneToRequest(id, request); + } else if (isProviderIdentifier(id)) { + request = AbstractAuthRequestHandler.addProviderToRequest(id, request); + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Unrecognized identifier: ' + id); + } + } + + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_ACCOUNTS_INFO, request); + } + + /** + * Exports the users (single batch only) with a size of maxResults and starting from + * the offset as specified by pageToken. + * + * @param maxResults - The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param pageToken - The next page token. If not specified, returns users starting + * without any offset. Users are returned in the order they were created from oldest to + * newest, relative to the page token offset. + * @returns A promise that resolves with the current batch of downloaded + * users and the next page token if available. For the last page, an empty list of users + * and no page token are returned. + */ + public downloadAccount( + maxResults: number = MAX_DOWNLOAD_ACCOUNT_PAGE_SIZE, + pageToken?: string): Promise<{users: object[]; nextPageToken?: string}> { + // Construct request. + const request = { + maxResults, + nextPageToken: pageToken, + }; + // Remove next page token if not provided. + if (typeof request.nextPageToken === 'undefined') { + delete request.nextPageToken; + } + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_DOWNLOAD_ACCOUNT, request) + .then((response: any) => { + // No more users available. + if (!response.users) { + response.users = []; + } + return response as {users: object[]; nextPageToken?: string}; + }); + } + + /** + * Imports the list of users provided to Firebase Auth. This is useful when + * migrating from an external authentication system without having to use the Firebase CLI SDK. + * At most, 1000 users are allowed to be imported one at a time. + * When importing a list of password users, UserImportOptions are required to be specified. + * + * @param users - The list of user records to import to Firebase Auth. + * @param options - The user import options, required when the users provided + * include password credentials. + * @returns A promise that resolves when the operation completes + * with the result of the import. This includes the number of successful imports, the number + * of failed uploads and their corresponding errors. + */ + public uploadAccount( + users: UserImportRecord[], options?: UserImportOptions): Promise { + // This will throw if any error is detected in the hash options. + // For errors in the list of users, this will not throw and will report the errors and the + // corresponding user index in the user import generated response below. + // No need to validate raw request or raw response as this is done in UserImportBuilder. + const userImportBuilder = new UserImportBuilder(users, options, (userRequest: any) => { + // Pass true to validate the uploadAccount specific fields. + validateCreateEditRequest(userRequest, WriteOperationType.Upload); + }); + const request = userImportBuilder.buildRequest(); + // Fail quickly if more users than allowed are to be imported. + if (validator.isArray(users) && users.length > MAX_UPLOAD_ACCOUNT_BATCH_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.MAXIMUM_USER_COUNT_EXCEEDED, + `A maximum of ${MAX_UPLOAD_ACCOUNT_BATCH_SIZE} users can be imported at once.`, + ); + } + // If no remaining user in request after client side processing, there is no need + // to send the request to the server. + if (!request.users || request.users.length === 0) { + return Promise.resolve(userImportBuilder.buildResponse([])); + } + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_UPLOAD_ACCOUNT, request) + .then((response: any) => { + // No error object is returned if no error encountered. + const failedUploads = (response.error || []) as Array<{index: number; message: string}>; + // Rewrite response as UserImportResult and re-insert client previously detected errors. + return userImportBuilder.buildResponse(failedUploads); + }); + } + + /** + * Deletes an account identified by a uid. + * + * @param uid - The uid of the user to delete. + * @returns A promise that resolves when the user is deleted. + */ + public deleteAccount(uid: string): Promise { + if (!validator.isUid(uid)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)); + } + + const request = { + localId: uid, + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_DELETE_ACCOUNT, request); + } + + public deleteAccounts(uids: string[], force: boolean): Promise { + if (uids.length === 0) { + return Promise.resolve({}); + } else if (uids.length > MAX_DELETE_ACCOUNTS_BATCH_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.MAXIMUM_USER_COUNT_EXCEEDED, + '`uids` parameter must have <= ' + MAX_DELETE_ACCOUNTS_BATCH_SIZE + ' entries.'); + } + + const request: BatchDeleteAccountsRequest = { + localIds: [], + force, + }; + + uids.forEach((uid) => { + if (!validator.isUid(uid)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_UID); + } + request.localIds!.push(uid); + }); + + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_BATCH_DELETE_ACCOUNTS, request); + } + + /** + * Sets additional developer claims on an existing user identified by provided UID. + * + * @param uid - The user to edit. + * @param customUserClaims - The developer claims to set. + * @returns A promise that resolves when the operation completes + * with the user id that was edited. + */ + public setCustomUserClaims(uid: string, customUserClaims: object | null): Promise { + // Validate user UID. + if (!validator.isUid(uid)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)); + } else if (!validator.isObject(customUserClaims)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'CustomUserClaims argument must be an object or null.', + ), + ); + } + // Delete operation. Replace null with an empty object. + if (customUserClaims === null) { + customUserClaims = {}; + } + // Construct custom user attribute editting request. + const request: any = { + localId: uid, + customAttributes: JSON.stringify(customUserClaims), + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + .then((response: any) => { + return response.localId as string; + }); + } + + /** + * Edits an existing user. + * + * @param uid - The user to edit. + * @param properties - The properties to set on the user. + * @returns A promise that resolves when the operation completes + * with the user id that was edited. + */ + public updateExistingAccount(uid: string, properties: UpdateRequest): Promise { + if (!validator.isUid(uid)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)); + } else if (!validator.isNonNullObject(properties)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Properties argument must be a non-null object.', + ), + ); + } else if (validator.isNonNullObject(properties.providerToLink)) { + // TODO(rsgowman): These checks overlap somewhat with + // validateProviderUserInfo. It may be possible to refactor a bit. + if (!validator.isNonEmptyString(properties.providerToLink.providerId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providerToLink.providerId of properties argument must be a non-empty string.'); + } + if (!validator.isNonEmptyString(properties.providerToLink.uid)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providerToLink.uid of properties argument must be a non-empty string.'); + } + } else if (typeof properties.providersToUnlink !== 'undefined') { + if (!validator.isArray(properties.providersToUnlink)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providersToUnlink of properties argument must be an array of strings.'); + } + + properties.providersToUnlink.forEach((providerId) => { + if (!validator.isNonEmptyString(providerId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'providersToUnlink of properties argument must be an array of strings.'); + } + }); + } + + // Build the setAccountInfo request. + const request: any = deepCopy(properties); + request.localId = uid; + // For deleting displayName or photoURL, these values must be passed as null. + // They will be removed from the backend request and an additional parameter + // deleteAttribute: ['PHOTO_URL', 'DISPLAY_NAME'] + // with an array of the parameter names to delete will be passed. + + // Parameters that are deletable and their deleteAttribute names. + // Use client facing names, photoURL instead of photoUrl. + const deletableParams: {[key: string]: string} = { + displayName: 'DISPLAY_NAME', + photoURL: 'PHOTO_URL', + }; + // Properties to delete if available. + request.deleteAttribute = []; + for (const key in deletableParams) { + if (request[key] === null) { + // Add property identifier to list of attributes to delete. + request.deleteAttribute.push(deletableParams[key]); + // Remove property from request. + delete request[key]; + } + } + if (request.deleteAttribute.length === 0) { + delete request.deleteAttribute; + } + + // For deleting phoneNumber, this value must be passed as null. + // It will be removed from the backend request and an additional parameter + // deleteProvider: ['phone'] with an array of providerIds (phone in this case), + // will be passed. + if (request.phoneNumber === null) { + request.deleteProvider ? request.deleteProvider.push('phone') : request.deleteProvider = ['phone']; + delete request.phoneNumber; + } + + if (typeof(request.providerToLink) !== 'undefined') { + request.linkProviderUserInfo = deepCopy(request.providerToLink); + delete request.providerToLink; + + request.linkProviderUserInfo.rawId = request.linkProviderUserInfo.uid; + delete request.linkProviderUserInfo.uid; + } + + if (typeof(request.providersToUnlink) !== 'undefined') { + if (!validator.isArray(request.deleteProvider)) { + request.deleteProvider = []; + } + request.deleteProvider = request.deleteProvider.concat(request.providersToUnlink); + delete request.providersToUnlink; + } + + // Rewrite photoURL to photoUrl. + if (typeof request.photoURL !== 'undefined') { + request.photoUrl = request.photoURL; + delete request.photoURL; + } + // Rewrite disabled to disableUser. + if (typeof request.disabled !== 'undefined') { + request.disableUser = request.disabled; + delete request.disabled; + } + // Construct mfa related user data. + if (validator.isNonNullObject(request.multiFactor)) { + if (request.multiFactor.enrolledFactors === null) { + // Remove all second factors. + request.mfa = {}; + } else if (validator.isArray(request.multiFactor.enrolledFactors)) { + request.mfa = { + enrollments: [], + }; + try { + request.multiFactor.enrolledFactors.forEach((multiFactorInfo: any) => { + request.mfa.enrollments.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } catch (e) { + return Promise.reject(e); + } + if (request.mfa.enrollments.length === 0) { + delete request.mfa.enrollments; + } + } + delete request.multiFactor; + } + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + .then((response: any) => { + return response.localId as string; + }); + } + + /** + * Revokes all refresh tokens for the specified user identified by the uid provided. + * In addition to revoking all refresh tokens for a user, all ID tokens issued + * before revocation will also be revoked on the Auth backend. Any request with an + * ID token generated before revocation will be rejected with a token expired error. + * Note that due to the fact that the timestamp is stored in seconds, any tokens minted in + * the same second as the revocation will still be valid. If there is a chance that a token + * was minted in the last second, delay for 1 second before revoking. + * + * @param uid - The user whose tokens are to be revoked. + * @returns A promise that resolves when the operation completes + * successfully with the user id of the corresponding user. + */ + public revokeRefreshTokens(uid: string): Promise { + // Validate user UID. + if (!validator.isUid(uid)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_UID)); + } + const request: any = { + localId: uid, + // validSince is in UTC seconds. + validSince: Math.floor(new Date().getTime() / 1000), + }; + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SET_ACCOUNT_INFO, request) + .then((response: any) => { + return response.localId as string; + }); + } + + /** + * Create a new user with the properties supplied. + * + * @param properties - The properties to set on the user. + * @returns A promise that resolves when the operation completes + * with the user id that was created. + */ + public createNewAccount(properties: CreateRequest): Promise { + if (!validator.isNonNullObject(properties)) { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Properties argument must be a non-null object.', + ), + ); + } + + // Build the signupNewUser request. + type SignUpNewUserRequest = CreateRequest & { + photoUrl?: string | null; + localId?: string; + mfaInfo?: AuthFactorInfo[]; + }; + const request: SignUpNewUserRequest = deepCopy(properties); + // Rewrite photoURL to photoUrl. + if (typeof request.photoURL !== 'undefined') { + request.photoUrl = request.photoURL; + delete request.photoURL; + } + // Rewrite uid to localId if it exists. + if (typeof request.uid !== 'undefined') { + request.localId = request.uid; + delete request.uid; + } + // Construct mfa related user data. + if (validator.isNonNullObject(request.multiFactor)) { + if (validator.isNonEmptyArray(request.multiFactor.enrolledFactors)) { + const mfaInfo: AuthFactorInfo[] = []; + try { + request.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + // Enrollment time and uid are not allowed for signupNewUser endpoint. + // They will automatically be provisioned server side. + if ('enrollmentTime' in multiFactorInfo) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"enrollmentTime" is not supported when adding second factors via "createUser()"'); + } else if ('uid' in multiFactorInfo) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"uid" is not supported when adding second factors via "createUser()"'); + } + mfaInfo.push(convertMultiFactorInfoToServerFormat(multiFactorInfo)); + }); + } catch (e) { + return Promise.reject(e); + } + request.mfaInfo = mfaInfo; + } + delete request.multiFactor; + } + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_SIGN_UP_NEW_USER, request) + .then((response: any) => { + // Return the user id. + return response.localId as string; + }); + } + + /** + * Generates the out of band email action link for the email specified using the action code settings provided. + * Returns a promise that resolves with the generated link. + * + * @param requestType - The request type. This could be either used for password reset, + * email verification, email link sign-in. + * @param email - The email of the user the link is being sent to. + * @param actionCodeSettings - The optional action code setings which defines whether + * the link is to be handled by a mobile app and the additional state information to be passed in the + * deep link, etc. Required when requestType === 'EMAIL_SIGNIN' + * @param newEmail - The email address the account is being updated to. + * Required only for VERIFY_AND_CHANGE_EMAIL requests. + * @returns A promise that resolves with the email action link. + */ + public getEmailActionLink( + requestType: string, email: string, + actionCodeSettings?: ActionCodeSettings, newEmail?: string): Promise { + let request = { + requestType, + email, + returnOobLink: true, + ...(typeof newEmail !== 'undefined') && { newEmail }, + }; + // ActionCodeSettings required for email link sign-in to determine the url where the sign-in will + // be completed. + if (typeof actionCodeSettings === 'undefined' && requestType === 'EMAIL_SIGNIN') { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "`actionCodeSettings` is required when `requestType` === 'EMAIL_SIGNIN'", + ), + ); + } + if (typeof actionCodeSettings !== 'undefined' || requestType === 'EMAIL_SIGNIN') { + try { + const builder = new ActionCodeSettingsBuilder(actionCodeSettings!); + request = deepExtend(request, builder.buildRequest()); + } catch (e) { + return Promise.reject(e); + } + } + if (requestType === 'VERIFY_AND_CHANGE_EMAIL' && typeof newEmail === 'undefined') { + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "`newEmail` is required when `requestType` === 'VERIFY_AND_CHANGE_EMAIL'", + ), + ); + } + return this.invokeRequestHandler(this.getAuthUrlBuilder(), FIREBASE_AUTH_GET_OOB_CODE, request) + .then((response: any) => { + // Return the link. + return response.oobLink as string; + }); + } + + /** + * Looks up an OIDC provider configuration by provider ID. + * + * @param providerId - The provider identifier of the configuration to lookup. + * @returns A promise that resolves with the provider configuration information. + */ + public getOAuthIdpConfig(providerId: string): Promise { + if (!OIDCConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), GET_OAUTH_IDP_CONFIG, {}, { providerId }); + } + + /** + * Lists the OIDC configurations (single batch only) with a size of maxResults and starting from + * the offset as specified by pageToken. + * + * @param maxResults - The page size, 100 if undefined. This is also the maximum + * allowed limit. + * @param pageToken - The next page token. If not specified, returns OIDC configurations + * without any offset. Configurations are returned in the order they were created from oldest to + * newest, relative to the page token offset. + * @returns A promise that resolves with the current batch of downloaded + * OIDC configurations and the next page token if available. For the last page, an empty list of provider + * configuration and no page token are returned. + */ + public listOAuthIdpConfigs( + maxResults: number = MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE, + pageToken?: string): Promise { + const request: {pageSize: number; pageToken?: string} = { + pageSize: maxResults, + }; + // Add next page token if provided. + if (typeof pageToken !== 'undefined') { + request.pageToken = pageToken; + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), LIST_OAUTH_IDP_CONFIGS, request) + .then((response: any) => { + if (!response.oauthIdpConfigs) { + response.oauthIdpConfigs = []; + delete response.nextPageToken; + } + return response as {oauthIdpConfigs: object[]; nextPageToken?: string}; + }); + } + + /** + * Deletes an OIDC configuration identified by a providerId. + * + * @param providerId - The identifier of the OIDC configuration to delete. + * @returns A promise that resolves when the OIDC provider is deleted. + */ + public deleteOAuthIdpConfig(providerId: string): Promise { + if (!OIDCConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), DELETE_OAUTH_IDP_CONFIG, {}, { providerId }) + .then(() => { + // Return nothing. + }); + } + + /** + * Creates a new OIDC provider configuration with the properties provided. + * + * @param options - The properties to set on the new OIDC provider configuration to be created. + * @returns A promise that resolves with the newly created OIDC + * configuration. + */ + public createOAuthIdpConfig(options: OIDCAuthProviderConfig): Promise { + // Construct backend request. + let request; + try { + request = OIDCConfig.buildServerRequest(options) || {}; + } catch (e) { + return Promise.reject(e); + } + const providerId = options.providerId; + return this.invokeRequestHandler( + this.getProjectConfigUrlBuilder(), CREATE_OAUTH_IDP_CONFIG, request, { providerId }) + .then((response: any) => { + if (!OIDCConfig.getProviderIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new OIDC provider configuration'); + } + return response as OIDCConfigServerResponse; + }); + } + + /** + * Updates an existing OIDC provider configuration with the properties provided. + * + * @param providerId - The provider identifier of the OIDC configuration to update. + * @param options - The properties to update on the existing configuration. + * @returns A promise that resolves with the modified provider + * configuration. + */ + public updateOAuthIdpConfig( + providerId: string, options: OIDCUpdateAuthProviderRequest): Promise { + if (!OIDCConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + // Construct backend request. + let request: OIDCConfigServerRequest; + try { + request = OIDCConfig.buildServerRequest(options, true) || {}; + } catch (e) { + return Promise.reject(e); + } + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), UPDATE_OAUTH_IDP_CONFIG, request, + { providerId, updateMask: updateMask.join(',') }) + .then((response: any) => { + if (!OIDCConfig.getProviderIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update OIDC provider configuration'); + } + return response as OIDCConfigServerResponse; + }); + } + + /** + * Looks up an SAML provider configuration by provider ID. + * + * @param providerId - The provider identifier of the configuration to lookup. + * @returns A promise that resolves with the provider configuration information. + */ + public getInboundSamlConfig(providerId: string): Promise { + if (!SAMLConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), GET_INBOUND_SAML_CONFIG, {}, { providerId }); + } + + /** + * Lists the SAML configurations (single batch only) with a size of maxResults and starting from + * the offset as specified by pageToken. + * + * @param maxResults - The page size, 100 if undefined. This is also the maximum + * allowed limit. + * @param pageToken - The next page token. If not specified, returns SAML configurations starting + * without any offset. Configurations are returned in the order they were created from oldest to + * newest, relative to the page token offset. + * @returns A promise that resolves with the current batch of downloaded + * SAML configurations and the next page token if available. For the last page, an empty list of provider + * configuration and no page token are returned. + */ + public listInboundSamlConfigs( + maxResults: number = MAX_LIST_PROVIDER_CONFIGURATION_PAGE_SIZE, + pageToken?: string): Promise { + const request: {pageSize: number; pageToken?: string} = { + pageSize: maxResults, + }; + // Add next page token if provided. + if (typeof pageToken !== 'undefined') { + request.pageToken = pageToken; + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), LIST_INBOUND_SAML_CONFIGS, request) + .then((response: any) => { + if (!response.inboundSamlConfigs) { + response.inboundSamlConfigs = []; + delete response.nextPageToken; + } + return response as {inboundSamlConfigs: object[]; nextPageToken?: string}; + }); + } + + /** + * Deletes a SAML configuration identified by a providerId. + * + * @param providerId - The identifier of the SAML configuration to delete. + * @returns A promise that resolves when the SAML provider is deleted. + */ + public deleteInboundSamlConfig(providerId: string): Promise { + if (!SAMLConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), DELETE_INBOUND_SAML_CONFIG, {}, { providerId }) + .then(() => { + // Return nothing. + }); + } + + /** + * Creates a new SAML provider configuration with the properties provided. + * + * @param options - The properties to set on the new SAML provider configuration to be created. + * @returns A promise that resolves with the newly created SAML + * configuration. + */ + public createInboundSamlConfig(options: SAMLAuthProviderConfig): Promise { + // Construct backend request. + let request; + try { + request = SAMLConfig.buildServerRequest(options) || {}; + } catch (e) { + return Promise.reject(e); + } + const providerId = options.providerId; + return this.invokeRequestHandler( + this.getProjectConfigUrlBuilder(), CREATE_INBOUND_SAML_CONFIG, request, { providerId }) + .then((response: any) => { + if (!SAMLConfig.getProviderIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new SAML provider configuration'); + } + return response as SAMLConfigServerResponse; + }); + } + + /** + * Updates an existing SAML provider configuration with the properties provided. + * + * @param providerId - The provider identifier of the SAML configuration to update. + * @param options - The properties to update on the existing configuration. + * @returns A promise that resolves with the modified provider + * configuration. + */ + public updateInboundSamlConfig( + providerId: string, options: SAMLUpdateAuthProviderRequest): Promise { + if (!SAMLConfig.isProviderId(providerId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + // Construct backend request. + let request: SAMLConfigServerRequest; + try { + request = SAMLConfig.buildServerRequest(options, true) || {}; + } catch (e) { + return Promise.reject(e); + } + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler(this.getProjectConfigUrlBuilder(), UPDATE_INBOUND_SAML_CONFIG, request, + { providerId, updateMask: updateMask.join(',') }) + .then((response: any) => { + if (!SAMLConfig.getProviderIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update SAML provider configuration'); + } + return response as SAMLConfigServerResponse; + }); + } + + /** + * Invokes the request handler based on the API settings object passed. + * + * @param urlBuilder - The URL builder for Auth endpoints. + * @param apiSettings - The API endpoint settings to apply to request and response. + * @param requestData - The request data. + * @param additionalResourceParams - Additional resource related params if needed. + * @returns A promise that resolves with the response. + */ + protected invokeRequestHandler( + urlBuilder: AuthResourceUrlBuilder, apiSettings: ApiSettings, + requestData: object | undefined, additionalResourceParams?: object): Promise { + return urlBuilder.getUrl(apiSettings.getEndpoint(), additionalResourceParams) + .then((url) => { + // Validate request. + if (requestData) { + const requestValidator = apiSettings.getRequestValidator(); + requestValidator(requestData); + } + // Process request. + const req: HttpRequestConfig = { + method: apiSettings.getHttpMethod(), + url, + headers: FIREBASE_AUTH_HEADER, + data: requestData, + timeout: FIREBASE_AUTH_TIMEOUT, + }; + return this.httpClient.send(req); + }) + .then((response) => { + // Validate response. + const responseValidator = apiSettings.getResponseValidator(); + responseValidator(response.data); + // Return entire response. + return response.data; + }) + .catch((err) => { + if (err instanceof HttpError) { + const error = err.response.data; + const errorCode = AbstractAuthRequestHandler.getErrorCode(error); + if (!errorCode) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Error returned from server: ' + error + '. Additionally, an ' + + 'internal error occurred while attempting to extract the ' + + 'errorcode from the error.', + ); + } + throw FirebaseAuthError.fromServerError(errorCode, /* message */ undefined, error); + } + throw err; + }); + } + + /** + * @returns A new Auth user management resource URL builder instance. + */ + protected abstract newAuthUrlBuilder(): AuthResourceUrlBuilder; + + /** + * @returns A new project config resource URL builder instance. + */ + protected abstract newProjectConfigUrlBuilder(): AuthResourceUrlBuilder; + + /** + * @returns The current Auth user management resource URL builder. + */ + private getAuthUrlBuilder(): AuthResourceUrlBuilder { + if (!this.authUrlBuilder) { + this.authUrlBuilder = this.newAuthUrlBuilder(); + } + return this.authUrlBuilder; + } + + /** + * @returns The current project config resource URL builder. + */ + private getProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + if (!this.projectConfigUrlBuilder) { + this.projectConfigUrlBuilder = this.newProjectConfigUrlBuilder(); + } + return this.projectConfigUrlBuilder; + } +} + +/** Instantiates the getConfig endpoint settings. */ +const GET_PROJECT_CONFIG = new ApiSettings('/config', 'GET') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get project config', + ); + } + }); + +/** Instantiates the updateConfig endpoint settings. */ +const UPDATE_PROJECT_CONFIG = new ApiSettings('/config?updateMask={updateMask}', 'PATCH') + .setResponseValidator((response: any) => { + // Response should always contain at least the config name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update project config', + ); + } + }); + +/** Instantiates the getTenant endpoint settings. */ +const GET_TENANT = new ApiSettings('/tenants/{tenantId}', 'GET') +// Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to get tenant', + ); + } + }); + +/** Instantiates the deleteTenant endpoint settings. */ +const DELETE_TENANT = new ApiSettings('/tenants/{tenantId}', 'DELETE'); + +/** Instantiates the updateTenant endpoint settings. */ +const UPDATE_TENANT = new ApiSettings('/tenants/{tenantId}?updateMask={updateMask}', 'PATCH') + // Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name) || + !Tenant.getTenantIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to update tenant', + ); + } + }); + +/** Instantiates the listTenants endpoint settings. */ +const LIST_TENANTS = new ApiSettings('/tenants', 'GET') + // Set request validator. + .setRequestValidator((request: any) => { + // Validate next page token. + if (typeof request.pageToken !== 'undefined' && + !validator.isNonEmptyString(request.pageToken)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PAGE_TOKEN); + } + // Validate max results. + if (!validator.isNumber(request.pageSize) || + request.pageSize <= 0 || + request.pageSize > MAX_LIST_TENANT_PAGE_SIZE) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'Required "maxResults" must be a positive non-zero number that does not exceed ' + + `the allowed ${MAX_LIST_TENANT_PAGE_SIZE}.`, + ); + } + }); + +/** Instantiates the createTenant endpoint settings. */ +const CREATE_TENANT = new ApiSettings('/tenants', 'POST') +// Set response validator. + .setResponseValidator((response: any) => { + // Response should always contain at least the tenant name. + if (!validator.isNonEmptyString(response.name) || + !Tenant.getTenantIdFromResourceName(response.name)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Unable to create new tenant', + ); + } + }); + + +/** + * Utility for sending requests to Auth server that are Auth instance related. This includes user, tenant, + * and project config management related APIs. This extends the BaseFirebaseAuthRequestHandler class and defines + * additional tenant management related APIs. + */ +export class AuthRequestHandler extends AbstractAuthRequestHandler { + + protected readonly authResourceUrlBuilder: AuthResourceUrlBuilder; + + /** + * The FirebaseAuthRequestHandler constructor used to initialize an instance using a FirebaseApp. + * + * @param app - The app used to fetch access tokens to sign API requests. + * @constructor. + */ + constructor(app: App) { + super(app); + this.authResourceUrlBuilder = new AuthResourceUrlBuilder(app, 'v2'); + } + + /** + * @returns A new Auth user management resource URL builder instance. + */ + protected newAuthUrlBuilder(): AuthResourceUrlBuilder { + return new AuthResourceUrlBuilder(this.app, 'v1'); + } + + /** + * @returns A new project config resource URL builder instance. + */ + protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + return new AuthResourceUrlBuilder(this.app, 'v2'); + } + + /** + * Get the current project's config + * @returns A promise that resolves with the project config information. + */ + public getProjectConfig(): Promise { + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_PROJECT_CONFIG, {}, {}) + .then((response: any) => { + return response as ProjectConfigServerResponse; + }); + } + + /** + * Update the current project's config. + * @returns A promise that resolves with the project config information. + */ + public updateProjectConfig(options: UpdateProjectConfigRequest): Promise { + try { + const request = ProjectConfig.buildServerRequest(options); + const updateMask = utils.generateUpdateMask(request); + return this.invokeRequestHandler( + this.authResourceUrlBuilder, UPDATE_PROJECT_CONFIG, request, { updateMask: updateMask.join(',') }) + .then((response: any) => { + return response as ProjectConfigServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Looks up a tenant by tenant ID. + * + * @param tenantId - The tenant identifier of the tenant to lookup. + * @returns A promise that resolves with the tenant information. + */ + public getTenant(tenantId: string): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + return this.invokeRequestHandler(this.authResourceUrlBuilder, GET_TENANT, {}, { tenantId }) + .then((response: any) => { + return response as TenantServerResponse; + }); + } + + /** + * Exports the tenants (single batch only) with a size of maxResults and starting from + * the offset as specified by pageToken. + * + * @param maxResults - The page size, 1000 if undefined. This is also the maximum + * allowed limit. + * @param pageToken - The next page token. If not specified, returns tenants starting + * without any offset. Tenants are returned in the order they were created from oldest to + * newest, relative to the page token offset. + * @returns A promise that resolves with the current batch of downloaded + * tenants and the next page token if available. For the last page, an empty list of tenants + * and no page token are returned. + */ + public listTenants( + maxResults: number = MAX_LIST_TENANT_PAGE_SIZE, + pageToken?: string): Promise<{tenants: TenantServerResponse[]; nextPageToken?: string}> { + const request = { + pageSize: maxResults, + pageToken, + }; + // Remove next page token if not provided. + if (typeof request.pageToken === 'undefined') { + delete request.pageToken; + } + return this.invokeRequestHandler(this.authResourceUrlBuilder, LIST_TENANTS, request) + .then((response: any) => { + if (!response.tenants) { + response.tenants = []; + delete response.nextPageToken; + } + return response as {tenants: TenantServerResponse[]; nextPageToken?: string}; + }); + } + + /** + * Deletes a tenant identified by a tenantId. + * + * @param tenantId - The identifier of the tenant to delete. + * @returns A promise that resolves when the tenant is deleted. + */ + public deleteTenant(tenantId: string): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + return this.invokeRequestHandler(this.authResourceUrlBuilder, DELETE_TENANT, undefined, { tenantId }) + .then(() => { + // Return nothing. + }); + } + + /** + * Creates a new tenant with the properties provided. + * + * @param tenantOptions - The properties to set on the new tenant to be created. + * @returns A promise that resolves with the newly created tenant object. + */ + public createTenant(tenantOptions: CreateTenantRequest): Promise { + try { + // Construct backend request. + const request = Tenant.buildServerRequest(tenantOptions, true); + return this.invokeRequestHandler(this.authResourceUrlBuilder, CREATE_TENANT, request) + .then((response: any) => { + return response as TenantServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } + + /** + * Updates an existing tenant with the properties provided. + * + * @param tenantId - The tenant identifier of the tenant to update. + * @param tenantOptions - The properties to update on the existing tenant. + * @returns A promise that resolves with the modified tenant object. + */ + public updateTenant(tenantId: string, tenantOptions: UpdateTenantRequest): Promise { + if (!validator.isNonEmptyString(tenantId)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_TENANT_ID)); + } + try { + // Construct backend request. + const request = Tenant.buildServerRequest(tenantOptions, false); + // Do not traverse deep into testPhoneNumbers. The entire content should be replaced + // and not just specific phone numbers. + const updateMask = utils.generateUpdateMask(request, ['testPhoneNumbers']); + return this.invokeRequestHandler(this.authResourceUrlBuilder, UPDATE_TENANT, request, + { tenantId, updateMask: updateMask.join(',') }) + .then((response: any) => { + return response as TenantServerResponse; + }); + } catch (e) { + return Promise.reject(e); + } + } +} + +/** + * Utility for sending requests to Auth server that are tenant Auth instance related. This includes user + * management related APIs for specified tenants. + * This extends the BaseFirebaseAuthRequestHandler class. + */ +export class TenantAwareAuthRequestHandler extends AbstractAuthRequestHandler { + /** + * The FirebaseTenantRequestHandler constructor used to initialize an instance using a + * FirebaseApp and a tenant ID. + * + * @param app - The app used to fetch access tokens to sign API requests. + * @param tenantId - The request handler's tenant ID. + * @constructor + */ + constructor(app: App, private readonly tenantId: string) { + super(app); + } + + /** + * @returns A new Auth user management resource URL builder instance. + */ + protected newAuthUrlBuilder(): AuthResourceUrlBuilder { + return new TenantAwareAuthResourceUrlBuilder(this.app, 'v1', this.tenantId); + } + + /** + * @returns A new project config resource URL builder instance. + */ + protected newProjectConfigUrlBuilder(): AuthResourceUrlBuilder { + return new TenantAwareAuthResourceUrlBuilder(this.app, 'v2', this.tenantId); + } + + /** + * Imports the list of users provided to Firebase Auth. This is useful when + * migrating from an external authentication system without having to use the Firebase CLI SDK. + * At most, 1000 users are allowed to be imported one at a time. + * When importing a list of password users, UserImportOptions are required to be specified. + * + * Overrides the superclass methods by adding an additional check to match tenant IDs of + * imported user records if present. + * + * @param users - The list of user records to import to Firebase Auth. + * @param options - The user import options, required when the users provided + * include password credentials. + * @returns A promise that resolves when the operation completes + * with the result of the import. This includes the number of successful imports, the number + * of failed uploads and their corresponding errors. + */ + public uploadAccount( + users: UserImportRecord[], options?: UserImportOptions): Promise { + // Add additional check to match tenant ID of imported user records. + users.forEach((user: UserImportRecord, index: number) => { + if (validator.isNonEmptyString(user.tenantId) && + user.tenantId !== this.tenantId) { + throw new FirebaseAuthError( + AuthClientErrorCode.MISMATCHING_TENANT_ID, + `UserRecord of index "${index}" has mismatching tenant ID "${user.tenantId}"`); + } + }); + return super.uploadAccount(users, options); + } +} + +function emulatorHost(): string | undefined { + return process.env.FIREBASE_AUTH_EMULATOR_HOST +} + +/** + * When true the SDK should communicate with the Auth Emulator for all API + * calls and also produce unsigned tokens. + */ +export function useEmulator(): boolean { + return !!emulatorHost(); +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_config.dart b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart new file mode 100644 index 0000000..e4f5ffa --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart @@ -0,0 +1,329 @@ +import 'package:firebaseapis/identitytoolkit/v1.dart' as v1; + +import '../dart_firebase_admin.dart'; + +const _sentinel = _Sentinel(); + +class _Sentinel { + const _Sentinel(); +} + +/// An object used to differentiate "no value" from "a null value". +/// +/// This is typically used to enable `update(displayName: null)`. +class Box { + Box(this.value); + + static Box? unwrap(Object? value) { + if (value == _sentinel) return null; + return Box(value as T?); + } + + final T value; +} + +/// Interface representing the properties to set on a new user record to be +/// created. +class CreateRequest extends _BaseUpdateRequest { + CreateRequest({ + super.disabled, + super.displayName, + super.email, + super.emailVerified, + super.password, + super.phoneNumber, + super.photoURL, + this.multiFactor, + this.uid, + }) : assert( + multiFactor is! MultiFactorUpdateSettings, + 'MultiFactorUpdateSettings is not supported for create requests.', + ); + + /// The user's `uid`. + final String? uid; + + /// The user's multi-factor related properties. + final MultiFactorCreateSettings? multiFactor; +} + +/// Interface representing the properties to update on the provided user. +class UpdateRequest extends _BaseUpdateRequest { + /// Interface representing the properties to update on the provided user. + UpdateRequest({ + super.disabled, + String? super.displayName, + super.email, + super.emailVerified, + super.password, + String? super.phoneNumber, + String? super.photoURL, + this.multiFactor, + this.providerToLink, + this.providersToUnlink, + }); + + UpdateRequest._({ + super.disabled, + super.displayName, + super.email, + super.emailVerified, + super.password, + super.phoneNumber, + super.photoURL, + this.multiFactor, + this.providerToLink, + this.providersToUnlink, + }); + + /// The user's updated multi-factor related properties. + final MultiFactorUpdateSettings? multiFactor; + + /// Links this user to the specified provider. + /// + /// Linking a provider to an existing user account does not invalidate the + /// refresh token of that account. In other words, the existing account + /// would continue to be able to access resources, despite not having used + /// the newly linked provider to log in. If you wish to force the user to + /// authenticate with this new provider, you need to (a) revoke their + /// refresh token (see + /// https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + /// and (b) ensure no other authentication methods are present on this + /// account. + final UserProvider? providerToLink; + + /// Unlinks this user from the specified providers. + final List? providersToUnlink; + + UpdateRequest Function({String? email, String? phoneNumber}) get copyWith { + // ignore: avoid_types_on_closure_parameters, false positive + return ({Object? email = _sentinel, Object? phoneNumber = _sentinel}) { + return UpdateRequest._( + disabled: disabled, + displayName: displayName, + email: email == _sentinel ? this.email : email as String?, + emailVerified: emailVerified, + password: password, + phoneNumber: phoneNumber == _sentinel + ? this.phoneNumber + : phoneNumber as String?, + photoURL: photoURL, + multiFactor: multiFactor, + providerToLink: providerToLink, + providersToUnlink: providersToUnlink, + ); + }; + } +} + +class _BaseUpdateRequest { + /// A base request to update a user. + /// This supports differentiating between unset properties and clearing + /// properties by setting them to `null`. + /// + /// As in `UpdateRequest()` vs `UpdateRequest(displayName: null)`. + /// + /// Use [UpdateRequest] directly instead, as this constructor has some + /// untyped parameters. + _BaseUpdateRequest({ + required this.disabled, + Object? displayName = _sentinel, + required this.email, + required this.emailVerified, + required this.password, + Object? phoneNumber = _sentinel, + Object? photoURL = _sentinel, + }) : displayName = Box.unwrap(displayName), + phoneNumber = Box.unwrap(phoneNumber), + photoURL = Box.unwrap(photoURL); + + /// Whether or not the user is disabled: `true` for disabled; + /// `false` for enabled. + final bool? disabled; + + /// The user's display name. + final Box? displayName; + + /// The user's primary email. + final String? email; + + /// Whether or not the user's primary email is verified. + final bool? emailVerified; + + /// The user's unhashed password. + final String? password; + + /// The user's primary phone number. + final Box? phoneNumber; + + /// The user's photo URL. + final Box? photoURL; +} + +/// Represents a user identity provider that can be associated with a Firebase user. +class UserProvider { + UserProvider({ + this.uid, + this.displayName, + this.email, + this.phoneNumber, + this.photoURL, + this.providerId, + }); + + /// The user identifier for the linked provider. + final String? uid; + + /// The display name for the linked provider. + final String? displayName; + + /// The email for the linked provider. + final String? email; + + /// The phone number for the linked provider. + final String? phoneNumber; + + /// The photo URL for the linked provider. + final String? photoURL; + + /// The linked provider ID (for example, "google.com" for the Google provider). + final String? providerId; + + v1.GoogleCloudIdentitytoolkitV1ProviderUserInfo toProviderUserInfo() { + return v1.GoogleCloudIdentitytoolkitV1ProviderUserInfo( + displayName: displayName, + email: email, + phoneNumber: phoneNumber, + photoUrl: photoURL, + providerId: providerId, + rawId: uid, + federatedId: null, + screenName: null, + ); + } +} + +/// The multi-factor related user settings for update operations. +class MultiFactorUpdateSettings { + MultiFactorUpdateSettings({this.enrolledFactors}); + + /// The updated list of enrolled second factors. The provided list overwrites the user's + /// existing list of second factors. + /// When null is passed, all of the user's existing second factors are removed. + final List? enrolledFactors; + + v1.GoogleCloudIdentitytoolkitV1MfaInfo toMfaInfo() { + final enrolledFactors = this.enrolledFactors; + if (enrolledFactors == null || enrolledFactors.isEmpty) { + // Remove all second factors. + return v1.GoogleCloudIdentitytoolkitV1MfaInfo(); + } + + return v1.GoogleCloudIdentitytoolkitV1MfaInfo( + enrollments: enrolledFactors.map((e) => e.toMfaEnrollment()).toList(), + ); + } +} + +/// The multi-factor related user settings for create operations. +class MultiFactorCreateSettings { + MultiFactorCreateSettings({ + required this.enrolledFactors, + }); + + /// The created user's list of enrolled second factors. + final List enrolledFactors; +} + +/// Interface representing a phone specific user-enrolled second factor for a +/// `CreateRequest`. +class CreatePhoneMultiFactorInfoRequest extends CreateMultiFactorInfoRequest { + CreatePhoneMultiFactorInfoRequest({ + required super.displayName, + required this.phoneNumber, + }); + + /// The phone number associated with a phone second factor. + final String phoneNumber; + + @override + v1.GoogleCloudIdentitytoolkitV1MfaFactor + toGoogleCloudIdentitytoolkitV1MfaFactor() { + return v1.GoogleCloudIdentitytoolkitV1MfaFactor( + displayName: displayName, + // TODO param is optional, but phoneNumber is required. + phoneInfo: phoneNumber, + ); + } +} + +/// Interface representing base properties of a user-enrolled second factor for a +/// `CreateRequest`. +sealed class CreateMultiFactorInfoRequest { + CreateMultiFactorInfoRequest({ + required this.displayName, + }); + + /// The optional display name for an enrolled second factor. + final String? displayName; + + v1.GoogleCloudIdentitytoolkitV1MfaFactor + toGoogleCloudIdentitytoolkitV1MfaFactor(); +} + +/// Interface representing a phone specific user-enrolled second factor +/// for an `UpdateRequest`. +class UpdatePhoneMultiFactorInfoRequest extends UpdateMultiFactorInfoRequest { + UpdatePhoneMultiFactorInfoRequest({ + required this.phoneNumber, + super.uid, + super.displayName, + super.enrollmentTime, + }); + + /// The phone number associated with a phone second factor. + final String phoneNumber; +} + +/// Interface representing common properties of a user-enrolled second factor +/// for an `UpdateRequest`. +sealed class UpdateMultiFactorInfoRequest { + UpdateMultiFactorInfoRequest({ + this.uid, + this.displayName, + this.enrollmentTime, + }) { + final enrollmentTime = this.enrollmentTime; + if (enrollmentTime != null && !enrollmentTime.isUtc) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidEnrollmentTime, + 'The second factor "enrollmentTime" for "$uid" must be a valid ' + 'UTC date.', + ); + } + } + + /// The ID of the enrolled second factor. This ID is unique to the user. When not provided, + /// a new one is provisioned by the Auth server. + final String? uid; + + /// The optional display name for an enrolled second factor. + final String? displayName; + + /// The optional date the second factor was enrolled. + final DateTime? enrollmentTime; + + v1.GoogleCloudIdentitytoolkitV1MfaEnrollment toMfaEnrollment() { + final that = this; + return switch (that) { + UpdatePhoneMultiFactorInfoRequest() => + v1.GoogleCloudIdentitytoolkitV1MfaEnrollment( + mfaEnrollmentId: uid, + displayName: displayName, + // Required for all phone second factors. + phoneInfo: that.phoneNumber, + enrolledAt: enrollmentTime?.toUtc().toIso8601String(), + ), + }; + } +} diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_config.ts b/packages/dart_firebase_admin/lib/src/auth/auth_config.ts new file mode 100644 index 0000000..3629157 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/auth_config.ts @@ -0,0 +1,2281 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from '../utils/validator'; +import { deepCopy } from '../utils/deep-copy'; +import { AuthClientErrorCode, FirebaseAuthError } from '../utils/error'; + +/** + * Interface representing base properties of a user-enrolled second factor for a + * `CreateRequest`. + */ +export interface BaseCreateMultiFactorInfoRequest { + + /** + * The optional display name for an enrolled second factor. + */ + displayName?: string; + + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; +} + +/** + * Interface representing a phone specific user-enrolled second factor for a + * `CreateRequest`. + */ +export interface CreatePhoneMultiFactorInfoRequest extends BaseCreateMultiFactorInfoRequest { + + /** + * The phone number associated with a phone second factor. + */ + phoneNumber: string; +} + +/** + * Type representing the properties of a user-enrolled second factor + * for a `CreateRequest`. + */ +export type CreateMultiFactorInfoRequest = | CreatePhoneMultiFactorInfoRequest; + +/** + * Interface representing common properties of a user-enrolled second factor + * for an `UpdateRequest`. + */ +export interface BaseUpdateMultiFactorInfoRequest { + + /** + * The ID of the enrolled second factor. This ID is unique to the user. When not provided, + * a new one is provisioned by the Auth server. + */ + uid?: string; + + /** + * The optional display name for an enrolled second factor. + */ + displayName?: string; + + /** + * The optional date the second factor was enrolled, formatted as a UTC string. + */ + enrollmentTime?: string; + + /** + * The type identifier of the second factor. For SMS second factors, this is `phone`. + */ + factorId: string; +} + +/** + * Interface representing a phone specific user-enrolled second factor + * for an `UpdateRequest`. + */ +export interface UpdatePhoneMultiFactorInfoRequest extends BaseUpdateMultiFactorInfoRequest { + + /** + * The phone number associated with a phone second factor. + */ + phoneNumber: string; +} + +/** + * Type representing the properties of a user-enrolled second factor + * for an `UpdateRequest`. + */ +export type UpdateMultiFactorInfoRequest = | UpdatePhoneMultiFactorInfoRequest; + +/** + * The multi-factor related user settings for create operations. + */ +export interface MultiFactorCreateSettings { + + /** + * The created user's list of enrolled second factors. + */ + enrolledFactors: CreateMultiFactorInfoRequest[]; +} + +/** + * The multi-factor related user settings for update operations. + */ +export interface MultiFactorUpdateSettings { + + /** + * The updated list of enrolled second factors. The provided list overwrites the user's + * existing list of second factors. + * When null is passed, all of the user's existing second factors are removed. + */ + enrolledFactors: UpdateMultiFactorInfoRequest[] | null; +} + +/** + * Interface representing the properties to update on the provided user. + */ +export interface UpdateRequest { + + /** + * Whether or not the user is disabled: `true` for disabled; + * `false` for enabled. + */ + disabled?: boolean; + + /** + * The user's display name. + */ + displayName?: string | null; + + /** + * The user's primary email. + */ + email?: string; + + /** + * Whether or not the user's primary email is verified. + */ + emailVerified?: boolean; + + /** + * The user's unhashed password. + */ + password?: string; + + /** + * The user's primary phone number. + */ + phoneNumber?: string | null; + + /** + * The user's photo URL. + */ + photoURL?: string | null; + + /** + * The user's updated multi-factor related properties. + */ + multiFactor?: MultiFactorUpdateSettings; + + /** + * Links this user to the specified provider. + * + * Linking a provider to an existing user account does not invalidate the + * refresh token of that account. In other words, the existing account + * would continue to be able to access resources, despite not having used + * the newly linked provider to log in. If you wish to force the user to + * authenticate with this new provider, you need to (a) revoke their + * refresh token (see + * https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + * and (b) ensure no other authentication methods are present on this + * account. + */ + providerToLink?: UserProvider; + + /** + * Unlinks this user from the specified providers. + */ + providersToUnlink?: string[]; +} + +/** + * Represents a user identity provider that can be associated with a Firebase user. + */ +export interface UserProvider { + + /** + * The user identifier for the linked provider. + */ + uid?: string; + + /** + * The display name for the linked provider. + */ + displayName?: string; + + /** + * The email for the linked provider. + */ + email?: string; + + /** + * The phone number for the linked provider. + */ + phoneNumber?: string; + + /** + * The photo URL for the linked provider. + */ + photoURL?: string; + + /** + * The linked provider ID (for example, "google.com" for the Google provider). + */ + providerId?: string; +} + + +/** + * Interface representing the properties to set on a new user record to be + * created. + */ +export interface CreateRequest extends UpdateRequest { + + /** + * The user's `uid`. + */ + uid?: string; + + /** + * The user's multi-factor related properties. + */ + multiFactor?: MultiFactorCreateSettings; +} + +/** + * The response interface for listing provider configs. This is only available + * when listing all identity providers' configurations via + * {@link BaseAuth.listProviderConfigs}. + */ +export interface ListProviderConfigResults { + + /** + * The list of providers for the specified type in the current page. + */ + providerConfigs: AuthProviderConfig[]; + + /** + * The next page token, if available. + */ + pageToken?: string; +} + +/** + * The filter interface used for listing provider configurations. This is used + * when specifying how to list configured identity providers via + * {@link BaseAuth.listProviderConfigs}. + */ +export interface AuthProviderConfigFilter { + + /** + * The Auth provider configuration filter. This can be either `saml` or `oidc`. + * The former is used to look up SAML providers only, while the latter is used + * for OIDC providers. + */ + type: 'saml' | 'oidc'; + + /** + * The maximum number of results to return per page. The default and maximum is + * 100. + */ + maxResults?: number; + + /** + * The next page token. When not specified, the lookup starts from the beginning + * of the list. + */ + pageToken?: string; +} + +/** + * The request interface for updating a SAML Auth provider. This is used + * when updating a SAML provider's configuration via + * {@link BaseAuth.updateProviderConfig}. + */ +export interface SAMLUpdateAuthProviderRequest { + + /** + * The SAML provider's updated display name. If not provided, the existing + * configuration's value is not modified. + */ + displayName?: string; + + /** + * Whether the SAML provider is enabled or not. If not provided, the existing + * configuration's setting is not modified. + */ + enabled?: boolean; + + /** + * The SAML provider's updated IdP entity ID. If not provided, the existing + * configuration's value is not modified. + */ + idpEntityId?: string; + + /** + * The SAML provider's updated SSO URL. If not provided, the existing + * configuration's value is not modified. + */ + ssoURL?: string; + + /** + * The SAML provider's updated list of X.509 certificated. If not provided, the + * existing configuration list is not modified. + */ + x509Certificates?: string[]; + + /** + * The SAML provider's updated RP entity ID. If not provided, the existing + * configuration's value is not modified. + */ + rpEntityId?: string; + + /** + * The SAML provider's callback URL. If not provided, the existing + * configuration's value is not modified. + */ + callbackURL?: string; +} + +/** + * The request interface for updating an OIDC Auth provider. This is used + * when updating an OIDC provider's configuration via + * {@link BaseAuth.updateProviderConfig}. + */ +export interface OIDCUpdateAuthProviderRequest { + + /** + * The OIDC provider's updated display name. If not provided, the existing + * configuration's value is not modified. + */ + displayName?: string; + + /** + * Whether the OIDC provider is enabled or not. If not provided, the existing + * configuration's setting is not modified. + */ + enabled?: boolean; + + /** + * The OIDC provider's updated client ID. If not provided, the existing + * configuration's value is not modified. + */ + clientId?: string; + + /** + * The OIDC provider's updated issuer. If not provided, the existing + * configuration's value is not modified. + */ + issuer?: string; + + /** + * The OIDC provider's client secret to enable OIDC code flow. + * If not provided, the existing configuration's value is not modified. + */ + clientSecret?: string; + + /** + * The OIDC provider's response object for OAuth authorization flow. + */ + responseType?: OAuthResponseType; +} + +export type UpdateAuthProviderRequest = + SAMLUpdateAuthProviderRequest | OIDCUpdateAuthProviderRequest; + +/** A maximum of 10 test phone number / code pairs can be configured. */ +export const MAXIMUM_TEST_PHONE_NUMBERS = 10; + +/** The server side SAML configuration request interface. */ +export interface SAMLConfigServerRequest { + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; + [key: string]: any; +} + +/** The server side SAML configuration response interface. */ +export interface SAMLConfigServerResponse { + // Used when getting config. + // projects/${projectId}/inboundSamlConfigs/${providerId} + name?: string; + idpConfig?: { + idpEntityId?: string; + ssoUrl?: string; + idpCertificates?: Array<{ + x509Certificate: string; + }>; + signRequest?: boolean; + }; + spConfig?: { + spEntityId?: string; + callbackUri?: string; + }; + displayName?: string; + enabled?: boolean; +} + +/** The server side OIDC configuration request interface. */ +export interface OIDCConfigServerRequest { + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; + clientSecret?: string; + responseType?: OAuthResponseType; + [key: string]: any; +} + +/** The server side OIDC configuration response interface. */ +export interface OIDCConfigServerResponse { + // Used when getting config. + // projects/${projectId}/oauthIdpConfigs/${providerId} + name?: string; + clientId?: string; + issuer?: string; + displayName?: string; + enabled?: boolean; + clientSecret?: string; + responseType?: OAuthResponseType; +} + +/** The server side email configuration request interface. */ +export interface EmailSignInConfigServerRequest { + allowPasswordSignup?: boolean; + enableEmailLinkSignin?: boolean; +} + +/** Identifies the server side second factor type. */ +type AuthFactorServerType = 'PHONE_SMS'; + +/** Client Auth factor type to server auth factor type mapping. */ +const AUTH_FACTOR_CLIENT_TO_SERVER_TYPE: {[key: string]: AuthFactorServerType} = { + phone: 'PHONE_SMS', +}; + +/** Server Auth factor type to client auth factor type mapping. */ +const AUTH_FACTOR_SERVER_TO_CLIENT_TYPE: {[key: string]: AuthFactorType} = + Object.keys(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE) + .reduce((res: {[key: string]: AuthFactorType}, key) => { + res[AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[key]] = key as AuthFactorType; + return res; + }, {}); + +/** Server side multi-factor configuration. */ +export interface MultiFactorAuthServerConfig { + state?: MultiFactorConfigState; + enabledProviders?: AuthFactorServerType[]; + providerConfigs?: MultiFactorProviderConfig[]; +} + +/** + * Identifies a second factor type. + */ +export type AuthFactorType = 'phone'; + +/** + * Identifies a multi-factor configuration state. + */ +export type MultiFactorConfigState = 'ENABLED' | 'DISABLED'; + +/** + * Interface representing a multi-factor configuration. + * This can be used to define whether multi-factor authentication is enabled + * or disabled and the list of second factor challenges that are supported. + */ +export interface MultiFactorConfig { + /** + * The multi-factor config state. + */ + state: MultiFactorConfigState; + + /** + * The list of identifiers for enabled second factors. + * Currently only ‘phone’ is supported. + */ + factorIds?: AuthFactorType[]; + + /** + * A list of multi-factor provider configurations. + * MFA providers (except phone) indicate whether they're enabled through this field. */ + providerConfigs?: MultiFactorProviderConfig[]; +} + +/** + * Interface representing a multi-factor auth provider configuration. + * This interface is used for second factor auth providers other than SMS. + * Currently, only TOTP is supported. + */export interface MultiFactorProviderConfig { + /** + * Indicates whether this multi-factor provider is enabled or disabled. */ + state: MultiFactorConfigState; + /** + * TOTP multi-factor provider config. */ + totpProviderConfig?: TotpMultiFactorProviderConfig; +} + +/** + * Interface representing configuration settings for TOTP second factor auth. + */ +export interface TotpMultiFactorProviderConfig { + /** + * The allowed number of adjacent intervals that will be used for verification + * to compensate for clock skew. */ + adjacentIntervals?: number; +} + +/** + * Defines the multi-factor config class used to convert client side MultiFactorConfig + * to a format that is understood by the Auth server. + * + * @internal + */ +export class MultiFactorAuthConfig implements MultiFactorConfig { + + /** + * The multi-factor config state. + */ + public readonly state: MultiFactorConfigState; + /** + * The list of identifiers for enabled second factors. + * Currently only ‘phone’ is supported. + */ + public readonly factorIds: AuthFactorType[]; + /** + * A list of multi-factor provider specific config. + * New MFA providers (except phone) will indicate enablement/disablement through this field. + */ + public readonly providerConfigs: MultiFactorProviderConfig[]; + + /** + * Static method to convert a client side request to a MultiFactorAuthServerConfig. + * Throws an error if validation fails. + * + * @param options - The options object to convert to a server request. + * @returns The resulting server request. + * @internal + */ + public static buildServerRequest(options: MultiFactorConfig): MultiFactorAuthServerConfig { + const request: MultiFactorAuthServerConfig = {}; + MultiFactorAuthConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'state')) { + request.state = options.state; + } + if (Object.prototype.hasOwnProperty.call(options, 'factorIds')) { + (options.factorIds || []).forEach((factorId) => { + if (typeof request.enabledProviders === 'undefined') { + request.enabledProviders = []; + } + request.enabledProviders.push(AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId]); + }); + // In case an empty array is passed. Ensure it gets populated so the array is cleared. + if (options.factorIds && options.factorIds.length === 0) { + request.enabledProviders = []; + } + } + if (Object.prototype.hasOwnProperty.call(options, 'providerConfigs')) { + request.providerConfigs = options.providerConfigs; + } + return request; + } + + /** + * Validates the MultiFactorConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + */ + public static validate(options: MultiFactorConfig): void { + const validKeys = { + state: true, + factorIds: true, + providerConfigs: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid MultiFactorConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.state !== 'undefined' && + options.state !== 'ENABLED' && + options.state !== 'DISABLED') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.state" must be either "ENABLED" or "DISABLED".', + ); + } + + if (typeof options.factorIds !== 'undefined') { + if (!validator.isArray(options.factorIds)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.factorIds" must be an array of valid "AuthFactorTypes".', + ); + } + + // Validate content of array. + options.factorIds.forEach((factorId) => { + if (typeof AUTH_FACTOR_CLIENT_TO_SERVER_TYPE[factorId] === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${factorId}" is not a valid "AuthFactorType".`, + ); + } + }); + } + + if (typeof options.providerConfigs !== 'undefined') { + if (!validator.isArray(options.providerConfigs)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs" must be an array of valid "MultiFactorProviderConfig."', + ); + } + //Validate content of array. + options.providerConfigs.forEach((multiFactorProviderConfig) => { + if (typeof multiFactorProviderConfig === 'undefined' || !validator.isObject(multiFactorProviderConfig)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${multiFactorProviderConfig}" is not a valid "MultiFactorProviderConfig" type.` + ) + } + const validProviderConfigKeys = { + state: true, + totpProviderConfig: true, + }; + for (const key in multiFactorProviderConfig) { + if (!(key in validProviderConfigKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid ProviderConfig parameter.`, + ); + } + } + if (typeof multiFactorProviderConfig.state === 'undefined' || + (multiFactorProviderConfig.state !== 'ENABLED' && + multiFactorProviderConfig.state !== 'DISABLED')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs.state" must be either "ENABLED" or "DISABLED".', + ) + } + // Since TOTP is the only provider config available right now, not defining it will lead into an error + if (typeof multiFactorProviderConfig.totpProviderConfig === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"MultiFactorConfig.providerConfigs.totpProviderConfig" must be defined.' + ) + } + const validTotpProviderConfigKeys = { + adjacentIntervals: true, + }; + for (const key in multiFactorProviderConfig.totpProviderConfig) { + if (!(key in validTotpProviderConfigKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid TotpProviderConfig parameter.`, + ); + } + } + const adjIntervals = multiFactorProviderConfig.totpProviderConfig.adjacentIntervals + if (typeof adjIntervals !== 'undefined' && + (!Number.isInteger(adjIntervals) || adjIntervals < 0 || adjIntervals > 10)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"MultiFactorConfig.providerConfigs.totpProviderConfig.adjacentIntervals" must' + + ' be a valid number between 0 and 10 (both inclusive).' + ) + } + }); + } + } + + /** + * The MultiFactorAuthConfig constructor. + * + * @param response - The server side response used to initialize the + * MultiFactorAuthConfig object. + * @constructor + * @internal + */ + constructor(response: MultiFactorAuthServerConfig) { + if (typeof response.state === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + } + this.state = response.state; + this.factorIds = []; + (response.enabledProviders || []).forEach((enabledProvider) => { + // Ignore unsupported types. It is possible the current admin SDK version is + // not up to date and newer backend types are supported. + if (typeof AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider] !== 'undefined') { + this.factorIds.push(AUTH_FACTOR_SERVER_TO_CLIENT_TYPE[enabledProvider]); + } + }) + this.providerConfigs = []; + (response.providerConfigs || []).forEach((providerConfig) => { + if (typeof providerConfig !== 'undefined') { + if (typeof providerConfig.state === 'undefined' || + typeof providerConfig.totpProviderConfig === 'undefined' || + (typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'undefined' && + typeof providerConfig.totpProviderConfig.adjacentIntervals !== 'number')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid multi-factor configuration response'); + } + this.providerConfigs.push(providerConfig); + } + }) + } + + /** Converts MultiFactorConfig to JSON object + * @returns The plain object representation of the multi-factor config instance. */ + public toJSON(): object { + return { + state: this.state, + factorIds: this.factorIds, + providerConfigs: this.providerConfigs, + }; + } +} + + +/** + * Validates the provided map of test phone number / code pairs. + * @param testPhoneNumbers - The phone number / code pairs to validate. + */ +export function validateTestPhoneNumbers( + testPhoneNumbers: {[phoneNumber: string]: string}, +): void { + if (!validator.isObject(testPhoneNumbers)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"testPhoneNumbers" must be a map of phone number / code pairs.', + ); + } + if (Object.keys(testPhoneNumbers).length > MAXIMUM_TEST_PHONE_NUMBERS) { + throw new FirebaseAuthError(AuthClientErrorCode.MAXIMUM_TEST_PHONE_NUMBER_EXCEEDED); + } + for (const phoneNumber in testPhoneNumbers) { + // Validate phone number. + if (!validator.isPhoneNumber(phoneNumber)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER, + `"${phoneNumber}" is not a valid E.164 standard compliant phone number.` + ); + } + + // Validate code. + if (!validator.isString(testPhoneNumbers[phoneNumber]) || + !/^[\d]{6}$/.test(testPhoneNumbers[phoneNumber])) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_TESTING_PHONE_NUMBER, + `"${testPhoneNumbers[phoneNumber]}" is not a valid 6 digit code string.` + ); + } + } +} + +/** + * The email sign in provider configuration. + */ +export interface EmailSignInProviderConfig { + /** + * Whether email provider is enabled. + */ + enabled: boolean; + + /** + * Whether password is required for email sign-in. When not required, + * email sign-in can be performed with password or via email link sign-in. + */ + passwordRequired?: boolean; // In the backend API, default is true if not provided +} + + +/** + * Defines the email sign-in config class used to convert client side EmailSignInConfig + * to a format that is understood by the Auth server. + * + * @internal + */ +export class EmailSignInConfig implements EmailSignInProviderConfig { + public readonly enabled: boolean; + public readonly passwordRequired?: boolean; + + /** + * Static method to convert a client side request to a EmailSignInConfigServerRequest. + * Throws an error if validation fails. + * + * @param options - The options object to convert to a server request. + * @returns The resulting server request. + * @internal + */ + public static buildServerRequest(options: EmailSignInProviderConfig): EmailSignInConfigServerRequest { + const request: EmailSignInConfigServerRequest = {}; + EmailSignInConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'enabled')) { + request.allowPasswordSignup = options.enabled; + } + if (Object.prototype.hasOwnProperty.call(options, 'passwordRequired')) { + request.enableEmailLinkSignin = !options.passwordRequired; + } + return request; + } + + /** + * Validates the EmailSignInConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + */ + private static validate(options: EmailSignInProviderConfig): void { + // TODO: Validate the request. + const validKeys = { + enabled: true, + passwordRequired: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `"${key}" is not a valid EmailSignInConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.enabled" must be a boolean.', + ); + } + if (typeof options.passwordRequired !== 'undefined' && + !validator.isBoolean(options.passwordRequired)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"EmailSignInConfig.passwordRequired" must be a boolean.', + ); + } + } + + /** + * The EmailSignInConfig constructor. + * + * @param response - The server side response used to initialize the + * EmailSignInConfig object. + * @constructor + */ + constructor(response: {[key: string]: any}) { + if (typeof response.allowPasswordSignup === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid email sign-in configuration response'); + } + this.enabled = response.allowPasswordSignup; + this.passwordRequired = !response.enableEmailLinkSignin; + } + + /** @returns The plain object representation of the email sign-in config. */ + public toJSON(): object { + return { + enabled: this.enabled, + passwordRequired: this.passwordRequired, + }; + } +} + +/** + * The base Auth provider configuration interface. + */ +export interface BaseAuthProviderConfig { + + /** + * The provider ID defined by the developer. + * For a SAML provider, this is always prefixed by `saml.`. + * For an OIDC provider, this is always prefixed by `oidc.`. + */ + providerId: string; + + /** + * The user-friendly display name to the current configuration. This name is + * also used as the provider label in the Cloud Console. + */ + displayName?: string; + + /** + * Whether the provider configuration is enabled or disabled. A user + * cannot sign in using a disabled provider. + */ + enabled: boolean; +} + +/** + * The + * [SAML](http://docs.oasis-open.org/security/saml/Post2.0/sstc-saml-tech-overview-2.0.html) + * Auth provider configuration interface. A SAML provider can be created via + * {@link BaseAuth.createProviderConfig}. + */ +export interface SAMLAuthProviderConfig extends BaseAuthProviderConfig { + + /** + * The SAML IdP entity identifier. + */ + idpEntityId: string; + + /** + * The SAML IdP SSO URL. This must be a valid URL. + */ + ssoURL: string; + + /** + * The list of SAML IdP X.509 certificates issued by CA for this provider. + * Multiple certificates are accepted to prevent outages during + * IdP key rotation (for example ADFS rotates every 10 days). When the Auth + * server receives a SAML response, it will match the SAML response with the + * certificate on record. Otherwise the response is rejected. + * Developers are expected to manage the certificate updates as keys are + * rotated. + */ + x509Certificates: string[]; + + /** + * The SAML relying party (service provider) entity ID. + * This is defined by the developer but needs to be provided to the SAML IdP. + */ + rpEntityId: string; + + /** + * This is fixed and must always be the same as the OAuth redirect URL + * provisioned by Firebase Auth, + * `https://project-id.firebaseapp.com/__/auth/handler` unless a custom + * `authDomain` is used. + * The callback URL should also be provided to the SAML IdP during + * configuration. + */ + callbackURL?: string; +} + +/** + * The interface representing OIDC provider's response object for OAuth + * authorization flow. + * One of the following settings is required: + *
    + *
  • Set code to true for the code flow.
  • + *
  • Set idToken to true for the ID token flow.
  • + *
+ */ +export interface OAuthResponseType { + /** + * Whether ID token is returned from IdP's authorization endpoint. + */ + idToken?: boolean; + + /** + * Whether authorization code is returned from IdP's authorization endpoint. + */ + code?: boolean; +} + +/** + * The [OIDC](https://openid.net/specs/openid-connect-core-1_0-final.html) Auth + * provider configuration interface. An OIDC provider can be created via + * {@link BaseAuth.createProviderConfig}. + */ +export interface OIDCAuthProviderConfig extends BaseAuthProviderConfig { + + /** + * This is the required client ID used to confirm the audience of an OIDC + * provider's + * [ID token](https://openid.net/specs/openid-connect-core-1_0-final.html#IDToken). + */ + clientId: string; + + /** + * This is the required provider issuer used to match the provider issuer of + * the ID token and to determine the corresponding OIDC discovery document, eg. + * [`/.well-known/openid-configuration`](https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig). + * This is needed for the following: + *
    + *
  • To verify the provided issuer.
  • + *
  • Determine the authentication/authorization endpoint during the OAuth + * `id_token` authentication flow.
  • + *
  • To retrieve the public signing keys via `jwks_uri` to verify the OIDC + * provider's ID token's signature.
  • + *
  • To determine the claims_supported to construct the user attributes to be + * returned in the additional user info response.
  • + *
+ * ID token validation will be performed as defined in the + * [spec](https://openid.net/specs/openid-connect-core-1_0.html#IDTokenValidation). + */ + issuer: string; + + /** + * The OIDC provider's client secret to enable OIDC code flow. + */ + clientSecret?: string; + + /** + * The OIDC provider's response object for OAuth authorization flow. + */ + responseType?: OAuthResponseType; +} + +/** + * The Auth provider configuration type. + * {@link BaseAuth.createProviderConfig}. + */ +export type AuthProviderConfig = SAMLAuthProviderConfig | OIDCAuthProviderConfig; + +/** + * Defines the SAMLConfig class used to convert a client side configuration to its + * server side representation. + * + * @internal + */ +export class SAMLConfig implements SAMLAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly idpEntityId: string; + public readonly ssoURL: string; + public readonly x509Certificates: string[]; + public readonly rpEntityId: string; + public readonly callbackURL?: string; + public readonly enableRequestSigning?: boolean; + + /** + * Converts a client side request to a SAMLConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a SAMLConfig request, + * returns null. + * + * @param options - The options object to convert to a server request. + * @param ignoreMissingFields - Whether to ignore missing fields. + * @returns The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: Partial, + ignoreMissingFields = false): SAMLConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: SAMLConfigServerRequest = {}; + // Validate options. + SAMLConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + // IdP config. + if (options.idpEntityId || options.ssoURL || options.x509Certificates) { + request.idpConfig = { + idpEntityId: options.idpEntityId, + ssoUrl: options.ssoURL, + signRequest: (options as any).enableRequestSigning, + idpCertificates: typeof options.x509Certificates === 'undefined' ? undefined : [], + }; + if (options.x509Certificates) { + for (const cert of (options.x509Certificates || [])) { + request.idpConfig!.idpCertificates!.push({ x509Certificate: cert }); + } + } + } + // RP config. + if (options.callbackURL || options.rpEntityId) { + request.spConfig = { + spEntityId: options.rpEntityId, + callbackUri: options.callbackURL, + }; + } + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param resourceName - The server side resource name. + * @returns The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/inboundSamlConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/inboundSamlConfigs\/(saml\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param providerId - The provider ID to check. + * @returns Whether the provider ID corresponds to a SAML provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('saml.') === 0; + } + + /** + * Validates the SAMLConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + * @param ignoreMissingFields - Whether to ignore missing fields. + */ + public static validate(options: Partial, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + idpEntityId: true, + ssoURL: true, + x509Certificates: true, + rpEntityId: true, + callbackURL: true, + enableRequestSigning: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SAML config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('saml.') !== 0) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + } else if (!ignoreMissingFields) { + // providerId is required and not provided correctly. + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"SAMLAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "saml.".', + ); + } + if (!(ignoreMissingFields && typeof options.idpEntityId === 'undefined') && + !validator.isNonEmptyString(options.idpEntityId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.idpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.ssoURL === 'undefined') && + !validator.isURL(options.ssoURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.ssoURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.rpEntityId === 'undefined') && + !validator.isNonEmptyString(options.rpEntityId)) { + throw new FirebaseAuthError( + !options.rpEntityId ? AuthClientErrorCode.MISSING_SAML_RELYING_PARTY_CONFIG : + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.rpEntityId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.callbackURL === 'undefined') && + !validator.isURL(options.callbackURL)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.callbackURL" must be a valid URL string.', + ); + } + if (!(ignoreMissingFields && typeof options.x509Certificates === 'undefined') && + !validator.isArray(options.x509Certificates)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + (options.x509Certificates || []).forEach((cert: string) => { + if (!validator.isNonEmptyString(cert)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.x509Certificates" must be a valid array of X509 certificate strings.', + ); + } + }); + if (typeof (options as any).enableRequestSigning !== 'undefined' && + !validator.isBoolean((options as any).enableRequestSigning)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enableRequestSigning" must be a boolean.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SAMLAuthProviderConfig.displayName" must be a valid string.', + ); + } + } + + /** + * The SAMLConfig constructor. + * + * @param response - The server side response used to initialize the SAMLConfig object. + * @constructor + */ + constructor(response: SAMLConfigServerResponse) { + if (!response || + !response.idpConfig || + !response.idpConfig.idpEntityId || + !response.idpConfig.ssoUrl || + !response.spConfig || + !response.spConfig.spEntityId || + !response.name || + !(validator.isString(response.name) && + SAMLConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + + const providerId = SAMLConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + // RP config. + this.rpEntityId = response.spConfig.spEntityId; + this.callbackURL = response.spConfig.callbackUri; + // IdP config. + this.idpEntityId = response.idpConfig.idpEntityId; + this.ssoURL = response.idpConfig.ssoUrl; + this.enableRequestSigning = !!response.idpConfig.signRequest; + const x509Certificates: string[] = []; + for (const cert of (response.idpConfig.idpCertificates || [])) { + if (cert.x509Certificate) { + x509Certificates.push(cert.x509Certificate); + } + } + this.x509Certificates = x509Certificates; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + } + + /** @returns The plain object representation of the SAMLConfig. */ + public toJSON(): object { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + idpEntityId: this.idpEntityId, + ssoURL: this.ssoURL, + x509Certificates: deepCopy(this.x509Certificates), + rpEntityId: this.rpEntityId, + callbackURL: this.callbackURL, + enableRequestSigning: this.enableRequestSigning, + }; + } +} + +/** + * Defines the OIDCConfig class used to convert a client side configuration to its + * server side representation. + * + * @internal + */ +export class OIDCConfig implements OIDCAuthProviderConfig { + public readonly enabled: boolean; + public readonly displayName?: string; + public readonly providerId: string; + public readonly issuer: string; + public readonly clientId: string; + public readonly clientSecret?: string; + public readonly responseType: OAuthResponseType; + + /** + * Converts a client side request to a OIDCConfigServerRequest which is the format + * accepted by the backend server. + * Throws an error if validation fails. If the request is not a OIDCConfig request, + * returns null. + * + * @param options - The options object to convert to a server request. + * @param ignoreMissingFields - Whether to ignore missing fields. + * @returns The resulting server request or null if not valid. + */ + public static buildServerRequest( + options: Partial, + ignoreMissingFields = false): OIDCConfigServerRequest | null { + const makeRequest = validator.isNonNullObject(options) && + (options.providerId || ignoreMissingFields); + if (!makeRequest) { + return null; + } + const request: OIDCConfigServerRequest = {}; + // Validate options. + OIDCConfig.validate(options, ignoreMissingFields); + request.enabled = options.enabled; + request.displayName = options.displayName; + request.issuer = options.issuer; + request.clientId = options.clientId; + if (typeof options.clientSecret !== 'undefined') { + request.clientSecret = options.clientSecret; + } + if (typeof options.responseType !== 'undefined') { + request.responseType = options.responseType; + } + return request; + } + + /** + * Returns the provider ID corresponding to the resource name if available. + * + * @param resourceName - The server side resource name + * @returns The provider ID corresponding to the resource, null otherwise. + */ + public static getProviderIdFromResourceName(resourceName: string): string | null { + // name is of form projects/project1/oauthIdpConfigs/providerId1 + const matchProviderRes = resourceName.match(/\/oauthIdpConfigs\/(oidc\..*)$/); + if (!matchProviderRes || matchProviderRes.length < 2) { + return null; + } + return matchProviderRes[1]; + } + + /** + * @param providerId - The provider ID to check. + * @returns Whether the provider ID corresponds to an OIDC provider. + */ + public static isProviderId(providerId: any): providerId is string { + return validator.isNonEmptyString(providerId) && providerId.indexOf('oidc.') === 0; + } + + /** + * Validates the OIDCConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + * @param ignoreMissingFields - Whether to ignore missing fields. + */ + public static validate(options: Partial, ignoreMissingFields = false): void { + const validKeys = { + enabled: true, + displayName: true, + providerId: true, + clientId: true, + issuer: true, + clientSecret: true, + responseType: true, + }; + const validResponseTypes = { + idToken: true, + code: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig" must be a valid non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid OIDC config parameter.`, + ); + } + } + // Required fields. + if (validator.isNonEmptyString(options.providerId)) { + if (options.providerId.indexOf('oidc.') !== 0) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + } else if (!ignoreMissingFields) { + throw new FirebaseAuthError( + !options.providerId ? AuthClientErrorCode.MISSING_PROVIDER_ID : AuthClientErrorCode.INVALID_PROVIDER_ID, + '"OIDCAuthProviderConfig.providerId" must be a valid non-empty string prefixed with "oidc.".', + ); + } + if (!(ignoreMissingFields && typeof options.clientId === 'undefined') && + !validator.isNonEmptyString(options.clientId)) { + throw new FirebaseAuthError( + !options.clientId ? AuthClientErrorCode.MISSING_OAUTH_CLIENT_ID : AuthClientErrorCode.INVALID_OAUTH_CLIENT_ID, + '"OIDCAuthProviderConfig.clientId" must be a valid non-empty string.', + ); + } + if (!(ignoreMissingFields && typeof options.issuer === 'undefined') && + !validator.isURL(options.issuer)) { + throw new FirebaseAuthError( + !options.issuer ? AuthClientErrorCode.MISSING_ISSUER : AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.issuer" must be a valid URL string.', + ); + } + if (typeof options.enabled !== 'undefined' && + !validator.isBoolean(options.enabled)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.enabled" must be a boolean.', + ); + } + if (typeof options.displayName !== 'undefined' && + !validator.isString(options.displayName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.displayName" must be a valid string.', + ); + } + if (typeof options.clientSecret !== 'undefined' && + !validator.isNonEmptyString(options.clientSecret)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"OIDCAuthProviderConfig.clientSecret" must be a valid string.', + ); + } + if (validator.isNonNullObject(options.responseType) && typeof options.responseType !== 'undefined') { + Object.keys(options.responseType).forEach((key) => { + if (!(key in validResponseTypes)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid OAuthResponseType parameter.`, + ); + } + }); + + const idToken = options.responseType.idToken; + if (typeof idToken !== 'undefined' && !validator.isBoolean(idToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"OIDCAuthProviderConfig.responseType.idToken" must be a boolean.', + ); + } + const code = options.responseType.code; + if (typeof code !== 'undefined') { + if (!validator.isBoolean(code)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"OIDCAuthProviderConfig.responseType.code" must be a boolean.', + ); + } + // If code flow is enabled, client secret must be provided. + if (code && typeof options.clientSecret === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.MISSING_OAUTH_CLIENT_SECRET, + 'The OAuth configuration client secret is required to enable OIDC code flow.', + ); + } + } + + const allKeys = Object.keys(options.responseType).length; + const enabledCount = Object.values(options.responseType).filter(Boolean).length; + // Only one of OAuth response types can be set to true. + if (allKeys > 1 && enabledCount !== 1) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_OAUTH_RESPONSETYPE, + 'Only exactly one OAuth responseType should be set to true.', + ); + } + } + } + + /** + * The OIDCConfig constructor. + * + * @param response - The server side response used to initialize the OIDCConfig object. + * @constructor + */ + constructor(response: OIDCConfigServerResponse) { + if (!response || + !response.issuer || + !response.clientId || + !response.name || + !(validator.isString(response.name) && + OIDCConfig.getProviderIdFromResourceName(response.name))) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid OIDC configuration response'); + } + + const providerId = OIDCConfig.getProviderIdFromResourceName(response.name); + if (!providerId) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid SAML configuration response'); + } + this.providerId = providerId; + + this.clientId = response.clientId; + this.issuer = response.issuer; + // When enabled is undefined, it takes its default value of false. + this.enabled = !!response.enabled; + this.displayName = response.displayName; + + if (typeof response.clientSecret !== 'undefined') { + this.clientSecret = response.clientSecret; + } + if (typeof response.responseType !== 'undefined') { + this.responseType = response.responseType; + } + } + + /** @returns The plain object representation of the OIDCConfig. */ + public toJSON(): OIDCAuthProviderConfig { + return { + enabled: this.enabled, + displayName: this.displayName, + providerId: this.providerId, + issuer: this.issuer, + clientId: this.clientId, + clientSecret: deepCopy(this.clientSecret), + responseType: deepCopy(this.responseType), + }; + } +} + +/** + * The request interface for updating a SMS Region Config. + * Configures the regions where users are allowed to send verification SMS. + * This is based on the calling code of the destination phone number. + */ +export type SmsRegionConfig = AllowByDefaultWrap | AllowlistOnlyWrap; + +/** + * Mutual exclusive SMS Region Config of AllowByDefault interface + */ +export interface AllowByDefaultWrap { + /** + * Allow every region by default. + */ + allowByDefault: AllowByDefault; + /** @alpha */ + allowlistOnly?: never; +} + +/** + * Mutually exclusive SMS Region Config of AllowlistOnly interface + */ +export interface AllowlistOnlyWrap { + /** + * Only allowing regions by explicitly adding them to an + * allowlist. + */ + allowlistOnly: AllowlistOnly; + /** @alpha */ + allowByDefault?: never; +} + +/** + * Defines a policy of allowing every region by default and adding disallowed + * regions to a disallow list. + */ +export interface AllowByDefault { + /** + * Two letter unicode region codes to disallow as defined by + * https://cldr.unicode.org/ + * The full list of these region codes is here: + * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json + */ + disallowedRegions: string[]; +} + +/** + * Defines a policy of only allowing regions by explicitly adding them to an + * allowlist. + */ +export interface AllowlistOnly { + /** + * Two letter unicode region codes to allow as defined by + * https://cldr.unicode.org/ + * The full list of these region codes is here: + * https://github.com/unicode-cldr/cldr-localenames-full/blob/master/main/en/territories.json + */ + allowedRegions: string[]; +} + +/** + * Defines the SMSRegionConfig class used for validation. + * + * @internal + */ +export class SmsRegionsAuthConfig { + public static validate(options: SmsRegionConfig): void { + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig" must be a non-null object.', + ); + } + + const validKeys = { + allowlistOnly: true, + allowByDefault: true, + }; + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig parameter.`, + ); + } + } + + // validate mutual exclusiveness of allowByDefault and allowlistOnly + if (typeof options.allowByDefault !== 'undefined' && typeof options.allowlistOnly !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'SmsRegionConfig cannot have both "allowByDefault" and "allowlistOnly" parameters.', + ); + } + // validation for allowByDefault type + if (typeof options.allowByDefault !== 'undefined') { + const allowByDefaultValidKeys = { + disallowedRegions: true, + } + for (const key in options.allowByDefault) { + if (!(key in allowByDefaultValidKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig.allowByDefault parameter.`, + ); + } + } + // disallowedRegion can be empty. + if (typeof options.allowByDefault.disallowedRegions !== 'undefined' + && !validator.isArray(options.allowByDefault.disallowedRegions)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig.allowByDefault.disallowedRegions" must be a valid string array.', + ); + } + } + + if (typeof options.allowlistOnly !== 'undefined') { + const allowListOnlyValidKeys = { + allowedRegions: true, + } + for (const key in options.allowlistOnly) { + if (!(key in allowListOnlyValidKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid SmsRegionConfig.allowlistOnly parameter.`, + ); + } + } + + // allowedRegions can be empty + if (typeof options.allowlistOnly.allowedRegions !== 'undefined' + && !validator.isArray(options.allowlistOnly.allowedRegions)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"SmsRegionConfig.allowlistOnly.allowedRegions" must be a valid string array.', + ); + } + } + } +} +/** +* Enforcement state of reCAPTCHA protection. +* - 'OFF': Unenforced. +* - 'AUDIT': Create assessment but don't enforce the result. +* - 'ENFORCE': Create assessment and enforce the result. +*/ +export type RecaptchaProviderEnforcementState = 'OFF' | 'AUDIT' | 'ENFORCE'; + +/** +* The actions to take for reCAPTCHA-protected requests. +* - 'BLOCK': The reCAPTCHA-protected request will be blocked. +*/ +export type RecaptchaAction = 'BLOCK'; + +/** + * The config for a reCAPTCHA action rule. + */ +export interface RecaptchaManagedRule { + /** + * The action will be enforced if the reCAPTCHA score of a request is larger than endScore. + */ + endScore: number; + /** + * The action for reCAPTCHA-protected requests. + */ + action?: RecaptchaAction; +} + +/** + * The key's platform type. + */ +export type RecaptchaKeyClientType = 'WEB' | 'IOS' | 'ANDROID'; + +/** + * The reCAPTCHA key config. + */ +export interface RecaptchaKey { + /** + * The key's client platform type. + */ + type?: RecaptchaKeyClientType; + + /** + * The reCAPTCHA site key. + */ + key: string; +} + +/** + * The request interface for updating a reCAPTCHA Config. + * By enabling reCAPTCHA Enterprise Integration you are + * agreeing to reCAPTCHA Enterprise + * {@link https://cloud.google.com/terms/service-terms | Term of Service}. + */ +export interface RecaptchaConfig { + /** + * The enforcement state of the email password provider. + */ + emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + /** + * The reCAPTCHA managed rules. + */ + managedRules?: RecaptchaManagedRule[]; + + /** + * The reCAPTCHA keys. + */ + recaptchaKeys?: RecaptchaKey[]; + + /** + * Whether to use account defender for reCAPTCHA assessment. + * The default value is false. + */ + useAccountDefender?: boolean; +} + +export class RecaptchaAuthConfig implements RecaptchaConfig { + public readonly emailPasswordEnforcementState?: RecaptchaProviderEnforcementState; + public readonly managedRules?: RecaptchaManagedRule[]; + public readonly recaptchaKeys?: RecaptchaKey[]; + public readonly useAccountDefender?: boolean; + + constructor(recaptchaConfig: RecaptchaConfig) { + this.emailPasswordEnforcementState = recaptchaConfig.emailPasswordEnforcementState; + this.managedRules = recaptchaConfig.managedRules; + this.recaptchaKeys = recaptchaConfig.recaptchaKeys; + this.useAccountDefender = recaptchaConfig.useAccountDefender; + } + + /** + * Validates the RecaptchaConfig options object. Throws an error on failure. + * @param options - The options object to validate. + */ + public static validate(options: RecaptchaConfig): void { + const validKeys = { + emailPasswordEnforcementState: true, + managedRules: true, + recaptchaKeys: true, + useAccountDefender: true, + }; + + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig" must be a non-null object.', + ); + } + + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid RecaptchaConfig parameter.`, + ); + } + } + + // Validation + if (typeof options.emailPasswordEnforcementState !== undefined) { + if (!validator.isNonEmptyString(options.emailPasswordEnforcementState)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"RecaptchaConfig.emailPasswordEnforcementState" must be a valid non-empty string.', + ); + } + + if (options.emailPasswordEnforcementState !== 'OFF' && + options.emailPasswordEnforcementState !== 'AUDIT' && + options.emailPasswordEnforcementState !== 'ENFORCE') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.emailPasswordEnforcementState" must be either "OFF", "AUDIT" or "ENFORCE".', + ); + } + } + + if (typeof options.managedRules !== 'undefined') { + // Validate array + if (!validator.isArray(options.managedRules)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.managedRules" must be an array of valid "RecaptchaManagedRule".', + ); + } + // Validate each rule of the array + options.managedRules.forEach((managedRule) => { + RecaptchaAuthConfig.validateManagedRule(managedRule); + }); + } + + if (typeof options.useAccountDefender !== 'undefined') { + if (!validator.isBoolean(options.useAccountDefender)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaConfig.useAccountDefender" must be a boolean value".', + ); + } + } + } + + /** + * Validate each element in ManagedRule array + * @param options - The options object to validate. + */ + private static validateManagedRule(options: RecaptchaManagedRule): void { + const validKeys = { + endScore: true, + action: true, + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid RecaptchaManagedRule parameter.`, + ); + } + } + + // Validate content. + if (typeof options.action !== 'undefined' && + options.action !== 'BLOCK') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"RecaptchaManagedRule.action" must be "BLOCK".', + ); + } + } + + /** + * Returns a JSON-serializable representation of this object. + * @returns The JSON-serializable object representation of the ReCaptcha config instance + */ + public toJSON(): object { + const json: any = { + emailPasswordEnforcementState: this.emailPasswordEnforcementState, + managedRules: deepCopy(this.managedRules), + recaptchaKeys: deepCopy(this.recaptchaKeys), + useAccountDefender: this.useAccountDefender, + } + + if (typeof json.emailPasswordEnforcementState === 'undefined') { + delete json.emailPasswordEnforcementState; + } + if (typeof json.managedRules === 'undefined') { + delete json.managedRules; + } + if (typeof json.recaptchaKeys === 'undefined') { + delete json.recaptchaKeys; + } + + if (typeof json.useAccountDefender === 'undefined') { + delete json.useAccountDefender; + } + + return json; + } +} + +/** + * A password policy configuration for a project or tenant +*/ +export interface PasswordPolicyConfig { + /** + * Enforcement state of the password policy + */ + enforcementState?: PasswordPolicyEnforcementState; + /** + * Require users to have a policy-compliant password to sign in + */ + forceUpgradeOnSignin?: boolean; + /** + * The constraints that make up the password strength policy + */ + constraints?: CustomStrengthOptionsConfig; +} + +/** + * A password policy's enforcement state. + */ +export type PasswordPolicyEnforcementState = 'ENFORCE' | 'OFF'; + +/** + * Constraints to be enforced on the password policy + */ +export interface CustomStrengthOptionsConfig { + /** + * The password must contain an upper case character + */ + requireUppercase?: boolean; + /** + * The password must contain a lower case character + */ + requireLowercase?: boolean; + /** + * The password must contain a non-alphanumeric character + */ + requireNonAlphanumeric?: boolean; + /** + * The password must contain a number + */ + requireNumeric?: boolean; + /** + * Minimum password length. Valid values are from 6 to 30 + */ + minLength?: number; + /** + * Maximum password length. No default max length + */ + maxLength?: number; +} + +/** + * Defines the password policy config class used to convert client side PasswordPolicyConfig + * to a format that is understood by the Auth server. + * + * @internal + */ +export class PasswordPolicyAuthConfig implements PasswordPolicyConfig { + + /** + * Identifies a password policy configuration state. + */ + public readonly enforcementState: PasswordPolicyEnforcementState; + /** + * Users must have a password compliant with the password policy to sign-in + */ + public readonly forceUpgradeOnSignin: boolean; + /** + * Must be of length 1. Contains the strength attributes for the password policy + */ + public readonly constraints?: CustomStrengthOptionsConfig; + + /** + * Static method to convert a client side request to a PasswordPolicyAuthServerConfig. + * Throws an error if validation fails. + * + * @param options - The options object to convert to a server request. + * @returns The resulting server request. + * @internal + */ + public static buildServerRequest(options: PasswordPolicyConfig): PasswordPolicyAuthServerConfig { + const request: PasswordPolicyAuthServerConfig = {}; + PasswordPolicyAuthConfig.validate(options); + if (Object.prototype.hasOwnProperty.call(options, 'enforcementState')) { + request.passwordPolicyEnforcementState = options.enforcementState; + } + request.forceUpgradeOnSignin = false; + if (Object.prototype.hasOwnProperty.call(options, 'forceUpgradeOnSignin')) { + request.forceUpgradeOnSignin = options.forceUpgradeOnSignin; + } + const constraintsRequest: CustomStrengthOptionsAuthServerConfig = { + containsUppercaseCharacter: false, + containsLowercaseCharacter: false, + containsNonAlphanumericCharacter: false, + containsNumericCharacter: false, + minPasswordLength: 6, + maxPasswordLength: 4096, + }; + request.passwordPolicyVersions = []; + if (Object.prototype.hasOwnProperty.call(options, 'constraints')) { + if (options) { + if (options.constraints?.requireUppercase !== undefined) { + constraintsRequest.containsUppercaseCharacter = options.constraints.requireUppercase; + } + if (options.constraints?.requireLowercase !== undefined) { + constraintsRequest.containsLowercaseCharacter = options.constraints.requireLowercase; + } + if (options.constraints?.requireNonAlphanumeric !== undefined) { + constraintsRequest.containsNonAlphanumericCharacter = options.constraints.requireNonAlphanumeric; + } + if (options.constraints?.requireNumeric !== undefined) { + constraintsRequest.containsNumericCharacter = options.constraints.requireNumeric; + } + if (options.constraints?.minLength !== undefined) { + constraintsRequest.minPasswordLength = options.constraints.minLength; + } + if (options.constraints?.maxLength !== undefined) { + constraintsRequest.maxPasswordLength = options.constraints.maxLength; + } + } + } + request.passwordPolicyVersions.push({ customStrengthOptions: constraintsRequest }); + return request; + } + + /** + * Validates the PasswordPolicyConfig options object. Throws an error on failure. + * + * @param options - The options object to validate. + * @internal + */ + public static validate(options: PasswordPolicyConfig): void { + const validKeys = { + enforcementState: true, + forceUpgradeOnSignin: true, + constraints: true, + }; + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig" must be a non-null object.', + ); + } + // Check for unsupported top level attributes. + for (const key in options) { + if (!(key in validKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid PasswordPolicyConfig parameter.`, + ); + } + } + // Validate content. + if (typeof options.enforcementState === 'undefined' || + !(options.enforcementState === 'ENFORCE' || + options.enforcementState === 'OFF')) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.enforcementState" must be either "ENFORCE" or "OFF".', + ); + } + + if (typeof options.forceUpgradeOnSignin !== 'undefined') { + if (!validator.isBoolean(options.forceUpgradeOnSignin)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.forceUpgradeOnSignin" must be a boolean.', + ); + } + } + + if (typeof options.constraints !== 'undefined') { + if (options.enforcementState === 'ENFORCE' && !validator.isNonNullObject(options.constraints)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints" must be a non-empty object.', + ); + } + + const validCharKeys = { + requireUppercase: true, + requireLowercase: true, + requireNumeric: true, + requireNonAlphanumeric: true, + minLength: true, + maxLength: true, + }; + + // Check for unsupported attributes. + for (const key in options.constraints) { + if (!(key in validCharKeys)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + `"${key}" is not a valid PasswordPolicyConfig.constraints parameter.`, + ); + } + } + if (typeof options.constraints.requireUppercase !== 'undefined' && + !validator.isBoolean(options.constraints.requireUppercase)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireUppercase" must be a boolean.', + ); + } + if (typeof options.constraints.requireLowercase !== 'undefined' && + !validator.isBoolean(options.constraints.requireLowercase)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireLowercase" must be a boolean.', + ); + } + if (typeof options.constraints.requireNonAlphanumeric !== 'undefined' && + !validator.isBoolean(options.constraints.requireNonAlphanumeric)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireNonAlphanumeric"' + + ' must be a boolean.', + ); + } + if (typeof options.constraints.requireNumeric !== 'undefined' && + !validator.isBoolean(options.constraints.requireNumeric)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.requireNumeric" must be a boolean.', + ); + } + if (typeof options.constraints.minLength === 'undefined') { + options.constraints.minLength = 6; + } else if (!validator.isNumber(options.constraints.minLength)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.minLength" must be a number.', + ); + } else { + if (!(options.constraints.minLength >= 6 + && options.constraints.minLength <= 30)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.minLength"' + + ' must be an integer between 6 and 30, inclusive.', + ); + } + } + if (typeof options.constraints.maxLength === 'undefined') { + options.constraints.maxLength = 4096; + } else if (!validator.isNumber(options.constraints.maxLength)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.maxLength" must be a number.', + ); + } else { + if (!(options.constraints.maxLength >= options.constraints.minLength && + options.constraints.maxLength <= 4096)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints.maxLength"' + + ' must be greater than or equal to minLength and at max 4096.', + ); + } + } + } else { + if (options.enforcementState === 'ENFORCE') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + '"PasswordPolicyConfig.constraints" must be defined.', + ); + } + } + } + + /** + * The PasswordPolicyAuthConfig constructor. + * + * @param response - The server side response used to initialize the + * PasswordPolicyAuthConfig object. + * @constructor + * @internal + */ + constructor(response: PasswordPolicyAuthServerConfig) { + if (typeof response.passwordPolicyEnforcementState === 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'INTERNAL ASSERT FAILED: Invalid password policy configuration response'); + } + this.enforcementState = response.passwordPolicyEnforcementState; + let constraintsResponse: CustomStrengthOptionsConfig = {}; + if (typeof response.passwordPolicyVersions !== 'undefined') { + (response.passwordPolicyVersions || []).forEach((policyVersion) => { + constraintsResponse = { + requireLowercase: policyVersion.customStrengthOptions?.containsLowercaseCharacter, + requireUppercase: policyVersion.customStrengthOptions?.containsUppercaseCharacter, + requireNonAlphanumeric: policyVersion.customStrengthOptions?.containsNonAlphanumericCharacter, + requireNumeric: policyVersion.customStrengthOptions?.containsNumericCharacter, + minLength: policyVersion.customStrengthOptions?.minPasswordLength, + maxLength: policyVersion.customStrengthOptions?.maxPasswordLength, + }; + }); + } + this.constraints = constraintsResponse; + this.forceUpgradeOnSignin = response.forceUpgradeOnSignin?true:false; + } +} + +/** + * Server side password policy configuration. + */ +export interface PasswordPolicyAuthServerConfig { + passwordPolicyEnforcementState?: PasswordPolicyEnforcementState; + passwordPolicyVersions?: PasswordPolicyVersionsAuthServerConfig[]; + forceUpgradeOnSignin?: boolean; +} + +/** + * Server side password policy versions configuration. + */ +export interface PasswordPolicyVersionsAuthServerConfig { + customStrengthOptions?: CustomStrengthOptionsAuthServerConfig; +} + +/** + * Server side password policy constraints configuration. + */ +export interface CustomStrengthOptionsAuthServerConfig { + containsLowercaseCharacter?: boolean; + containsUppercaseCharacter?: boolean; + containsNumericCharacter?: boolean; + containsNonAlphanumericCharacter?: boolean; + minPasswordLength?: number; + maxPasswordLength?: number; +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_exception.dart b/packages/dart_firebase_admin/lib/src/auth/auth_exception.dart index 0dba898..bd54a2a 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_exception.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_exception.dart @@ -1,27 +1,23 @@ -part of '../../dart_firebase_admin.dart'; +part of '../dart_firebase_admin.dart'; class FirebaseAuthAdminException extends FirebaseAdminException { - FirebaseAuthAdminException._(String code, [String? message]) - : super('auth', code, message); + FirebaseAuthAdminException( + this.errorCode, [ + String? message, + ]) : super('auth', errorCode.name, errorCode.message ?? message); factory FirebaseAuthAdminException.fromServerError( firebase_auth_v1.DetailedApiRequestError error, ) { final code = _authServerToClientCode(error.message) ?? AuthClientErrorCode.unknown; - return FirebaseAuthAdminException._(code.name, code.message); + return FirebaseAuthAdminException(code); } - factory FirebaseAuthAdminException.fromAuthClientErrorCode( - AuthClientErrorCode code, - ) { - return FirebaseAuthAdminException._(code.name, code.message); - } + final AuthClientErrorCode errorCode; @override - String toString() { - return 'FirebaseAuthAdminException: $code: $message'; - } + String toString() => 'FirebaseAuthAdminException: $code: $message'; } extension AuthClientErrorCodeExtension on AuthClientErrorCode { @@ -118,7 +114,7 @@ enum AuthClientErrorCode { userNotFound, notFound, userDisabled, - userNotDisabled, + userNotDisabled; } String? _authClientCodeMessage(AuthClientErrorCode code) { @@ -391,7 +387,6 @@ String? _authClientCodeMessage(AuthClientErrorCode code) { return 'The user must be disabled in order to bulk delete it (or you must pass force=true).'; case AuthClientErrorCode.unknown: - default: return null; } } @@ -634,3 +629,30 @@ AuthClientErrorCode? _authServerToClientCode(String? serverCode) { return null; } + +/// A generic guard wrapper for API calls to handle exceptions. +R authGuard(R Function() cb) { + try { + final value = cb(); + + if (value is Future) { + return value.catchError(_handleException) as R; + } + + return value; + } catch (error, stackTrace) { + _handleException(error, stackTrace); + } +} + +/// Converts a Exception to a FirebaseAdminException. +Never _handleException(Object exception, StackTrace stackTrace) { + if (exception is firebase_auth_v1.DetailedApiRequestError) { + Error.throwWithStackTrace( + FirebaseAuthAdminException.fromServerError(exception), + stackTrace, + ); + } + + Error.throwWithStackTrace(exception, stackTrace); +} diff --git a/packages/dart_firebase_admin/lib/src/auth/base_auth.dart b/packages/dart_firebase_admin/lib/src/auth/base_auth.dart new file mode 100644 index 0000000..1c8e91d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/base_auth.dart @@ -0,0 +1,442 @@ +import 'package:collection/collection.dart'; +import 'package:firebaseapis/identitytoolkit/v1.dart' as auth1; +import 'package:meta/meta.dart'; + +import '../dart_firebase_admin.dart'; +import '../app/core.dart'; +import '../utils/validator.dart'; +import 'auth_api_request.dart'; +import 'auth_config.dart'; +import 'identifier.dart'; +import 'token_verifier.dart'; +import 'user.dart'; +import 'user_import_builder.dart'; + +abstract class BaseAuth { + FirebaseAdminApp get app; + @visibleForOverriding + AbstractAuthRequestHandler get authRequestHandler; + FirebaseTokenVerifier get _sessionCookieVerifier; + + // TODO createCustomToken + // TODO verifyIdToken + // TODO setCustomUserClaims + // TODO revokeRefreshTokens + // TODO createSessionCookie + // TODO verifySessionCookie + // TODO generatePasswordResetLink + // TODO generateEmailVerificationLink + // TODO generateVerifyAndChangeEmailLink + // TODO generateSignInWithEmailLink + // TODO listProviderConfigs + // TODO getProviderConfig + // TODO deleteProviderConfig + // TODO updateProviderConfig + // TODO createProviderConfig + + Future verifySessionCookie( + String sessionCookie, { + bool checkRevoked = false, + }) async { + final isEmulator = app.isUsingEmulator; + throw UnimplementedError(); + } + + /// Imports the provided list of users into Firebase Auth. + /// A maximum of 1000 users are allowed to be imported one at a time. + /// When importing users with passwords, + /// [UserImportOptions] are required to be + /// specified. + /// This operation is optimized for bulk imports and will ignore checks on `uid`, + /// `email` and other identifier uniqueness which could result in duplications. + /// + /// - users - The list of user records to import to Firebase Auth. + /// - options - The user import options, required when the users provided include + /// password credentials. + /// + /// Returns a Future that resolves when + /// the operation completes with the result of the import. This includes the + /// number of successful imports, the number of failed imports and their + /// corresponding errors. + Future importUsers( + List users, [ + UserImportOptions? options, + ]) async { + return authRequestHandler.uploadAccount(users, options); + } + + /// Retrieves a list of users (single batch only) with a size of `maxResults` + /// starting from the offset as specified by `pageToken`. This is used to + /// retrieve all the users of a specified project in batches. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#list_all_users + /// for code samples and detailed documentation. + /// + /// - maxResults - The page size, 1000 if undefined. This is also + /// the maximum allowed limit. + /// - pageToken - The next page token. If not specified, returns + /// users starting without any offset. + /// + /// Returns a promise that resolves with + /// the current batch of downloaded users and the next page token. + Future listUsers({ + int? maxResults, + String? pageToken, + }) async { + final response = await authRequestHandler.downloadAccount( + maxResults: maxResults, + pageToken: pageToken, + ); + + final users = + response.users?.map(UserRecord.fromResponse).toList() ?? []; + + return ListUsersResult._( + users: users, + pageToken: response.nextPageToken, + ); + } + + /// Deletes an existing user. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#delete_a_user + /// for code samples and detailed documentation. + /// + /// Returns an empty promise fulfilled once the user has been + /// deleted. + Future deleteUser(String uid) async { + await authRequestHandler.deleteAccount(uid); + } + + /// Deletes the users specified by the given uids. + /// + /// Deleting a non-existing user won't generate an error (i.e. this method + /// is idempotent.) Non-existing users are considered to be successfully + /// deleted, and are therefore counted in the + /// `DeleteUsersResult.successCount` value. + /// + /// Only a maximum of 1000 identifiers may be supplied. If more than 1000 + /// identifiers are supplied, this method throws a FirebaseAuthError. + /// + /// This API is currently rate limited at the server to 1 QPS. If you exceed + /// this, you may get a quota exceeded error. Therefore, if you want to + /// delete more than 1000 users, you may need to add a delay to ensure you + /// don't go over this limit. + /// + /// Returns a Futrue that resolves to the total number of successful/failed + /// deletions, as well as the array of errors that corresponds to the + /// failed deletions. + Future deleteUsers(List uids) async { + uids.forEach(assertIsUid); + + final response = await authRequestHandler.deleteAccounts(uids, force: true); + final errors = response.errors ?? + []; + + return DeleteUsersResult._( + successCount: uids.length - errors.length, + failureCount: errors.length, + errors: errors.map((batchDeleteErrorInfo) { + final index = batchDeleteErrorInfo.index; + if (index == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'Corrupt BatchDeleteAccountsResponse detected', + ); + } + + FirebaseAuthAdminException errMsgToError(String? msg) { + // We unconditionally set force=true, so the 'NOT_DISABLED' error + // should not be possible. + final code = msg != null && msg.startsWith('NOT_DISABLED') + ? AuthClientErrorCode.userNotDisabled + : AuthClientErrorCode.internalError; + + return FirebaseAuthAdminException(code, batchDeleteErrorInfo.message); + } + + return FirebaseArrayIndexError( + index: index, + error: errMsgToError(batchDeleteErrorInfo.message), + ); + }).toList(), + ); + } + + /// Gets the user data for the user corresponding to a given `uid`. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data + /// for code samples and detailed documentation. + /// + /// Returns a Future fulfilled with the use data corresponding to the provided `uid`. + Future getUser(String uid) async { + final response = await authRequestHandler.getAccountInfoByUid(uid); + // Returns the user record populated with server response. + return UserRecord.fromResponse(response); + } + + /// Gets the user data for the user corresponding to a given phone number. The + /// phone number has to conform to the E.164 specification. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data + /// for code samples and detailed documentation. + /// + /// Takes the phone number corresponding to the user whose + /// data to fetch. + /// + /// Returns a Future fulfilled with the user + /// data corresponding to the provided phone number. + Future getUserByPhoneNumber(String phoneNumber) async { + final response = + await authRequestHandler.getAccountInfoByPhoneNumber(phoneNumber); + // Returns the user record populated with server response. + return UserRecord.fromResponse(response); + } + + /// Gets the user data for the user corresponding to a given email. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data + /// for code samples and detailed documentation. + /// + /// Receives the email corresponding to the user whose data to fetch. + /// + /// Returns a promise fulfilled with the user + /// data corresponding to the provided email. + Future getUserByEmail(String email) async { + final response = await authRequestHandler.getAccountInfoByEmail(email); + // Returns the user record populated with server response. + return UserRecord.fromResponse(response); + } + + /// Gets the user data for the user corresponding to a given provider id. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data + /// for code samples and detailed documentation. + /// + /// - `providerId`: The provider ID, for example, "google.com" for the + /// Google provider. + /// - `uid`: The user identifier for the given provider. + /// + /// Returns a Future fulfilled with the user data corresponding to the + /// given provider id. + Future getUserByProviderUid({ + required String providerId, + required String uid, + }) async { + // Although we don't really advertise it, we want to also handle + // non-federated idps with this call. So if we detect one of them, we'll + // reroute this request appropriately. + if (providerId == 'phone') { + return getUserByPhoneNumber(uid); + } else if (providerId == 'email') { + return getUserByEmail(uid); + } + + final response = await authRequestHandler.getAccountInfoByFederatedUid( + providerId: providerId, + rawId: uid, + ); + + // Returns the user record populated with server response. + return UserRecord.fromResponse(response); + } + + /// Gets the user data corresponding to the specified identifiers. + /// + /// There are no ordering guarantees; in particular, the nth entry in the result list is not + /// guaranteed to correspond to the nth entry in the input parameters list. + /// + /// Only a maximum of 100 identifiers may be supplied. If more than 100 identifiers are supplied, + /// this method throws a FirebaseAuthError. + /// + /// Takes a list of [UserIdentifier] used to indicate which user records should be returned. + /// Must not have more than 100 entries. + /// + /// Returns a Future that resolves to the corresponding user records. + /// Throws [FirebaseAdminException] if any of the identifiers are invalid or if more than 100 + /// identifiers are specified. + Future getUsers(List identifiers) async { + final response = + await authRequestHandler.getAccountInfoByIdentifiers(identifiers); + + final userRecords = response.users?.map(UserRecord.fromResponse).toList() ?? + const []; + + // Checks if the specified identifier is within the list of UserRecords. + bool isUserFound(UserIdentifier id) { + return userRecords.any((userRecord) { + switch (id) { + case UidIdentifier(): + return id.uid == userRecord.uid; + case EmailIdentifier(): + return id.email == userRecord.email; + case PhoneIdentifier(): + return id.phoneNumber == userRecord.phoneNumber; + case ProviderIdentifier(): + final matchingUserInfo = userRecord.providerData + .firstWhereOrNull((userInfo) => userInfo.phoneNumber != null); + return matchingUserInfo != null && + id.providerUid == matchingUserInfo.uid; + } + }); + } + + final notFound = identifiers.where((id) => !isUserFound(id)).toList(); + + return GetUsersResult._(users: userRecords, notFound: notFound); + } + + /// Creates a new user. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#create_a_user + /// for code samples and detailed documentation. + /// + /// Returns A Future fulfilled with the user + /// data corresponding to the newly created user. + Future createUser(CreateRequest properties) async { + return authRequestHandler + .createNewAccount(properties) + // Return the corresponding user record. + .then(getUser) + .onError((error, _) { + if (error.errorCode == AuthClientErrorCode.userNotFound) { + // Something must have happened after creating the user and then retrieving it. + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'Unable to create the user record provided.', + ); + } + throw error; + }); + } + + /// Updates an existing user. + /// + /// See https://firebase.google.com/docs/auth/admin/manage-users#update_a_user + /// for code samples and detailed documentation. + /// + /// - uid - The `uid` corresponding to the user to update. + /// - properties - The properties to update on the provided user. + /// + /// Returns a [Future] fulfilled with the updated user data. + Future updateuser(String uid, UpdateRequest properties) async { + // Although we don't really advertise it, we want to also handle linking of + // non-federated idps with this call. So if we detect one of them, we'll + // adjust the properties parameter appropriately. This *does* imply that a + // conflict could arise, e.g. if the user provides a phoneNumber property, + // but also provides a providerToLink with a 'phone' provider id. In that + // case, we'll throw an error. + var request = properties; + final providerToLink = properties.providerToLink; + switch (providerToLink) { + case UserProvider(providerId: 'email'): + if (properties.email != null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + "Both UpdateRequest.email and UpdateRequest.providerToLink.providerId='email' were set. To " + 'link to the email/password provider, only specify the UpdateRequest.email field.', + ); + } + request = properties.copyWith(email: providerToLink.uid); + case UserProvider(providerId: 'phone'): + if (properties.phoneNumber != null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + "Both UpdateRequest.phoneNumber and UpdateRequest.providerToLink.providerId='phone' were set. To " + 'link to a phone provider, only specify the UpdateRequest.phoneNumber field.', + ); + } + request = properties.copyWith(phoneNumber: providerToLink.uid); + } + final providersToUnlink = properties.providersToUnlink; + if (providersToUnlink != null && providersToUnlink.contains('phone')) { + // If we've been told to unlink the phone provider both via setting + // phoneNumber to null *and* by setting providersToUnlink to include + // 'phone', then we'll reject that. Though it might also be reasonable + // to relax this restriction and just unlink it. + if (properties.phoneNumber == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + "Both UpdateRequest.phoneNumber and UpdateRequest.providersToUnlink=['phone'] were set. To " + 'unlink the phone provider, only specify the UpdateRequest.providersToUnlink field.', + ); + } + } + + final existingUid = + await authRequestHandler.updateExistingAccount(uid, request); + return getUser(existingUid); + } +} + +/// Interface representing the object returned from a +/// [BaseAuth.listUsers] operation. Contains the list +/// of users for the current batch and the next page token if available. +class ListUsersResult { + ListUsersResult._({required this.users, required this.pageToken}); + + /// The list of {@link UserRecord} objects for the + /// current downloaded batch. + final List users; + + /// The next page token if available. This is needed for the next batch download. + final String? pageToken; +} + +/// Represents the result of the {@link BaseAuth.getUsers} API. +class GetUsersResult { + GetUsersResult._({required this.users, required this.notFound}); + + /// Set of user records, corresponding to the set of users that were + /// requested. Only users that were found are listed here. The result set is + /// unordered. + final List users; + + /// Set of identifiers that were requested, but not found. + final List notFound; +} + +/// Represents the result of the {@link BaseAuth.deleteUsers}. +/// API. +class DeleteUsersResult { + DeleteUsersResult._({ + required this.failureCount, + required this.successCount, + required this.errors, + }); + + /// The number of user records that failed to be deleted (possibly zero). + final int failureCount; + + /// The number of users that were deleted successfully (possibly zero). + /// Users that did not exist prior to calling `deleteUsers()` are + /// considered to be successfully deleted. + final int successCount; + + /// A list of `FirebaseArrayIndexError` instances describing the errors that + /// were encountered during the deletion. Length of this list is equal to + /// the return value of {@link DeleteUsersResult.failureCount}. + final List errors; +} + +/// Interface representing the response from the +/// [BaseAuth.importUsers] method for batch +/// importing users to Firebase Auth. +class UserImportResult { + @internal + UserImportResult({ + required this.failureCount, + required this.successCount, + required this.errors, + }); + + /// The number of user records that failed to import to Firebase Auth. + final int failureCount; + + /// The number of user records that successfully imported to Firebase Auth. + final int successCount; + + /// An array of errors corresponding to the provided users to import. The + /// length of this array is equal to [failureCount]. + final List errors; +} diff --git a/packages/dart_firebase_admin/lib/src/auth/base_auth.ts b/packages/dart_firebase_admin/lib/src/auth/base_auth.ts new file mode 100644 index 0000000..229cbc9 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/base_auth.ts @@ -0,0 +1,1144 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { App, FirebaseArrayIndexError } from '../app'; +import { AuthClientErrorCode, ErrorInfo, FirebaseAuthError } from '../utils/error'; +import { deepCopy } from '../utils/deep-copy'; +import * as validator from '../utils/validator'; + +import { AbstractAuthRequestHandler, useEmulator } from './auth-api-request'; +import { FirebaseTokenGenerator, EmulatedSigner, handleCryptoSignerError } from './token-generator'; +import { + FirebaseTokenVerifier, + createSessionCookieVerifier, + createIdTokenVerifier, + createAuthBlockingTokenVerifier, + DecodedIdToken, + DecodedAuthBlockingToken, +} from './token-verifier'; +import { + AuthProviderConfig, SAMLAuthProviderConfig, AuthProviderConfigFilter, ListProviderConfigResults, + SAMLConfig, OIDCConfig, OIDCConfigServerResponse, SAMLConfigServerResponse, + UpdateAuthProviderRequest, OIDCAuthProviderConfig, CreateRequest, UpdateRequest, +} from './auth-config'; +import { UserRecord } from './user-record'; +import { + UserIdentifier, isUidIdentifier, isEmailIdentifier, isPhoneIdentifier, isProviderIdentifier, +} from './identifier'; +import { UserImportOptions, UserImportRecord, UserImportResult } from './user-import-builder'; +import { ActionCodeSettings } from './action-code-settings-builder'; +import { cryptoSignerFromApp } from '../utils/crypto-signer'; + +/** Interface representing a phone number. */ + +/** Represents the result of the {@link BaseAuth.getUsers} API. */ +export interface GetUsersResult { + /** + * Set of user records, corresponding to the set of users that were + * requested. Only users that were found are listed here. The result set is + * unordered. + */ + users: UserRecord[]; + + /** Set of identifiers that were requested, but not found. */ + notFound: UserIdentifier[]; +} + +/** + * Interface representing the object returned from a + * {@link BaseAuth.listUsers} operation. Contains the list + * of users for the current batch and the next page token if available. + */ +export interface ListUsersResult { + + /** + * The list of {@link UserRecord} objects for the + * current downloaded batch. + */ + users: UserRecord[]; + + /** + * The next page token if available. This is needed for the next batch download. + */ + pageToken?: string; +} + +/** + * Represents the result of the {@link BaseAuth.deleteUsers}. + * API. + */ +export interface DeleteUsersResult { + /** + * The number of user records that failed to be deleted (possibly zero). + */ + failureCount: number; + + /** + * The number of users that were deleted successfully (possibly zero). + * Users that did not exist prior to calling `deleteUsers()` are + * considered to be successfully deleted. + */ + successCount: number; + + /** + * A list of `FirebaseArrayIndexError` instances describing the errors that + * were encountered during the deletion. Length of this list is equal to + * the return value of {@link DeleteUsersResult.failureCount}. + */ + errors: FirebaseArrayIndexError[]; +} + +/** + * Interface representing the session cookie options needed for the + * {@link BaseAuth.createSessionCookie} method. + */ +export interface SessionCookieOptions { + + /** + * The session cookie custom expiration in milliseconds. The minimum allowed is + * 5 minutes and the maxium allowed is 2 weeks. + */ + expiresIn: number; +} + +/** + * @internal + */ +export function createFirebaseTokenGenerator(app: App, + tenantId?: string): FirebaseTokenGenerator { + try { + const signer = useEmulator() ? new EmulatedSigner() : cryptoSignerFromApp(app); + return new FirebaseTokenGenerator(signer, tenantId); + } catch (err) { + throw handleCryptoSignerError(err); + } +} + +/** + * Common parent interface for both `Auth` and `TenantAwareAuth` APIs. + */ +export abstract class BaseAuth { + + /** @internal */ + protected readonly tokenGenerator: FirebaseTokenGenerator; + /** @internal */ + protected readonly idTokenVerifier: FirebaseTokenVerifier; + /** @internal */ + protected readonly authBlockingTokenVerifier: FirebaseTokenVerifier; + /** @internal */ + protected readonly sessionCookieVerifier: FirebaseTokenVerifier; + + /** + * The BaseAuth class constructor. + * + * @param app - The FirebaseApp to associate with this Auth instance. + * @param authRequestHandler - The RPC request handler for this instance. + * @param tokenGenerator - Optional token generator. If not specified, a + * (non-tenant-aware) instance will be created. Use this paramter to + * specify a tenant-aware tokenGenerator. + * @constructor + * @internal + */ + constructor( + app: App, + /** @internal */ protected readonly authRequestHandler: AbstractAuthRequestHandler, + tokenGenerator?: FirebaseTokenGenerator) { + if (tokenGenerator) { + this.tokenGenerator = tokenGenerator; + } else { + this.tokenGenerator = createFirebaseTokenGenerator(app); + } + + this.sessionCookieVerifier = createSessionCookieVerifier(app); + this.idTokenVerifier = createIdTokenVerifier(app); + this.authBlockingTokenVerifier = createAuthBlockingTokenVerifier(app); + } + + /** + * Creates a new Firebase custom token (JWT) that can be sent back to a client + * device to use to sign in with the client SDKs' `signInWithCustomToken()` + * methods. (Tenant-aware instances will also embed the tenant ID in the + * token.) + * + * See {@link https://firebase.google.com/docs/auth/admin/create-custom-tokens | Create Custom Tokens} + * for code samples and detailed documentation. + * + * @param uid - The `uid` to use as the custom token's subject. + * @param developerClaims - Optional additional claims to include + * in the custom token's payload. + * + * @returns A promise fulfilled with a custom token for the + * provided `uid` and payload. + */ + public createCustomToken(uid: string, developerClaims?: object): Promise { + return this.tokenGenerator.createCustomToken(uid, developerClaims); + } + + /** + * Verifies a Firebase ID token (JWT). If the token is valid, the promise is + * fulfilled with the token's decoded claims; otherwise, the promise is + * rejected. + * + * If `checkRevoked` is set to true, first verifies whether the corresponding + * user is disabled. If yes, an `auth/user-disabled` error is thrown. If no, + * verifies if the session corresponding to the ID token was revoked. If the + * corresponding user's session was invalidated, an `auth/id-token-revoked` + * error is thrown. If not specified the check is not applied. + * + * See {@link https://firebase.google.com/docs/auth/admin/verify-id-tokens | Verify ID Tokens} + * for code samples and detailed documentation. + * + * @param idToken - The ID token to verify. + * @param checkRevoked - Whether to check if the ID token was revoked. + * This requires an extra request to the Firebase Auth backend to check + * the `tokensValidAfterTime` time for the corresponding user. + * When not specified, this additional check is not applied. + * + * @returns A promise fulfilled with the + * token's decoded claims if the ID token is valid; otherwise, a rejected + * promise. + */ + public verifyIdToken(idToken: string, checkRevoked = false): Promise { + const isEmulator = useEmulator(); + return this.idTokenVerifier.verifyJWT(idToken, isEmulator) + .then((decodedIdToken: DecodedIdToken) => { + // Whether to check if the token was revoked. + if (checkRevoked || isEmulator) { + return this.verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken, + AuthClientErrorCode.ID_TOKEN_REVOKED); + } + return decodedIdToken; + }); + } + + /** + * Gets the user data for the user corresponding to a given `uid`. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} + * for code samples and detailed documentation. + * + * @param uid - The `uid` corresponding to the user whose data to fetch. + * + * @returns A promise fulfilled with the user + * data corresponding to the provided `uid`. + */ + public getUser(uid: string): Promise { + return this.authRequestHandler.getAccountInfoByUid(uid) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Gets the user data for the user corresponding to a given email. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} + * for code samples and detailed documentation. + * + * @param email - The email corresponding to the user whose data to + * fetch. + * + * @returns A promise fulfilled with the user + * data corresponding to the provided email. + */ + public getUserByEmail(email: string): Promise { + return this.authRequestHandler.getAccountInfoByEmail(email) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Gets the user data for the user corresponding to a given phone number. The + * phone number has to conform to the E.164 specification. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} + * for code samples and detailed documentation. + * + * @param phoneNumber - The phone number corresponding to the user whose + * data to fetch. + * + * @returns A promise fulfilled with the user + * data corresponding to the provided phone number. + */ + public getUserByPhoneNumber(phoneNumber: string): Promise { + return this.authRequestHandler.getAccountInfoByPhoneNumber(phoneNumber) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Gets the user data for the user corresponding to a given provider id. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#retrieve_user_data | Retrieve user data} + * for code samples and detailed documentation. + * + * @param providerId - The provider ID, for example, "google.com" for the + * Google provider. + * @param uid - The user identifier for the given provider. + * + * @returns A promise fulfilled with the user data corresponding to the + * given provider id. + */ + public getUserByProviderUid(providerId: string, uid: string): Promise { + // Although we don't really advertise it, we want to also handle + // non-federated idps with this call. So if we detect one of them, we'll + // reroute this request appropriately. + if (providerId === 'phone') { + return this.getUserByPhoneNumber(uid); + } else if (providerId === 'email') { + return this.getUserByEmail(uid); + } + + return this.authRequestHandler.getAccountInfoByFederatedUid(providerId, uid) + .then((response: any) => { + // Returns the user record populated with server response. + return new UserRecord(response.users[0]); + }); + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + * There are no ordering guarantees; in particular, the nth entry in the result list is not + * guaranteed to correspond to the nth entry in the input parameters list. + * + * Only a maximum of 100 identifiers may be supplied. If more than 100 identifiers are supplied, + * this method throws a FirebaseAuthError. + * + * @param identifiers - The identifiers used to indicate which user records should be returned. + * Must not have more than 100 entries. + * @returns A promise that resolves to the corresponding user records. + * @throws FirebaseAuthError If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + */ + public getUsers(identifiers: UserIdentifier[]): Promise { + if (!validator.isArray(identifiers)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, '`identifiers` parameter must be an array'); + } + return this.authRequestHandler + .getAccountInfoByIdentifiers(identifiers) + .then((response: any) => { + /** + * Checks if the specified identifier is within the list of + * UserRecords. + */ + const isUserFound = ((id: UserIdentifier, userRecords: UserRecord[]): boolean => { + return !!userRecords.find((userRecord) => { + if (isUidIdentifier(id)) { + return id.uid === userRecord.uid; + } else if (isEmailIdentifier(id)) { + return id.email === userRecord.email; + } else if (isPhoneIdentifier(id)) { + return id.phoneNumber === userRecord.phoneNumber; + } else if (isProviderIdentifier(id)) { + const matchingUserInfo = userRecord.providerData.find((userInfo) => { + return id.providerId === userInfo.providerId; + }); + return !!matchingUserInfo && id.providerUid === matchingUserInfo.uid; + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unhandled identifier type'); + } + }); + }); + + const users = response.users ? response.users.map((user: any) => new UserRecord(user)) : []; + const notFound = identifiers.filter((id) => !isUserFound(id, users)); + + return { users, notFound }; + }); + } + + /** + * Retrieves a list of users (single batch only) with a size of `maxResults` + * starting from the offset as specified by `pageToken`. This is used to + * retrieve all the users of a specified project in batches. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#list_all_users | List all users} + * for code samples and detailed documentation. + * + * @param maxResults - The page size, 1000 if undefined. This is also + * the maximum allowed limit. + * @param pageToken - The next page token. If not specified, returns + * users starting without any offset. + * @returns A promise that resolves with + * the current batch of downloaded users and the next page token. + */ + public listUsers(maxResults?: number, pageToken?: string): Promise { + return this.authRequestHandler.downloadAccount(maxResults, pageToken) + .then((response: any) => { + // List of users to return. + const users: UserRecord[] = []; + // Convert each user response to a UserRecord. + response.users.forEach((userResponse: any) => { + users.push(new UserRecord(userResponse)); + }); + // Return list of user records and the next page token if available. + const result = { + users, + pageToken: response.nextPageToken, + }; + // Delete result.pageToken if undefined. + if (typeof result.pageToken === 'undefined') { + delete result.pageToken; + } + return result; + }); + } + + /** + * Creates a new user. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#create_a_user | Create a user} + * for code samples and detailed documentation. + * + * @param properties - The properties to set on the + * new user record to be created. + * + * @returns A promise fulfilled with the user + * data corresponding to the newly created user. + */ + public createUser(properties: CreateRequest): Promise { + return this.authRequestHandler.createNewAccount(properties) + .then((uid) => { + // Return the corresponding user record. + return this.getUser(uid); + }) + .catch((error) => { + if (error.code === 'auth/user-not-found') { + // Something must have happened after creating the user and then retrieving it. + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Unable to create the user record provided.'); + } + throw error; + }); + } + + /** + * Deletes an existing user. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#delete_a_user | Delete a user} + * for code samples and detailed documentation. + * + * @param uid - The `uid` corresponding to the user to delete. + * + * @returns An empty promise fulfilled once the user has been + * deleted. + */ + public deleteUser(uid: string): Promise { + return this.authRequestHandler.deleteAccount(uid) + .then(() => { + // Return nothing on success. + }); + } + + /** + * Deletes the users specified by the given uids. + * + * Deleting a non-existing user won't generate an error (i.e. this method + * is idempotent.) Non-existing users are considered to be successfully + * deleted, and are therefore counted in the + * `DeleteUsersResult.successCount` value. + * + * Only a maximum of 1000 identifiers may be supplied. If more than 1000 + * identifiers are supplied, this method throws a FirebaseAuthError. + * + * This API is currently rate limited at the server to 1 QPS. If you exceed + * this, you may get a quota exceeded error. Therefore, if you want to + * delete more than 1000 users, you may need to add a delay to ensure you + * don't go over this limit. + * + * @param uids - The `uids` corresponding to the users to delete. + * + * @returns A Promise that resolves to the total number of successful/failed + * deletions, as well as the array of errors that corresponds to the + * failed deletions. + */ + public deleteUsers(uids: string[]): Promise { + if (!validator.isArray(uids)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, '`uids` parameter must be an array'); + } + return this.authRequestHandler.deleteAccounts(uids, /*force=*/true) + .then((batchDeleteAccountsResponse) => { + const result: DeleteUsersResult = { + failureCount: 0, + successCount: uids.length, + errors: [], + }; + + if (!validator.isNonEmptyArray(batchDeleteAccountsResponse.errors)) { + return result; + } + + result.failureCount = batchDeleteAccountsResponse.errors.length; + result.successCount = uids.length - batchDeleteAccountsResponse.errors.length; + result.errors = batchDeleteAccountsResponse.errors.map((batchDeleteErrorInfo) => { + if (batchDeleteErrorInfo.index === undefined) { + throw new FirebaseAuthError( + AuthClientErrorCode.INTERNAL_ERROR, + 'Corrupt BatchDeleteAccountsResponse detected'); + } + + const errMsgToError = (msg?: string): FirebaseAuthError => { + // We unconditionally set force=true, so the 'NOT_DISABLED' error + // should not be possible. + const code = msg && msg.startsWith('NOT_DISABLED') ? + AuthClientErrorCode.USER_NOT_DISABLED : AuthClientErrorCode.INTERNAL_ERROR; + return new FirebaseAuthError(code, batchDeleteErrorInfo.message); + }; + + return { + index: batchDeleteErrorInfo.index, + error: errMsgToError(batchDeleteErrorInfo.message), + }; + }); + + return result; + }); + } + + /** + * Updates an existing user. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-users#update_a_user | Update a user} + * for code samples and detailed documentation. + * + * @param uid - The `uid` corresponding to the user to update. + * @param properties - The properties to update on + * the provided user. + * + * @returns A promise fulfilled with the + * updated user data. + */ + public updateUser(uid: string, properties: UpdateRequest): Promise { + // Although we don't really advertise it, we want to also handle linking of + // non-federated idps with this call. So if we detect one of them, we'll + // adjust the properties parameter appropriately. This *does* imply that a + // conflict could arise, e.g. if the user provides a phoneNumber property, + // but also provides a providerToLink with a 'phone' provider id. In that + // case, we'll throw an error. + properties = deepCopy(properties); + + if (properties?.providerToLink) { + if (properties.providerToLink.providerId === 'email') { + if (typeof properties.email !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.email and UpdateRequest.providerToLink.providerId='email' were set. To " + + 'link to the email/password provider, only specify the UpdateRequest.email field.'); + } + properties.email = properties.providerToLink.uid; + delete properties.providerToLink; + } else if (properties.providerToLink.providerId === 'phone') { + if (typeof properties.phoneNumber !== 'undefined') { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.phoneNumber and UpdateRequest.providerToLink.providerId='phone' were set. To " + + 'link to a phone provider, only specify the UpdateRequest.phoneNumber field.'); + } + properties.phoneNumber = properties.providerToLink.uid; + delete properties.providerToLink; + } + } + if (properties?.providersToUnlink) { + if (properties.providersToUnlink.indexOf('phone') !== -1) { + // If we've been told to unlink the phone provider both via setting + // phoneNumber to null *and* by setting providersToUnlink to include + // 'phone', then we'll reject that. Though it might also be reasonable + // to relax this restriction and just unlink it. + if (properties.phoneNumber === null) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + "Both UpdateRequest.phoneNumber=null and UpdateRequest.providersToUnlink=['phone'] were set. To " + + 'unlink from a phone provider, only specify the UpdateRequest.phoneNumber=null field.'); + } + } + } + + return this.authRequestHandler.updateExistingAccount(uid, properties) + .then((existingUid) => { + // Return the corresponding user record. + return this.getUser(existingUid); + }); + } + + /** + * Sets additional developer claims on an existing user identified by the + * provided `uid`, typically used to define user roles and levels of + * access. These claims should propagate to all devices where the user is + * already signed in (after token expiration or when token refresh is forced) + * and the next time the user signs in. If a reserved OIDC claim name + * is used (sub, iat, iss, etc), an error is thrown. They are set on the + * authenticated user's ID token JWT. + * + * See {@link https://firebase.google.com/docs/auth/admin/custom-claims | + * Defining user roles and access levels} + * for code samples and detailed documentation. + * + * @param uid - The `uid` of the user to edit. + * @param customUserClaims - The developer claims to set. If null is + * passed, existing custom claims are deleted. Passing a custom claims payload + * larger than 1000 bytes will throw an error. Custom claims are added to the + * user's ID token which is transmitted on every authenticated request. + * For profile non-access related user attributes, use database or other + * separate storage systems. + * @returns A promise that resolves when the operation completes + * successfully. + */ + public setCustomUserClaims(uid: string, customUserClaims: object | null): Promise { + return this.authRequestHandler.setCustomUserClaims(uid, customUserClaims) + .then(() => { + // Return nothing on success. + }); + } + + /** + * Revokes all refresh tokens for an existing user. + * + * This API will update the user's {@link UserRecord.tokensValidAfterTime} to + * the current UTC. It is important that the server on which this is called has + * its clock set correctly and synchronized. + * + * While this will revoke all sessions for a specified user and disable any + * new ID tokens for existing sessions from getting minted, existing ID tokens + * may remain active until their natural expiration (one hour). To verify that + * ID tokens are revoked, use {@link BaseAuth.verifyIdToken} + * where `checkRevoked` is set to true. + * + * @param uid - The `uid` corresponding to the user whose refresh tokens + * are to be revoked. + * + * @returns An empty promise fulfilled once the user's refresh + * tokens have been revoked. + */ + public revokeRefreshTokens(uid: string): Promise { + return this.authRequestHandler.revokeRefreshTokens(uid) + .then(() => { + // Return nothing on success. + }); + } + + /** + * Imports the provided list of users into Firebase Auth. + * A maximum of 1000 users are allowed to be imported one at a time. + * When importing users with passwords, + * {@link UserImportOptions} are required to be + * specified. + * This operation is optimized for bulk imports and will ignore checks on `uid`, + * `email` and other identifier uniqueness which could result in duplications. + * + * @param users - The list of user records to import to Firebase Auth. + * @param options - The user import options, required when the users provided include + * password credentials. + * @returns A promise that resolves when + * the operation completes with the result of the import. This includes the + * number of successful imports, the number of failed imports and their + * corresponding errors. + */ + public importUsers( + users: UserImportRecord[], options?: UserImportOptions): Promise { + return this.authRequestHandler.uploadAccount(users, options); + } + + /** + * Creates a new Firebase session cookie with the specified options. The created + * JWT string can be set as a server-side session cookie with a custom cookie + * policy, and be used for session management. The session cookie JWT will have + * the same payload claims as the provided ID token. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-cookies | Manage Session Cookies} + * for code samples and detailed documentation. + * + * @param idToken - The Firebase ID token to exchange for a session + * cookie. + * @param sessionCookieOptions - The session + * cookie options which includes custom session duration. + * + * @returns A promise that resolves on success with the + * created session cookie. + */ + public createSessionCookie( + idToken: string, sessionCookieOptions: SessionCookieOptions): Promise { + // Return rejected promise if expiresIn is not available. + if (!validator.isNonNullObject(sessionCookieOptions) || + !validator.isNumber(sessionCookieOptions.expiresIn)) { + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_SESSION_COOKIE_DURATION)); + } + return this.authRequestHandler.createSessionCookie( + idToken, sessionCookieOptions.expiresIn); + } + + /** + * Verifies a Firebase session cookie. Returns a Promise with the cookie claims. + * Rejects the promise if the cookie could not be verified. + * + * If `checkRevoked` is set to true, first verifies whether the corresponding + * user is disabled: If yes, an `auth/user-disabled` error is thrown. If no, + * verifies if the session corresponding to the session cookie was revoked. + * If the corresponding user's session was invalidated, an + * `auth/session-cookie-revoked` error is thrown. If not specified the check + * is not performed. + * + * See {@link https://firebase.google.com/docs/auth/admin/manage-cookies#verify_session_cookie_and_check_permissions | + * Verify Session Cookies} + * for code samples and detailed documentation + * + * @param sessionCookie - The session cookie to verify. + * @param checkForRevocation - Whether to check if the session cookie was + * revoked. This requires an extra request to the Firebase Auth backend to + * check the `tokensValidAfterTime` time for the corresponding user. + * When not specified, this additional check is not performed. + * + * @returns A promise fulfilled with the + * session cookie's decoded claims if the session cookie is valid; otherwise, + * a rejected promise. + */ + public verifySessionCookie( + sessionCookie: string, checkRevoked = false): Promise { + const isEmulator = useEmulator(); + return this.sessionCookieVerifier.verifyJWT(sessionCookie, isEmulator) + .then((decodedIdToken: DecodedIdToken) => { + // Whether to check if the token was revoked. + if (checkRevoked || isEmulator) { + return this.verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken, + AuthClientErrorCode.SESSION_COOKIE_REVOKED); + } + return decodedIdToken; + }); + } + + /** + * Generates the out of band email action link to reset a user's password. + * The link is generated for the user with the specified email address. The + * optional {@link ActionCodeSettings} object + * defines whether the link is to be handled by a mobile app or browser and the + * additional state information to be passed in the deep link, etc. + * + * @example + * ```javascript + * var actionCodeSettings = { + * url: 'https://www.example.com/?email=user@example.com', + * iOS: { + * bundleId: 'com.example.ios' + * }, + * android: { + * packageName: 'com.example.android', + * installApp: true, + * minimumVersion: '12' + * }, + * handleCodeInApp: true, + * dynamicLinkDomain: 'custom.page.link' + * }; + * admin.auth() + * .generatePasswordResetLink('user@example.com', actionCodeSettings) + * .then(function(link) { + * // The link was successfully generated. + * }) + * .catch(function(error) { + * // Some error occurred, you can inspect the code: error.code + * }); + * ``` + * + * @param email - The email address of the user whose password is to be + * reset. + * @param actionCodeSettings - The action + * code settings. If specified, the state/continue URL is set as the + * "continueUrl" parameter in the password reset link. The default password + * reset landing page will use this to display a link to go back to the app + * if it is installed. + * If the actionCodeSettings is not specified, no URL is appended to the + * action URL. + * The state URL provided must belong to a domain that is whitelisted by the + * developer in the console. Otherwise an error is thrown. + * Mobile app redirects are only applicable if the developer configures + * and accepts the Firebase Dynamic Links terms of service. + * The Android package name and iOS bundle ID are respected only if they + * are configured in the same Firebase Auth project. + * @returns A promise that resolves with the generated link. + */ + public generatePasswordResetLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('PASSWORD_RESET', email, actionCodeSettings); + } + + /** + * Generates the out of band email action link to verify the user's ownership + * of the specified email. The {@link ActionCodeSettings} object provided + * as an argument to this method defines whether the link is to be handled by a + * mobile app or browser along with additional state information to be passed in + * the deep link, etc. + * + * @example + * ```javascript + * var actionCodeSettings = { + * url: 'https://www.example.com/cart?email=user@example.com&cartId=123', + * iOS: { + * bundleId: 'com.example.ios' + * }, + * android: { + * packageName: 'com.example.android', + * installApp: true, + * minimumVersion: '12' + * }, + * handleCodeInApp: true, + * dynamicLinkDomain: 'custom.page.link' + * }; + * admin.auth() + * .generateEmailVerificationLink('user@example.com', actionCodeSettings) + * .then(function(link) { + * // The link was successfully generated. + * }) + * .catch(function(error) { + * // Some error occurred, you can inspect the code: error.code + * }); + * ``` + * + * @param email - The email account to verify. + * @param actionCodeSettings - The action + * code settings. If specified, the state/continue URL is set as the + * "continueUrl" parameter in the email verification link. The default email + * verification landing page will use this to display a link to go back to + * the app if it is installed. + * If the actionCodeSettings is not specified, no URL is appended to the + * action URL. + * The state URL provided must belong to a domain that is whitelisted by the + * developer in the console. Otherwise an error is thrown. + * Mobile app redirects are only applicable if the developer configures + * and accepts the Firebase Dynamic Links terms of service. + * The Android package name and iOS bundle ID are respected only if they + * are configured in the same Firebase Auth project. + * @returns A promise that resolves with the generated link. + */ + public generateEmailVerificationLink(email: string, actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('VERIFY_EMAIL', email, actionCodeSettings); + } + + /** + * Generates an out-of-band email action link to verify the user's ownership + * of the specified email. The {@link ActionCodeSettings} object provided + * as an argument to this method defines whether the link is to be handled by a + * mobile app or browser along with additional state information to be passed in + * the deep link, etc. + * + * @param email - The current email account. + * @param newEmail - The email address the account is being updated to. + * @param actionCodeSettings - The action + * code settings. If specified, the state/continue URL is set as the + * "continueUrl" parameter in the email verification link. The default email + * verification landing page will use this to display a link to go back to + * the app if it is installed. + * If the actionCodeSettings is not specified, no URL is appended to the + * action URL. + * The state URL provided must belong to a domain that is authorized + * in the console, or an error will be thrown. + * Mobile app redirects are only applicable if the developer configures + * and accepts the Firebase Dynamic Links terms of service. + * The Android package name and iOS bundle ID are respected only if they + * are configured in the same Firebase Auth project. + * @returns A promise that resolves with the generated link. + */ + public generateVerifyAndChangeEmailLink(email: string, newEmail: string, + actionCodeSettings?: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('VERIFY_AND_CHANGE_EMAIL', email, actionCodeSettings, newEmail); + } + + /** + * Generates the out of band email action link to verify the user's ownership + * of the specified email. The {@link ActionCodeSettings} object provided + * as an argument to this method defines whether the link is to be handled by a + * mobile app or browser along with additional state information to be passed in + * the deep link, etc. + * + * @example + * ```javascript + * var actionCodeSettings = { + * url: 'https://www.example.com/cart?email=user@example.com&cartId=123', + * iOS: { + * bundleId: 'com.example.ios' + * }, + * android: { + * packageName: 'com.example.android', + * installApp: true, + * minimumVersion: '12' + * }, + * handleCodeInApp: true, + * dynamicLinkDomain: 'custom.page.link' + * }; + * admin.auth() + * .generateEmailVerificationLink('user@example.com', actionCodeSettings) + * .then(function(link) { + * // The link was successfully generated. + * }) + * .catch(function(error) { + * // Some error occurred, you can inspect the code: error.code + * }); + * ``` + * + * @param email - The email account to verify. + * @param actionCodeSettings - The action + * code settings. If specified, the state/continue URL is set as the + * "continueUrl" parameter in the email verification link. The default email + * verification landing page will use this to display a link to go back to + * the app if it is installed. + * If the actionCodeSettings is not specified, no URL is appended to the + * action URL. + * The state URL provided must belong to a domain that is whitelisted by the + * developer in the console. Otherwise an error is thrown. + * Mobile app redirects are only applicable if the developer configures + * and accepts the Firebase Dynamic Links terms of service. + * The Android package name and iOS bundle ID are respected only if they + * are configured in the same Firebase Auth project. + * @returns A promise that resolves with the generated link. + */ + public generateSignInWithEmailLink(email: string, actionCodeSettings: ActionCodeSettings): Promise { + return this.authRequestHandler.getEmailActionLink('EMAIL_SIGNIN', email, actionCodeSettings); + } + + /** + * Returns the list of existing provider configurations matching the filter + * provided. At most, 100 provider configs can be listed at a time. + * + * SAML and OIDC provider support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the {@link https://cloud.google.com/identity-platform | GCIP documentation}. + * + * @param options - The provider config filter to apply. + * @returns A promise that resolves with the list of provider configs meeting the + * filter requirements. + */ + public listProviderConfigs(options: AuthProviderConfigFilter): Promise { + const processResponse = (response: any, providerConfigs: AuthProviderConfig[]): ListProviderConfigResults => { + // Return list of provider configuration and the next page token if available. + const result: ListProviderConfigResults = { + providerConfigs, + }; + // Delete result.pageToken if undefined. + if (Object.prototype.hasOwnProperty.call(response, 'nextPageToken')) { + result.pageToken = response.nextPageToken; + } + return result; + }; + if (options && options.type === 'oidc') { + return this.authRequestHandler.listOAuthIdpConfigs(options.maxResults, options.pageToken) + .then((response: any) => { + // List of provider configurations to return. + const providerConfigs: OIDCConfig[] = []; + // Convert each provider config response to a OIDCConfig. + response.oauthIdpConfigs.forEach((configResponse: any) => { + providerConfigs.push(new OIDCConfig(configResponse)); + }); + // Return list of provider configuration and the next page token if available. + return processResponse(response, providerConfigs); + }); + } else if (options && options.type === 'saml') { + return this.authRequestHandler.listInboundSamlConfigs(options.maxResults, options.pageToken) + .then((response: any) => { + // List of provider configurations to return. + const providerConfigs: SAMLConfig[] = []; + // Convert each provider config response to a SAMLConfig. + response.inboundSamlConfigs.forEach((configResponse: any) => { + providerConfigs.push(new SAMLConfig(configResponse)); + }); + // Return list of provider configuration and the next page token if available. + return processResponse(response, providerConfigs); + }); + } + return Promise.reject( + new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"AuthProviderConfigFilter.type" must be either "saml" or "oidc"')); + } + + /** + * Looks up an Auth provider configuration by the provided ID. + * Returns a promise that resolves with the provider configuration + * corresponding to the provider ID specified. If the specified ID does not + * exist, an `auth/configuration-not-found` error is thrown. + * + * SAML and OIDC provider support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the {@link https://cloud.google.com/identity-platform | GCIP documentation}. + * + * @param providerId - The provider ID corresponding to the provider + * config to return. + * @returns A promise that resolves + * with the configuration corresponding to the provided ID. + */ + public getProviderConfig(providerId: string): Promise { + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.getOAuthIdpConfig(providerId) + .then((response: OIDCConfigServerResponse) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.getInboundSamlConfig(providerId) + .then((response: SAMLConfigServerResponse) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Deletes the provider configuration corresponding to the provider ID passed. + * If the specified ID does not exist, an `auth/configuration-not-found` error + * is thrown. + * + * SAML and OIDC provider support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the {@link https://cloud.google.com/identity-platform | GCIP documentation}. + * + * @param providerId - The provider ID corresponding to the provider + * config to delete. + * @returns A promise that resolves on completion. + */ + public deleteProviderConfig(providerId: string): Promise { + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.deleteOAuthIdpConfig(providerId); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.deleteInboundSamlConfig(providerId); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Returns a promise that resolves with the updated `AuthProviderConfig` + * corresponding to the provider ID specified. + * If the specified ID does not exist, an `auth/configuration-not-found` error + * is thrown. + * + * SAML and OIDC provider support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the {@link https://cloud.google.com/identity-platform | GCIP documentation}. + * + * @param providerId - The provider ID corresponding to the provider + * config to update. + * @param updatedConfig - The updated configuration. + * @returns A promise that resolves with the updated provider configuration. + */ + public updateProviderConfig( + providerId: string, updatedConfig: UpdateAuthProviderRequest): Promise { + if (!validator.isNonNullObject(updatedConfig)) { + return Promise.reject(new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'Request is missing "UpdateAuthProviderRequest" configuration.', + )); + } + if (OIDCConfig.isProviderId(providerId)) { + return this.authRequestHandler.updateOAuthIdpConfig(providerId, updatedConfig) + .then((response) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(providerId)) { + return this.authRequestHandler.updateInboundSamlConfig(providerId, updatedConfig) + .then((response) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** + * Returns a promise that resolves with the newly created `AuthProviderConfig` + * when the new provider configuration is created. + * + * SAML and OIDC provider support requires Google Cloud's Identity Platform + * (GCIP). To learn more about GCIP, including pricing and features, + * see the {@link https://cloud.google.com/identity-platform | GCIP documentation}. + * + * @param config - The provider configuration to create. + * @returns A promise that resolves with the created provider configuration. + */ + public createProviderConfig(config: AuthProviderConfig): Promise { + if (!validator.isNonNullObject(config)) { + return Promise.reject(new FirebaseAuthError( + AuthClientErrorCode.INVALID_CONFIG, + 'Request is missing "AuthProviderConfig" configuration.', + )); + } + if (OIDCConfig.isProviderId(config.providerId)) { + return this.authRequestHandler.createOAuthIdpConfig(config as OIDCAuthProviderConfig) + .then((response) => { + return new OIDCConfig(response); + }); + } else if (SAMLConfig.isProviderId(config.providerId)) { + return this.authRequestHandler.createInboundSamlConfig(config as SAMLAuthProviderConfig) + .then((response) => { + return new SAMLConfig(response); + }); + } + return Promise.reject(new FirebaseAuthError(AuthClientErrorCode.INVALID_PROVIDER_ID)); + } + + /** @alpha */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _verifyAuthBlockingToken( + token: string, + audience?: string + ): Promise { + const isEmulator = useEmulator(); + return this.authBlockingTokenVerifier._verifyAuthBlockingToken(token, isEmulator, audience) + .then((decodedAuthBlockingToken: DecodedAuthBlockingToken) => { + return decodedAuthBlockingToken; + }); + } + + /** + * Verifies the decoded Firebase issued JWT is not revoked or disabled. Returns a promise that + * resolves with the decoded claims on success. Rejects the promise with revocation error if revoked + * or user disabled. + * + * @param decodedIdToken - The JWT's decoded claims. + * @param revocationErrorInfo - The revocation error info to throw on revocation + * detection. + * @returns A promise that will be fulfilled after a successful verification. + */ + private verifyDecodedJWTNotRevokedOrDisabled( + decodedIdToken: DecodedIdToken, revocationErrorInfo: ErrorInfo): Promise { + // Get tokens valid after time for the corresponding user. + return this.getUser(decodedIdToken.sub) + .then((user: UserRecord) => { + if (user.disabled) { + throw new FirebaseAuthError( + AuthClientErrorCode.USER_DISABLED, + 'The user record is disabled.'); + } + // If no tokens valid after time available, token is not revoked. + if (user.tokensValidAfterTime) { + // Get the ID token authentication time and convert to milliseconds UTC. + const authTimeUtc = decodedIdToken.auth_time * 1000; + // Get user tokens valid after time in milliseconds UTC. + const validSinceUtc = new Date(user.tokensValidAfterTime).getTime(); + // Check if authentication time is older than valid since time. + if (authTimeUtc < validSinceUtc) { + throw new FirebaseAuthError(revocationErrorInfo); + } + } + // All checks above passed. Return the decoded token. + return decodedIdToken; + }); + } +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/create_request.dart b/packages/dart_firebase_admin/lib/src/auth/create_request.dart deleted file mode 100644 index 5618ab5..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/create_request.dart +++ /dev/null @@ -1,28 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class CreateRequest extends UpdateRequest { - CreateRequest({ - bool? disabled, - String? displayName, - String? email, - bool? emailVerified, - String? password, - String? photoURL, - List? providersToUnlink, - String? providerToLink, - this.uid, - }) : super( - disabled: disabled, - displayName: displayName, - email: email, - emailVerified: emailVerified, - password: password, - photoURL: photoURL, - providersToUnlink: providersToUnlink, - providerToLink: providerToLink, - ); - - // TODO multiFactor - - final String? uid; -} diff --git a/packages/dart_firebase_admin/lib/src/auth/delete_users_result.dart b/packages/dart_firebase_admin/lib/src/auth/delete_users_result.dart deleted file mode 100644 index ce38cab..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/delete_users_result.dart +++ /dev/null @@ -1,27 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class DeleteUsersResult { - DeleteUsersResult._(this.localIds, this._delegate); - - final List localIds; - - final firebase_auth_v1.GoogleCloudIdentitytoolkitV1BatchDeleteAccountsResponse - _delegate; - - /// The number of user records that failed to be deleted (possibly zero). - int get failureCount => _delegate.errors?.length ?? 0; - - /// The number of users that were deleted successfully (possibly zero). - /// - /// Users that did not exist prior to calling [FirebaseAdminAuth.deleteUsers] - /// are considered to be successfully deleted. - int get successCount => localIds.length - failureCount; - - /// A list of FirebaseArrayIndexError instances describing the errors that - /// were encountered during the deletion - List get errors => - _delegate.errors?.map((e) { - return FirebaseArrayIndexException(e.index!, e.message ?? 'Unknown'); - }).toList() ?? - []; -} diff --git a/packages/dart_firebase_admin/lib/src/auth/firebase_admin_auth.dart b/packages/dart_firebase_admin/lib/src/auth/firebase_admin_auth.dart deleted file mode 100644 index 1474bee..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/firebase_admin_auth.dart +++ /dev/null @@ -1,254 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class FirebaseAdminAuth { - FirebaseAdminAuth(this.app); - final FirebaseAdminApp app; - - auth.AuthClient? _client; - - Object tenantManager() { - return {}; - } - - Future _getClient() async { - return _client ??= await app._credential._getAuthClient([ - firebase_auth_v3.IdentityToolkitApi.cloudPlatformScope, - firebase_auth_v3.IdentityToolkitApi.firebaseScope, - ]); - } - - Future _v1() async { - return firebase_auth_v1.IdentityToolkitApi(await _getClient()); - } - - Future _v2() async { - return firebase_auth_v2.IdentityToolkitApi(await _getClient()); - } - - Future _v3() async { - return firebase_auth_v3.IdentityToolkitApi(await _getClient()); - } - - Future createCustomToken( - String uid, [ - Map? developerClaims, - ]) async { - throw UnimplementedError(); - } - - Future createProviderConfig(Object config) async { - throw UnimplementedError(); - } - - Future createSessionCookie(String idToken, {int? expiresIn}) async { - return guard( - () async { - final request = firebase_auth_v1 - .GoogleCloudIdentitytoolkitV1CreateSessionCookieRequest( - idToken: idToken, - validDuration: expiresIn?.toString(), - ); - - final response = await (await _v1()).projects.createSessionCookie( - request, - app._projectId, - ); - - return response.sessionCookie ?? ''; - }, - ); - } - - Future createUser( - CreateRequest properties, - ) async { - throw UnimplementedError(); - } - - Future deleteProviderConfig( - String providerId, - ) async { - return; - } - - Future deleteUser( - String uid, - ) async { - return guard(() async { - final request = - firebase_auth_v3.IdentitytoolkitRelyingpartyDeleteAccountRequest( - localId: uid, - ); - - await (await _v3()).relyingparty.deleteAccount(request); - }); - } - - Future deleteUsers( - List uids, - ) async { - return guard( - () async { - final localIds = uids.where((element) => element.isUid).toList(); - - final request = firebase_auth_v1 - .GoogleCloudIdentitytoolkitV1BatchDeleteAccountsRequest( - localIds: localIds, - ); - - final response = await (await _v1()).projects.accounts_1.batchDelete( - request, - app._projectId, - ); - - return DeleteUsersResult._(localIds, response); - }, - ); - } - - Future generateEmailVerificationLink( - String email, [ - Object? actionCodeSettings, - ]) async { - throw UnimplementedError(); - } - - Future generatePasswordResetLink( - String email, [ - Object? actionCodeSettings, - ]) async { - throw UnimplementedError(); - } - - Future generateSignInWithEmailLink( - String email, - Object actionCodeSettings, - ) async { - throw UnimplementedError(); - } - - Future getProviderConfig(String providerId) async { - throw UnimplementedError(); - } - - Future _getUserRecord( - firebase_auth_v1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest request, - ) async { - final response = await (await _v1()).projects.accounts_1.lookup( - request, - app._projectId, - ); - - if (response.users == null || response.users!.isEmpty) { - throw FirebaseAuthAdminException.fromAuthClientErrorCode( - AuthClientErrorCode.userNotFound, - ); - } - - return UserRecord._(response.users!.first); - } - - Future getUser(String uid) async { - return guard( - () async { - if (!uid.isUid) { - throw FirebaseAuthAdminException.fromAuthClientErrorCode( - AuthClientErrorCode.invalidUid, - ); - } - - return _getUserRecord( - firebase_auth_v1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( - localId: [uid], - ), - ); - }, - ); - } - - Future getUserByEmail(String email) async { - return guard( - () async { - if (!email.isEmail) { - throw FirebaseAuthAdminException.fromAuthClientErrorCode( - AuthClientErrorCode.invalidEmail, - ); - } - - return _getUserRecord( - firebase_auth_v1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( - email: [email], - ), - ); - }, - ); - } - - Future getUserByPhoneNumber(String phoneNumber) async { - return guard( - () async { - if (!phoneNumber.isPhoneNumber) { - throw FirebaseAuthAdminException.fromAuthClientErrorCode( - AuthClientErrorCode.invalidPhoneNumber, - ); - } - - return _getUserRecord( - firebase_auth_v1.GoogleCloudIdentitytoolkitV1GetAccountInfoRequest( - phoneNumber: [phoneNumber], - ), - ); - }, - ); - } - - Future getUserByProviderUid(String providerId, String uid) async { - throw UnimplementedError(); - } - - Future getUsers(List identifiers) async { - throw UnimplementedError(); - } - - Future importUsers(List users, [Object? options]) async { - throw UnimplementedError(); - } - - Future listProviderConfigs(Object options) async { - throw UnimplementedError(); - } - - Future listUsers({int? maxResults = 1000, String? pageToken}) async { - throw UnimplementedError(); - } - - Future revokeRefreshTokens(String uid) async { - throw UnimplementedError(); - } - - Future setCustomUserClaims(String uid, Object? customUserClaims) async { - throw UnimplementedError(); - } - - Future updateProviderConfig( - String providerId, - Object updatedConfig, - ) async { - throw UnimplementedError(); - } - - Future updateUser(String uid, Object properties) async { - throw UnimplementedError(); - } - - Future verifyIdToken(String idToken, {bool? checkRevoked}) async { - throw UnimplementedError(); - } - - Future verifySessionCookie( - String sessionCookie, { - bool? checkRevoked, - }) async { - throw UnimplementedError(); - } -} diff --git a/packages/dart_firebase_admin/lib/src/auth/identifier.dart b/packages/dart_firebase_admin/lib/src/auth/identifier.dart new file mode 100644 index 0000000..5b1d556 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/identifier.dart @@ -0,0 +1,62 @@ +import '../dart_firebase_admin.dart'; +import '../utils/validator.dart'; +import 'base_auth.dart'; + +/// Identifies a user to be looked up. +/// +/// See also: +/// - [ProviderIdentifier] +/// - [PhoneIdentifier] +/// - [EmailIdentifier] +/// - [UidIdentifier] +sealed class UserIdentifier {} + +/// Used for looking up an account by federated provider. +/// +/// See [BaseAuth.getUsers]. +class ProviderIdentifier extends UserIdentifier { + ProviderIdentifier({required this.providerId, required this.providerUid}) { + if (providerId.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderId); + } + if (providerUid.isEmpty) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidProviderUid); + } + } + + final String providerId; + final String providerUid; +} + +/// Used for looking up an account by phone number. +/// +/// See [BaseAuth.getUsers]. +class PhoneIdentifier extends UserIdentifier { + PhoneIdentifier({required this.phoneNumber}) { + assertIsPhoneNumber(phoneNumber); + } + + final String phoneNumber; +} + +/// Used for looking up an account by email. +/// +/// See [BaseAuth.getUsers]. +class EmailIdentifier extends UserIdentifier { + EmailIdentifier({required this.email}) { + assertIsEmail(email); + } + + final String email; +} + +/// Used for looking up an account by uid. +/// +/// See [BaseAuth.getUsers]. +class UidIdentifier extends UserIdentifier { + UidIdentifier({required this.uid}) { + assertIsUid(uid); + } + + final String uid; +} diff --git a/packages/dart_firebase_admin/lib/src/auth/identifier.ts b/packages/dart_firebase_admin/lib/src/auth/identifier.ts new file mode 100644 index 0000000..fcd978d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/identifier.ts @@ -0,0 +1,80 @@ +/*! + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Used for looking up an account by uid. + * + * See {@link BaseAuth.getUsers}. + */ +export interface UidIdentifier { + uid: string; +} + +/** + * Used for looking up an account by email. + * + * See {@link BaseAuth.getUsers}. + */ +export interface EmailIdentifier { + email: string; +} + +/** + * Used for looking up an account by phone number. + * + * See {@link BaseAuth.getUsers}. + */ +export interface PhoneIdentifier { + phoneNumber: string; +} + +/** + * Used for looking up an account by federated provider. + * + * See {@link BaseAuth.getUsers}. + */ +export interface ProviderIdentifier { + providerId: string; + providerUid: string; +} + +/** + * Identifies a user to be looked up. + */ +export type UserIdentifier = + UidIdentifier | EmailIdentifier | PhoneIdentifier | ProviderIdentifier; + +/* + * User defined type guards. See + * https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards + */ + +export function isUidIdentifier(id: UserIdentifier): id is UidIdentifier { + return (id as UidIdentifier).uid !== undefined; +} + +export function isEmailIdentifier(id: UserIdentifier): id is EmailIdentifier { + return (id as EmailIdentifier).email !== undefined; +} + +export function isPhoneIdentifier(id: UserIdentifier): id is PhoneIdentifier { + return (id as PhoneIdentifier).phoneNumber !== undefined; +} + +export function isProviderIdentifier(id: ProviderIdentifier): id is ProviderIdentifier { + const pid = id as ProviderIdentifier; + return pid.providerId !== undefined && pid.providerUid !== undefined; +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart new file mode 100644 index 0000000..a9cfe4d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/token_verifier.dart @@ -0,0 +1,209 @@ +import '../dart_firebase_admin.dart'; +import '../utils/error.dart'; +import '../utils/jwt.dart'; + +class FirebaseTokenInfo { + FirebaseTokenInfo({ + required this.url, + required this.verifyApiName, + required this.jwtName, + required this.shortName, + required this.expiredErrorCode, + }) { + if (verifyApiName.isEmpty) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'The JWT verify API name must be a non-empty string.', + ); + } + if (jwtName.isEmpty) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'The JWT public full name must be a non-empty string.', + ); + } + if (shortName.isEmpty) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + 'The JWT public full name must be a non-empty string.', + ); + } + } + + /// Documentation URL. + final Uri url; + + /// verify API name. + final String verifyApiName; + + /// The JWT full name. + final String jwtName; + + /// The JWT short name. + final String shortName; + + /// JWT Expiration error code. + final ErrorInfo expiredErrorCode; +} + +class FirebaseTokenVerifier { + FirebaseTokenVerifier({ + required Uri clientCertUrl, + required this.issuer, + required this.tokenInfo, + required this.app, + }) : _shortNameArticle = RegExp('[aeiou]', caseSensitive: false) + .hasMatch(tokenInfo.shortName[0]) + ? 'an' + : 'a', + _signatureVerifier = + PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl); + + final String _shortNameArticle; + final Uri issuer; + final FirebaseAdminApp app; + final FirebaseTokenInfo tokenInfo; + final SignatureVerifier _signatureVerifier; +} + +class TokenProvider { + // TODO optional parameters + TokenProvider({ + required this.identities, + required this.signInProvider, + required this.signInSecondFactor, + required this.secondFactorIdentifier, + required this.tenant, + }); + + /// Provider-specific identity details corresponding + /// to the provider used to sign in the user. + final Map identities; + + /// The ID of the provider used to sign in the user. + /// One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`, + /// `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`, + /// `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`, + /// or `"custom"`. + /// + /// Additional Identity Platform provider IDs include `"linkedin.com"`, + /// OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."` + /// respectively. + final String signInProvider; + + /// The type identifier or `factorId` of the second factor, provided the + /// ID token was obtained from a multi-factor authenticated user. + /// For phone, this is `"phone"`. + final String? signInSecondFactor; + + /// The `uid` of the second factor used to sign in, provided the + /// ID token was obtained from a multi-factor authenticated user. + final String? secondFactorIdentifier; + + /// The ID of the tenant the user belongs to, if available. + final String? tenant; + // TODO allow any key + // [key: string]: any; +} + +/// Interface representing a decoded Firebase ID token, returned from the +/// {@link BaseAuth.verifyIdToken} method. +/// +/// Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs). +/// See the +/// [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) +/// for more information about the specific properties below. +class DecodedIdToken { + // TODO optional parameters + DecodedIdToken({ + required this.aud, + required this.authTime, + required this.email, + required this.emailVerified, + required this.exp, + required this.firebase, + required this.iat, + required this.iss, + required this.phoneNumber, + required this.picture, + required this.sub, + required this.uid, + }); + + /// The audience for which this token is intended. + /// + /// This value is a string equal to your Firebase project ID, the unique + /// identifier for your Firebase project, which can be found in [your project's + /// settings](https://console.firebase.google.com/project/_/settings/general/android:com.random.android). + final String aud; + + /// Time, in seconds since the Unix epoch, when the end-user authentication + /// occurred. + /// + /// This value is not set when this particular ID token was created, but when the + /// user initially logged in to this session. In a single session, the Firebase + /// SDKs will refresh a user's ID tokens every hour. Each ID token will have a + /// different [`iat`](#iat) value, but the same `auth_time` value. + final DateTime authTime; + + /// The email of the user to whom the ID token belongs, if available. + final String? email; + + /// Whether or not the email of the user to whom the ID token belongs is + /// verified, provided the user has an email. + final bool? emailVerified; + + /// The ID token's expiration time, in seconds since the Unix epoch. That is, the + /// time at which this ID token expires and should no longer be considered valid. + /// + /// The Firebase SDKs transparently refresh ID tokens every hour, issuing a new + /// ID token with up to a one hour expiration. + final int exp; + + /// Information about the sign in event, including which sign in provider was + /// used and provider-specific identity details. + /// + /// This data is provided by the Firebase Authentication service and is a + /// reserved claim in the ID token. + final TokenProvider firebase; + + /// The ID token's issued-at time, in seconds since the Unix epoch. That is, the + /// time at which this ID token was issued and should start to be considered + /// valid. + /// + /// The Firebase SDKs transparently refresh ID tokens every hour, issuing a new + /// ID token with a new issued-at time. If you want to get the time at which the + /// user session corresponding to the ID token initially occurred, see the + /// [`auth_time`](#auth_time) property. + final int iat; + + /// The issuer identifier for the issuer of the response. + /// + /// This value is a URL with the format + /// `https://securetoken.google.com/`, where `` is the + /// same project ID specified in the [`aud`](#aud) property. + final String iss; + + /// The phone number of the user to whom the ID token belongs, if available. + final String? phoneNumber; + + /// The photo URL for the user to whom the ID token belongs, if available. + final String? picture; + + /// The `uid` corresponding to the user who the ID token belonged to. + /// + /// As a convenience, this value is copied over to the [`uid`](#uid) property. + final String sub; + + /// The `uid` corresponding to the user who the ID token belonged to. + /// + /// This value is not actually in the JWT token claims itself. It is added as a + /// convenience, and is set as the value of the [`sub`](#sub) property. + final String uid; + + /** + * Other arbitrary claims included in the ID token. + */ + // TODO allow any key + // [key: string]: any; +} diff --git a/packages/dart_firebase_admin/lib/src/auth/token_verifier.ts b/packages/dart_firebase_admin/lib/src/auth/token_verifier.ts new file mode 100644 index 0000000..5788982 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/token_verifier.ts @@ -0,0 +1,627 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { AuthClientErrorCode, FirebaseAuthError, ErrorInfo } from '../utils/error'; +import * as util from '../utils/index'; +import * as validator from '../utils/validator'; +import { + DecodedToken, decodeJwt, JwtError, JwtErrorCode, EmulatorSignatureVerifier, + PublicKeySignatureVerifier, ALGORITHM_RS256, SignatureVerifier, +} from '../utils/jwt'; +import { App } from '../app/index'; + +/** + * Interface representing a decoded Firebase ID token, returned from the + * {@link BaseAuth.verifyIdToken} method. + * + * Firebase ID tokens are OpenID Connect spec-compliant JSON Web Tokens (JWTs). + * See the + * [ID Token section of the OpenID Connect spec](http://openid.net/specs/openid-connect-core-1_0.html#IDToken) + * for more information about the specific properties below. + */ +export interface DecodedIdToken { + + /** + * The audience for which this token is intended. + * + * This value is a string equal to your Firebase project ID, the unique + * identifier for your Firebase project, which can be found in [your project's + * settings](https://console.firebase.google.com/project/_/settings/general/android:com.random.android). + */ + aud: string; + + /** + * Time, in seconds since the Unix epoch, when the end-user authentication + * occurred. + * + * This value is not set when this particular ID token was created, but when the + * user initially logged in to this session. In a single session, the Firebase + * SDKs will refresh a user's ID tokens every hour. Each ID token will have a + * different [`iat`](#iat) value, but the same `auth_time` value. + */ + auth_time: number; + + /** + * The email of the user to whom the ID token belongs, if available. + */ + email?: string; + + /** + * Whether or not the email of the user to whom the ID token belongs is + * verified, provided the user has an email. + */ + email_verified?: boolean; + + /** + * The ID token's expiration time, in seconds since the Unix epoch. That is, the + * time at which this ID token expires and should no longer be considered valid. + * + * The Firebase SDKs transparently refresh ID tokens every hour, issuing a new + * ID token with up to a one hour expiration. + */ + exp: number; + + /** + * Information about the sign in event, including which sign in provider was + * used and provider-specific identity details. + * + * This data is provided by the Firebase Authentication service and is a + * reserved claim in the ID token. + */ + firebase: { + + /** + * Provider-specific identity details corresponding + * to the provider used to sign in the user. + */ + identities: { + [key: string]: any; + }; + + /** + * The ID of the provider used to sign in the user. + * One of `"anonymous"`, `"password"`, `"facebook.com"`, `"github.com"`, + * `"google.com"`, `"twitter.com"`, `"apple.com"`, `"microsoft.com"`, + * `"yahoo.com"`, `"phone"`, `"playgames.google.com"`, `"gc.apple.com"`, + * or `"custom"`. + * + * Additional Identity Platform provider IDs include `"linkedin.com"`, + * OIDC and SAML identity providers prefixed with `"saml."` and `"oidc."` + * respectively. + */ + sign_in_provider: string; + + /** + * The type identifier or `factorId` of the second factor, provided the + * ID token was obtained from a multi-factor authenticated user. + * For phone, this is `"phone"`. + */ + sign_in_second_factor?: string; + + /** + * The `uid` of the second factor used to sign in, provided the + * ID token was obtained from a multi-factor authenticated user. + */ + second_factor_identifier?: string; + + /** + * The ID of the tenant the user belongs to, if available. + */ + tenant?: string; + [key: string]: any; + }; + + /** + * The ID token's issued-at time, in seconds since the Unix epoch. That is, the + * time at which this ID token was issued and should start to be considered + * valid. + * + * The Firebase SDKs transparently refresh ID tokens every hour, issuing a new + * ID token with a new issued-at time. If you want to get the time at which the + * user session corresponding to the ID token initially occurred, see the + * [`auth_time`](#auth_time) property. + */ + iat: number; + + /** + * The issuer identifier for the issuer of the response. + * + * This value is a URL with the format + * `https://securetoken.google.com/`, where `` is the + * same project ID specified in the [`aud`](#aud) property. + */ + iss: string; + + /** + * The phone number of the user to whom the ID token belongs, if available. + */ + phone_number?: string; + + /** + * The photo URL for the user to whom the ID token belongs, if available. + */ + picture?: string; + + /** + * The `uid` corresponding to the user who the ID token belonged to. + * + * As a convenience, this value is copied over to the [`uid`](#uid) property. + */ + sub: string; + + /** + * The `uid` corresponding to the user who the ID token belonged to. + * + * This value is not actually in the JWT token claims itself. It is added as a + * convenience, and is set as the value of the [`sub`](#sub) property. + */ + uid: string; + + /** + * Other arbitrary claims included in the ID token. + */ + [key: string]: any; +} + +/** @alpha */ +export interface DecodedAuthBlockingSharedUserInfo { + uid: string; + display_name?: string; + email?: string; + photo_url?: string; + phone_number?: string; +} + +/** @alpha */ +export interface DecodedAuthBlockingMetadata { + creation_time?: number; + last_sign_in_time?: number; +} + +/** @alpha */ +export interface DecodedAuthBlockingUserInfo extends DecodedAuthBlockingSharedUserInfo { + provider_id: string; +} + +/** @alpha */ +export interface DecodedAuthBlockingMfaInfo { + uid: string; + display_name?: string; + phone_number?: string; + enrollment_time?: string; + factor_id?: string; +} + +/** @alpha */ +export interface DecodedAuthBlockingEnrolledFactors { + enrolled_factors?: DecodedAuthBlockingMfaInfo[]; +} + +/** @alpha */ +export interface DecodedAuthBlockingUserRecord extends DecodedAuthBlockingSharedUserInfo { + email_verified?: boolean; + disabled?: boolean; + metadata?: DecodedAuthBlockingMetadata; + password_hash?: string; + password_salt?: string; + provider_data?: DecodedAuthBlockingUserInfo[]; + multi_factor?: DecodedAuthBlockingEnrolledFactors; + custom_claims?: any; + tokens_valid_after_time?: number; + tenant_id?: string; + [key: string]: any; +} + +/** @alpha */ +export interface DecodedAuthBlockingToken { + aud: string; + exp: number; + iat: number; + iss: string; + sub: string; + event_id: string; + event_type: string; + ip_address: string; + user_agent?: string; + locale?: string; + sign_in_method?: string; + user_record?: DecodedAuthBlockingUserRecord; + tenant_id?: string; + raw_user_info?: string; + sign_in_attributes?: { + [key: string]: any; + }; + oauth_id_token?: string; + oauth_access_token?: string; + oauth_refresh_token?: string; + oauth_token_secret?: string; + oauth_expires_in?: number; + [key: string]: any; +} + +// Audience to use for Firebase Auth Custom tokens +const FIREBASE_AUDIENCE = 'https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit'; + +// URL containing the public keys for the Google certs (whose private keys are used to sign Firebase +// Auth ID tokens) +const CLIENT_CERT_URL = 'https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com'; + +// URL containing the public keys for Firebase session cookies. This will be updated to a different URL soon. +const SESSION_COOKIE_CERT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'; + +const EMULATOR_VERIFIER = new EmulatorSignatureVerifier(); + +/** + * User facing token information related to the Firebase ID token. + * + * @internal + */ +export const ID_TOKEN_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/verify-id-tokens', + verifyApiName: 'verifyIdToken()', + jwtName: 'Firebase ID token', + shortName: 'ID token', + expiredErrorCode: AuthClientErrorCode.ID_TOKEN_EXPIRED, +}; + +/** + * User facing token information related to the Firebase Auth Blocking token. + * + * @internal + */ +export const AUTH_BLOCKING_TOKEN_INFO: FirebaseTokenInfo = { + url: 'https://cloud.google.com/identity-platform/docs/blocking-functions', + verifyApiName: '_verifyAuthBlockingToken()', + jwtName: 'Firebase Auth Blocking token', + shortName: 'Auth Blocking token', + expiredErrorCode: AuthClientErrorCode.AUTH_BLOCKING_TOKEN_EXPIRED, +}; + +/** + * User facing token information related to the Firebase session cookie. + * + * @internal + */ +export const SESSION_COOKIE_INFO: FirebaseTokenInfo = { + url: 'https://firebase.google.com/docs/auth/admin/manage-cookies', + verifyApiName: 'verifySessionCookie()', + jwtName: 'Firebase session cookie', + shortName: 'session cookie', + expiredErrorCode: AuthClientErrorCode.SESSION_COOKIE_EXPIRED, +}; + +/** + * Interface that defines token related user facing information. + * + * @internal + */ +export interface FirebaseTokenInfo { + /** Documentation URL. */ + url: string; + /** verify API name. */ + verifyApiName: string; + /** The JWT full name. */ + jwtName: string; + /** The JWT short name. */ + shortName: string; + /** JWT Expiration error code. */ + expiredErrorCode: ErrorInfo; +} + +/** + * Class for verifying general purpose Firebase JWTs. This verifies ID tokens and session cookies. + * + * @internal + */ +export class FirebaseTokenVerifier { + + private readonly shortNameArticle: string; + private readonly signatureVerifier: SignatureVerifier; + + constructor(clientCertUrl: string, private issuer: string, private tokenInfo: FirebaseTokenInfo, + private readonly app: App) { + + if (!validator.isURL(clientCertUrl)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The provided public client certificate URL is an invalid URL.', + ); + } else if (!validator.isURL(issuer)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The provided JWT issuer is an invalid URL.', + ); + } else if (!validator.isNonNullObject(tokenInfo)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The provided JWT information is not an object or null.', + ); + } else if (!validator.isURL(tokenInfo.url)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The provided JWT verification documentation URL is invalid.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.verifyApiName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The JWT verify API name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.jwtName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The JWT public full name must be a non-empty string.', + ); + } else if (!validator.isNonEmptyString(tokenInfo.shortName)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The JWT public short name must be a non-empty string.', + ); + } else if (!validator.isNonNullObject(tokenInfo.expiredErrorCode) || !('code' in tokenInfo.expiredErrorCode)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + 'The JWT expiration error code must be a non-null ErrorInfo object.', + ); + } + this.shortNameArticle = tokenInfo.shortName.charAt(0).match(/[aeiou]/i) ? 'an' : 'a'; + + this.signatureVerifier = + PublicKeySignatureVerifier.withCertificateUrl(clientCertUrl, app.options.httpAgent); + + // For backward compatibility, the project ID is validated in the verification call. + } + + /** + * Verifies the format and signature of a Firebase Auth JWT token. + * + * @param jwtToken - The Firebase Auth JWT token to verify. + * @param isEmulator - Whether to accept Auth Emulator tokens. + * @returns A promise fulfilled with the decoded claims of the Firebase Auth ID token. + */ + public verifyJWT(jwtToken: string, isEmulator = false): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + return this.ensureProjectId() + .then((projectId) => { + return this.decodeAndVerify(jwtToken, projectId, isEmulator); + }) + .then((decoded) => { + const decodedIdToken = decoded.payload as DecodedIdToken; + decodedIdToken.uid = decodedIdToken.sub; + return decodedIdToken; + }); + } + + /** @alpha */ + // eslint-disable-next-line @typescript-eslint/naming-convention + public _verifyAuthBlockingToken( + jwtToken: string, + isEmulator: boolean, + audience: string | undefined): Promise { + if (!validator.isString(jwtToken)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + `First argument to ${this.tokenInfo.verifyApiName} must be a ${this.tokenInfo.jwtName} string.`, + ); + } + + return this.ensureProjectId() + .then((projectId) => { + if (typeof audience === 'undefined') { + audience = `${projectId}.cloudfunctions.net/`; + } + return this.decodeAndVerify(jwtToken, projectId, isEmulator, audience); + }) + .then((decoded) => { + const decodedAuthBlockingToken = decoded.payload as DecodedAuthBlockingToken; + decodedAuthBlockingToken.uid = decodedAuthBlockingToken.sub; + return decodedAuthBlockingToken; + }); + } + + private ensureProjectId(): Promise { + return util.findProjectId(this.app) + .then((projectId) => { + if (!validator.isNonEmptyString(projectId)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_CREDENTIAL, + 'Must initialize app with a cert credential or set your Firebase project ID as the ' + + `GOOGLE_CLOUD_PROJECT environment variable to call ${this.tokenInfo.verifyApiName}.`, + ); + } + return Promise.resolve(projectId); + }) + } + + private decodeAndVerify( + token: string, + projectId: string, + isEmulator: boolean, + audience?: string): Promise { + return this.safeDecode(token) + .then((decodedToken) => { + this.verifyContent(decodedToken, projectId, isEmulator, audience); + return this.verifySignature(token, isEmulator) + .then(() => decodedToken); + }); + } + + private safeDecode(jwtToken: string): Promise { + return decodeJwt(jwtToken) + .catch((err: JwtError) => { + if (err.code === JwtErrorCode.INVALID_ARGUMENT) { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + const errorMessage = `Decoding ${this.tokenInfo.jwtName} failed. Make sure you passed ` + + `the entire string JWT which represents ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}.` + verifyJwtTokenDocsMessage; + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, + errorMessage); + } + throw new FirebaseAuthError(AuthClientErrorCode.INTERNAL_ERROR, err.message); + }); + } + + /** + * Verifies the content of a Firebase Auth JWT. + * + * @param fullDecodedToken - The decoded JWT. + * @param projectId - The Firebase Project Id. + * @param isEmulator - Whether the token is an Emulator token. + */ + private verifyContent( + fullDecodedToken: DecodedToken, + projectId: string | null, + isEmulator: boolean, + audience: string | undefined): void { + const header = fullDecodedToken && fullDecodedToken.header; + const payload = fullDecodedToken && fullDecodedToken.payload; + + const projectIdMatchMessage = ` Make sure the ${this.tokenInfo.shortName} comes from the same ` + + 'Firebase project as the service account used to authenticate this SDK.'; + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + + let errorMessage: string | undefined; + if (!isEmulator && typeof header.kid === 'undefined') { + const isCustomToken = (payload.aud === FIREBASE_AUDIENCE); + const isLegacyCustomToken = (header.alg === 'HS256' && payload.v === 0 && 'd' in payload && 'uid' in payload.d); + + if (isCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a custom token.`; + } else if (isLegacyCustomToken) { + errorMessage = `${this.tokenInfo.verifyApiName} expects ${this.shortNameArticle} ` + + `${this.tokenInfo.shortName}, but was given a legacy custom token.`; + } else { + errorMessage = `${this.tokenInfo.jwtName} has no "kid" claim.`; + } + + errorMessage += verifyJwtTokenDocsMessage; + } else if (!isEmulator && header.alg !== ALGORITHM_RS256) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect algorithm. Expected "` + ALGORITHM_RS256 + '" but got ' + + '"' + header.alg + '".' + verifyJwtTokenDocsMessage; + } else if (typeof audience !== 'undefined' && !(payload.aud as string).includes(audience)) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + audience + '" but got "' + payload.aud + '".' + verifyJwtTokenDocsMessage; + } else if (typeof audience === 'undefined' && payload.aud !== projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "aud" (audience) claim. Expected "` + + projectId + '" but got "' + payload.aud + '".' + projectIdMatchMessage + + verifyJwtTokenDocsMessage; + } else if (payload.iss !== this.issuer + projectId) { + errorMessage = `${this.tokenInfo.jwtName} has incorrect "iss" (issuer) claim. Expected ` + + `"${this.issuer}` + projectId + '" but got "' + + payload.iss + '".' + projectIdMatchMessage + verifyJwtTokenDocsMessage; + } else if (typeof payload.sub !== 'string') { + errorMessage = `${this.tokenInfo.jwtName} has no "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub === '') { + errorMessage = `${this.tokenInfo.jwtName} has an empty string "sub" (subject) claim.` + verifyJwtTokenDocsMessage; + } else if (payload.sub.length > 128) { + errorMessage = `${this.tokenInfo.jwtName} has "sub" (subject) claim longer than 128 characters.` + + verifyJwtTokenDocsMessage; + } + if (errorMessage) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } + } + + private verifySignature(jwtToken: string, isEmulator: boolean): + Promise { + const verifier = isEmulator ? EMULATOR_VERIFIER : this.signatureVerifier; + return verifier.verify(jwtToken) + .catch((error) => { + throw this.mapJwtErrorToAuthError(error); + }); + } + + /** + * Maps JwtError to FirebaseAuthError + * + * @param error - JwtError to be mapped. + * @returns FirebaseAuthError or Error instance. + */ + private mapJwtErrorToAuthError(error: JwtError): Error { + const verifyJwtTokenDocsMessage = ` See ${this.tokenInfo.url} ` + + `for details on how to retrieve ${this.shortNameArticle} ${this.tokenInfo.shortName}.`; + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + const errorMessage = `${this.tokenInfo.jwtName} has expired. Get a fresh ${this.tokenInfo.shortName}` + + ` from your client app and try again (auth/${this.tokenInfo.expiredErrorCode.code}).` + + verifyJwtTokenDocsMessage; + return new FirebaseAuthError(this.tokenInfo.expiredErrorCode, errorMessage); + } else if (error.code === JwtErrorCode.INVALID_SIGNATURE) { + const errorMessage = `${this.tokenInfo.jwtName} has invalid signature.` + verifyJwtTokenDocsMessage; + return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } else if (error.code === JwtErrorCode.NO_MATCHING_KID) { + const errorMessage = `${this.tokenInfo.jwtName} has "kid" claim which does not ` + + `correspond to a known public key. Most likely the ${this.tokenInfo.shortName} ` + + 'is expired, so get a fresh token from your client app and try again.'; + return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, errorMessage); + } + return new FirebaseAuthError(AuthClientErrorCode.INVALID_ARGUMENT, error.message); + } +} + +/** + * Creates a new FirebaseTokenVerifier to verify Firebase ID tokens. + * + * @internal + * @param app - Firebase app instance. + * @returns FirebaseTokenVerifier + */ +export function createIdTokenVerifier(app: App): FirebaseTokenVerifier { + return new FirebaseTokenVerifier( + CLIENT_CERT_URL, + 'https://securetoken.google.com/', + ID_TOKEN_INFO, + app + ); +} + +/** + * Creates a new FirebaseTokenVerifier to verify Firebase Auth Blocking tokens. + * + * @internal + * @param app - Firebase app instance. + * @returns FirebaseTokenVerifier + */ +export function createAuthBlockingTokenVerifier(app: App): FirebaseTokenVerifier { + return new FirebaseTokenVerifier( + CLIENT_CERT_URL, + 'https://securetoken.google.com/', + AUTH_BLOCKING_TOKEN_INFO, + app + ); +} + +/** + * Creates a new FirebaseTokenVerifier to verify Firebase session cookies. + * + * @internal + * @param app - Firebase app instance. + * @returns FirebaseTokenVerifier + */ +export function createSessionCookieVerifier(app: App): FirebaseTokenVerifier { + return new FirebaseTokenVerifier( + SESSION_COOKIE_CERT_URL, + 'https://session.firebase.google.com/', + SESSION_COOKIE_INFO, + app + ); +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/auth/update_request.dart b/packages/dart_firebase_admin/lib/src/auth/update_request.dart deleted file mode 100644 index 1074d94..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/update_request.dart +++ /dev/null @@ -1,25 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class UpdateRequest { - UpdateRequest({ - this.disabled, - this.displayName, - this.email, - this.emailVerified, - this.password, - this.photoURL, - this.providersToUnlink, - this.providerToLink, - }); - - final bool? disabled; - final String? displayName; - final String? email; - final bool? emailVerified; - // TODO multifactor - final String? password; - final String? photoURL; - final List? providersToUnlink; - // TODO UserProvider - final Object? providerToLink; -} diff --git a/packages/dart_firebase_admin/lib/src/auth/user.dart b/packages/dart_firebase_admin/lib/src/auth/user.dart new file mode 100644 index 0000000..320008f --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/user.dart @@ -0,0 +1,417 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:firebaseapis/identitytoolkit/v1.dart' as firebase_auth_v1; +import 'package:meta/meta.dart'; + +import '../dart_firebase_admin.dart'; +import '../object_utils.dart'; + +/// 'REDACTED', encoded as a base64 string. +final b64Redacted = base64Encode('REDACTED'.codeUnits); + +enum MultiFactorId { + phone._('phone'), + totp._('totp'); + + const MultiFactorId._(this._value); + + final String _value; +} + +class UserRecord { + @internal + UserRecord({ + required this.uid, + required this.email, + required this.emailVerified, + required this.displayName, + required this.photoUrl, + required this.phoneNumber, + required this.disabled, + required this.metadata, + required this.providerData, + required this.passwordHash, + required this.passwordSalt, + required this.customClaims, + required this.tenantId, + required this.tokensValidAfterTime, + required this.multiFactor, + }); + + @internal + factory UserRecord.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1UserInfo response, + ) { + final localId = response.localId; + // The Firebase user id is required. + if (localId == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'INTERNAL ASSERT FAILED: Invalid user response', + ); + } + // If disabled is not provided, the account is enabled by default. + final disabled = response.disabled ?? false; + final metadata = UserMetadata.fromResponse(response); + + final providerData = []; + final providerUserInfo = response.providerUserInfo; + if (providerUserInfo != null) { + for (final entry in providerUserInfo) { + providerData.add(UserInfo.fromResponse(entry)); + } + } + + // If the password hash is redacted (probably due to missing permissions) + // then clear it out, similar to how the salt is returned. (Otherwise, it + // *looks* like a b64-encoded hash is present, which is confusing.) + final passwordHash = + response.passwordHash == b64Redacted ? null : response.passwordHash; + + final customAttributes = response.customAttributes; + final customClaims = customAttributes != null + ? UnmodifiableMapView( + jsonDecode(customAttributes) as Map, + ) + : null; + + DateTime? tokensValidAfterTime; + final validSince = response.validSince; + if (validSince != null) { + // Convert validSince first to UTC milliseconds and then to UTC date string. + tokensValidAfterTime = DateTime.fromMillisecondsSinceEpoch( + // TODO double check that 1000 + int.parse(validSince) * 1000, + isUtc: true, + ); + } + + MultiFactorSettings? multiFactor = + MultiFactorSettings.fromResponse(response); + if (multiFactor.enrolledFactors.isEmpty) { + multiFactor = null; + } + + return UserRecord( + uid: localId, + email: response.email, + emailVerified: response.emailVerified ?? false, + displayName: response.displayName, + photoUrl: response.photoUrl, + phoneNumber: response.phoneNumber, + disabled: disabled, + metadata: metadata, + providerData: UnmodifiableListView(providerData), + passwordHash: passwordHash, + passwordSalt: response.salt, + customClaims: customClaims, + tenantId: response.tenantId, + tokensValidAfterTime: tokensValidAfterTime, + multiFactor: multiFactor, + ); + } + + /// The user's `uid`. + final String uid; + + /// The user's primary email, if set. + final String? email; + + /// Whether or not the user's primary email is verified. + final bool emailVerified; + + /// The user's display name. + final String? displayName; + + /// The user's photo URL. + final String? photoUrl; + + /// The user's primary phone number, if set. + final String? phoneNumber; + + /// Whether or not the user is disabled: `true` for disabled; `false` for + /// enabled. + final bool disabled; + + /// Additional metadata about the user. + final UserMetadata metadata; + + /// An array of providers (for example, Google, Facebook) linked to the user. + final List providerData; + + /// The user's hashed password (base64-encoded), only if Firebase Auth hashing + /// algorithm (SCRYPT) is used. If a different hashing algorithm had been used + /// when uploading this user, as is typical when migrating from another Auth + /// system, this will be an empty string. If no password is set, this is + /// null. This is only available when the user is obtained from + /// {@link BaseAuth.listUsers}. + final String? passwordHash; + + /// The user's password salt (base64-encoded), only if Firebase Auth hashing + /// algorithm (SCRYPT) is used. If a different hashing algorithm had been used to + /// upload this user, typical when migrating from another Auth system, this will + /// be an empty string. If no password is set, this is null. This is only + /// available when the user is obtained from {@link BaseAuth.listUsers}. + final String? passwordSalt; + + /// The user's custom claims object if available, typically used to define + /// user roles and propagated to an authenticated user's ID token. + /// This is set via {@link BaseAuth.setCustomUserClaims} + final Map? customClaims; + + /// The ID of the tenant the user belongs to, if available. + final String? tenantId; + + /// The date the user's tokens are valid after, formatted as a UTC string. + /// This is updated every time the user's refresh token are revoked either +// TODO review {@link} + /// from the {@link BaseAuth.revokeRefreshTokens} + /// API or from the Firebase Auth backend on big account changes (password + /// resets, password or email updates, etc). + final DateTime? tokensValidAfterTime; + + /// The multi-factor related properties for the current user, if available. + final MultiFactorSettings? multiFactor; + + /// Returns a JSON-serializable representation of this object. + /// + /// @returns A JSON-serializable representation of this object. + Map toJson() { + final providerDataJson = []; + final json = { + 'uid': uid, + 'email': email, + 'emailVerified': emailVerified, + 'displayName': displayName, + 'photoURL': photoUrl, + 'phoneNumber': phoneNumber, + 'disabled': disabled, + // Convert metadata to json. + 'metadata': metadata.toJson(), + 'passwordHash': passwordHash, + 'passwordSalt': passwordSalt, + 'customClaims': customClaims, + 'tokensValidAfterTime': tokensValidAfterTime, + 'tenantId': tenantId, + 'providerData': providerDataJson, + }; + + final multiFactor = this.multiFactor; + if (multiFactor != null) json['multiFactor'] = multiFactor.toJson(); + + json['providerData'] = []; + for (final entry in providerData) { + // Convert each provider data to json. + providerDataJson.add(entry.toJson()); + } + return json; + } +} + +class UserInfo { + UserInfo({ + required this.uid, + required this.displayName, + required this.email, + required this.photoUrl, + required this.providerId, + required this.phoneNumber, + }); + + UserInfo.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1ProviderUserInfo response, + ) : uid = response.rawId, + displayName = response.displayName, + email = response.email, + photoUrl = response.photoUrl, + providerId = response.providerId, + phoneNumber = response.phoneNumber { + if (response.rawId == null || response.providerId == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + ); + } + } + + final String? uid; + final String? displayName; + final String? email; + final String? photoUrl; + final String? providerId; + final String? phoneNumber; + + Map toJson() { + return { + 'uid': uid, + 'displayName': displayName, + 'email': email, + 'photoURL': photoUrl, + 'providerId': providerId, + 'phoneNumber': phoneNumber, + }; + } +} + +class MultiFactorSettings { + MultiFactorSettings({required this.enrolledFactors}); + + factory MultiFactorSettings.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1UserInfo response, + ) { + final parsedEnrolledFactors = [ + ...?response.mfaInfo + ?.map(MultiFactorInfo.initMultiFactorInfo) + .whereNotNull(), + ]; + + return MultiFactorSettings( + enrolledFactors: UnmodifiableListView(parsedEnrolledFactors), + ); + } + + final List enrolledFactors; + + Map toJson() { + return { + 'enrolledFactors': enrolledFactors.map((info) => info.toJson()).toList(), + }; + } +} + +/// Interface representing the common properties of a user-enrolled second factor. +abstract class MultiFactorInfo { + MultiFactorInfo({ + required this.uid, + required this.displayName, + required this.enrollmentTime, + }); + + MultiFactorInfo.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1MfaEnrollment response, + ) : uid = response.mfaEnrollmentId.orThrow( + () => throw FirebaseAuthAdminException( + AuthClientErrorCode.internalError, + 'INTERNAL ASSERT FAILED: No uid found for MFA info.', + ), + ), + displayName = response.displayName, + enrollmentTime = response.enrolledAt + .let(int.parse) + .let(DateTime.fromMillisecondsSinceEpoch); + + /// Initializes the MultiFactorInfo associated subclass using the server side. + /// If no MultiFactorInfo is associated with the response, null is returned. + /// + /// @param response - The server side response. + /// @internal + static MultiFactorInfo? initMultiFactorInfo( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1MfaEnrollment response, + ) { + // PhoneMultiFactorInfo, TotpMultiFactorInfo currently available. + try { + final phoneInfo = response.phoneInfo; + // TODO Support TotpMultiFactorInfo + // final totpInfo = response.totpInfo; + + if (phoneInfo != null) { + return PhoneMultiFactorInfo.fromResponse(response); + } /* else if (totpInfo != null) { + return TotpMultiFactorInfo(response); + }*/ + + // Ignore the other SDK unsupported MFA factors to prevent blocking developers using the current SDK. + } catch (e) { + // Ignore error. + } + + return null; + } + + /// The ID of the enrolled second factor. This ID is unique to the user. + final String uid; + + /// The optional display name of the enrolled second factor. + final String? displayName; + + /// The type identifier of the second factor. + /// For SMS second factors, this is `phone`. + /// For TOTP second factors, this is `totp`. + MultiFactorId get factorId; + + /// The optional date the second factor was enrolled, formatted as a UTC string. + final DateTime? enrollmentTime; + + /// Returns a JSON-serializable representation of this object. + /// + /// @returns A JSON-serializable representation of this object. + Map toJson() { + return { + 'uid': uid, + 'displayName': displayName, + 'factorId': factorId._value, + 'enrollmentTime': enrollmentTime, + }; + } +} + +/// Interface representing a phone specific user-enrolled second factor. +class PhoneMultiFactorInfo extends MultiFactorInfo { + /// Initializes the PhoneMultiFactorInfo object using the server side response. + @internal + PhoneMultiFactorInfo.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1MfaEnrollment response, + ) : phoneNumber = response.phoneInfo, + factorId = response.phoneInfo != null ? MultiFactorId.phone : throw 42, + super.fromResponse(response); + + /// The phone number associated with a phone second factor. + final String? phoneNumber; + + @override + final MultiFactorId factorId; + + /// {@inheritdoc MultiFactorInfo.toJSON} + @override + Map toJson() { + return { + ...super.toJson(), + 'phoneNumber': phoneNumber, + }; + } +} + +class UserMetadata { + UserMetadata({ + required this.creationTime, + required this.lastSignInTime, + required this.lastRefreshTime, + }); + + @internal + UserMetadata.fromResponse( + firebase_auth_v1.GoogleCloudIdentitytoolkitV1UserInfo response, + ) : creationTime = DateTime.fromMillisecondsSinceEpoch( + int.parse(response.createdAt!), + ), + lastSignInTime = DateTime.fromMillisecondsSinceEpoch( + int.parse(response.lastLoginAt!), + ), + lastRefreshTime = response.lastRefreshAt == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + int.parse(response.lastRefreshAt!), + ); + + final DateTime creationTime; + final DateTime lastSignInTime; + final DateTime? lastRefreshTime; + + Map toJson() { + return { + 'creationTime': creationTime.toUtc().toString(), + 'lastSignInTime': lastSignInTime.toUtc().toString(), + 'lastRefreshTime': lastRefreshTime?.toUtc().toString(), + }; + } +} diff --git a/packages/dart_firebase_admin/lib/src/auth/user_import_builder.dart b/packages/dart_firebase_admin/lib/src/auth/user_import_builder.dart new file mode 100644 index 0000000..47a02f1 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/user_import_builder.dart @@ -0,0 +1,578 @@ +// TODO remove redundant "required" + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:firebaseapis/identitytoolkit/v1.dart' as v1; +import 'package:meta/meta.dart'; + +import '../dart_firebase_admin.dart'; +import '../app/core.dart'; +import '../object_utils.dart'; +import 'auth_config.dart'; +import 'base_auth.dart'; + +enum HashAlgorithmType { + scrypt('SCRYPT'), + standardScrypt('STANDARD_SCRYPT'), + hmacSha512('HMAC_SHA512'), + hmacSha256('HMAC_SHA256'), + hmacSha1('HMAC_SHA1'), + hmacMd5('HMAC_MD5'), + md5('MD5'), + pbkdfSha1('PBKDF_SHA1'), + bcrypt('BCRYPT'), + pbkdf2Sha256('PBKDF2_SHA256'), + sha512('SHA512'), + sha256('SHA256'), + sha1('SHA1'); + + const HashAlgorithmType(this.value); + + final String value; +} + +class UserImportHashOptions { + UserImportHashOptions({ + required this.algorithm, + required this.key, + required this.saltSeparator, + required this.rounds, + required this.memoryCost, + required this.parallelization, + required this.blockSize, + required this.derivedKeyLength, + }); + + /// The password hashing algorithm identifier. The following algorithm + /// identifiers are supported: + /// `SCRYPT`, `STANDARD_SCRYPT`, `HMAC_SHA512`, `HMAC_SHA256`, `HMAC_SHA1`, + /// `HMAC_MD5`, `MD5`, `PBKDF_SHA1`, `BCRYPT`, `PBKDF2_SHA256`, `SHA512`, + /// `SHA256` and `SHA1`. + final HashAlgorithmType algorithm; + + /// The signing key used in the hash algorithm in buffer bytes. + /// Required by hashing algorithms `SCRYPT`, `HMAC_SHA512`, `HMAC_SHA256`, + /// `HAMC_SHA1` and `HMAC_MD5`. + final Uint8List? key; + + /// The salt separator in buffer bytes which is appended to salt when + /// verifying a password. This is only used by the `SCRYPT` algorithm. + final Uint8List? saltSeparator; + + /// The number of rounds for hashing calculation. + /// Required for `SCRYPT`, `MD5`, `SHA512`, `SHA256`, `SHA1`, `PBKDF_SHA1` and + /// `PBKDF2_SHA256`. + final int? rounds; + + /// The memory cost required for `SCRYPT` algorithm, or the CPU/memory cost. + /// Required for `STANDARD_SCRYPT` algorithm. + final int? memoryCost; + + /// The parallelization of the hashing algorithm. Required for the + /// `STANDARD_SCRYPT` algorithm. + final int? parallelization; + + /// The block size (normally 8) of the hashing algorithm. Required for the + /// `STANDARD_SCRYPT` algorithm. + final int? blockSize; + + /// The derived key length of the hashing algorithm. Required for the + /// `STANDARD_SCRYPT` algorithm. + final int? derivedKeyLength; +} + +/// Interface representing the user import options needed for +/// {@link BaseAuth.importUsers} method. This is used to +/// provide the password hashing algorithm information. +class UserImportOptions { + UserImportOptions({required this.hash}); + + /// The password hashing information. + final UserImportHashOptions hash; +} + +class UploadAccountOptions { + UploadAccountOptions._({ + this.hashAlgorithm, + this.signerKey, + this.rounds, + this.memoryCost, + this.saltSeparator, + this.cpuMemCost, + this.parallelization, + this.blockSize, + this.dkLen, + }); + + final HashAlgorithmType? hashAlgorithm; + final String? signerKey; + final int? rounds; + final int? memoryCost; + final String? saltSeparator; + final int? cpuMemCost; + final int? parallelization; + final int? blockSize; + final int? dkLen; +} + +/// User provider data to include when importing a user. +class UserProviderRequest { + UserProviderRequest({ + required this.uid, + required this.displayName, + required this.email, + required this.phoneNumber, + required this.photoURL, + required this.providerId, + }); + + /// The user identifier for the linked provider. + final String uid; + + /// The display name for the linked provider. + final String? displayName; + + /// The email for the linked provider. + final String? email; + + /// The phone number for the linked provider. + final String? phoneNumber; + + /// The photo URL for the linked provider. + final String? photoURL; + + /// The linked provider ID (for example, "google.com" for the Google provider). + final String providerId; +} + +/// Interface representing a user to import to Firebase Auth via the +/// {@link BaseAuth.importUsers} method. +class UserImportRecord { + UserImportRecord({ + required this.uid, + required this.email, + required this.emailVerified, + required this.displayName, + required this.phoneNumber, + required this.photoURL, + required this.disabled, + required this.metadata, + required this.providerData, + required this.customClaims, + required this.passwordHash, + required this.passwordSalt, + required this.tenantId, + required this.multiFactor, + }); + + /// The user's `uid`. + final String uid; + + /// The user's primary email, if set. + final String? email; + + /// Whether or not the user's primary email is verified. + final bool? emailVerified; + + /// The user's display name. + final String? displayName; + + /// The user's primary phone number, if set. + final String? phoneNumber; + + /// The user's photo URL. + final String? photoURL; + + /// Whether or not the user is disabled: `true` for disabled; `false` for + /// enabled. + final bool? disabled; + + /// Additional metadata about the user. + final UserMetadataRequest? metadata; + + /// An array of providers (for example, Google, Facebook) linked to the user. + final List? providerData; + + /// The user's custom claims object if available, typically used to define + /// user roles and propagated to an authenticated user's ID token. + final Map? customClaims; + + /// The buffer of bytes representing the user's hashed password. + /// When a user is to be imported with a password hash, + /// {@link UserImportOptions} are required to be + /// specified to identify the hashing algorithm used to generate this hash. + final Uint8List? passwordHash; + + /// The buffer of bytes representing the user's password salt. + final Uint8List? passwordSalt; + + /// The identifier of the tenant where user is to be imported to. + /// When not provided in an `admin.auth.Auth` context, the user is uploaded to + /// the default parent project. + /// When not provided in an `admin.auth.TenantAwareAuth` context, the user is uploaded + /// to the tenant corresponding to that `TenantAwareAuth` instance's tenant ID. + final String? tenantId; + + /// The user's multi-factor related properties. + final MultiFactorUpdateSettings? multiFactor; +} + +/// Interface representing an Auth second factor in Auth server format. +class AuthFactorInfo { + AuthFactorInfo({ + required this.mfaEnrollmentId, + required this.displayName, + required this.phoneInfo, + required this.enrolledAt, + }); + + // TODO allow any key + + // Not required for signupNewUser endpoint. + final String? mfaEnrollmentId; + final String? displayName; + final String? phoneInfo; + final String? enrolledAt; +} + +class UploadProviderUserInfo { + UploadProviderUserInfo({ + required this.rawId, + required this.providerId, + required this.email, + required this.displayName, + required this.photoUrl, + }); + + final String rawId; + final String providerId; + final String? email; + final String? displayName; + final String? photoUrl; +} + +/// Callback function to validate an UploadAccountUser object. +typedef ValidatorFunction = void Function( + v1.GoogleCloudIdentitytoolkitV1UserInfo data, +); + +/// User metadata to include when importing a user. +class UserMetadataRequest { + UserMetadataRequest({ + required this.lastSignInTime, + required this.creationTime, + }); + + /// The date the user last signed in, formatted as a UTC string. + final DateTime? lastSignInTime; + + /// The date the user was created, formatted as a UTC string. + final DateTime? creationTime; +} + +@internal +class UserImportBuilder { + UserImportBuilder({ + required this.users, + required this.options, + required this.userRequestValidator, + }) { + _validatedUsers = _populateUsers(users, userRequestValidator); + _validatedOptions = _populateOptions( + options, + requiresHashOptions: _requiresHashOptions, + ); + } + + final List users; + final UserImportOptions? options; + final ValidatorFunction? userRequestValidator; + + var _requiresHashOptions = false; + var _validatedUsers = []; + UploadAccountOptions? _validatedOptions; + final _indexMap = {}; + final _userImportResultErrors = []; + + v1.GoogleCloudIdentitytoolkitV1UploadAccountRequest buildRequest() { + return v1.GoogleCloudIdentitytoolkitV1UploadAccountRequest( + hashAlgorithm: _validatedOptions?.hashAlgorithm?.value, + signerKey: _validatedOptions?.signerKey, + rounds: _validatedOptions?.rounds, + memoryCost: _validatedOptions?.memoryCost, + saltSeparator: _validatedOptions?.saltSeparator, + cpuMemCost: _validatedOptions?.cpuMemCost, + parallelization: _validatedOptions?.parallelization, + blockSize: _validatedOptions?.blockSize, + dkLen: _validatedOptions?.dkLen, + users: _validatedUsers.toList(), + ); + } + + UserImportResult buildResponse( + List failedUploads, + ) { + // Initialize user import result. + final importResult = UserImportResult( + successCount: _validatedUsers.length - failedUploads.length, + failureCount: _userImportResultErrors.length + failedUploads.length, + errors: [ + ..._userImportResultErrors, + for (final failedUpload in failedUploads) + FirebaseArrayIndexError( + // Map backend request index to original developer provided array index. + index: _indexMap[failedUpload.index]!, + error: FirebaseAuthAdminException( + AuthClientErrorCode.invalidUserImport, + failedUpload.message, + ), + ), + ], + ); + // Sort errors by index. + importResult.errors.sort((a, b) => a.index - b.index); + // Return sorted result + return importResult; + } + + UploadAccountOptions _populateOptions( + UserImportOptions? options, { + required bool requiresHashOptions, + }) { + if (!requiresHashOptions) return UploadAccountOptions._(); + + if (options == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidArgument, + '"UserImportOptions" are required when importing users with passwords.', + ); + } + + switch (options.hash.algorithm) { + case HashAlgorithmType.hmacSha512: + case HashAlgorithmType.hmacSha256: + case HashAlgorithmType.hmacSha1: + case HashAlgorithmType.hmacMd5: + final key = options.hash.key; + if (key == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashKey, + 'A non-empty "hash.key" byte buffer must be provided for hash ' + 'algorithm ${options.hash.algorithm}.', + ); + } + + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + signerKey: base64Encode(key), + ); + + case HashAlgorithmType.md5: + case HashAlgorithmType.sha1: + case HashAlgorithmType.sha256: + case HashAlgorithmType.sha512: + // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] + final rounds = options.hash.rounds; + final minRounds = + options.hash.algorithm == HashAlgorithmType.md5 ? 0 : 1; + if (rounds == null || rounds < minRounds || rounds > 8192) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashRounds, + 'A "hash.rounds" value between $minRounds and 8192 must be provided ' + 'for hash algorithm ${options.hash.algorithm}.', + ); + } + + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + rounds: rounds, + ); + + case HashAlgorithmType.pbkdfSha1: + case HashAlgorithmType.pbkdf2Sha256: + final rounds = options.hash.rounds; + if (rounds == null || rounds < 0 || rounds > 120000) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashRounds, + 'A "hash.rounds" value between 0 and 120000 must be provided ' + 'for hash algorithm ${options.hash.algorithm}.', + ); + } + + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + rounds: rounds, + ); + + case HashAlgorithmType.scrypt: + final key = options.hash.key; + if (key == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashKey, + 'A "hash.key" byte buffer must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + final rounds = options.hash.rounds; + if (rounds == null || rounds <= 0 || rounds > 8) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashRounds, + 'A valid "hash.rounds" number between 1 and 8 must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + final memoryCost = options.hash.memoryCost; + if (memoryCost == null || memoryCost <= 0 || memoryCost > 14) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashMemoryCost, + 'A valid "hash.memoryCost" number between 1 and 14 must be provided ' + 'for hash algorithm ${options.hash.algorithm}.', + ); + } + final saltSeparator = options.hash.saltSeparator; + + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + signerKey: base64Encode(key), + rounds: rounds, + memoryCost: memoryCost, + saltSeparator: base64Encode(saltSeparator ?? Uint8List(0)), + ); + + case HashAlgorithmType.bcrypt: + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + ); + + case HashAlgorithmType.standardScrypt: + final cpuMemCost = options.hash.memoryCost; + if (cpuMemCost == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashMemoryCost, + 'A valid "hash.memoryCost" number must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + final parallelization = options.hash.parallelization; + if (parallelization == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashParallelization, + 'A valid "hash.parallelization" number must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + final blockSize = options.hash.blockSize; + if (blockSize == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashBlockSize, + 'A valid "hash.blockSize" number must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + final dkLen = options.hash.derivedKeyLength; + if (dkLen == null) { + throw FirebaseAuthAdminException( + AuthClientErrorCode.invalidHashDerivedKeyLength, + 'A valid "hash.derivedKeyLength" number must be provided for ' + 'hash algorithm ${options.hash.algorithm}.', + ); + } + + return UploadAccountOptions._( + hashAlgorithm: options.hash.algorithm, + memoryCost: cpuMemCost, + parallelization: parallelization, + blockSize: blockSize, + dkLen: dkLen, + ); + } + } + + /// Validates and returns the users list of the uploadAccount request. + /// Whenever a user with an error is detected, the error is cached and will later be + /// merged into the user import result. This allows the processing of valid users without + /// failing early on the first error detected. + /// @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser + /// objects. + /// @param {ValidatorFunction=} userValidator The user validator function. + /// @returns {UploadAccountUser[]} The populated uploadAccount users. + List _populateUsers( + List users, + ValidatorFunction? userValidator, + ) { + final populatedUsers = []; + users.forEachIndexed((index, user) { + try { + final result = _populateUploadAccountUser(user, userValidator); + if (result.passwordHash != null) { + _requiresHashOptions = true; + } + + // Only users that pass client screening will be passed to backend for processing. + populatedUsers.add(result); + // Map user's index (the one to be sent to backend) to original developer provided array. + _indexMap[populatedUsers.length - 1] = index; + } on FirebaseException catch (err) { + _userImportResultErrors.add( + FirebaseArrayIndexError(index: index, error: err), + ); + } + }); + + return populatedUsers; + } +} + +/// Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid +/// fields are provided. +/// @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. +/// @param {ValidatorFunction=} userValidator The user validator function. +/// @returns {UploadAccountUser} The corresponding UploadAccountUser to return. +v1.GoogleCloudIdentitytoolkitV1UserInfo _populateUploadAccountUser( + UserImportRecord user, + ValidatorFunction? userValidator, +) { + final mfaInfo = user.multiFactor?.enrolledFactors + ?.map( + (factor) => factor.toMfaEnrollment(), + ) + .toList(); + + final providerUserInfo = user.providerData + ?.map( + (providerData) => v1.GoogleCloudIdentitytoolkitV1ProviderUserInfo( + rawId: providerData.uid, + providerId: providerData.providerId, + email: providerData.email, + displayName: providerData.displayName, + photoUrl: providerData.photoURL, + ), + ) + .toList(); + + final result = v1.GoogleCloudIdentitytoolkitV1UserInfo( + localId: user.uid, + email: user.email, + emailVerified: user.emailVerified, + displayName: user.displayName, + disabled: user.disabled, + photoUrl: user.photoURL, + phoneNumber: user.phoneNumber, + providerUserInfo: providerUserInfo != null && providerUserInfo.isNotEmpty + ? providerUserInfo + : null, + mfaInfo: mfaInfo != null && mfaInfo.isNotEmpty ? mfaInfo : null, + tenantId: user.tenantId, + customAttributes: user.customClaims.let(json.encode), + passwordHash: user.passwordHash.let(base64Encode), + salt: user.passwordSalt.let(base64Encode), + createdAt: user.metadata?.creationTime?.toIso8601String(), + lastLoginAt: user.metadata?.lastSignInTime?.toIso8601String(), + ); + + userValidator?.call(result); + + return result; +} diff --git a/packages/dart_firebase_admin/lib/src/auth/user_import_builder.ts b/packages/dart_firebase_admin/lib/src/auth/user_import_builder.ts new file mode 100644 index 0000000..12afb37 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/auth/user_import_builder.ts @@ -0,0 +1,786 @@ +/*! + * Copyright 2018 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseArrayIndexError } from "../app/index"; +import { deepCopy, deepExtend } from "../utils/deep-copy"; +import * as utils from "../utils"; +import * as validator from "../utils/validator"; +import { AuthClientErrorCode, FirebaseAuthError } from "../utils/error"; +import { + UpdateMultiFactorInfoRequest, + UpdatePhoneMultiFactorInfoRequest, + MultiFactorUpdateSettings, +} from "./auth-config"; + +export type HashAlgorithmType = + | "SCRYPT" + | "STANDARD_SCRYPT" + | "HMAC_SHA512" + | "HMAC_SHA256" + | "HMAC_SHA1" + | "HMAC_MD5" + | "MD5" + | "PBKDF_SHA1" + | "BCRYPT" + | "PBKDF2_SHA256" + | "SHA512" + | "SHA256" + | "SHA1"; + +/** + * Interface representing the user import options needed for + * {@link BaseAuth.importUsers} method. This is used to + * provide the password hashing algorithm information. + */ +export interface UserImportOptions { + /** + * The password hashing information. + */ + hash: { + /** + * The password hashing algorithm identifier. The following algorithm + * identifiers are supported: + * `SCRYPT`, `STANDARD_SCRYPT`, `HMAC_SHA512`, `HMAC_SHA256`, `HMAC_SHA1`, + * `HMAC_MD5`, `MD5`, `PBKDF_SHA1`, `BCRYPT`, `PBKDF2_SHA256`, `SHA512`, + * `SHA256` and `SHA1`. + */ + algorithm: HashAlgorithmType; + + /** + * The signing key used in the hash algorithm in buffer bytes. + * Required by hashing algorithms `SCRYPT`, `HMAC_SHA512`, `HMAC_SHA256`, + * `HAMC_SHA1` and `HMAC_MD5`. + */ + key?: Buffer; + + /** + * The salt separator in buffer bytes which is appended to salt when + * verifying a password. This is only used by the `SCRYPT` algorithm. + */ + saltSeparator?: Buffer; + + /** + * The number of rounds for hashing calculation. + * Required for `SCRYPT`, `MD5`, `SHA512`, `SHA256`, `SHA1`, `PBKDF_SHA1` and + * `PBKDF2_SHA256`. + */ + rounds?: number; + + /** + * The memory cost required for `SCRYPT` algorithm, or the CPU/memory cost. + * Required for `STANDARD_SCRYPT` algorithm. + */ + memoryCost?: number; + + /** + * The parallelization of the hashing algorithm. Required for the + * `STANDARD_SCRYPT` algorithm. + */ + parallelization?: number; + + /** + * The block size (normally 8) of the hashing algorithm. Required for the + * `STANDARD_SCRYPT` algorithm. + */ + blockSize?: number; + /** + * The derived key length of the hashing algorithm. Required for the + * `STANDARD_SCRYPT` algorithm. + */ + derivedKeyLength?: number; + }; +} + +/** + * Interface representing a user to import to Firebase Auth via the + * {@link BaseAuth.importUsers} method. + */ +export interface UserImportRecord { + /** + * The user's `uid`. + */ + uid: string; + + /** + * The user's primary email, if set. + */ + email?: string; + + /** + * Whether or not the user's primary email is verified. + */ + emailVerified?: boolean; + + /** + * The user's display name. + */ + displayName?: string; + + /** + * The user's primary phone number, if set. + */ + phoneNumber?: string; + + /** + * The user's photo URL. + */ + photoURL?: string; + + /** + * Whether or not the user is disabled: `true` for disabled; `false` for + * enabled. + */ + disabled?: boolean; + + /** + * Additional metadata about the user. + */ + metadata?: UserMetadataRequest; + + /** + * An array of providers (for example, Google, Facebook) linked to the user. + */ + providerData?: UserProviderRequest[]; + + /** + * The user's custom claims object if available, typically used to define + * user roles and propagated to an authenticated user's ID token. + */ + customClaims?: { [key: string]: any }; + + /** + * The buffer of bytes representing the user's hashed password. + * When a user is to be imported with a password hash, + * {@link UserImportOptions} are required to be + * specified to identify the hashing algorithm used to generate this hash. + */ + passwordHash?: Buffer; + + /** + * The buffer of bytes representing the user's password salt. + */ + passwordSalt?: Buffer; + + /** + * The identifier of the tenant where user is to be imported to. + * When not provided in an `admin.auth.Auth` context, the user is uploaded to + * the default parent project. + * When not provided in an `admin.auth.TenantAwareAuth` context, the user is uploaded + * to the tenant corresponding to that `TenantAwareAuth` instance's tenant ID. + */ + tenantId?: string; + + /** + * The user's multi-factor related properties. + */ + multiFactor?: MultiFactorUpdateSettings; +} + +/** + * User metadata to include when importing a user. + */ +export interface UserMetadataRequest { + /** + * The date the user last signed in, formatted as a UTC string. + */ + lastSignInTime?: string; + + /** + * The date the user was created, formatted as a UTC string. + */ + creationTime?: string; +} + +/** + * User provider data to include when importing a user. + */ +export interface UserProviderRequest { + /** + * The user identifier for the linked provider. + */ + uid: string; + + /** + * The display name for the linked provider. + */ + displayName?: string; + + /** + * The email for the linked provider. + */ + email?: string; + + /** + * The phone number for the linked provider. + */ + phoneNumber?: string; + + /** + * The photo URL for the linked provider. + */ + photoURL?: string; + + /** + * The linked provider ID (for example, "google.com" for the Google provider). + */ + providerId: string; +} + +/** + * Interface representing the response from the + * {@link BaseAuth.importUsers} method for batch + * importing users to Firebase Auth. + */ +export interface UserImportResult { + /** + * The number of user records that failed to import to Firebase Auth. + */ + failureCount: number; + + /** + * The number of user records that successfully imported to Firebase Auth. + */ + successCount: number; + + /** + * An array of errors corresponding to the provided users to import. The + * length of this array is equal to [`failureCount`](#failureCount). + */ + errors: FirebaseArrayIndexError[]; +} + +/** Interface representing an Auth second factor in Auth server format. */ +export interface AuthFactorInfo { + // Not required for signupNewUser endpoint. + mfaEnrollmentId?: string; + displayName?: string; + phoneInfo?: string; + enrolledAt?: string; + [key: string]: any; +} + +/** UploadAccount endpoint request user interface. */ +interface UploadAccountUser { + localId: string; + email?: string; + emailVerified?: boolean; + displayName?: string; + disabled?: boolean; + photoUrl?: string; + phoneNumber?: string; + providerUserInfo?: Array<{ + rawId: string; + providerId: string; + email?: string; + displayName?: string; + photoUrl?: string; + }>; + mfaInfo?: AuthFactorInfo[]; + passwordHash?: string; + salt?: string; + lastLoginAt?: number; + createdAt?: number; + customAttributes?: string; + tenantId?: string; +} + +/** UploadAccount endpoint request hash options. */ +export interface UploadAccountOptions { + hashAlgorithm?: string; + signerKey?: string; + rounds?: number; + memoryCost?: number; + saltSeparator?: string; + cpuMemCost?: number; + parallelization?: number; + blockSize?: number; + dkLen?: number; +} + +/** UploadAccount endpoint complete request interface. */ +export interface UploadAccountRequest extends UploadAccountOptions { + users?: UploadAccountUser[]; +} + +/** Callback function to validate an UploadAccountUser object. */ +export type ValidatorFunction = (data: UploadAccountUser) => void; + +/** + * Converts a client format second factor object to server format. + * @param multiFactorInfo - The client format second factor. + * @returns The corresponding AuthFactorInfo server request format. + */ +export function convertMultiFactorInfoToServerFormat( + multiFactorInfo: UpdateMultiFactorInfoRequest +): AuthFactorInfo { + let enrolledAt; + if (typeof multiFactorInfo.enrollmentTime !== "undefined") { + if (validator.isUTCDateString(multiFactorInfo.enrollmentTime)) { + // Convert from UTC date string (client side format) to ISO date string (server side format). + enrolledAt = new Date(multiFactorInfo.enrollmentTime).toISOString(); + } else { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ENROLLMENT_TIME, + `The second factor "enrollmentTime" for "${multiFactorInfo.uid}" must be a valid ` + + "UTC date string." + ); + } + } + // Currently only phone second factors are supported. + if (isPhoneFactor(multiFactorInfo)) { + // If any required field is missing or invalid, validation will still fail later. + const authFactorInfo: AuthFactorInfo = { + mfaEnrollmentId: multiFactorInfo.uid, + displayName: multiFactorInfo.displayName, + // Required for all phone second factors. + phoneInfo: multiFactorInfo.phoneNumber, + enrolledAt, + }; + for (const objKey in authFactorInfo) { + if (typeof authFactorInfo[objKey] === "undefined") { + delete authFactorInfo[objKey]; + } + } + return authFactorInfo; + } else { + // Unsupported second factor. + throw new FirebaseAuthError( + AuthClientErrorCode.UNSUPPORTED_SECOND_FACTOR, + `Unsupported second factor "${JSON.stringify(multiFactorInfo)}" provided.` + ); + } +} + +function isPhoneFactor( + multiFactorInfo: UpdateMultiFactorInfoRequest +): multiFactorInfo is UpdatePhoneMultiFactorInfoRequest { + return multiFactorInfo.factorId === "phone"; +} + +/** + * @param {any} obj The object to check for number field within. + * @param {string} key The entry key. + * @returns {number} The corresponding number if available. Otherwise, NaN. + */ +function getNumberField(obj: any, key: string): number { + if (typeof obj[key] !== "undefined" && obj[key] !== null) { + return parseInt(obj[key].toString(), 10); + } + return NaN; +} + +/** + * Converts a UserImportRecord to a UploadAccountUser object. Throws an error when invalid + * fields are provided. + * @param {UserImportRecord} user The UserImportRecord to conver to UploadAccountUser. + * @param {ValidatorFunction=} userValidator The user validator function. + * @returns {UploadAccountUser} The corresponding UploadAccountUser to return. + */ +function populateUploadAccountUser( + user: UserImportRecord, + userValidator?: ValidatorFunction +): UploadAccountUser { + const result: UploadAccountUser = { + localId: user.uid, + email: user.email, + emailVerified: user.emailVerified, + displayName: user.displayName, + disabled: user.disabled, + photoUrl: user.photoURL, + phoneNumber: user.phoneNumber, + providerUserInfo: [], + mfaInfo: [], + tenantId: user.tenantId, + customAttributes: user.customClaims && JSON.stringify(user.customClaims), + }; + if (typeof user.passwordHash !== "undefined") { + if (!validator.isBuffer(user.passwordHash)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_HASH); + } + result.passwordHash = utils.toWebSafeBase64(user.passwordHash); + } + if (typeof user.passwordSalt !== "undefined") { + if (!validator.isBuffer(user.passwordSalt)) { + throw new FirebaseAuthError(AuthClientErrorCode.INVALID_PASSWORD_SALT); + } + result.salt = utils.toWebSafeBase64(user.passwordSalt); + } + if (validator.isNonNullObject(user.metadata)) { + if (validator.isNonEmptyString(user.metadata.creationTime)) { + result.createdAt = new Date(user.metadata.creationTime).getTime(); + } + if (validator.isNonEmptyString(user.metadata.lastSignInTime)) { + result.lastLoginAt = new Date(user.metadata.lastSignInTime).getTime(); + } + } + if (validator.isArray(user.providerData)) { + user.providerData.forEach((providerData) => { + result.providerUserInfo!.push({ + providerId: providerData.providerId, + rawId: providerData.uid, + email: providerData.email, + displayName: providerData.displayName, + photoUrl: providerData.photoURL, + }); + }); + } + + // Convert user.multiFactor.enrolledFactors to server format. + if ( + validator.isNonNullObject(user.multiFactor) && + validator.isNonEmptyArray(user.multiFactor.enrolledFactors) + ) { + user.multiFactor.enrolledFactors.forEach((multiFactorInfo) => { + result.mfaInfo!.push( + convertMultiFactorInfoToServerFormat(multiFactorInfo) + ); + }); + } + + // Remove blank fields. + let key: keyof UploadAccountUser; + for (key in result) { + if (typeof result[key] === "undefined") { + delete result[key]; + } + } + if (result.providerUserInfo!.length === 0) { + delete result.providerUserInfo; + } + if (result.mfaInfo!.length === 0) { + delete result.mfaInfo; + } + // Validate the constructured user individual request. This will throw if an error + // is detected. + if (typeof userValidator === "function") { + userValidator(result); + } + return result; +} + +/** + * Class that provides a helper for building/validating uploadAccount requests and + * UserImportResult responses. + */ +export class UserImportBuilder { + private requiresHashOptions: boolean; + private validatedUsers: UploadAccountUser[]; + private validatedOptions: UploadAccountOptions; + private indexMap: { [key: number]: number }; + private userImportResultErrors: FirebaseArrayIndexError[]; + + /** + * @param {UserImportRecord[]} users The list of user records to import. + * @param {UserImportOptions=} options The import options which includes hashing + * algorithm details. + * @param {ValidatorFunction=} userRequestValidator The user request validator function. + * @constructor + */ + constructor( + users: UserImportRecord[], + options?: UserImportOptions, + userRequestValidator?: ValidatorFunction + ) { + this.requiresHashOptions = false; + this.validatedUsers = []; + this.userImportResultErrors = []; + this.indexMap = {}; + + this.validatedUsers = this.populateUsers(users, userRequestValidator); + this.validatedOptions = this.populateOptions( + options, + this.requiresHashOptions + ); + } + + /** + * Returns the corresponding constructed uploadAccount request. + * @returns {UploadAccountRequest} The constructed uploadAccount request. + */ + public buildRequest(): UploadAccountRequest { + const users = this.validatedUsers.map((user) => { + return deepCopy(user); + }); + return deepExtend( + { users }, + deepCopy(this.validatedOptions) + ) as UploadAccountRequest; + } + + /** + * Populates the UserImportResult using the client side detected errors and the server + * side returned errors. + * @returns {UserImportResult} The user import result based on the returned failed + * uploadAccount response. + */ + public buildResponse( + failedUploads: Array<{ index: number; message: string }> + ): UserImportResult { + // Initialize user import result. + const importResult: UserImportResult = { + successCount: this.validatedUsers.length, + failureCount: this.userImportResultErrors.length, + errors: deepCopy(this.userImportResultErrors), + }; + importResult.failureCount += failedUploads.length; + importResult.successCount -= failedUploads.length; + failedUploads.forEach((failedUpload) => { + importResult.errors.push({ + // Map backend request index to original developer provided array index. + index: this.indexMap[failedUpload.index], + error: new FirebaseAuthError( + AuthClientErrorCode.INVALID_USER_IMPORT, + failedUpload.message + ), + }); + }); + // Sort errors by index. + importResult.errors.sort((a, b) => { + return a.index - b.index; + }); + // Return sorted result. + return importResult; + } + + /** + * Validates and returns the hashing options of the uploadAccount request. + * Throws an error whenever an invalid or missing options is detected. + * @param {UserImportOptions} options The UserImportOptions. + * @param {boolean} requiresHashOptions Whether to require hash options. + * @returns {UploadAccountOptions} The populated UploadAccount options. + */ + private populateOptions( + options: UserImportOptions | undefined, + requiresHashOptions: boolean + ): UploadAccountOptions { + let populatedOptions: UploadAccountOptions; + if (!requiresHashOptions) { + return {}; + } + if (!validator.isNonNullObject(options)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_ARGUMENT, + '"UserImportOptions" are required when importing users with passwords.' + ); + } + if (!validator.isNonNullObject(options.hash)) { + throw new FirebaseAuthError( + AuthClientErrorCode.MISSING_HASH_ALGORITHM, + '"hash.algorithm" is missing from the provided "UserImportOptions".' + ); + } + if ( + typeof options.hash.algorithm === "undefined" || + !validator.isNonEmptyString(options.hash.algorithm) + ) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + '"hash.algorithm" must be a string matching the list of supported algorithms.' + ); + } + + let rounds: number; + switch (options.hash.algorithm) { + case "HMAC_SHA512": + case "HMAC_SHA256": + case "HMAC_SHA1": + case "HMAC_MD5": + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + 'A non-empty "hash.key" byte buffer must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + }; + break; + + case "MD5": + case "SHA1": + case "SHA256": + case "SHA512": { + // MD5 is [0,8192] but SHA1, SHA256, and SHA512 are [1,8192] + rounds = getNumberField(options.hash, "rounds"); + const minRounds = options.hash.algorithm === "MD5" ? 0 : 1; + if (isNaN(rounds) || rounds < minRounds || rounds > 8192) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + `A valid "hash.rounds" number between ${minRounds} and 8192 must be provided for ` + + `hash algorithm ${options.hash.algorithm}.` + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + } + case "PBKDF_SHA1": + case "PBKDF2_SHA256": + rounds = getNumberField(options.hash, "rounds"); + if (isNaN(rounds) || rounds < 0 || rounds > 120000) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + 'A valid "hash.rounds" number between 0 and 120000 must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + rounds, + }; + break; + + case "SCRYPT": { + if (!validator.isBuffer(options.hash.key)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_KEY, + 'A "hash.key" byte buffer must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + rounds = getNumberField(options.hash, "rounds"); + if (isNaN(rounds) || rounds <= 0 || rounds > 8) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ROUNDS, + 'A valid "hash.rounds" number between 1 and 8 must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + const memoryCost = getNumberField(options.hash, "memoryCost"); + if (isNaN(memoryCost) || memoryCost <= 0 || memoryCost > 14) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + 'A valid "hash.memoryCost" number between 1 and 14 must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + if ( + typeof options.hash.saltSeparator !== "undefined" && + !validator.isBuffer(options.hash.saltSeparator) + ) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_SALT_SEPARATOR, + '"hash.saltSeparator" must be a byte buffer.' + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + signerKey: utils.toWebSafeBase64(options.hash.key), + rounds, + memoryCost, + saltSeparator: utils.toWebSafeBase64( + options.hash.saltSeparator || Buffer.from("") + ), + }; + break; + } + case "BCRYPT": + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + }; + break; + + case "STANDARD_SCRYPT": { + const cpuMemCost = getNumberField(options.hash, "memoryCost"); + if (isNaN(cpuMemCost)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_MEMORY_COST, + 'A valid "hash.memoryCost" number must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + const parallelization = getNumberField(options.hash, "parallelization"); + if (isNaN(parallelization)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_PARALLELIZATION, + 'A valid "hash.parallelization" number must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + const blockSize = getNumberField(options.hash, "blockSize"); + if (isNaN(blockSize)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_BLOCK_SIZE, + 'A valid "hash.blockSize" number must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + const dkLen = getNumberField(options.hash, "derivedKeyLength"); + if (isNaN(dkLen)) { + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_DERIVED_KEY_LENGTH, + 'A valid "hash.derivedKeyLength" number must be provided for ' + + `hash algorithm ${options.hash.algorithm}.` + ); + } + populatedOptions = { + hashAlgorithm: options.hash.algorithm, + cpuMemCost, + parallelization, + blockSize, + dkLen, + }; + break; + } + default: + throw new FirebaseAuthError( + AuthClientErrorCode.INVALID_HASH_ALGORITHM, + `Unsupported hash algorithm provider "${options.hash.algorithm}".` + ); + } + return populatedOptions; + } + + /** + * Validates and returns the users list of the uploadAccount request. + * Whenever a user with an error is detected, the error is cached and will later be + * merged into the user import result. This allows the processing of valid users without + * failing early on the first error detected. + * @param {UserImportRecord[]} users The UserImportRecords to convert to UnploadAccountUser + * objects. + * @param {ValidatorFunction=} userValidator The user validator function. + * @returns {UploadAccountUser[]} The populated uploadAccount users. + */ + private populateUsers( + users: UserImportRecord[], + userValidator?: ValidatorFunction + ): UploadAccountUser[] { + const populatedUsers: UploadAccountUser[] = []; + users.forEach((user, index) => { + try { + const result = populateUploadAccountUser(user, userValidator); + if (typeof result.passwordHash !== "undefined") { + this.requiresHashOptions = true; + } + // Only users that pass client screening will be passed to backend for processing. + populatedUsers.push(result); + // Map user's index (the one to be sent to backend) to original developer provided array. + this.indexMap[populatedUsers.length - 1] = index; + } catch (error) { + // Save the client side error with respect to the developer provided array. + this.userImportResultErrors.push({ + index, + error, + }); + } + }); + return populatedUsers; + } +} diff --git a/packages/dart_firebase_admin/lib/src/auth/user_info.dart b/packages/dart_firebase_admin/lib/src/auth/user_info.dart deleted file mode 100644 index 612f962..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/user_info.dart +++ /dev/null @@ -1,19 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class UserInfo { - UserInfo._(this._delegate); - - final firebase_auth_v1.GoogleCloudIdentitytoolkitV1ProviderUserInfo _delegate; - - String get displayName => _delegate.displayName!; - - String get email => _delegate.email!; - - String get phoneNumber => _delegate.phoneNumber!; - - String get photoURL => _delegate.photoUrl!; - - String get providerId => _delegate.providerId!; - - String get uid => _delegate.rawId!; -} diff --git a/packages/dart_firebase_admin/lib/src/auth/user_metadata.dart b/packages/dart_firebase_admin/lib/src/auth/user_metadata.dart deleted file mode 100644 index 4de3fda..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/user_metadata.dart +++ /dev/null @@ -1,21 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class UserMetadata { - UserMetadata._(this._delegate); - - final firebase_auth_v1.GoogleCloudIdentitytoolkitV1UserInfo _delegate; - - DateTime get creationTime => DateTime.fromMillisecondsSinceEpoch( - int.parse(_delegate.createdAt!), - ); - - DateTime get lastSignInTime => DateTime.fromMillisecondsSinceEpoch( - int.parse(_delegate.lastLoginAt!), - ); - - DateTime? get lastRefreshTime => _delegate.lastRefreshAt == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - int.parse(_delegate.lastRefreshAt!), - ); -} diff --git a/packages/dart_firebase_admin/lib/src/auth/user_record.dart b/packages/dart_firebase_admin/lib/src/auth/user_record.dart deleted file mode 100644 index 6dd7eb8..0000000 --- a/packages/dart_firebase_admin/lib/src/auth/user_record.dart +++ /dev/null @@ -1,42 +0,0 @@ -part of '../../dart_firebase_admin.dart'; - -class UserRecord { - UserRecord._(this._delegate); - - final firebase_auth_v1.GoogleCloudIdentitytoolkitV1UserInfo _delegate; - - Map? get customClaims => _delegate.customAttributes == null - ? null - : jsonDecode(_delegate.customAttributes!) as Map; - - bool get disabled => _delegate.disabled ?? false; - - String? get displayName => _delegate.displayName; - - String? get email => _delegate.email; - - UserMetadata get metadata => UserMetadata._(_delegate); - - Object get multiFactor => {}; - - String? get passwordHash => _delegate.passwordHash; - - String? get passwordSalt => _delegate.salt; - - String? get phoneNumber => _delegate.phoneNumber; - - String? get photoURL => _delegate.photoUrl; - - List get providerData => - _delegate.providerUserInfo?.map(UserInfo._).toList() ?? []; - - String? get tenantId => _delegate.tenantId; - - DateTime? get tokensValidAfterTime => _delegate.validSince == null - ? null - : DateTime.fromMillisecondsSinceEpoch( - int.parse(_delegate.validSince! * 1000), - ); - - String get uid => _delegate.localId!; -} diff --git a/packages/dart_firebase_admin/lib/src/credential.dart b/packages/dart_firebase_admin/lib/src/credential.dart index e85f3c1..da53d15 100644 --- a/packages/dart_firebase_admin/lib/src/credential.dart +++ b/packages/dart_firebase_admin/lib/src/credential.dart @@ -1,8 +1,10 @@ -part of '../dart_firebase_admin.dart'; +part of 'dart_firebase_admin.dart'; +/// Authentication informations for Firebase Admin SDK. class Credential { Credential._(this._serviceAccountCredentials); + /// Log in to firebase from a service account file. factory Credential.fromServiceAccount(File serviceAccountFile) { final content = serviceAccountFile.readAsStringSync(); @@ -17,11 +19,13 @@ class Credential { return Credential._(serviceAccountCredentials); } + /// Log in to firebase using the environment variable. Credential.fromApplicationDefaultCredentials() : this._(null); final auth.ServiceAccountCredentials? _serviceAccountCredentials; - Future _getAuthClient(List scopes) { + @internal + Future getAuthClient(List scopes) { final serviceAccountCredentials = _serviceAccountCredentials; if (serviceAccountCredentials == null) { return auth.clientViaApplicationDefaultCredentials(scopes: scopes); diff --git a/packages/dart_firebase_admin/lib/src/dart_firebase_admin.dart b/packages/dart_firebase_admin/lib/src/dart_firebase_admin.dart new file mode 100644 index 0000000..51043d8 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/dart_firebase_admin.dart @@ -0,0 +1,15 @@ +library dart_firebase_admin; + +import 'dart:convert'; +import 'dart:io'; + +import 'package:firebaseapis/identitytoolkit/v1.dart' as firebase_auth_v1; +import 'package:googleapis_auth/auth_io.dart' as auth; +import 'package:meta/meta.dart'; + +import 'app/core.dart'; + +part 'auth/auth_exception.dart'; +part 'credential.dart'; +part 'exception.dart'; +part 'firebase_admin.dart'; diff --git a/packages/dart_firebase_admin/lib/src/exception.dart b/packages/dart_firebase_admin/lib/src/exception.dart index d5d0d43..9f1a28f 100644 --- a/packages/dart_firebase_admin/lib/src/exception.dart +++ b/packages/dart_firebase_admin/lib/src/exception.dart @@ -1,4 +1,4 @@ -part of '../dart_firebase_admin.dart'; +part of 'dart_firebase_admin.dart'; /// A set of platform level error codes. /// @@ -43,52 +43,16 @@ String _platformErrorCodeMessage(String code) { } /// Base interface for all Firebase Admin related errors. -abstract class FirebaseAdminException implements Exception { +abstract class FirebaseAdminException extends FirebaseException { FirebaseAdminException(this.service, this._code, [this._message]); final String service; final String _code; final String? _message; + @override String get code => '$service/${_code.replaceAll('_', '-').toLowerCase()}'; - String get message => _message ?? _platformErrorCodeMessage(_code); -} - -/// Converts a Exception to a FirebaseAdminException. -Never _handleException(Object exception, StackTrace stackTrace) { - if (exception is firebase_auth_v1.DetailedApiRequestError) { - Error.throwWithStackTrace( - FirebaseAuthAdminException.fromServerError(exception), - stackTrace, - ); - } - - Error.throwWithStackTrace(exception, stackTrace); -} - -/// A generic guard wrapper for API calls to handle exceptions. -R guard(R Function() cb) { - try { - final value = cb(); - - if (value is Future) { - return value.catchError(_handleException) as R; - } - - return value; - } catch (error, stackTrace) { - _handleException(error, stackTrace); - } -} - -class FirebaseArrayIndexException implements Exception { - FirebaseArrayIndexException(this.index, this.message); - - final int index; - - final String message; - @override - String toString() => 'FirebaseArrayIndexException: $message'; + String get message => _message ?? _platformErrorCodeMessage(_code); } diff --git a/packages/dart_firebase_admin/lib/src/firebase_admin.dart b/packages/dart_firebase_admin/lib/src/firebase_admin.dart index 3383084..8dc969f 100644 --- a/packages/dart_firebase_admin/lib/src/firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/src/firebase_admin.dart @@ -1,24 +1,22 @@ -part of '../dart_firebase_admin.dart'; +part of 'dart_firebase_admin.dart'; class FirebaseAdminApp { - FirebaseAdminApp._(this._projectId, this._credential); + FirebaseAdminApp.initializeApp(this.projectId, this.credential); - factory FirebaseAdminApp.initializeApp( - String projectId, - Credential credential, - ) { - return FirebaseAdminApp._(projectId, credential); - } + final String projectId; + final Credential credential; - final String _projectId; - final Credential _credential; -} + bool get isUsingEmulator => _isUsingEmulator; + var _isUsingEmulator = false; -extension FirebaseAdminStringExtension on String { - bool get isUid => isNotEmpty && length <= 128; - // TODO check these are correct - // https://github.com/firebase/firebase-admin-node/blob/aea280d325c202fedc3890850d8c04f2f7e9cd54/src/utils/validator.ts#L160 - bool get isEmail => RegExp(r'/^[^@]+@[^@]+$/').hasMatch(this); - bool get isPhoneNumber => - startsWith('+') && RegExp(r'/[\da-zA-Z]+/').hasMatch(this); + @internal + Uri authApiHost = Uri.https('identitytoolkit.googleapis.com', '/'); + @internal + Uri firestoreApiHost = Uri.https('identitytoolkit.googleapis.com', '/'); + + void useEmulator() { + _isUsingEmulator = true; + authApiHost = Uri.http('127.0.0.1:9099', 'identitytoolkit.googleapis.com/'); + firestoreApiHost = Uri.http('127.0.0.1:8080', '/'); + } } diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/convert.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/convert.dart new file mode 100644 index 0000000..af4cd5d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/convert.dart @@ -0,0 +1,26 @@ +part of 'firestore.dart'; + +/// Verifies that a `Value` only has a single type set. +void _assertValidProtobufValue(firestore1.Value proto) { + final values = [ + proto.booleanValue, + proto.doubleValue, + proto.integerValue, + proto.stringValue, + proto.timestampValue, + proto.nullValue, + proto.mapValue, + proto.arrayValue, + proto.referenceValue, + proto.geoPointValue, + proto.bytesValue, + ]; + + if (values.whereNotNull().length != 1) { + throw ArgumentError.value( + proto, + 'proto', + 'Unable to infer type value', + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart new file mode 100644 index 0000000..0729275 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document.dart @@ -0,0 +1,499 @@ +part of 'firestore.dart'; + +class Optional { + const Optional(this.value); + final T? value; +} + +/// A DocumentSnapshot is an immutable representation for a document in a +/// Firestore database. The data can be extracted with [data]. +/// +/// For a DocumentSnapshot that points to a non-existing document, any data +/// access will return 'undefined'. You can use the +/// [exists] property to explicitly verify a document's existence. +@immutable +class DocumentSnapshot { + const DocumentSnapshot._({ + required this.ref, + required this.readTime, + required this.createTime, + required this.updateTime, + required firestore1.MapValue? fieldsProto, + }) : _fieldsProto = fieldsProto; + + factory DocumentSnapshot._fromObject( + DocumentReference ref, + DocumentData data, + ) { + final serializer = ref.firestore._serializer; + + return DocumentSnapshot._( + ref: ref, + fieldsProto: serializer.encodeFields(data), + readTime: null, + createTime: null, + updateTime: null, + ); + } + + factory DocumentSnapshot.fromUpdateMap( + DocumentReference ref, + UpdateMap data, + ) { + final serializer = ref.firestore._serializer; + + /// Merges 'value' at the field path specified by the path array into + /// 'target'. + ApiMapValue? merge({ + required ApiMapValue target, + required Object? value, + required List path, + required int pos, + }) { + final key = path[pos]; + final isLast = pos == path.length - 1; + + if (!target.containsKey(key)) { + if (isLast) { + if (value is _FieldTransform) { + // If there is already data at this path, we need to retain it. + // Otherwise, we don't include it in the DocumentSnapshot. + return target.isNotEmpty ? target : null; + } + // The merge is done + final leafNode = serializer.encodeValue(value); + if (leafNode != null) { + target[key] = leafNode; + } + return target; + } else { + // We need to expand the target object. + final childNode = {}; + + final nestedValue = merge( + target: childNode, + value: value, + path: path, + pos: pos + 1, + ); + + if (nestedValue != null) { + target[key] = firestore1.Value( + mapValue: firestore1.MapValue(fields: nestedValue), + ); + return target; + } else { + return target.isNotEmpty ? target : null; + } + } + } else { + assert(!isLast, "Can't merge current value into a nested object"); + target[key] = firestore1.Value( + mapValue: firestore1.MapValue( + fields: merge( + target: target[key]!.mapValue!.fields!, + value: value, + path: path, + pos: pos + 1, + ), + ), + ); + return target; + } + } + + final res = {}; + for (final entry in data.entries) { + final path = entry.key._toList(); + merge(target: res, value: entry.value, path: path, pos: 0); + } + + return DocumentSnapshot._( + ref: ref, + fieldsProto: firestore1.MapValue(fields: res), + readTime: null, + createTime: null, + updateTime: null, + ); + } + + static DocumentSnapshot _fromDocument( + firestore1.Document document, + String? readTime, + Firestore firestore, + ) { + final ref = DocumentReference._( + firestore: firestore, + path: _QualifiedResourcePath.fromSlashSeparatedString(document.name!), + converter: _jsonConverter, + ); + + final builder = _DocumentSnapshotBuilder(ref) + ..fieldsProto = firestore1.MapValue(fields: document.fields ?? {}) + ..createTime = document.createTime.let(Timestamp._fromString) + ..readTime = readTime.let(Timestamp._fromString) + ..updateTime = document.updateTime.let(Timestamp._fromString); + + return builder.build(); + } + + static DocumentSnapshot _missing( + String document, + String? readTime, + Firestore firestore, + ) { + final ref = DocumentReference._( + firestore: firestore, + path: _QualifiedResourcePath.fromSlashSeparatedString(document), + converter: _jsonConverter, + ); + + final builder = _DocumentSnapshotBuilder(ref) + ..readTime = readTime.let(Timestamp._fromString); + + return builder.build(); + } + + /// A [DocumentReference] for the document stored in this snapshot. + final DocumentReference ref; + final Timestamp? readTime; + final Timestamp? createTime; + final Timestamp? updateTime; + final firestore1.MapValue? _fieldsProto; + + /// The ID of the document for which this DocumentSnapshot contains data. + String get id => ref.id; + + /// True if the document exists. + /// + /// @type {boolean} + /// @name DocumentSnapshot#exists + /// @readonly + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.get().then((documentSnapshot) { + /// if (documentSnapshot.exists) { + /// print('Data: ${JSON.stringify(documentSnapshot.data())}'); + /// } + /// }); + /// ``` + bool get exists => this._fieldsProto != null; + + /// Retrieves all fields in the document as an object. Returns 'undefined' if + /// the document doesn't exist. + /// + /// Returns an object containing all fields in the document or + /// 'null' if the document doesn't exist. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.get().then((documentSnapshot) { + /// final data = documentSnapshot.data(); + /// print('Retrieved data: ${JSON.stringify(data)}'); + /// }); + /// ``` + T? data() { + final fieldsProto = this._fieldsProto; + final fields = fieldsProto?.fields; + if (fields == null || fieldsProto == null) return null; + + final converter = ref._converter; + // We only want to use the converter and create a new QueryDocumentSnapshot + // if a converter has been provided. + if (!identical(converter, _jsonConverter)) { + final untypedReference = DocumentReference._( + firestore: ref.firestore, + path: ref._path, + converter: _jsonConverter, + ); + + return converter.fromFirestore( + QueryDocumentSnapshot._( + ref: untypedReference, + fieldsProto: fieldsProto, + readTime: readTime, + createTime: createTime, + updateTime: updateTime, + ), + ); + } else { + final object = { + for (final prop in fields.entries) + prop.key: ref.firestore._serializer.decodeValue(prop.value), + }; + + return object as T; + } + } + + /// Retrieves the field specified by [field]. + /// + /// Will return `null` if the field does not exists. + /// Will return `Optional(null)` if the field exists but is `null`. + Optional? get(Object field) { + final fieldPath = FieldPath.from(field); + final protoField = _protoField(fieldPath); + + if (protoField == null) return null; + + return Optional( + ref.firestore._serializer.decodeValue(protoField), + ); + } + + firestore1.Value? _protoField(FieldPath field) { + final fieldsProto = this._fieldsProto?.fields; + if (fieldsProto == null) return null; + var fields = fieldsProto; + + final components = field._toList(); + for (var i = 0; i < components.length - 1; i++) { + final component = components[i]; + + final newFields = fields[component]?.mapValue; + // The field component is not present. + if (newFields == null) return null; + + fields = newFields.fields!; + } + + return fields[components.last]; + } + + firestore1.Write _toWriteProto() { + return firestore1.Write( + update: firestore1.Document( + name: ref._formattedName, + fields: _fieldsProto?.fields, + ), + ); + } + + @override + bool operator ==(Object other) { + return other is DocumentSnapshot && + runtimeType == other.runtimeType && + ref == other.ref && + const DeepCollectionEquality().equals(_fieldsProto, other._fieldsProto); + } + + @override + int get hashCode => Object.hash( + runtimeType, + ref, + const DeepCollectionEquality().hash(_fieldsProto), + ); +} + +class _DocumentSnapshotBuilder { + _DocumentSnapshotBuilder(this.ref); + + final DocumentReference ref; + + Timestamp? readTime; + Timestamp? createTime; + Timestamp? updateTime; + firestore1.MapValue? fieldsProto; + + DocumentSnapshot build() { + assert( + (this.fieldsProto != null) == (this.createTime != null), + 'Create time should be set iff document exists.', + ); + assert( + (this.fieldsProto != null) == (this.updateTime != null), + 'Update time should be set iff document exists.', + ); + + final fieldsProto = this.fieldsProto; + if (fieldsProto != null) { + return QueryDocumentSnapshot._( + ref: ref, + fieldsProto: fieldsProto, + readTime: readTime, + createTime: createTime, + updateTime: updateTime, + ); + } + + return DocumentSnapshot._( + ref: ref, + readTime: readTime, + createTime: createTime, + updateTime: updateTime, + fieldsProto: null, + ); + } +} + +class QueryDocumentSnapshot extends DocumentSnapshot { + const QueryDocumentSnapshot._({ + required super.ref, + required super.readTime, + required super.createTime, + required super.updateTime, + required super.fieldsProto, + }) : super._(); + + @override + Timestamp get createTime => super.createTime!; + + @override + Timestamp get updateTime => super.updateTime!; + + @override + T data() { + final data = super.data(); + if (data == null) { + throw StateError( + 'The data in a QueryDocumentSnapshot should always exist.', + ); + } + return data; + } +} + +/// A Firestore Document Transform. +/// +/// A DocumentTransform contains pending server-side transforms and their +/// corresponding field paths. +class _DocumentTransform { + _DocumentTransform({required this.ref, required this.transforms}); + + factory _DocumentTransform.fromObject( + DocumentReference ref, + DocumentData data, + ) { + final updateMap = { + for (final entry in data.entries) FieldPath([entry.key]): entry.value, + }; + + return _DocumentTransform.fromUpdateMap(ref, updateMap); + } + + factory _DocumentTransform.fromUpdateMap( + DocumentReference ref, + UpdateMap data, + ) { + final transforms = {}; + + void encode( + Object? val, + FieldPath path, { + required bool allowTransforms, + }) { + if (val is _FieldTransform && val.includeInDocumentTransform) { + if (allowTransforms) { + transforms[path] = val; + } else { + throw ArgumentError( + '${val.methodName}() is not supported inside of array values.', + ); + } + } else if (val is List) { + val.forEachIndexed((i, value) { + // We need to verify that no array value contains a document transform + encode( + value, + path._append('$i'), + allowTransforms: false, + ); + }); + } else if (val is Map) { + for (final entry in val.entries) { + encode( + entry.value, + path._append(entry.key.toString()), + allowTransforms: allowTransforms, + ); + } + } + } + + for (final entry in data.entries) { + encode(entry.value, entry.key, allowTransforms: true); + } + + return _DocumentTransform( + ref: ref, + transforms: transforms, + ); + } + + final DocumentReference ref; + final Map transforms; + + void validate() { + for (final transform in transforms.values) { + transform.validate(); + } + } + + /// Converts a document transform to the Firestore 'FieldTransform' Proto. + List toProto(_Serializer serializer) { + return [ + for (final entry in transforms.entries) + entry.value._toProto(serializer, entry.key), + ]; + } +} + +/// A condition to check before performing an operation. +class Precondition { + /// Checks that the document exists or not. + // ignore: avoid_positional_boolean_parameters, cf https://github.com/dart-lang/linter/issues/1638 + Precondition.exists(bool this._exists) : _lastUpdateTime = null; + + /// Checks that the document has last been updated at the specified time. + Precondition.timestamp(Timestamp this._lastUpdateTime) : _exists = null; + + final bool? _exists; + final Timestamp? _lastUpdateTime; + + /// Whether this DocumentTransform contains any enforcement. + bool get _isEmpty => _exists == null && _lastUpdateTime == null; + + firestore1.Precondition? _toProto() { + if (_isEmpty) return null; + + final lastUpdateTime = _lastUpdateTime; + if (lastUpdateTime != null) { + return firestore1.Precondition( + updateTime: lastUpdateTime._toProto().timestampValue, + ); + } + + return firestore1.Precondition(exists: _exists); + } +} + +class _DocumentMask { + _DocumentMask(List fieldPaths) + : _sortedPaths = fieldPaths.sorted((a, b) => a.compareTo(b)); + + factory _DocumentMask.fromUpdateMap(Map data) { + final fieldPaths = []; + + for (final entry in data.entries) { + final value = entry.value; + if (value is! _FieldTransform || value.includeInDocumentMask) { + fieldPaths.add(entry.key); + } + } + + return _DocumentMask(fieldPaths); + } + + final List _sortedPaths; + + firestore1.DocumentMask toProto() { + if (_sortedPaths.isEmpty) return firestore1.DocumentMask(); + + return firestore1.DocumentMask( + fieldPaths: _sortedPaths.map((e) => e._formattedName).toList(), + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_change.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_change.dart new file mode 100644 index 0000000..05dbf2a --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_change.dart @@ -0,0 +1,50 @@ +part of 'firestore.dart'; + +enum DocumentChangeType { + added, + removed, + modified, +} + +/// A DocumentChange represents a change to the documents matching a query. +/// It contains the document affected and the type of change that occurred. +@immutable +class DocumentChange { + const DocumentChange._({ + required this.oldIndex, + required this.newIndex, + required this.doc, + required this.type, + }); + + /// The index of the changed document in the result set immediately prior to + /// this DocumentChange (i.e. supposing that all prior DocumentChange objects + /// have been applied). Is -1 for 'added' events. + final int oldIndex; + + /// The index of the changed document in the result set immediately after + /// this DocumentChange (i.e. supposing that all prior DocumentChange + /// objects and the current DocumentChange object have been applied). + /// Is -1 for 'removed' events. + final int newIndex; + + /// The document affected by this change. + final QueryDocumentSnapshot doc; + + /// The type of change ('added', 'modified', or 'removed'). + final DocumentChangeType type; + + @override + bool operator ==(Object? other) { + return identical(this, other) || + other is DocumentChange && + runtimeType == other.runtimeType && + oldIndex == other.oldIndex && + newIndex == other.newIndex && + doc == other.doc && + type == other.type; + } + + @override + int get hashCode => Object.hash(runtimeType, oldIndex, newIndex, doc, type); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart new file mode 100644 index 0000000..83310d6 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/document_reader.dart @@ -0,0 +1,90 @@ +part of 'firestore.dart'; + +@internal +class DocumentReader { + DocumentReader({ + required this.firestore, + required this.documents, + required this.fieldMask, + required this.transactionId, + }) : _outstandingDocuments = documents.map((e) => e._formattedName).toSet(); + + final Firestore firestore; + final List> documents; + final List? fieldMask; + final String? transactionId; + final Set _outstandingDocuments; + final _retreivedDocuments = >{}; + + /// Invokes the BatchGetDocuments RPC and returns the results. + Future>> get(String requestTag) async { + await _fetchDocuments(requestTag); + + // BatchGetDocuments doesn't preserve document order. We use the request + // order to sort the resulting documents. + final orderedDocuments = >[]; + + for (final docRef in documents) { + final document = _retreivedDocuments[docRef._formattedName]; + if (document != null) { + // Recreate the DocumentSnapshot with the DocumentReference + // containing the original converter. + final finalDoc = _DocumentSnapshotBuilder(docRef) + ..fieldsProto = document._fieldsProto + ..createTime = document.createTime + ..readTime = document.readTime + ..updateTime = document.updateTime; + + orderedDocuments.add(finalDoc.build()); + } else { + throw StateError('Did not receive document for "${docRef.path}".'); + } + } + + return orderedDocuments; + } + + Future _fetchDocuments(String requestTag) async { + if (_outstandingDocuments.isEmpty) return; + + final documents = await firestore._client.v1((client) async { + return client.projects.databases.documents.batchGet( + firestore1.BatchGetDocumentsRequest( + documents: _outstandingDocuments.toList(), + mask: fieldMask.let((fieldMask) { + return firestore1.DocumentMask( + fieldPaths: fieldMask.map((e) => e._formattedName).toList(), + ); + }), + transaction: transactionId, + ), + firestore._formattedDatabaseName, + ); + }); + + for (final response in documents) { + DocumentSnapshot documentSnapshot; + + final found = response.found; + if (found != null) { + documentSnapshot = DocumentSnapshot._fromDocument( + found, + response.readTime, + firestore, + ); + } else { + final missing = response.missing!; + documentSnapshot = DocumentSnapshot._missing( + missing, + response.readTime, + firestore, + ); + } + + final path = documentSnapshot.ref._formattedName; + _outstandingDocuments.remove(path); + _retreivedDocuments[path] = documentSnapshot; + } + // TODO handle retry + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/field_value.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/field_value.dart new file mode 100644 index 0000000..9c911bd --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/field_value.dart @@ -0,0 +1,488 @@ +part of 'firestore.dart'; + +abstract class FieldValue { + /// Returns a special value that can be used with set(), create() or update() + /// that tells the server to increment the the field's current value by the + /// given value. + /// + /// If either current field value or the operand uses floating point + /// precision, both values will be interpreted as floating point numbers and + /// all arithmetic will follow IEEE 754 semantics. Otherwise, integer + /// precision is kept and the result is capped between -2^63 and 2^63-1. + /// + /// If the current field value is not of type 'number', or if the field does + /// not yet exist, the transformation will set the field to the given value. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.update({ + /// 'counter', Firestore.FieldValue.increment(1), + /// }).then(() { + /// return documentRef.get(); + /// }).then((doc) { + /// // doc.get('counter') was incremented + /// }); + /// ``` + const factory FieldValue.increment(num n) = _NumericIncrementTransform; + + /// Returns a special value that can be used with set(), create() or update() + /// that tells the server to union the given elements with any array value that + /// already exists on the server. Each specified element that doesn't already + /// exist in the array will be added to the end. If the field being modified is + /// not already an array it will be overwritten with an array containing + /// exactly the specified elements. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.update({ + /// 'array': Firestore.FieldValue.arrayUnion('foo'), + /// }).then(() { + /// return documentRef.get(); + /// }).then((doc) { + /// // doc.get('array') contains field 'foo' + /// }); + /// ``` + const factory FieldValue.arrayUnion(List elements) = + _ArrayUnionTransform; + + /// Returns a special value that can be used with set(), create() or update() + /// that tells the server to remove the given elements from any array value + /// that already exists on the server. All instances of each element specified + /// will be removed from the array. If the field being modified is not already + /// an array it will be overwritten with an empty array. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.update({ + /// 'array': Firestore.FieldValue.arrayRemove('foo'), + /// }).then(() { + /// return documentRef.get(); + /// }).then((doc) { + /// // doc.get('array') no longer contains field 'foo' + /// }); + /// ``` + const factory FieldValue.arrayRemove(List elements) = + _ArrayRemoveTransform; + + /// Returns a sentinel for use with update() to mark a field for deletion. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// final data = { a: 'b', c: 'd' }; + /// + /// documentRef.set(data).then(() { + /// return documentRef.update({a: Firestore.FieldValue.delete()}); + /// }).then(() { + /// // Document now only contains { c: 'd' } + /// }); + /// ``` + static const FieldValue delete = _DeleteTransform.deleteSentinel; + + /// Returns a sentinel used with set(), create() or update() to include a + /// server-generated timestamp in the written data. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.set({ + /// 'time': Firestore.FieldValue.serverTimestamp() + /// }).then(() { + /// return documentRef.get(); + /// }).then((doc) { + /// print('Server time set to ${doc.get('time')}'); + /// }); + /// ``` + static const FieldValue serverTimestamp = + _ServerTimestampTransform.serverTimestampSentinel; +} + +/// An internal interface shared by all field transforms. +// +/// A [_FieldTransform] subclass should implement [includeInDocumentMask], +/// [includeInDocumentTransform] and 'toProto' (if [includeInDocumentTransform] +/// is `true`). +abstract class _FieldTransform implements FieldValue { + /// Whether this field transform should be included in the document mask. + bool get includeInDocumentMask; + + /// Whether this field transform should be included in the document transform. + bool get includeInDocumentTransform; + + /// The method name used to obtain the field transform. + String get methodName; + + /// Performs input validation on the values of this field transform. + /// + /// - [allowUndefined]: Whether to allow nested properties that are undefined + void validate(); + + /// The proto representation for this field transform. + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ); +} + +/// A transform that deletes a field from a Firestore document. +class _DeleteTransform implements _FieldTransform { + const _DeleteTransform._(); + + /// A sentinel value for a field delete. + static const deleteSentinel = _DeleteTransform._(); + + /// Deletes are included in document masks + @override + bool get includeInDocumentMask => true; + + /// Deletes are are omitted from document transforms. + @override + bool get includeInDocumentTransform => false; + + @override + String get methodName => 'FieldValue.delete'; + + @override + void validate() {} + + @override + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ) { + throw UnsupportedError( + 'FieldValue.delete() should not be included in a FieldTransform', + ); + } +} + +/// Increments a field value on the backend. +@immutable +class _NumericIncrementTransform implements _FieldTransform { + const _NumericIncrementTransform(this.value); + + /// The value to increment by. + final num value; + + @override + bool get includeInDocumentMask => false; + + @override + bool get includeInDocumentTransform => true; + + @override + String get methodName => 'FieldValue.increment'; + + @override + void validate() { + if (value.isNaN) { + throw ArgumentError.value( + value, + 'value', + 'Increment transforms require a valid numeric value, but got $value', + ); + } + } + + @override + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ) { + return firestore1.FieldTransform( + fieldPath: fieldPath._formattedName, + increment: serializer.encodeValue(value), + ); + } + + @override + bool operator ==(Object? other) { + return other is _NumericIncrementTransform && value == other.value; + } + + @override + int get hashCode => value.hashCode; +} + +/// Transforms an array value via a union operation. +@immutable +class _ArrayUnionTransform implements _FieldTransform { + const _ArrayUnionTransform(this.elements); + + final List elements; + + @override + bool get includeInDocumentMask => false; + + @override + bool get includeInDocumentTransform => true; + + @override + String get methodName => 'FieldValue.arrayUnion'; + + @override + void validate() { + elements.forEachIndexed(_validateArrayElement); + } + + @override + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ) { + return firestore1.FieldTransform( + fieldPath: fieldPath._formattedName, + appendMissingElements: serializer.encodeValue(elements)!.arrayValue, + ); + } + + @override + bool operator ==(Object? other) { + return other is _ArrayUnionTransform && + const DeepCollectionEquality().equals(elements, other.elements); + } + + @override + int get hashCode => const DeepCollectionEquality().hash(elements); +} + +/// Transforms an array value via a remove operation. +@immutable +class _ArrayRemoveTransform implements _FieldTransform { + const _ArrayRemoveTransform(this.elements); + + final List elements; + + @override + bool get includeInDocumentMask => false; + + @override + bool get includeInDocumentTransform => true; + + @override + String get methodName => 'FieldValue.arrayRemove'; + + @override + void validate() { + elements.forEachIndexed(_validateArrayElement); + } + + @override + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ) { + return firestore1.FieldTransform( + fieldPath: fieldPath._formattedName, + removeAllFromArray: serializer.encodeValue(elements)!.arrayValue, + ); + } + + @override + bool operator ==(Object? other) { + return other is _ArrayRemoveTransform && + const DeepCollectionEquality().equals(elements, other.elements); + } + + @override + int get hashCode => const DeepCollectionEquality().hash(elements); +} + +/// A transform that sets a field to the Firestore server time. +class _ServerTimestampTransform implements _FieldTransform { + const _ServerTimestampTransform(); + + static const serverTimestampSentinel = _ServerTimestampTransform(); + + @override + bool get includeInDocumentMask => false; + + @override + bool get includeInDocumentTransform => true; + + @override + String get methodName => 'FieldValue.serverTimestamp'; + + @override + firestore1.FieldTransform _toProto( + _Serializer serializer, + FieldPath fieldPath, + ) { + return firestore1.FieldTransform( + fieldPath: fieldPath._formattedName, + setToServerValue: 'REQUEST_TIME', + ); + } + + @override + void validate() {} +} + +enum _AllowDeletes { + none, + root, + all; +} + +/// The maximum depth of a Firestore object. +const _maxDepth = 20; + +class _ValidateUserInputOptions { + const _ValidateUserInputOptions({ + required this.allowDeletes, + required this.allowTransform, + }); + + /// At what level field deletes are supported. + final _AllowDeletes allowDeletes; + + /// Whether server transforms are supported. + final bool allowTransform; +} + +/// Validates a Dart value for usage as a Firestore value. +void _validateUserInput( + Object arg, + Object? value, { + required String description, + required _ValidateUserInputOptions options, + int level = 0, + bool inArray = false, + FieldPath? path, +}) { + if (path != null && path._length > _maxDepth) { + throw ArgumentError.value( + value, + description, + 'Firestore objects may not contain more than $_maxDepth levels of nesting or contain a cycle', + ); + } + + final fieldPathMessage = + path == null ? '' : ' (found in field ${path._formattedName})'; + + switch (value) { + case List(): + value.forEachIndexed((index, element) { + _validateUserInput( + arg, + element, + description: description, + options: options, + path: path == null + ? FieldPath([arg.toString()]) + : path._append(arg.toString()), + level: level + 1, + inArray: true, + ); + }); + + case Map(): + for (final entry in value.entries) { + _validateUserInput( + arg, + entry.value, + description: description, + options: options, + path: path == null + ? FieldPath.from(entry.key) + : path._appendPath(FieldPath.from(entry.key)), + level: level + 1, + inArray: inArray, + ); + } + + case _DeleteTransform(): + if (inArray) { + throw ArgumentError.value( + value, + value.methodName, + 'cannot be used inside an array$fieldPathMessage', + ); + } else if (options.allowDeletes == _AllowDeletes.none) { + throw ArgumentError.value( + value, + value.methodName, + 'must appear at the top-level and can only be used in update()$fieldPathMessage.', + ); + } else if (options.allowDeletes == _AllowDeletes.root) { + switch (level) { + case 1: + // Ok, at the root of update({}) + break; + default: + throw ArgumentError.value( + value, + value.methodName, + 'must appear at the top-level and can only be used in update()$fieldPathMessage.', + ); + } + } + + case _FieldTransform(): + if (inArray) { + throw ArgumentError.value( + value, + value.methodName, + 'cannot be used inside an array$fieldPathMessage', + ); + } else if (!options.allowTransform) { + throw ArgumentError.value( + value, + value.methodName, + 'can only be used with set(), create() or update()$fieldPathMessage.', + ); + } + + case FieldPath(): + throw ArgumentError.value( + value, + description, + 'Cannot use object of type "FieldPath" as a Firestore value$fieldPathMessage.', + ); + + case DocumentReference(): + case GeoPoint(): + case Timestamp() || DateTime(): + case null: + case num(): + case BigInt(): + case String(): + case bool(): + // Ok. + break; + + default: + throw ArgumentError.value( + value, + description, + 'Unsupported value type: ${value.runtimeType}$fieldPathMessage.', + ); + } +} + +// Validates that `value` can be used as an element inside of an array. Certain +// field values (such as ServerTimestamps) are rejected. Nested arrays are also +// rejected. +void _validateArrayElement(int index, Object? item) { + if (item is List) { + throw ArgumentError.value( + item, + 'elements[$index]', + 'Nested arrays are not supported', + ); + } + + _validateUserInput( + index, + item, + description: 'array element', + options: const _ValidateUserInputOptions( + allowDeletes: _AllowDeletes.none, + allowTransform: false, + ), + inArray: true, + ); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/filter.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/filter.dart new file mode 100644 index 0000000..bb73cf4 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/filter.dart @@ -0,0 +1,182 @@ +part of 'firestore.dart'; + +enum WhereFilter { + lessThan('LESS_THAN'), + lessThanOrEqual('LESS_THAN_OR_EQUAL'), + equal('EQUAL'), + notEqual('NOT_EQUAL'), + greaterThanOrEqual('GREATER_THAN_OR_EQUAL'), + greaterThan('GREATER_THAN'), + isIn('IN'), + notIn('NOT_IN'), + arrayContains('ARRAY_CONTAINS'), + arrayContainsAny('ARRAY_CONTAINS_ANY'); + + const WhereFilter(this.proto); + + final String proto; +} + +/// A `Filter` represents a restriction on one or more field values and can +/// be used to refine the results of a [Query]. +/// `Filters`s are created by invoking [Filter.where], [Filter.or], +/// or [Filter.and] and can then be passed to {@link Query#where} +/// to create a new [Query] instance that also contains this `Filter`. +@immutable +sealed class Filter { + /// Creates and returns a new [Filter], which can be applied to [Query.where], + /// [Filter.or] or [Filter.and]. When applied to a [Query] it requires that + /// documents must contain the specified field and that its value should + /// satisfy the relation constraint provided. + /// + /// - [fieldPath]: The name of a property value to compare. + /// - [op] A comparison operation in the form of a string. + /// Acceptable operator strings are "<", "<=", "==", "!=", ">=", ">", "array-contains", + /// "in", "not-in", and "array-contains-any". + /// - [value] The value to which to compare the field for inclusion in + /// a query. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// collectionRef.where(Filter.where('foo', '==', 'bar')).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + factory Filter.where( + Object fieldPath, + WhereFilter op, + Object? value, + ) = _UnaryFilter.fromString; + + /// Creates and returns a new [Filter], which can be applied to [Query.where], + /// [Filter.or] or [Filter.and]. When applied to a [Query] it requires that + /// documents must contain the specified field and that its value should + /// satisfy the relation constraint provided. + /// + /// - [fieldPath]: The name of a property value to compare. + /// - [op] A comparison operation in the form of a string. + /// Acceptable operator strings are "<", "<=", "==", "!=", ">=", ">", "array-contains", + /// "in", "not-in", and "array-contains-any". + /// - [value] The value to which to compare the field for inclusion in + /// a query. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// collectionRef.where(Filter.where('foo', '==', 'bar')).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + factory Filter.whereFieldPath( + FieldPath fieldPath, + WhereFilter op, + Object? value, + ) = _UnaryFilter; + + /// Creates and returns a new [Filter] that is a disjunction of the given + /// [Filter]s. A disjunction filter includes a document if it satisfies any + /// of the given [Filter]s. + /// + /// The returned Filter can be applied to [Query.where] [Filter.or], or + /// [Filter.and]. When applied to a [Query] it requires that documents must + /// satisfy one of the provided [Filter]s. + /// + /// - [filters] The [Filter]s + /// for OR operation. These must be created with calls to [Filter], + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// // doc.foo == 'bar' || doc.baz > 0 + /// final orFilter = Filter.or(Filter.where('foo', WhereFilter.equal, 'bar'), Filter.where('baz', WhereFilter.greaterThan, 0)); + /// + /// collectionRef.where(orFilter).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + factory Filter.or(List filters) = _CompositeFilter.or; + + /// Creates and returns a new [Filter]{@link Filter} that is a + /// conjunction of the given {@link Filter}s. A conjunction filter includes + /// a document if it satisfies all of the given {@link Filter}s. + /// + /// The returned Filter can be applied to [Query.where()], [Filter.or], or + /// [Filter.and]. When applied to a [Query] it requires that documents must satisfy + /// one of the provided [Filter]s. + /// + /// - [filter]: The [Filter]s + /// for AND operation. These must be created with calls to [Filter.where], + /// [Filter.or], or [Filter.and]. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// // doc.foo == 'bar' && doc.baz > 0 + /// final andFilter = Filter.and(Filter.where('foo', WhereFilter.equal, 'bar'), Filter.where('baz', WhereFilter.greaterThan, 0)); + /// + /// collectionRef.where(andFilter).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + factory Filter.and(List filters) = _CompositeFilter.and; +} + +class _UnaryFilter implements Filter { + _UnaryFilter( + this.fieldPath, + this.op, + this.value, + ) { + if (value == null || identical(value, double.nan)) { + if (op != WhereFilter.equal && op != WhereFilter.notEqual) { + throw ArgumentError( + 'Invalid query for value $value. Only == and != are supported.', + ); + } + } + } + + _UnaryFilter.fromString( + Object field, + WhereFilter op, + Object? value, + ) : this(FieldPath.from(field), op, value); + + final FieldPath fieldPath; + final WhereFilter op; + final Object? value; +} + +class _CompositeFilter implements Filter { + _CompositeFilter({required this.filters, required this.operator}); + + _CompositeFilter.or(List filters) + : this(filters: filters, operator: _CompositeOperator.or); + + _CompositeFilter.and(List filters) + : this(filters: filters, operator: _CompositeOperator.and); + + final List filters; + final _CompositeOperator operator; +} + +enum _CompositeOperator { + and, + or; + + String get proto { + return switch (this) { + _CompositeOperator.and => 'AND', + _CompositeOperator.or => 'OR', + }; + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart new file mode 100644 index 0000000..e089bce --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart @@ -0,0 +1,220 @@ +import 'dart:math' as math; + +import 'package:collection/collection.dart'; +import 'package:firebaseapis/firestore/v1.dart' as firestore1; +import 'package:firebaseapis/firestore/v1beta1.dart' as firestore1beta1; +import 'package:firebaseapis/firestore/v1beta2.dart' as firestore1beta2; +import 'package:firebaseapis/identitytoolkit/v3.dart' as auth3; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:googleapis_auth/googleapis_auth.dart' as auth; +import 'package:intl/intl.dart'; + +import '../dart_firebase_admin.dart'; +import '../object_utils.dart'; +import 'util.dart'; + +part 'convert.dart'; +part 'document.dart'; +part 'document_reader.dart'; +part 'field_value.dart'; +part 'firestore.freezed.dart'; +part 'geo_point.dart'; +part 'path.dart'; +part 'reference.dart'; +part 'serializer.dart'; +part 'timestamp.dart'; +part 'transaction.dart'; +part 'types.dart'; +part 'write_batch.dart'; +part 'document_change.dart'; +part 'filter.dart'; +part 'firestore_exception.dart'; + +class Firestore { + Firestore(this.app, {Settings? settings}) + : _settings = settings ?? Settings(); + + /// Returns the Database ID for this Firestore instance. + String get _databaseId => _settings.databaseId ?? '(default)'; + + /// The Database ID, using the format 'projects/${app.projectId}/databases/$_databaseId' + String get _formattedDatabaseName { + return 'projects/${app.projectId}/databases/$_databaseId'; + } + + final FirebaseAdminApp app; + final Settings _settings; + + late final _client = _FirestoreHttpClient(app); + late final _serializer = _Serializer(this); + + /// Gets a [DocumentReference]{@link DocumentReference} instance that + /// refers to the document at the specified path. + /// + /// - [documentPath]: A slash-separated path to a document. + /// + /// Returns The [DocumentReference] instance. + /// + /// ```dart + /// final documentRef = firestore.doc('collection/document'); + /// print('Path of document is ${documentRef.path}'); + /// ``` + DocumentReference doc(String documentPath) { + _validateResourcePath('documentPath', documentPath); + + final path = _ResourcePath.empty._append(documentPath); + if (!path.isDocument) { + throw ArgumentError.value( + documentPath, + 'documentPath', + 'Value for argument "documentPath" must point to a document, but was "$documentPath". ' + 'Your path does not contain an even number of components.', + ); + } + + return DocumentReference._( + firestore: this, + path: path._toQualifiedResourcePath(app.projectId, _databaseId), + converter: _jsonConverter, + ); + } + + /// Gets a [CollectionReference] instance + /// that refers to the collection at the specified path. + /// + /// - [collectionPath]: A slash-separated path to a collection. + /// + /// Returns [CollectionReference] A reference to the new + /// subcollection. + /// + /// @example + /// ``` + /// let documentRef = firestore.doc('col/doc'); + /// let subcollection = documentRef.collection('subcollection'); + /// console.log(`Path to subcollection: ${subcollection.path}`); + /// ``` + CollectionReference collection(String collectionPath) { + _validateResourcePath('collectionPath', collectionPath); + + final path = _ResourcePath.empty._append(collectionPath); + if (!path.isCollection) { + throw ArgumentError.value( + collectionPath, + 'collectionPath', + 'Value for argument "collectionPath" must point to a collection, but was ' + '"$collectionPath". Your path does not contain an odd number of components.', + ); + } + + return CollectionReference._( + firestore: this, + path: path._toQualifiedResourcePath(app.projectId, _databaseId), + converter: _jsonConverter, + ); + } + + // Retrieves multiple documents from Firestore. + Future>> getAll( + List> documents, [ + ReadOptions? readOptions, + ]) async { + if (documents.isEmpty) { + throw ArgumentError.value( + documents, + 'documents', + 'must not be an empty array.', + ); + } + + final fieldMask = _parseFieldMask(readOptions); + final tag = requestTag(); + + final reader = DocumentReader( + firestore: this, + documents: documents, + transactionId: null, + fieldMask: fieldMask, + ); + + return reader.get(tag); + } +} + +class SettingsCredentials { + SettingsCredentials({this.clientEmail, this.privateKey}); + + final String? clientEmail; + final String? privateKey; +} + +/// Settings used to directly configure a `Firestore` instance. +@freezed +class Settings with _$Settings { + /// Settings used to directly configure a `Firestore` instance. + factory Settings({ + /// The database name. If omitted, the default database will be used. + String? databaseId, + + /// Whether to use `BigInt` for integer types when deserializing Firestore + /// Documents. Regardless of magnitude, all integer values are returned as + /// `BigInt` to match the precision of the Firestore backend. Floating point + /// numbers continue to use JavaScript's `number` type. + bool? useBigInt, + }) = _Settings; +} + +class _FirestoreHttpClient { + _FirestoreHttpClient(this.app); + + // TODO needs to send "owner" as bearer token when using the emulator + final FirebaseAdminApp app; + + auth.AuthClient? _client; + // TODO refactor with auth + // TODO is it fine to use AuthClient? + Future _getClient() async { + return _client ??= await app.credential.getAuthClient([ + auth3.IdentityToolkitApi.cloudPlatformScope, + auth3.IdentityToolkitApi.firebaseScope, + ]); + } + + Future v1( + Future Function(firestore1.FirestoreApi client) fn, + ) { + return _firestoreGuard( + () async => fn( + firestore1.FirestoreApi( + await _getClient(), + rootUrl: app.firestoreApiHost.toString(), + ), + ), + ); + } + + Future v1Beta1( + Future Function(firestore1beta1.FirestoreApi client) fn, + ) async { + return _firestoreGuard( + () async => fn( + firestore1beta1.FirestoreApi( + await _getClient(), + rootUrl: app.firestoreApiHost.toString(), + ), + ), + ); + } + + Future v1Beta2( + Future Function(firestore1beta2.FirestoreApi client) fn, + ) async { + return _firestoreGuard( + () async => fn( + firestore1beta2.FirestoreApi( + await _getClient(), + rootUrl: app.firestoreApiHost.toString(), + ), + ), + ); + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.freezed.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.freezed.dart new file mode 100644 index 0000000..6c4c3e3 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.freezed.dart @@ -0,0 +1,630 @@ +// coverage:ignore-file +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'firestore.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +T _$identity(T value) => value; + +final _privateConstructorUsedError = UnsupportedError( + 'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#custom-getters-and-methods'); + +/// @nodoc +mixin _$Settings { + /// The database name. If omitted, the default database will be used. + String? get databaseId => throw _privateConstructorUsedError; + + /// Whether to use `BigInt` for integer types when deserializing Firestore + /// Documents. Regardless of magnitude, all integer values are returned as + /// `BigInt` to match the precision of the Firestore backend. Floating point + /// numbers continue to use JavaScript's `number` type. + bool? get useBigInt => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + $SettingsCopyWith get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class $SettingsCopyWith<$Res> { + factory $SettingsCopyWith(Settings value, $Res Function(Settings) then) = + _$SettingsCopyWithImpl<$Res, Settings>; + @useResult + $Res call({String? databaseId, bool? useBigInt}); +} + +/// @nodoc +class _$SettingsCopyWithImpl<$Res, $Val extends Settings> + implements $SettingsCopyWith<$Res> { + _$SettingsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? databaseId = freezed, + Object? useBigInt = freezed, + }) { + return _then(_value.copyWith( + databaseId: freezed == databaseId + ? _value.databaseId + : databaseId // ignore: cast_nullable_to_non_nullable + as String?, + useBigInt: freezed == useBigInt + ? _value.useBigInt + : useBigInt // ignore: cast_nullable_to_non_nullable + as bool?, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$_SettingsCopyWith<$Res> implements $SettingsCopyWith<$Res> { + factory _$$_SettingsCopyWith( + _$_Settings value, $Res Function(_$_Settings) then) = + __$$_SettingsCopyWithImpl<$Res>; + @override + @useResult + $Res call({String? databaseId, bool? useBigInt}); +} + +/// @nodoc +class __$$_SettingsCopyWithImpl<$Res> + extends _$SettingsCopyWithImpl<$Res, _$_Settings> + implements _$$_SettingsCopyWith<$Res> { + __$$_SettingsCopyWithImpl( + _$_Settings _value, $Res Function(_$_Settings) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? databaseId = freezed, + Object? useBigInt = freezed, + }) { + return _then(_$_Settings( + databaseId: freezed == databaseId + ? _value.databaseId + : databaseId // ignore: cast_nullable_to_non_nullable + as String?, + useBigInt: freezed == useBigInt + ? _value.useBigInt + : useBigInt // ignore: cast_nullable_to_non_nullable + as bool?, + )); + } +} + +/// @nodoc + +class _$_Settings implements _Settings { + _$_Settings({this.databaseId, this.useBigInt}); + + /// The database name. If omitted, the default database will be used. + @override + final String? databaseId; + + /// Whether to use `BigInt` for integer types when deserializing Firestore + /// Documents. Regardless of magnitude, all integer values are returned as + /// `BigInt` to match the precision of the Firestore backend. Floating point + /// numbers continue to use JavaScript's `number` type. + @override + final bool? useBigInt; + + @override + String toString() { + return 'Settings(databaseId: $databaseId, useBigInt: $useBigInt)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$_Settings && + (identical(other.databaseId, databaseId) || + other.databaseId == databaseId) && + (identical(other.useBigInt, useBigInt) || + other.useBigInt == useBigInt)); + } + + @override + int get hashCode => Object.hash(runtimeType, databaseId, useBigInt); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$_SettingsCopyWith<_$_Settings> get copyWith => + __$$_SettingsCopyWithImpl<_$_Settings>(this, _$identity); +} + +abstract class _Settings implements Settings { + factory _Settings({final String? databaseId, final bool? useBigInt}) = + _$_Settings; + + @override + + /// The database name. If omitted, the default database will be used. + String? get databaseId; + @override + + /// Whether to use `BigInt` for integer types when deserializing Firestore + /// Documents. Regardless of magnitude, all integer values are returned as + /// `BigInt` to match the precision of the Firestore backend. Floating point + /// numbers continue to use JavaScript's `number` type. + bool? get useBigInt; + @override + @JsonKey(ignore: true) + _$$_SettingsCopyWith<_$_Settings> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +mixin _$_QueryOptions { + _QualifiedResourcePath get parentPath => throw _privateConstructorUsedError; + String get collectionId => throw _privateConstructorUsedError; + ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) get converter => throw _privateConstructorUsedError; + bool get allDescendants => throw _privateConstructorUsedError; + List<_FilterInternal> get filters => throw _privateConstructorUsedError; + List<_FieldOrder> get fieldOrders => throw _privateConstructorUsedError; + _QueryCursor? get startAt => throw _privateConstructorUsedError; + _QueryCursor? get endAt => throw _privateConstructorUsedError; + int? get limit => throw _privateConstructorUsedError; + firestore1.Projection? get projection => throw _privateConstructorUsedError; + LimitType? get limitType => throw _privateConstructorUsedError; + int? get offset => + throw _privateConstructorUsedError; // Whether to select all documents under `parentPath`. By default, only +// collections that match `collectionId` are selected. + bool get kindless => + throw _privateConstructorUsedError; // Whether to require consistent documents when restarting the query. By +// default, restarting the query uses the readTime offset of the original +// query to provide consistent results. + bool get requireConsistency => throw _privateConstructorUsedError; + + @JsonKey(ignore: true) + _$QueryOptionsCopyWith> get copyWith => + throw _privateConstructorUsedError; +} + +/// @nodoc +abstract class _$QueryOptionsCopyWith { + factory _$QueryOptionsCopyWith( + _QueryOptions value, $Res Function(_QueryOptions) then) = + __$QueryOptionsCopyWithImpl>; + @useResult + $Res call( + {_QualifiedResourcePath parentPath, + String collectionId, + ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) converter, + bool allDescendants, + List<_FilterInternal> filters, + List<_FieldOrder> fieldOrders, + _QueryCursor? startAt, + _QueryCursor? endAt, + int? limit, + firestore1.Projection? projection, + LimitType? limitType, + int? offset, + bool kindless, + bool requireConsistency}); +} + +/// @nodoc +class __$QueryOptionsCopyWithImpl> + implements _$QueryOptionsCopyWith { + __$QueryOptionsCopyWithImpl(this._value, this._then); + + // ignore: unused_field + final $Val _value; + // ignore: unused_field + final $Res Function($Val) _then; + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? parentPath = null, + Object? collectionId = null, + Object? converter = null, + Object? allDescendants = null, + Object? filters = null, + Object? fieldOrders = null, + Object? startAt = freezed, + Object? endAt = freezed, + Object? limit = freezed, + Object? projection = freezed, + Object? limitType = freezed, + Object? offset = freezed, + Object? kindless = null, + Object? requireConsistency = null, + }) { + return _then(_value.copyWith( + parentPath: null == parentPath + ? _value.parentPath + : parentPath // ignore: cast_nullable_to_non_nullable + as _QualifiedResourcePath, + collectionId: null == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String, + converter: null == converter + ? _value.converter + : converter // ignore: cast_nullable_to_non_nullable + as ({ + T Function( + QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }), + allDescendants: null == allDescendants + ? _value.allDescendants + : allDescendants // ignore: cast_nullable_to_non_nullable + as bool, + filters: null == filters + ? _value.filters + : filters // ignore: cast_nullable_to_non_nullable + as List<_FilterInternal>, + fieldOrders: null == fieldOrders + ? _value.fieldOrders + : fieldOrders // ignore: cast_nullable_to_non_nullable + as List<_FieldOrder>, + startAt: freezed == startAt + ? _value.startAt + : startAt // ignore: cast_nullable_to_non_nullable + as _QueryCursor?, + endAt: freezed == endAt + ? _value.endAt + : endAt // ignore: cast_nullable_to_non_nullable + as _QueryCursor?, + limit: freezed == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int?, + projection: freezed == projection + ? _value.projection + : projection // ignore: cast_nullable_to_non_nullable + as firestore1.Projection?, + limitType: freezed == limitType + ? _value.limitType + : limitType // ignore: cast_nullable_to_non_nullable + as LimitType?, + offset: freezed == offset + ? _value.offset + : offset // ignore: cast_nullable_to_non_nullable + as int?, + kindless: null == kindless + ? _value.kindless + : kindless // ignore: cast_nullable_to_non_nullable + as bool, + requireConsistency: null == requireConsistency + ? _value.requireConsistency + : requireConsistency // ignore: cast_nullable_to_non_nullable + as bool, + ) as $Val); + } +} + +/// @nodoc +abstract class _$$__QueryOptionsCopyWith + implements _$QueryOptionsCopyWith { + factory _$$__QueryOptionsCopyWith( + _$__QueryOptions value, $Res Function(_$__QueryOptions) then) = + __$$__QueryOptionsCopyWithImpl; + @override + @useResult + $Res call( + {_QualifiedResourcePath parentPath, + String collectionId, + ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) converter, + bool allDescendants, + List<_FilterInternal> filters, + List<_FieldOrder> fieldOrders, + _QueryCursor? startAt, + _QueryCursor? endAt, + int? limit, + firestore1.Projection? projection, + LimitType? limitType, + int? offset, + bool kindless, + bool requireConsistency}); +} + +/// @nodoc +class __$$__QueryOptionsCopyWithImpl + extends __$QueryOptionsCopyWithImpl> + implements _$$__QueryOptionsCopyWith { + __$$__QueryOptionsCopyWithImpl( + _$__QueryOptions _value, $Res Function(_$__QueryOptions) _then) + : super(_value, _then); + + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? parentPath = null, + Object? collectionId = null, + Object? converter = null, + Object? allDescendants = null, + Object? filters = null, + Object? fieldOrders = null, + Object? startAt = freezed, + Object? endAt = freezed, + Object? limit = freezed, + Object? projection = freezed, + Object? limitType = freezed, + Object? offset = freezed, + Object? kindless = null, + Object? requireConsistency = null, + }) { + return _then(_$__QueryOptions( + parentPath: null == parentPath + ? _value.parentPath + : parentPath // ignore: cast_nullable_to_non_nullable + as _QualifiedResourcePath, + collectionId: null == collectionId + ? _value.collectionId + : collectionId // ignore: cast_nullable_to_non_nullable + as String, + converter: null == converter + ? _value.converter + : converter // ignore: cast_nullable_to_non_nullable + as ({ + T Function( + QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }), + allDescendants: null == allDescendants + ? _value.allDescendants + : allDescendants // ignore: cast_nullable_to_non_nullable + as bool, + filters: null == filters + ? _value._filters + : filters // ignore: cast_nullable_to_non_nullable + as List<_FilterInternal>, + fieldOrders: null == fieldOrders + ? _value._fieldOrders + : fieldOrders // ignore: cast_nullable_to_non_nullable + as List<_FieldOrder>, + startAt: freezed == startAt + ? _value.startAt + : startAt // ignore: cast_nullable_to_non_nullable + as _QueryCursor?, + endAt: freezed == endAt + ? _value.endAt + : endAt // ignore: cast_nullable_to_non_nullable + as _QueryCursor?, + limit: freezed == limit + ? _value.limit + : limit // ignore: cast_nullable_to_non_nullable + as int?, + projection: freezed == projection + ? _value.projection + : projection // ignore: cast_nullable_to_non_nullable + as firestore1.Projection?, + limitType: freezed == limitType + ? _value.limitType + : limitType // ignore: cast_nullable_to_non_nullable + as LimitType?, + offset: freezed == offset + ? _value.offset + : offset // ignore: cast_nullable_to_non_nullable + as int?, + kindless: null == kindless + ? _value.kindless + : kindless // ignore: cast_nullable_to_non_nullable + as bool, + requireConsistency: null == requireConsistency + ? _value.requireConsistency + : requireConsistency // ignore: cast_nullable_to_non_nullable + as bool, + )); + } +} + +/// @nodoc + +class _$__QueryOptions extends __QueryOptions { + _$__QueryOptions( + {required this.parentPath, + required this.collectionId, + required this.converter, + required this.allDescendants, + required final List<_FilterInternal> filters, + required final List<_FieldOrder> fieldOrders, + this.startAt, + this.endAt, + this.limit, + this.projection, + this.limitType, + this.offset, + this.kindless = false, + this.requireConsistency = true}) + : _filters = filters, + _fieldOrders = fieldOrders, + super._(); + + @override + final _QualifiedResourcePath parentPath; + @override + final String collectionId; + @override + final ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) converter; + @override + final bool allDescendants; + final List<_FilterInternal> _filters; + @override + List<_FilterInternal> get filters { + if (_filters is EqualUnmodifiableListView) return _filters; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_filters); + } + + final List<_FieldOrder> _fieldOrders; + @override + List<_FieldOrder> get fieldOrders { + if (_fieldOrders is EqualUnmodifiableListView) return _fieldOrders; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_fieldOrders); + } + + @override + final _QueryCursor? startAt; + @override + final _QueryCursor? endAt; + @override + final int? limit; + @override + final firestore1.Projection? projection; + @override + final LimitType? limitType; + @override + final int? offset; +// Whether to select all documents under `parentPath`. By default, only +// collections that match `collectionId` are selected. + @override + @JsonKey() + final bool kindless; +// Whether to require consistent documents when restarting the query. By +// default, restarting the query uses the readTime offset of the original +// query to provide consistent results. + @override + @JsonKey() + final bool requireConsistency; + + @override + String toString() { + return '_QueryOptions<$T>(parentPath: $parentPath, collectionId: $collectionId, converter: $converter, allDescendants: $allDescendants, filters: $filters, fieldOrders: $fieldOrders, startAt: $startAt, endAt: $endAt, limit: $limit, projection: $projection, limitType: $limitType, offset: $offset, kindless: $kindless, requireConsistency: $requireConsistency)'; + } + + @override + bool operator ==(dynamic other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$__QueryOptions && + (identical(other.parentPath, parentPath) || + other.parentPath == parentPath) && + (identical(other.collectionId, collectionId) || + other.collectionId == collectionId) && + (identical(other.converter, converter) || + other.converter == converter) && + (identical(other.allDescendants, allDescendants) || + other.allDescendants == allDescendants) && + const DeepCollectionEquality().equals(other._filters, _filters) && + const DeepCollectionEquality() + .equals(other._fieldOrders, _fieldOrders) && + (identical(other.startAt, startAt) || other.startAt == startAt) && + (identical(other.endAt, endAt) || other.endAt == endAt) && + (identical(other.limit, limit) || other.limit == limit) && + (identical(other.projection, projection) || + other.projection == projection) && + (identical(other.limitType, limitType) || + other.limitType == limitType) && + (identical(other.offset, offset) || other.offset == offset) && + (identical(other.kindless, kindless) || + other.kindless == kindless) && + (identical(other.requireConsistency, requireConsistency) || + other.requireConsistency == requireConsistency)); + } + + @override + int get hashCode => Object.hash( + runtimeType, + parentPath, + collectionId, + converter, + allDescendants, + const DeepCollectionEquality().hash(_filters), + const DeepCollectionEquality().hash(_fieldOrders), + startAt, + endAt, + limit, + projection, + limitType, + offset, + kindless, + requireConsistency); + + @JsonKey(ignore: true) + @override + @pragma('vm:prefer-inline') + _$$__QueryOptionsCopyWith> get copyWith => + __$$__QueryOptionsCopyWithImpl>(this, _$identity); +} + +abstract class __QueryOptions extends _QueryOptions { + factory __QueryOptions( + {required final _QualifiedResourcePath parentPath, + required final String collectionId, + required final ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) converter, + required final bool allDescendants, + required final List<_FilterInternal> filters, + required final List<_FieldOrder> fieldOrders, + final _QueryCursor? startAt, + final _QueryCursor? endAt, + final int? limit, + final firestore1.Projection? projection, + final LimitType? limitType, + final int? offset, + final bool kindless, + final bool requireConsistency}) = _$__QueryOptions; + __QueryOptions._() : super._(); + + @override + _QualifiedResourcePath get parentPath; + @override + String get collectionId; + @override + ({ + T Function(QueryDocumentSnapshot>) fromFirestore, + Map Function(T) toFirestore + }) get converter; + @override + bool get allDescendants; + @override + List<_FilterInternal> get filters; + @override + List<_FieldOrder> get fieldOrders; + @override + _QueryCursor? get startAt; + @override + _QueryCursor? get endAt; + @override + int? get limit; + @override + firestore1.Projection? get projection; + @override + LimitType? get limitType; + @override + int? get offset; + @override // Whether to select all documents under `parentPath`. By default, only +// collections that match `collectionId` are selected. + bool get kindless; + @override // Whether to require consistent documents when restarting the query. By +// default, restarting the query uses the readTime offset of the original +// query to provide consistent results. + bool get requireConsistency; + @override + @JsonKey(ignore: true) + _$$__QueryOptionsCopyWith> get copyWith => + throw _privateConstructorUsedError; +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_exception.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_exception.dart new file mode 100644 index 0000000..87e2709 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore_exception.dart @@ -0,0 +1,46 @@ +part of 'firestore.dart'; + +/// A generic guard wrapper for API calls to handle exceptions. +R _firestoreGuard(R Function() cb) { + try { + final value = cb(); + + if (value is Future) { + return value.catchError(_handleException) as R; + } + + return value; + } catch (error, stackTrace) { + _handleException(error, stackTrace); + } +} + +/// Converts a Exception to a FirebaseAdminException. +Never _handleException(Object exception, StackTrace stackTrace) { + if (exception is firestore1.DetailedApiRequestError) { + Error.throwWithStackTrace( + FirebaseFirestoreAdminException.fromServerError(exception), + stackTrace, + ); + } + + Error.throwWithStackTrace(exception, stackTrace); +} + +class FirebaseFirestoreAdminException extends FirebaseAdminException { + FirebaseFirestoreAdminException.fromServerError( + this.serverError, + ) : super('firestore', 'unknown', serverError.message); + + /// The error thrown by the http/grpc client. + /// + /// This is exposed temporarily as a workaround until proper status codes + /// are exposed officially. + // TODO handle firestore error codes. + @experimental + final firestore1.DetailedApiRequestError serverError; + + @override + String toString() => + 'FirebaseFirestoreAdminException: $code: $message ${serverError.jsonResponse} '; +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/geo_point.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/geo_point.dart new file mode 100644 index 0000000..43a9b45 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/geo_point.dart @@ -0,0 +1,75 @@ +part of 'firestore.dart'; + +/// An immutable object representing a geographic location in Firestore. The +/// location is represented as a latitude/longitude pair. +@immutable +class GeoPoint implements _Serializable { + GeoPoint({ + required this.latitude, + required this.longitude, + }) { + if (latitude.isNaN) { + throw ArgumentError.value( + latitude, + 'latitude', + 'Value for argument "latitude" is not a valid number', + ); + } + if (longitude.isNaN) { + throw ArgumentError.value( + longitude, + 'longitude', + 'Value for argument "longitude" is not a valid number', + ); + } + + if (latitude < -90 || latitude > 90) { + throw ArgumentError.value( + latitude, + 'latitude', + 'Latitude must be in the range of [-90, 90]', + ); + } + if (longitude < -180 || longitude > 180) { + throw ArgumentError.value( + longitude, + 'longitude', + 'Longitude must be in the range of [-180, 180]', + ); + } + } + + /// Converts a google.type.LatLng proto to its GeoPoint representation. + factory GeoPoint._fromProto(firestore1.LatLng latLng) { + return GeoPoint( + latitude: latLng.latitude ?? 0, + longitude: latLng.longitude ?? 0, + ); + } + + /// The latitude as a number between -90 and 90. + final double latitude; + + /// The longitude as a number between -180 and 180. + final double longitude; + + @override + firestore1.Value _toProto() { + return firestore1.Value( + geoPointValue: firestore1.LatLng( + latitude: latitude, + longitude: longitude, + ), + ); + } + + @override + bool operator ==(Object? other) { + return other is GeoPoint && + other.latitude == latitude && + other.longitude == longitude; + } + + @override + int get hashCode => Object.hash(latitude, longitude); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart new file mode 100644 index 0000000..181f220 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/path.dart @@ -0,0 +1,330 @@ +part of 'firestore.dart'; + +/// Validates that the given string can be used as a relative or absolute +/// resource path. +void _validateResourcePath(Object arg, String resourcePath) { + if (resourcePath.isEmpty) { + throw ArgumentError.value( + resourcePath, + arg.toString(), + 'Must be a non-empty string', + ); + } + + if (resourcePath.contains('//')) { + throw ArgumentError.value( + resourcePath, + arg.toString(), + 'Must not contain "//"', + ); + } +} + +@immutable +abstract class _Path> implements Comparable<_Path> { + const _Path(this.segments); + + final List segments; + + /// Constructs a new instance of [_Path]. + T _construct(List segments); + + /// Splits a string into path segments. + List _split(String relativePath); + + /// Returns the path of the parent node. + T? _parent() { + if (segments.isEmpty) return null; + + return _construct(segments.sublist(0, segments.length - 1)); + } + + /// Create a child path beneath the current level. + T _appendPath(_Path relativePath) { + return _construct([...segments, ...relativePath.segments]); + } + + /// Create a child path beneath the current level. + T _append(String relativePath) { + return _construct([...segments, ..._split(relativePath)]); + } + + List _toList() => segments.toList(); + + /// Checks whether the current path is a prefix of the specified path. + bool _isPrefixOf(_Path other) { + if (other.segments.length < this.segments.length) { + return false; + } + + for (var i = 0; i < this.segments.length; i++) { + if (this.segments[i] != other.segments[i]) { + return false; + } + } + + return true; + } + + @override + int compareTo(_Path other) { + final len = math.min(segments.length, other.segments.length); + for (var i = 0; i < len; i++) { + final compare = segments[i].compareTo(other.segments[i]); + if (compare != 0) return compare; + } + + if (this.segments.length < other.segments.length) return -1; + if (this.segments.length > other.segments.length) return 1; + + return 0; + } + + @override + bool operator ==(Object other) { + return other is _Path && + runtimeType == other.runtimeType && + const ListEquality().equals(segments, other.segments); + } + + @override + int get hashCode => Object.hash( + runtimeType, + const ListEquality().hash(segments), + ); +} + +class _ResourcePath extends _Path<_ResourcePath> { + const _ResourcePath._([super.segments = const []]); + + static const empty = _ResourcePath._(); + + /// Returns the location of this path relative to the root of the + /// project's database. + String get relativeName => segments.join('/'); + + /// Indicates whether this path points to a collection. + bool get isCollection => segments.length.isOdd; + + /// Indicates whether this path points to a document. + bool get isDocument => segments.isNotEmpty && segments.length.isEven; + + /// The last component of the path. + String? get id { + if (segments.isEmpty) return null; + return segments.last; + } + + @override + _ResourcePath _construct(List segments) => _ResourcePath._(segments); + + @override + List _split(String relativePath) { + // We may have an empty segment at the beginning or end if they had a + // leading or trailing slash (which we allow). + return relativePath + .split('/') + .where((segment) => segment.isNotEmpty) + .toList(); + } + + _QualifiedResourcePath _toQualifiedResourcePath( + String projectId, + String databaseId, + ) { + return _QualifiedResourcePath._( + projectId: projectId, + databaseId: databaseId, + segments: segments, + ); + } +} + +class _QualifiedResourcePath extends _ResourcePath { + const _QualifiedResourcePath._({ + required String projectId, + required String databaseId, + required List segments, + }) : _projectId = projectId, + _databaseId = databaseId, + super._(segments); + + factory _QualifiedResourcePath.fromSlashSeparatedString(String absolutePath) { + final elements = _resourcePathRe.firstMatch(absolutePath); + + if (elements == null) { + throw ArgumentError.value(absolutePath, 'absolutePath'); + } + + final project = elements.group(1)!; + final database = elements.group(2)!; + final path = elements.group(3)!; + + return _QualifiedResourcePath._( + projectId: project, + databaseId: database, + segments: const [], + )._append(path); + } + + /// A regular expression to verify an absolute Resource Path in Firestore. It + /// extracts the project ID, the database name and the relative resource path + /// if available. + static final _resourcePathRe = RegExp( + // Note: [\s\S] matches all characters including newlines. + r'^projects\/([^/]*)\/databases\/([^/]*)(?:\/documents\/)?([\s\S]*)$', + ); + + final String _projectId; + final String _databaseId; + + @override + _QualifiedResourcePath? _parent() => + super._parent() as _QualifiedResourcePath?; + + /// String representation of a ResourcePath as expected by the API. + String get _formattedName { + final components = [ + 'projects', + _projectId, + 'databases', + _databaseId, + 'documents', + ...segments, + ]; + return components.join('/'); + } + + @override + _QualifiedResourcePath _append(String relativePath) { + return super._append(relativePath) as _QualifiedResourcePath; + } + + @override + _QualifiedResourcePath _appendPath(_Path<_ResourcePath> relativePath) { + return super._appendPath(relativePath) as _QualifiedResourcePath; + } + + @override + _QualifiedResourcePath _construct(List segments) { + return _QualifiedResourcePath._( + projectId: _projectId, + databaseId: _databaseId, + segments: segments, + ); + } + + @override + int compareTo(_Path<_ResourcePath> other) { + if (other is _QualifiedResourcePath) { + final compare = _projectId.compareTo(other._projectId); + if (compare != 0) return compare; + + final compare2 = _databaseId.compareTo(other._databaseId); + if (compare2 != 0) return compare2; + } + + return super.compareTo(other); + } +} + +sealed class FieldMask { + factory FieldMask.field(String path) = _StringFieldMask; + factory FieldMask.fieldPath(FieldPath fieldPath) = _FieldPathFieldMask; +} + +final _fieldPathRegex = RegExp(r'^[^*~/[\]]+$'); + +class _StringFieldMask implements FieldMask { + _StringFieldMask(this.path) { + if (path.contains('..')) { + throw ArgumentError.value( + path, + 'path', + 'must not contain ".."', + ); + } + + if (path.startsWith('.') || path.endsWith('.')) { + throw ArgumentError.value( + path, + 'path', + 'must not start or end with "."', + ); + } + + if (!_fieldPathRegex.hasMatch(path)) { + throw ArgumentError.value( + path, + 'path', + "Paths can't be empty and must not contain '*~/[]'.", + ); + } + } + final String path; +} + +class _FieldPathFieldMask implements FieldMask { + _FieldPathFieldMask(this.fieldPath); + final FieldPath fieldPath; +} + +class FieldPath extends _Path { + FieldPath(super.segments) { + if (segments.isEmpty) { + throw ArgumentError.value(segments, 'segments', 'must not be empty.'); + } + + for (var i = 0; i < segments.length; ++i) { + if (segments[i].isEmpty) { + throw ArgumentError.value( + segments[i], + 'Element at index $i', + 'should not be an empty string.', + ); + } + } + } + + factory FieldPath.from(Object? object) { + if (object is String) { + return FieldPath.fromArgument(FieldMask.field(object)); + } else if (object is FieldPath) { + return object; + } + + throw ArgumentError.value( + object, + 'object', + 'must be a String or FieldPath.', + ); + } + + factory FieldPath.fromArgument(FieldMask fieldMask) { + return switch (fieldMask) { + _FieldPathFieldMask() => fieldMask.fieldPath, + _StringFieldMask() => FieldPath(fieldMask.path.split('.').toList()), + }; + } + + /// A special [FieldPath] value to refer to the ID of a document. It can be used + /// in queries to sort or filter by the document ID. + static final documentId = FieldPath(const ['__name__']); + + /// Returns the number of segments of this field path. + int get _length => segments.length; + + String get _formattedName { + final regex = RegExp(r'^[_a-zA-Z][_a-zA-Z0-9]*$'); + return segments.map((e) { + if (regex.hasMatch(e)) return e; + return '`${e.replaceAll(r'\', r'\\').replaceAll('`', r'\')}`'; + }).join('.'); + } + + @override + FieldPath _construct(List segments) => FieldPath(segments); + + @override + List _split(String relativePath) => relativePath.split('.'); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart new file mode 100644 index 0000000..b58ff43 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -0,0 +1,1710 @@ +part of 'firestore.dart'; + +class CollectionReference extends Query { + CollectionReference._({ + required super.firestore, + required _QualifiedResourcePath path, + required _FirestoreDataConverter converter, + }) : super._( + queryOptions: _QueryOptions.forCollectionQuery(path, converter), + ); + + _QualifiedResourcePath get _resourcePath => + _queryOptions.parentPath._append(id); + + /// The last path element of the referenced collection. + String get id => _queryOptions.collectionId; + + /// A reference to the containing Document if this is a subcollection, else + /// null. + /// + /// ```dart + /// final collectionRef = firestore.collection('col/doc/subcollection'); + /// final documentRef = collectionRef.parent; + /// print('Parent name: ${documentRef.path}'); + /// ``` + DocumentReference? get parent { + if (!_queryOptions.parentPath.isDocument) return null; + + return DocumentReference._( + firestore: firestore, + path: _queryOptions.parentPath, + converter: _queryOptions.converter, + ); + } + + /// A string representing the path of the referenced collection (relative + /// to the root of the database). + String get path => _resourcePath.relativeName; + + /// Gets a [DocumentReference] instance that refers to the document at + /// the specified path. + /// + /// If no path is specified, an automatically-generated unique ID will be + /// used for the returned [DocumentReference]. + /// + /// If using [withConverter], the [path] must not contain any slash. + DocumentReference doc([String? documentPath]) { + if (documentPath != null) { + _validateResourcePath('documentPath', documentPath); + } else { + documentPath = autoId(); + } + + final path = _resourcePath._append(documentPath); + if (!path.isDocument) { + throw ArgumentError.value( + documentPath, + 'documentPath', + 'Value for argument "documentPath" must point to a document, but was ' + '"$documentPath". Your path does not contain an even number of components.', + ); + } + + if (!identical(_queryOptions.converter, _jsonConverter) && + path._parent() != _resourcePath) { + throw ArgumentError.value( + documentPath, + 'documentPath', + 'Value for argument "documentPath" must not contain a slash (/) if ' + 'the parent collection has a custom converter.', + ); + } + + return DocumentReference._( + firestore: firestore, + path: path, + converter: _queryOptions.converter, + ); + } + + /// Retrieves the list of documents in this collection. + /// + /// The document references returned may include references to "missing + /// documents", i.e. document locations that have no document present but + /// which contain subcollections with documents. Attempting to read such a + /// document reference (e.g. via [DocumentReference.get]) will return a + /// [DocumentSnapshot] whose [DocumentSnapshot.exists] property is `false`. + Future>> listDocuments() async { + final parentPath = _queryOptions.parentPath._toQualifiedResourcePath( + firestore.app.projectId, + firestore._databaseId, + ); + + final response = await firestore._client.v1((client) { + return client.projects.databases.documents.list( + parentPath._formattedName, + id, + showMissing: true, + // Setting `pageSize` to an arbitrarily large value lets the backend cap + // the page size (currently to 300). Note that the backend rejects + // MAX_INT32 (b/146883794). + pageSize: math.pow(2, 16 - 1).toInt(), + mask_fieldPaths: [], + ); + }); + + return [ + for (final document + in response.documents ?? const []) + doc( + // ignore: unnecessary_null_checks, we don't want to inadvertedly obtain a new document + _QualifiedResourcePath.fromSlashSeparatedString(document.name!).id!, + ), + ]; + } + + /// Add a new document to this collection with the specified data, assigning + /// it a document ID automatically. + Future> add(T data) async { + final firestoreData = _queryOptions.converter.toFirestore(data); + _validateDocumentData( + 'data', + firestoreData, + allowDeletes: false, + ); + + final documentRef = doc(); + final jsonDocumentRef = documentRef.withConverter( + fromFirestore: _jsonConverter.fromFirestore, + toFirestore: _jsonConverter.toFirestore, + ); + + return jsonDocumentRef.create(firestoreData).then((_) => documentRef); + } + + @override + CollectionReference withConverter({ + required FromFirestore fromFirestore, + required ToFirestore toFirestore, + }) { + return CollectionReference._( + firestore: firestore, + path: _queryOptions.parentPath._append(id), + converter: ( + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ), + ); + } + + @override + // ignore: hash_and_equals, already implemented in Query + bool operator ==(Object other) { + return other is CollectionReference && super == other; + } +} + +@immutable +class DocumentReference implements _Serializable { + const DocumentReference._({ + required this.firestore, + required _QualifiedResourcePath path, + required _FirestoreDataConverter converter, + }) : _converter = converter, + _path = path; + + final _QualifiedResourcePath _path; + final _FirestoreDataConverter _converter; + final Firestore firestore; + + /// A string representing the path of the referenced document (relative + /// to the root of the database). + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// collectionRef.add({'foo': 'bar'}).then((documentReference) { + /// print('Added document at "${documentReference.path}"'); + /// }); + /// ``` + String get path => _path.relativeName; + + /// The last path element of the referenced document. + String get id => _path.id!; + + /// A reference to the collection to which this DocumentReference belongs. + CollectionReference get parent { + return CollectionReference._( + firestore: firestore, + path: _path._parent()!, + converter: _converter, + ); + } + + /// The string representation of the DocumentReference's location. + String get _formattedName { + return _path + ._toQualifiedResourcePath( + firestore.app.projectId, + firestore._databaseId, + ) + ._formattedName; + } + + /// Changes the de/serializing mechanism for this [DocumentReference]. + /// + /// This changes the return value of [DocumentSnapshot.data]. + DocumentReference withConverter({ + required FromFirestore fromFirestore, + required ToFirestore toFirestore, + }) { + return DocumentReference._( + firestore: firestore, + path: _path, + converter: ( + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ), + ); + } + + Future> get() async { + final result = await firestore.getAll([this], null); + return result.single; + } + + /// Create a document with the provided object values. This will fail the write + /// if a document exists at its location. + /// + /// - [data]: An object that contains the fields and data to + /// serialize as the document. + /// + /// Throws if the provided input is not a valid Firestore document. + /// + /// Returns a Future that resolves with the write time of this create. + /// + /// ```dart + /// final documentRef = firestore.collection('col').doc(); + /// + /// documentRef.create({foo: 'bar'}).then((res) { + /// print('Document created at ${res.updateTime}'); + /// }).catch((err) => { + /// print('Failed to create document: ${err}'); + /// }); + /// ``` + Future create(T data) async { + final writeBatch = WriteBatch._(this.firestore)..create(this, data); + + final results = await writeBatch.commit(); + return results.single; + } + + /// Deletes the document referred to by this [DocumentReference]. + /// + /// A delete for a non-existing document is treated as a success (unless + /// [precondition] is specified). + Future delete([Precondition? precondition]) async { + final writeBatch = WriteBatch._(this.firestore) + ..delete(this, precondition: precondition); + + final results = await writeBatch.commit(); + return results.single; + } + + /// Writes to the document referred to by this DocumentReference. If the + /// document does not yet exist, it will be created. + Future set(T data) async { + final writeBatch = WriteBatch._(this.firestore)..set(this, data); + + final results = await writeBatch.commit(); + return results.single; + } + + /// Updates fields in the document referred to by this DocumentReference. + /// If the document doesn't yet exist, the update fails and the returned + /// Promise will be rejected. + /// + /// The update() method accepts either an object with field paths encoded as + /// keys and field values encoded as values, or a variable number of arguments + /// that alternate between field paths and field values. + /// + /// A [Precondition] restricting this update can be specified as the last + /// argument. + Future update( + Map data, [ + Precondition? precondition, + ]) async { + final writeBatch = WriteBatch._(this.firestore) + ..update( + this, + { + for (final entry in data.entries) + FieldPath.from(entry.key): entry.value, + }, + precondition: precondition, + ); + + final results = await writeBatch.commit(); + return results.single; + } + + /// Gets a [CollectionReference] instance + /// that refers to the collection at the specified path. + /// + /// - [collectionPath]: A slash-separated path to a collection. + /// + /// Returns A reference to the new subcollection. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// final subcollection = documentRef.collection('subcollection'); + /// print('Path to subcollection: ${subcollection.path}'); + /// ``` + CollectionReference collection(String collectionPath) { + _validateResourcePath('collectionPath', collectionPath); + + final path = _path._append(collectionPath); + if (!path.isCollection) { + throw ArgumentError.value( + collectionPath, + 'collectionPath', + 'Value for argument "collectionPath" must point to a collection, but was ' + '"$collectionPath". Your path does not contain an odd number of components.', + ); + } + + return CollectionReference._( + firestore: firestore, + path: path, + converter: _jsonConverter, + ); + } + + // TODO listCollections + // TODO snapshots + + @override + firestore1.Value _toProto() { + return firestore1.Value(referenceValue: _formattedName); + } + + @override + bool operator ==(Object other) { + return other is DocumentReference && + runtimeType == other.runtimeType && + firestore == other.firestore && + _path == other._path && + _converter == other._converter; + } + + @override + int get hashCode => Object.hash(runtimeType, firestore, _path, _converter); +} + +bool _valuesEqual( + List? a, + List? b, +) { + if (a == null) return b == null; + if (b == null) return false; + + if (a.length != b.length) return false; + + for (final (index, value) in a.indexed) { + if (!_valueEqual(value, b[index])) return false; + } + + return true; +} + +bool _valueEqual(firestore1.Value a, firestore1.Value b) { + switch (a) { + case firestore1.Value(:final arrayValue?): + return _valuesEqual(arrayValue.values, b.arrayValue?.values); + case firestore1.Value(:final booleanValue?): + return booleanValue == b.booleanValue; + case firestore1.Value(:final bytesValue?): + return bytesValue == b.bytesValue; + case firestore1.Value(:final doubleValue?): + return doubleValue == b.doubleValue; + case firestore1.Value(:final geoPointValue?): + return geoPointValue.latitude == b.geoPointValue?.latitude && + geoPointValue.longitude == b.geoPointValue?.longitude; + case firestore1.Value(:final integerValue?): + return integerValue == b.integerValue; + case firestore1.Value(:final mapValue?): + final bMap = b.mapValue; + if (bMap == null || bMap.fields?.length != mapValue.fields?.length) { + return false; + } + + for (final MapEntry(:key, :value) in mapValue.fields?.entries ?? + const >[]) { + final bValue = bMap.fields?[key]; + if (bValue == null) return false; + if (!_valueEqual(value, bValue)) return false; + } + case firestore1.Value(:final nullValue?): + return nullValue == b.nullValue; + case firestore1.Value(:final referenceValue?): + return referenceValue == b.referenceValue; + case firestore1.Value(:final stringValue?): + return stringValue == b.stringValue; + case firestore1.Value(:final timestampValue?): + return timestampValue == b.timestampValue; + } + return false; +} + +@immutable +class _QueryCursor { + const _QueryCursor({required this.before, required this.values}); + + final bool before; + final List values; + + @override + bool operator ==(Object other) { + // if (other is! _QueryCursor) return false; + + // print(_valuesEqual(values, other.values)); + + return other is _QueryCursor && + runtimeType == other.runtimeType && + before == other.before && + _valuesEqual(values, other.values); + } + + @override + int get hashCode => Object.hash( + before, + const ListEquality().hash(values), + ); +} + +/* + * Denotes whether a provided limit is applied to the beginning or the end of + * the result set. + */ +enum LimitType { + first, + last, +} + +enum _Direction { + ascending('ASCENDING'), + descending('DESCENDING'); + + const _Direction(this.value); + + final String value; +} + +/// A Query order-by field. +@immutable +class _FieldOrder { + const _FieldOrder({ + required this.fieldPath, + this.direction = _Direction.ascending, + }); + + final FieldPath fieldPath; + final _Direction direction; + + firestore1.Order _toProto() { + return firestore1.Order( + field: firestore1.FieldReference( + fieldPath: fieldPath._formattedName, + ), + direction: direction.value, + ); + } + + @override + bool operator ==(Object other) { + return other is _FieldOrder && + fieldPath == other.fieldPath && + direction == other.direction; + } + + @override + int get hashCode => Object.hash(fieldPath, direction); +} + +@freezed +class _QueryOptions with _$_QueryOptions { + factory _QueryOptions({ + required _QualifiedResourcePath parentPath, + required String collectionId, + required _FirestoreDataConverter converter, + required bool allDescendants, + required List<_FilterInternal> filters, + required List<_FieldOrder> fieldOrders, + _QueryCursor? startAt, + _QueryCursor? endAt, + int? limit, + firestore1.Projection? projection, + LimitType? limitType, + int? offset, + // Whether to select all documents under `parentPath`. By default, only + // collections that match `collectionId` are selected. + + @Default(false) bool kindless, + // Whether to require consistent documents when restarting the query. By + // default, restarting the query uses the readTime offset of the original + // query to provide consistent results. + @Default(true) bool requireConsistency, + }) = __QueryOptions; + _QueryOptions._(); + + /// Returns query options for a single-collection query. + factory _QueryOptions.forCollectionQuery( + _QualifiedResourcePath collectionRef, + _FirestoreDataConverter converter, + ) { + return _QueryOptions( + parentPath: collectionRef._parent()!, + collectionId: collectionRef.id!, + converter: converter, + allDescendants: false, + filters: [], + fieldOrders: [], + ); + } + + bool get hasFieldOrders => fieldOrders.isNotEmpty; + + _QueryOptions withConverter( + _FirestoreDataConverter converter, + ) { + return _QueryOptions( + converter: converter, + parentPath: parentPath, + collectionId: collectionId, + allDescendants: allDescendants, + filters: filters, + fieldOrders: fieldOrders, + startAt: startAt, + endAt: endAt, + limit: limit, + limitType: limitType, + offset: offset, + projection: projection, + ); + } +} + +@immutable +sealed class _FilterInternal { + /// Returns a list of all field filters that are contained within this filter + List<_FieldFilterInternal> get flattenedFilters; + + /// Returns a list of all filters that are contained within this filter + List<_FilterInternal> get filters; + + /// Returns the field of the first filter that's an inequality, or null if none. + FieldPath? get firstInequalityField; + + /// Returns the proto representation of this filter + firestore1.Filter toProto(); + + @mustBeOverridden + @override + bool operator ==(Object other); + + @mustBeOverridden + @override + int get hashCode; +} + +class _CompositeFilterInternal implements _FilterInternal { + _CompositeFilterInternal({required this.op, required this.filters}); + + final _CompositeOperator op; + @override + final List<_FilterInternal> filters; + + bool get isConjunction => op == _CompositeOperator.and; + + @override + late final flattenedFilters = filters.fold>( + [], + (allFilters, subfilter) { + return allFilters..addAll(subfilter.flattenedFilters); + }, + ); + + @override + FieldPath? get firstInequalityField { + return flattenedFilters + .firstWhereOrNull((filter) => filter.isInequalityFilter) + ?.field; + } + + @override + firestore1.Filter toProto() { + if (filters.length == 1) return filters.single.toProto(); + + return firestore1.Filter( + compositeFilter: firestore1.CompositeFilter( + op: op.proto, + filters: filters.map((e) => e.toProto()).toList(), + ), + ); + } + + @override + bool operator ==(Object other) { + return other is _CompositeFilterInternal && + runtimeType == other.runtimeType && + op == other.op && + const ListEquality<_FilterInternal>().equals(filters, other.filters); + } + + @override + int get hashCode => Object.hash(runtimeType, op, filters); +} + +class _FieldFilterInternal implements _FilterInternal { + _FieldFilterInternal({ + required this.field, + required this.op, + required this.value, + required this.serializer, + }); + + final FieldPath field; + final WhereFilter op; + final Object? value; + final _Serializer serializer; + + @override + List<_FieldFilterInternal> get flattenedFilters => [this]; + + @override + List<_FieldFilterInternal> get filters => [this]; + + @override + FieldPath? get firstInequalityField => isInequalityFilter ? field : null; + + bool get isInequalityFilter { + return op == WhereFilter.lessThan || + op == WhereFilter.lessThanOrEqual || + op == WhereFilter.greaterThan || + op == WhereFilter.greaterThanOrEqual; + } + + @override + firestore1.Filter toProto() { + final value = this.value; + if (value is num && value.isNaN) { + return firestore1.Filter( + unaryFilter: firestore1.UnaryFilter( + field: firestore1.FieldReference( + fieldPath: field._formattedName, + ), + op: op == WhereFilter.equal ? 'IS_NAN' : 'IS_NOT_NAN', + ), + ); + } + + if (value == null) { + return firestore1.Filter( + unaryFilter: firestore1.UnaryFilter( + field: firestore1.FieldReference( + fieldPath: field._formattedName, + ), + op: op == WhereFilter.equal ? 'IS_NULL' : 'IS_NOT_NULL', + ), + ); + } + + return firestore1.Filter( + fieldFilter: firestore1.FieldFilter( + field: firestore1.FieldReference( + fieldPath: field._formattedName, + ), + op: op.proto, + value: serializer.encodeValue(value), + ), + ); + } + + @override + bool operator ==(Object? other) { + return other is _FieldFilterInternal && + field == other.field && + op == other.op && + value == other.value; + } + + @override + int get hashCode => Object.hash(field, op, value); +} + +@immutable +class Query { + const Query._({ + required this.firestore, + required _QueryOptions queryOptions, + }) : _queryOptions = queryOptions; + + static List _extractFieldValues( + DocumentSnapshot documentSnapshot, + List<_FieldOrder> fieldOrders, + ) { + return fieldOrders.map((fieldOrder) { + if (fieldOrder.fieldPath == FieldPath.documentId) { + return documentSnapshot.ref; + } + + final fieldValue = documentSnapshot.get(fieldOrder.fieldPath); + if (fieldValue == null) { + throw StateError( + 'Field "${fieldOrder.fieldPath}" is missing in the provided DocumentSnapshot. ' + 'Please provide a document that contains values for all specified orderBy() ' + 'and where() constraints.', + ); + } + return fieldValue.value; + }).toList(); + } + + final Firestore firestore; + final _QueryOptions _queryOptions; + + /// Applies a custom data converter to this Query, allowing you to use your + /// own custom model objects with Firestore. When you call [get] on the + /// returned [Query], the provided converter will convert between Firestore + /// data and your custom type U. + /// + /// Using the converter allows you to specify generic type arguments when + /// storing and retrieving objects from Firestore. + @mustBeOverridden + Query withConverter({ + required FromFirestore fromFirestore, + required ToFirestore toFirestore, + }) { + return Query._( + firestore: firestore, + queryOptions: _queryOptions.withConverter( + ( + fromFirestore: fromFirestore, + toFirestore: toFirestore, + ), + ), + ); + } + + _QueryCursor _createCursor( + List<_FieldOrder> fieldOrders, { + List? fieldValues, + DocumentSnapshot? snapshot, + required bool before, + }) { + if (fieldValues != null && snapshot != null) { + throw ArgumentError( + 'You cannot specify both "fieldValues" and "snapshot".', + ); + } + + if (snapshot != null) { + fieldValues = Query._extractFieldValues(snapshot, fieldOrders); + } + + if (fieldValues == null) { + throw ArgumentError( + 'You must specify "fieldValues" or "snapshot".', + ); + } + + if (fieldValues.length > fieldOrders.length) { + throw ArgumentError( + 'Too many cursor values specified. The specified ' + 'values must match the orderBy() constraints of the query.', + ); + } + + final cursorValues = []; + final cursor = _QueryCursor(before: before, values: cursorValues); + + for (var i = 0; i < fieldValues.length; ++i) { + final fieldValue = fieldValues[i]; + + if (fieldOrders[i].fieldPath == FieldPath.documentId && + fieldValue is! DocumentReference) { + throw ArgumentError( + 'When ordering with FieldPath.documentId(), ' + 'the cursor must be a DocumentReference.', + ); + } + + _validateQueryValue('$i', fieldValue); + cursor.values.add(this.firestore._serializer.encodeValue(fieldValue)!); + } + + return cursor; + } + + (_QueryCursor, List<_FieldOrder>) _cursorFromValues({ + List? fieldValues, + DocumentSnapshot? snapshot, + required bool before, + }) { + if (fieldValues != null && fieldValues.isEmpty) { + throw ArgumentError.value( + fieldValues, + 'fieldValues', + 'Value must not be an empty List.', + ); + } + + final fieldOrders = _createImplicitOrderBy(snapshot); + final cursor = _createCursor( + fieldOrders, + fieldValues: fieldValues, + snapshot: snapshot, + before: before, + ); + return (cursor, fieldOrders); + } + + /// Computes the backend ordering semantics for DocumentSnapshot cursors. + List<_FieldOrder> _createImplicitOrderBy( + DocumentSnapshot? snapshot, + ) { + // Add an implicit orderBy if the only cursor value is a DocumentSnapshot + // or a DocumentReference. + if (snapshot == null) return _queryOptions.fieldOrders; + + final fieldOrders = _queryOptions.fieldOrders.toList(); + + // If no explicit ordering is specified, use the first inequality to + // define an implicit order. + if (fieldOrders.isEmpty) { + for (final filter in _queryOptions.filters) { + final fieldReference = filter.firstInequalityField; + if (fieldReference != null) { + fieldOrders.add(_FieldOrder(fieldPath: fieldReference)); + break; + } + } + } + + final hasDocumentId = fieldOrders.any( + (fieldOrder) => fieldOrder.fieldPath == FieldPath.documentId, + ); + if (!hasDocumentId) { + // Add implicit sorting by name, using the last specified direction. + final lastDirection = fieldOrders.isEmpty + ? _Direction.ascending + : fieldOrders.last.direction; + + fieldOrders.add( + _FieldOrder(fieldPath: FieldPath.documentId, direction: lastDirection), + ); + } + + return fieldOrders; + } + + /// Creates and returns a new [Query] that starts at the provided + /// set of field values relative to the order of the query. The order of the + /// provided values must match the order of the order by clauses of the query. + /// + /// - [fieldValues] The field values to start this query at, + /// in order of the query's order by. + /// + /// ```dart + /// final query = firestore.collection('col'); + /// + /// query.orderBy('foo').startAt(42).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query startAt(List fieldValues) { + final (startAt, fieldOrders) = _cursorFromValues( + fieldValues: fieldValues, + before: true, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + startAt: startAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that starts at the provided + /// set of field values relative to the order of the query. The order of the + /// provided values must match the order of the order by clauses of the query. + /// + /// - [documentSnapshot] The snapshot of the document the query results + /// should start at, in order of the query's order by. + Query startAtDocument(DocumentSnapshot documentSnapshot) { + final (startAt, fieldOrders) = _cursorFromValues( + snapshot: documentSnapshot, + before: true, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + startAt: startAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that starts after the + /// provided set of field values relative to the order of the query. The order + /// of the provided values must match the order of the order by clauses of the + /// query. + /// + /// - [fieldValues]: The field values to + /// start this query after, in order of the query's order by. + /// + /// ```dart + /// final query = firestore.collection('col'); + /// + /// query.orderBy('foo').startAfter(42).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query startAfter(List fieldValues) { + final (startAt, fieldOrders) = _cursorFromValues( + fieldValues: fieldValues, + before: false, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + startAt: startAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that starts after the + /// provided set of field values relative to the order of the query. The order + /// of the provided values must match the order of the order by clauses of the + /// query. + /// + /// - [snapshot]: The snapshot of the document the query results + /// should start at, in order of the query's order by. + Query startAfterDocument(DocumentSnapshot snapshot) { + final (startAt, fieldOrders) = _cursorFromValues( + snapshot: snapshot, + before: false, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + startAt: startAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that ends before the set of + /// field values relative to the order of the query. The order of the provided + /// values must match the order of the order by clauses of the query. + /// + /// - [fieldValues]: The field values to + /// end this query before, in order of the query's order by. + /// + /// ```dart + /// final query = firestore.collection('col'); + /// + /// query.orderBy('foo').endBefore(42).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query endBefore(List fieldValues) { + final (endAt, fieldOrders) = _cursorFromValues( + fieldValues: fieldValues, + before: true, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + endAt: endAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that ends before the set of + /// field values relative to the order of the query. The order of the provided + /// values must match the order of the order by clauses of the query. + /// + /// - [fieldValuesOrDocumentSnapshot]: The snapshot + /// of the document the query results should end before. + Query endBeforeDocument(DocumentSnapshot snapshot) { + final (endAt, fieldOrders) = _cursorFromValues( + snapshot: snapshot, + before: true, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + endAt: endAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that ends at the provided + /// set of field values relative to the order of the query. The order of the + /// provided values must match the order of the order by clauses of the query. + /// + /// - [fieldValues]: The field values to end + /// this query at, in order of the query's order by. + /// + /// ```dart + /// final query = firestore.collection('col'); + /// + /// query.orderBy('foo').endAt(42).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query endAt(List fieldValues) { + final (endAt, fieldOrders) = _cursorFromValues( + fieldValues: fieldValues, + before: false, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + endAt: endAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that ends at the provided + /// set of field values relative to the order of the query. The order of the + /// provided values must match the order of the order by clauses of the query. + /// + /// - [snapshot]: The snapshot + /// of the document the query results should end at, in order of the query's order by. + /// ``` + Query endAtDocument(DocumentSnapshot snapshot) { + final (endAt, fieldOrders) = _cursorFromValues( + snapshot: snapshot, + before: false, + ); + + final options = _queryOptions.copyWith( + fieldOrders: fieldOrders, + endAt: endAt, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Executes the query and returns the results as a [QuerySnapshot]. + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 'bar'); + /// + /// query.get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Future> get() => _get(transactionId: null); + + Future> _get({required String? transactionId}) async { + final response = await firestore._client.v1((client) async { + return client.projects.databases.documents.runQuery( + _toProto( + transactionId: transactionId, + readTime: null, + ), + _buildProtoParentPath(), + ); + }); + + Timestamp? readTime; + final snapshots = response + .map((e) { + final document = e.document; + if (document == null) { + readTime = e.readTime.let(Timestamp._fromString); + return null; + } + + final snapshot = DocumentSnapshot._fromDocument( + document, + e.readTime, + firestore, + ); + final finalDoc = _DocumentSnapshotBuilder( + snapshot.ref.withConverter( + fromFirestore: _queryOptions.converter.fromFirestore, + toFirestore: _queryOptions.converter.toFirestore, + ), + ) + // Recreate the QueryDocumentSnapshot with the DocumentReference + // containing the original converter. + ..fieldsProto = firestore1.MapValue(fields: document.fields) + ..readTime = snapshot.readTime + ..createTime = snapshot.createTime + ..updateTime = snapshot.updateTime; + + return finalDoc.build(); + }) + .whereNotNull() + // Specifying fieldsProto should cause the builder to create a query snapshot. + .cast>() + .toList(); + + return QuerySnapshot._( + query: this, + readTime: readTime, + docs: snapshots, + ); + } + + String _buildProtoParentPath() { + return _queryOptions.parentPath + ._toQualifiedResourcePath( + firestore.app.projectId, + firestore._databaseId, + ) + ._formattedName; + } + + firestore1.RunQueryRequest _toProto({ + required String? transactionId, + required Timestamp? readTime, + }) { + if (readTime != null && transactionId != null) { + throw ArgumentError( + 'readTime and transactionId cannot both be set.', + ); + } + + final structuredQuery = _toStructuredQuery(); + + // For limitToLast queries, the structured query has to be translated to a version with + // reversed ordered, and flipped startAt/endAt to work properly. + if (this._queryOptions.limitType == LimitType.last) { + if (!this._queryOptions.hasFieldOrders) { + throw ArgumentError( + 'limitToLast() queries require specifying at least one orderBy() clause.', + ); + } + + structuredQuery.orderBy = _queryOptions.fieldOrders.map((order) { + // Flip the orderBy directions since we want the last results + final dir = order.direction == _Direction.descending + ? _Direction.ascending + : _Direction.descending; + return _FieldOrder(fieldPath: order.fieldPath, direction: dir) + ._toProto(); + }).toList(); + + // Swap the cursors to match the now-flipped query ordering. + structuredQuery.startAt = _queryOptions.endAt != null + ? _toCursor( + _QueryCursor( + values: _queryOptions.endAt!.values, + before: !_queryOptions.endAt!.before, + ), + ) + : null; + structuredQuery.endAt = _queryOptions.startAt != null + ? _toCursor( + _QueryCursor( + values: _queryOptions.startAt!.values, + before: !_queryOptions.startAt!.before, + ), + ) + : null; + } + + final runQueryRequest = firestore1.RunQueryRequest( + structuredQuery: structuredQuery, + ); + + if (transactionId != null) { + runQueryRequest.transaction = transactionId; + } else if (readTime != null) { + runQueryRequest.readTime = readTime._toProto().timestampValue; + } + + return runQueryRequest; + } + + firestore1.StructuredQuery _toStructuredQuery() { + final structuredQuery = firestore1.StructuredQuery( + from: [firestore1.CollectionSelector()], + ); + + if (_queryOptions.allDescendants) { + structuredQuery.from![0].allDescendants = true; + } + + // Kindless queries select all descendant documents, so we remove the + // collectionId field. + if (!_queryOptions.kindless) { + structuredQuery.from![0].collectionId = this._queryOptions.collectionId; + } + + if (_queryOptions.filters.isNotEmpty) { + structuredQuery.where = _CompositeFilterInternal( + filters: this._queryOptions.filters, + op: _CompositeOperator.and, + ).toProto(); + } + + if (this._queryOptions.hasFieldOrders) { + structuredQuery.orderBy = + _queryOptions.fieldOrders.map((o) => o._toProto()).toList(); + } + + structuredQuery.startAt = _toCursor(_queryOptions.startAt); + structuredQuery.endAt = _toCursor(_queryOptions.endAt); + + final limit = _queryOptions.limit; + if (limit != null) structuredQuery.limit = limit; + + structuredQuery.offset = _queryOptions.offset; + structuredQuery.select = _queryOptions.projection; + + return structuredQuery; + } + + /// Converts a QueryCursor to its proto representation. + firestore1.Cursor? _toCursor(_QueryCursor? cursor) { + if (cursor == null) return null; + + return cursor.before + ? firestore1.Cursor(before: true, values: cursor.values) + : firestore1.Cursor(values: cursor.values); + } + + // TODO onSnapshot + // TODO stream + + /// {@macro collection_reference.where} + Query where(Object path, WhereFilter op, Object? value) { + final fieldPath = FieldPath.from(path); + return whereFieldPath(fieldPath, op, value); + } + + /// {@template collection_reference.where} + /// Creates and returns a new [Query] with the additional filter + /// that documents must contain the specified field and that its value should + /// satisfy the relation constraint provided. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the filter. + /// + /// - [fieldPath]: The name of a property value to compare. + /// - [op]: A comparison operation in the form of a string. + /// Acceptable operator strings are "<", "<=", "==", "!=", ">=", ">", "array-contains", + /// "in", "not-in", and "array-contains-any". + /// - [value]: The value to which to compare the field for inclusion in + /// a query. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// collectionRef.where('foo', WhereFilter.equal, 'bar').get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + /// {@endtemplate} + Query whereFieldPath( + FieldPath fieldPath, + WhereFilter op, + Object? value, + ) { + return whereFilter(Filter.where(fieldPath, op, value)); + } + + /// Creates and returns a new [Query] with the additional filter + /// that documents should satisfy the relation constraint(s) provided. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the filter. + /// + /// - [filter] A unary or composite filter to apply to the Query. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// + /// collectionRef.where(Filter.and(Filter.where('foo', WhereFilter.equal, 'bar'), Filter.where('foo', WhereFilter.notEqual, 'baz'))).get() + /// .then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query whereFilter(Filter filter) { + if (_queryOptions.startAt != null || _queryOptions.endAt != null) { + throw ArgumentError( + 'Cannot specify a where() filter after calling ' + 'startAt(), startAfter(), endBefore() or endAt().', + ); + } + + final parsedFilter = _parseFilter(filter); + if (parsedFilter.filters.isEmpty) { + // Return the existing query if not adding any more filters (e.g. an empty composite filter). + return this; + } + + final options = _queryOptions.copyWith( + filters: [..._queryOptions.filters, parsedFilter], + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + _FilterInternal _parseFilter(Filter filter) { + switch (filter) { + case _UnaryFilter(): + return _parseFieldFilter(filter); + case _CompositeFilter(): + return _parseCompositeFilter(filter); + } + } + + _FieldFilterInternal _parseFieldFilter(_UnaryFilter fieldFilterData) { + final value = fieldFilterData.value; + final operator = fieldFilterData.op; + final fieldPath = fieldFilterData.fieldPath; + + _validateQueryValue('value', value); + + if (fieldPath == FieldPath.documentId) { + switch (operator) { + case WhereFilter.arrayContains: + case WhereFilter.arrayContainsAny: + throw ArgumentError.value( + operator, + 'op', + "Invalid query. You can't perform '$operator' queries on FieldPath.documentId().", + ); + case WhereFilter.isIn: + case WhereFilter.notIn: + if (value is! List || value.isEmpty) { + throw ArgumentError.value( + value, + 'value', + "Invalid query. A non-empty array is required for '$operator' filters.", + ); + } + for (final item in value) { + if (item is! DocumentReference) { + throw ArgumentError.value( + value, + 'value', + "Invalid query. When querying with '$operator', " + 'you must provide a List of non-empty DocumentReference instances as the argument.', + ); + } + } + default: + if (value is! DocumentReference) { + throw ArgumentError.value( + value, + 'value', + 'Invalid query. When querying by document ID you must provide a ' + 'DocumentReference instance.', + ); + } + } + } + + return _FieldFilterInternal( + serializer: firestore._serializer, + field: fieldPath, + op: operator, + value: value, + ); + } + + _FilterInternal _parseCompositeFilter(_CompositeFilter compositeFilterData) { + final parsedFilters = compositeFilterData.filters + .map(_parseFilter) + .where((filter) => filter.filters.isNotEmpty) + .toList(); + + // For composite filters containing 1 filter, return the only filter. + // For example: AND(FieldFilter1) == FieldFilter1 + if (parsedFilters.length == 1) { + return parsedFilters.single; + } + return _CompositeFilterInternal( + filters: parsedFilters, + op: compositeFilterData.operator == _CompositeOperator.and + ? _CompositeOperator.and + : _CompositeOperator.or, + ); + } + + /// Creates and returns a new [Query] instance that applies a + /// field mask to the result and returns only the specified subset of fields. + /// You can specify a list of field paths to return, or use an empty list to + /// only return the references of matching documents. + /// + /// Queries that contain field masks cannot be listened to via `onSnapshot()` + /// listeners. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the field mask. + /// + /// - [fieldPaths] The field paths to return. + /// + /// ```dart + /// final collectionRef = firestore.collection('col'); + /// final documentRef = collectionRef.doc('doc'); + /// + /// return documentRef.set({x:10, y:5}).then(() { + /// return collectionRef.where('x', '>', 5).select('y').get(); + /// }).then((res) { + /// print('y is ${res.docs[0].get('y')}.'); + /// }); + /// ``` + Query select([List fieldPaths = const []]) { + final fields = [ + if (fieldPaths.isEmpty) + firestore1.FieldReference( + fieldPath: FieldPath.documentId._formattedName, + ) + else + for (final fieldPath in fieldPaths) + firestore1.FieldReference(fieldPath: fieldPath._formattedName), + ]; + + return Query._( + firestore: firestore, + queryOptions: _queryOptions + .copyWith(projection: firestore1.Projection(fields: fields)) + .withConverter( + // By specifying a field mask, the query result no longer conforms to type + // `T`. We there return `Query`. + _jsonConverter, + ), + ); + } + + /// Creates and returns a new [Query] that's additionally sorted + /// by the specified field, optionally in descending order instead of + /// ascending. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the field mask. + /// + /// - [fieldPath]: The field to sort by. + /// - [descending] (false by default) Whether to obtain documents in descending order. + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); + /// + /// query.orderaBy('foo', 'desc').get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query orderByFieldPath( + FieldPath fieldPath, { + bool descending = false, + }) { + if (_queryOptions.startAt != null || _queryOptions.endAt != null) { + throw ArgumentError( + 'Cannot specify an orderBy() constraint after calling ' + 'startAt(), startAfter(), endBefore() or endAt().', + ); + } + + final newOrder = _FieldOrder( + fieldPath: fieldPath, + direction: descending ? _Direction.descending : _Direction.ascending, + ); + + final options = _queryOptions.copyWith( + fieldOrders: [..._queryOptions.fieldOrders, newOrder], + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that's additionally sorted + /// by the specified field, optionally in descending order instead of + /// ascending. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the field mask. + /// + /// - [fieldPath]: The field to sort by. + /// - [descending] (false by default) Whether to obtain documents in descending order. + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); + /// + /// query.orderBy('foo', 'desc').get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query orderBy( + Object path, { + bool descending = false, + }) { + return orderByFieldPath( + FieldPath.from(path), + descending: descending, + ); + } + + /// Creates and returns a new [Query] that only returns the first matching documents. + /// + /// This function returns a new (immutable) instance of the Query (rather than + /// modify the existing instance) to impose the limit. + /// + /// - [limit] The maximum number of items to return. + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); + /// + /// query.limit(1).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query limit(int limit) { + final options = _queryOptions.copyWith( + limit: limit, + limitType: LimitType.first, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Creates and returns a new [Query] that only returns the last matching + /// documents. + /// + /// You must specify at least one [orderBy] clause for limitToLast queries, + /// otherwise an exception will be thrown during execution. + /// + /// Results for limitToLast queries cannot be streamed. + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', '>', 42); + /// + /// query.limitToLast(1).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Last matching document is ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query limitToLast(int limit) { + final options = _queryOptions.copyWith( + limit: limit, + limitType: LimitType.last, + ); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + /// Specifies the offset of the returned results. + /// + /// This function returns a new (immutable) instance of the [Query] + /// (rather than modify the existing instance) to impose the offset. + /// + /// - [offset] The offset to apply to the Query results + /// + /// ```dart + /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); + /// + /// query.limit(10).offset(20).get().then((querySnapshot) { + /// querySnapshot.forEach((documentSnapshot) { + /// print('Found document at ${documentSnapshot.ref.path}'); + /// }); + /// }); + /// ``` + Query offset(int offset) { + final options = _queryOptions.copyWith(offset: offset); + return Query._( + firestore: firestore, + queryOptions: options, + ); + } + + @mustBeOverridden + @override + bool operator ==(Object other) { + return other is Query && + runtimeType == other.runtimeType && + _queryOptions == other._queryOptions; + } + + @override + int get hashCode => Object.hash(runtimeType, _queryOptions); +} + +/// A QuerySnapshot contains zero or more [QueryDocumentSnapshot] objects +/// representing the results of a query. +/// +/// The documents can be accessed as an array via the [docs] property. +@immutable +class QuerySnapshot { + QuerySnapshot._({ + required this.docs, + required this.query, + required this.readTime, + }); + + /// The query used in order to get this [QuerySnapshot]. + final Query query; + + /// The time this query snapshot was obtained. + final Timestamp? readTime; + + /// A list of all the documents in this QuerySnapshot. + final List> docs; + + /// Returns a list of the documents changes since the last snapshot. + /// + /// If this is the first snapshot, all documents will be in the list as added + /// changes. + late final List> docChanges = [ + for (final (index, doc) in docs.indexed) + DocumentChange._( + type: DocumentChangeType.added, + oldIndex: -1, + newIndex: index, + doc: doc, + ), + ]; + + @override + bool operator ==(Object other) { + return other is QuerySnapshot && + runtimeType == other.runtimeType && + query == other.query && + const ListEquality>() + .equals(docs, other.docs) && + const ListEquality>() + .equals(docChanges, other.docChanges); + } + + @override + int get hashCode => Object.hash( + runtimeType, + query, + const ListEquality>().hash(docs), + const ListEquality>().hash(docChanges), + ); +} + +/// Validates that 'value' can be used as a query value. +void _validateQueryValue( + String arg, + Object? value, +) { + _validateUserInput( + arg, + value, + description: 'query constraint', + options: const _ValidateUserInputOptions( + allowDeletes: _AllowDeletes.none, + allowTransform: false, + ), + ); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/serializer.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/serializer.dart new file mode 100644 index 0000000..c6f4d3d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/serializer.dart @@ -0,0 +1,147 @@ +part of 'firestore.dart'; + +/// A type representing the raw Firestore document data. +typedef DocumentData = Map; + +@internal +typedef ApiMapValue = Map; + +abstract class _Serializable { + firestore1.Value _toProto(); +} + +class _Serializer { + _Serializer(this.firestore); + + final Firestore firestore; + + Object _createInteger(String n) { + if (firestore._settings.useBigInt ?? false) { + return BigInt.parse(n); + } else { + return int.parse(n); + } + } + + /// Encodes a Dart object into the Firestore 'Fields' representation. + firestore1.MapValue encodeFields(DocumentData obj) { + return firestore1.MapValue( + fields: obj.map((key, value) { + return MapEntry(key, encodeValue(value)); + }).whereValueNotNull(), + ); + } + + /// Encodes a Dart value into the Firestore 'Value' representation. + firestore1.Value? encodeValue(Object? value) { + switch (value) { + case _FieldTransform(): + return null; + + case String(): + return firestore1.Value(stringValue: value); + + case bool(): + return firestore1.Value(booleanValue: value); + + case int(): + case BigInt(): + return firestore1.Value(integerValue: value.toString()); + + case double(): + return firestore1.Value(doubleValue: value); + + case DateTime(): + final timestamp = Timestamp.fromDate(value); + return timestamp._toProto(); + + case null: + return firestore1.Value( + nullValue: 'NULL_VALUE', + ); + + case _Serializable(): + return value._toProto(); + + case List(): + return firestore1.Value( + arrayValue: firestore1.ArrayValue( + values: value.map(encodeValue).whereNotNull().toList(), + ), + ); + + case Map(): + if (value.isEmpty) { + return firestore1.Value( + mapValue: firestore1.MapValue(fields: {}), + ); + } + + final fields = encodeFields(Map.from(value)); + if (fields.fields!.isEmpty) return null; + + return firestore1.Value(mapValue: fields); + + default: + throw ArgumentError.value( + value, + 'value', + 'Unsupported field value: ${value.runtimeType}', + ); + } + } + + /// Decodes a single Firestore 'Value' Protobuf. + Object? decodeValue(Object? proto) { + if (proto is! firestore1.Value) { + throw ArgumentError.value( + proto, + 'proto', + 'Cannot decode type from Firestore Value: ${proto.runtimeType}', + ); + } + _assertValidProtobufValue(proto); + + switch (proto) { + case firestore1.Value(:final stringValue?): + return stringValue; + case firestore1.Value(:final booleanValue?): + return booleanValue; + case firestore1.Value(:final integerValue?): + return _createInteger(integerValue); + case firestore1.Value(:final doubleValue?): + return doubleValue; + case firestore1.Value(:final timestampValue?): + return Timestamp._fromString(timestampValue); + case firestore1.Value(:final referenceValue?): + final reosucePath = _QualifiedResourcePath.fromSlashSeparatedString( + referenceValue, + ); + return firestore.doc(reosucePath.relativeName); + case firestore1.Value(:final arrayValue?): + final values = arrayValue.values; + return [ + if (values != null) + for (final value in values) decodeValue(value), + ]; + case firestore1.Value(nullValue: != null): + return null; + case firestore1.Value(:final mapValue?): + final fields = mapValue.fields; + return { + if (fields != null) + for (final entry in fields.entries) + entry.key: decodeValue(entry.value), + }; + case firestore1.Value(:final geoPointValue?): + return GeoPoint._fromProto(geoPointValue); + + default: + throw ArgumentError.value( + proto, + 'proto', + 'Cannot decode type from Firestore Value: ${proto.runtimeType}', + ); + } + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/status_code.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/status_code.dart new file mode 100644 index 0000000..b4a30f8 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/status_code.dart @@ -0,0 +1,54 @@ +import 'package:meta/meta.dart'; + +@internal +enum StatusCode { + ok(0), + cancelled(1), + unknown(2), + invalidArgument(3), + deadlineExceeded(4), + notFound(5), + alreadyExists(6), + permissionDenied(7), + resourceExhausted(8), + failedPrecondition(9), + aborted(10), + outOfRange(11), + unimplemented(12), + internal(13), + unavailable(14), + dataLoss(15), + unauthenticated(16); + + const StatusCode(this.value); + + // Imported from https://github.com/googleapis/nodejs-firestore/blob/fba4949be5be8b26720f0fefcf176e549829e382/dev/src/v1/firestore_client_config.json + static const nonIdempotentRetryCodes = []; + static const idempotentRetryCodes = [ + StatusCode.deadlineExceeded, + StatusCode.unavailable, + ]; + + static const deadlineExceededResourceExhaustedInternalUnavailable = + [ + StatusCode.deadlineExceeded, + StatusCode.resourceExhausted, + StatusCode.internal, + StatusCode.unavailable, + ]; + + static const resourceExhaustedUnavailable = [ + StatusCode.resourceExhausted, + StatusCode.unavailable, + ]; + + static const resourceExhaustedAbortedUnavailable = [ + StatusCode.resourceExhausted, + StatusCode.aborted, + StatusCode.unavailable, + ]; + + static const commitRetryCodes = resourceExhaustedUnavailable; + + final int value; +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/timestamp.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/timestamp.dart new file mode 100644 index 0000000..5dcf7c1 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/timestamp.dart @@ -0,0 +1,144 @@ +part of 'firestore.dart'; + +/// Encode seconds+nanoseconds to a Google Firestore timestamp string. +String _toGoogleDateTime({required int seconds, required int nanoseconds}) { + final date = DateTime.fromMillisecondsSinceEpoch(seconds * 1000, isUtc: true); + var formattedDate = DateFormat('yyyy-MM-ddTHH:mm:ss').format(date); + + if (nanoseconds > 0) { + final nanoString = + nanoseconds.toString().padLeft(9, '0'); // Ensure it has 9 digits + formattedDate = '$formattedDate.$nanoString'; + } + + return '${formattedDate}Z'; +} + +@immutable +class Timestamp implements _Serializable { + Timestamp._({required this.seconds, required this.nanoseconds}) { + const minSeconds = -62135596800; + const maxSeconds = 253402300799; + + if (seconds < minSeconds || seconds > maxSeconds) { + throw ArgumentError.value( + seconds, + 'seconds', + 'must be between $minSeconds and $maxSeconds.', + ); + } + + const maxNanoSeconds = 999999999; + if (nanoseconds < 0 || nanoseconds > maxNanoSeconds) { + throw ArgumentError.value( + nanoseconds, + 'nanoseconds', + 'must be between 0 and $maxNanoSeconds.', + ); + } + } + + /// Creates a new timestamp with the current date, with millisecond precision. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.set({'updateTime': Timestamp.now()}); + /// ``` + /// Returns a new `Timestamp` representing the current date. + factory Timestamp.now() => Timestamp.fromDate(DateTime.now()); + + /// Creates a new timestamp from the given date. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// final date = Date.parse('01 Jan 2000 00:00:00 GMT'); + /// documentRef.set({ 'startTime': Timestamp.fromDate(date) }); + /// + /// ``` + /// + /// - [date]: The date to initialize the `Timestamp` from. + /// + /// Returns a new [Timestamp] representing the same point in time + /// as the given date. + factory Timestamp.fromDate(DateTime date) { + return Timestamp.fromMillis(date.millisecondsSinceEpoch); + } + + /// Creates a new timestamp from the given number of milliseconds. + /// + /// ```dart + /// final documentRef = firestore.doc('col/doc'); + /// + /// documentRef.set({ 'startTime': Timestamp.fromMillis(42) }); + /// ``` + /// + /// - [milliseconds]: Number of milliseconds since Unix epoch + /// 1970-01-01T00:00:00Z. + /// + /// Returns a new [Timestamp] representing the same point in time + /// as the given number of milliseconds. + factory Timestamp.fromMillis(int milliseconds) { + final seconds = (milliseconds / 1000).floor(); + final nanos = (milliseconds - seconds * 1000) * _msToNanos; + return Timestamp._(seconds: seconds, nanoseconds: nanos); + } + + factory Timestamp._fromString(String timestampValue) { + final date = DateTime.parse(timestampValue); + var nanos = 0; + + if (timestampValue.length > 20) { + final nanoString = timestampValue.substring( + 20, + timestampValue.length - 1, + ); + final trailingZeroes = 9 - nanoString.length; + nanos = int.parse(nanoString) * (math.pow(10, trailingZeroes).toInt()); + } + + if (nanos.isNaN || date.second.isNaN) { + throw ArgumentError.value( + timestampValue, + 'timestampValue', + 'Specify a valid ISO 8601 timestamp.', + ); + } + + return Timestamp._( + seconds: date.millisecondsSinceEpoch ~/ 1000, + nanoseconds: nanos, + ); + } + + static const _msToNanos = 1000000; + + final int seconds; + final int nanoseconds; + + @override + firestore1.Value _toProto() { + return firestore1.Value( + timestampValue: _toGoogleDateTime( + seconds: seconds, + nanoseconds: nanoseconds, + ), + ); + } + + @override + bool operator ==(Object other) { + return other is Timestamp && + seconds == other.seconds && + nanoseconds == other.nanoseconds; + } + + @override + int get hashCode => Object.hash(seconds, nanoseconds); + + @override + String toString() { + return 'Timestamp(seconds=$seconds, nanoseconds=$nanoseconds)'; + } +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart new file mode 100644 index 0000000..3e96b66 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/transaction.dart @@ -0,0 +1,17 @@ +part of 'firestore.dart'; + +class ReadOptions { + ReadOptions({this.fieldMask}); + + /// Specifies the set of fields to return and reduces the amount of data + /// transmitted by the backend. + /// + /// Adding a field mask does not filter results. Documents do not need to + /// contain values for all the fields in the mask to be part of the result + /// set. + final List? fieldMask; +} + +List? _parseFieldMask(ReadOptions? readOptions) { + return readOptions?.fieldMask?.map(FieldPath.fromArgument).toList(); +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/types.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/types.dart new file mode 100644 index 0000000..1243b48 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/types.dart @@ -0,0 +1,24 @@ +part of 'firestore.dart'; + +typedef UpdateMap = Map; + +typedef FromFirestore = T Function( + QueryDocumentSnapshot value, +); +typedef ToFirestore = DocumentData Function(T value); + +DocumentData _jsonFromFirestore(QueryDocumentSnapshot value) { + return value.data(); +} + +DocumentData _jsonToFirestore(DocumentData value) => value; + +const _FirestoreDataConverter _jsonConverter = ( + fromFirestore: _jsonFromFirestore, + toFirestore: _jsonToFirestore, +); + +typedef _FirestoreDataConverter = ({ + FromFirestore fromFirestore, + ToFirestore toFirestore, +}); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/util.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/util.dart new file mode 100644 index 0000000..4a71b24 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/util.dart @@ -0,0 +1,54 @@ +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; + +@internal +extension MapWhereValue on Map { + Map whereValueNotNull() { + return Map.fromEntries( + entries + .where((e) => e.value != null) + // ignore: null_check_on_nullable_type_parameter + .map((e) => MapEntry(e.key, e.value!)), + ); + } +} + +@internal +Uint8List randomBytes(int length) { + final rnd = Random.secure(); + return Uint8List.fromList( + List.generate(length, (i) => rnd.nextInt(256)), + ); +} + +/// Generate a unique client-side identifier. +/// +/// Used for the creation of new documents. +/// Returns a unique 20-character wide identifier. +@internal +String autoId() { + const chars = + 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + var autoId = ''; + while (autoId.length < 20) { + final bytes = randomBytes(40); + for (final b in bytes) { + // Length of `chars` is 62. We only take bytes between 0 and 62*4-1 + // (both inclusive). The value is then evenly mapped to indices of `char` + // via a modulo operation. + const maxValue = 62 * 4 - 1; + if (autoId.length < 20 && b <= maxValue) { + autoId += chars[b % 62]; + } + } + } + return autoId; +} + +/// Generate a short and semi-random client-side identifier. +/// +/// Used for the creation of request tags. +@internal +String requestTag() => autoId().substring(0, 5); diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/validate.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/validate.dart new file mode 100644 index 0000000..70daa5d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/validate.dart @@ -0,0 +1,26 @@ +import 'package:meta/meta.dart'; + +/// Validates that 'value' is a host. +@internal +void validateHost( + String value, { + required String argName, +}) { + final urlString = 'http://$value/'; + Uri parsed; + try { + parsed = Uri.parse(urlString); + } catch (e) { + throw ArgumentError.value(value, argName, 'Must be a valid host'); + } + + if (parsed.query.isNotEmpty || + parsed.path != '/' || + parsed.userName.isNotEmpty) { + throw ArgumentError.value(value, argName, 'Must be a valid host'); + } +} + +extension on Uri { + String get userName => userInfo.split(':').first; +} diff --git a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart new file mode 100644 index 0000000..4cfda6d --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/write_batch.dart @@ -0,0 +1,296 @@ +part of 'firestore.dart'; + +/// A WriteResult wraps the write time set by the Firestore servers on sets(), +/// updates(), and creates(). +@immutable +class WriteResult { + const WriteResult._(this.writeTime); + + /// The write time as set by the Firestore servers. + final Timestamp writeTime; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is WriteResult && writeTime == other.writeTime; + } + + @override + int get hashCode => writeTime.hashCode; +} + +// ignore: avoid_private_typedef_functions +typedef _PendingWriteOp = firestore1.Write Function(); + +/// A Firestore WriteBatch that can be used to atomically commit multiple write +/// operations at once. +class WriteBatch { + WriteBatch._(this.firestore); + + final Firestore firestore; + var _commited = false; + final _operations = <({String docPath, _PendingWriteOp op})>[]; + + /// Create a document with the provided object values. This will fail the batch + /// if a document exists at its location. + /// + /// - [documentRef]: A reference to the document to be created. + /// - [data] The object to serialize as the document. + /// + /// Throws if the provided input is not a valid Firestore document. + /// + /// ```dart + /// final writeBatch = firestore.batch(); + /// final documentRef = firestore.collection('col').doc(); + /// + /// writeBatch.create(documentRef, {foo: 'bar'}); + /// + /// writeBatch.commit().then(() { + /// print('Successfully executed batch.'); + /// }); + /// ``` + void create(DocumentReference ref, T data) { + final firestoreData = ref._converter.toFirestore(data); + _validateDocumentData('data', firestoreData, allowDeletes: false); + + _verifyNotCommited(); + + final transform = _DocumentTransform.fromObject(ref, firestoreData); + transform.validate(); + + final precondition = Precondition.exists(false); + + firestore1.Write op() { + final document = DocumentSnapshot._fromObject(ref, firestoreData); + final write = document._toWriteProto(); + if (transform.transforms.isNotEmpty) { + write.updateTransforms = transform.toProto(firestore._serializer); + } + write.currentDocument = precondition._toProto(); + return write; + } + + _operations.add((docPath: ref.path, op: op)); + } + + /// Atomically commits all pending operations to the database and verifies all + /// preconditions. Fails the entire write if any precondition is not met. + /// + /// Returns a future that resolves when this batch completes. + /// + /// ```dart + /// final writeBatch = firestore.batch(); + /// final documentRef = firestore.doc('col/doc'); + /// + /// writeBatch.set(documentRef, {foo: 'bar'}); + /// + /// writeBatch.commit().then(() { + /// console.log('Successfully executed batch.'); + /// }); + /// ``` + Future> commit() async { + final response = await _commit(transactionId: null); + + return [ + for (final writeResult + in response.writeResults ?? []) + WriteResult._( + Timestamp._fromString( + writeResult.updateTime ?? response.commitTime!, + ), + ), + ]; + } + + Future _commit({ + required String? transactionId, + }) async { + _commited = true; + + final request = firestore1.CommitRequest( + transaction: transactionId, + writes: _operations.map((op) => op.op()).toList(), + ); + + return firestore._client.v1((client) async { + return client.projects.databases.documents.commit( + request, + firestore._formattedDatabaseName, + ); + }); + } + + /// Deletes a document from the database. + /// + /// - [precondition] can be passed to specify custom requirements for the + /// request (e.g. only delete if it was last updated at a given time). + void delete( + DocumentReference documentRef, { + Precondition? precondition, + }) { + _verifyNotCommited(); + + firestore1.Write op() { + final write = firestore1.Write( + delete: documentRef._formattedName, + ); + if (precondition != null && !precondition._isEmpty) { + write.currentDocument = precondition._toProto(); + } + return write; + } + + _operations.add((docPath: documentRef.path, op: op)); + } + + /// Write to the document referred to by the provided + /// [DocumentReference]. If the document does not + /// exist yet, it will be created. + void set(DocumentReference documentReference, T data) { + final firestoreData = documentReference._converter.toFirestore(data); + + _validateDocumentData( + 'data', + firestoreData, + allowDeletes: false, + ); + + _verifyNotCommited(); + + final transform = + _DocumentTransform.fromObject(documentReference, firestoreData); + transform.validate(); + + firestore1.Write op() { + final document = + DocumentSnapshot._fromObject(documentReference, firestoreData); + + final write = document._toWriteProto(); + if (transform.transforms.isNotEmpty) { + write.updateTransforms = transform.toProto(firestore._serializer); + } + return write; + } + + _operations.add((docPath: documentReference.path, op: op)); + } + + /// Update fields of the document referred to by the provided + /// [DocumentReference]. If the document doesn't yet exist, + /// the update fails and the entire batch will be rejected. + // TODO support update(ref, List<(FieldPath, value)>) + void update( + DocumentReference documentRef, + UpdateMap data, { + Precondition? precondition, + }) { + _update( + data: data, + documentRef: documentRef, + precondition: precondition, + ); + } + + void _update({ + required UpdateMap data, + required DocumentReference documentRef, + required Precondition? precondition, + }) { + _verifyNotCommited(); + _validateUpdateMap('data', data); + + precondition ??= Precondition.exists(true); + + _validateNoConflictingFields('data', data); + + final transform = _DocumentTransform.fromUpdateMap(documentRef, data); + transform.validate(); + + final documentMask = _DocumentMask.fromUpdateMap(data); + + firestore1.Write op() { + final document = DocumentSnapshot.fromUpdateMap(documentRef, data); + final write = document._toWriteProto(); + write.updateMask = documentMask.toProto(); + if (transform.transforms.isNotEmpty) { + write.updateTransforms = transform.toProto(firestore._serializer); + } + write.currentDocument = precondition!._toProto(); + return write; + } + + _operations.add((docPath: documentRef.path, op: op)); + } + + void _verifyNotCommited() { + if (_commited) { + throw StateError('Cannot modify a WriteBatch that has been committed.'); + } + } +} + +/// Validates that the update data does not contain any ambiguous field +/// definitions (such as 'a.b' and 'a'). +void _validateNoConflictingFields(String arg, Map data) { + final fields = data.keys.sorted((left, right) => left.compareTo(right)); + + for (var i = 1; i < fields.length; i++) { + if (fields[i - 1]._isPrefixOf(fields[i])) { + throw ArgumentError.value( + data, + arg, + 'Field "${fields[i - 1]._formattedName}" was specified multiple times.', + ); + } + } +} + +void _validateUpdateMap(String arg, UpdateMap obj) { + if (obj.isEmpty) { + throw ArgumentError.value(obj, arg, 'At least one field must be updated.'); + } + + _validateFieldValue(arg, obj); +} + +void _validateFieldValue( + String arg, + UpdateMap obj, { + FieldPath? path, +}) { + _validateUserInput( + arg, + obj, + description: 'Firestore value', + options: const _ValidateUserInputOptions( + allowDeletes: _AllowDeletes.root, + allowTransform: true, + ), + path: path, + ); +} + +void _validateDocumentData( + String arg, + Object? obj, { + required bool allowDeletes, +}) { + if (obj is! DocumentData) { + throw ArgumentError.value( + obj, + arg, + 'Value for argument "$arg" is not a valid Firestore document. Input ' + 'is not a plain JavaScript object.', + ); + } + + _validateUserInput( + arg, + obj, + description: 'Firestore document', + options: _ValidateUserInputOptions( + allowDeletes: allowDeletes ? _AllowDeletes.all : _AllowDeletes.none, + allowTransform: true, + ), + ); +} diff --git a/packages/dart_firebase_admin/lib/src/object_utils.dart b/packages/dart_firebase_admin/lib/src/object_utils.dart new file mode 100644 index 0000000..7fcdbaa --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/object_utils.dart @@ -0,0 +1,8 @@ +extension ObjectUtils on T? { + T orThrow(Never Function() thrower) => this ?? thrower(); + + R? let(R Function(T) block) { + final that = this; + return that == null ? null : block(that); + } +} diff --git a/packages/dart_firebase_admin/lib/src/utils/error.dart b/packages/dart_firebase_admin/lib/src/utils/error.dart new file mode 100644 index 0000000..7d3cd1e --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/error.dart @@ -0,0 +1,6 @@ +class ErrorInfo { + ErrorInfo({required this.code, required this.message}); + + final String code; + final String message; +} diff --git a/packages/dart_firebase_admin/lib/src/utils/error.ts b/packages/dart_firebase_admin/lib/src/utils/error.ts new file mode 100644 index 0000000..aba1b16 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/error.ts @@ -0,0 +1,1080 @@ +/*! + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseError as FirebaseErrorInterface } from '../app'; +import { deepCopy } from '../utils/deep-copy'; + +/** + * Defines error info type. This includes a code and message string. + */ +export interface ErrorInfo { + code: string; + message: string; +} + +/** + * Defines a type that stores all server to client codes (string enum). + */ +interface ServerToClientCode { + [code: string]: string; +} + +/** + * Firebase error code structure. This extends Error. + * + * @param errorInfo - The error information (code and message). + * @constructor + */ +export class FirebaseError extends Error implements FirebaseErrorInterface { + constructor(private errorInfo: ErrorInfo) { + super(errorInfo.message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseError.prototype; + } + + /** @returns The error code. */ + public get code(): string { + return this.errorInfo.code; + } + + /** @returns The error message. */ + public get message(): string { + return this.errorInfo.message; + } + + /** @returns The object representation of the error. */ + public toJSON(): object { + return { + code: this.code, + message: this.message, + }; + } +} + +/** + * A FirebaseError with a prefix in front of the error code. + * + * @param codePrefix - The prefix to apply to the error code. + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class PrefixedFirebaseError extends FirebaseError { + constructor(private codePrefix: string, code: string, message: string) { + super({ + code: `${codePrefix}/${code}`, + message, + }); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = PrefixedFirebaseError.prototype; + } + + /** + * Allows the error type to be checked without needing to know implementation details + * of the code prefixing. + * + * @param code - The non-prefixed error code to test against. + * @returns True if the code matches, false otherwise. + */ + public hasCode(code: string): boolean { + return `${this.codePrefix}/${code}` === this.code; + } +} + +/** + * Firebase App error code structure. This extends PrefixedFirebaseError. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class FirebaseAppError extends PrefixedFirebaseError { + constructor(code: string, message: string) { + super('app', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseAppError.prototype; + } +} + +/** + * Firebase Auth error code structure. This extends PrefixedFirebaseError. + * + * @param info - The error code info. + * @param [message] The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseAuthError extends PrefixedFirebaseError { + /** + * Creates the developer-facing error corresponding to the backend error code. + * + * @param serverErrorCode - The server error code. + * @param [message] The error message. The default message is used + * if not provided. + * @param [rawServerResponse] The error's raw server response. + * @returns The corresponding developer-facing error. + */ + public static fromServerError( + serverErrorCode: string, + message?: string, + rawServerResponse?: object, + ): FirebaseAuthError { + // serverErrorCode could contain additional details: + // ERROR_CODE : Detailed message which can also contain colons + const colonSeparator = (serverErrorCode || '').indexOf(':'); + let customMessage = null; + if (colonSeparator !== -1) { + customMessage = serverErrorCode.substring(colonSeparator + 1).trim(); + serverErrorCode = serverErrorCode.substring(0, colonSeparator).trim(); + } + // If not found, default to internal error. + const clientCodeKey = AUTH_SERVER_TO_CLIENT_CODE[serverErrorCode] || 'INTERNAL_ERROR'; + const error: ErrorInfo = deepCopy((AuthClientErrorCode as any)[clientCodeKey]); + // Server detailed message should have highest priority. + error.message = customMessage || message || error.message; + + if (clientCodeKey === 'INTERNAL_ERROR' && typeof rawServerResponse !== 'undefined') { + try { + error.message += ` Raw server response: "${ JSON.stringify(rawServerResponse) }"`; + } catch (e) { + // Ignore JSON parsing error. + } + } + + return new FirebaseAuthError(error); + } + + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super('auth', info.code, message || info.message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseAuthError.prototype; + } +} + +/** + * Firebase Database error code structure. This extends FirebaseError. + * + * @param info - The error code info. + * @param [message] The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseDatabaseError extends FirebaseError { + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super({ code: 'database/' + info.code, message: message || info.message }); + } +} + +/** + * Firebase Firestore error code structure. This extends FirebaseError. + * + * @param info - The error code info. + * @param [message] The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseFirestoreError extends FirebaseError { + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super({ code: 'firestore/' + info.code, message: message || info.message }); + } +} + +/** + * Firebase instance ID error code structure. This extends FirebaseError. + * + * @param info - The error code info. + * @param [message] The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseInstanceIdError extends FirebaseError { + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super({ code: 'instance-id/' + info.code, message: message || info.message }); + (this as any).__proto__ = FirebaseInstanceIdError.prototype; + } +} + +/** + * Firebase Installations service error code structure. This extends `FirebaseError`. + * + * @param info - The error code info. + * @param message - The error message. This will override the default + * message if provided. + * @constructor + */ +export class FirebaseInstallationsError extends FirebaseError { + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super({ code: 'installations/' + info.code, message: message || info.message }); + (this as any).__proto__ = FirebaseInstallationsError.prototype; + } +} + + +/** + * Firebase Messaging error code structure. This extends PrefixedFirebaseError. + * + * @param info - The error code info. + * @param [message] The error message. This will override the default message if provided. + * @constructor + */ +export class FirebaseMessagingError extends PrefixedFirebaseError { + /** + * Creates the developer-facing error corresponding to the backend error code. + * + * @param serverErrorCode - The server error code. + * @param [message] The error message. The default message is used + * if not provided. + * @param [rawServerResponse] The error's raw server response. + * @returns The corresponding developer-facing error. + */ + public static fromServerError( + serverErrorCode: string | null, + message?: string | null, + rawServerResponse?: object, + ): FirebaseMessagingError { + // If not found, default to unknown error. + let clientCodeKey = 'UNKNOWN_ERROR'; + if (serverErrorCode && serverErrorCode in MESSAGING_SERVER_TO_CLIENT_CODE) { + clientCodeKey = MESSAGING_SERVER_TO_CLIENT_CODE[serverErrorCode]; + } + const error: ErrorInfo = deepCopy((MessagingClientErrorCode as any)[clientCodeKey]); + error.message = message || error.message; + + if (clientCodeKey === 'UNKNOWN_ERROR' && typeof rawServerResponse !== 'undefined') { + try { + error.message += ` Raw server response: "${ JSON.stringify(rawServerResponse) }"`; + } catch (e) { + // Ignore JSON parsing error. + } + } + + return new FirebaseMessagingError(error); + } + + public static fromTopicManagementServerError( + serverErrorCode: string, + message?: string, + rawServerResponse?: object, + ): FirebaseMessagingError { + // If not found, default to unknown error. + const clientCodeKey = TOPIC_MGT_SERVER_TO_CLIENT_CODE[serverErrorCode] || 'UNKNOWN_ERROR'; + const error: ErrorInfo = deepCopy((MessagingClientErrorCode as any)[clientCodeKey]); + error.message = message || error.message; + + if (clientCodeKey === 'UNKNOWN_ERROR' && typeof rawServerResponse !== 'undefined') { + try { + error.message += ` Raw server response: "${ JSON.stringify(rawServerResponse) }"`; + } catch (e) { + // Ignore JSON parsing error. + } + } + + return new FirebaseMessagingError(error); + } + + constructor(info: ErrorInfo, message?: string) { + // Override default message if custom message provided. + super('messaging', info.code, message || info.message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseMessagingError.prototype; + } +} + +/** + * Firebase project management error code structure. This extends PrefixedFirebaseError. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class FirebaseProjectManagementError extends PrefixedFirebaseError { + constructor(code: ProjectManagementErrorCode, message: string) { + super('project-management', code, message); + + /* tslint:disable:max-line-length */ + // Set the prototype explicitly. See the following link for more details: + // https://github.com/Microsoft/TypeScript/wiki/Breaking-Changes#extending-built-ins-like-error-array-and-map-may-no-longer-work + /* tslint:enable:max-line-length */ + (this as any).__proto__ = FirebaseProjectManagementError.prototype; + } +} + +/** + * App client error codes and their default messages. + */ +export class AppErrorCodes { + public static APP_DELETED = 'app-deleted'; + public static DUPLICATE_APP = 'duplicate-app'; + public static INVALID_ARGUMENT = 'invalid-argument'; + public static INTERNAL_ERROR = 'internal-error'; + public static INVALID_APP_NAME = 'invalid-app-name'; + public static INVALID_APP_OPTIONS = 'invalid-app-options'; + public static INVALID_CREDENTIAL = 'invalid-credential'; + public static NETWORK_ERROR = 'network-error'; + public static NETWORK_TIMEOUT = 'network-timeout'; + public static NO_APP = 'no-app'; + public static UNABLE_TO_PARSE_RESPONSE = 'unable-to-parse-response'; +} + +/** + * Auth client error codes and their default messages. + */ +export class AuthClientErrorCode { + public static AUTH_BLOCKING_TOKEN_EXPIRED = { + code: 'auth-blocking-token-expired', + message: 'The provided Firebase Auth Blocking token is expired.', + }; + public static BILLING_NOT_ENABLED = { + code: 'billing-not-enabled', + message: 'Feature requires billing to be enabled.', + }; + public static CLAIMS_TOO_LARGE = { + code: 'claims-too-large', + message: 'Developer claims maximum payload size exceeded.', + }; + public static CONFIGURATION_EXISTS = { + code: 'configuration-exists', + message: 'A configuration already exists with the provided identifier.', + }; + public static CONFIGURATION_NOT_FOUND = { + code: 'configuration-not-found', + message: 'There is no configuration corresponding to the provided identifier.', + }; + public static ID_TOKEN_EXPIRED = { + code: 'id-token-expired', + message: 'The provided Firebase ID token is expired.', + }; + public static INVALID_ARGUMENT = { + code: 'argument-error', + message: 'Invalid argument provided.', + }; + public static INVALID_CONFIG = { + code: 'invalid-config', + message: 'The provided configuration is invalid.', + }; + public static EMAIL_ALREADY_EXISTS = { + code: 'email-already-exists', + message: 'The email address is already in use by another account.', + }; + public static EMAIL_NOT_FOUND = { + code: 'email-not-found', + message: 'There is no user record corresponding to the provided email.', + }; + public static FORBIDDEN_CLAIM = { + code: 'reserved-claim', + message: 'The specified developer claim is reserved and cannot be specified.', + }; + public static INVALID_ID_TOKEN = { + code: 'invalid-id-token', + message: 'The provided ID token is not a valid Firebase ID token.', + }; + public static ID_TOKEN_REVOKED = { + code: 'id-token-revoked', + message: 'The Firebase ID token has been revoked.', + }; + public static INTERNAL_ERROR = { + code: 'internal-error', + message: 'An internal error has occurred.', + }; + public static INVALID_CLAIMS = { + code: 'invalid-claims', + message: 'The provided custom claim attributes are invalid.', + }; + public static INVALID_CONTINUE_URI = { + code: 'invalid-continue-uri', + message: 'The continue URL must be a valid URL string.', + }; + public static INVALID_CREATION_TIME = { + code: 'invalid-creation-time', + message: 'The creation time must be a valid UTC date string.', + }; + public static INVALID_CREDENTIAL = { + code: 'invalid-credential', + message: 'Invalid credential object provided.', + }; + public static INVALID_DISABLED_FIELD = { + code: 'invalid-disabled-field', + message: 'The disabled field must be a boolean.', + }; + public static INVALID_DISPLAY_NAME = { + code: 'invalid-display-name', + message: 'The displayName field must be a valid string.', + }; + public static INVALID_DYNAMIC_LINK_DOMAIN = { + code: 'invalid-dynamic-link-domain', + message: 'The provided dynamic link domain is not configured or authorized ' + + 'for the current project.', + }; + public static INVALID_EMAIL_VERIFIED = { + code: 'invalid-email-verified', + message: 'The emailVerified field must be a boolean.', + }; + public static INVALID_EMAIL = { + code: 'invalid-email', + message: 'The email address is improperly formatted.', + }; + public static INVALID_NEW_EMAIL = { + code: 'invalid-new-email', + message: 'The new email address is improperly formatted.', + }; + public static INVALID_ENROLLED_FACTORS = { + code: 'invalid-enrolled-factors', + message: 'The enrolled factors must be a valid array of MultiFactorInfo objects.', + }; + public static INVALID_ENROLLMENT_TIME = { + code: 'invalid-enrollment-time', + message: 'The second factor enrollment time must be a valid UTC date string.', + }; + public static INVALID_HASH_ALGORITHM = { + code: 'invalid-hash-algorithm', + message: 'The hash algorithm must match one of the strings in the list of ' + + 'supported algorithms.', + }; + public static INVALID_HASH_BLOCK_SIZE = { + code: 'invalid-hash-block-size', + message: 'The hash block size must be a valid number.', + }; + public static INVALID_HASH_DERIVED_KEY_LENGTH = { + code: 'invalid-hash-derived-key-length', + message: 'The hash derived key length must be a valid number.', + }; + public static INVALID_HASH_KEY = { + code: 'invalid-hash-key', + message: 'The hash key must a valid byte buffer.', + }; + public static INVALID_HASH_MEMORY_COST = { + code: 'invalid-hash-memory-cost', + message: 'The hash memory cost must be a valid number.', + }; + public static INVALID_HASH_PARALLELIZATION = { + code: 'invalid-hash-parallelization', + message: 'The hash parallelization must be a valid number.', + }; + public static INVALID_HASH_ROUNDS = { + code: 'invalid-hash-rounds', + message: 'The hash rounds must be a valid number.', + }; + public static INVALID_HASH_SALT_SEPARATOR = { + code: 'invalid-hash-salt-separator', + message: 'The hashing algorithm salt separator field must be a valid byte buffer.', + }; + public static INVALID_LAST_SIGN_IN_TIME = { + code: 'invalid-last-sign-in-time', + message: 'The last sign-in time must be a valid UTC date string.', + }; + public static INVALID_NAME = { + code: 'invalid-name', + message: 'The resource name provided is invalid.', + }; + public static INVALID_OAUTH_CLIENT_ID = { + code: 'invalid-oauth-client-id', + message: 'The provided OAuth client ID is invalid.', + }; + public static INVALID_PAGE_TOKEN = { + code: 'invalid-page-token', + message: 'The page token must be a valid non-empty string.', + }; + public static INVALID_PASSWORD = { + code: 'invalid-password', + message: 'The password must be a string with at least 6 characters.', + }; + public static INVALID_PASSWORD_HASH = { + code: 'invalid-password-hash', + message: 'The password hash must be a valid byte buffer.', + }; + public static INVALID_PASSWORD_SALT = { + code: 'invalid-password-salt', + message: 'The password salt must be a valid byte buffer.', + }; + public static INVALID_PHONE_NUMBER = { + code: 'invalid-phone-number', + message: 'The phone number must be a non-empty E.164 standard compliant identifier ' + + 'string.', + }; + public static INVALID_PHOTO_URL = { + code: 'invalid-photo-url', + message: 'The photoURL field must be a valid URL.', + }; + public static INVALID_PROJECT_ID = { + code: 'invalid-project-id', + message: 'Invalid parent project. Either parent project doesn\'t exist or didn\'t enable multi-tenancy.', + }; + public static INVALID_PROVIDER_DATA = { + code: 'invalid-provider-data', + message: 'The providerData must be a valid array of UserInfo objects.', + }; + public static INVALID_PROVIDER_ID = { + code: 'invalid-provider-id', + message: 'The providerId must be a valid supported provider identifier string.', + }; + public static INVALID_PROVIDER_UID = { + code: 'invalid-provider-uid', + message: 'The providerUid must be a valid provider uid string.', + }; + public static INVALID_OAUTH_RESPONSETYPE = { + code: 'invalid-oauth-responsetype', + message: 'Only exactly one OAuth responseType should be set to true.', + }; + public static INVALID_SESSION_COOKIE_DURATION = { + code: 'invalid-session-cookie-duration', + message: 'The session cookie duration must be a valid number in milliseconds ' + + 'between 5 minutes and 2 weeks.', + }; + public static INVALID_TENANT_ID = { + code: 'invalid-tenant-id', + message: 'The tenant ID must be a valid non-empty string.', + }; + public static INVALID_TENANT_TYPE = { + code: 'invalid-tenant-type', + message: 'Tenant type must be either "full_service" or "lightweight".', + }; + public static INVALID_TESTING_PHONE_NUMBER = { + code: 'invalid-testing-phone-number', + message: 'Invalid testing phone number or invalid test code provided.', + }; + public static INVALID_UID = { + code: 'invalid-uid', + message: 'The uid must be a non-empty string with at most 128 characters.', + }; + public static INVALID_USER_IMPORT = { + code: 'invalid-user-import', + message: 'The user record to import is invalid.', + }; + public static INVALID_TOKENS_VALID_AFTER_TIME = { + code: 'invalid-tokens-valid-after-time', + message: 'The tokensValidAfterTime must be a valid UTC number in seconds.', + }; + public static MISMATCHING_TENANT_ID = { + code: 'mismatching-tenant-id', + message: 'User tenant ID does not match with the current TenantAwareAuth tenant ID.', + }; + public static MISSING_ANDROID_PACKAGE_NAME = { + code: 'missing-android-pkg-name', + message: 'An Android Package Name must be provided if the Android App is ' + + 'required to be installed.', + }; + public static MISSING_CONFIG = { + code: 'missing-config', + message: 'The provided configuration is missing required attributes.', + }; + public static MISSING_CONTINUE_URI = { + code: 'missing-continue-uri', + message: 'A valid continue URL must be provided in the request.', + }; + public static MISSING_DISPLAY_NAME = { + code: 'missing-display-name', + message: 'The resource being created or edited is missing a valid display name.', + }; + public static MISSING_EMAIL = { + code: 'missing-email', + message: 'The email is required for the specified action. For example, a multi-factor user ' + + 'requires a verified email.', + }; + public static MISSING_IOS_BUNDLE_ID = { + code: 'missing-ios-bundle-id', + message: 'The request is missing an iOS Bundle ID.', + }; + public static MISSING_ISSUER = { + code: 'missing-issuer', + message: 'The OAuth/OIDC configuration issuer must not be empty.', + }; + public static MISSING_HASH_ALGORITHM = { + code: 'missing-hash-algorithm', + message: 'Importing users with password hashes requires that the hashing ' + + 'algorithm and its parameters be provided.', + }; + public static MISSING_OAUTH_CLIENT_ID = { + code: 'missing-oauth-client-id', + message: 'The OAuth/OIDC configuration client ID must not be empty.', + }; + public static MISSING_OAUTH_CLIENT_SECRET = { + code: 'missing-oauth-client-secret', + message: 'The OAuth configuration client secret is required to enable OIDC code flow.', + }; + public static MISSING_PROVIDER_ID = { + code: 'missing-provider-id', + message: 'A valid provider ID must be provided in the request.', + }; + public static MISSING_SAML_RELYING_PARTY_CONFIG = { + code: 'missing-saml-relying-party-config', + message: 'The SAML configuration provided is missing a relying party configuration.', + }; + public static MAXIMUM_TEST_PHONE_NUMBER_EXCEEDED = { + code: 'test-phone-number-limit-exceeded', + message: 'The maximum allowed number of test phone number / code pairs has been exceeded.', + }; + public static MAXIMUM_USER_COUNT_EXCEEDED = { + code: 'maximum-user-count-exceeded', + message: 'The maximum allowed number of users to import has been exceeded.', + }; + public static MISSING_UID = { + code: 'missing-uid', + message: 'A uid identifier is required for the current operation.', + }; + public static OPERATION_NOT_ALLOWED = { + code: 'operation-not-allowed', + message: 'The given sign-in provider is disabled for this Firebase project. ' + + 'Enable it in the Firebase console, under the sign-in method tab of the ' + + 'Auth section.', + }; + public static PHONE_NUMBER_ALREADY_EXISTS = { + code: 'phone-number-already-exists', + message: 'The user with the provided phone number already exists.', + }; + public static PROJECT_NOT_FOUND = { + code: 'project-not-found', + message: 'No Firebase project was found for the provided credential.', + }; + public static INSUFFICIENT_PERMISSION = { + code: 'insufficient-permission', + message: 'Credential implementation provided to initializeApp() via the "credential" property ' + + 'has insufficient permission to access the requested resource. See ' + + 'https://firebase.google.com/docs/admin/setup for details on how to authenticate this SDK ' + + 'with appropriate permissions.', + }; + public static QUOTA_EXCEEDED = { + code: 'quota-exceeded', + message: 'The project quota for the specified operation has been exceeded.', + }; + public static SECOND_FACTOR_LIMIT_EXCEEDED = { + code: 'second-factor-limit-exceeded', + message: 'The maximum number of allowed second factors on a user has been exceeded.', + }; + public static SECOND_FACTOR_UID_ALREADY_EXISTS = { + code: 'second-factor-uid-already-exists', + message: 'The specified second factor "uid" already exists.', + }; + public static SESSION_COOKIE_EXPIRED = { + code: 'session-cookie-expired', + message: 'The Firebase session cookie is expired.', + }; + public static SESSION_COOKIE_REVOKED = { + code: 'session-cookie-revoked', + message: 'The Firebase session cookie has been revoked.', + }; + public static TENANT_NOT_FOUND = { + code: 'tenant-not-found', + message: 'There is no tenant corresponding to the provided identifier.', + }; + public static UID_ALREADY_EXISTS = { + code: 'uid-already-exists', + message: 'The user with the provided uid already exists.', + }; + public static UNAUTHORIZED_DOMAIN = { + code: 'unauthorized-continue-uri', + message: 'The domain of the continue URL is not whitelisted. Whitelist the domain in the ' + + 'Firebase console.', + }; + public static UNSUPPORTED_FIRST_FACTOR = { + code: 'unsupported-first-factor', + message: 'A multi-factor user requires a supported first factor.', + }; + public static UNSUPPORTED_SECOND_FACTOR = { + code: 'unsupported-second-factor', + message: 'The request specified an unsupported type of second factor.', + }; + public static UNSUPPORTED_TENANT_OPERATION = { + code: 'unsupported-tenant-operation', + message: 'This operation is not supported in a multi-tenant context.', + }; + public static UNVERIFIED_EMAIL = { + code: 'unverified-email', + message: 'A verified email is required for the specified action. For example, a multi-factor user ' + + 'requires a verified email.', + }; + public static USER_NOT_FOUND = { + code: 'user-not-found', + message: 'There is no user record corresponding to the provided identifier.', + }; + public static NOT_FOUND = { + code: 'not-found', + message: 'The requested resource was not found.', + }; + public static USER_DISABLED = { + code: 'user-disabled', + message: 'The user record is disabled.', + } + public static USER_NOT_DISABLED = { + code: 'user-not-disabled', + message: 'The user must be disabled in order to bulk delete it (or you must pass force=true).', + }; + public static INVALID_RECAPTCHA_ACTION = { + code: 'invalid-recaptcha-action', + message: 'reCAPTCHA action must be "BLOCK".' + } + public static INVALID_RECAPTCHA_ENFORCEMENT_STATE = { + code: 'invalid-recaptcha-enforcement-state', + message: 'reCAPTCHA enforcement state must be either "OFF", "AUDIT" or "ENFORCE".' + } + public static RECAPTCHA_NOT_ENABLED = { + code: 'racaptcha-not-enabled', + message: 'reCAPTCHA enterprise is not enabled.' + } +} + +/** + * Messaging client error codes and their default messages. + */ +export class MessagingClientErrorCode { + public static INVALID_ARGUMENT = { + code: 'invalid-argument', + message: 'Invalid argument provided.', + }; + public static INVALID_RECIPIENT = { + code: 'invalid-recipient', + message: 'Invalid message recipient provided.', + }; + public static INVALID_PAYLOAD = { + code: 'invalid-payload', + message: 'Invalid message payload provided.', + }; + public static INVALID_DATA_PAYLOAD_KEY = { + code: 'invalid-data-payload-key', + message: 'The data message payload contains an invalid key. See the reference documentation ' + + 'for the DataMessagePayload type for restricted keys.', + }; + public static PAYLOAD_SIZE_LIMIT_EXCEEDED = { + code: 'payload-size-limit-exceeded', + message: 'The provided message payload exceeds the FCM size limits. See the error documentation ' + + 'for more details.', + }; + public static INVALID_OPTIONS = { + code: 'invalid-options', + message: 'Invalid message options provided.', + }; + public static INVALID_REGISTRATION_TOKEN = { + code: 'invalid-registration-token', + message: 'Invalid registration token provided. Make sure it matches the registration token ' + + 'the client app receives from registering with FCM.', + }; + public static REGISTRATION_TOKEN_NOT_REGISTERED = { + code: 'registration-token-not-registered', + message: 'The provided registration token is not registered. A previously valid registration ' + + 'token can be unregistered for a variety of reasons. See the error documentation for more ' + + 'details. Remove this registration token and stop using it to send messages.', + }; + public static MISMATCHED_CREDENTIAL = { + code: 'mismatched-credential', + message: 'The credential used to authenticate this SDK does not have permission to send ' + + 'messages to the device corresponding to the provided registration token. Make sure the ' + + 'credential and registration token both belong to the same Firebase project.', + }; + public static INVALID_PACKAGE_NAME = { + code: 'invalid-package-name', + message: 'The message was addressed to a registration token whose package name does not match ' + + 'the provided "restrictedPackageName" option.', + }; + public static DEVICE_MESSAGE_RATE_EXCEEDED = { + code: 'device-message-rate-exceeded', + message: 'The rate of messages to a particular device is too high. Reduce the number of ' + + 'messages sent to this device and do not immediately retry sending to this device.', + }; + public static TOPICS_MESSAGE_RATE_EXCEEDED = { + code: 'topics-message-rate-exceeded', + message: 'The rate of messages to subscribers to a particular topic is too high. Reduce the ' + + 'number of messages sent for this topic, and do not immediately retry sending to this topic.', + }; + public static MESSAGE_RATE_EXCEEDED = { + code: 'message-rate-exceeded', + message: 'Sending limit exceeded for the message target.', + }; + public static THIRD_PARTY_AUTH_ERROR = { + code: 'third-party-auth-error', + message: 'A message targeted to an iOS device could not be sent because the required APNs ' + + 'SSL certificate was not uploaded or has expired. Check the validity of your development ' + + 'and production certificates.', + }; + public static TOO_MANY_TOPICS = { + code: 'too-many-topics', + message: 'The maximum number of topics the provided registration token can be subscribed to ' + + 'has been exceeded.', + }; + public static AUTHENTICATION_ERROR = { + code: 'authentication-error', + message: 'An error occurred when trying to authenticate to the FCM servers. Make sure the ' + + 'credential used to authenticate this SDK has the proper permissions. See ' + + 'https://firebase.google.com/docs/admin/setup for setup instructions.', + }; + public static SERVER_UNAVAILABLE = { + code: 'server-unavailable', + message: 'The FCM server could not process the request in time. See the error documentation ' + + 'for more details.', + }; + public static INTERNAL_ERROR = { + code: 'internal-error', + message: 'An internal error has occurred. Please retry the request.', + }; + public static UNKNOWN_ERROR = { + code: 'unknown-error', + message: 'An unknown server error was returned.', + }; +} + +export class InstallationsClientErrorCode { + public static INVALID_ARGUMENT = { + code: 'invalid-argument', + message: 'Invalid argument provided.', + }; + public static INVALID_PROJECT_ID = { + code: 'invalid-project-id', + message: 'Invalid project ID provided.', + }; + public static INVALID_INSTALLATION_ID = { + code: 'invalid-installation-id', + message: 'Invalid installation ID provided.', + }; + public static API_ERROR = { + code: 'api-error', + message: 'Installation ID API call failed.', + }; +} + +export class InstanceIdClientErrorCode extends InstallationsClientErrorCode { + public static INVALID_INSTANCE_ID = { + code: 'invalid-instance-id', + message: 'Invalid instance ID provided.', + }; +} + +export type ProjectManagementErrorCode = + 'already-exists' + | 'authentication-error' + | 'internal-error' + | 'invalid-argument' + | 'invalid-project-id' + | 'invalid-server-response' + | 'not-found' + | 'service-unavailable' + | 'unknown-error'; + +/** @const {ServerToClientCode} Auth server to client enum error codes. */ +const AUTH_SERVER_TO_CLIENT_CODE: ServerToClientCode = { + // Feature being configured or used requires a billing account. + BILLING_NOT_ENABLED: 'BILLING_NOT_ENABLED', + // Claims payload is too large. + CLAIMS_TOO_LARGE: 'CLAIMS_TOO_LARGE', + // Configuration being added already exists. + CONFIGURATION_EXISTS: 'CONFIGURATION_EXISTS', + // Configuration not found. + CONFIGURATION_NOT_FOUND: 'CONFIGURATION_NOT_FOUND', + // Provided credential has insufficient permissions. + INSUFFICIENT_PERMISSION: 'INSUFFICIENT_PERMISSION', + // Provided configuration has invalid fields. + INVALID_CONFIG: 'INVALID_CONFIG', + // Provided configuration identifier is invalid. + INVALID_CONFIG_ID: 'INVALID_PROVIDER_ID', + // ActionCodeSettings missing continue URL. + INVALID_CONTINUE_URI: 'INVALID_CONTINUE_URI', + // Dynamic link domain in provided ActionCodeSettings is not authorized. + INVALID_DYNAMIC_LINK_DOMAIN: 'INVALID_DYNAMIC_LINK_DOMAIN', + // uploadAccount provides an email that already exists. + DUPLICATE_EMAIL: 'EMAIL_ALREADY_EXISTS', + // uploadAccount provides a localId that already exists. + DUPLICATE_LOCAL_ID: 'UID_ALREADY_EXISTS', + // Request specified a multi-factor enrollment ID that already exists. + DUPLICATE_MFA_ENROLLMENT_ID: 'SECOND_FACTOR_UID_ALREADY_EXISTS', + // setAccountInfo email already exists. + EMAIL_EXISTS: 'EMAIL_ALREADY_EXISTS', + // /accounts:sendOobCode for password reset when user is not found. + EMAIL_NOT_FOUND: 'EMAIL_NOT_FOUND', + // Reserved claim name. + FORBIDDEN_CLAIM: 'FORBIDDEN_CLAIM', + // Invalid claims provided. + INVALID_CLAIMS: 'INVALID_CLAIMS', + // Invalid session cookie duration. + INVALID_DURATION: 'INVALID_SESSION_COOKIE_DURATION', + // Invalid email provided. + INVALID_EMAIL: 'INVALID_EMAIL', + // Invalid new email provided. + INVALID_NEW_EMAIL: 'INVALID_NEW_EMAIL', + // Invalid tenant display name. This can be thrown on CreateTenant and UpdateTenant. + INVALID_DISPLAY_NAME: 'INVALID_DISPLAY_NAME', + // Invalid ID token provided. + INVALID_ID_TOKEN: 'INVALID_ID_TOKEN', + // Invalid tenant/parent resource name. + INVALID_NAME: 'INVALID_NAME', + // OIDC configuration has an invalid OAuth client ID. + INVALID_OAUTH_CLIENT_ID: 'INVALID_OAUTH_CLIENT_ID', + // Invalid page token. + INVALID_PAGE_SELECTION: 'INVALID_PAGE_TOKEN', + // Invalid phone number. + INVALID_PHONE_NUMBER: 'INVALID_PHONE_NUMBER', + // Invalid agent project. Either agent project doesn't exist or didn't enable multi-tenancy. + INVALID_PROJECT_ID: 'INVALID_PROJECT_ID', + // Invalid provider ID. + INVALID_PROVIDER_ID: 'INVALID_PROVIDER_ID', + // Invalid service account. + INVALID_SERVICE_ACCOUNT: 'INVALID_SERVICE_ACCOUNT', + // Invalid testing phone number. + INVALID_TESTING_PHONE_NUMBER: 'INVALID_TESTING_PHONE_NUMBER', + // Invalid tenant type. + INVALID_TENANT_TYPE: 'INVALID_TENANT_TYPE', + // Missing Android package name. + MISSING_ANDROID_PACKAGE_NAME: 'MISSING_ANDROID_PACKAGE_NAME', + // Missing configuration. + MISSING_CONFIG: 'MISSING_CONFIG', + // Missing configuration identifier. + MISSING_CONFIG_ID: 'MISSING_PROVIDER_ID', + // Missing tenant display name: This can be thrown on CreateTenant and UpdateTenant. + MISSING_DISPLAY_NAME: 'MISSING_DISPLAY_NAME', + // Email is required for the specified action. For example a multi-factor user requires + // a verified email. + MISSING_EMAIL: 'MISSING_EMAIL', + // Missing iOS bundle ID. + MISSING_IOS_BUNDLE_ID: 'MISSING_IOS_BUNDLE_ID', + // Missing OIDC issuer. + MISSING_ISSUER: 'MISSING_ISSUER', + // No localId provided (deleteAccount missing localId). + MISSING_LOCAL_ID: 'MISSING_UID', + // OIDC configuration is missing an OAuth client ID. + MISSING_OAUTH_CLIENT_ID: 'MISSING_OAUTH_CLIENT_ID', + // Missing provider ID. + MISSING_PROVIDER_ID: 'MISSING_PROVIDER_ID', + // Missing SAML RP config. + MISSING_SAML_RELYING_PARTY_CONFIG: 'MISSING_SAML_RELYING_PARTY_CONFIG', + // Empty user list in uploadAccount. + MISSING_USER_ACCOUNT: 'MISSING_UID', + // Password auth disabled in console. + OPERATION_NOT_ALLOWED: 'OPERATION_NOT_ALLOWED', + // Provided credential has insufficient permissions. + PERMISSION_DENIED: 'INSUFFICIENT_PERMISSION', + // Phone number already exists. + PHONE_NUMBER_EXISTS: 'PHONE_NUMBER_ALREADY_EXISTS', + // Project not found. + PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + // In multi-tenancy context: project creation quota exceeded. + QUOTA_EXCEEDED: 'QUOTA_EXCEEDED', + // Currently only 5 second factors can be set on the same user. + SECOND_FACTOR_LIMIT_EXCEEDED: 'SECOND_FACTOR_LIMIT_EXCEEDED', + // Tenant not found. + TENANT_NOT_FOUND: 'TENANT_NOT_FOUND', + // Tenant ID mismatch. + TENANT_ID_MISMATCH: 'MISMATCHING_TENANT_ID', + // Token expired error. + TOKEN_EXPIRED: 'ID_TOKEN_EXPIRED', + // Continue URL provided in ActionCodeSettings has a domain that is not whitelisted. + UNAUTHORIZED_DOMAIN: 'UNAUTHORIZED_DOMAIN', + // A multi-factor user requires a supported first factor. + UNSUPPORTED_FIRST_FACTOR: 'UNSUPPORTED_FIRST_FACTOR', + // The request specified an unsupported type of second factor. + UNSUPPORTED_SECOND_FACTOR: 'UNSUPPORTED_SECOND_FACTOR', + // Operation is not supported in a multi-tenant context. + UNSUPPORTED_TENANT_OPERATION: 'UNSUPPORTED_TENANT_OPERATION', + // A verified email is required for the specified action. For example a multi-factor user + // requires a verified email. + UNVERIFIED_EMAIL: 'UNVERIFIED_EMAIL', + // User on which action is to be performed is not found. + USER_NOT_FOUND: 'USER_NOT_FOUND', + // User record is disabled. + USER_DISABLED: 'USER_DISABLED', + // Password provided is too weak. + WEAK_PASSWORD: 'INVALID_PASSWORD', + // Unrecognized reCAPTCHA action. + INVALID_RECAPTCHA_ACTION: 'INVALID_RECAPTCHA_ACTION', + // Unrecognized reCAPTCHA enforcement state. + INVALID_RECAPTCHA_ENFORCEMENT_STATE: 'INVALID_RECAPTCHA_ENFORCEMENT_STATE', + // reCAPTCHA is not enabled for account defender. + RECAPTCHA_NOT_ENABLED: 'RECAPTCHA_NOT_ENABLED' +}; + +/** @const {ServerToClientCode} Messaging server to client enum error codes. */ +const MESSAGING_SERVER_TO_CLIENT_CODE: ServerToClientCode = { + /* GENERIC ERRORS */ + // Generic invalid message parameter provided. + InvalidParameters: 'INVALID_ARGUMENT', + // Mismatched sender ID. + MismatchSenderId: 'MISMATCHED_CREDENTIAL', + // FCM server unavailable. + Unavailable: 'SERVER_UNAVAILABLE', + // FCM server internal error. + InternalServerError: 'INTERNAL_ERROR', + + /* SEND ERRORS */ + // Invalid registration token format. + InvalidRegistration: 'INVALID_REGISTRATION_TOKEN', + // Registration token is not registered. + NotRegistered: 'REGISTRATION_TOKEN_NOT_REGISTERED', + // Registration token does not match restricted package name. + InvalidPackageName: 'INVALID_PACKAGE_NAME', + // Message payload size limit exceeded. + MessageTooBig: 'PAYLOAD_SIZE_LIMIT_EXCEEDED', + // Invalid key in the data message payload. + InvalidDataKey: 'INVALID_DATA_PAYLOAD_KEY', + // Invalid time to live option. + InvalidTtl: 'INVALID_OPTIONS', + // Device message rate exceeded. + DeviceMessageRateExceeded: 'DEVICE_MESSAGE_RATE_EXCEEDED', + // Topics message rate exceeded. + TopicsMessageRateExceeded: 'TOPICS_MESSAGE_RATE_EXCEEDED', + // Invalid APNs credentials. + InvalidApnsCredential: 'THIRD_PARTY_AUTH_ERROR', + + /* FCM v1 canonical error codes */ + NOT_FOUND: 'REGISTRATION_TOKEN_NOT_REGISTERED', + PERMISSION_DENIED: 'MISMATCHED_CREDENTIAL', + RESOURCE_EXHAUSTED: 'MESSAGE_RATE_EXCEEDED', + UNAUTHENTICATED: 'THIRD_PARTY_AUTH_ERROR', + + /* FCM v1 new error codes */ + APNS_AUTH_ERROR: 'THIRD_PARTY_AUTH_ERROR', + INTERNAL: 'INTERNAL_ERROR', + INVALID_ARGUMENT: 'INVALID_ARGUMENT', + QUOTA_EXCEEDED: 'MESSAGE_RATE_EXCEEDED', + SENDER_ID_MISMATCH: 'MISMATCHED_CREDENTIAL', + THIRD_PARTY_AUTH_ERROR: 'THIRD_PARTY_AUTH_ERROR', + UNAVAILABLE: 'SERVER_UNAVAILABLE', + UNREGISTERED: 'REGISTRATION_TOKEN_NOT_REGISTERED', + UNSPECIFIED_ERROR: 'UNKNOWN_ERROR', +}; + +/** @const {ServerToClientCode} Topic management (IID) server to client enum error codes. */ +const TOPIC_MGT_SERVER_TO_CLIENT_CODE: ServerToClientCode = { + /* TOPIC SUBSCRIPTION MANAGEMENT ERRORS */ + NOT_FOUND: 'REGISTRATION_TOKEN_NOT_REGISTERED', + INVALID_ARGUMENT: 'INVALID_REGISTRATION_TOKEN', + TOO_MANY_TOPICS: 'TOO_MANY_TOPICS', + RESOURCE_EXHAUSTED: 'TOO_MANY_TOPICS', + PERMISSION_DENIED: 'AUTHENTICATION_ERROR', + DEADLINE_EXCEEDED: 'SERVER_UNAVAILABLE', + INTERNAL: 'INTERNAL_ERROR', + UNKNOWN: 'UNKNOWN_ERROR', +}; \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.dart b/packages/dart_firebase_admin/lib/src/utils/jwt.dart new file mode 100644 index 0000000..ad94f32 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/jwt.dart @@ -0,0 +1,86 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +abstract class SignatureVerifier { + Future verify(String token); +} + +abstract class KeyFetcher { + Future> fetchPublicKeys(); +} + +class UrlKeyFetcher implements KeyFetcher { + UrlKeyFetcher(this.clientCert); + + final Uri clientCert; + + Map? _publicKeys; + late DateTime _publicKeysExpireAt; + + Future> fetchPublicKeys() async { + if (_shouldRefresh()) return refresh(); + return _publicKeys!; + } + + bool _shouldRefresh() { + if (_publicKeys == null) return true; + return _publicKeysExpireAt.isBefore(DateTime.now()); + } + + Future> refresh() async { + final response = await http.get(clientCert); + final json = jsonDecode(response.body) as Map; + final error = json['error']; + if (error != null) { + var errorMessage = 'Error fetching public keys for Google certs: $error'; + final description = json['error_description']; + if (description != null) { + errorMessage += ' ($description)'; + } + throw Exception(errorMessage); + } + + // reset expire at from previous set of keys. + _publicKeysExpireAt = DateTime(0); + final cacheControl = response.headers['cache-control']; + if (cacheControl != null) { + final parts = cacheControl.split(','); + for (final part in parts) { + final subParts = part.trim().split('='); + if (subParts[0] == 'max-age') { + final maxAge = int.parse(subParts[1]); + // Is "seconds" correct? + _publicKeysExpireAt = DateTime.now().add(Duration(seconds: maxAge)); + } + } + } + return _publicKeys = Map.from(json); + } +} + +class PublicKeySignatureVerifier implements SignatureVerifier { + PublicKeySignatureVerifier(this.keyFetcher); + + PublicKeySignatureVerifier.withCertificateUrl(Uri clientCert) + : this(UrlKeyFetcher(clientCert)); + + final KeyFetcher keyFetcher; + + @override + Future verify(String token) { + throw UnimplementedError(); + // verifyJwtSignature(token); + } +} + +sealed class SecretOrPublicKey {} + +Future verifyJwtSignature( + String token, + SecretOrPublicKey secretOrPublicKey, [ + // TODO what about options? + Object? options, +]) { + throw UnimplementedError(); +} diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.ts b/packages/dart_firebase_admin/lib/src/utils/jwt.ts new file mode 100644 index 0000000..f8d80a6 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/jwt.ts @@ -0,0 +1,370 @@ +/*! + * Copyright 2021 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as validator from './validator'; +import * as jwt from 'jsonwebtoken'; +import * as jwks from 'jwks-rsa'; +import { HttpClient, HttpRequestConfig, HttpError } from '../utils/api-request'; +import { Agent } from 'http'; + +export const ALGORITHM_RS256: jwt.Algorithm = 'RS256' as const; + +// `jsonwebtoken` converts errors from the `getKey` callback to its own `JsonWebTokenError` type +// and prefixes the error message with the following. Use the prefix to identify errors thrown +// from the key provider callback. +// https://github.com/auth0/node-jsonwebtoken/blob/d71e383862fc735991fd2e759181480f066bf138/verify.js#L96 +const JWT_CALLBACK_ERROR_PREFIX = 'error in secret or public key callback: '; + +const NO_MATCHING_KID_ERROR_MESSAGE = 'no-matching-kid-error'; +const NO_KID_IN_HEADER_ERROR_MESSAGE = 'no-kid-in-header-error'; + +const HOUR_IN_SECONDS = 3600; + +export type Dictionary = { [key: string]: any } + +export type DecodedToken = { + header: Dictionary; + payload: Dictionary; +} + +export interface SignatureVerifier { + verify(token: string): Promise; +} + +interface KeyFetcher { + fetchPublicKeys(): Promise<{ [key: string]: string }>; +} + +export class JwksFetcher implements KeyFetcher { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt = 0; + private client: jwks.JwksClient; + + constructor(jwksUrl: string) { + if (!validator.isURL(jwksUrl)) { + throw new Error('The provided JWKS URL is not a valid URL.'); + } + + this.client = jwks({ + jwksUri: jwksUrl, + cache: false, // disable jwks-rsa LRU cache as the keys are always cached for 6 hours. + }); + } + + public fetchPublicKeys(): Promise<{ [key: string]: string }> { + if (this.shouldRefresh()) { + return this.refresh(); + } + return Promise.resolve(this.publicKeys); + } + + private shouldRefresh(): boolean { + return !this.publicKeys || this.publicKeysExpireAt <= Date.now(); + } + + private refresh(): Promise<{ [key: string]: string }> { + return this.client.getSigningKeys() + .then((signingKeys) => { + // reset expire at from previous set of keys. + this.publicKeysExpireAt = 0; + const newKeys = signingKeys.reduce((map: { [key: string]: string }, signingKey: jwks.SigningKey) => { + map[signingKey.kid] = signingKey.getPublicKey(); + return map; + }, {}); + this.publicKeysExpireAt = Date.now() + (HOUR_IN_SECONDS * 6 * 1000); + this.publicKeys = newKeys; + return newKeys; + }).catch((err) => { + throw new Error(`Error fetching Json Web Keys: ${err.message}`); + }); + } +} + +/** + * Class to fetch public keys from a client certificates URL. + */ +export class UrlKeyFetcher implements KeyFetcher { + private publicKeys: { [key: string]: string }; + private publicKeysExpireAt = 0; + + constructor(private clientCertUrl: string, private readonly httpAgent?: Agent) { + if (!validator.isURL(clientCertUrl)) { + throw new Error( + 'The provided public client certificate URL is not a valid URL.', + ); + } + } + + /** + * Fetches the public keys for the Google certs. + * + * @returns A promise fulfilled with public keys for the Google certs. + */ + public fetchPublicKeys(): Promise<{ [key: string]: string }> { + if (this.shouldRefresh()) { + return this.refresh(); + } + return Promise.resolve(this.publicKeys); + } + + /** + * Checks if the cached public keys need to be refreshed. + * + * @returns Whether the keys should be fetched from the client certs url or not. + */ + private shouldRefresh(): boolean { + return !this.publicKeys || this.publicKeysExpireAt <= Date.now(); + } + + private refresh(): Promise<{ [key: string]: string }> { + const client = new HttpClient(); + const request: HttpRequestConfig = { + method: 'GET', + url: this.clientCertUrl, + httpAgent: this.httpAgent, + }; + return client.send(request).then((resp) => { + if (!resp.isJson() || resp.data.error) { + // Treat all non-json messages and messages with an 'error' field as + // error responses. + throw new HttpError(resp); + } + // reset expire at from previous set of keys. + this.publicKeysExpireAt = 0; + if (Object.prototype.hasOwnProperty.call(resp.headers, 'cache-control')) { + const cacheControlHeader: string = resp.headers['cache-control']; + const parts = cacheControlHeader.split(','); + parts.forEach((part) => { + const subParts = part.trim().split('='); + if (subParts[0] === 'max-age') { + const maxAge: number = +subParts[1]; + this.publicKeysExpireAt = Date.now() + (maxAge * 1000); + } + }); + } + this.publicKeys = resp.data; + return resp.data; + }).catch((err) => { + if (err instanceof HttpError) { + let errorMessage = 'Error fetching public keys for Google certs: '; + const resp = err.response; + if (resp.isJson() && resp.data.error) { + errorMessage += `${resp.data.error}`; + if (resp.data.error_description) { + errorMessage += ' (' + resp.data.error_description + ')'; + } + } else { + errorMessage += `${resp.text}`; + } + throw new Error(errorMessage); + } + throw err; + }); + } +} + +/** + * Class for verifying JWT signature with a public key. + */ +export class PublicKeySignatureVerifier implements SignatureVerifier { + constructor(private keyFetcher: KeyFetcher) { + if (!validator.isNonNullObject(keyFetcher)) { + throw new Error('The provided key fetcher is not an object or null.'); + } + } + + public static withCertificateUrl(clientCertUrl: string, httpAgent?: Agent): PublicKeySignatureVerifier { + return new PublicKeySignatureVerifier(new UrlKeyFetcher(clientCertUrl, httpAgent)); + } + + public static withJwksUrl(jwksUrl: string): PublicKeySignatureVerifier { + return new PublicKeySignatureVerifier(new JwksFetcher(jwksUrl)); + } + + public verify(token: string): Promise { + if (!validator.isString(token)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + return verifyJwtSignature(token, getKeyCallback(this.keyFetcher), { algorithms: [ALGORITHM_RS256] }) + .catch((error: JwtError) => { + if (error.code === JwtErrorCode.NO_KID_IN_HEADER) { + // No kid in JWT header. Try with all the public keys. + return this.verifyWithoutKid(token); + } + throw error; + }); + } + + private verifyWithoutKid(token: string): Promise { + return this.keyFetcher.fetchPublicKeys() + .then(publicKeys => this.verifyWithAllKeys(token, publicKeys)); + } + + private verifyWithAllKeys(token: string, keys: { [key: string]: string }): Promise { + const promises: Promise[] = []; + Object.values(keys).forEach((key) => { + const result = verifyJwtSignature(token, key) + .then(() => true) + .catch((error) => { + if (error.code === JwtErrorCode.TOKEN_EXPIRED) { + throw error; + } + return false; + }) + promises.push(result); + }); + + return Promise.all(promises) + .then((result) => { + if (result.every((r) => r === false)) { + throw new JwtError(JwtErrorCode.INVALID_SIGNATURE, 'Invalid token signature.'); + } + }); + } +} + +/** + * Class for verifying unsigned (emulator) JWTs. + */ +export class EmulatorSignatureVerifier implements SignatureVerifier { + public verify(token: string): Promise { + // Signature checks skipped for emulator; no need to fetch public keys. + return verifyJwtSignature(token, undefined as any, { algorithms:['none'] }); + } +} + +/** + * Provides a callback to fetch public keys. + * + * @param fetcher - KeyFetcher to fetch the keys from. + * @returns A callback function that can be used to get keys in `jsonwebtoken`. + */ +function getKeyCallback(fetcher: KeyFetcher): jwt.GetPublicKeyOrSecret { + return (header: jwt.JwtHeader, callback: jwt.SigningKeyCallback) => { + if (!header.kid) { + callback(new Error(NO_KID_IN_HEADER_ERROR_MESSAGE)); + } + const kid = header.kid || ''; + fetcher.fetchPublicKeys().then((publicKeys) => { + if (!Object.prototype.hasOwnProperty.call(publicKeys, kid)) { + callback(new Error(NO_MATCHING_KID_ERROR_MESSAGE)); + } else { + callback(null, publicKeys[kid]); + } + }) + .catch(error => { + callback(error); + }); + } +} + +/** + * Verifies the signature of a JWT using the provided secret or a function to fetch + * the secret or public key. + * + * @param token - The JWT to be verified. + * @param secretOrPublicKey - The secret or a function to fetch the secret or public key. + * @param options - JWT verification options. + * @returns A Promise resolving for a token with a valid signature. + */ +export function verifyJwtSignature(token: string, secretOrPublicKey: jwt.Secret | jwt.GetPublicKeyOrSecret, + options?: jwt.VerifyOptions): Promise { + if (!validator.isString(token)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + return new Promise((resolve, reject) => { + jwt.verify(token, secretOrPublicKey, options, + (error: jwt.VerifyErrors | null) => { + if (!error) { + return resolve(); + } + if (error.name === 'TokenExpiredError') { + return reject(new JwtError(JwtErrorCode.TOKEN_EXPIRED, + 'The provided token has expired. Get a fresh token from your ' + + 'client app and try again.')); + } else if (error.name === 'JsonWebTokenError') { + if (error.message && error.message.includes(JWT_CALLBACK_ERROR_PREFIX)) { + const message = error.message.split(JWT_CALLBACK_ERROR_PREFIX).pop() || 'Error fetching public keys.'; + let code = JwtErrorCode.KEY_FETCH_ERROR; + if (message === NO_MATCHING_KID_ERROR_MESSAGE) { + code = JwtErrorCode.NO_MATCHING_KID; + } else if (message === NO_KID_IN_HEADER_ERROR_MESSAGE) { + code = JwtErrorCode.NO_KID_IN_HEADER; + } + return reject(new JwtError(code, message)); + } + } + return reject(new JwtError(JwtErrorCode.INVALID_SIGNATURE, error.message)); + }); + }); +} + +/** + * Decodes general purpose Firebase JWTs. + * + * @param jwtToken - JWT token to be decoded. + * @returns Decoded token containing the header and payload. + */ +export function decodeJwt(jwtToken: string): Promise { + if (!validator.isString(jwtToken)) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'The provided token must be a string.')); + } + + const fullDecodedToken: any = jwt.decode(jwtToken, { + complete: true, + }); + + if (!fullDecodedToken) { + return Promise.reject(new JwtError(JwtErrorCode.INVALID_ARGUMENT, + 'Decoding token failed.')); + } + + const header = fullDecodedToken?.header; + const payload = fullDecodedToken?.payload; + return Promise.resolve({ header, payload }); +} + +/** + * Jwt error code structure. + * + * @param code - The error code. + * @param message - The error message. + * @constructor + */ +export class JwtError extends Error { + constructor(readonly code: JwtErrorCode, readonly message: string) { + super(message); + (this as any).__proto__ = JwtError.prototype; + } +} + +/** + * JWT error codes. + */ +export enum JwtErrorCode { + INVALID_ARGUMENT = 'invalid-argument', + INVALID_CREDENTIAL = 'invalid-credential', + TOKEN_EXPIRED = 'token-expired', + INVALID_SIGNATURE = 'invalid-token', + NO_MATCHING_KID = 'no-matching-kid-error', + NO_KID_IN_HEADER = 'no-kid-error', + KEY_FETCH_ERROR = 'key-fetch-error', +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/utils/validator.dart b/packages/dart_firebase_admin/lib/src/utils/validator.dart new file mode 100644 index 0000000..5dad088 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/validator.dart @@ -0,0 +1,43 @@ +import '../dart_firebase_admin.dart'; + +/// Validates that a string is a valid phone number. +bool isPhoneNumber(String phoneNumber) { + // Phone number validation is very lax here. Backend will enforce E.164 + // spec compliance and will normalize accordingly. + // The phone number string must be non-empty and starts with a plus sign. + final re1 = RegExp(r'^\+/'); + // The phone number string must contain at least one alphanumeric character. + final re2 = RegExp(r'[\da-zA-Z]+/'); + return re1.hasMatch(phoneNumber) && re2.hasMatch(phoneNumber); +} + +/// Verifies that a string is a valid phone number. Throws otherwise. +void assertIsPhoneNumber(String phoneNumber) { + if (!isPhoneNumber(phoneNumber)) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidPhoneNumber); + } +} + +/// Validates that a string is a valid email. +bool isEmail(String email) { + // There must at least one character before the @ symbol and another after. + final re = RegExp(r'^[^@]+@[^@]+$'); + return re.hasMatch(email); +} + +/// Verifies that a string is a valid email. Throws otherwise. +void assertIsEmail(String email) { + if (!isEmail(email)) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidEmail); + } +} + +/// Validates that a string is a valid Firebase Auth uid. +bool isUid(String uid) => uid.isNotEmpty && uid.length <= 128; + +/// Verifies that a string is a valid Firebase Auth uid. Throws otherwise. +void assertIsUid(String uid) { + if (!isUid(uid)) { + throw FirebaseAuthAdminException(AuthClientErrorCode.invalidUid); + } +} diff --git a/packages/dart_firebase_admin/lib/src/utils/validator.ts b/packages/dart_firebase_admin/lib/src/utils/validator.ts new file mode 100644 index 0000000..3b66775 --- /dev/null +++ b/packages/dart_firebase_admin/lib/src/utils/validator.ts @@ -0,0 +1,296 @@ +/*! + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import url = require('url'); + +/** + * Validates that a value is a byte buffer. + * + * @param value - The value to validate. + * @returns Whether the value is byte buffer or not. + */ +export function isBuffer(value: any): value is Buffer { + return value instanceof Buffer; +} + +/** + * Validates that a value is an array. + * + * @param value - The value to validate. + * @returns Whether the value is an array or not. + */ +export function isArray(value: any): value is T[] { + return Array.isArray(value); +} + +/** + * Validates that a value is a non-empty array. + * + * @param value - The value to validate. + * @returns Whether the value is a non-empty array or not. + */ +export function isNonEmptyArray(value: any): value is T[] { + return isArray(value) && value.length !== 0; +} + + +/** + * Validates that a value is a boolean. + * + * @param value - The value to validate. + * @returns Whether the value is a boolean or not. + */ +export function isBoolean(value: any): boolean { + return typeof value === 'boolean'; +} + + +/** + * Validates that a value is a number. + * + * @param value - The value to validate. + * @returns Whether the value is a number or not. + */ +export function isNumber(value: any): boolean { + return typeof value === 'number' && !isNaN(value); +} + + +/** + * Validates that a value is a string. + * + * @param value - The value to validate. + * @returns Whether the value is a string or not. + */ +export function isString(value: any): value is string { + return typeof value === 'string'; +} + + +/** + * Validates that a value is a base64 string. + * + * @param value - The value to validate. + * @returns Whether the value is a base64 string or not. + */ +export function isBase64String(value: any): boolean { + if (!isString(value)) { + return false; + } + return /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value); +} + + +/** + * Validates that a value is a non-empty string. + * + * @param value - The value to validate. + * @returns Whether the value is a non-empty string or not. + */ +export function isNonEmptyString(value: any): value is string { + return isString(value) && value !== ''; +} + + +/** + * Validates that a value is a nullable object. + * + * @param value - The value to validate. + * @returns Whether the value is an object or not. + */ +export function isObject(value: any): boolean { + return typeof value === 'object' && !isArray(value); +} + + +/** + * Validates that a value is a non-null object. + * + * @param value - The value to validate. + * @returns Whether the value is a non-null object or not. + */ +export function isNonNullObject(value: T | null | undefined): value is T { + return isObject(value) && value !== null; +} + + +/** + * Validates that a string is a valid Firebase Auth uid. + * + * @param uid - The string to validate. + * @returns Whether the string is a valid Firebase Auth uid. + */ +export function isUid(uid: any): boolean { + return typeof uid === 'string' && uid.length > 0 && uid.length <= 128; +} + + +/** + * Validates that a string is a valid Firebase Auth password. + * + * @param password - The password string to validate. + * @returns Whether the string is a valid Firebase Auth password. + */ +export function isPassword(password: any): boolean { + // A password must be a string of at least 6 characters. + return typeof password === 'string' && password.length >= 6; +} + + +/** + * Validates that a string is a valid email. + * + * @param email - The string to validate. + * @returns Whether the string is valid email or not. + */ +export function isEmail(email: any): boolean { + if (typeof email !== 'string') { + return false; + } + // There must at least one character before the @ symbol and another after. + const re = /^[^@]+@[^@]+$/; + return re.test(email); +} + + +/** + * Validates that a string is a valid phone number. + * + * @param phoneNumber - The string to validate. + * @returns Whether the string is a valid phone number or not. + */ +export function isPhoneNumber(phoneNumber: any): boolean { + if (typeof phoneNumber !== 'string') { + return false; + } + // Phone number validation is very lax here. Backend will enforce E.164 + // spec compliance and will normalize accordingly. + // The phone number string must be non-empty and starts with a plus sign. + const re1 = /^\+/; + // The phone number string must contain at least one alphanumeric character. + const re2 = /[\da-zA-Z]+/; + return re1.test(phoneNumber) && re2.test(phoneNumber); +} + +/** + * Validates that a string is a valid ISO date string. + * + * @param dateString - The string to validate. + * @returns Whether the string is a valid ISO date string. + */ +export function isISODateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toISOString() === dateString); + } catch (e) { + return false; + } +} + + +/** + * Validates that a string is a valid UTC date string. + * + * @param dateString - The string to validate. + * @returns Whether the string is a valid UTC date string. + */ +export function isUTCDateString(dateString: any): boolean { + try { + return isNonEmptyString(dateString) && + (new Date(dateString).toUTCString() === dateString); + } catch (e) { + return false; + } +} + + +/** + * Validates that a string is a valid web URL. + * + * @param urlStr - The string to validate. + * @returns Whether the string is valid web URL or not. + */ +export function isURL(urlStr: any): boolean { + if (typeof urlStr !== 'string') { + return false; + } + // Lookup illegal characters. + const re = /[^a-z0-9:/?#[\]@!$&'()*+,;=.\-_~%]/i; + if (re.test(urlStr)) { + return false; + } + try { + const uri = url.parse(urlStr); + const scheme = uri.protocol; + const slashes = uri.slashes; + const hostname = uri.hostname; + const pathname = uri.pathname; + if ((scheme !== 'http:' && scheme !== 'https:') || !slashes) { + return false; + } + // Validate hostname: Can contain letters, numbers, underscore and dashes separated by a dot. + // Each zone must not start with a hyphen or underscore. + if (!hostname || !/^[a-zA-Z0-9]+[\w-]*([.]?[a-zA-Z0-9]+[\w-]*)*$/.test(hostname)) { + return false; + } + // Allow for pathnames: (/chars+)*/? + // Where chars can be a combination of: a-z A-Z 0-9 - _ . ~ ! $ & ' ( ) * + , ; = : @ % + const pathnameRe = /^(\/[\w\-.~!$'()*+,;=:@%]+)*\/?$/; + // Validate pathname. + if (pathname && + pathname !== '/' && + !pathnameRe.test(pathname)) { + return false; + } + // Allow any query string and hash as long as no invalid character is used. + } catch (e) { + return false; + } + return true; +} + + +/** + * Validates that the provided topic is a valid FCM topic name. + * + * @param topic - The topic to validate. + * @returns Whether the provided topic is a valid FCM topic name. + */ +export function isTopic(topic: any): boolean { + if (typeof topic !== 'string') { + return false; + } + + const VALID_TOPIC_REGEX = /^(\/topics\/)?(private\/)?[a-zA-Z0-9-_.~%]+$/; + return VALID_TOPIC_REGEX.test(topic); +} + +/** + * Validates that the provided string can be used as a task ID + * for Cloud Tasks. + * + * @param taskId - the task ID to validate. + * @returns Whether the provided task ID is valid. + */ +export function isTaskId(taskId: any): boolean { + if (typeof taskId !== 'string') { + return false; + } + + const VALID_TASK_ID_REGEX = /^[A-Za-z0-9_-]+$/; + return VALID_TASK_ID_REGEX.test(taskId); +} \ No newline at end of file diff --git a/packages/dart_firebase_admin/pubspec.yaml b/packages/dart_firebase_admin/pubspec.yaml index acc14bf..ad48811 100644 --- a/packages/dart_firebase_admin/pubspec.yaml +++ b/packages/dart_firebase_admin/pubspec.yaml @@ -3,12 +3,21 @@ description: A Firebase Admin SDK implementation for Dart. version: 0.0.1 homepage: "https://github.com/invertase/dart_firebase_admin" +environment: + sdk: ">=3.0.0 <4.0.0" + dependencies: - firebaseapis: ^0.1.0+1 + collection: ^1.18.0 + firebaseapis: ^0.2.0 + freezed_annotation: ^2.4.1 googleapis_auth: ^1.3.0 + http: ^0.13.6 + intl: ^0.18.1 + meta: ^1.9.1 -environment: - sdk: ">=2.16.0 <3.0.0" dev_dependencies: + build_runner: ^2.4.6 file: ^7.0.0 + freezed: ^2.4.2 test: ^1.24.4 + uuid: ^3.0.7 diff --git a/packages/dart_firebase_admin/test/credential_test.dart b/packages/dart_firebase_admin/test/credential_test.dart index ef6c615..87594cf 100644 --- a/packages/dart_firebase_admin/test/credential_test.dart +++ b/packages/dart_firebase_admin/test/credential_test.dart @@ -1,6 +1,6 @@ import 'dart:io'; -import 'package:dart_firebase_admin/dart_firebase_admin.dart'; +import 'package:dart_firebase_admin/src/dart_firebase_admin.dart'; import 'package:file/memory.dart'; import 'package:test/test.dart'; diff --git a/packages/dart_firebase_admin/test/firebase_admin_app_test.dart b/packages/dart_firebase_admin/test/firebase_admin_app_test.dart new file mode 100644 index 0000000..d528202 --- /dev/null +++ b/packages/dart_firebase_admin/test/firebase_admin_app_test.dart @@ -0,0 +1,38 @@ +import 'package:dart_firebase_admin/src/dart_firebase_admin.dart'; +import 'package:test/test.dart'; + +void main() { + group(FirebaseAdminApp, () { + test('initializeApp() creates a new FirebaseAdminApp', () { + final app = FirebaseAdminApp.initializeApp( + 'dart-firebase-admin', + Credential.fromApplicationDefaultCredentials(), + ); + + expect(app, isA()); + expect(app.authApiHost, Uri.https('identitytoolkit.googleapis.com', '/')); + expect( + app.firestoreApiHost, + Uri.https('identitytoolkit.googleapis.com', '/'), + ); + }); + + test('useEmulator() sets the apiHost to the emulator', () { + final app = FirebaseAdminApp.initializeApp( + 'dart-firebase-admin', + Credential.fromApplicationDefaultCredentials(), + ); + + app.useEmulator(); + + expect( + app.authApiHost, + Uri.http('127.0.0.1:9099', 'identitytoolkit.googleapis.com/'), + ); + expect( + app.authApiHost, + Uri.http('127.0.0.1:8080', 'identitytoolkit.googleapis.com/'), + ); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart new file mode 100644 index 0000000..bb0ac49 --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart @@ -0,0 +1,172 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:test/test.dart' hide throwsArgumentError; + +import 'util/helpers.dart'; + +void main() { + group('Collection interface', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('has doc() method', () { + final collection = firestore.collection('colId'); + + expect(collection.id, 'colId'); + expect(collection.path, 'colId'); + + final documentRef = collection.doc('docId'); + + expect(documentRef, isA>()); + expect(documentRef.id, 'docId'); + expect(documentRef.path, 'colId/docId'); + + expect( + () => collection.doc(''), + throwsArgumentError(message: 'Must be a non-empty string'), + ); + expect( + () => collection.doc('doc/coll'), + throwsArgumentError( + message: + 'Value for argument "documentPath" must point to a document, ' + 'but was "doc/coll". ' + 'Your path does not contain an even number of components.', + ), + ); + + expect( + collection.doc('docId/colId/docId'), + isA>(), + ); + }); + + test('has parent getter', () { + final collection = firestore.collection('col1/doc/col2'); + expect(collection.path, 'col1/doc/col2'); + + final document = collection.parent; + expect(document!.path, 'col1/doc'); + }); + + test('parent returns null for root', () { + final collection = firestore.collection('col1'); + + expect(collection.parent, isNull); + }); + + test('supports auto-generated ids', () { + final collection = firestore.collection('col1'); + + final document = collection.doc(); + expect(document.id, hasLength(20)); + }); + + test('has add() method', () async { + final collection = firestore.collection('addCollection'); + + final documentRef = await collection.add({'foo': 'bar'}); + + expect(documentRef, isA>()); + expect(documentRef.id, hasLength(20)); + expect(documentRef.path, 'addCollection/${documentRef.id}'); + + final documentSnapshot = await documentRef.get(); + + expect(documentSnapshot.data(), {'foo': 'bar'}); + }); + + test('has list() method', () async { + final collection = firestore.collection('listCollection'); + + final a = collection.doc('a'); + await a.set({'foo': 'bar'}); + + final b = collection.doc('b'); + await b.set({'baz': 'quaz'}); + + final documents = await collection.listDocuments(); + + expect(documents, unorderedEquals([a, b])); + }); + + test('override equal', () async { + final coll1 = firestore.collection('coll1'); + final coll1Equals = firestore.collection('coll1'); + final coll2 = firestore.collection('coll2'); + + expect(coll1, coll1Equals); + expect(coll1, isNot(coll2)); + }); + + test('override hashCode', () async { + final coll1 = firestore.collection('coll1'); + final coll1Equals = firestore.collection('coll1'); + final coll2 = firestore.collection('coll2'); + + expect(coll1.hashCode, coll1Equals.hashCode); + expect(coll1.hashCode, isNot(coll2.hashCode)); + }); + + test('for CollectionReference.withConverter().doc()', () async { + final collection = firestore.collection('withConverterColDoc'); + + final rawDoc = collection.doc('doc'); + + final docRef = collection + .withConverter( + fromFirestore: (snapshot) => snapshot.data()['value']! as int, + toFirestore: (value) => {'value': value}, + ) + .doc('doc'); + + expect(docRef, isA>()); + expect(docRef.id, 'doc'); + expect(docRef.path, 'withConverterColDoc/doc'); + + await docRef.set(42); + + final rawDocSnapshot = await rawDoc.get(); + expect(rawDocSnapshot.data(), {'value': 42}); + + final docSnapshot = await docRef.get(); + expect(docSnapshot.data(), 42); + }); + + test('for CollectionReference.withConverter().add()', () async { + final collection = + firestore.collection('withConverterColAdd').withConverter( + fromFirestore: (snapshot) => snapshot.data()['value']! as int, + toFirestore: (value) => {'value': value}, + ); + + expect(collection, isA>()); + + final docRef = await collection.add(42); + + expect(docRef, isA>()); + expect(docRef.id, hasLength(20)); + expect(docRef.path, 'withConverterColAdd/${docRef.id}'); + + final docSnapshot = await docRef.get(); + expect(docSnapshot.data(), 42); + }); + + test('drops the converter when calling CollectionReference.parent()', + () { + final collection = firestore + .collection('withConverterColParent/doc/child') + .withConverter( + fromFirestore: (snapshot) => snapshot.data()['value']! as int, + toFirestore: (value) => {'value': value}, + ); + + expect(collection, isA>()); + + final parent = collection.parent; + + expect(parent, isA>()); + expect(parent!.path, 'withConverterColParent/doc'); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart new file mode 100644 index 0000000..34e51a1 --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart @@ -0,0 +1,653 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:test/test.dart' hide throwsArgumentError; + +import 'util/helpers.dart'; + +void main() { + group('DocumentReference', () { + late Firestore firestore; + late DocumentReference> documentRef; + + setUp(() { + firestore = createInstance(); + documentRef = firestore.doc('collectionId/documentId'); + }); + + test('has collection() method', () { + final collection = documentRef.collection('col'); + expect(collection.id, 'col'); + + expect( + () => documentRef.collection('col/doc'), + throwsArgumentError( + message: + 'Value for argument "collectionPath" must point to a collection, but was "col/doc". ' + 'Your path does not contain an odd number of components.', + ), + ); + + expect( + documentRef.collection('col/doc/col').id, + 'col', + ); + }); + + test('has path property', () { + expect(documentRef.path, 'collectionId/documentId'); + }); + + test('has parent property', () { + expect(documentRef.parent.path, 'collectionId'); + }); + + test('overrides equal operator', () { + final doc1 = firestore.doc('coll/doc1'); + final doc1Equals = firestore.doc('coll/doc1'); + final doc2 = firestore.doc('coll/doc1/coll/doc1'); + expect(doc1, doc1Equals); + expect(doc1, isNot(doc2)); + }); + + test('overrides hash operator', () { + final doc1 = firestore.doc('coll/doc1'); + final doc1Equals = firestore.doc('coll/doc1'); + final doc2 = firestore.doc('coll/doc1/coll/doc1'); + expect(doc1.hashCode, doc1Equals.hashCode); + expect(doc1.hashCode, isNot(doc2.hashCode)); + }); + }); + + group('serialize document', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test("doesn't serialize unsupported types", () { + expect( + firestore + .doc('unknownType/documentId') + .set({'foo': FieldPath.documentId}), + throwsArgumentError( + message: 'Cannot use object of type "FieldPath" ' + 'as a Firestore value (found in field foo).', + ), + ); + + expect( + firestore.doc('unknownType/object').set({'foo': Object()}), + throwsArgumentError( + message: 'Unsupported value type: Object (found in field foo).', + ), + ); + }); + + test('serializes date before 1970', () async { + await firestore.doc('collectionId/before1970').set({ + 'moonLanding': DateTime(1960, 7, 20, 20, 18), + }); + + final data = await firestore + .doc('collectionId/before1970') + .get() + .then((snapshot) => snapshot.data()!['moonLanding']); + + expect( + data, + Timestamp.fromDate(DateTime(1960, 7, 20, 20, 18)), + ); + }); + + test('Supports BigInt', () async { + final firestore = createInstance(Settings(useBigInt: true)); + + await firestore.doc('collectionId/bigInt').set({ + 'foo': BigInt.from(9223372036854775807), + }); + + final data = await firestore + .doc('collectionId/bigInt') + .get() + .then((snapshot) => snapshot.data()!['foo']); + + expect(data, BigInt.from(9223372036854775807)); + }); + + test('serializes unicode keys', () async { + await firestore.doc('collectionId/unicode').set({ + '😀': '😜', + }); + + final data = await firestore + .doc('collectionId/unicode') + .get() + .then((snapshot) => snapshot.data()); + + expect(data, {'😀': '😜'}); + }); + + test('Supports NaN and Infinity', skip: true, () async { + // This fails because GRPC uses dart:convert.json.encode which does not support NaN or Infinity + await firestore.doc('collectionId/nan').set({ + 'nan': double.nan, + 'infinity': double.infinity, + 'negativeInfinity': double.negativeInfinity, + }); + + final data = await firestore + .doc('collectionId/nan') + .get() + .then((snapshot) => snapshot.data()); + + expect(data, { + 'nan': double.nan, + 'infinity': double.infinity, + 'negativeInfinity': double.negativeInfinity, + }); + }); + + test('with invalid geopoint', () { + expect( + () => GeoPoint(latitude: double.nan, longitude: 0), + throwsArgumentError( + message: 'Value for argument "latitude" is not a valid number', + ), + ); + + expect( + () => GeoPoint(latitude: 0, longitude: double.nan), + throwsArgumentError( + message: 'Value for argument "longitude" is not a valid number', + ), + ); + + expect( + () => GeoPoint(latitude: double.infinity, longitude: 0), + throwsArgumentError( + message: 'Latitude must be in the range of [-90, 90]', + ), + ); + expect( + () => GeoPoint(latitude: 91, longitude: 0), + throwsArgumentError( + message: 'Latitude must be in the range of [-90, 90]', + ), + ); + + expect( + () => GeoPoint(latitude: 90, longitude: 181), + throwsArgumentError( + message: 'Longitude must be in the range of [-180, 180]', + ), + ); + }); + + test('resolves infinite nesting', () { + final obj = {}; + obj['foo'] = obj; + + expect( + () => firestore.doc('collectionId/nesting').set(obj), + throwsArgumentError( + message: + 'Firestore objects may not contain more than 20 levels of nesting ' + 'or contain a cycle', + ), + ); + }); + }); + + group('get document', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('returns document', () async { + firestore = createInstance(); + await firestore.doc('collectionId/getdocument').set({ + 'foo': { + 'bar': 'foobar', + }, + 'null': null, + }); + + final snapshot = await firestore.doc('collectionId/getdocument').get(); + + expect(snapshot.data(), { + 'foo': {'bar': 'foobar'}, + 'null': null, + }); + + expect(snapshot.get('foo')?.value, { + 'bar': 'foobar', + }); + expect(snapshot.get('unknown'), null); + expect(snapshot.get('null'), isNotNull); + expect(snapshot.get('null')!.value, null); + expect(snapshot.get('foo.bar')?.value, 'foobar'); + + expect(snapshot.get(FieldPath(const ['foo']))?.value, { + 'bar': 'foobar', + }); + expect(snapshot.get(FieldPath(const ['foo', 'bar']))?.value, 'foobar'); + + expect(snapshot.ref.id, 'getdocument'); + }); + + test('returns read, update and create times', () async { + final time = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + + await firestore.doc('collectionId/times').delete(); + await firestore.doc('collectionId/times').set({}); + + final snapshot = await firestore.doc('collectionId/times').get(); + + expect( + snapshot.createTime!.seconds * 1000, + greaterThan(time), + ); + expect( + snapshot.updateTime!.seconds * 1000, + greaterThan(time), + ); + expect( + snapshot.readTime!.seconds * 1000, + greaterThan(time), + ); + }); + + test('returns not found', () async { + await firestore.doc('collectionId/found').set({}); + + final found = await firestore.doc('collectionId/found').get(); + final notFound = await firestore.doc('collectionId/not_found').get(); + + expect(found.exists, isTrue); + expect(found.data(), isNotNull); + expect(found.createTime, isNotNull); + expect(found.updateTime, isNotNull); + expect(found.readTime, isNotNull); + + expect(notFound.exists, isFalse); + expect(notFound.data(), isNull); + expect(notFound.createTime, isNull); + expect(notFound.updateTime, isNull); + expect(notFound.readTime, isNotNull); + }); + }); + + // TODO add tests dependent on invalid reads. This needs API overrides to have GRPC return invalid data + + group('delete document', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('works', () async { + await firestore.doc('collectionId/deletedoc').set({}); + + expect( + await firestore + .doc('collectionId/deletedoc') + .get() + .then((s) => s.exists), + isTrue, + ); + + await firestore.doc('collectionId/deletedoc').delete(); + + expect( + await firestore + .doc('collectionId/deletedoc') + .get() + .then((s) => s.exists), + isFalse, + ); + }); + + test('Supports preconditions', () async { + final result = await firestore.doc('collectionId/precondition').set({}); + + await firestore + .doc('collectionId/precondition') + .delete(Precondition.timestamp(result.writeTime)); + + expect( + await firestore + .doc('collectionId/precondition') + .get() + .then((s) => s.exists), + isFalse, + ); + + await firestore.doc('collectionId/precondition').set({}); + + expect( + () => firestore + .doc('collectionId/precondition') + .delete(Precondition.timestamp(result.writeTime)), + throwsA(isA()), + ); + }); + }); + + group('set documents', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('sends empty non-merge write even with just field transform', + () async { + final now = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + await firestore.doc('collectionId/setdoctransform').set({ + 'a': FieldValue.serverTimestamp, + 'b': {'c': FieldValue.serverTimestamp}, + }); + + final writes = await firestore + .doc('collectionId/setdoctransform') + .get() + .then((s) => s.data()!); + + expect( + (writes['a']! as Timestamp).seconds * 1000, + greaterThan(now), + ); + expect( + ((writes['b']! as Map)['c']! as Timestamp).seconds * 1000, + greaterThan(now), + ); + }); + + test("doesn't split on dots", () async { + await firestore.doc('collectionId/setdots').set({'a.b': 'c'}); + + final writes = await firestore + .doc('collectionId/setdots') + .get() + .then((s) => s.data()!); + + expect(writes, {'a.b': 'c'}); + }); + + test("doesn't support non-merge deletes", () { + expect( + () => firestore + .doc('collectionId/nonMergeDelete') + .set({'foo': FieldValue.delete}), + throwsArgumentError( + message: + 'must appear at the top-level and can only be used in update() ' + '(found in field foo).', + ), + ); + }); + }); + + group('create document', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('creates document', () async { + await firestore.doc('collectionId/createdoc').delete(); + await firestore.doc('collectionId/createdoc').create({'foo': 'bar'}); + + final snapshot = await firestore.doc('collectionId/createdoc').get(); + + expect(snapshot.data(), {'foo': 'bar'}); + }); + + test('returns update time', () async { + final time = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + + await firestore.doc('collectionId/createdoctime').delete(); + final result = + await firestore.doc('collectionId/createdoctime').create({}); + + expect( + result.writeTime.seconds * 1000, + greaterThan(time), + ); + }); + + test('supports field transforms', () async { + final time = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + + await firestore.doc('collectionId/createdoctransform').delete(); + await firestore + .doc('collectionId/createdoctransform') + .create({'a': FieldValue.serverTimestamp}); + + final writes = await firestore + .doc('collectionId/createdoctransform') + .get() + .then((s) => s.data()!); + + expect( + (writes['a']! as Timestamp).seconds * 1000, + greaterThan(time), + ); + }); + }); + + group('update document', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('works', () async { + await firestore.doc('collectionId/updatedoc').set({'foo': 'bar'}); + await firestore.doc('collectionId/updatedoc').update({'bar': 'baz'}); + + final snapshot = await firestore.doc('collectionId/updatedoc').get(); + + expect(snapshot.data(), {'foo': 'bar', 'bar': 'baz'}); + }); + + test('supports nested field transform', () async { + final time = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + + await firestore.doc('collectionId/updatedocnestedtransform').set({}); + await firestore.doc('collectionId/updatedocnestedtransform').update({ + 'foo': {}, + 'a': {'b': FieldValue.serverTimestamp}, + 'c.d': FieldValue.serverTimestamp, + }); + + final writes = await firestore + .doc('collectionId/updatedocnestedtransform') + .get() + .then((s) => s.data()!); + + final a = writes['a']! as Map; + final c = writes['c']! as Map; + + expect( + (a['b']! as Timestamp).seconds * 1000, + greaterThan(time), + ); + expect( + (c['d']! as Timestamp).seconds * 1000, + greaterThan(time), + ); + }); + + test('supports nested empty map', () async { + await firestore.doc('collectionId/updatedocemptymap').set({}); + await firestore.doc('collectionId/updatedocemptymap').update({ + 'foo': {}, + }); + + final writes = await firestore + .doc('collectionId/updatedocemptymap') + .get() + .then((s) => s.data()!); + + expect(writes, {'foo': {}}); + }); + + test('supports nested delete using chained paths', () async { + await firestore.doc('collectionId/updatenesteddelete').set({ + 'foo': {'bar': 'foobar'}, + }); + await firestore.doc('collectionId/updatenesteddelete').update({ + 'foo.bar': FieldValue.delete, + }); + + final writes = await firestore + .doc('collectionId/updatenesteddelete') + .get() + .then((s) => s.data()!); + + expect(writes, {'foo': {}}); + }); + + test('supports nested delete if not at root level', () async { + expect( + firestore.doc('collectionId/updatenesteddeleteinvalid').update({ + 'foo': { + 'bar': FieldValue.delete, + }, + }), + throwsArgumentError( + message: + 'must appear at the top-level and can only be used in update() ' + '(found in field foo.bar).', + ), + ); + }); + + test('returns update time', () async { + final time = DateTime.now().toUtc().millisecondsSinceEpoch - 5000; + + await firestore.doc('collectionId/updatedoctime').set({}); + final result = await firestore.doc('collectionId/updatedoctime').update({ + 'foo': 42, + }); + + expect( + result.writeTime.seconds * 1000, + greaterThan(time), + ); + }); + + test('with invalid last update time precondition', () async { + final soon = DateTime.now().toUtc().millisecondsSinceEpoch + 5000; + + await expectLater( + firestore.doc('collectionId/lastupdatetimeprecondition').update( + {'foo': 'bar'}, + Precondition.timestamp(Timestamp.fromMillis(soon)), + ), + throwsA(isA()), + ); + }); + + test('with valid last update time precondition', () async { + final result = await firestore + .doc('collectionId/lastupdatetimeprecondition') + .set({}); + + // does not throw + await firestore.doc('collectionId/lastupdatetimeprecondition').update( + {'foo': 'bar'}, + Precondition.timestamp(result.writeTime), + ); + }); + + test('requires at least one field', () { + expect( + firestore.doc('collectionId/emptyupdate').update({}), + throwsArgumentError( + message: 'At least one field must be updated.', + ), + ); + }); + + test('with two nested fields', () async { + await firestore.doc('collectionId/twonestedfields').set({}); + + await firestore.doc('collectionId/twonestedfields').update({ + 'foo.foo': 'one', + 'foo.bar': 'two', + 'foo.deep.foo': 'one', + 'foo.deep.bar': 'two', + }); + + final writes = await firestore + .doc('collectionId/twonestedfields') + .get() + .then((s) => s.data()!); + + expect(writes, { + 'foo': { + 'foo': 'one', + 'bar': 'two', + 'deep': { + 'foo': 'one', + 'bar': 'two', + }, + }, + }); + }); + + test('with field with dot', () async { + await firestore.doc('collectionId/fieldwithdot').set({}); + + await firestore.doc('collectionId/fieldwithdot').update({ + FieldPath(const ['foo.bar']): 'one', + }); + + final writes = await firestore + .doc('collectionId/fieldwithdot') + .get() + .then((s) => s.data()!); + + expect(writes, {'foo.bar': 'one'}); + }); + + test('with conflicting update', () async { + expect( + () => firestore.doc('collectionId/conflictingupdate').update({ + 'foo': 'bar', + 'foo.bar': 'baz', + }), + throwsArgumentError( + message: 'Field "foo" was specified multiple times.', + ), + ); + + expect( + () => firestore.doc('collectionId/conflictingupdate').update({ + 'foo': 'bar', + 'foo.bar.foobar': 'baz', + }), + throwsArgumentError( + message: 'Field "foo" was specified multiple times.', + ), + ); + + expect( + () => firestore.doc('collectionId/conflictingupdate').update({ + 'foo.bar': 'baz', + 'foo': 'bar', + }), + throwsArgumentError( + message: 'Field "foo" was specified multiple times.', + ), + ); + + expect( + () => firestore.doc('collectionId/conflictingupdate').update({ + 'foo.bar': 'foobar', + 'foo.bar.baz': 'foobar', + }), + throwsArgumentError( + message: 'Field "foo.bar" was specified multiple times.', + ), + ); + }); + }); + + // TODO add tests starting at "with valid field paths" +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart new file mode 100644 index 0000000..d68378d --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.dart @@ -0,0 +1,468 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:test/test.dart'; + +import 'util/helpers.dart'; + +void main() { + group('query interface', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('overrides ==', () { + final queryA = firestore.collection('col1'); + final queryB = firestore.collection('col1'); + + void queryEquals( + List> equals, [ + List> notEquals = const [], + ]) { + for (var i = 0; i < equals.length; ++i) { + for (final equal in equals) { + expect(equals[i], equal); + expect(equal, equals[i]); + } + + for (final notEqual in notEquals) { + expect(equals[i], isNot(notEqual)); + expect(notEqual, isNot(equals[i])); + } + } + } + + queryEquals( + [ + queryA.where('a', WhereFilter.equal, '1'), + queryB.where('a', WhereFilter.equal, '1'), + ], + ); + + queryEquals( + [ + queryA + .where('a', WhereFilter.equal, '1') + .where('b', WhereFilter.equal, 2), + queryB + .where('a', WhereFilter.equal, '1') + .where('b', WhereFilter.equal, 2), + ], + ); + + queryEquals([ + queryA.orderBy('__name__'), + queryA.orderBy('__name__', descending: false), + queryB.orderBy(FieldPath.documentId), + ], [ + queryA.orderBy('foo'), + queryB.orderBy(FieldPath.documentId, descending: true), + ]); + + queryEquals( + [queryA.limit(0), queryB.limit(0).limit(0)], + [queryA, queryB.limit(10)], + ); + + queryEquals( + [queryA.offset(0), queryB.offset(0).offset(0)], + [queryA, queryB.offset(10)], + ); + + queryEquals([ + queryA.orderBy('foo').startAt(['a']), + queryB.orderBy('foo').startAt(['a']), + ], [ + queryA.orderBy('foo').startAfter(['a']), + queryB.orderBy('foo').endAt(['a']), + queryA.orderBy('foo').endBefore(['a']), + queryB.orderBy('foo').startAt(['b']), + queryA.orderBy('bar').startAt(['a']), + ]); + + queryEquals([ + queryA.orderBy('foo').startAfter(['a']), + queryB.orderBy('foo').startAfter(['a']), + ], [ + queryA.orderBy('foo').startAfter(['b']), + queryB.orderBy('bar').startAfter(['a']), + ]); + + queryEquals([ + queryA.orderBy('foo').endBefore(['a']), + queryB.orderBy('foo').endBefore(['a']), + ], [ + queryA.orderBy('foo').endBefore(['b']), + queryB.orderBy('bar').endBefore(['a']), + ]); + + queryEquals( + [ + queryA.orderBy('foo').endAt(['a']), + queryB.orderBy('foo').endAt(['a']), + ], + [ + queryA.orderBy('foo').endAt(['b']), + queryB.orderBy('bar').endAt(['a']), + ], + ); + + queryEquals( + [ + queryA + .orderBy('foo') + .orderBy('__name__') + .startAt(['b', queryA.doc('c')]), + queryB + .orderBy('foo') + .orderBy('__name__') + .startAt(['b', queryA.doc('c')]), + ], + ); + }); + + test('accepts all variations', () async { + final query = firestore + .collection('allVarations') + .where('foo', WhereFilter.equal, '1') + .orderBy('foo') + .limit(10); + + final snapshot = await query.get(); + + expect(snapshot.docs, isEmpty); + expect(snapshot.query, query); + }); + + test('Supports empty gets', () async { + final snapshot = await firestore.collection('emptyget').get(); + + expect(snapshot.docs, isEmpty); + expect(snapshot.readTime, isNotNull); + }); + + // TODO handle retries + + test('propagates withConverter() through QueryOptions', () async { + final collection = + firestore.collection('withConverterQueryOptions').withConverter( + fromFirestore: (snapshot) => snapshot.data()['value']! as int, + toFirestore: (value) => {'value': value}, + ); + + await collection.doc('doc').set(42); + await collection.doc('doc2').set(1); + + final query = collection.where('value', WhereFilter.equal, 1); + expect(query, isA>()); + + final snapshot = await query.get(); + + expect(snapshot.docs.single.ref, collection.doc('doc2')); + expect(snapshot.docs.single.data(), 1); + }); + + test('supports OR queries with cursors', () async { + final collection = firestore.collection('orQueryWithCursors'); + final query = collection + .orderBy('a') + .whereFilter( + Filter.or([ + Filter.where('a', WhereFilter.greaterThanOrEqual, 4), + Filter.where('a', WhereFilter.equal, 2), + // Unused due to startAt + Filter.where('a', WhereFilter.equal, 0), + ]), + ) + .startAt([1]).limit(3); + + await Future.wait([ + collection.doc('0').set({'a': 0}), + collection.doc('1').set({'a': 1}), + collection.doc('2').set({'a': 2}), + collection.doc('3').set({'a': 3}), + collection.doc('4').set({'a': 4}), + collection.doc('5').set({'a': 5}), + collection.doc('6').set({'a': 6}), + ]); + + final snapshot = await query.get(); + + expect(snapshot.docs.map((doc) => doc.id), ['2', '4', '5']); + }); + }); + + group('where()', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('handles all operators', () { + expect(WhereFilter.equal.proto, 'EQUAL'); + expect(WhereFilter.greaterThan.proto, 'GREATER_THAN'); + expect(WhereFilter.greaterThanOrEqual.proto, 'GREATER_THAN_OR_EQUAL'); + expect(WhereFilter.lessThan.proto, 'LESS_THAN'); + expect(WhereFilter.lessThanOrEqual.proto, 'LESS_THAN_OR_EQUAL'); + expect(WhereFilter.notEqual.proto, 'NOT_EQUAL'); + expect(WhereFilter.isIn.proto, 'IN'); + expect(WhereFilter.notIn.proto, 'NOT_IN'); + expect(WhereFilter.arrayContains.proto, 'ARRAY_CONTAINS'); + expect(WhereFilter.arrayContainsAny.proto, 'ARRAY_CONTAINS_ANY'); + }); + + test('accepts objects', () async { + final collection = firestore.collection('whereObjects'); + final doc = collection.doc('doc'); + + await doc.set({ + 'a': {'b': 1}, + }); + + final snapshot = await collection.where( + 'a', + WhereFilter.equal, + {'b': 1}, + ).get(); + + expect(snapshot.docs.single.ref, doc); + }); + + test('supports field path objects', () async { + final collection = firestore.collection('whereFieldPathObj'); + final doc = collection.doc('doc'); + + await doc.set({ + 'a': {'b': 1}, + }); + + final snapshot = await collection + .where(FieldPath(const ['a', 'b']), WhereFilter.equal, 1) + .get(); + + expect(snapshot.docs.single.ref, doc); + }); + + test('supports reference array for IN queries', () async { + final collection = firestore.collection('whereReferenceArray'); + + final doc2 = collection.doc('doc'); + await doc2.set({}); + await collection.doc('doc2').set({}); + + final snapshot = await collection.where( + FieldPath.documentId, + WhereFilter.isIn, + [doc2], + ).get(); + + expect(snapshot.docs.single.ref, doc2); + }); + + test('Fields of IN queries are not used in implicit order by', () async { + final collection = firestore.collection('whereInImplicitOrderBy'); + + await collection.doc('b').set({'foo': 'bar'}); + await collection.doc('a').set({'foo': 'bar'}); + + final snapshot = + await collection.where('foo', WhereFilter.isIn, ['bar']).get(); + + expect(snapshot.docs.map((doc) => doc.id), ['a', 'b']); + }); + + test('throws if in/not-in have non-reference values', () { + final collection = firestore.collection('whereInValidation'); + + expect( + () => collection.where(FieldPath.documentId, WhereFilter.isIn, [1]), + throwsA(isA()), + ); + + expect( + () => collection.where(FieldPath.documentId, WhereFilter.notIn, [1]), + throwsA(isA()), + ); + }); + + test( + 'throws if FieldPath.documentId is used with array-contains/array-contains-any', + () { + final collection = firestore.collection('whereArrayContainsValidation'); + + expect( + () => collection.where( + FieldPath.documentId, + WhereFilter.arrayContains, + [collection.doc('doc')], + ), + throwsA(isA()), + ); + + expect( + () => collection.where( + FieldPath.documentId, + WhereFilter.arrayContainsAny, + [collection.doc('doc')], + ), + throwsA(isA()), + ); + }); + + test('rejects field paths as value', () { + final collection = firestore.collection('whereFieldPathValue'); + + expect( + () => collection.where('foo', WhereFilter.equal, FieldPath.documentId), + throwsA(isA()), + ); + }); + + test('rejects field delete as value', () { + final collection = firestore.collection('whereFieldDeleteValue'); + + expect( + () => collection.where('foo', WhereFilter.equal, FieldValue.delete), + throwsA(isA()), + ); + }); + + test('rejects custom classes as value', () { + final collection = firestore.collection('whereObject'); + + expect( + () => collection.where('foo', WhereFilter.equal, Object()), + throwsA(isA()), + ); + }); + + test('supports isNull', () async { + final collection = firestore.collection('whereNull'); + + final doc = collection.doc('doc'); + await doc.set({'a': null}); + await collection.doc('doc2').set({'a': 42}); + + final snapshot = await collection + .where( + 'a', + WhereFilter.equal, + null, + ) + .get(); + + expect(snapshot.docs.single.ref, doc); + }); + + test('supports isNotNull', () async { + final collection = firestore.collection('whereNull'); + + final doc = collection.doc('doc'); + await doc.set({'a': 42}); + await collection.doc('doc2').set({'a': null}); + + final snapshot = await collection + .where( + 'a', + WhereFilter.notEqual, + null, + ) + .get(); + + expect(snapshot.docs.single.ref, doc); + }); + + test('rejects invalid null/nan filters', () { + final collection = firestore.collection('whereNull'); + + expect( + () => collection.where('foo', WhereFilter.greaterThan, null), + throwsA(isA()), + ); + + expect( + () => collection.where('foo', WhereFilter.greaterThan, double.nan), + throwsA(isA()), + ); + }); + }); + + group('orderBy', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('accepts asc', () async { + final collection = firestore.collection('orderByAsc'); + + await collection.doc('a').set({'foo': 1}); + await collection.doc('b').set({'foo': 2}); + + final snapshot = await collection.orderBy('foo').get(); + expect(snapshot.docs.map((doc) => doc.id), ['a', 'b']); + + final snapshot2 = await collection.orderBy('foo', descending: true).get(); + expect(snapshot2.docs.map((doc) => doc.id), ['b', 'a']); + }); + + test('rejecs call after cursor', () { + final collection = firestore.collection('orderByAfterCursor'); + + expect( + () => collection.orderBy('foo').startAt(['foo']).orderBy('bar'), + throwsA(isA()), + ); + + expect( + () => collection + .where('foo', WhereFilter.equal, 0) + .startAt(['foo']).where('bar', WhereFilter.equal, 0), + throwsA(isA()), + ); + }); + + test('concatenantes orders', () async { + final collection = firestore.collection('orderByConcat'); + + await collection.doc('d').set({'foo': 1, 'bar': 1}); + await collection.doc('c').set({'foo': 1, 'bar': 2}); + await collection.doc('b').set({'foo': 2, 'bar': 1}); + await collection.doc('a').set({'foo': 2, 'bar': 2}); + + final snapshot = await collection.orderBy('foo').orderBy('bar').get(); + expect(snapshot.docs.map((doc) => doc.id), ['d', 'c', 'b', 'a']); + }); + }); + + group('limit()', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('uses latest limit', () async { + final collection = firestore.collection('limitLatest'); + + await collection.doc('a').set({'foo': 1}); + await collection.doc('b').set({'foo': 2}); + await collection.doc('c').set({'foo': 3}); + + final snapshot = await collection.limit(1).limit(2).get(); + expect(snapshot.docs.map((doc) => doc.id), ['a', 'b']); + }); + }); + + group('limitToLatest()', () { + late Firestore firestore; + + setUp(() => firestore = createInstance()); + + test('uses latest limit', () async { + final collection = firestore.collection('limitLatest'); + + await collection.doc('a').set({'foo': 1}); + await collection.doc('b').set({'foo': 2}); + await collection.doc('c').set({'foo': 3}); + + final snapshot = + await collection.orderBy('foo').limitToLast(1).limitToLast(2).get(); + expect(snapshot.docs.map((doc) => doc.id), ['c', 'b']); + }); + }); +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.ts b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.ts new file mode 100644 index 0000000..b7e3828 --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/query_test.ts @@ -0,0 +1,2878 @@ +// Copyright 2017 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {DocumentData} from '@google-cloud/firestore'; + +import {describe, it, beforeEach, afterEach} from 'mocha'; +import {expect, use} from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as extend from 'extend'; + +import {firestore, google} from '../protos/firestore_v1_proto_api'; +import { + DocumentReference, + FieldPath, + FieldValue, + Firestore, + Query, + QueryDocumentSnapshot, + setLogFunction, + Timestamp, +} from '../src'; +import {setTimeoutHandler} from '../src/backoff'; +import {DocumentSnapshot, DocumentSnapshotBuilder} from '../src/document'; +import {QualifiedResourcePath} from '../src/path'; +import { + ApiOverride, + collect, + createInstance, + document, + InvalidApiUsage, + Post, + postConverter, + requestEquals, + response, + set, + stream, + streamWithoutEnd, + verifyInstance, + writeResult, +} from './util/helpers'; + +import {GoogleError} from 'google-gax'; +import api = google.firestore.v1; +import protobuf = google.protobuf; +import {Filter} from '../src/filter'; +import {Deferred} from '../src/util'; + +const PROJECT_ID = 'test-project'; +const DATABASE_ROOT = `projects/${PROJECT_ID}/databases/(default)`; + +// Change the argument to 'console.log' to enable debug output. +setLogFunction(null); + +use(chaiAsPromised); + +function snapshot( + relativePath: string, + data: DocumentData +): Promise { + return createInstance().then(firestore => { + const path = QualifiedResourcePath.fromSlashSeparatedString( + `${DATABASE_ROOT}/documents/${relativePath}` + ); + const ref = new DocumentReference(firestore, path); + const snapshot = new DocumentSnapshotBuilder(ref); + snapshot.fieldsProto = firestore['_serializer']!.encodeFields(data); + snapshot.readTime = Timestamp.fromMillis(0); + snapshot.createTime = Timestamp.fromMillis(0); + snapshot.updateTime = Timestamp.fromMillis(0); + return snapshot.build(); + }); +} + +function where(filter: api.StructuredQuery.IFilter): api.IStructuredQuery { + return { + where: filter, + }; +} + +export function fieldFiltersQuery( + fieldPath: string, + op: api.StructuredQuery.FieldFilter.Operator, + value: string | api.IValue, + ...fieldPathOpAndValues: Array< + string | api.StructuredQuery.FieldFilter.Operator | string | api.IValue + > +): api.IStructuredQuery { + return { + where: fieldFilters(fieldPath, op, value, ...fieldPathOpAndValues), + }; +} + +export function fieldFilters( + fieldPath: string, + op: api.StructuredQuery.FieldFilter.Operator, + value: string | api.IValue, + ...fieldPathOpAndValues: Array< + string | api.StructuredQuery.FieldFilter.Operator | string | api.IValue + > +): api.StructuredQuery.IFilter { + const filters: api.StructuredQuery.IFilter[] = []; + + fieldPathOpAndValues = [fieldPath, op, value, ...fieldPathOpAndValues]; + + for (let i = 0; i < fieldPathOpAndValues.length; i += 3) { + fieldPath = fieldPathOpAndValues[i] as string; + op = fieldPathOpAndValues[ + i + 1 + ] as api.StructuredQuery.FieldFilter.Operator; + value = fieldPathOpAndValues[i + 2] as string | api.IValue; + + const filter: api.StructuredQuery.IFieldFilter = { + field: { + fieldPath, + }, + op, + }; + + if (typeof value === 'string') { + filter.value = {stringValue: value}; + } else { + filter.value = value; + } + + filters.push({fieldFilter: filter}); + } + + if (filters.length === 1) { + return { + fieldFilter: filters[0].fieldFilter, + }; + } else { + return { + compositeFilter: { + op: 'AND', + filters, + }, + }; + } +} + +export function fieldFilter( + fieldPath: string, + op: api.StructuredQuery.FieldFilter.Operator, + value: string | api.IValue +): api.StructuredQuery.IFilter { + return fieldFilters(fieldPath, op, value); +} + +export function compositeFilter( + op: api.StructuredQuery.CompositeFilter.Operator, + ...filters: api.StructuredQuery.IFilter[] +): api.StructuredQuery.IFilter { + return { + compositeFilter: { + op: op, + filters, + }, + }; +} + +export function orFilter( + op: api.StructuredQuery.CompositeFilter.Operator, + ...filters: api.StructuredQuery.IFilter[] +): api.StructuredQuery.IFilter { + return compositeFilter('OR', ...filters); +} + +export function andFilter( + op: api.StructuredQuery.CompositeFilter.Operator, + ...filters: api.StructuredQuery.IFilter[] +): api.StructuredQuery.IFilter { + return compositeFilter('AND', ...filters); +} + +function unaryFiltersQuery( + fieldPath: string, + equals: 'IS_NAN' | 'IS_NULL' | 'IS_NOT_NAN' | 'IS_NOT_NULL', + ...fieldPathsAndEquals: string[] +): api.IStructuredQuery { + return { + where: unaryFilters(fieldPath, equals, ...fieldPathsAndEquals), + }; +} + +function unaryFilters( + fieldPath: string, + equals: 'IS_NAN' | 'IS_NULL' | 'IS_NOT_NAN' | 'IS_NOT_NULL', + ...fieldPathsAndEquals: string[] +): api.StructuredQuery.IFilter { + const filters: api.StructuredQuery.IFilter[] = []; + + fieldPathsAndEquals.unshift(fieldPath, equals); + + for (let i = 0; i < fieldPathsAndEquals.length; i += 2) { + const fieldPath = fieldPathsAndEquals[i]; + const equals = fieldPathsAndEquals[i + 1]; + + expect(equals).to.be.oneOf([ + 'IS_NAN', + 'IS_NULL', + 'IS_NOT_NAN', + 'IS_NOT_NULL', + ]); + + filters.push({ + unaryFilter: { + field: { + fieldPath, + }, + op: equals as 'IS_NAN' | 'IS_NULL' | 'IS_NOT_NAN' | 'IS_NOT_NULL', + }, + }); + } + + if (filters.length === 1) { + return { + unaryFilter: filters[0].unaryFilter, + }; + } else { + return { + compositeFilter: { + op: 'AND', + filters, + }, + }; + } +} + +export function orderBy( + fieldPath: string, + direction: api.StructuredQuery.Direction, + ...fieldPathAndOrderBys: Array +): api.IStructuredQuery { + const orderBy: api.StructuredQuery.IOrder[] = []; + + fieldPathAndOrderBys.unshift(fieldPath, direction); + + for (let i = 0; i < fieldPathAndOrderBys.length; i += 2) { + const fieldPath = fieldPathAndOrderBys[i] as string; + const direction = fieldPathAndOrderBys[ + i + 1 + ] as api.StructuredQuery.Direction; + orderBy.push({ + field: { + fieldPath, + }, + direction, + }); + } + + return {orderBy}; +} + +export function limit(n: number): api.IStructuredQuery { + return { + limit: { + value: n, + }, + }; +} + +function offset(n: number): api.IStructuredQuery { + return { + offset: n, + }; +} + +export function allDescendants(kindless = false): api.IStructuredQuery { + if (kindless) { + return {from: [{allDescendants: true}]}; + } + return {from: [{collectionId: 'collectionId', allDescendants: true}]}; +} + +export function select(...fields: string[]): api.IStructuredQuery { + const select: api.StructuredQuery.IProjection = { + fields: [], + }; + + for (const field of fields) { + select.fields!.push({fieldPath: field}); + } + + return {select}; +} + +export function startAt( + before: boolean, + ...values: Array +): api.IStructuredQuery { + const cursor: api.ICursor = { + values: [], + }; + + if (before) { + cursor.before = true; + } + + for (const value of values) { + if (typeof value === 'string') { + cursor.values!.push({ + stringValue: value, + }); + } else { + cursor.values!.push(value); + } + } + + return {startAt: cursor}; +} + +function endAt( + before: boolean, + ...values: Array +): api.IStructuredQuery { + const cursor: api.ICursor = { + values: [], + }; + + if (before) { + cursor.before = true; + } + + for (const value of values) { + if (typeof value === 'string') { + cursor.values!.push({ + stringValue: value, + }); + } else { + cursor.values!.push(value); + } + } + + return {endAt: cursor}; +} + +/** + * Returns the timestamp value for the provided readTimes, or the default + * readTime value used in tests if no values are provided. + */ +export function readTime( + seconds?: number, + nanos?: number +): protobuf.ITimestamp { + if (seconds === undefined && nanos === undefined) { + return {seconds: '5', nanos: 6}; + } + return {seconds: String(seconds), nanos: nanos}; +} + +export function queryEqualsWithParent( + actual: api.IRunQueryRequest | undefined, + parent: string, + ...protoComponents: api.IStructuredQuery[] +): void { + expect(actual).to.not.be.undefined; + + if (parent !== '') { + parent = '/' + parent; + } + + const query: api.IRunQueryRequest = { + parent: DATABASE_ROOT + '/documents' + parent, + structuredQuery: {}, + }; + + for (const protoComponent of protoComponents) { + extend(true, query.structuredQuery, protoComponent); + } + + // We add the `from` selector here in order to avoid setting collectionId on + // kindless queries. + if (query.structuredQuery!.from === undefined) { + query.structuredQuery!.from = [ + { + collectionId: 'collectionId', + }, + ]; + } + + // 'extend' removes undefined fields in the request object. The backend + // ignores these fields, but we need to manually strip them before we compare + // the expected and the actual request. + actual = extend(true, {}, actual); + expect(actual).to.deep.eq(query); +} + +export function queryEquals( + actual: api.IRunQueryRequest | undefined, + ...protoComponents: api.IStructuredQuery[] +): void { + queryEqualsWithParent(actual, /* parent= */ '', ...protoComponents); +} + +function bundledQueryEquals( + actual: firestore.IBundledQuery | undefined, + limitType: firestore.BundledQuery.LimitType | undefined, + ...protoComponents: api.IStructuredQuery[] +) { + expect(actual).to.not.be.undefined; + + const query: firestore.IBundledQuery = { + parent: DATABASE_ROOT + '/documents', + structuredQuery: { + from: [ + { + collectionId: 'collectionId', + }, + ], + }, + limitType, + }; + + for (const protoComponent of protoComponents) { + extend(true, query.structuredQuery, protoComponent); + } + + // 'extend' removes undefined fields in the request object. The backend + // ignores these fields, but we need to manually strip them before we compare + // the expected and the actual request. + actual = extend(true, {}, actual); + expect(actual).to.deep.eq(query); +} + +export function result( + documentId: string, + setDone?: boolean +): api.IRunQueryResponse { + if (setDone) { + return { + document: document(documentId), + readTime: {seconds: 5, nanos: 6}, + done: setDone, + }; + } else { + return {document: document(documentId), readTime: {seconds: 5, nanos: 6}}; + } +} + +describe('query interface', () => { + let firestore: Firestore; + + beforeEach(() => { + setTimeoutHandler(setImmediate); + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(async () => { + await verifyInstance(firestore); + setTimeoutHandler(setTimeout); + }); + + it('has isEqual() method', () => { + const queryA = firestore.collection('collectionId'); + const queryB = firestore.collection('collectionId'); + + const queryEquals = (equals: Query[], notEquals: Query[]) => { + for (let i = 0; i < equals.length; ++i) { + for (const equal of equals) { + expect(equals[i].isEqual(equal)).to.be.true; + expect(equal.isEqual(equals[i])).to.be.true; + } + + for (const notEqual of notEquals) { + expect(equals[i].isEqual(notEqual)).to.be.false; + expect(notEqual.isEqual(equals[i])).to.be.false; + } + } + }; + + queryEquals( + [queryA.where('a', '==', '1'), queryB.where('a', '==', '1')], + [queryA.where('a', '=' as InvalidApiUsage, 1)] + ); + + queryEquals( + [ + queryA.where('a', '==', '1').where('b', '==', 2), + queryB.where('a', '==', '1').where('b', '==', 2), + ], + [] + ); + + queryEquals( + [ + queryA.orderBy('__name__'), + queryA.orderBy('__name__', 'asc'), + queryB.orderBy('__name__', 'ASC' as InvalidApiUsage), + queryB.orderBy(FieldPath.documentId()), + ], + [queryA.orderBy('foo'), queryB.orderBy(FieldPath.documentId(), 'desc')] + ); + + queryEquals( + [queryA.limit(0), queryB.limit(0).limit(0)], + [queryA, queryB.limit(10)] + ); + + queryEquals( + [queryA.offset(0), queryB.offset(0).offset(0)], + [queryA, queryB.offset(10)] + ); + + queryEquals( + [queryA.orderBy('foo').startAt('a'), queryB.orderBy('foo').startAt('a')], + [ + queryA.orderBy('foo').startAfter('a'), + queryB.orderBy('foo').endAt('a'), + queryA.orderBy('foo').endBefore('a'), + queryB.orderBy('foo').startAt('b'), + queryA.orderBy('bar').startAt('a'), + ] + ); + + queryEquals( + [ + queryA.orderBy('foo').startAfter('a'), + queryB.orderBy('foo').startAfter('a'), + ], + [ + queryA.orderBy('foo').startAfter('b'), + queryB.orderBy('bar').startAfter('a'), + ] + ); + + queryEquals( + [ + queryA.orderBy('foo').endBefore('a'), + queryB.orderBy('foo').endBefore('a'), + ], + [ + queryA.orderBy('foo').endBefore('b'), + queryB.orderBy('bar').endBefore('a'), + ] + ); + + queryEquals( + [queryA.orderBy('foo').endAt('a'), queryB.orderBy('foo').endAt('a')], + [queryA.orderBy('foo').endAt('b'), queryB.orderBy('bar').endAt('a')] + ); + + queryEquals( + [ + queryA.orderBy('foo').orderBy('__name__').startAt('b', 'c'), + queryB.orderBy('foo').orderBy('__name__').startAt('b', 'c'), + ], + [] + ); + }); + + it('accepts all variations', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery('foo', 'EQUAL', 'bar'), + orderBy('foo', 'ASCENDING'), + limit(10) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', 'bar'); + query = query.orderBy('foo'); + query = query.limit(10); + return query.get().then(results => { + expect(results.query).to.equal(query); + expect(results.size).to.equal(0); + expect(results.empty).to.be.true; + }); + }); + }); + + it('supports empty gets', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return stream({readTime: {seconds: 5, nanos: 6}}); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + return query.get().then(results => { + expect(results.size).to.equal(0); + expect(results.empty).to.be.true; + expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + }); + }); + }); + + it('retries on stream failure', () => { + let attempts = 0; + const overrides: ApiOverride = { + runQuery: () => { + ++attempts; + throw new Error('Expected error'); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + return query + .get() + .then(() => { + throw new Error('Unexpected success'); + }) + .catch(() => { + expect(attempts).to.equal(5); + }); + }); + }); + + it('supports empty streams', callback => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return stream({readTime: {seconds: 5, nanos: 6}}); + }, + }; + + createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + query + .stream() + .on('data', () => { + callback(Error('Unexpected document')); + }) + .on('end', () => { + callback(); + }); + }); + }); + + it('returns results', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return stream(result('first'), result('second')); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + return query.get().then(results => { + expect(results.size).to.equal(2); + expect(results.empty).to.be.false; + expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + expect(results.docs[0].id).to.equal('first'); + expect(results.docs[1].id).to.equal('second'); + expect(results.docChanges()).to.have.length(2); + + let count = 0; + + results.forEach(doc => { + expect(doc instanceof DocumentSnapshot).to.be.true; + expect(doc.createTime.isEqual(new Timestamp(1, 2))).to.be.true; + expect(doc.updateTime.isEqual(new Timestamp(3, 4))).to.be.true; + expect(doc.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + ++count; + }); + + expect(2).to.equal(count); + }); + }); + }); + + // Test Logical Termination on get() + it('successful return without ending the stream on get()', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return streamWithoutEnd(result('first'), result('second', true)); + }, + }; + + let counter = 0; + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + return query.get().then(results => { + expect(++counter).to.equal(1); + expect(results.size).to.equal(2); + expect(results.empty).to.be.false; + expect(results.readTime.isEqual(new Timestamp(5, 6))).to.be.true; + expect(results.docs[0].id).to.equal('first'); + expect(results.docs[1].id).to.equal('second'); + expect(results.docChanges()).to.have.length(2); + }); + }); + }); + + it('handles stream exception at initialization', () => { + const query = firestore.collection('collectionId'); + + query._stream = () => { + throw new Error('Expected error'); + }; + + return query + .get() + .then(() => { + throw new Error('Unexpected success in Promise'); + }) + .catch(err => { + expect(err.message).to.equal('Expected error'); + }); + }); + + it('handles stream exception during initialization', () => { + const overrides: ApiOverride = { + runQuery: () => { + return stream(new Error('Expected error')); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return firestore + .collection('collectionId') + .get() + .then(() => { + throw new Error('Unexpected success in Promise'); + }) + .catch(err => { + expect(err.message).to.equal('Expected error'); + }); + }); + }); + + it('handles stream exception after initialization (with get())', () => { + const responses = [ + () => stream(result('first'), new Error('Expected error')), + () => stream(result('second')), + ]; + const overrides: ApiOverride = { + runQuery: () => responses.shift()!(), + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return firestore + .collection('collectionId') + .get() + .then(snap => { + expect(snap.size).to.equal(2); + expect(snap.docs[0].id).to.equal('first'); + expect(snap.docs[1].id).to.equal('second'); + }); + }); + }); + + it('handles stream exception after initialization (with stream())', done => { + const responses = [ + () => stream(result('first'), new Error('Expected error')), + () => stream(result('second')), + ]; + const overrides: ApiOverride = { + runQuery: () => responses.shift()!(), + }; + + createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const result = firestore.collection('collectionId').stream(); + + let resultCount = 0; + result.on('data', doc => { + expect(doc).to.be.an.instanceOf(QueryDocumentSnapshot); + ++resultCount; + }); + result.on('end', () => { + expect(resultCount).to.equal(2); + done(); + }); + }); + }); + + it('streams results', callback => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return stream(result('first'), result('second')); + }, + }; + + createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId'); + let received = 0; + + query + .stream() + .on('data', doc => { + expect(doc).to.be.an.instanceOf(DocumentSnapshot); + ++received; + }) + .on('end', () => { + expect(received).to.equal(2); + callback(); + }); + }); + }); + + // Test Logical Termination on stream() + it('successful return without ending the stream on stream()', callback => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request); + return streamWithoutEnd(result('first'), result('second', true)); + }, + }; + + let endCounter = 0; + createInstance(overrides).then(firestore => { + const query = firestore.collection('collectionId'); + let received = 0; + + query + .stream() + .on('data', doc => { + expect(doc).to.be.an.instanceOf(DocumentSnapshot); + ++received; + }) + .on('end', () => { + expect(received).to.equal(2); + ++endCounter; + setImmediate(() => { + expect(endCounter).to.equal(1); + callback(); + }); + }); + }); + }); + + it('for Query.withConverter()', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + commit: request => { + const expectedRequest = set({ + document: doc, + }); + requestEquals(request, expectedRequest); + return response(writeResult(1)); + }, + runQuery: request => { + queryEquals(request, fieldFiltersQuery('title', 'EQUAL', 'post')); + return stream({document: doc, readTime: {seconds: 5, nanos: 6}}); + }, + }; + + return createInstance(overrides).then(async firestoreInstance => { + firestore = firestoreInstance; + await firestore + .collection('collectionId') + .doc('documentId') + .set({title: 'post', author: 'author'}); + const posts = await firestore + .collection('collectionId') + .where('title', '==', 'post') + .withConverter(postConverter) + .get(); + expect(posts.size).to.equal(1); + expect(posts.docs[0].data().toString()).to.equal('post, by author'); + }); + }); + + it('propagates withConverter() through QueryOptions', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, fieldFiltersQuery('title', 'EQUAL', 'post')); + return stream({document: doc, readTime: {seconds: 5, nanos: 6}}); + }, + }; + + return createInstance(overrides).then(async firestoreInstance => { + firestore = firestoreInstance; + const coll = await firestore + .collection('collectionId') + .withConverter(postConverter); + + // Verify that the converter is carried through. + const posts = await coll.where('title', '==', 'post').get(); + expect(posts.size).to.equal(1); + expect(posts.docs[0].data().toString()).to.equal('post, by author'); + }); + }); + + it('withConverter(null) applies the default converter', async () => { + const doc = document('documentId', 'author', 'author', 'title', 'post'); + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, fieldFiltersQuery('title', 'EQUAL', 'post')); + return stream({document: doc, readTime: {seconds: 5, nanos: 6}}); + }, + }; + + return createInstance(overrides).then(async firestoreInstance => { + firestore = firestoreInstance; + const coll = await firestore + .collection('collectionId') + .withConverter(postConverter) + .withConverter(null); + + const posts = await coll.where('title', '==', 'post').get(); + expect(posts.size).to.equal(1); + expect(posts.docs[0].data()).to.not.be.instanceOf(Post); + }); + }); + + it('supports OR query with cursor', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + where( + compositeFilter( + 'OR', + fieldFilter('a', 'GREATER_THAN', {integerValue: 10}), + unaryFilters('b', 'IS_NOT_NULL') + ) + ), + limit(3), + orderBy('a', 'ASCENDING'), + startAt(true, {integerValue: 1}) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query + .where( + Filter.or(Filter.where('a', '>', 10), Filter.where('b', '!=', null)) + ) + .orderBy('a') + .startAt(1) + .limit(3); + return query.get(); + }); + }); +}); + +describe('where() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('generates proto', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, fieldFiltersQuery('foo', 'EQUAL', 'bar')); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', 'bar'); + return query.get(); + }); + }); + + it('concatenates all accepted filters', () => { + const arrValue: api.IValue = { + arrayValue: { + values: [ + { + stringValue: 'barArray', + }, + ], + }, + }; + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery( + 'fooSmaller', + 'LESS_THAN', + 'barSmaller', + 'fooSmallerOrEquals', + 'LESS_THAN_OR_EQUAL', + 'barSmallerOrEquals', + 'fooEquals', + 'EQUAL', + 'barEquals', + 'fooEqualsLong', + 'EQUAL', + 'barEqualsLong', + 'fooGreaterOrEquals', + 'GREATER_THAN_OR_EQUAL', + 'barGreaterOrEquals', + 'fooGreater', + 'GREATER_THAN', + 'barGreater', + 'fooContains', + 'ARRAY_CONTAINS', + 'barContains', + 'fooIn', + 'IN', + arrValue, + 'fooContainsAny', + 'ARRAY_CONTAINS_ANY', + arrValue, + 'fooNotEqual', + 'NOT_EQUAL', + 'barEqualsLong', + 'fooNotIn', + 'NOT_IN', + arrValue + ) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('fooSmaller', '<', 'barSmaller'); + query = query.where('fooSmallerOrEquals', '<=', 'barSmallerOrEquals'); + query = query.where('fooEquals', '=' as InvalidApiUsage, 'barEquals'); + query = query.where('fooEqualsLong', '==', 'barEqualsLong'); + query = query.where('fooGreaterOrEquals', '>=', 'barGreaterOrEquals'); + query = query.where('fooGreater', '>', 'barGreater'); + query = query.where('fooContains', 'array-contains', 'barContains'); + query = query.where('fooIn', 'in', ['barArray']); + query = query.where('fooContainsAny', 'array-contains-any', ['barArray']); + query = query.where('fooNotEqual', '!=', 'barEqualsLong'); + query = query.where('fooNotIn', 'not-in', ['barArray']); + return query.get(); + }); + }); + + it('accepts object', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery('foo', 'EQUAL', { + mapValue: { + fields: { + foo: {stringValue: 'bar'}, + }, + }, + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', {foo: 'bar'}); + return query.get(); + }); + }); + + it('supports field path objects for field paths', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery( + 'foo.bar', + 'EQUAL', + 'foobar', + 'bar.foo', + 'EQUAL', + 'foobar' + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo.bar', '==', 'foobar'); + query = query.where(new FieldPath('bar', 'foo'), '==', 'foobar'); + return query.get(); + }); + }); + + it('supports strings for FieldPath.documentId()', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery('__name__', 'EQUAL', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/foo', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where(FieldPath.documentId(), '==', 'foo'); + return query.get(); + }); + }); + + it('supports reference array for IN queries', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery('__name__', 'IN', { + arrayValue: { + values: [ + { + referenceValue: `projects/${PROJECT_ID}/databases/(default)/documents/collectionId/foo`, + }, + { + referenceValue: `projects/${PROJECT_ID}/databases/(default)/documents/collectionId/bar`, + }, + ], + }, + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const collection = firestore.collection('collectionId'); + const query = collection.where(FieldPath.documentId(), 'in', [ + 'foo', + collection.doc('bar'), + ]); + return query.get(); + }); + }); + + it('Fields of IN queries are not used in implicit order by', async () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + fieldFiltersQuery('foo', 'IN', { + arrayValue: { + values: [ + { + stringValue: 'bar', + }, + ], + }, + }), + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc1', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(async firestoreInstance => { + firestore = firestoreInstance; + const collection = firestore.collection('collectionId'); + const query = collection + .where('foo', 'in', ['bar']) + .startAt(await snapshot('collectionId/doc1', {})); + return query.get(); + }); + }); + + it('validates references for in/not-in queries', () => { + const query = firestore.collection('collectionId'); + + expect(() => { + query.where(FieldPath.documentId(), 'in', ['foo', 42]); + }).to.throw( + 'The corresponding value for FieldPath.documentId() must be a string or a DocumentReference, but was "42".' + ); + + expect(() => { + query.where(FieldPath.documentId(), 'in', 42); + }).to.throw( + "Invalid Query. A non-empty array is required for 'in' filters." + ); + + expect(() => { + query.where(FieldPath.documentId(), 'in', []); + }).to.throw( + "Invalid Query. A non-empty array is required for 'in' filters." + ); + + expect(() => { + query.where(FieldPath.documentId(), 'not-in', ['foo', 42]); + }).to.throw( + 'The corresponding value for FieldPath.documentId() must be a string or a DocumentReference, but was "42".' + ); + + expect(() => { + query.where(FieldPath.documentId(), 'not-in', 42); + }).to.throw( + "Invalid Query. A non-empty array is required for 'not-in' filters." + ); + + expect(() => { + query.where(FieldPath.documentId(), 'not-in', []); + }).to.throw( + "Invalid Query. A non-empty array is required for 'not-in' filters." + ); + }); + + it('validates query operator for FieldPath.document()', () => { + const query = firestore.collection('collectionId'); + + expect(() => { + query.where(FieldPath.documentId(), 'array-contains', query.doc()); + }).to.throw( + "Invalid Query. You can't perform 'array-contains' queries on FieldPath.documentId()." + ); + + expect(() => { + query.where(FieldPath.documentId(), 'array-contains-any', query.doc()); + }).to.throw( + "Invalid Query. You can't perform 'array-contains-any' queries on FieldPath.documentId()." + ); + }); + + it('rejects custom objects for field paths', () => { + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where({} as InvalidApiUsage, '==', 'bar'); + return query.get(); + }).to.throw( + 'Value for argument "fieldPath" is not a valid field path. Paths can only be specified as strings or via a FieldPath object.' + ); + + class FieldPath {} + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where(new FieldPath() as InvalidApiUsage, '==', 'bar'); + return query.get(); + }).to.throw( + 'Detected an object of type "FieldPath" that doesn\'t match the expected instance.' + ); + }); + + it('rejects field paths as value', () => { + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', new FieldPath('bar')); + return query.get(); + }).to.throw( + 'Value for argument "value" is not a valid query constraint. Cannot use object of type "FieldPath" as a Firestore value.' + ); + }); + + it('rejects field delete as value', () => { + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', FieldValue.delete()); + return query.get(); + }).to.throw( + 'FieldValue.delete() must appear at the top-level and can only be used in update() or set() with {merge:true}.' + ); + }); + + it('rejects custom classes as value', () => { + class Foo {} + class FieldPath {} + class FieldValue {} + class GeoPoint {} + class DocumentReference {} + + const query = firestore.collection('collectionId'); + + expect(() => { + query.where('foo', '==', new Foo()).get(); + }).to.throw( + 'Value for argument "value" is not a valid Firestore document. Couldn\'t serialize object of type "Foo". Firestore doesn\'t support JavaScript objects with custom prototypes (i.e. objects that were created via the "new" operator).' + ); + + expect(() => { + query.where('foo', '==', new FieldPath()).get(); + }).to.throw( + 'Detected an object of type "FieldPath" that doesn\'t match the expected instance.' + ); + + expect(() => { + query.where('foo', '==', new FieldValue()).get(); + }).to.throw( + 'Detected an object of type "FieldValue" that doesn\'t match the expected instance.' + ); + + expect(() => { + query.where('foo', '==', new DocumentReference()).get(); + }).to.throw( + 'Detected an object of type "DocumentReference" that doesn\'t match the expected instance.' + ); + + expect(() => { + query.where('foo', '==', new GeoPoint()).get(); + }).to.throw( + 'Detected an object of type "GeoPoint" that doesn\'t match the expected instance.' + ); + }); + + it('supports unary filters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + unaryFiltersQuery('foo', 'IS_NAN', 'bar', 'IS_NULL') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '==', NaN); + query = query.where('bar', '==', null); + return query.get(); + }); + }); + + it('supports unary filters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + unaryFiltersQuery('foo', 'IS_NOT_NAN', 'bar', 'IS_NOT_NULL') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '!=', NaN); + query = query.where('bar', '!=', null); + return query.get(); + }); + }); + + it('rejects invalid NaN filter', () => { + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '>', NaN); + return query.get(); + }).to.throw( + "Invalid query. You can only perform '==' and '!=' comparisons on NaN." + ); + }); + + it('rejects invalid Null filter', () => { + expect(() => { + let query: Query = firestore.collection('collectionId'); + query = query.where('foo', '>', null); + return query.get(); + }).to.throw( + "Invalid query. You can only perform '==' and '!=' comparisons on Null." + ); + }); + + it('verifies field path', () => { + let query: Query = firestore.collection('collectionId'); + expect(() => { + query = query.where('foo.', '==', 'foobar'); + }).to.throw( + 'Value for argument "fieldPath" is not a valid field path. Paths must not start or end with ".".' + ); + }); + + it('verifies operator', () => { + let query: Query = firestore.collection('collectionId'); + expect(() => { + query = query.where('foo', '@' as InvalidApiUsage, 'foobar'); + }).to.throw( + 'Value for argument "opStr" is invalid. Acceptable values are: <, <=, ==, !=, >, >=, array-contains, in, not-in, array-contains-any' + ); + }); + + it('supports composite filters - outer OR', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + where( + compositeFilter( + 'OR', + fieldFilter('a', 'EQUAL', {integerValue: 10}), + compositeFilter( + 'AND', + fieldFilter('b', 'EQUAL', {integerValue: 20}), + fieldFilter('c', 'EQUAL', {integerValue: 30}), + compositeFilter( + 'OR', + fieldFilter('d', 'EQUAL', {integerValue: 40}), + fieldFilter('e', 'GREATER_THAN', {integerValue: 50}) + ), + unaryFilters('f', 'IS_NAN') + ) + ) + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where( + Filter.or( + Filter.where('a', '==', 10), + Filter.and( + Filter.where('b', '==', 20), + Filter.where('c', '==', 30), + Filter.or(Filter.where('d', '==', 40), Filter.where('e', '>', 50)), + Filter.or(Filter.where('f', '==', NaN)), + Filter.and(Filter.or()) + ) + ) + ); + return query.get(); + }); + }); + + it('supports composite filters - outer AND', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + where( + compositeFilter( + 'AND', + fieldFilter('a', 'EQUAL', {integerValue: 10}), + compositeFilter( + 'OR', + fieldFilter('b', 'EQUAL', {integerValue: 20}), + fieldFilter('c', 'EQUAL', {integerValue: 30}), + compositeFilter( + 'AND', + fieldFilter('d', 'EQUAL', {integerValue: 40}), + fieldFilter('e', 'GREATER_THAN', {integerValue: 50}) + ), + unaryFilters('f', 'IS_NAN') + ) + ) + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where( + Filter.and( + Filter.where('a', '==', 10), + Filter.or( + Filter.where('b', '==', 20), + Filter.where('c', '==', 30), + Filter.and(Filter.where('d', '==', 40), Filter.where('e', '>', 50)), + Filter.and(Filter.where('f', '==', NaN)), + Filter.or(Filter.and()) + ) + ) + ); + return query.get(); + }); + }); + + it('supports implicit AND filters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + where( + compositeFilter( + 'AND', + fieldFilter('a', 'EQUAL', {integerValue: 10}), + fieldFilter('b', 'EQUAL', {integerValue: 20}), + fieldFilter('c', 'EQUAL', {integerValue: 30}), + fieldFilter('d', 'EQUAL', {integerValue: 40}), + fieldFilter('e', 'GREATER_THAN', {integerValue: 50}), + unaryFilters('f', 'IS_NAN') + ) + ) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query + .where('a', '==', 10) + .where('b', '==', 20) + .where('c', '==', 30) + .where('d', '==', 40) + .where('e', '>', 50) + .where('f', '==', NaN); + return query.get(); + }); + }); + + it('supports single filter composite filters', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + where(fieldFilter('a', 'GREATER_THAN', {integerValue: 10})) + ); + return stream(); + }, + }; + + const filters = [ + Filter.and(Filter.where('a', '>', 10)), + Filter.or(Filter.where('a', '>', 10)), + Filter.or(Filter.and(Filter.or(Filter.and(Filter.where('a', '>', 10))))), + ]; + + return Promise.all( + filters.map(filter => + createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.where(filter); + return query.get(); + }) + ) + ); + }); +}); + +describe('orderBy() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('accepts empty string', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'ASCENDING')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo'); + return query.get(); + }); + }); + + it('accepts asc', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'ASCENDING')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo', 'asc'); + return query.get(); + }); + }); + + it('accepts desc', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'DESCENDING')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo', 'desc'); + return query.get(); + }); + }); + + it('verifies order', () => { + let query: Query = firestore.collection('collectionId'); + expect(() => { + query = query.orderBy('foo', 'foo' as InvalidApiUsage); + }).to.throw( + 'Value for argument "directionStr" is invalid. Acceptable values are: asc, desc' + ); + }); + + it('accepts field path', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo.bar', 'ASCENDING', 'bar.foo', 'ASCENDING') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo.bar'); + query = query.orderBy(new FieldPath('bar', 'foo')); + return query.get(); + }); + }); + + it('verifies field path', () => { + let query: Query = firestore.collection('collectionId'); + expect(() => { + query = query.orderBy('foo.'); + }).to.throw( + 'Value for argument "fieldPath" is not a valid field path. Paths must not start or end with ".".' + ); + }); + + it('rejects call after cursor', () => { + let query: Query = firestore.collection('collectionId'); + + return snapshot('collectionId/doc', {foo: 'bar'}).then(snapshot => { + expect(() => { + query = query.orderBy('foo').startAt('foo').orderBy('foo'); + }).to.throw( + 'Cannot specify an orderBy() constraint after calling startAt(), startAfter(), endBefore() or endAt().' + ); + + expect(() => { + query = query + .where('foo', '>', 'bar') + .startAt(snapshot) + .where('foo', '>', 'bar'); + }).to.throw( + 'Cannot specify a where() filter after calling startAt(), startAfter(), endBefore() or endAt().' + ); + + expect(() => { + query = query.orderBy('foo').endAt('foo').orderBy('foo'); + }).to.throw( + 'Cannot specify an orderBy() constraint after calling startAt(), startAfter(), endBefore() or endAt().' + ); + + expect(() => { + query = query + .where('foo', '>', 'bar') + .endAt(snapshot) + .where('foo', '>', 'bar'); + }).to.throw( + 'Cannot specify a where() filter after calling startAt(), startAfter(), endBefore() or endAt().' + ); + }); + }); + + it('concatenates orders', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy( + 'foo', + 'ASCENDING', + 'bar', + 'DESCENDING', + 'foobar', + 'ASCENDING' + ) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query + .orderBy('foo', 'asc') + .orderBy('bar', 'desc') + .orderBy('foobar'); + return query.get(); + }); + }); +}); + +describe('limit() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('generates proto', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, limit(10)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.limit(10); + return query.get(); + }); + }); + + it('expects number', () => { + const query = firestore.collection('collectionId'); + expect(() => query.limit(Infinity)).to.throw( + 'Value for argument "limit" is not a valid integer.' + ); + }); + + it('uses latest limit', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, limit(3)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.limit(1).limit(2).limit(3); + return query.get(); + }); + }); +}); + +describe('limitToLast() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('reverses order constraints', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'DESCENDING'), limit(10)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').limitToLast(10); + return query.get(); + }); + }); + + it('reverses cursors', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'DESCENDING'), + startAt(true, 'end'), + endAt(false, 'start'), + limit(10) + ); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query + .orderBy('foo') + .startAt('start') + .endAt('end') + .limitToLast(10); + return query.get(); + }); + }); + + it('reverses results', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'DESCENDING'), limit(2)); + return stream(result('second'), result('first')); + }, + }; + + return createInstance(overrides).then(async firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').limitToLast(2); + const result = await query.get(); + expect(result.docs[0].id).to.equal('first'); + expect(result.docs[1].id).to.equal('second'); + }); + }); + + it('expects number', () => { + const query = firestore.collection('collectionId'); + expect(() => query.limitToLast(Infinity)).to.throw( + 'Value for argument "limitToLast" is not a valid integer.' + ); + }); + + it('requires at least one ordering constraints', () => { + const query = firestore.collection('collectionId'); + const result = query.limitToLast(1).get(); + return expect(result).to.eventually.be.rejectedWith( + 'limitToLast() queries require specifying at least one orderBy() clause.' + ); + }); + + it('rejects Query.stream()', () => { + const query = firestore.collection('collectionId'); + expect(() => query.limitToLast(1).stream()).to.throw( + 'Query results for queries that include limitToLast() constraints cannot be streamed. Use Query.get() instead.' + ); + }); + + it('uses latest limitToLast', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'DESCENDING'), limit(3)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').limitToLast(1).limitToLast(2).limitToLast(3); + return query.get(); + }); + }); + + it('converts to bundled query without order reversing', () => { + return createInstance().then(firestore => { + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').limitToLast(10); + const bundledQuery = query._toBundledQuery(); + bundledQueryEquals( + bundledQuery, + 'LAST', + orderBy('foo', 'ASCENDING'), + limit(10) + ); + }); + }); + + it('converts to bundled query without cursor flipping', () => { + return createInstance().then(firestore => { + let query: Query = firestore.collection('collectionId'); + query = query + .orderBy('foo') + .startAt('start') + .endAt('end') + .limitToLast(10); + const bundledQuery = query._toBundledQuery(); + bundledQueryEquals( + bundledQuery, + 'LAST', + orderBy('foo', 'ASCENDING'), + limit(10), + startAt(true, 'start'), + endAt(false, 'end') + ); + }); + }); + + it('converts to bundled query without order reversing', () => { + return createInstance().then(firestore => { + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').limitToLast(10); + const bundledQuery = query._toBundledQuery(); + bundledQueryEquals( + bundledQuery, + 'LAST', + orderBy('foo', 'ASCENDING'), + limit(10) + ); + }); + }); + + it('converts to bundled query without cursor flipping', () => { + return createInstance().then(firestore => { + let query: Query = firestore.collection('collectionId'); + query = query + .orderBy('foo') + .startAt('start') + .endAt('end') + .limitToLast(10); + const bundledQuery = query._toBundledQuery(); + bundledQueryEquals( + bundledQuery, + 'LAST', + orderBy('foo', 'ASCENDING'), + limit(10), + startAt(true, 'start'), + endAt(false, 'end') + ); + }); + }); +}); + +describe('offset() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('generates proto', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, offset(10)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.offset(10); + return query.get(); + }); + }); + + it('expects number', () => { + const query = firestore.collection('collectionId'); + expect(() => query.offset(Infinity)).to.throw( + 'Value for argument "offset" is not a valid integer.' + ); + }); + + it('uses latest offset', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, offset(3)); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.offset(1).offset(2).offset(3); + return query.get(); + }); + }); +}); + +describe('select() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('generates proto', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, select('a', 'b.c')); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const collection = firestore.collection('collectionId'); + const query = collection.select('a', new FieldPath('b', 'c')); + + return query.get().then(() => { + return collection.select('a', 'b.c').get(); + }); + }); + }); + + it('validates field path', () => { + const query = firestore.collection('collectionId'); + expect(() => query.select(1 as InvalidApiUsage)).to.throw( + 'Element at index 0 is not a valid field path. Paths can only be specified as strings or via a FieldPath object.' + ); + + expect(() => query.select('.')).to.throw( + 'Element at index 0 is not a valid field path. Paths must not start or end with ".".' + ); + }); + + it('uses latest field mask', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, select('bar')); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.select('foo').select('bar'); + return query.get(); + }); + }); + + it('implicitly adds FieldPath.documentId()', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, select('__name__')); + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.select(); + return query.get(); + }); + }); +}); + +describe('startAt() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('accepts fields', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', 'bar', 'ASCENDING'), + startAt(true, 'foo', 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').orderBy('bar').startAt('foo', 'bar'); + return query.get(); + }); + }); + + it('accepts FieldPath.documentId()', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {foo: 'bar'}).then(doc => { + const query = firestore.collection('collectionId'); + + return Promise.all([ + query.orderBy(FieldPath.documentId()).startAt(doc.id).get(), + query.orderBy(FieldPath.documentId()).startAt(doc.ref).get(), + ]); + }); + }); + }); + + it('validates value for FieldPath.documentId()', () => { + const query = firestore.collection('coll/doc/coll'); + + expect(() => { + query.orderBy(FieldPath.documentId()).startAt(42); + }).to.throw( + 'The corresponding value for FieldPath.documentId() must be a string or a DocumentReference, but was "42".' + ); + + expect(() => { + query + .orderBy(FieldPath.documentId()) + .startAt(firestore.doc('coll/doc/other/doc')); + }).to.throw( + '"coll/doc/other/doc" is not part of the query result set and cannot be used as a query boundary.' + ); + + expect(() => { + query + .orderBy(FieldPath.documentId()) + .startAt(firestore.doc('coll/doc/coll_suffix/doc')); + }).to.throw( + '"coll/doc/coll_suffix/doc" is not part of the query result set and cannot be used as a query boundary.' + ); + + expect(() => { + query.orderBy(FieldPath.documentId()).startAt(firestore.doc('coll/doc')); + }).to.throw( + '"coll/doc" is not part of the query result set and cannot be used as a query boundary.' + ); + + expect(() => { + query + .orderBy(FieldPath.documentId()) + .startAt(firestore.doc('coll/doc/coll/doc/coll/doc')); + }).to.throw( + 'Only a direct child can be used as a query boundary. Found: "coll/doc/coll/doc/coll/doc".' + ); + + // Validate that we can't pass a reference to a collection. + expect(() => { + query.orderBy(FieldPath.documentId()).startAt('doc/coll'); + }).to.throw( + 'When querying a collection and ordering by FieldPath.documentId(), ' + + 'the corresponding value must be a plain document ID, but ' + + "'doc/coll' contains a slash." + ); + }); + + it('requires at least one value', () => { + const query = firestore.collection('coll/doc/coll'); + + expect(() => { + query.startAt(); + }).to.throw('Function "Query.startAt()" requires at least 1 argument.'); + }); + + it('can specify document snapshot', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {}).then(doc => { + const query = firestore.collection('collectionId').startAt(doc); + return query.get(); + }); + }); + }); + + it("doesn't append documentId() twice", () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {}).then(doc => { + const query = firestore + .collection('collectionId') + .orderBy(FieldPath.documentId()) + .startAt(doc); + return query.get(); + }); + }); + }); + + it('appends orderBy for DocumentReference cursors', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {foo: 'bar'}).then(doc => { + let query: Query = firestore.collection('collectionId'); + query = query.startAt(doc.ref); + return query.get(); + }); + }); + }); + + it('can extract implicit direction for document snapshot', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', '__name__', 'ASCENDING'), + startAt(true, 'bar', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {foo: 'bar'}).then(doc => { + let query: Query = firestore.collection('collectionId').orderBy('foo'); + query = query.startAt(doc); + return query.get(); + }); + }); + }); + + it('can extract explicit direction for document snapshot', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'DESCENDING', '__name__', 'DESCENDING'), + startAt(true, 'bar', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {foo: 'bar'}).then(doc => { + let query: Query = firestore + .collection('collectionId') + .orderBy('foo', 'desc'); + query = query.startAt(doc); + return query.get(); + }); + }); + }); + + it('can specify document snapshot with inequality filter', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('c', 'ASCENDING', '__name__', 'ASCENDING'), + startAt(true, 'c', { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery( + 'a', + 'EQUAL', + 'a', + 'b', + 'ARRAY_CONTAINS', + 'b', + 'c', + 'GREATER_THAN_OR_EQUAL', + 'c', + 'd', + 'EQUAL', + 'd' + ) + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {c: 'c'}).then(doc => { + const query = firestore + .collection('collectionId') + .where('a', '==', 'a') + .where('b', 'array-contains', 'b') + .where('c', '>=', 'c') + .where('d', '==', 'd') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('ignores equality filter with document snapshot cursor', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('__name__', 'ASCENDING'), + startAt(true, { + referenceValue: + `projects/${PROJECT_ID}/databases/(default)/` + + 'documents/collectionId/doc', + }), + fieldFiltersQuery('foo', 'EQUAL', 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + return snapshot('collectionId/doc', {foo: 'bar'}).then(doc => { + const query = firestore + .collection('collectionId') + .where('foo', '==', 'bar') + .startAt(doc); + return query.get(); + }); + }); + }); + + it('validates field exists in document snapshot', () => { + const query = firestore.collection('collectionId').orderBy('foo', 'desc'); + + return snapshot('collectionId/doc', {}).then(doc => { + expect(() => query.startAt(doc)).to.throw( + 'Field "foo" is missing in the provided DocumentSnapshot. Please provide a document that contains values for all specified orderBy() and where() constraints.' + ); + }); + }); + + it('does not accept field deletes', () => { + const query = firestore.collection('collectionId').orderBy('foo'); + + expect(() => { + query.orderBy('foo').startAt('foo', FieldValue.delete()); + }).to.throw( + 'Element at index 1 is not a valid query constraint. FieldValue.delete() must appear at the top-level and can only be used in update() or set() with {merge:true}.' + ); + }); + + it('requires order by', () => { + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo'); + + expect(() => query.startAt('foo', 'bar')).to.throw( + 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query.' + ); + }); + + it('can overspecify order by', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', 'bar', 'ASCENDING'), + startAt(true, 'foo') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').orderBy('bar').startAt('foo'); + return query.get(); + }); + }); + + it('validates input', () => { + const query = firestore.collection('collectionId'); + expect(() => query.startAt(123)).to.throw( + 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query.' + ); + }); + + it('uses latest value', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'ASCENDING'), startAt(true, 'bar')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').startAt('foo').startAt('bar'); + return query.get(); + }); + }); +}); + +describe('startAfter() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('accepts fields', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', 'bar', 'ASCENDING'), + startAt(false, 'foo', 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').orderBy('bar').startAfter('foo', 'bar'); + return query.get(); + }); + }); + + it('validates input', () => { + const query = firestore.collection('collectionId'); + expect(() => query.startAfter(123)).to.throw( + 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query.' + ); + }); + + it('uses latest value', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING'), + startAt(false, 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').startAfter('foo').startAfter('bar'); + return query.get(); + }); + }); +}); + +describe('endAt() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('accepts fields', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', 'bar', 'ASCENDING'), + endAt(false, 'foo', 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').orderBy('bar').endAt('foo', 'bar'); + return query.get(); + }); + }); + + it('validates input', () => { + const query = firestore.collection('collectionId'); + expect(() => query.endAt(123)).to.throw( + 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query.' + ); + }); + + it('uses latest value', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'ASCENDING'), endAt(false, 'bar')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').endAt('foo').endAt('bar'); + return query.get(); + }); + }); +}); + +describe('endBefore() interface', () => { + let firestore: Firestore; + + beforeEach(() => { + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(() => verifyInstance(firestore)); + + it('accepts fields', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + orderBy('foo', 'ASCENDING', 'bar', 'ASCENDING'), + endAt(true, 'foo', 'bar') + ); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').orderBy('bar').endBefore('foo', 'bar'); + return query.get(); + }); + }); + + it('validates input', () => { + const query = firestore.collection('collectionId'); + expect(() => query.endBefore(123)).to.throw( + 'Too many cursor values specified. The specified values must match the orderBy() constraints of the query.' + ); + }); + + it('uses latest value', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, orderBy('foo', 'ASCENDING'), endAt(true, 'bar')); + + return stream(); + }, + }; + + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + let query: Query = firestore.collection('collectionId'); + query = query.orderBy('foo').endBefore('foo').endBefore('bar'); + return query.get(); + }); + }); + + it('is immutable', () => { + let expectedComponents = [limit(10)]; + + const overrides: ApiOverride = { + runQuery: request => { + queryEquals(request, ...expectedComponents); + return stream(); + }, + }; + return createInstance(overrides).then(firestoreInstance => { + firestore = firestoreInstance; + const query = firestore.collection('collectionId').limit(10); + const adjustedQuery = query.orderBy('foo').endBefore('foo'); + + return query.get().then(() => { + expectedComponents = [ + limit(10), + orderBy('foo', 'ASCENDING'), + endAt(true, 'foo'), + ]; + + return adjustedQuery.get(); + }); + }); + }); +}); + +describe('collectionGroup queries', () => { + it('serialize correctly', () => { + const overrides: ApiOverride = { + runQuery: request => { + queryEquals( + request, + allDescendants(), + fieldFiltersQuery('foo', 'EQUAL', 'bar') + ); + return stream(); + }, + }; + return createInstance(overrides).then(firestore => { + const query = firestore + .collectionGroup('collectionId') + .where('foo', '==', 'bar'); + return query.get(); + }); + }); + + it('rejects slashes', () => { + return createInstance().then(firestore => { + expect(() => firestore.collectionGroup('foo/bar')).to.throw( + "Invalid collectionId 'foo/bar'. Collection IDs must not contain '/'." + ); + }); + }); + + it('rejects slashes', () => { + return createInstance().then(firestore => { + const query = firestore.collectionGroup('collectionId'); + + expect(() => { + query.orderBy(FieldPath.documentId()).startAt('coll'); + }).to.throw( + 'When querying a collection group and ordering by ' + + 'FieldPath.documentId(), the corresponding value must result in a ' + + "valid document path, but 'coll' is not because it contains an odd " + + 'number of segments.' + ); + }); + }); +}); + +describe('query resumption', () => { + let firestore: Firestore; + + beforeEach(() => { + setTimeoutHandler(setImmediate); + return createInstance().then(firestoreInstance => { + firestore = firestoreInstance; + }); + }); + + afterEach(async () => { + await verifyInstance(firestore); + setTimeoutHandler(setTimeout); + }); + + // Prevent regression of + // https://github.com/googleapis/nodejs-firestore/issues/1790 + it('results should not be double produced on retryable error with back pressure', async () => { + // Generate the IDs of the documents that will match the query. + const documentIds = Array.from(new Array(500), (_, index) => `doc${index}`); + + // Finds the index in `documentIds` of the document referred to in the + // "startAt" of the given request. + function getStartAtDocumentIndex( + request: api.IRunQueryRequest + ): number | null { + const startAt = request.structuredQuery?.startAt; + const startAtValue = startAt?.values?.[0]?.referenceValue; + const startAtBefore = startAt?.before; + if (typeof startAtValue !== 'string') { + return null; + } + const docId = startAtValue.split('/').pop()!; + const docIdIndex = documentIds.indexOf(docId); + if (docIdIndex < 0) { + return null; + } + return startAtBefore ? docIdIndex : docIdIndex + 1; + } + + const RETRYABLE_ERROR_DOMAIN = 'RETRYABLE_ERROR_DOMAIN'; + + // A mock replacement for Query._isPermanentRpcError which (a) resolves + // a promise once invoked and (b) treats a specific error "domain" as + // non-retryable. + function mockIsPermanentRpcError(err: GoogleError): boolean { + mockIsPermanentRpcError.invoked.resolve(true); + return err?.domain !== RETRYABLE_ERROR_DOMAIN; + } + mockIsPermanentRpcError.invoked = new Deferred(); + + // Return the first half of the documents, followed by a retryable error. + function* getRequest1Responses(): Generator { + const runQueryResponses = documentIds + .slice(0, documentIds.length / 2) + .map(documentId => result(documentId)); + for (const runQueryResponse of runQueryResponses) { + yield runQueryResponse; + } + const retryableError = new GoogleError('simulated retryable error'); + retryableError.domain = RETRYABLE_ERROR_DOMAIN; + yield retryableError; + } + + // Return the remaining documents. + function* getRequest2Responses( + request: api.IRunQueryRequest + ): Generator { + const startAtDocumentIndex = getStartAtDocumentIndex(request); + if (startAtDocumentIndex === null) { + throw new Error('request #2 should specify a valid startAt'); + } + const runQueryResponses = documentIds + .slice(startAtDocumentIndex) + .map(documentId => result(documentId)); + for (const runQueryResponse of runQueryResponses) { + yield runQueryResponse; + } + } + + // Set up the mocked responses from Watch. + let requestNum = 0; + const overrides: ApiOverride = { + runQuery: request => { + requestNum++; + switch (requestNum) { + case 1: + return stream(...getRequest1Responses()); + case 2: + return stream(...getRequest2Responses(request!)); + default: + throw new Error(`should never get here (requestNum=${requestNum})`); + } + }, + }; + + // Create an async iterator to get the result set but DO NOT iterate over + // it immediately. Instead, allow the responses to pile up and fill the + // buffers. Once isPermanentError() is invoked, indicating that the first + // request has failed and is about to be retried, collect the results from + // the async iterator into an array. + firestore = await createInstance(overrides); + const query = firestore.collection('collectionId'); + query._isPermanentRpcError = mockIsPermanentRpcError; + const iterator = query + .stream() + [Symbol.asyncIterator]() as AsyncIterator; + await mockIsPermanentRpcError.invoked.promise; + const snapshots = await collect(iterator); + + // Verify that the async iterator returned the correct documents and, + // especially, does not have duplicate results. + const actualDocumentIds = snapshots.map(snapshot => snapshot.id); + expect(actualDocumentIds).to.eql(documentIds); + }); +}); \ No newline at end of file diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart new file mode 100644 index 0000000..3d1d79d --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart @@ -0,0 +1,27 @@ +import 'package:dart_firebase_admin/firestore.dart'; +import 'package:dart_firebase_admin/src/dart_firebase_admin.dart'; +import 'package:test/test.dart'; + +const projectId = 'dart-firebase-admin'; + +FirebaseAdminApp createApp() { + final credential = Credential.fromApplicationDefaultCredentials(); + return FirebaseAdminApp.initializeApp(projectId, credential)..useEmulator(); +} + +Firestore createInstance([Settings? settings]) { + return Firestore(createApp(), settings: settings); +} + +Matcher isArgumentError({String? message}) { + var matcher = isA(); + if (message != null) { + matcher = matcher.having((e) => e.message, 'message', message); + } + + return matcher; +} + +Matcher throwsArgumentError({String? message}) { + return throwsA(isArgumentError(message: message)); +} diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.ts b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.ts new file mode 100644 index 0000000..53e0a03 --- /dev/null +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.ts @@ -0,0 +1,459 @@ +// Copyright 2018 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { + DocumentData, + Settings, + SetOptions, + PartialWithFieldValue, +} from '@google-cloud/firestore'; + +import {expect} from 'chai'; +import * as extend from 'extend'; +import {JSONStreamIterator} from 'length-prefixed-json-stream'; +import {Duplex, PassThrough} from 'stream'; +import * as through2 from 'through2'; +import {firestore} from '../../protos/firestore_v1_proto_api'; +import type {grpc} from 'google-gax'; +import * as proto from '../../protos/firestore_v1_proto_api'; +import * as v1 from '../../src/v1'; +import {Firestore, QueryDocumentSnapshot} from '../../src'; +import {ClientPool} from '../../src/pool'; +import {GapicClient} from '../../src/types'; + +import api = proto.google.firestore.v1; + +let SSL_CREDENTIALS: grpc.ChannelCredentials | null = null; +if (!isPreferRest()) { + const grpc = require('google-gax').grpc; + SSL_CREDENTIALS = grpc.credentials.createInsecure(); +} + +export const PROJECT_ID = 'test-project'; +export const DATABASE_ROOT = `projects/${PROJECT_ID}/databases/(default)`; +export const COLLECTION_ROOT = `${DATABASE_ROOT}/documents/collectionId`; +export const DOCUMENT_NAME = `${COLLECTION_ROOT}/documentId`; + +// Allow invalid API usage to test error handling. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type InvalidApiUsage = any; + +/** Defines the request handlers used by Firestore. */ +export type ApiOverride = Partial; + +/** + * Creates a new Firestore instance for testing. Request handlers can be + * overridden by providing `apiOverrides`. + * + * @param apiOverrides An object with request handlers to override. + * @param firestoreSettings Firestore Settings to configure the client. + * @return A Promise that resolves with the new Firestore client. + */ +export function createInstance( + apiOverrides?: ApiOverride, + firestoreSettings?: Settings +): Promise { + const initializationOptions = { + ...{projectId: PROJECT_ID, sslCreds: SSL_CREDENTIALS!}, + ...firestoreSettings, + }; + + const firestore = new Firestore(); + firestore.settings(initializationOptions); + + firestore['_clientPool'] = new ClientPool( + /* concurrentRequestLimit= */ 1, + /* maxIdleClients= */ 0, + () => + ({ + ...new v1.FirestoreClient(initializationOptions), + ...apiOverrides, + } as any) // eslint-disable-line @typescript-eslint/no-explicit-any + ); + + return Promise.resolve(firestore); +} + +/** + * Verifies that all streams have been properly shutdown at the end of a test + * run. + */ +export function verifyInstance(firestore: Firestore): Promise { + // Allow the setTimeout() call in _initializeStream to run before + // verifying that all operations have finished executing. + return new Promise((resolve, reject) => { + if (firestore['_clientPool'].opCount === 0) { + resolve(); + } else { + setTimeout(() => { + const opCount = firestore['_clientPool'].opCount; + if (opCount === 0) { + resolve(); + } else { + reject( + new Error( + `Firestore has ${opCount} unfinished operations executing.` + ) + ); + } + }, 10); + } + }); +} + +function write( + document: api.IDocument, + mask: api.IDocumentMask | null, + transforms: api.DocumentTransform.IFieldTransform[] | null, + precondition: api.IPrecondition | null +): api.ICommitRequest { + const writes: api.IWrite[] = []; + const update = Object.assign({}, document); + delete update.updateTime; + delete update.createTime; + writes.push({update}); + + if (mask) { + writes[0].updateMask = mask; + } + + if (transforms) { + writes[0].updateTransforms = transforms; + } + + if (precondition) { + writes[0].currentDocument = precondition; + } + + return {writes}; +} + +export function updateMask(...fieldPaths: string[]): api.IDocumentMask { + return fieldPaths.length === 0 ? {} : {fieldPaths}; +} + +export function set(opts: { + document: api.IDocument; + transforms?: api.DocumentTransform.IFieldTransform[]; + mask?: api.IDocumentMask; +}): api.ICommitRequest { + return write( + opts.document, + opts.mask || null, + opts.transforms || null, + /* precondition= */ null + ); +} + +export function update(opts: { + document: api.IDocument; + transforms?: api.DocumentTransform.IFieldTransform[]; + mask?: api.IDocumentMask; + precondition?: api.IPrecondition; +}): api.ICommitRequest { + const precondition = opts.precondition || {exists: true}; + const mask = opts.mask || updateMask(); + return write(opts.document, mask, opts.transforms || null, precondition); +} + +export function create(opts: { + document: api.IDocument; + transforms?: api.DocumentTransform.IFieldTransform[]; + mask?: api.IDocumentMask; +}): api.ICommitRequest { + return write(opts.document, /* updateMask= */ null, opts.transforms || null, { + exists: false, + }); +} + +function value(value: string | api.IValue): api.IValue { + if (typeof value === 'string') { + return { + stringValue: value, + }; + } else { + return value; + } +} + +export function retrieve(id: string): api.IBatchGetDocumentsRequest { + return {documents: [`${DATABASE_ROOT}/documents/collectionId/${id}`]}; +} + +export function remove( + id: string, + precondition?: api.IPrecondition +): api.ICommitRequest { + const writes: api.IWrite[] = [ + {delete: `${DATABASE_ROOT}/documents/collectionId/${id}`}, + ]; + + if (precondition) { + writes[0].currentDocument = precondition; + } + + return {writes}; +} + +export function found( + dataOrId: api.IDocument | string +): api.IBatchGetDocumentsResponse { + return { + found: typeof dataOrId === 'string' ? document(dataOrId) : dataOrId, + readTime: {seconds: 5, nanos: 6}, + }; +} + +export function missing(id: string): api.IBatchGetDocumentsResponse { + return { + missing: `${DATABASE_ROOT}/documents/collectionId/${id}`, + readTime: {seconds: 5, nanos: 6}, + }; +} + +export function document( + id: string, + field?: string, + value?: string | api.IValue, + ...fieldOrValues: Array +): api.IDocument { + const document: api.IDocument = { + name: `${DATABASE_ROOT}/documents/collectionId/${id}`, + fields: {}, + createTime: {seconds: 1, nanos: 2}, + updateTime: {seconds: 3, nanos: 4}, + }; + + if (field !== undefined) { + fieldOrValues = [field, value!].concat(fieldOrValues); + + for (let i = 0; i < fieldOrValues.length; i += 2) { + const field = fieldOrValues[i] as string; + const value = fieldOrValues[i + 1]; + + if (typeof value === 'string') { + document.fields![field] = { + stringValue: value, + }; + } else { + document.fields![field] = value; + } + } + } + + return document; +} + +export function serverTimestamp( + field: string +): api.DocumentTransform.IFieldTransform { + return {fieldPath: field, setToServerValue: 'REQUEST_TIME'}; +} + +export function incrementTransform( + field: string, + n: number +): api.DocumentTransform.IFieldTransform { + return { + fieldPath: field, + increment: Number.isInteger(n) ? {integerValue: n} : {doubleValue: n}, + }; +} + +export function arrayTransform( + field: string, + transform: 'appendMissingElements' | 'removeAllFromArray', + ...values: Array +): api.DocumentTransform.IFieldTransform { + const fieldTransform: api.DocumentTransform.IFieldTransform = { + fieldPath: field, + }; + + fieldTransform[transform] = {values: values.map(val => value(val))}; + + return fieldTransform; +} + +export function writeResult(count: number): api.IWriteResponse { + const response: api.IWriteResponse = { + commitTime: { + nanos: 0, + seconds: 1, + }, + }; + + if (count > 0) { + response.writeResults = []; + + for (let i = 1; i <= count; ++i) { + response.writeResults.push({ + updateTime: { + nanos: i * 2, + seconds: i * 2 + 1, + }, + }); + } + } + + return response; +} + +export function requestEquals( + actual: object | undefined, + expected: object +): void { + expect(actual).to.not.be.undefined; + + // 'extend' removes undefined fields in the request object. The backend + // ignores these fields, but we need to manually strip them before we compare + // the expected and the actual request. + actual = extend(true, {}, actual); + const proto = Object.assign({database: DATABASE_ROOT}, expected); + expect(actual).to.deep.eq(proto); +} + +export function stream(...elements: Array): Duplex { + const stream = through2.obj(); + + setImmediate(() => { + for (const el of elements) { + if (el instanceof Error) { + stream.destroy(el); + return; + } + stream.push(el); + } + stream.push(null); + }); + + return stream; +} + +export function streamWithoutEnd(...elements: Array): Duplex { + const stream = through2.obj(); + + setImmediate(() => { + for (const el of elements) { + if (el instanceof Error) { + stream.destroy(el); + return; + } + stream.push(el); + } + }); + + return stream; +} + +/** Creates a response as formatted by the GAPIC request methods. */ +export function response(result: T): Promise<[T, unknown, unknown]> { + return Promise.resolve([result, undefined, undefined]); +} + +/** Sample user object class used in tests. */ +export class Post { + constructor(readonly title: string, readonly author: string) {} + toString(): string { + return this.title + ', by ' + this.author; + } +} + +/** Converts Post objects to and from Firestore in tests. */ +export const postConverter = { + toFirestore(post: Post): DocumentData { + return {title: post.title, author: post.author}; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): Post { + const data = snapshot.data(); + return new Post(data.title, data.author); + }, +}; + +export const postConverterMerge = { + toFirestore( + post: PartialWithFieldValue, + options?: SetOptions + ): DocumentData { + if (options) { + expect(post).to.not.be.an.instanceOf(Post); + } else { + expect(post).to.be.an.instanceof(Post); + } + const result: DocumentData = {}; + if (post.title) result.title = post.title; + if (post.author) result.author = post.author; + return result; + }, + fromFirestore(snapshot: QueryDocumentSnapshot): Post { + const data = snapshot.data(); + return new Post(data.title, data.author); + }, +}; + +export async function bundleToElementArray( + bundle: Buffer +): Promise> { + const result: Array = []; + const readable = new PassThrough(); + readable.end(bundle); + const streamIterator = new JSONStreamIterator(readable); + for await (const value of streamIterator) { + result.push(value as firestore.IBundleElement); + } + return result; +} + +/** + * Reads the elements of an AsyncIterator. + * + * Example: + * + * const query = firestore.collection('collectionId'); + * const iterator = query.stream()[Symbol.asyncIterator]() + * as AsyncIterator; + * return collect(iterator).then(snapshots => { + * expect(snapshots).to.have.length(2); + * }); + * + * @param iterator the iterator whose elements over which to iterate. + * @return a Promise that is fulfilled with the elements that were produced, or + * is rejected with the cause of the first failed iteration. + */ +export async function collect( + iterator: AsyncIterator +): Promise> { + const values: Array = []; + // eslint-disable-next-line no-constant-condition + while (true) { + const {done, value} = await iterator.next(); + if (done) { + break; + } + values.push(value); + } + return values; +} + +/** + * Returns a value indicating whether preferRest is enabled + * via the environment variable `FIRESTORE_PREFER_REST`. + * + * @return `true` if preferRest is enabled via the environment variable `FIRESTORE_PREFER_REST`. + */ +export function isPreferRest(): boolean { + return ( + process.env.FIRESTORE_PREFER_REST === '1' || + process.env.FIRESTORE_PREFER_REST === 'true' + ); +} \ No newline at end of file diff --git a/remoteconfig.template.json b/remoteconfig.template.json new file mode 100644 index 0000000..9e26dfe --- /dev/null +++ b/remoteconfig.template.json @@ -0,0 +1 @@ +{} \ No newline at end of file