From 18f249b1899206724a92f81e8a8930570440c9c6 Mon Sep 17 00:00:00 2001 From: Masahiro Aoki Date: Mon, 8 Jul 2024 08:24:37 +0200 Subject: [PATCH 01/11] Update reference.dart (#30) --- .../lib/src/google_cloud_firestore/reference.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index 45a7f7e..1574f92 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -1502,7 +1502,7 @@ class Query { /// ```dart /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); /// - /// query.orderBy('foo', 'desc').get().then((querySnapshot) { + /// query.orderBy('foo', descending: true).get().then((querySnapshot) { /// querySnapshot.forEach((documentSnapshot) { /// print('Found document at ${documentSnapshot.ref.path}'); /// }); @@ -1546,7 +1546,7 @@ class Query { /// ```dart /// final query = firestore.collection('col').where('foo', WhereFilter.equal, 42); /// - /// query.orderBy('foo', 'desc').get().then((querySnapshot) { + /// query.orderBy('foo', descending: true).get().then((querySnapshot) { /// querySnapshot.forEach((documentSnapshot) { /// print('Found document at ${documentSnapshot.ref.path}'); /// }); From c0c9f6f359033f9fd7989bd11929d0163db8c87e Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 07:16:29 +0200 Subject: [PATCH 02/11] Add CI --- .github/ISSUE_TEMPLATE/bug_report.md | 18 +++++++++ .github/ISSUE_TEMPLATE/config.yml | 5 +++ .github/ISSUE_TEMPLATE/example_request.md | 20 ++++++++++ .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++ .github/PULL_REQUEST_TEMPLATE.md | 34 +++++++++++++++++ .github/dependabot.yaml | 7 ++++ .github/workflows/build.yml | 45 +++++++++++++++++++++++ scripts/coverage.sh | 10 +++++ 8 files changed, 159 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/example_request.md create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100644 .github/dependabot.yaml create mode 100644 .github/workflows/build.yml create mode 100755 scripts/coverage.sh diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..20065d8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,18 @@ +--- +name: Bug report +about: There is a problem in how provider behaves +title: "" +labels: bug, needs triage +assignees: + - rrousselGit +--- + +**Describe the bug** +A clear and concise description of what the bug is. + +**To Reproduce** + + + +**Expected behavior** +A clear and concise description of what you expected to happen. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e49231f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: I have a problem and I need help + url: https://github.com/rrousselGit/riverpod/discussions + about: Please ask and answer questions here. diff --git a/.github/ISSUE_TEMPLATE/example_request.md b/.github/ISSUE_TEMPLATE/example_request.md new file mode 100644 index 0000000..332337b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/example_request.md @@ -0,0 +1,20 @@ +--- +name: Documentation improvement request +about: >- + Suggest a new example/documentation or ask for clarification about an + existing one. +title: "" +labels: documentation, needs triage +assignees: + - rrousselGit +--- + +**Describe what scenario you think is uncovered by the existing examples/articles** +A clear and concise description of the problem that you want explained. + +**Describe why existing examples/articles do not cover this case** +Explain which examples/articles you have seen before making this request, and +why they did not help you with your problem. + +**Additional context** +Add any other context or screenshots about the documentation request here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..65c5ae3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: "" +labels: enhancement, needs triage +assignees: + - rrousselGit +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9d1e206 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,34 @@ +## Related Issues + +fixes #your-issue-number + + + +## Checklist + +Before you create this PR confirm that it meets all requirements listed below by checking the relevant checkboxes (`[x]`). + +- [ ] I have updated the `CHANGELOG.md` of the relevant packages. + Changelog files must be edited under the form: + + ```md + ## Unreleased fix/major/minor + + - Description of your change. (thanks to @yourGithubId) + ``` + +- [ ] If this contains new features or behavior changes, + I have updated the documentation to match those changes. diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml new file mode 100644 index 0000000..39bd9ac --- /dev/null +++ b/.github/dependabot.yaml @@ -0,0 +1,7 @@ +version: 2 +enable-beta-ecosystems: true +updates: + - package-ecosystem: "pub" + directory: "/" + schedule: + interval: "weekly" \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..9fac224 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,45 @@ +name: Build + +on: + pull_request: + paths-ignore: + - "**.md" + - "**.mdx" + + schedule: + # runs the CI everyday at 10AM + - cron: "0 10 * * *" + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3.1.0 + with: + fetch-depth: 2 + - uses: subosito/flutter-action@v2.7.1 + with: + channel: master + - name: Add pub cache bin to PATH + run: echo "$HOME/.pub-cache/bin" >> $GITHUB_PATH + - name: Add pub cache to PATH + run: echo "PUB_CACHE="$HOME/.pub-cache"" >> $GITHUB_ENV + + - run: cd packages/dart_firebase_admin + + - name: Install dependencies + run: dart pub get + + - name: Check format + run: dart format --set-exit-if-changed . + + - name: Analyze + run: dart analyze + + - name: Run tests + run: | + ${{github.workspace}}/scripts/coverage.sh + + - name: Upload coverage to codecov + run: curl -s https://codecov.io/bash | bash diff --git a/scripts/coverage.sh b/scripts/coverage.sh new file mode 100755 index 0000000..fdbc00a --- /dev/null +++ b/scripts/coverage.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +# Fast fail the script on failures. +set -e + +dart pub global activate coverage + +dart test --coverage="coverage" + +format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file From 658cef9c97dc555bf7797405181e288befec419e Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 07:47:34 +0200 Subject: [PATCH 03/11] Cleanup analyzer (#37) --- .github/workflows/build.yml | 8 +- .../dart_firebase_admin/lib/messaging.dart | 3 +- .../lib/src/auth/auth_config.dart | 33 +- .../lib/src/auth/user.dart | 9 +- .../lib/src/auth/user_import_builder.dart | 3 - .../src/google_cloud_firestore/convert.dart | 2 +- .../src/google_cloud_firestore/reference.dart | 4 +- .../google_cloud_firestore/serializer.dart | 2 +- .../lib/src/messaging.dart | 174 +-- .../messaging-api-request-internal.ts | 174 --- .../lib/src/messaging/messaging-api.ts | 1118 ----------------- .../lib/src/messaging/messaging_api.dart | 71 -- .../test/messaging/messaging_test.dart | 4 +- 13 files changed, 39 insertions(+), 1566 deletions(-) delete mode 100644 packages/dart_firebase_admin/lib/src/messaging/messaging-api-request-internal.ts delete mode 100644 packages/dart_firebase_admin/lib/src/messaging/messaging-api.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9fac224..c55c19c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,6 +14,10 @@ jobs: build: runs-on: ubuntu-latest + defaults: + run: + working-directory: packages/dart_firebase_admin + steps: - uses: actions/checkout@v3.1.0 with: @@ -26,10 +30,8 @@ jobs: - name: Add pub cache to PATH run: echo "PUB_CACHE="$HOME/.pub-cache"" >> $GITHUB_ENV - - run: cd packages/dart_firebase_admin - - name: Install dependencies - run: dart pub get + run: dart pub get && cd example && dart pub get && cd - - name: Check format run: dart format --set-exit-if-changed . diff --git a/packages/dart_firebase_admin/lib/messaging.dart b/packages/dart_firebase_admin/lib/messaging.dart index 9633c92..e2d7947 100644 --- a/packages/dart_firebase_admin/lib/messaging.dart +++ b/packages/dart_firebase_admin/lib/messaging.dart @@ -1,2 +1 @@ -export 'src/messaging.dart' - hide FirebaseMessagingRequestHandler, MessagingTopicManagementResponse; +export 'src/messaging.dart' hide FirebaseMessagingRequestHandler; diff --git a/packages/dart_firebase_admin/lib/src/auth/auth_config.dart b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart index 76d8baa..96310f1 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_config.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_config.dart @@ -52,9 +52,9 @@ class ListProviderConfigResults { abstract class UpdateAuthProviderRequest {} -class _SAMLAuthProviderRequestBase implements UpdateAuthProviderRequest { +abstract class _SAMLAuthProviderRequestBase + implements UpdateAuthProviderRequest { _SAMLAuthProviderRequestBase({ - this.providerId, this.displayName, this.enabled, this.idpEntityId, @@ -62,17 +62,15 @@ class _SAMLAuthProviderRequestBase implements UpdateAuthProviderRequest { this.x509Certificates, this.rpEntityId, this.callbackURL, - this.enableRequestSigning, - this.issuer, }); - final bool? enableRequestSigning; + bool? get enableRequestSigning; - final String? issuer; + String? get issuer; /// The SAML provider's updated provider ID. If not provided, the existing /// configuration's value is not modified. - final String? providerId; + String? get providerId; /// The SAML provider's updated display name. If not provided, the existing /// configuration's value is not modified. @@ -117,11 +115,19 @@ class SAMLUpdateAuthProviderRequest extends _SAMLAuthProviderRequestBase super.rpEntityId, super.callbackURL, }); + + @override + bool? get enableRequestSigning => null; + + @override + String? get issuer => null; + + @override + String? get providerId => null; } abstract class _OIDCAuthProviderRequestBase { _OIDCAuthProviderRequestBase({ - this.providerId, this.displayName, this.enabled, this.clientId, @@ -132,7 +138,7 @@ abstract class _OIDCAuthProviderRequestBase { /// The OIDC provider's updated provider ID. If not provided, the existing /// configuration's value is not modified. - final String? providerId; + String? get providerId; /// The OIDC provider's updated display name. If not provided, the existing /// configuration's value is not modified. @@ -171,6 +177,9 @@ class OIDCUpdateAuthProviderRequest extends _OIDCAuthProviderRequestBase super.clientSecret, super.responseType, }); + + @override + String? get providerId => null; } sealed class AuthProviderConfig { @@ -516,9 +525,7 @@ class _SAMLConfig extends SAMLAuthProviderConfig { idpEntityId: idpEntityId, ssoURL: ssoURL, x509Certificates: [ - ...?idpConfig.idpCertificates - ?.map((c) => c.x509Certificate) - .whereNotNull(), + ...?idpConfig.idpCertificates?.map((c) => c.x509Certificate).nonNulls, ], rpEntityId: spEntityId, callbackURL: spConfig.callbackUri, @@ -855,8 +862,6 @@ class UserProvider { photoUrl: photoURL, providerId: providerId, rawId: uid, - federatedId: null, - screenName: null, ); } } diff --git a/packages/dart_firebase_admin/lib/src/auth/user.dart b/packages/dart_firebase_admin/lib/src/auth/user.dart index b5e17ac..59953a6 100644 --- a/packages/dart_firebase_admin/lib/src/auth/user.dart +++ b/packages/dart_firebase_admin/lib/src/auth/user.dart @@ -169,6 +169,8 @@ class UserRecord { /// Returns a JSON-serializable representation of this object. /// /// A JSON-serializable representation of this object. + // TODO is this dead code? + // ignore: unused_element Map _toJson() { final providerDataJson = []; final json = { @@ -252,9 +254,7 @@ class MultiFactorSettings { auth1.GoogleCloudIdentitytoolkitV1UserInfo response, ) { final parsedEnrolledFactors = [ - ...?response.mfaInfo - ?.map(MultiFactorInfo.initMultiFactorInfo) - .whereNotNull(), + ...?response.mfaInfo?.map(MultiFactorInfo.initMultiFactorInfo).nonNulls, ]; return MultiFactorSettings( @@ -349,14 +349,13 @@ class PhoneMultiFactorInfo extends MultiFactorInfo { @internal PhoneMultiFactorInfo.fromResponse(super.response) : phoneNumber = response.phoneInfo, - factorId = response.phoneInfo != null ? MultiFactorId.phone : throw 42, super.fromResponse(); /// The phone number associated with a phone second factor. final String? phoneNumber; @override - final MultiFactorId factorId; + MultiFactorId get factorId => MultiFactorId.phone; @override Map _toJson() { 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 index 2cdf2c5..4b1e644 100644 --- a/packages/dart_firebase_admin/lib/src/auth/user_import_builder.dart +++ b/packages/dart_firebase_admin/lib/src/auth/user_import_builder.dart @@ -87,7 +87,6 @@ class UploadAccountOptions { this.rounds, this.memoryCost, this.saltSeparator, - this.cpuMemCost, this.parallelization, this.blockSize, this.dkLen, @@ -98,7 +97,6 @@ class UploadAccountOptions { final int? rounds; final int? memoryCost; final String? saltSeparator; - final int? cpuMemCost; final int? parallelization; final int? blockSize; final int? dkLen; @@ -255,7 +253,6 @@ class _UserImportBuilder { rounds: _validatedOptions?.rounds, memoryCost: _validatedOptions?.memoryCost, saltSeparator: _validatedOptions?.saltSeparator, - cpuMemCost: _validatedOptions?.cpuMemCost, parallelization: _validatedOptions?.parallelization, blockSize: _validatedOptions?.blockSize, dkLen: _validatedOptions?.dkLen, 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 index af4cd5d..99f6958 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/convert.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/convert.dart @@ -16,7 +16,7 @@ void _assertValidProtobufValue(firestore1.Value proto) { proto.bytesValue, ]; - if (values.whereNotNull().length != 1) { + if (values.nonNulls.length != 1) { throw ArgumentError.value( proto, 'proto', 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 index 1574f92..b5a089f 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -220,7 +220,7 @@ class DocumentReference implements _Serializable { } Future> get() async { - final result = await firestore.getAll([this], null); + final result = await firestore.getAll([this]); return result.single; } @@ -1146,7 +1146,7 @@ class Query { return finalDoc.build(); }) - .whereNotNull() + .nonNulls // Specifying fieldsProto should cause the builder to create a query snapshot. .cast>() .toList(); 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 index c6f4d3d..ccccf5a 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/serializer.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/serializer.dart @@ -66,7 +66,7 @@ class _Serializer { case List(): return firestore1.Value( arrayValue: firestore1.ArrayValue( - values: value.map(encodeValue).whereNotNull().toList(), + values: value.map(encodeValue).nonNulls.toList(), ), ); diff --git a/packages/dart_firebase_admin/lib/src/messaging.dart b/packages/dart_firebase_admin/lib/src/messaging.dart index 03e07cd..cc57316 100644 --- a/packages/dart_firebase_admin/lib/src/messaging.dart +++ b/packages/dart_firebase_admin/lib/src/messaging.dart @@ -69,8 +69,9 @@ class Messaging { /// Sends each message in the given array via Firebase Cloud Messaging. /// - /// Unlike [Messaging.sendAll], this method makes a single RPC call for each message - /// in the given array. + // TODO once we have Messaging.sendAll, add the following: + // Unlike [Messaging.sendAll], this method makes a single RPC call for each message + // in the given array. /// /// The responses list obtained from the return value corresponds to the order of `messages`. /// An error from this method or a `BatchResponse` with all failures indicates a total failure, @@ -174,174 +175,7 @@ class Messaging { // TODO uncomment code below when we figure out hot to send the subscription request // TODO also unmark the response as internal - // /// Subscribes a device to an FCM topic. - // /// - // /// See [Subscribe to a topic](https://firebase.google.com/docs/cloud-messaging/manage-topics#suscribe_and_unsubscribe_using_the) - // /// for code samples and detailed documentation. Optionally, you can provide an - // /// array of tokens to subscribe multiple devices. - // /// - // /// - [registrationTokens]: A token or array of registration tokens - // /// for the devices to subscribe to the topic. - // /// - [topic]: The topic to which to subscribe. - // /// - // /// Returns a future fulfilled with the server's response after the device has been - // /// subscribed to the topic. - // Future subscribeToTopic( - // List registrationTokenOrTokens, - // String topic, - // ) { - // return _sendTopicManagementRequest( - // registrationTokenOrTokens, - // topic: topic, - // methodName: 'subscribeToTopic', - // path: _fcmTopicManagementAddPath, - // ); - // } - - // /// Unsubscribes a device from an FCM topic. - // /// - // /// See [Unsubscribe from a topic](https://firebase.google.com/docs/cloud-messaging/admin/manage-topic-subscriptions#unsubscribe_from_a_topic) - // /// for code samples and detailed documentation. Optionally, you can provide an - // /// array of tokens to unsubscribe multiple devices. - // /// - // /// - [registrationTokens]: A device registration token or an array of - // /// device registration tokens to unsubscribe from the topic. - // /// - [topic]: The topic from which to unsubscribe. - // /// - // /// Returns a Future fulfilled with the server's response after the device has been - // /// unsubscribed from the topic. - // Future unsubscribeFromTopic( - // List registrationTokenOrTokens, - // String topic, - // ) { - // return _sendTopicManagementRequest( - // registrationTokenOrTokens, - // topic: topic, - // methodName: 'unsubscribeFromTopic', - // path: _fcmTopicManagementRemovePath, - // ); - // } - - // /// Helper method which sends and handles topic subscription management requests. - // Future _sendTopicManagementRequest( - // List registrationTokenOrTokens, { - // required String topic, - // required String methodName, - // required String path, - // }) async { - // _validateRegistrationTokensType( - // registrationTokenOrTokens, - // methodName: methodName, - // ); - // _validateTopicType(topic, methodName: methodName); - - // // Prepend the topic with /topics/ if necessary. - // topic = _normalizeTopic(topic); - - // _validateRegistrationTokens( - // registrationTokenOrTokens, - // methodName: methodName, - // ); - // _validateTopic(topic, methodName: methodName); - - // final response = await _requestHandler.invokeRequestHandler( - // host: _fcmTopicManagementHost, - // path: path, - // requestData: { - // 'to': topic, - // 'registration_tokens': registrationTokenOrTokens, - // }, - // ); - - // return MessagingTopicManagementResponse._fromResponse(response); - // } - - // /// Validates the type of the provided registration token(s). - // /// If invalid, an error will be thrown. - // void _validateRegistrationTokensType( - // List registrationTokenOrTokens, { - // required String methodName, - // MessagingClientErrorCode errorInfo = - // MessagingClientErrorCode.invalidArgument, - // }) { - // if (registrationTokenOrTokens.isEmpty) { - // throw FirebaseMessagingAdminException( - // errorInfo, - // 'Registration token(s) provided to $methodName() must be a non-empty string or a ' - // 'non-empty array.', - // ); - // } - // } - - // /// Validates the provided registration tokens. - // /// If invalid, an error will be thrown. - // void _validateRegistrationTokens( - // List registrationTokenOrTokens, { - // required String methodName, - // MessagingClientErrorCode errorInfo = - // MessagingClientErrorCode.invalidArgument, - // }) { - // // Validate the array contains no more than 1,000 registration tokens. - // if (registrationTokenOrTokens.length > 1000) { - // throw FirebaseMessagingAdminException( - // errorInfo, - // 'Too many registration tokens provided in a single request to $methodName(). Batch ' - // 'your requests to contain no more than 1,000 registration tokens per request.', - // ); - // } - - // // Validate the array contains registration tokens which are non-empty strings. - // registrationTokenOrTokens.forEachIndexed((index, registrationToken) { - // if (registrationToken.isEmpty) { - // throw FirebaseMessagingAdminException( - // errorInfo, - // 'Registration token provided to $methodName() at index $index must be a ' - // 'non-empty string.', - // ); - // } - // }); - // } - - // /// Validates the type of the provided topic. If invalid, an error will be thrown. - // void _validateTopicType( - // String topic, { - // required String methodName, - // MessagingClientErrorCode errorInfo = - // MessagingClientErrorCode.invalidArgument, - // }) { - // if (topic.isEmpty) { - // throw FirebaseMessagingAdminException( - // errorInfo, - // 'Topic provided to $methodName() must be a string which matches the format ' - // '"/topics/[a-zA-Z0-9-_.~%]+".', - // ); - // } - // } - - // /// Normalizes the provided topic name by prepending it with '/topics/', if necessary. - // String _normalizeTopic(String topic) { - // if (!topic.startsWith('/topics/')) { - // return '/topics/$topic'; - // } - // return topic; - // } - - // /// Validates the provided topic. If invalid, an error will be thrown. - // void _validateTopic( - // String topic, { - // required String methodName, - // MessagingClientErrorCode errorInfo = - // MessagingClientErrorCode.invalidArgument, - // }) { - // if (!validator.isTopic(topic)) { - // throw FirebaseMessagingAdminException( - // errorInfo, - // 'Topic provided to $methodName() must be a string which matches the format ' - // '"/topics/[a-zA-Z0-9-_.~%]+".', - // ); - // } - // } - + // TODO subscribeToTopic, unsubscribeFromTopic // TODO sendAll – missing batch client implementation // TODO sendMulticast - relies on sendAll } diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging-api-request-internal.ts b/packages/dart_firebase_admin/lib/src/messaging/messaging-api-request-internal.ts deleted file mode 100644 index 1a73166..0000000 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging-api-request-internal.ts +++ /dev/null @@ -1,174 +0,0 @@ -/*! - * @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'; -import { FirebaseApp } from '../app/firebase-app'; -import { - HttpMethod, AuthorizedHttpClient, HttpRequestConfig, HttpError, HttpResponse, -} from '../utils/api-request'; -import { createFirebaseError, getErrorCode } from './messaging-errors-internal'; -import { SubRequest, BatchRequestClient } from './batch-request-internal'; -import { getSdkVersion } from '../utils/index'; -import { SendResponse, BatchResponse } from './messaging-api'; - - -// FCM backend constants -const FIREBASE_MESSAGING_TIMEOUT = 15000; -const FIREBASE_MESSAGING_BATCH_URL = 'https://fcm.googleapis.com/batch'; -const FIREBASE_MESSAGING_HTTP_METHOD: HttpMethod = 'POST'; -const FIREBASE_MESSAGING_HEADERS = { - 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, -}; -const LEGACY_FIREBASE_MESSAGING_HEADERS = { - 'X-Firebase-Client': `fire-admin-node/${getSdkVersion()}`, - 'access_token_auth': 'true', -}; - - -/** - * Class that provides a mechanism to send requests to the Firebase Cloud Messaging backend. - */ -export class FirebaseMessagingRequestHandler { - private readonly httpClient: AuthorizedHttpClient; - private readonly batchClient: BatchRequestClient; - - /** - * @param app - The app used to fetch access tokens to sign API requests. - * @constructor - */ - constructor(app: App) { - this.httpClient = new AuthorizedHttpClient(app as FirebaseApp); - this.batchClient = new BatchRequestClient( - this.httpClient, FIREBASE_MESSAGING_BATCH_URL, FIREBASE_MESSAGING_HEADERS); - } - - /** - * Invokes the request handler with the provided request data. - * - * @param host - The host to which to send the request. - * @param path - The path to which to send the request. - * @param requestData - The request data. - * @returns A promise that resolves with the response. - */ - public invokeRequestHandler(host: string, path: string, requestData: object): Promise { - const request: HttpRequestConfig = { - method: FIREBASE_MESSAGING_HTTP_METHOD, - url: `https://${host}${path}`, - data: requestData, - headers: LEGACY_FIREBASE_MESSAGING_HEADERS, - timeout: FIREBASE_MESSAGING_TIMEOUT, - }; - return this.httpClient.send(request).then((response) => { - // Send non-JSON responses to the catch() below where they will be treated as errors. - if (!response.isJson()) { - throw new HttpError(response); - } - - // Check for backend errors in the response. - const errorCode = getErrorCode(response.data); - if (errorCode) { - throw new HttpError(response); - } - - // Return entire response. - return response.data; - }) - .catch((err) => { - if (err instanceof HttpError) { - throw createFirebaseError(err); - } - // Re-throw the error if it already has the proper format. - throw err; - }); - } - - /** - * Invokes the request handler with the provided request data. - * - * @param host - The host to which to send the request. - * @param path - The path to which to send the request. - * @param requestData - The request data. - * @returns A promise that resolves with the {@link SendResponse}. - */ - public invokeRequestHandlerForSendResponse(host: string, path: string, requestData: object): Promise { - const request: HttpRequestConfig = { - method: FIREBASE_MESSAGING_HTTP_METHOD, - url: `https://${host}${path}`, - data: requestData, - headers: LEGACY_FIREBASE_MESSAGING_HEADERS, - timeout: FIREBASE_MESSAGING_TIMEOUT, - }; - return this.httpClient.send(request).then((response) => { - return this.buildSendResponse(response); - }) - .catch((err) => { - if (err instanceof HttpError) { - return this.buildSendResponseFromError(err); - } - // Re-throw the error if it already has the proper format. - throw err; - }); - } - - /** - * Sends the given array of sub requests as a single batch to FCM, and parses the result into - * a BatchResponse object. - * - * @param requests - An array of sub requests to send. - * @returns A promise that resolves when the send operation is complete. - */ - public sendBatchRequest(requests: SubRequest[]): Promise { - return this.batchClient.send(requests) - .then((responses: HttpResponse[]) => { - return responses.map((part: HttpResponse) => { - return this.buildSendResponse(part); - }); - }).then((responses: SendResponse[]) => { - const successCount: number = responses.filter((resp) => resp.success).length; - return { - responses, - successCount, - failureCount: responses.length - successCount, - }; - }).catch((err) => { - if (err instanceof HttpError) { - throw createFirebaseError(err); - } - // Re-throw the error if it already has the proper format. - throw err; - }); - } - - private buildSendResponse(response: HttpResponse): SendResponse { - const result: SendResponse = { - success: response.status === 200, - }; - if (result.success) { - result.messageId = response.data.name; - } else { - result.error = createFirebaseError(new HttpError(response)); - } - return result; - } - - private buildSendResponseFromError(err: HttpError): SendResponse { - return { - success: false, - error: createFirebaseError(err) - }; - } -} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging-api.ts b/packages/dart_firebase_admin/lib/src/messaging/messaging-api.ts deleted file mode 100644 index 1901c9a..0000000 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging-api.ts +++ /dev/null @@ -1,1118 +0,0 @@ -/*! - * @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 { FirebaseArrayIndexError, FirebaseError } from '../app/index'; - -export interface BaseMessage { - data?: { [key: string]: string }; - notification?: Notification; - android?: AndroidConfig; - webpush?: WebpushConfig; - apns?: ApnsConfig; - fcmOptions?: FcmOptions; -} - -export interface TokenMessage extends BaseMessage { - token: string; -} - -export interface TopicMessage extends BaseMessage { - topic: string; -} - -export interface ConditionMessage extends BaseMessage { - condition: string; -} - -/** - * Payload for the {@link Messaging.send} operation. The payload contains all the fields - * in the BaseMessage type, and exactly one of token, topic or condition. - */ -export type Message = TokenMessage | TopicMessage | ConditionMessage; - -/** - * Payload for the {@link Messaging.sendMulticast} method. The payload contains all the fields - * in the BaseMessage type, and a list of tokens. - */ -export interface MulticastMessage extends BaseMessage { - tokens: string[]; -} - -/** - * A notification that can be included in {@link Message}. - */ -export interface Notification { - /** - * The title of the notification. - */ - title?: string; - /** - * The notification body - */ - body?: string; - /** - * URL of an image to be displayed in the notification. - */ - imageUrl?: string; -} - -/** - * Represents platform-independent options for features provided by the FCM SDKs. - */ -export interface FcmOptions { - /** - * The label associated with the message's analytics data. - */ - analyticsLabel?: string; -} - -/** - * Represents the WebPush protocol options that can be included in an - * {@link Message}. - */ -export interface WebpushConfig { - - /** - * A collection of WebPush headers. Header values must be strings. - * - * See {@link https://tools.ietf.org/html/rfc8030#section-5 | WebPush specification} - * for supported headers. - */ - headers?: { [key: string]: string }; - - /** - * A collection of data fields. - */ - data?: { [key: string]: string }; - - /** - * A WebPush notification payload to be included in the message. - */ - notification?: WebpushNotification; - - /** - * Options for features provided by the FCM SDK for Web. - */ - fcmOptions?: WebpushFcmOptions; -} - -/** Represents options for features provided by the FCM SDK for Web - * (which are not part of the Webpush standard). - */ -export interface WebpushFcmOptions { - - /** - * The link to open when the user clicks on the notification. - * For all URL values, HTTPS is required. - */ - link?: string; -} - -/** - * Represents the WebPush-specific notification options that can be included in - * {@link WebpushConfig}. This supports most of the standard - * options as defined in the Web Notification - * {@link https://developer.mozilla.org/en-US/docs/Web/API/notification/Notification | specification}. - */ -export interface WebpushNotification { - - /** - * Title text of the notification. - */ - title?: string; - - /** - * An array of notification actions representing the actions - * available to the user when the notification is presented. - */ - actions?: Array<{ - - /** - * An action available to the user when the notification is presented - */ - action: string; - - /** - * Optional icon for a notification action. - */ - icon?: string; - - /** - * Title of the notification action. - */ - title: string; - }>; - - /** - * URL of the image used to represent the notification when there is - * not enough space to display the notification itself. - */ - badge?: string; - - /** - * Body text of the notification. - */ - body?: string; - - /** - * Arbitrary data that you want associated with the notification. - * This can be of any data type. - */ - data?: any; - - /** - * The direction in which to display the notification. Must be one - * of `auto`, `ltr` or `rtl`. - */ - dir?: 'auto' | 'ltr' | 'rtl'; - - /** - * URL to the notification icon. - */ - icon?: string; - - /** - * URL of an image to be displayed in the notification. - */ - image?: string; - - /** - * The notification's language as a BCP 47 language tag. - */ - lang?: string; - - /** - * A boolean specifying whether the user should be notified after a - * new notification replaces an old one. Defaults to false. - */ - renotify?: boolean; - - /** - * Indicates that a notification should remain active until the user - * clicks or dismisses it, rather than closing automatically. - * Defaults to false. - */ - requireInteraction?: boolean; - - /** - * A boolean specifying whether the notification should be silent. - * Defaults to false. - */ - silent?: boolean; - - /** - * An identifying tag for the notification. - */ - tag?: string; - - /** - * Timestamp of the notification. Refer to - * https://developer.mozilla.org/en-US/docs/Web/API/notification/timestamp - * for details. - */ - timestamp?: number; - - /** - * A vibration pattern for the device's vibration hardware to emit - * when the notification fires. - */ - vibrate?: number | number[]; - [key: string]: any; -} - -/** - * Represents the APNs-specific options that can be included in an - * {@link Message}. Refer to - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CommunicatingwithAPNs.html | - * Apple documentation} for various headers and payload fields supported by APNs. - */ -export interface ApnsConfig { - /** - * A collection of APNs headers. Header values must be strings. - */ - headers?: { [key: string]: string }; - - /** - * An APNs payload to be included in the message. - */ - payload?: ApnsPayload; - - /** - * Options for features provided by the FCM SDK for iOS. - */ - fcmOptions?: ApnsFcmOptions; -} - -/** - * Represents the payload of an APNs message. Mainly consists of the `aps` - * dictionary. But may also contain other arbitrary custom keys. - */ -export interface ApnsPayload { - - /** - * The `aps` dictionary to be included in the message. - */ - aps: Aps; - [customData: string]: any; -} - -/** - * Represents the {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html | - * aps dictionary} that is part of APNs messages. - */ -export interface Aps { - - /** - * Alert to be included in the message. This may be a string or an object of - * type `admin.messaging.ApsAlert`. - */ - alert?: string | ApsAlert; - - /** - * Badge to be displayed with the message. Set to 0 to remove the badge. When - * not specified, the badge will remain unchanged. - */ - badge?: number; - - /** - * Sound to be played with the message. - */ - sound?: string | CriticalSound; - - /** - * Specifies whether to configure a background update notification. - */ - contentAvailable?: boolean; - - /** - * Specifies whether to set the `mutable-content` property on the message - * so the clients can modify the notification via app extensions. - */ - mutableContent?: boolean; - - /** - * Type of the notification. - */ - category?: string; - - /** - * An app-specific identifier for grouping notifications. - */ - threadId?: string; - [customData: string]: any; -} - -export interface ApsAlert { - title?: string; - subtitle?: string; - body?: string; - locKey?: string; - locArgs?: string[]; - titleLocKey?: string; - titleLocArgs?: string[]; - subtitleLocKey?: string; - subtitleLocArgs?: string[]; - actionLocKey?: string; - launchImage?: string; -} - -/** - * Represents a critical sound configuration that can be included in the - * `aps` dictionary of an APNs payload. - */ -export interface CriticalSound { - - /** - * The critical alert flag. Set to `true` to enable the critical alert. - */ - critical?: boolean; - - /** - * The name of a sound file in the app's main bundle or in the `Library/Sounds` - * folder of the app's container directory. Specify the string "default" to play - * the system sound. - */ - name: string; - - /** - * The volume for the critical alert's sound. Must be a value between 0.0 - * (silent) and 1.0 (full volume). - */ - volume?: number; -} - -/** - * Represents options for features provided by the FCM SDK for iOS. - */ -export interface ApnsFcmOptions { - - /** - * The label associated with the message's analytics data. - */ - analyticsLabel?: string; - - /** - * URL of an image to be displayed in the notification. - */ - imageUrl?: string; -} - - -/** - * Represents the Android-specific options that can be included in an - * {@link Message}. - */ -export interface AndroidConfig { - - /** - * Collapse key for the message. Collapse key serves as an identifier for a - * group of messages that can be collapsed, so that only the last message gets - * sent when delivery can be resumed. A maximum of four different collapse keys - * may be active at any given time. - */ - collapseKey?: string; - - /** - * Priority of the message. Must be either `normal` or `high`. - */ - priority?: ('high' | 'normal'); - - /** - * Time-to-live duration of the message in milliseconds. - */ - ttl?: number; - - /** - * Package name of the application where the registration tokens must match - * in order to receive the message. - */ - restrictedPackageName?: string; - - /** - * A collection of data fields to be included in the message. All values must - * be strings. When provided, overrides any data fields set on the top-level - * {@link Message}. - */ - data?: { [key: string]: string }; - - /** - * Android notification to be included in the message. - */ - notification?: AndroidNotification; - - /** - * Options for features provided by the FCM SDK for Android. - */ - fcmOptions?: AndroidFcmOptions; -} - -/** - * Represents the Android-specific notification options that can be included in - * {@link AndroidConfig}. - */ -export interface AndroidNotification { - /** - * Title of the Android notification. When provided, overrides the title set via - * `admin.messaging.Notification`. - */ - title?: string; - - /** - * Body of the Android notification. When provided, overrides the body set via - * `admin.messaging.Notification`. - */ - body?: string; - - /** - * Icon resource for the Android notification. - */ - icon?: string; - - /** - * Notification icon color in `#rrggbb` format. - */ - color?: string; - - /** - * File name of the sound to be played when the device receives the - * notification. - */ - sound?: string; - - /** - * Notification tag. This is an identifier used to replace existing - * notifications in the notification drawer. If not specified, each request - * creates a new notification. - */ - tag?: string; - - /** - * URL of an image to be displayed in the notification. - */ - imageUrl?: string; - - /** - * Action associated with a user click on the notification. If specified, an - * activity with a matching Intent Filter is launched when a user clicks on the - * notification. - */ - clickAction?: string; - - /** - * Key of the body string in the app's string resource to use to localize the - * body text. - * - */ - bodyLocKey?: string; - - /** - * An array of resource keys that will be used in place of the format - * specifiers in `bodyLocKey`. - */ - bodyLocArgs?: string[]; - - /** - * Key of the title string in the app's string resource to use to localize the - * title text. - */ - titleLocKey?: string; - - /** - * An array of resource keys that will be used in place of the format - * specifiers in `titleLocKey`. - */ - titleLocArgs?: string[]; - - /** - * The Android notification channel ID (new in Android O). The app must create - * a channel with this channel ID before any notification with this channel ID - * can be received. If you don't send this channel ID in the request, or if the - * channel ID provided has not yet been created by the app, FCM uses the channel - * ID specified in the app manifest. - */ - channelId?: string; - - /** - * Sets the "ticker" text, which is sent to accessibility services. Prior to - * API level 21 (Lollipop), sets the text that is displayed in the status bar - * when the notification first arrives. - */ - ticker?: string; - - /** - * When set to `false` or unset, the notification is automatically dismissed when - * the user clicks it in the panel. When set to `true`, the notification persists - * even when the user clicks it. - */ - sticky?: boolean; - - /** - * For notifications that inform users about events with an absolute time reference, sets - * the time that the event in the notification occurred. Notifications - * in the panel are sorted by this time. - */ - eventTimestamp?: Date; - - /** - * Sets whether or not this notification is relevant only to the current device. - * Some notifications can be bridged to other devices for remote display, such as - * a Wear OS watch. This hint can be set to recommend this notification not be bridged. - * See {@link https://developer.android.com/training/wearables/notifications/bridger#existing-method-of-preventing-bridging | - * Wear OS guides}. - */ - localOnly?: boolean; - - /** - * Sets the relative priority for this notification. Low-priority notifications - * may be hidden from the user in certain situations. Note this priority differs - * from `AndroidMessagePriority`. This priority is processed by the client after - * the message has been delivered. Whereas `AndroidMessagePriority` is an FCM concept - * that controls when the message is delivered. - */ - priority?: ('min' | 'low' | 'default' | 'high' | 'max'); - - /** - * Sets the vibration pattern to use. Pass in an array of milliseconds to - * turn the vibrator on or off. The first value indicates the duration to wait before - * turning the vibrator on. The next value indicates the duration to keep the - * vibrator on. Subsequent values alternate between duration to turn the vibrator - * off and to turn the vibrator on. If `vibrate_timings` is set and `default_vibrate_timings` - * is set to `true`, the default value is used instead of the user-specified `vibrate_timings`. - */ - vibrateTimingsMillis?: number[]; - - /** - * If set to `true`, use the Android framework's default vibrate pattern for the - * notification. Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | - * config.xml}. If `default_vibrate_timings` is set to `true` and `vibrate_timings` is also set, - * the default value is used instead of the user-specified `vibrate_timings`. - */ - defaultVibrateTimings?: boolean; - - /** - * If set to `true`, use the Android framework's default sound for the notification. - * Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | - * config.xml}. - */ - defaultSound?: boolean; - - /** - * Settings to control the notification's LED blinking rate and color if LED is - * available on the device. The total blinking time is controlled by the OS. - */ - lightSettings?: LightSettings; - - /** - * If set to `true`, use the Android framework's default LED light settings - * for the notification. Default values are specified in {@link https://android.googlesource.com/platform/frameworks/base/+/master/core/res/res/values/config.xml | - * config.xml}. - * If `default_light_settings` is set to `true` and `light_settings` is also set, - * the user-specified `light_settings` is used instead of the default value. - */ - defaultLightSettings?: boolean; - - /** - * Sets the visibility of the notification. Must be either `private`, `public`, - * or `secret`. If unspecified, defaults to `private`. - */ - visibility?: ('private' | 'public' | 'secret'); - - /** - * Sets the number of items this notification represents. May be displayed as a - * badge count for Launchers that support badging. See {@link https://developer.android.com/training/notify-user/badges | - * NotificationBadge}. - * For example, this might be useful if you're using just one notification to - * represent multiple new messages but you want the count here to represent - * the number of total new messages. If zero or unspecified, systems - * that support badging use the default, which is to increment a number - * displayed on the long-press menu each time a new notification arrives. - */ - notificationCount?: number; -} - -/** - * Represents settings to control notification LED that can be included in - * {@link AndroidNotification}. - */ -export interface LightSettings { - /** - * Required. Sets color of the LED in `#rrggbb` or `#rrggbbaa` format. - */ - color: string; - - /** - * Required. Along with `light_off_duration`, defines the blink rate of LED flashes. - */ - lightOnDurationMillis: number; - - /** - * Required. Along with `light_on_duration`, defines the blink rate of LED flashes. - */ - lightOffDurationMillis: number; -} - -/** - * Represents options for features provided by the FCM SDK for Android. - */ -export interface AndroidFcmOptions { - - /** - * The label associated with the message's analytics data. - */ - analyticsLabel?: string; -} - -/** - * Interface representing an FCM legacy API data message payload. Data - * messages let developers send up to 4KB of custom key-value pairs. The - * keys and values must both be strings. Keys can be any custom string, - * except for the following reserved strings: - * - *
    - *
  • from
  • - *
  • Anything starting with google.
  • - *
- * - * See {@link https://firebase.google.com/docs/cloud-messaging/send-message | Build send requests} - * for code samples and detailed documentation. - */ -export interface DataMessagePayload { - [key: string]: string; -} - -/** - * Interface representing an FCM legacy API notification message payload. - * Notification messages let developers send up to 4KB of predefined - * key-value pairs. Accepted keys are outlined below. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/send-message | Build send requests} - * for code samples and detailed documentation. - */ -export interface NotificationMessagePayload { - - /** - * Identifier used to replace existing notifications in the notification drawer. - * - * If not specified, each request creates a new notification. - * - * If specified and a notification with the same tag is already being shown, - * the new notification replaces the existing one in the notification drawer. - * - * **Platforms:** Android - */ - tag?: string; - - /** - * The notification's body text. - * - * **Platforms:** iOS, Android, Web - */ - body?: string; - - /** - * The notification's icon. - * - * **Android:** Sets the notification icon to `myicon` for drawable resource - * `myicon`. If you don't send this key in the request, FCM displays the - * launcher icon specified in your app manifest. - * - * **Web:** The URL to use for the notification's icon. - * - * **Platforms:** Android, Web - */ - icon?: string; - - /** - * The value of the badge on the home screen app icon. - * - * If not specified, the badge is not changed. - * - * If set to `0`, the badge is removed. - * - * **Platforms:** iOS - */ - badge?: string; - - /** - * The notification icon's color, expressed in `#rrggbb` format. - * - * **Platforms:** Android - */ - color?: string; - - /** - * The sound to be played when the device receives a notification. Supports - * "default" for the default notification sound of the device or the filename of a - * sound resource bundled in the app. - * Sound files must reside in `/res/raw/`. - * - * **Platforms:** Android - */ - sound?: string; - - /** - * The notification's title. - * - * **Platforms:** iOS, Android, Web - */ - title?: string; - - /** - * The key to the body string in the app's string resources to use to localize - * the body text to the user's current localization. - * - * **iOS:** Corresponds to `loc-key` in the APNs payload. See - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html | - * Payload Key Reference} and - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 | - * Localizing the Content of Your Remote Notifications} for more information. - * - * **Android:** See - * {@link http://developer.android.com/guide/topics/resources/string-resource.html | String Resources} - * for more information. - * - * **Platforms:** iOS, Android - */ - bodyLocKey?: string; - - /** - * Variable string values to be used in place of the format specifiers in - * `body_loc_key` to use to localize the body text to the user's current - * localization. - * - * The value should be a stringified JSON array. - * - * **iOS:** Corresponds to `loc-args` in the APNs payload. See - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html | - * Payload Key Reference} and - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 | - * Localizing the Content of Your Remote Notifications} for more information. - * - * **Android:** See - * {@link http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling | - * Formatting and Styling} for more information. - * - * **Platforms:** iOS, Android - */ - bodyLocArgs?: string; - - /** - * Action associated with a user click on the notification. If specified, an - * activity with a matching Intent Filter is launched when a user clicks on the - * notification. - * - * * **Platforms:** Android - */ - clickAction?: string; - - /** - * The key to the title string in the app's string resources to use to localize - * the title text to the user's current localization. - * - * **iOS:** Corresponds to `title-loc-key` in the APNs payload. See - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html | - * Payload Key Reference} and - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 | - * Localizing the Content of Your Remote Notifications} for more information. - * - * **Android:** See - * {@link http://developer.android.com/guide/topics/resources/string-resource.html | String Resources} - * for more information. - * - * **Platforms:** iOS, Android - */ - titleLocKey?: string; - - /** - * Variable string values to be used in place of the format specifiers in - * `title_loc_key` to use to localize the title text to the user's current - * localization. - * - * The value should be a stringified JSON array. - * - * **iOS:** Corresponds to `title-loc-args` in the APNs payload. See - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/PayloadKeyReference.html | - * Payload Key Reference} and - * {@link https://developer.apple.com/library/content/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/CreatingtheNotificationPayload.html#//apple_ref/doc/uid/TP40008194-CH10-SW9 | - * Localizing the Content of Your Remote Notifications} for more information. - * - * **Android:** See - * {@link http://developer.android.com/guide/topics/resources/string-resource.html#FormattingAndStyling | - * Formatting and Styling} for more information. - * - * **Platforms:** iOS, Android - */ - titleLocArgs?: string; - [key: string]: string | undefined; -} - -/** - * Interface representing a Firebase Cloud Messaging message payload. One or - * both of the `data` and `notification` keys are required. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/send-message | Build send requests} - * for code samples and detailed documentation. - */ -export interface MessagingPayload { - - /** - * The data message payload. - */ - data?: DataMessagePayload; - - /** - * The notification message payload. - */ - notification?: NotificationMessagePayload; -} - -/** - * Interface representing the options that can be provided when sending a - * message via the FCM legacy APIs. - * - * See {@link https://firebase.google.com/docs/cloud-messaging/send-message | Build send requests} - * for code samples and detailed documentation. - */ -export interface MessagingOptions { - - /** - * Whether or not the message should actually be sent. When set to `true`, - * allows developers to test a request without actually sending a message. When - * set to `false`, the message will be sent. - * - * **Default value:** `false` - */ - dryRun?: boolean; - - /** - * The priority of the message. Valid values are `"normal"` and `"high".` On - * iOS, these correspond to APNs priorities `5` and `10`. - * - * By default, notification messages are sent with high priority, and data - * messages are sent with normal priority. Normal priority optimizes the client - * app's battery consumption and should be used unless immediate delivery is - * required. For messages with normal priority, the app may receive the message - * with unspecified delay. - * - * When a message is sent with high priority, it is sent immediately, and the - * app can wake a sleeping device and open a network connection to your server. - * - * For more information, see - * {@link https://firebase.google.com/docs/cloud-messaging/concept-options#setting-the-priority-of-a-message | - * Setting the priority of a message}. - * - * **Default value:** `"high"` for notification messages, `"normal"` for data - * messages - */ - priority?: string; - - /** - * How long (in seconds) the message should be kept in FCM storage if the device - * is offline. The maximum time to live supported is four weeks, and the default - * value is also four weeks. For more information, see - * {@link https://firebase.google.com/docs/cloud-messaging/concept-options#ttl | Setting the lifespan of a message}. - * - * **Default value:** `2419200` (representing four weeks, in seconds) - */ - timeToLive?: number; - - /** - * String identifying a group of messages (for example, "Updates Available") - * that can be collapsed, so that only the last message gets sent when delivery - * can be resumed. This is used to avoid sending too many of the same messages - * when the device comes back online or becomes active. - * - * There is no guarantee of the order in which messages get sent. - * - * A maximum of four different collapse keys is allowed at any given time. This - * means FCM server can simultaneously store four different - * send-to-sync messages per client app. If you exceed this number, there is no - * guarantee which four collapse keys the FCM server will keep. - * - * **Default value:** None - */ - collapseKey?: string; - - /** - * On iOS, use this field to represent `mutable-content` in the APNs payload. - * When a notification is sent and this is set to `true`, the content of the - * notification can be modified before it is displayed, using a - * {@link https://developer.apple.com/reference/usernotifications/unnotificationserviceextension | - * Notification Service app extension}. - * - * On Android and Web, this parameter will be ignored. - * - * **Default value:** `false` - */ - mutableContent?: boolean; - - /** - * On iOS, use this field to represent `content-available` in the APNs payload. - * When a notification or data message is sent and this is set to `true`, an - * inactive client app is awoken. On Android, data messages wake the app by - * default. On Chrome, this flag is currently not supported. - * - * **Default value:** `false` - */ - contentAvailable?: boolean; - - /** - * The package name of the application which the registration tokens must match - * in order to receive the message. - * - * **Default value:** None - */ - restrictedPackageName?: string; - [key: string]: any | undefined; -} - -/** - * Individual status response payload from single devices - * - * @deprecated Returned by {@link Messaging#sendToDevice}, which is also deprecated. - */ -export interface MessagingDeviceResult { - /** - * The error that occurred when processing the message for the recipient. - */ - error?: FirebaseError; - - /** - * A unique ID for the successfully processed message. - */ - messageId?: string; - - /** - * The canonical registration token for the client app that the message was - * processed and sent to. You should use this value as the registration token - * for future requests. Otherwise, future messages might be rejected. - */ - canonicalRegistrationToken?: string; -} - -/** - * Interface representing the status of a message sent to an individual device - * via the FCM legacy APIs. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_individual_devices | - * Send to individual devices} for code samples and detailed documentation. - * - * @deprecated Returned by {@link Messaging.sendToDevice}, which is also deprecated. - */ -export interface MessagingDevicesResponse { - canonicalRegistrationTokenCount: number; - failureCount: number; - multicastId: number; - results: MessagingDeviceResult[]; - successCount: number; -} - -/** - * Interface representing the server response from the {@link Messaging.sendToDeviceGroup} - * method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/send-message?authuser=0#send_messages_to_device_groups | - * Send messages to device groups} for code samples and detailed documentation. - * - * @deprecated Returned by {@link Messaging.sendToDeviceGroup}, which is also deprecated. - */ -export interface MessagingDeviceGroupResponse { - - /** - * The number of messages that could not be processed and resulted in an error. - */ - successCount: number; - - /** - * The number of messages that could not be processed and resulted in an error. - */ - failureCount: number; - - /** - * An array of registration tokens that failed to receive the message. - */ - failedRegistrationTokens: string[]; -} - -/** - * Interface representing the server response from the legacy {@link Messaging.sendToTopic} method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_a_topic | - * Send to a topic} for code samples and detailed documentation. - */ -export interface MessagingTopicResponse { - /** - * The message ID for a successfully received request which FCM will attempt to - * deliver to all subscribed devices. - */ - messageId: number; -} - -/** - * Interface representing the server response from the legacy - * {@link Messaging.sendToCondition} method. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/admin/send-messages#send_to_a_condition | - * Send to a condition} for code samples and detailed documentation. - */ -export interface MessagingConditionResponse { - /** - * The message ID for a successfully received request which FCM will attempt to - * deliver to all subscribed devices. - */ - messageId: number; -} - -/** - * Interface representing the server response from the - * {@link Messaging.subscribeToTopic} and {@link Messaging.unsubscribeFromTopic} - * methods. - * - * See - * {@link https://firebase.google.com/docs/cloud-messaging/manage-topics | - * Manage topics from the server} for code samples and detailed documentation. - */ -export interface MessagingTopicManagementResponse { - /** - * The number of registration tokens that could not be subscribed to the topic - * and resulted in an error. - */ - failureCount: number; - - /** - * The number of registration tokens that were successfully subscribed to the - * topic. - */ - successCount: number; - - /** - * An array of errors corresponding to the provided registration token(s). The - * length of this array will be equal to {@link MessagingTopicManagementResponse.failureCount}. - */ - errors: FirebaseArrayIndexError[]; -} - -/** - * Interface representing the server response from the - * {@link Messaging.sendAll} and {@link Messaging.sendMulticast} methods. - */ -export interface BatchResponse { - - /** - * An array of responses, each corresponding to a message. - */ - responses: SendResponse[]; - - /** - * The number of messages that were successfully handed off for sending. - */ - successCount: number; - - /** - * The number of messages that resulted in errors when sending. - */ - failureCount: number; -} - -/** - * Interface representing the status of an individual message that was sent as - * part of a batch request. - */ -export interface SendResponse { - /** - * A boolean indicating if the message was successfully handed off to FCM or - * not. When true, the `messageId` attribute is guaranteed to be set. When - * false, the `error` attribute is guaranteed to be set. - */ - success: boolean; - - /** - * A unique message ID string, if the message was handed off to FCM for - * delivery. - * - */ - messageId?: string; - - /** - * An error, if the message was not handed off to FCM successfully. - */ - error?: FirebaseError; -} \ No newline at end of file diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging_api.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging_api.dart index 7798734..9baa0db 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging_api.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging_api.dart @@ -1265,77 +1265,6 @@ class MessagingTopicResponse { final num messageId; } -/// Interface representing the server response from the -/// [Messaging.subscribeToTopic] and [Messaging.unsubscribeFromTopic] -/// methods. -/// -/// See -/// [Manage topics from the server](https://firebase.google.com/docs/cloud-messaging/manage-topics) -/// for code samples and detailed documentation. -@internal -class MessagingTopicManagementResponse { - /// Interface representing the server response from the - /// [Messaging.subscribeToTopic] and [Messaging.unsubscribeFromTopic] - /// methods. - /// - /// See - /// [Manage topics from the server](https://firebase.google.com/docs/cloud-messaging/manage-topics) - /// for code samples and detailed documentation. - MessagingTopicManagementResponse._({ - required this.failureCount, - required this.successCount, - required this.errors, - }); - - factory MessagingTopicManagementResponse._fromResponse(Object? response) { - // Add the success and failure counts. - var successCount = 0; - var failureCount = 0; - final errors = []; - - if (response case {'results': final List results}) { - results.forEachIndexed((index, tokenManagementResult) { - // Map the FCM server's error strings to actual error objects. - if (tokenManagementResult case {'error': final String error}) { - failureCount += 1; - final newError = - FirebaseMessagingAdminException.fromTopicManagementServerError( - serverErrorCode: error, - rawServerResponse: error, - ); - - errors.add( - FirebaseArrayIndexError( - index: index, - error: newError, - ), - ); - } else { - successCount += 1; - } - }); - } - - return MessagingTopicManagementResponse._( - successCount: successCount, - failureCount: failureCount, - errors: [], - ); - } - - /// The number of registration tokens that could not be subscribed to the topic - /// and resulted in an error. - final int failureCount; - - /// The number of registration tokens that were successfully subscribed to the - /// topic. - final int successCount; - - /// An array of errors corresponding to the provided registration token(s). The - /// length of this array will be equal to [MessagingTopicManagementResponse.failureCount]. - final List errors; -} - /// Interface representing the server response from the /// [Messaging.sendEach] and [Messaging.sendEachForMulticast] methods. class BatchResponse { diff --git a/packages/dart_firebase_admin/test/messaging/messaging_test.dart b/packages/dart_firebase_admin/test/messaging/messaging_test.dart index 109b919..c2009e9 100644 --- a/packages/dart_firebase_admin/test/messaging/messaging_test.dart +++ b/packages/dart_firebase_admin/test/messaging/messaging_test.dart @@ -199,10 +199,10 @@ void main() { (i) => Future.value(fmc1.Message(name: 'test')), ); - await messaging.sendEach([ + await messaging.sendEach(dryRun: true, [ TopicMessage(topic: 'test'), TopicMessage(topic: 'test2'), - ], dryRun: true); + ]); final capture = verify(() => messages.send(captureAny(), any())) ..called(2); From 8077d2079427ba9cb17f877fa630f0c4c5f182b7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 9 Jul 2024 07:48:01 +0200 Subject: [PATCH 04/11] Bump melos from 5.3.0 to 6.1.0 (#36) Bumps [melos](https://github.com/invertase/melos/tree/main/packages) from 5.3.0 to 6.1.0. - [Release notes](https://github.com/invertase/melos/releases) - [Changelog](https://github.com/invertase/melos/blob/main/CHANGELOG.md) - [Commits](https://github.com/invertase/melos/commits/melos-v6.1.0/packages) --- updated-dependencies: - dependency-name: melos dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pubspec.yaml b/pubspec.yaml index e16eb89..255837e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,4 +4,4 @@ publish_to: none environment: sdk: '>=3.0.0 <4.0.0' dev_dependencies: - melos: ^5.2.1 + melos: ^6.1.0 From a4595027b6dc5ce855086c89adbb19a86d090892 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 08:56:49 +0200 Subject: [PATCH 05/11] Fix test/firebase_admin_app_test.dart --- .../dart_firebase_admin/test/firebase_admin_app_test.dart | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/dart_firebase_admin/test/firebase_admin_app_test.dart b/packages/dart_firebase_admin/test/firebase_admin_app_test.dart index 1f9563d..132cfc2 100644 --- a/packages/dart_firebase_admin/test/firebase_admin_app_test.dart +++ b/packages/dart_firebase_admin/test/firebase_admin_app_test.dart @@ -13,7 +13,7 @@ void main() { expect(app.authApiHost, Uri.https('identitytoolkit.googleapis.com', '/')); expect( app.firestoreApiHost, - Uri.https('identitytoolkit.googleapis.com', '/'), + Uri.https('firestore.googleapis.com', '/'), ); }); @@ -30,8 +30,8 @@ void main() { Uri.http('127.0.0.1:9099', 'identitytoolkit.googleapis.com/'), ); expect( - app.authApiHost, - Uri.http('127.0.0.1:8080', 'identitytoolkit.googleapis.com/'), + app.firestoreApiHost, + Uri.http('127.0.0.1:8080', '/'), ); }); }); From 31fe399733dbc432dbbf9a2d8f48ee32a2360354 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 09:06:51 +0200 Subject: [PATCH 06/11] Ignore logs --- .gitignore | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.gitignore b/.gitignore index bb5ea97..db5356d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ +firebase-debug.log +ui-debug.log +firestore-debug.log + build coverage From 0e5d0319f44f20f0a5505965b81d37219be30a4b Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 10:17:29 +0200 Subject: [PATCH 07/11] Run emulators in CI (#39) --- .github/workflows/build.yml | 4 ++++ scripts/coverage.sh | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c55c19c..6cc6f62 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -22,6 +22,7 @@ jobs: - uses: actions/checkout@v3.1.0 with: fetch-depth: 2 + - uses: actions/setup-node@v4 - uses: subosito/flutter-action@v2.7.1 with: channel: master @@ -30,6 +31,9 @@ jobs: - name: Add pub cache to PATH run: echo "PUB_CACHE="$HOME/.pub-cache"" >> $GITHUB_ENV + - name: Install firebase CLI + run: npm install -g firebase-tools + - name: Install dependencies run: dart pub get && cd example && dart pub get && cd - diff --git a/scripts/coverage.sh b/scripts/coverage.sh index fdbc00a..c2186dd 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -5,6 +5,6 @@ set -e dart pub global activate coverage -dart test --coverage="coverage" +firebase emulators:exec --project flutterfire-e2e-tests --only firestore,auth "dart test --coverage=coverage" format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file From 6cc43d97dc1d86cf6873696f2d911fc70ed3f725 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 10:36:05 +0200 Subject: [PATCH 08/11] Fix warning --- scripts/coverage.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/coverage.sh b/scripts/coverage.sh index c2186dd..9c65223 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -5,6 +5,6 @@ set -e dart pub global activate coverage -firebase emulators:exec --project flutterfire-e2e-tests --only firestore,auth "dart test --coverage=coverage" +firebase emulators:exec --project dart-firebase-admin --only firestore,auth "dart test --coverage=coverage" format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file From 03ba4528583e0463577295a1dbebc3dcc70e7366 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 11:01:03 +0200 Subject: [PATCH 09/11] Delete TS --- .../dart_firebase_admin/lib/src/utils/jwt.ts | 370 -- .../test/auth/integration.ts | 3243 ----------------- 2 files changed, 3613 deletions(-) delete mode 100644 packages/dart_firebase_admin/lib/src/utils/jwt.ts delete mode 100644 packages/dart_firebase_admin/test/auth/integration.ts diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.ts b/packages/dart_firebase_admin/lib/src/utils/jwt.ts deleted file mode 100644 index f8d80a6..0000000 --- a/packages/dart_firebase_admin/lib/src/utils/jwt.ts +++ /dev/null @@ -1,370 +0,0 @@ -/*! - * 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/test/auth/integration.ts b/packages/dart_firebase_admin/test/auth/integration.ts deleted file mode 100644 index b8898c1..0000000 --- a/packages/dart_firebase_admin/test/auth/integration.ts +++ /dev/null @@ -1,3243 +0,0 @@ -/*! - * 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 url from 'url'; -import * as crypto from 'crypto'; -import * as bcrypt from 'bcrypt'; -import * as chai from 'chai'; -import * as chaiAsPromised from 'chai-as-promised'; -import firebase from '@firebase/app-compat'; -import '@firebase/auth-compat'; -import { clone } from 'lodash'; -import { User, FirebaseAuth } from '@firebase/auth-types'; -import { - generateRandomString, projectId, apiKey, noServiceAccountApp, cmdArgs, -} from './setup'; -import * as mocks from '../resources/mocks'; -import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; -import { - AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, - TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, - UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, - PasswordPolicyConfig, SmsRegionConfig, -} from '../../lib/auth/index'; -import * as sinon from 'sinon'; -import * as sinonChai from 'sinon-chai'; - -const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires - -chai.should(); -chai.use(sinonChai); -chai.use(chaiAsPromised); - -const expect = chai.expect; - -const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST; - -const newUserUid = generateRandomString(20); -const nonexistentUid = generateRandomString(20); -const newMultiFactorUserUid = generateRandomString(20); -const sessionCookieUids = [ - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), -]; -const testPhoneNumber = '+11234567890'; -const testPhoneNumber2 = '+16505550101'; -const nonexistentPhoneNumber = '+18888888888'; -const updatedEmail = generateRandomString(20).toLowerCase() + '@example.com'; -const updatedPhone = '+16505550102'; -const customClaims: { [key: string]: any } = { - admin: true, - groupId: '1234', -}; -const uids = [newUserUid + '-1', newUserUid + '-2', newUserUid + '-3']; -const mockUserData = { - email: newUserUid.toLowerCase() + '@example.com', - emailVerified: false, - phoneNumber: testPhoneNumber, - password: 'password', - displayName: 'Random User ' + newUserUid, - photoURL: 'http://www.example.com/' + newUserUid + '/photo.png', - disabled: false, -}; -const actionCodeSettings = { - url: 'http://localhost/?a=1&b=2#c=3', - handleCodeInApp: false, -}; -let deleteQueue = Promise.resolve(); - -interface UserImportTest { - name: string; - importOptions: UserImportOptions; - rawPassword: string; - rawSalt?: string; - computePasswordHash(userImportTest: UserImportTest): Buffer; -} - -/** @return Random generated SAML provider ID. */ -function randomSamlProviderId(): string { - return 'saml.' + generateRandomString(10, false).toLowerCase(); -} - -/** @return Random generated OIDC provider ID. */ -function randomOidcProviderId(): string { - return 'oidc.' + generateRandomString(10, false).toLowerCase(); -} - -function clientAuth(): FirebaseAuth { - expect(firebase.auth).to.be.ok; - return firebase.auth!(); -} - -describe('admin.auth', () => { - - let uidFromCreateUserWithoutUid: string; - const processWarningSpy = sinon.spy(); - - before(() => { - firebase.initializeApp({ - apiKey, - authDomain: projectId + '.firebaseapp.com', - }); - if (authEmulatorHost) { - (clientAuth() as any).useEmulator('http://' + authEmulatorHost); - } - process.on('warning', processWarningSpy); - return cleanup(); - }); - - afterEach(() => { - expect( - processWarningSpy.neverCalledWith( - sinon.match( - (warning: Error) => warning.name === 'MaxListenersExceededWarning' - ) - ), - 'process.on("warning") was called with an unexpected MaxListenersExceededWarning.' - ).to.be.true; - processWarningSpy.resetHistory(); - }); - - after(() => { - process.removeListener('warning', processWarningSpy); - return cleanup(); - }); - - it('createUser() creates a new user when called without a UID', () => { - const newUserData = clone(mockUserData); - newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; - newUserData.phoneNumber = testPhoneNumber2; - return getAuth().createUser(newUserData) - .then((userRecord) => { - uidFromCreateUserWithoutUid = userRecord.uid; - expect(typeof userRecord.uid).to.equal('string'); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('createUser() creates a new user with the specified UID', () => { - const newUserData: any = clone(mockUserData); - newUserData.uid = newUserUid; - return getAuth().createUser(newUserData) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('createUser() creates a new user with enrolled second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - const enrolledFactors = [ - { - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - }, - { - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - }, - ]; - const newUserData: any = { - uid: newMultiFactorUserUid, - email: generateRandomString(20).toLowerCase() + '@example.com', - emailVerified: true, - password: 'password', - multiFactor: { - enrolledFactors, - }, - }; - return getAuth().createUser(newUserData) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newMultiFactorUserUid); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm second factors added to user. - expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(2); - // Confirm first enrolled second factor. - const firstMultiFactor = userRecord.multiFactor!.enrolledFactors[0]; - expect(firstMultiFactor.uid).not.to.be.undefined; - expect(firstMultiFactor.enrollmentTime).not.to.be.undefined; - expect((firstMultiFactor as PhoneMultiFactorInfo).phoneNumber).to.equal( - enrolledFactors[0].phoneNumber); - expect(firstMultiFactor.displayName).to.equal(enrolledFactors[0].displayName); - expect(firstMultiFactor.factorId).to.equal(enrolledFactors[0].factorId); - // Confirm second enrolled second factor. - const secondMultiFactor = userRecord.multiFactor!.enrolledFactors[1]; - expect(secondMultiFactor.uid).not.to.be.undefined; - expect(secondMultiFactor.enrollmentTime).not.to.be.undefined; - expect((secondMultiFactor as PhoneMultiFactorInfo).phoneNumber).to.equal( - enrolledFactors[1].phoneNumber); - expect(secondMultiFactor.displayName).to.equal(enrolledFactors[1].displayName); - expect(secondMultiFactor.factorId).to.equal(enrolledFactors[1].factorId); - }); - }); - - it('createUser() fails when the UID is already in use', () => { - const newUserData: any = clone(mockUserData); - newUserData.uid = newUserUid; - return getAuth().createUser(newUserData) - .should.eventually.be.rejected.and.have.property('code', 'auth/uid-already-exists'); - }); - - it('getUser() returns a user record with the matching UID', () => { - return getAuth().getUser(newUserUid) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByEmail() returns a user record with the matching email', () => { - return getAuth().getUserByEmail(mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByPhoneNumber() returns a user record with the matching phone number', () => { - return getAuth().getUserByPhoneNumber(mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - describe('getUsers()', () => { - /** - * Filters a list of object to another list of objects that only contains - * the uid, email, and phoneNumber fields. Works with at least UserRecord - * and UserImportRecord instances. - */ - function mapUserRecordsToUidEmailPhones( - values: Array<{ uid: string; email?: string; phoneNumber?: string }> - ): Array<{ uid: string; email?: string; phoneNumber?: string }> { - return values.map((ur) => ({ uid: ur.uid, email: ur.email, phoneNumber: ur.phoneNumber })); - } - - const testUser1 = { uid: 'uid1', email: 'user1@example.com', phoneNumber: '+15555550001' }; - const testUser2 = { uid: 'uid2', email: 'user2@example.com', phoneNumber: '+15555550002' }; - const testUser3 = { uid: 'uid3', email: 'user3@example.com', phoneNumber: '+15555550003' }; - const usersToCreate = [testUser1, testUser2, testUser3]; - - // Also create a user with a provider config. (You can't create a user with - // a provider config. But you *can* import one.) - const importUser1: UserImportRecord = { - uid: 'uid4', - email: 'user4@example.com', - phoneNumber: '+15555550004', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Four', - email: 'user4@example.com', - phoneNumber: '+15555550004', - photoURL: 'http://example.com/user4', - providerId: 'google.com', - uid: 'google_uid4', - }], - }; - - const testUser4 = mapUserRecordsToUidEmailPhones([importUser1])[0]; - - before(async () => { - // Delete all the users that we're about to create (in case they were - // left over from a prior run). - const uidsToDelete = usersToCreate.map((user) => user.uid); - uidsToDelete.push(importUser1.uid); - await deleteUsersWithDelay(uidsToDelete); - - // Create/import users required by these tests - await Promise.all(usersToCreate.map((user) => getAuth().createUser(user))); - await getAuth().importUsers([importUser1]); - }); - - after(async () => { - const uidsToDelete = usersToCreate.map((user) => user.uid); - uidsToDelete.push(importUser1.uid); - await deleteUsersWithDelay(uidsToDelete); - }); - - it('returns users by various identifier types in a single call', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { email: 'user2@example.com' }, - { phoneNumber: '+15555550003' }, - { providerId: 'google.com', providerUid: 'google_uid4' }, - ]) - .then((getUsersResult) => getUsersResult.users) - .then(mapUserRecordsToUidEmailPhones); - - expect(users).to.have.deep.members([testUser1, testUser2, testUser3, testUser4]); - }); - - it('returns found users and ignores non-existing users', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { uid: 'uid_that_doesnt_exist' }, - { uid: 'uid3' }, - ]); - expect(users.notFound).to.have.deep.members([{ uid: 'uid_that_doesnt_exist' }]); - - const foundUsers = mapUserRecordsToUidEmailPhones(users.users); - expect(foundUsers).to.have.deep.members([testUser1, testUser3]); - }); - - it('returns nothing when queried for only non-existing users', async () => { - const notFoundIds = [{ uid: 'non-existing user' }]; - const users = await getAuth().getUsers(notFoundIds); - - expect(users.users).to.be.empty; - expect(users.notFound).to.deep.equal(notFoundIds); - }); - - it('de-dups duplicate users', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { uid: 'uid1' }, - ]) - .then((getUsersResult) => getUsersResult.users) - .then(mapUserRecordsToUidEmailPhones); - - expect(users).to.deep.equal([testUser1]); - }); - - it('returns users with a lastRefreshTime', async () => { - const isUTCString = (s: string): boolean => { - return new Date(s).toUTCString() === s; - }; - - const newUserRecord = await getAuth().createUser({ - uid: 'lastRefreshTimeUser', - email: 'lastRefreshTimeUser@example.com', - password: 'p4ssword', - }); - - try { - // New users should not have a lastRefreshTime set. - expect(newUserRecord.metadata.lastRefreshTime).to.be.null; - - // Login to set the lastRefreshTime. - await firebase.auth!().signInWithEmailAndPassword('lastRefreshTimeUser@example.com', 'p4ssword') - .then(async () => { - // Attempt to retrieve the user 3 times (with a small delay between - // each attempt). Occassionally, this call retrieves the user data - // without the lastLoginTime/lastRefreshTime set; possibly because - // it's hitting a different server than the login request uses. - let userRecord: UserRecord | null = null; - - for (let i = 0; i < 3; i++) { - userRecord = await getAuth().getUser('lastRefreshTimeUser'); - if (userRecord!['metadata']['lastRefreshTime']) { - break; - } - - await new Promise((resolve) => { - setTimeout(resolve, 1000 * Math.pow(2, i)); - }); - } - - const metadata = userRecord!['metadata']; - expect(metadata['lastRefreshTime']).to.exist; - expect(isUTCString(metadata['lastRefreshTime']!)).to.be.true; - const creationTime = new Date(metadata['creationTime']).getTime(); - const lastRefreshTime = new Date(metadata['lastRefreshTime']!).getTime(); - expect(creationTime).lte(lastRefreshTime); - expect(lastRefreshTime).lte(creationTime + 3600 * 1000); - }); - } finally { - getAuth().deleteUser('lastRefreshTimeUser'); - } - }); - }); - - it('listUsers() returns up to the specified number of users', () => { - const promises: Array> = []; - uids.forEach((uid) => { - const tempUserData = { - uid, - password: 'password', - }; - promises.push(getAuth().createUser(tempUserData)); - }); - return Promise.all(promises) - .then(() => { - // Return 2 users with the provided page token. - // This test will fail if other users are created in between. - return getAuth().listUsers(2, uids[0]); - }) - .then((listUsersResult) => { - // Confirm expected number of users. - expect(listUsersResult.users.length).to.equal(2); - // Confirm next page token present. - expect(typeof listUsersResult.pageToken).to.equal('string'); - // Confirm each user's uid and the hashed passwords. - expect(listUsersResult.users[0].uid).to.equal(uids[1]); - - expect( - listUsersResult.users[0].passwordHash, - 'Missing passwordHash field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', - ).to.be.ok; - expect(listUsersResult.users[0].passwordHash!.length).greaterThan(0); - - expect( - listUsersResult.users[0].passwordSalt, - 'Missing passwordSalt field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', - ).to.be.ok; - expect(listUsersResult.users[0].passwordSalt!.length).greaterThan(0); - - expect(listUsersResult.users[1].uid).to.equal(uids[2]); - expect(listUsersResult.users[1].passwordHash!.length).greaterThan(0); - expect(listUsersResult.users[1].passwordSalt!.length).greaterThan(0); - }); - }); - - it('revokeRefreshTokens() invalidates existing sessions and ID tokens', async () => { - let currentIdToken: string; - let currentUser: User; - // Sign in with an email and password account. - return clientAuth().signInWithEmailAndPassword(mockUserData.email, mockUserData.password) - .then(({ user }) => { - expect(user).to.exist; - currentUser = user!; - // Get user's ID token. - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - // Verify that user's ID token while checking for revocation. - return getAuth().verifyIdToken(currentIdToken, true); - }) - .then((decodedIdToken) => { - // Verification should succeed. Revoke that user's session. - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(decodedIdToken.sub), - ), 1000)); - }) - .then(() => { - const verifyingIdToken = getAuth().verifyIdToken(currentIdToken) - if (authEmulatorHost) { - // Check revocation is forced in emulator-mode and this should throw. - return verifyingIdToken.should.eventually.be.rejected; - } else { - // verifyIdToken without checking revocation should still succeed. - return verifyingIdToken.should.eventually.be.fulfilled; - } - }) - .then(() => { - // verifyIdToken while checking for revocation should fail. - return getAuth().verifyIdToken(currentIdToken, true) - .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-revoked'); - }) - .then(() => { - // Confirm token revoked on client. - return currentUser.reload() - .should.eventually.be.rejected.and.have.property('code', 'auth/user-token-expired'); - }) - .then(() => { - // New sign-in should succeed. - return clientAuth().signInWithEmailAndPassword( - mockUserData.email, mockUserData.password); - }) - .then(({ user }) => { - // Get new session's ID token. - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // ID token for new session should be valid even with revocation check. - return getAuth().verifyIdToken(idToken, true) - .should.eventually.be.fulfilled; - }); - }); - - it('setCustomUserClaims() sets claims that are accessible via user\'s ID token', () => { - // Set custom claims on the user. - return getAuth().setCustomUserClaims(newUserUid, customClaims) - .then(() => { - return getAuth().getUser(newUserUid); - }) - .then((userRecord) => { - // Confirm custom claims set on the UserRecord. - expect(userRecord.customClaims).to.deep.equal(customClaims); - expect(userRecord.email).to.exist; - return clientAuth().signInWithEmailAndPassword( - userRecord.email!, mockUserData.password); - }) - .then(({ user }) => { - // Get the user's ID token. - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // Verify ID token contents. - return getAuth().verifyIdToken(idToken); - }) - .then((decodedIdToken: { [key: string]: any }) => { - // Confirm expected claims set on the user's ID token. - for (const key in customClaims) { - if (Object.prototype.hasOwnProperty.call(customClaims, key)) { - expect(decodedIdToken[key]).to.equal(customClaims[key]); - } - } - // Test clearing of custom claims. - return getAuth().setCustomUserClaims(newUserUid, null); - }) - .then(() => { - return getAuth().getUser(newUserUid); - }) - .then((userRecord) => { - // Custom claims should be cleared. - expect(userRecord.customClaims).to.deep.equal({}); - // Force token refresh. All claims should be cleared. - expect(clientAuth().currentUser).to.exist; - return clientAuth().currentUser!.getIdToken(true); - }) - .then((idToken) => { - // Verify ID token contents. - return getAuth().verifyIdToken(idToken); - }) - .then((decodedIdToken: { [key: string]: any }) => { - // Confirm all custom claims are cleared. - for (const key in customClaims) { - if (Object.prototype.hasOwnProperty.call(customClaims, key)) { - expect(decodedIdToken[key]).to.be.undefined; - } - } - }); - }); - - describe('updateUser()', () => { - /** - * Creates a new user for testing purposes. The user's uid will be - * '$name_$tenRandomChars' and email will be - * '$name_$tenRandomChars@example.com'. - */ - // TODO(rsgowman): This function could usefully be employed throughout this file. - function createTestUser(name: string): Promise { - const tenRandomChars = generateRandomString(10); - return getAuth().createUser({ - uid: name + '_' + tenRandomChars, - displayName: name, - email: name + '_' + tenRandomChars + '@example.com', - }); - } - - let updateUser: UserRecord; - before(async () => { - updateUser = await createTestUser('UpdateUser'); - }); - - after(() => { - return safeDelete(updateUser.uid); - }); - - it('updates the user record with the given parameters', () => { - const updatedDisplayName = 'Updated User ' + updateUser.uid; - return getAuth().updateUser(updateUser.uid, { - email: updatedEmail, - phoneNumber: updatedPhone, - emailVerified: true, - displayName: updatedDisplayName, - }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - expect(userRecord.displayName).to.equal(updatedDisplayName); - // Confirm expected email. - expect(userRecord.email).to.equal(updatedEmail); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(updatedPhone); - }); - }); - - it('creates, updates, and removes second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - - const now = new Date(1476235905000).toUTCString(); - // Update user with enrolled second factors. - const enrolledFactors = [ - { - uid: 'mfaUid1', - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - enrollmentTime: now, - }, - { - uid: 'mfaUid2', - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - enrollmentTime: now, - }, - ]; - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors, - }, - }) - .then((userRecord) => { - // Confirm second factors added to user. - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); - expect(actualUserRecord.multiFactor.enrolledFactors).to.deep.equal(enrolledFactors); - // Update list of second factors. - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors: [enrolledFactors[0]], - }, - }); - }) - .then((userRecord) => { - expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(1); - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]); - // Remove all second factors. - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors: null, - }, - }); - }) - .then((userRecord) => { - // Confirm all second factors removed. - expect(userRecord.multiFactor).to.be.undefined; - }); - }); - - it('can link/unlink with a federated provider', async function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - const googleFederatedUid = 'google_uid_' + generateRandomString(10); - let userRecord = await getAuth().updateUser(updateUser.uid, { - providerToLink: { - providerId: 'google.com', - uid: googleFederatedUid, - }, - }); - - let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.deep.include(googleFederatedUid); - expect(providerIds).to.deep.include('google.com'); - - userRecord = await getAuth().updateUser(updateUser.uid, { - providersToUnlink: ['google.com'], - }); - - providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.not.deep.include(googleFederatedUid); - expect(providerIds).to.not.deep.include('google.com'); - }); - - it('can unlink multiple providers at once, incl a non-federated provider', async function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - await deletePhoneNumberUser('+15555550001'); - - const googleFederatedUid = 'google_uid_' + generateRandomString(10); - const facebookFederatedUid = 'facebook_uid_' + generateRandomString(10); - - let userRecord = await getAuth().updateUser(updateUser.uid, { - phoneNumber: '+15555550001', - providerToLink: { - providerId: 'google.com', - uid: googleFederatedUid, - }, - }); - userRecord = await getAuth().updateUser(updateUser.uid, { - providerToLink: { - providerId: 'facebook.com', - uid: facebookFederatedUid, - }, - }); - - let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); - expect(providerIds).to.deep.include.members(['google.com', 'facebook.com', 'phone']); - - userRecord = await getAuth().updateUser(updateUser.uid, { - providersToUnlink: ['google.com', 'facebook.com', 'phone'], - }); - - providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.not.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); - expect(providerIds).to.not.deep.include.members(['google.com', 'facebook.com', 'phone']); - }); - - it('noops successfully when given an empty providersToUnlink list', async () => { - const userRecord = await createTestUser('NoopWithEmptyProvidersToDeleteUser'); - try { - const updatedUserRecord = await getAuth().updateUser(userRecord.uid, { - providersToUnlink: [], - }); - - expect(updatedUserRecord).to.deep.equal(userRecord); - } finally { - safeDelete(userRecord.uid); - } - }); - - it('A user with user record disabled is unable to sign in', async () => { - const password = 'password'; - const email = 'updatedEmail@example.com'; - return getAuth().updateUser(updateUser.uid, { disabled: true, password, email }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, password); - }) - .then(() => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.have.property('code', 'auth/user-disabled'); - }); - }); - }); - - it('getUser() fails when called with a non-existing UID', () => { - return getAuth().getUser(nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByEmail() fails when called with a non-existing email', () => { - return getAuth().getUserByEmail(nonexistentUid + '@example.com') - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByPhoneNumber() fails when called with a non-existing phone number', () => { - return getAuth().getUserByPhoneNumber(nonexistentPhoneNumber) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('updateUser() fails when called with a non-existing UID', () => { - return getAuth().updateUser(nonexistentUid, { - emailVerified: true, - }).should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('deleteUser() fails when called with a non-existing UID', () => { - return getAuth().deleteUser(nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('createCustomToken() mints a JWT that can be used to sign in', () => { - return getAuth().createCustomToken(newUserUid, { - isAdmin: true, - }) - .then((customToken) => { - return clientAuth().signInWithCustomToken(customToken); - }) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth().verifyIdToken(idToken); - }) - .then((token) => { - expect(token.uid).to.equal(newUserUid); - expect(token.isAdmin).to.be.true; - }); - }); - - it('createCustomToken() can mint JWTs without a service account', () => { - return getAuth(noServiceAccountApp).createCustomToken(newUserUid, { - isAdmin: true, - }) - .then((customToken) => { - return clientAuth().signInWithCustomToken(customToken); - }) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth(noServiceAccountApp).verifyIdToken(idToken); - }) - .then((token) => { - expect(token.uid).to.equal(newUserUid); - expect(token.isAdmin).to.be.true; - }); - }); - - it('verifyIdToken() fails when called with an invalid token', () => { - return getAuth().verifyIdToken('invalid-token') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - if (authEmulatorHost) { - describe('Auth emulator support', () => { - const uid = 'authEmulatorUser'; - before(() => { - return getAuth().createUser({ - uid, - email: 'lastRefreshTimeUser@example.com', - password: 'p4ssword', - }); - }); - after(() => { - return getAuth().deleteUser(uid); - }); - - it('verifyIdToken() succeeds when called with an unsigned token', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: uid, - }, undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken); - }); - - it('verifyIdToken() fails when called with a token with wrong project', () => { - const unsignedToken = mocks.generateIdToken( - { algorithm: 'none', audience: 'nosuch' }, - undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('verifyIdToken() fails when called with a token that does not belong to a user', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: 'nosuch', - }, undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - }); - } - - describe('Link operations', () => { - const uid = generateRandomString(20).toLowerCase(); - const email = uid + '@example.com'; - const newEmail = uid + 'new@example.com'; - const newPassword = 'newPassword'; - const userData = { - uid, - email, - emailVerified: false, - password: 'password', - }; - - // Create the test user before running this suite of tests. - before(() => { - return getAuth().createUser(userData); - }); - - // Sign out after each test. - afterEach(() => { - return clientAuth().signOut(); - }); - - // Delete test user at the end of test suite. - after(() => { - return safeDelete(uid); - }); - - it('generatePasswordResetLink() should return a password reset link', () => { - // Ensure old password set on created user. - return getAuth().updateUser(uid, { password: 'password' }) - .then(() => { - return getAuth().generatePasswordResetLink(email, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().confirmPasswordReset(code, newPassword); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, newPassword); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - // Password reset also verifies the user's email. - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateEmailVerificationLink() should return a verification link', () => { - // Ensure the user's email is unverified. - return getAuth().updateUser(uid, { password: 'password', emailVerified: false }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.false; - return getAuth().generateEmailVerificationLink(email, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().applyActionCode(code); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, userData.password); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateSignInWithEmailLink() should return a sign-in link', () => { - return getAuth().generateSignInWithEmailLink(email, actionCodeSettings) - .then((link) => { - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().signInWithEmailLink(email, link); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateVerifyAndChangeEmailLink() should return a verification link', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - // Ensure the user's email is verified. - return getAuth().updateUser(uid, { password: 'password', emailVerified: true }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - return getAuth().generateVerifyAndChangeEmailLink(email, newEmail, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().applyActionCode(code); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(newEmail, 'password'); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(newEmail); - expect(result.user!.emailVerified).to.be.true; - }); - }); - }); - - describe('Project config management operations', () => { - before(function () { - if (authEmulatorHost) { - this.skip(); // getConfig is not supported in Auth Emulator - } - }); - - after(() => { - getAuth().projectConfigManager().updateProjectConfig({ - passwordPolicyConfig: { - enforcementState: 'OFF', - forceUpgradeOnSignin: false, - constraints: { - requireLowercase: false, - requireNonAlphanumeric: false, - requireNumeric: false, - requireUppercase: false, - maxLength: 4096, - minLength: 6, - } - } - }) - }); - - const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }; - const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - } - ], - }; - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'ENFORCE', - forceUpgradeOnSignin: true, - constraints: { - requireUppercase: true, - requireLowercase: true, - requireNonAlphanumeric: true, - requireNumeric: true, - minLength: 8, - maxLength: 30, - }, - }; - const smsRegionAllowByDefaultConfig: SmsRegionConfig = { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - }; - const smsRegionAllowlistOnlyConfig: SmsRegionConfig = { - allowlistOnly: { - allowedRegions: ['AC', 'AD'], - } - }; - const projectConfigOption1: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowByDefaultConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const projectConfigOption2: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - useAccountDefender: false, - }, - }; - const projectConfigOptionSmsEnabledTotpDisabled: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - }; - const expectedProjectConfig1: any = { - smsRegionConfig: smsRegionAllowByDefaultConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const expectedProjectConfig2: any = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - }, - }; - const expectedProjectConfigSmsEnabledTotpDisabled: any = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - }, - }; - - it('updateProjectConfig() should resolve with the updated project config', () => { - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) - .then((actualProjectConfig) => { - // ReCAPTCHA keys are generated differently each time. - delete actualProjectConfig.recaptchaConfig?.recaptchaKeys; - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfig1); - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); - }) - .then((actualProjectConfig) => { - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfig2); - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOptionSmsEnabledTotpDisabled); - }) - .then((actualProjectConfig) => { - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); - }); - }); - - it('getProjectConfig() should resolve with expected project config', () => { - return getAuth().projectConfigManager().getProjectConfig() - .then((actualConfig) => { - const actualConfigObj = actualConfig._toJson(); - expect(actualConfigObj).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); - }); - }); - }); - - describe('Tenant management operations', () => { - let createdTenantId: string; - const createdTenants: string[] = []; - const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }; - const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - } - ], - } - const mfaSmsDisabledTotpEnabledConfig: MultiFactorConfig = { - state: 'DISABLED', - factorIds: [], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: {}, - } - ], - } - const smsRegionAllowByDefaultConfig: SmsRegionConfig = { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - } - const tenantOptions: CreateTenantRequest = { - displayName: 'testTenant1', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - // Add random phone number / code pairs. - testPhoneNumbers: { - '+16505551234': '019287', - '+16505550676': '985235', - }, - }; - const expectedCreatedTenant: any = { - displayName: 'testTenant1', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - // These test phone numbers will not be checked when running integration - // tests against the emulator suite and are ignored in auth emulator - // altogether. For more information, please refer to this section of the - // auth emulator DD: go/firebase-auth-emulator-dd#heading=h.odk06so2ydjd - testPhoneNumbers: { - '+16505551234': '019287', - '+16505550676': '985235', - }, - }; - const expectedUpdatedTenant: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: false, - passwordRequired: true, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsDisabledTotpEnabledConfig, - // Test phone numbers will not be checked when running integration tests - // against emulator suite. For more information, please refer to: - // go/firebase-auth-emulator-dd#heading=h.odk06so2ydjd - testPhoneNumbers: { - '+16505551234': '123456', - }, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const expectedUpdatedTenant2: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - smsRegionConfig: smsRegionAllowByDefaultConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: false, - }, - }; - const expectedUpdatedTenantSmsEnabledTotpDisabled: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - smsRegionConfig: smsRegionAllowByDefaultConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: false, - }, - }; - - // https://mochajs.org/ - // Passing arrow functions (aka "lambdas") to Mocha is discouraged. - // Lambdas lexically bind this and cannot access the Mocha context. - before(function () { - /* tslint:disable:no-console */ - if (!cmdArgs.testMultiTenancy) { - // To enable, run: npm run test:integration -- --testMultiTenancy - // By default we skip multi-tenancy as it is a Google Cloud Identity Platform - // feature only and requires to be enabled via the Cloud Console. - console.log(chalk.yellow(' Skipping multi-tenancy tests.')); - this.skip(); - } - /* tslint:enable:no-console */ - }); - - // Delete test tenants at the end of test suite. - after(() => { - const promises: Array> = []; - createdTenants.forEach((tenantId) => { - promises.push( - getAuth().tenantManager().deleteTenant(tenantId) - .catch(() => {/** Ignore. */ })); - }); - return Promise.all(promises); - }); - - it('createTenant() should resolve with a new tenant', () => { - return getAuth().tenantManager().createTenant(tenantOptions) - .then((actualTenant) => { - createdTenantId = actualTenant.tenantId; - createdTenants.push(createdTenantId); - expectedCreatedTenant.tenantId = createdTenantId; - const actualTenantObj = actualTenant._toJson(); - if (authEmulatorHost) { - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedCreatedTenant.testPhoneNumbers; - } - expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); - }); - }); - - it('createTenant() can enable anonymous users', async () => { - const tenant = await getAuth().tenantManager().createTenant({ - displayName: 'testTenantWithAnon', - emailSignInConfig: { - enabled: false, - passwordRequired: true, - }, - anonymousSignInEnabled: true, - }); - createdTenants.push(tenant.tenantId); - - expect(tenant.anonymousSignInEnabled).to.be.true; - }); - - // Sanity check user management + email link generation + custom attribute APIs. - // TODO: Confirm behavior in client SDK when it starts supporting it. - describe('supports user management, email link generation, custom attribute and token revocation APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - let createdUserUid: string; - let lastValidSinceTime: number; - const newUserData = clone(mockUserData); - newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; - newUserData.phoneNumber = testPhoneNumber; - const importOptions: any = { - hash: { - algorithm: 'HMAC_SHA256', - key: Buffer.from('secret'), - }, - }; - const rawPassword = 'password'; - const rawSalt = 'NaCl'; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete test user at the end of test suite. - after(() => { - // If user successfully created, make sure it is deleted at the end of the test suite. - if (createdUserUid) { - return tenantAwareAuth.deleteUser(createdUserUid) - .catch(() => { - // Ignore error. - }); - } - }); - - it('createUser() should create a user in the expected tenant', () => { - return tenantAwareAuth.createUser(newUserData) - .then((userRecord) => { - createdUserUid = userRecord.uid; - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.email).to.equal(newUserData.email); - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('setCustomUserClaims() should set custom attributes on the tenant specific user', () => { - return tenantAwareAuth.setCustomUserClaims(createdUserUid, customClaims) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - expect(userRecord.uid).to.equal(createdUserUid); - expect(userRecord.tenantId).to.equal(createdTenantId); - // Confirm custom claims set on the UserRecord. - expect(userRecord.customClaims).to.deep.equal(customClaims); - }); - }); - - it('updateUser() should update the tenant specific user', () => { - return tenantAwareAuth.updateUser(createdUserUid, { - email: updatedEmail, - phoneNumber: updatedPhone, - }) - .then((userRecord) => { - expect(userRecord.uid).to.equal(createdUserUid); - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.email).to.equal(updatedEmail); - expect(userRecord.phoneNumber).to.equal(updatedPhone); - }); - }); - - it('generateEmailVerificationLink() should generate the link for tenant specific user', () => { - // Generate email verification link to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generateEmailVerificationLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('generatePasswordResetLink() should generate the link for tenant specific user', () => { - // Generate password reset link to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generatePasswordResetLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('generateSignInWithEmailLink() should generate the link for tenant specific user', () => { - // Generate link for sign-in to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generateSignInWithEmailLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('revokeRefreshTokens() should revoke the tokens for the tenant specific user', () => { - // Revoke refresh tokens. - // On revocation, tokensValidAfterTime will be updated to current time. All tokens issued - // before that time will be rejected. As the underlying backend field is rounded to the nearest - // second, we are subtracting one second. - lastValidSinceTime = new Date().getTime() - 1000; - return tenantAwareAuth.revokeRefreshTokens(createdUserUid) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - expect(userRecord.tokensValidAfterTime).to.exist; - expect(new Date(userRecord.tokensValidAfterTime!).getTime()) - .to.be.greaterThan(lastValidSinceTime); - }); - }); - - it('listUsers() should list tenant specific users', () => { - return tenantAwareAuth.listUsers(100) - .then((listUsersResult) => { - // Confirm expected user returned in the list and all users returned - // belong to the expected tenant. - const allUsersBelongToTenant = - listUsersResult.users.every((user) => user.tenantId === createdTenantId); - expect(allUsersBelongToTenant).to.be.true; - const knownUserInTenant = - listUsersResult.users.some((user) => user.uid === createdUserUid); - expect(knownUserInTenant).to.be.true; - }); - }); - - it('deleteUser() should delete the tenant specific user', () => { - return tenantAwareAuth.deleteUser(createdUserUid) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - }); - - it('importUsers() should upload a user to the specified tenant', () => { - const currentHashKey = importOptions.hash.key.toString('utf8'); - const passwordHash = - crypto.createHmac('sha256', currentHashKey).update(rawPassword + rawSalt).digest(); - const importUserRecord: any = { - uid: createdUserUid, - email: createdUserUid + '@example.com', - passwordHash, - passwordSalt: Buffer.from(rawSalt), - }; - return tenantAwareAuth.importUsers([importUserRecord], importOptions) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - // Confirm user uploaded successfully. - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.uid).to.equal(createdUserUid); - }); - }); - - it('createCustomToken() mints a JWT that can be used to sign in tenant users', async () => { - try { - clientAuth().tenantId = createdTenantId; - - const customToken = await tenantAwareAuth.createCustomToken('uid1'); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.not.be.null; - const idToken = await user!.getIdToken(); - const token = await tenantAwareAuth.verifyIdToken(idToken); - - expect(token.uid).to.equal('uid1'); - expect(token.firebase.tenant).to.equal(createdTenantId); - } finally { - clientAuth().tenantId = null; - } - }); - }); - - // Sanity check OIDC/SAML config management API. - describe('SAML management APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - const authProviderConfig = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME1', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID1', - ssoURL: 'https://example.com/login1', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID1', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME3', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID3', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete SAML configuration at the end of test suite. - after(() => { - if (tenantAwareAuth) { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch(() => { - // Ignore error. - }); - } - }); - - it('should support CRUD operations', function () { - // TODO(lisajian): Unskip once auth emulator supports OIDC/SAML - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - return tenantAwareAuth.createProviderConfig(authProviderConfig) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); - }) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.updateProviderConfig( - authProviderConfig.providerId, modifiedConfigOptions); - }) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); - }) - .then(() => { - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - describe('OIDC management APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - const authProviderConfig = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME1', - enabled: true, - issuer: 'https://oidc.com/issuer1', - clientId: 'CLIENT_ID1', - responseType: { - idToken: true, - }, - }; - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: true, - }, - }; - const modifiedConfigOptions = { - providerId: authProviderConfig.providerId, - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete OIDC configuration at the end of test suite. - after(() => { - if (tenantAwareAuth) { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch(() => { - // Ignore error. - }); - } - }); - - it('should support CRUD operations', function () { - // TODO(lisajian): Unskip once auth emulator supports OIDC/SAML - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - return tenantAwareAuth.createProviderConfig(authProviderConfig) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); - }) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.updateProviderConfig( - authProviderConfig.providerId, deltaChanges); - }) - .then((config) => { - assertDeepEqualUnordered(modifiedConfigOptions, config); - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); - }) - .then(() => { - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - it('getTenant() should resolve with expected tenant', () => { - return getAuth().tenantManager().getTenant(createdTenantId) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - if (authEmulatorHost) { - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedCreatedTenant.testPhoneNumbers; - } - expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); - }); - }); - - it('updateTenant() should resolve with the updated tenant', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - expectedUpdatedTenant2.tenantId = createdTenantId; - const updatedOptions: UpdateTenantRequest = { - displayName: expectedUpdatedTenant.displayName, - emailSignInConfig: { - enabled: false, - }, - multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), - testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), - recaptchaConfig: deepCopy(expectedUpdatedTenant.recaptchaConfig), - }; - const updatedOptions2: UpdateTenantRequest = { - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), - // Test clearing of phone numbers. - testPhoneNumbers: null, - smsRegionConfig: deepCopy(expectedUpdatedTenant2.smsRegionConfig), - recaptchaConfig: deepCopy(expectedUpdatedTenant2.recaptchaConfig), - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant); - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2); - }) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) - .then((actualTenant) => { - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenant); - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2); - }) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should not update tenant when SMS region config is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updatedOptions2: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - smsRegionConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should not update MFA-related config of tenant when MultiFactorConfig is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updateRequestNoMfaConfig: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - multiFactorConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Configuring test phone numbers are not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) - }); - - it('updateTenant() should not update tenant reCAPTCHA config is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updatedOptions2: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - recaptchaConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => { - expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId; - const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {} - }, - ], - }, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Configuring test phone numbers are not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenantSmsEnabledTotpDisabled.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenantSmsEnabledTotpDisabled); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenantSmsEnabledTotpDisabled); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should be able to enable/disable anon provider', async () => { - const tenantManager = getAuth().tenantManager(); - let tenant = await tenantManager.createTenant({ - displayName: 'testTenantUpdateAnon', - }); - createdTenants.push(tenant.tenantId); - expect(tenant.anonymousSignInEnabled).to.be.false; - - tenant = await tenantManager.updateTenant(tenant.tenantId, { - anonymousSignInEnabled: true, - }); - expect(tenant.anonymousSignInEnabled).to.be.true; - - tenant = await tenantManager.updateTenant(tenant.tenantId, { - anonymousSignInEnabled: false, - }); - expect(tenant.anonymousSignInEnabled).to.be.false; - }); - - it('updateTenant() should enforce password policies on tenant', () => { - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'ENFORCE', - forceUpgradeOnSignin: true, - constraints: { - requireLowercase: true, - requireNonAlphanumeric: true, - requireNumeric: true, - requireUppercase: true, - minLength: 6, - maxLength: 30, - }, - }; - return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) - .then((actualTenant) => { - expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(passwordConfig as any); - }); - }); - - it('updateTenant() should disable password policies on tenant', () => { - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'OFF', - }; - const expectedPasswordConfig: any = { - enforcementState: 'OFF', - forceUpgradeOnSignin: false, - constraints: { - requireLowercase: false, - requireNonAlphanumeric: false, - requireNumeric: false, - requireUppercase: false, - minLength: 6, - maxLength: 4096, - }, - }; - return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) - .then((actualTenant) => { - expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(expectedPasswordConfig); - }); - }); - - it('listTenants() should resolve with expected number of tenants', () => { - const allTenantIds: string[] = []; - const tenantOptions2 = deepCopy(tenantOptions); - tenantOptions2.displayName = 'testTenant2'; - const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { - return getAuth().tenantManager().listTenants(100, nextPageToken) - .then((result) => { - result.tenants.forEach((tenant) => { - tenantIds.push(tenant.tenantId); - }); - if (result.pageToken) { - return listAllTenantIds(tenantIds, result.pageToken); - } - }); - }; - return getAuth().tenantManager().createTenant(tenantOptions2) - .then((actualTenant) => { - createdTenants.push(actualTenant.tenantId); - // Test listTenants returns the expected tenants. - return listAllTenantIds(allTenantIds); - }) - .then(() => { - // All created tenants should be in the list of tenants. - createdTenants.forEach((tenantId) => { - expect(allTenantIds).to.contain(tenantId); - }); - }); - }); - - it('deleteTenant() should successfully delete the provided tenant', () => { - const allTenantIds: string[] = []; - const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { - return getAuth().tenantManager().listTenants(100, nextPageToken) - .then((result) => { - result.tenants.forEach((tenant) => { - tenantIds.push(tenant.tenantId); - }); - if (result.pageToken) { - return listAllTenantIds(tenantIds, result.pageToken); - } - }); - }; - - return getAuth().tenantManager().deleteTenant(createdTenantId) - .then(() => { - // Use listTenants() instead of getTenant() to check that the tenant - // is no longer present, because Auth Emulator implicitly creates the - // tenant in getTenant() when it is not found - return listAllTenantIds(allTenantIds); - }) - .then(() => { - expect(allTenantIds).to.not.contain(createdTenantId); - }); - }); - }); - - describe('SAML configuration operations', () => { - const authProviderConfig1 = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME1', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID1', - ssoURL: 'https://example.com/login1', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID1', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const authProviderConfig2 = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME2', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID2', - ssoURL: 'https://example.com/login2', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID2', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - - const removeTempConfigs = (): Promise => { - return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), - ]); - }; - - // Clean up temp configurations used for test. - before(function () { - if (authEmulatorHost) { - return this.skip(); // Not implemented. - } - return removeTempConfigs().then(() => getAuth().createProviderConfig(authProviderConfig1)); - }); - - after(() => { - return removeTempConfigs(); - }); - - it('createProviderConfig() successfully creates a SAML config', () => { - return getAuth().createProviderConfig(authProviderConfig2) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig2, config); - }); - }); - - it('getProviderConfig() successfully returns the expected SAML config', () => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig1, config); - }); - }); - - it('listProviderConfig() successfully returns the list of SAML providers', () => { - const configs: AuthProviderConfig[] = []; - const listProviders: any = (type: 'saml' | 'oidc', maxResults?: number, pageToken?: string) => { - return getAuth().listProviderConfigs({ type, maxResults, pageToken }) - .then((result) => { - result.providerConfigs.forEach((config: AuthProviderConfig) => { - configs.push(config); - }); - if (result.pageToken) { - return listProviders(type, maxResults, result.pageToken); - } - }); - }; - // In case the project already has existing providers, list all configurations and then - // check the 2 test configs are available. - return listProviders('saml', 1) - .then(() => { - let index1 = 0; - let index2 = 0; - for (let i = 0; i < configs.length; i++) { - if (configs[i].providerId === authProviderConfig1.providerId) { - index1 = i; - } else if (configs[i].providerId === authProviderConfig2.providerId) { - index2 = i; - } - } - assertDeepEqualUnordered(authProviderConfig1, configs[index1]); - assertDeepEqualUnordered(authProviderConfig2, configs[index2]); - }); - }); - - it('updateProviderConfig() successfully overwrites a SAML config', () => { - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME3', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID3', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, modifiedConfigOptions) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig1.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - }); - }); - - it('updateProviderConfig() successfully partially modifies a SAML config', () => { - const deltaChanges = { - displayName: 'SAML_DISPLAY_NAME4', - x509Certificates: [mocks.x509CertPairs[0].public], - // Note, currently backend has a bug where error is thrown when callbackURL is not - // passed event though it is not required. Fix is on the way. - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - rpEntityId: 'RP_ENTITY_ID4', - }; - // Only above fields should be modified. - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME4', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID4', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig1.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - }); - }); - - it('deleteProviderConfig() successfully deletes an existing SAML config', () => { - return getAuth().deleteProviderConfig(authProviderConfig1.providerId).then(() => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - describe('OIDC configuration operations', () => { - const authProviderConfig1 = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME1', - enabled: true, - issuer: 'https://oidc.com/issuer1', - clientId: 'CLIENT_ID1', - responseType: { - idToken: true, - }, - }; - const authProviderConfig2 = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME2', - enabled: true, - issuer: 'https://oidc.com/issuer2', - clientId: 'CLIENT_ID2', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - - const removeTempConfigs = (): Promise => { - return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), - ]); - }; - - // Clean up temp configurations used for test. - before(function () { - if (authEmulatorHost) { - return this.skip(); // Not implemented. - } - return removeTempConfigs().then(() => getAuth().createProviderConfig(authProviderConfig1)); - }); - - after(() => { - return removeTempConfigs(); - }); - - it('createProviderConfig() successfully creates an OIDC config', () => { - return getAuth().createProviderConfig(authProviderConfig2) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig2, config); - }); - }); - - it('getProviderConfig() successfully returns the expected OIDC config', () => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig1, config); - }); - }); - - it('listProviderConfig() successfully returns the list of OIDC providers', () => { - const configs: AuthProviderConfig[] = []; - const listProviders: any = (type: 'saml' | 'oidc', maxResults?: number, pageToken?: string) => { - return getAuth().listProviderConfigs({ type, maxResults, pageToken }) - .then((result) => { - result.providerConfigs.forEach((config: AuthProviderConfig) => { - configs.push(config); - }); - if (result.pageToken) { - return listProviders(type, maxResults, result.pageToken); - } - }); - }; - // In case the project already has existing providers, list all configurations and then - // check the 2 test configs are available. - return listProviders('oidc', 1) - .then(() => { - let index1 = 0; - let index2 = 0; - for (let i = 0; i < configs.length; i++) { - if (configs[i].providerId === authProviderConfig1.providerId) { - index1 = i; - } else if (configs[i].providerId === authProviderConfig2.providerId) { - index2 = i; - } - } - assertDeepEqualUnordered(authProviderConfig1, configs[index1]); - assertDeepEqualUnordered(authProviderConfig2, configs[index2]); - }); - }); - - it('updateProviderConfig() successfully partially modifies an OIDC config', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: true, - }, - }; - // Only above fields should be modified. - const modifiedConfigOptions = { - providerId: authProviderConfig1.providerId, - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges) - .then((config) => { - assertDeepEqualUnordered(modifiedConfigOptions, config); - }); - }); - - it('updateProviderConfig() with invalid oauth response type should be rejected', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME4', - enabled: false, - issuer: 'https://oidc.com/issuer4', - clientId: 'CLIENT_ID4', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: false, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges). - should.eventually.be.rejected.and.have.property('code', 'auth/invalid-oauth-responsetype'); - }); - - it('updateProviderConfig() code flow with no client secret should be rejected', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME5', - enabled: false, - issuer: 'https://oidc.com/issuer5', - clientId: 'CLIENT_ID5', - responseType: { - idToken: false, - code: true, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges). - should.eventually.be.rejected.and.have.property('code', 'auth/missing-oauth-client-secret'); - }); - - it('deleteProviderConfig() successfully deletes an existing OIDC config', () => { - return getAuth().deleteProviderConfig(authProviderConfig1.providerId).then(() => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - it('deleteUser() deletes the user with the given UID', () => { - return Promise.all([ - getAuth().deleteUser(newUserUid), - getAuth().deleteUser(uidFromCreateUserWithoutUid), - ]).should.eventually.be.fulfilled; - }); - - describe('deleteUsers()', () => { - it('deletes users', async () => { - const uid1 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid2 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid3 = await getAuth().createUser({}).then((ur) => ur.uid); - const ids = [{ uid: uid1 }, { uid: uid2 }, { uid: uid3 }]; - - return deleteUsersWithDelay([uid1, uid2, uid3]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(3); - expect(deleteUsersResult.failureCount).to.equal(0); - expect(deleteUsersResult.errors).to.have.length(0); - - return getAuth().getUsers(ids); - }) - .then((getUsersResult) => { - expect(getUsersResult.users).to.have.length(0); - expect(getUsersResult.notFound).to.have.deep.members(ids); - }); - }); - - it('deletes users that exist even when non-existing users also specified', async () => { - const uid1 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid2 = 'uid-that-doesnt-exist'; - const ids = [{ uid: uid1 }, { uid: uid2 }]; - - return deleteUsersWithDelay([uid1, uid2]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(2); - expect(deleteUsersResult.failureCount).to.equal(0); - expect(deleteUsersResult.errors).to.have.length(0); - - return getAuth().getUsers(ids); - }) - .then((getUsersResult) => { - expect(getUsersResult.users).to.have.length(0); - expect(getUsersResult.notFound).to.have.deep.members(ids); - }); - }); - - it('is idempotent', async () => { - const uid = await getAuth().createUser({}).then((ur) => ur.uid); - - return deleteUsersWithDelay([uid]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(1); - expect(deleteUsersResult.failureCount).to.equal(0); - }) - // Delete the user again, ensuring that everything still counts as a success. - .then(() => deleteUsersWithDelay([uid])) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(1); - expect(deleteUsersResult.failureCount).to.equal(0); - }); - }); - }); - - describe('createSessionCookie()', () => { - let expectedExp: number; - let expectedIat: number; - const expiresIn = 24 * 60 * 60 * 1000; - let payloadClaims: any; - let currentIdToken: string; - const uid = sessionCookieUids[0]; - const uid2 = sessionCookieUids[1]; - const uid3 = sessionCookieUids[2]; - const uid4 = sessionCookieUids[3]; - - it('creates a valid Firebase session cookie', () => { - return getAuth().createCustomToken(uid, { admin: true, groupId: '1234' }) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - return getAuth().verifyIdToken(idToken); - }).then((decodedIdTokenClaims) => { - expectedExp = Math.floor((new Date().getTime() + expiresIn) / 1000); - payloadClaims = decodedIdTokenClaims; - payloadClaims.iss = payloadClaims.iss.replace( - 'securetoken.google.com', 'session.firebase.google.com'); - delete payloadClaims.exp; - delete payloadClaims.iat; - expectedIat = Math.floor(new Date().getTime() / 1000); - // One day long session cookie. - return getAuth().createSessionCookie(currentIdToken, { expiresIn }); - }) - .then((sessionCookie) => getAuth().verifySessionCookie(sessionCookie)) - .then((decodedIdToken) => { - // Check for expected expiration with +/-5 seconds of variation. - expect(decodedIdToken.exp).to.be.within(expectedExp - 5, expectedExp + 5); - expect(decodedIdToken.iat).to.be.within(expectedIat - 5, expectedIat + 5); - // Not supported in ID token, - delete decodedIdToken.nonce; - // exp and iat may vary depending on network connection latency. - delete (decodedIdToken as any).exp; - delete (decodedIdToken as any).iat; - expect(decodedIdToken).to.deep.equal(payloadClaims); - }); - }); - - it('creates a revocable session cookie', () => { - let currentSessionCookie: string; - return getAuth().createCustomToken(uid2) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // One day long session cookie. - return getAuth().createSessionCookie(idToken, { expiresIn }); - }) - .then((sessionCookie) => { - currentSessionCookie = sessionCookie; - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(uid2), - ), 1000)); - }) - .then(() => { - const verifyingSessionCookie = getAuth().verifySessionCookie(currentSessionCookie); - if (authEmulatorHost) { - // Check revocation is forced in emulator-mode and this should throw. - return verifyingSessionCookie.should.eventually.be.rejected; - } else { - // verifyIdToken without checking revocation should still succeed. - return verifyingSessionCookie.should.eventually.be.fulfilled; - } - }) - .then(() => { - return getAuth().verifySessionCookie(currentSessionCookie, true) - .should.eventually.be.rejected.and.have.property('code', 'auth/session-cookie-revoked'); - }); - }); - - it('fails when called with a revoked ID token', () => { - return getAuth().createCustomToken(uid3, { admin: true, groupId: '1234' }) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(uid3), - ), 1000)); - }) - .then(() => { - return getAuth().createSessionCookie(currentIdToken, { expiresIn }) - .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-expired'); - }); - }); - - it('fails when called with user disabled', async () => { - const expiresIn = 24 * 60 * 60 * 1000; - const customToken = await getAuth().createCustomToken(uid4, { admin: true, groupId: '1234' }); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.exist; - - const idToken = await user!.getIdToken(); - const decodedIdTokenClaims = await getAuth().verifyIdToken(idToken); - expect(decodedIdTokenClaims.uid).to.be.equal(uid4); - - const sessionCookie = await getAuth().createSessionCookie(idToken, { expiresIn }); - const decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); - expect(decodedIdToken.uid).to.equal(uid4); - - const userRecord = await getAuth().updateUser(uid4, { disabled: true }); - // Ensure disabled field has been updated. - expect(userRecord.uid).to.equal(uid4); - expect(userRecord.disabled).to.equal(true); - - return getAuth().createSessionCookie(idToken, { expiresIn }) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-disabled'); - }); - }); - - describe('verifySessionCookie()', () => { - const uid = sessionCookieUids[0]; - it('fails when called with an invalid session cookie', () => { - return getAuth().verifySessionCookie('invalid-token') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('fails when called with a Firebase ID token', () => { - return getAuth().createCustomToken(uid) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth().verifySessionCookie(idToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - }); - - it('fails with checkRevoked set to true and corresponding user disabled', async () => { - const expiresIn = 24 * 60 * 60 * 1000; - const customToken = await getAuth().createCustomToken(uid, { admin: true, groupId: '1234' }); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.exist; - - const idToken = await user!.getIdToken(); - const decodedIdTokenClaims = await getAuth().verifyIdToken(idToken); - expect(decodedIdTokenClaims.uid).to.be.equal(uid); - - const sessionCookie = await getAuth().createSessionCookie(idToken, { expiresIn }); - let decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); - expect(decodedIdToken.uid).to.equal(uid); - - const userRecord = await getAuth().updateUser(uid, { disabled: true }); - // Ensure disabled field has been updated. - expect(userRecord.uid).to.equal(uid); - expect(userRecord.disabled).to.equal(true); - - try { - // If it is in emulator mode, a user-disabled error will be thrown. - decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, false); - expect(decodedIdToken.uid).to.equal(uid); - } catch (error) { - if (authEmulatorHost) { - expect(error).to.have.property('code', 'auth/user-disabled'); - } else { - throw error; - } - } - - try { - await getAuth().verifySessionCookie(sessionCookie, true); - } catch (error) { - expect(error).to.have.property('code', 'auth/user-disabled'); - } - }); - }); - - describe('importUsers()', () => { - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - let importUserRecord: UserImportRecord; - const rawPassword = 'password'; - const rawSalt = 'NaCl'; - // Simulate a user stored using SCRYPT being migrated to Firebase Auth via importUsers. - // Obtained from https://github.com/firebase/scrypt. - const scryptHashKey = 'jxspr8Ki0RYycVU8zykbdLGjFQ3McFUH0uiiTvC8pVMXAn210wjLNmdZ' + - 'JzxUECKbm0QsEmYUSDzZvpjeJ9WmXA=='; - const scryptPasswordHash = 'V358E8LdWJXAO7muq0CufVpEOXaj8aFiC7T/rcaGieN04q/ZPJ0' + - '8WhJEHGjj9lz/2TT+/86N5VjVoc5DdBhBiw=='; - const scryptHashOptions = { - hash: { - algorithm: 'SCRYPT', - key: Buffer.from(scryptHashKey, 'base64'), - saltSeparator: Buffer.from('Bw==', 'base64'), - rounds: 8, - memoryCost: 14, - }, - }; - - afterEach(() => { - return safeDelete(randomUid); - }); - - const fixtures: UserImportTest[] = [ - { - name: 'HMAC_SHA256', - importOptions: { - hash: { - algorithm: 'HMAC_SHA256', - key: Buffer.from('secret'), - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - expect(userImportTest.importOptions.hash.key).to.exist; - const currentHashKey = userImportTest.importOptions.hash.key!.toString('utf8'); - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return crypto.createHmac('sha256', currentHashKey) - .update(currentRawPassword + currentRawSalt).digest(); - }, - rawPassword, - rawSalt, - }, - { - name: 'SHA256', - importOptions: { - hash: { - algorithm: 'SHA256', - rounds: 1, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return crypto.createHash('sha256').update(currentRawSalt + currentRawPassword).digest(); - }, - rawPassword, - rawSalt, - }, - { - name: 'MD5', - importOptions: { - hash: { - algorithm: 'MD5', - rounds: 0, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return Buffer.from(crypto.createHash('md5') - .update(currentRawSalt + currentRawPassword).digest('hex')); - }, - rawPassword, - rawSalt, - }, - { - name: 'BCRYPT', - importOptions: { - hash: { - algorithm: 'BCRYPT', - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - return Buffer.from(bcrypt.hashSync(userImportTest.rawPassword, 10)); - }, - rawPassword, - }, - { - name: 'STANDARD_SCRYPT', - importOptions: { - hash: { - algorithm: 'STANDARD_SCRYPT', - memoryCost: 1024, - parallelization: 16, - blockSize: 8, - derivedKeyLength: 64, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - - expect(userImportTest.rawSalt).to.exist; - const currentRawSalt = userImportTest.rawSalt!; - - expect(userImportTest.importOptions.hash.memoryCost).to.exist; - const N = userImportTest.importOptions.hash.memoryCost!; - - expect(userImportTest.importOptions.hash.blockSize).to.exist; - const r = userImportTest.importOptions.hash.blockSize!; - - expect(userImportTest.importOptions.hash.parallelization).to.exist; - const p = userImportTest.importOptions.hash.parallelization!; - - expect(userImportTest.importOptions.hash.derivedKeyLength).to.exist; - const dkLen = userImportTest.importOptions.hash.derivedKeyLength!; - - return Buffer.from( - crypto.scryptSync( - currentRawPassword, - Buffer.from(currentRawSalt), - dkLen, - { - N, r, p, - })); - }, - rawPassword, - rawSalt, - }, - { - name: 'PBKDF2_SHA256', - importOptions: { - hash: { - algorithm: 'PBKDF2_SHA256', - rounds: 100000, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - expect(userImportTest.rawSalt).to.exist; - const currentRawSalt = userImportTest.rawSalt!; - expect(userImportTest.importOptions.hash.rounds).to.exist; - const currentRounds = userImportTest.importOptions.hash.rounds!; - return crypto.pbkdf2Sync( - currentRawPassword, currentRawSalt, currentRounds, 64, 'sha256'); - }, - rawPassword, - rawSalt, - }, - { - name: 'SCRYPT', - importOptions: scryptHashOptions as any, - computePasswordHash: (): Buffer => { - return Buffer.from(scryptPasswordHash, 'base64'); - }, - rawPassword, - rawSalt, - }, - ]; - - fixtures.forEach((fixture) => { - it(`successfully imports users with ${fixture.name} to Firebase Auth.`, function () { - if (authEmulatorHost) { - return this.skip(); // Auth Emulator does not support real hashes. - } - importUserRecord = { - uid: randomUid, - email: randomUid + '@example.com', - }; - importUserRecord.passwordHash = fixture.computePasswordHash(fixture); - if (typeof fixture.rawSalt !== 'undefined') { - importUserRecord.passwordSalt = Buffer.from(fixture.rawSalt); - } - return testImportAndSignInUser( - importUserRecord, fixture.importOptions, fixture.rawPassword) - .should.eventually.be.fulfilled; - - }); - }); - - it('successfully imports users with multiple OAuth providers', () => { - const uid = randomUid; - const email = uid + '@example.com'; - const now = new Date(1476235905000).toUTCString(); - const photoURL = 'http://www.example.com/' + uid + '/photo.png'; - importUserRecord = { - uid, - email, - emailVerified: true, - displayName: 'Test User', - photoURL, - phoneNumber: '+15554446666', - disabled: false, - customClaims: { admin: true }, - metadata: { - lastSignInTime: now, - creationTime: now, - // TODO(rsgowman): Enable once importing users supports lastRefreshTime - //lastRefreshTime: now, - }, - providerData: [ - { - uid: uid + '-facebook', - displayName: 'Facebook User', - email, - photoURL: photoURL + '?providerId=facebook.com', - providerId: 'facebook.com', - }, - { - uid: uid + '-twitter', - displayName: 'Twitter User', - photoURL: photoURL + '?providerId=twitter.com', - providerId: 'twitter.com', - }, - ], - }; - uids.push(importUserRecord.uid); - return getAuth().importUsers([importUserRecord]) - .then((result) => { - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - return getAuth().getUser(uid); - }).then((userRecord) => { - // The phone number provider will be appended to the list of accounts. - importUserRecord.providerData?.push({ - uid: importUserRecord.phoneNumber!, - providerId: 'phone', - phoneNumber: importUserRecord.phoneNumber!, - }); - // The lastRefreshTime should be set to null - type Writable = { - -readonly [k in keyof UserMetadata]: UserMetadata[k]; - }; - (importUserRecord.metadata as Writable).lastRefreshTime = null; - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - for (const key of Object.keys(importUserRecord)) { - expect(JSON.stringify(actualUserRecord[key])) - .to.be.equal(JSON.stringify((importUserRecord as any)[key])); - } - }); - }); - - it('successfully imports users with enrolled second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet implemented. - } - const uid = generateRandomString(20).toLowerCase(); - const email = uid + '@example.com'; - const now = new Date(1476235905000).toUTCString(); - const enrolledFactors: UpdatePhoneMultiFactorInfoRequest[] = [ - { - uid: 'mfaUid1', - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - enrollmentTime: now, - }, - { - uid: 'mfaUid2', - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - enrollmentTime: now, - }, - ]; - - importUserRecord = { - uid, - email, - emailVerified: true, - displayName: 'Test User', - disabled: false, - metadata: { - lastSignInTime: now, - creationTime: now, - }, - providerData: [ - { - uid: uid + '-facebook', - displayName: 'Facebook User', - email, - providerId: 'facebook.com', - }, - ], - multiFactor: { - enrolledFactors, - }, - }; - uids.push(importUserRecord.uid); - - return getAuth().importUsers([importUserRecord]) - .then((result) => { - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - return getAuth().getUser(uid); - }).then((userRecord) => { - // Confirm second factors added to user. - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); - expect(actualUserRecord.multiFactor.enrolledFactors) - .to.deep.equal(importUserRecord.multiFactor?.enrolledFactors); - }).should.eventually.be.fulfilled; - }); - - it('fails when invalid users are provided', () => { - const users = [ - { uid: generateRandomString(20).toLowerCase(), email: 'invalid' }, - { uid: generateRandomString(20).toLowerCase(), emailVerified: 'invalid' } as any, - ]; - return getAuth().importUsers(users) - .then((result) => { - expect(result.successCount).to.equal(0); - expect(result.failureCount).to.equal(2); - expect(result.errors.length).to.equal(2); - expect(result.errors[0].index).to.equal(0); - expect(result.errors[0].error.code).to.equals('auth/invalid-email'); - expect(result.errors[1].index).to.equal(1); - expect(result.errors[1].error.code).to.equals('auth/invalid-email-verified'); - }); - }); - - it('fails when users with invalid phone numbers are provided', function () { - if (authEmulatorHost) { - // Auth Emulator's phoneNumber validation is also lax and won't throw. - return this.skip(); - } - const users = [ - // These phoneNumbers passes local (lax) validator but fails remotely. - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error' }, - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1invalid' }, - ]; - return getAuth().importUsers(users) - .then((result) => { - expect(result.successCount).to.equal(0); - expect(result.failureCount).to.equal(2); - expect(result.errors.length).to.equal(2); - expect(result.errors[0].index).to.equal(0); - expect(result.errors[0].error.code).to.equals('auth/invalid-user-import'); - expect(result.errors[1].index).to.equal(1); - expect(result.errors[1].error.code).to.equals('auth/invalid-user-import'); - }); - }); - }); -}); - -/** - * Imports the provided user record with the specified hashing options and then - * validates the import was successful by signing in to the imported account using - * the corresponding plain text password. - * @param importUserRecord The user record to import. - * @param importOptions The import hashing options. - * @param rawPassword The plain unhashed password string. - * @retunr A promise that resolved on success. - */ -function testImportAndSignInUser( - importUserRecord: UserImportRecord, - importOptions: any, - rawPassword: string): Promise { - const users = [importUserRecord]; - // Import the user record. - return getAuth().importUsers(users, importOptions) - .then((result) => { - // Verify the import result. - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - // Sign in with an email and password to the imported account. - return clientAuth().signInWithEmailAndPassword(users[0].email!, rawPassword); - }) - .then(({ user }) => { - // Confirm successful sign-in. - expect(user).to.exist; - expect(user!.email).to.equal(users[0].email); - expect(user!.providerData[0]).to.exist; - expect(user!.providerData[0]!.providerId).to.equal('password'); - }); -} - -/** - * Helper function that deletes the user with the specified phone number - * if it exists. - * @param phoneNumber The phone number of the user to delete. - * @return A promise that resolves when the user is deleted - * or is found not to exist. - */ -function deletePhoneNumberUser(phoneNumber: string): Promise { - return getAuth().getUserByPhoneNumber(phoneNumber) - .then((userRecord) => { - return safeDelete(userRecord.uid); - }) - .catch((error) => { - // Suppress user not found error. - if (error.code !== 'auth/user-not-found') { - throw error; - } - }); -} - -/** - * Runs cleanup routine that could affect outcome of tests and removes any - * intermediate users created. - * - * @return A promise that resolves when test preparations are ready. - */ -function cleanup(): Promise { - // Delete any existing users that could affect the test outcome. - const promises: Array> = [ - deletePhoneNumberUser(testPhoneNumber), - deletePhoneNumberUser(testPhoneNumber2), - deletePhoneNumberUser(nonexistentPhoneNumber), - deletePhoneNumberUser(updatedPhone), - ]; - // Delete users created for session cookie tests. - sessionCookieUids.forEach((uid) => uids.push(uid)); - // Delete list of users for testing listUsers. - uids.forEach((uid) => { - // Use safeDelete to avoid getting throttled. - promises.push(safeDelete(uid)); - }); - return Promise.all(promises); -} - -/** - * Returns the action code corresponding to the link. - * - * @param link The link to parse for the action code. - * @return The link's corresponding action code. - */ -function getActionCode(link: string): string { - const parsedUrl = new url.URL(link); - const oobCode = parsedUrl.searchParams.get('oobCode'); - expect(oobCode).to.exist; - return oobCode!; -} - -/** - * Returns the continue URL corresponding to the link. - * - * @param link The link to parse for the continue URL. - * @return The link's corresponding continue URL. - */ -function getContinueUrl(link: string): string { - const parsedUrl = new url.URL(link); - const continueUrl = parsedUrl.searchParams.get('continueUrl'); - expect(continueUrl).to.exist; - return continueUrl!; -} - -/** - * Returns the tenant ID corresponding to the link. - * - * @param link The link to parse for the tenant ID. - * @return The link's corresponding tenant ID. - */ -function getTenantId(link: string): string { - const parsedUrl = new url.URL(link); - const tenantId = parsedUrl.searchParams.get('tenantId'); - expect(tenantId).to.exist; - return tenantId!; -} - -/** - * Safely deletes a specificed user identified by uid. This API chains all delete - * requests and throttles them as the Auth backend rate limits this endpoint. - * A bulk delete API is being designed to help solve this issue. - * - * @param uid The identifier of the user to delete. - * @return A promise that resolves when delete operation resolves. - */ -function safeDelete(uid: string): Promise { - // Wait for delete queue to empty. - const deletePromise = deleteQueue - .then(() => { - return getAuth().deleteUser(uid); - }) - .catch((error) => { - // Suppress user not found error. - if (error.code !== 'auth/user-not-found') { - throw error; - } - }); - // Suppress errors in delete queue to not spill over to next item in queue. - deleteQueue = deletePromise.catch(() => { - // Do nothing. - }); - return deletePromise; -} - -/** - * Deletes the specified list of users by calling the `deleteUsers()` API. This - * API is rate limited at 1 QPS, and therefore this helper function staggers - * subsequent invocations by adding 1 second delay to each call. - * - * @param uids The list of user identifiers to delete. - * @return A promise that resolves when delete operation resolves. - */ -async function deleteUsersWithDelay(uids: string[]): Promise { - if (!authEmulatorHost) { - await new Promise((resolve) => { setTimeout(resolve, 1000); }); - } - return getAuth().deleteUsers(uids); -} - -/** - * Asserts actual object is equal to expected object while ignoring key order. - * This is useful since to.deep.equal fails when order differs. - * - * @param expected object. - * @param actual object. - */ -function assertDeepEqualUnordered(expected: { [key: string]: any }, actual: { [key: string]: any }): void { - for (const key in expected) { - if (Object.prototype.hasOwnProperty.call(expected, key)) { - expect(actual[key]) - .to.deep.equal(expected[key]); - } - } - expect(Object.keys(actual).length).to.be.equal(Object.keys(expected).length); -} \ No newline at end of file From 1b8423cfe0afdcdfd57447dab9d2503e2dd08684 Mon Sep 17 00:00:00 2001 From: Remi Rousselet Date: Tue, 9 Jul 2024 14:01:10 +0200 Subject: [PATCH 10/11] Fix-CI (#40) Also fix warning when using emulator fixes #38 --- packages/dart_firebase_admin/lib/src/app.dart | 1 + .../lib/src/app/credential.dart | 57 +- .../lib/src/app/exception.dart | 2 +- .../lib/src/app/firebase_admin.dart | 23 +- .../dart_firebase_admin/lib/src/auth.dart | 2 +- .../lib/src/auth/auth_api_request.dart | 5 +- .../src/google_cloud_firestore/firestore.dart | 6 +- .../src/google_cloud_firestore/reference.dart | 6 +- .../lib/src/messaging.dart | 1 - .../messaging_api_request_internal.dart | 7 +- .../dart_firebase_admin/lib/src/utils/jwt.ts | 370 -- .../test/analysis_options.yaml | 5 + .../test/auth/integration.ts | 3243 ----------------- .../test/auth/integration_test.dart | 4 +- .../collection_group_test.dart | 11 +- .../collection_test.dart | 3 +- .../google_cloud_firestore/document_test.dart | 4 +- .../google_cloud_firestore/util/helpers.dart | 13 +- scripts/coverage.sh | 2 +- 19 files changed, 103 insertions(+), 3662 deletions(-) delete mode 100644 packages/dart_firebase_admin/lib/src/utils/jwt.ts create mode 100644 packages/dart_firebase_admin/test/analysis_options.yaml delete mode 100644 packages/dart_firebase_admin/test/auth/integration.ts diff --git a/packages/dart_firebase_admin/lib/src/app.dart b/packages/dart_firebase_admin/lib/src/app.dart index 1607661..f89ccdd 100644 --- a/packages/dart_firebase_admin/lib/src/app.dart +++ b/packages/dart_firebase_admin/lib/src/app.dart @@ -7,6 +7,7 @@ import 'dart:io'; import 'package:firebaseapis/identitytoolkit/v3.dart' as auth3; import 'package:googleapis_auth/auth_io.dart' as auth; import 'package:googleapis_auth/googleapis_auth.dart'; +import 'package:http/http.dart'; import 'package:meta/meta.dart'; part 'app/credential.dart'; diff --git a/packages/dart_firebase_admin/lib/src/app/credential.dart b/packages/dart_firebase_admin/lib/src/app/credential.dart index 15abb5d..c9fca89 100644 --- a/packages/dart_firebase_admin/lib/src/app/credential.dart +++ b/packages/dart_firebase_admin/lib/src/app/credential.dart @@ -3,6 +3,46 @@ part of '../app.dart'; @internal const envSymbol = #_envSymbol; +class _RequestImpl extends BaseRequest { + _RequestImpl(super.method, super.url, [Stream>? stream]) + : _stream = stream ?? const Stream.empty(); + + final Stream> _stream; + + @override + ByteStream finalize() { + super.finalize(); + return ByteStream(_stream); + } +} + +/// Will close the underlying `http.Client` depending on a constructor argument. +class _EmulatorClient extends BaseClient { + _EmulatorClient(this.client); + + final Client client; + + @override + Future send(BaseRequest request) async { + // Make new request object and perform the authenticated request. + final modifiedRequest = _RequestImpl( + request.method, + request.url, + request.finalize(), + ); + modifiedRequest.headers.addAll(request.headers); + modifiedRequest.headers['Authorization'] = 'Bearer owner'; + + return client.send(modifiedRequest); + } + + @override + void close() { + client.close(); + super.close(); + } +} + /// Authentication information for Firebase Admin SDK. class Credential { Credential._( @@ -72,21 +112,4 @@ class Credential { @internal final auth.ServiceAccountCredentials? serviceAccountCredentials; - - @internal - late final client = _getClient( - [ - auth3.IdentityToolkitApi.cloudPlatformScope, - auth3.IdentityToolkitApi.firebaseScope, - ], - ); - - Future _getClient(List scopes) async { - final serviceAccountCredentials = this.serviceAccountCredentials; - final client = serviceAccountCredentials == null - ? await auth.clientViaApplicationDefaultCredentials(scopes: scopes) - : await auth.clientViaServiceAccount(serviceAccountCredentials, scopes); - - return client; - } } diff --git a/packages/dart_firebase_admin/lib/src/app/exception.dart b/packages/dart_firebase_admin/lib/src/app/exception.dart index 4d66f30..0a17558 100644 --- a/packages/dart_firebase_admin/lib/src/app/exception.dart +++ b/packages/dart_firebase_admin/lib/src/app/exception.dart @@ -56,7 +56,7 @@ String _platformErrorCodeMessage(String code) { } /// Base interface for all Firebase Admin related errors. -abstract class FirebaseAdminException { +abstract class FirebaseAdminException implements Exception { FirebaseAdminException(this.service, this._code, [this._message]); final String service; diff --git a/packages/dart_firebase_admin/lib/src/app/firebase_admin.dart b/packages/dart_firebase_admin/lib/src/app/firebase_admin.dart index d0412b9..0b6215d 100644 --- a/packages/dart_firebase_admin/lib/src/app/firebase_admin.dart +++ b/packages/dart_firebase_admin/lib/src/app/firebase_admin.dart @@ -24,9 +24,30 @@ class FirebaseAdminApp { firestoreApiHost = Uri.http('127.0.0.1:8080', '/'); } + @internal + late final client = _getClient( + [ + auth3.IdentityToolkitApi.cloudPlatformScope, + auth3.IdentityToolkitApi.firebaseScope, + ], + ); + + Future _getClient(List scopes) async { + if (isUsingEmulator) { + return _EmulatorClient(Client()); + } + + final serviceAccountCredentials = credential.serviceAccountCredentials; + final client = serviceAccountCredentials == null + ? await auth.clientViaApplicationDefaultCredentials(scopes: scopes) + : await auth.clientViaServiceAccount(serviceAccountCredentials, scopes); + + return client; + } + /// Stops the app and releases any resources associated with it. Future close() async { - final client = await credential.client; + final client = await this.client; client.close(); } } diff --git a/packages/dart_firebase_admin/lib/src/auth.dart b/packages/dart_firebase_admin/lib/src/auth.dart index 8f58c74..e04b1f4 100644 --- a/packages/dart_firebase_admin/lib/src/auth.dart +++ b/packages/dart_firebase_admin/lib/src/auth.dart @@ -8,7 +8,7 @@ import 'package:firebaseapis/identitytoolkit/v1.dart' as v1; import 'package:firebaseapis/identitytoolkit/v2.dart' as auth2; import 'package:firebaseapis/identitytoolkit/v2.dart' as v2; import 'package:firebaseapis/identitytoolkit/v3.dart' as auth3; -import 'package:googleapis_auth/googleapis_auth.dart'; +import 'package:http/http.dart'; import 'package:meta/meta.dart'; import 'app.dart'; 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 index dc0f98a..fc5bfe1 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart @@ -750,7 +750,6 @@ class _AuthHttpClient { _AuthHttpClient(this.app); // TODO handle tenants - // TODO needs to send "owner" as bearer token when using the emulator final FirebaseAdminApp app; String _buildParent() => 'projects/${app.projectId}'; @@ -1012,9 +1011,9 @@ class _AuthHttpClient { } Future _run( - Future Function(AutoRefreshingAuthClient client) fn, + Future Function(Client client) fn, ) { - return _authGuard(() => app.credential.client.then(fn)); + return _authGuard(() => app.client.then(fn)); } Future v1( 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 index 132c564..431429a 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/firestore.dart @@ -5,7 +5,7 @@ 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:freezed_annotation/freezed_annotation.dart'; -import 'package:googleapis_auth/googleapis_auth.dart'; +import 'package:http/http.dart'; import 'package:intl/intl.dart'; import '../app.dart'; @@ -206,9 +206,9 @@ class _FirestoreHttpClient { // TODO refactor with auth // TODO is it fine to use AuthClient? Future _run( - Future Function(AutoRefreshingAuthClient client) fn, + Future Function(Client client) fn, ) { - return _firestoreGuard(() => app.credential.client.then(fn)); + return _firestoreGuard(() => app.client.then(fn)); } Future v1( 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 index b5a089f..6961a35 100644 --- a/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart +++ b/packages/dart_firebase_admin/lib/src/google_cloud_firestore/reference.dart @@ -23,13 +23,13 @@ class CollectionReference extends Query { /// final documentRef = collectionRef.parent; /// print('Parent name: ${documentRef.path}'); /// ``` - DocumentReference? get parent { + DocumentReference? get parent { if (!_queryOptions.parentPath.isDocument) return null; - return DocumentReference._( + return DocumentReference._( firestore: firestore, path: _queryOptions.parentPath as _QualifiedResourcePath, - converter: _queryOptions.converter, + converter: _jsonConverter, ); } diff --git a/packages/dart_firebase_admin/lib/src/messaging.dart b/packages/dart_firebase_admin/lib/src/messaging.dart index cc57316..c26aeb9 100644 --- a/packages/dart_firebase_admin/lib/src/messaging.dart +++ b/packages/dart_firebase_admin/lib/src/messaging.dart @@ -3,7 +3,6 @@ import 'dart:convert'; import 'package:collection/collection.dart'; import 'package:firebaseapis/fcm/v1.dart' as fmc1; -import 'package:googleapis_auth/auth_io.dart'; import 'package:http/http.dart'; import 'package:meta/meta.dart'; diff --git a/packages/dart_firebase_admin/lib/src/messaging/messaging_api_request_internal.dart b/packages/dart_firebase_admin/lib/src/messaging/messaging_api_request_internal.dart index 84772c2..f80d426 100644 --- a/packages/dart_firebase_admin/lib/src/messaging/messaging_api_request_internal.dart +++ b/packages/dart_firebase_admin/lib/src/messaging/messaging_api_request_internal.dart @@ -13,9 +13,9 @@ class FirebaseMessagingRequestHandler { final FirebaseAdminApp firebase; Future _run( - Future Function(AutoRefreshingAuthClient client) fn, + Future Function(Client client) fn, ) { - return _fmcGuard(() => firebase.credential.client.then(fn)); + return _fmcGuard(() => firebase.client.then(fn)); } Future _fmcGuard( @@ -45,7 +45,7 @@ class FirebaseMessagingRequestHandler { Object? requestData, }) async { try { - final client = await firebase.credential.client; + final client = await firebase.client; final response = await client.post( Uri.https(host, path), @@ -53,7 +53,6 @@ class FirebaseMessagingRequestHandler { headers: { ..._legacyFirebaseMessagingHeaders, 'content-type': 'application/json', - 'Authorization': 'Bearer ${client.credentials.accessToken.data}', }, ); diff --git a/packages/dart_firebase_admin/lib/src/utils/jwt.ts b/packages/dart_firebase_admin/lib/src/utils/jwt.ts deleted file mode 100644 index f8d80a6..0000000 --- a/packages/dart_firebase_admin/lib/src/utils/jwt.ts +++ /dev/null @@ -1,370 +0,0 @@ -/*! - * 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/test/analysis_options.yaml b/packages/dart_firebase_admin/test/analysis_options.yaml new file mode 100644 index 0000000..abf6c12 --- /dev/null +++ b/packages/dart_firebase_admin/test/analysis_options.yaml @@ -0,0 +1,5 @@ +include: ../../../analysis_options.yaml +linter: + rules: + # Disabling inside tests for the sake of type testing + omit_local_variable_types: false diff --git a/packages/dart_firebase_admin/test/auth/integration.ts b/packages/dart_firebase_admin/test/auth/integration.ts deleted file mode 100644 index b8898c1..0000000 --- a/packages/dart_firebase_admin/test/auth/integration.ts +++ /dev/null @@ -1,3243 +0,0 @@ -/*! - * 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 url from 'url'; -import * as crypto from 'crypto'; -import * as bcrypt from 'bcrypt'; -import * as chai from 'chai'; -import * as chaiAsPromised from 'chai-as-promised'; -import firebase from '@firebase/app-compat'; -import '@firebase/auth-compat'; -import { clone } from 'lodash'; -import { User, FirebaseAuth } from '@firebase/auth-types'; -import { - generateRandomString, projectId, apiKey, noServiceAccountApp, cmdArgs, -} from './setup'; -import * as mocks from '../resources/mocks'; -import { deepExtend, deepCopy } from '../../src/utils/deep-copy'; -import { - AuthProviderConfig, CreateTenantRequest, DeleteUsersResult, PhoneMultiFactorInfo, - TenantAwareAuth, UpdatePhoneMultiFactorInfoRequest, UpdateTenantRequest, UserImportOptions, - UserImportRecord, UserRecord, getAuth, UpdateProjectConfigRequest, UserMetadata, MultiFactorConfig, - PasswordPolicyConfig, SmsRegionConfig, -} from '../../lib/auth/index'; -import * as sinon from 'sinon'; -import * as sinonChai from 'sinon-chai'; - -const chalk = require('chalk'); // eslint-disable-line @typescript-eslint/no-var-requires - -chai.should(); -chai.use(sinonChai); -chai.use(chaiAsPromised); - -const expect = chai.expect; - -const authEmulatorHost = process.env.FIREBASE_AUTH_EMULATOR_HOST; - -const newUserUid = generateRandomString(20); -const nonexistentUid = generateRandomString(20); -const newMultiFactorUserUid = generateRandomString(20); -const sessionCookieUids = [ - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), - generateRandomString(20), -]; -const testPhoneNumber = '+11234567890'; -const testPhoneNumber2 = '+16505550101'; -const nonexistentPhoneNumber = '+18888888888'; -const updatedEmail = generateRandomString(20).toLowerCase() + '@example.com'; -const updatedPhone = '+16505550102'; -const customClaims: { [key: string]: any } = { - admin: true, - groupId: '1234', -}; -const uids = [newUserUid + '-1', newUserUid + '-2', newUserUid + '-3']; -const mockUserData = { - email: newUserUid.toLowerCase() + '@example.com', - emailVerified: false, - phoneNumber: testPhoneNumber, - password: 'password', - displayName: 'Random User ' + newUserUid, - photoURL: 'http://www.example.com/' + newUserUid + '/photo.png', - disabled: false, -}; -const actionCodeSettings = { - url: 'http://localhost/?a=1&b=2#c=3', - handleCodeInApp: false, -}; -let deleteQueue = Promise.resolve(); - -interface UserImportTest { - name: string; - importOptions: UserImportOptions; - rawPassword: string; - rawSalt?: string; - computePasswordHash(userImportTest: UserImportTest): Buffer; -} - -/** @return Random generated SAML provider ID. */ -function randomSamlProviderId(): string { - return 'saml.' + generateRandomString(10, false).toLowerCase(); -} - -/** @return Random generated OIDC provider ID. */ -function randomOidcProviderId(): string { - return 'oidc.' + generateRandomString(10, false).toLowerCase(); -} - -function clientAuth(): FirebaseAuth { - expect(firebase.auth).to.be.ok; - return firebase.auth!(); -} - -describe('admin.auth', () => { - - let uidFromCreateUserWithoutUid: string; - const processWarningSpy = sinon.spy(); - - before(() => { - firebase.initializeApp({ - apiKey, - authDomain: projectId + '.firebaseapp.com', - }); - if (authEmulatorHost) { - (clientAuth() as any).useEmulator('http://' + authEmulatorHost); - } - process.on('warning', processWarningSpy); - return cleanup(); - }); - - afterEach(() => { - expect( - processWarningSpy.neverCalledWith( - sinon.match( - (warning: Error) => warning.name === 'MaxListenersExceededWarning' - ) - ), - 'process.on("warning") was called with an unexpected MaxListenersExceededWarning.' - ).to.be.true; - processWarningSpy.resetHistory(); - }); - - after(() => { - process.removeListener('warning', processWarningSpy); - return cleanup(); - }); - - it('createUser() creates a new user when called without a UID', () => { - const newUserData = clone(mockUserData); - newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; - newUserData.phoneNumber = testPhoneNumber2; - return getAuth().createUser(newUserData) - .then((userRecord) => { - uidFromCreateUserWithoutUid = userRecord.uid; - expect(typeof userRecord.uid).to.equal('string'); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('createUser() creates a new user with the specified UID', () => { - const newUserData: any = clone(mockUserData); - newUserData.uid = newUserUid; - return getAuth().createUser(newUserData) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('createUser() creates a new user with enrolled second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - const enrolledFactors = [ - { - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - }, - { - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - }, - ]; - const newUserData: any = { - uid: newMultiFactorUserUid, - email: generateRandomString(20).toLowerCase() + '@example.com', - emailVerified: true, - password: 'password', - multiFactor: { - enrolledFactors, - }, - }; - return getAuth().createUser(newUserData) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newMultiFactorUserUid); - // Confirm expected email. - expect(userRecord.email).to.equal(newUserData.email); - // Confirm second factors added to user. - expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(2); - // Confirm first enrolled second factor. - const firstMultiFactor = userRecord.multiFactor!.enrolledFactors[0]; - expect(firstMultiFactor.uid).not.to.be.undefined; - expect(firstMultiFactor.enrollmentTime).not.to.be.undefined; - expect((firstMultiFactor as PhoneMultiFactorInfo).phoneNumber).to.equal( - enrolledFactors[0].phoneNumber); - expect(firstMultiFactor.displayName).to.equal(enrolledFactors[0].displayName); - expect(firstMultiFactor.factorId).to.equal(enrolledFactors[0].factorId); - // Confirm second enrolled second factor. - const secondMultiFactor = userRecord.multiFactor!.enrolledFactors[1]; - expect(secondMultiFactor.uid).not.to.be.undefined; - expect(secondMultiFactor.enrollmentTime).not.to.be.undefined; - expect((secondMultiFactor as PhoneMultiFactorInfo).phoneNumber).to.equal( - enrolledFactors[1].phoneNumber); - expect(secondMultiFactor.displayName).to.equal(enrolledFactors[1].displayName); - expect(secondMultiFactor.factorId).to.equal(enrolledFactors[1].factorId); - }); - }); - - it('createUser() fails when the UID is already in use', () => { - const newUserData: any = clone(mockUserData); - newUserData.uid = newUserUid; - return getAuth().createUser(newUserData) - .should.eventually.be.rejected.and.have.property('code', 'auth/uid-already-exists'); - }); - - it('getUser() returns a user record with the matching UID', () => { - return getAuth().getUser(newUserUid) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByEmail() returns a user record with the matching email', () => { - return getAuth().getUserByEmail(mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByPhoneNumber() returns a user record with the matching phone number', () => { - return getAuth().getUserByPhoneNumber(mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() returns a user record with the matching provider id', async () => { - // TODO(rsgowman): Once we can link a provider id with a user, just do that - // here instead of creating a new user. - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - const importUser: UserImportRecord = { - uid: randomUid, - email: 'user@example.com', - phoneNumber: '+15555550000', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Name', - email: 'user@example.com', - phoneNumber: '+15555550000', - photoURL: 'http://example.com/user', - providerId: 'google.com', - uid: 'google_uid', - }], - }; - - await getAuth().importUsers([importUser]); - - try { - await getAuth().getUserByProviderUid('google.com', 'google_uid') - .then((userRecord) => { - expect(userRecord.uid).to.equal(importUser.uid); - }); - } finally { - await safeDelete(importUser.uid); - } - }); - - it('getUserByProviderUid() redirects to getUserByEmail if given an email', () => { - return getAuth().getUserByProviderUid('email', mockUserData.email) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - it('getUserByProviderUid() redirects to getUserByPhoneNumber if given a phone number', () => { - return getAuth().getUserByProviderUid('phone', mockUserData.phoneNumber) - .then((userRecord) => { - expect(userRecord.uid).to.equal(newUserUid); - }); - }); - - describe('getUsers()', () => { - /** - * Filters a list of object to another list of objects that only contains - * the uid, email, and phoneNumber fields. Works with at least UserRecord - * and UserImportRecord instances. - */ - function mapUserRecordsToUidEmailPhones( - values: Array<{ uid: string; email?: string; phoneNumber?: string }> - ): Array<{ uid: string; email?: string; phoneNumber?: string }> { - return values.map((ur) => ({ uid: ur.uid, email: ur.email, phoneNumber: ur.phoneNumber })); - } - - const testUser1 = { uid: 'uid1', email: 'user1@example.com', phoneNumber: '+15555550001' }; - const testUser2 = { uid: 'uid2', email: 'user2@example.com', phoneNumber: '+15555550002' }; - const testUser3 = { uid: 'uid3', email: 'user3@example.com', phoneNumber: '+15555550003' }; - const usersToCreate = [testUser1, testUser2, testUser3]; - - // Also create a user with a provider config. (You can't create a user with - // a provider config. But you *can* import one.) - const importUser1: UserImportRecord = { - uid: 'uid4', - email: 'user4@example.com', - phoneNumber: '+15555550004', - emailVerified: true, - disabled: false, - metadata: { - lastSignInTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - creationTime: 'Thu, 01 Jan 1970 00:00:00 UTC', - }, - providerData: [{ - displayName: 'User Four', - email: 'user4@example.com', - phoneNumber: '+15555550004', - photoURL: 'http://example.com/user4', - providerId: 'google.com', - uid: 'google_uid4', - }], - }; - - const testUser4 = mapUserRecordsToUidEmailPhones([importUser1])[0]; - - before(async () => { - // Delete all the users that we're about to create (in case they were - // left over from a prior run). - const uidsToDelete = usersToCreate.map((user) => user.uid); - uidsToDelete.push(importUser1.uid); - await deleteUsersWithDelay(uidsToDelete); - - // Create/import users required by these tests - await Promise.all(usersToCreate.map((user) => getAuth().createUser(user))); - await getAuth().importUsers([importUser1]); - }); - - after(async () => { - const uidsToDelete = usersToCreate.map((user) => user.uid); - uidsToDelete.push(importUser1.uid); - await deleteUsersWithDelay(uidsToDelete); - }); - - it('returns users by various identifier types in a single call', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { email: 'user2@example.com' }, - { phoneNumber: '+15555550003' }, - { providerId: 'google.com', providerUid: 'google_uid4' }, - ]) - .then((getUsersResult) => getUsersResult.users) - .then(mapUserRecordsToUidEmailPhones); - - expect(users).to.have.deep.members([testUser1, testUser2, testUser3, testUser4]); - }); - - it('returns found users and ignores non-existing users', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { uid: 'uid_that_doesnt_exist' }, - { uid: 'uid3' }, - ]); - expect(users.notFound).to.have.deep.members([{ uid: 'uid_that_doesnt_exist' }]); - - const foundUsers = mapUserRecordsToUidEmailPhones(users.users); - expect(foundUsers).to.have.deep.members([testUser1, testUser3]); - }); - - it('returns nothing when queried for only non-existing users', async () => { - const notFoundIds = [{ uid: 'non-existing user' }]; - const users = await getAuth().getUsers(notFoundIds); - - expect(users.users).to.be.empty; - expect(users.notFound).to.deep.equal(notFoundIds); - }); - - it('de-dups duplicate users', async () => { - const users = await getAuth().getUsers([ - { uid: 'uid1' }, - { uid: 'uid1' }, - ]) - .then((getUsersResult) => getUsersResult.users) - .then(mapUserRecordsToUidEmailPhones); - - expect(users).to.deep.equal([testUser1]); - }); - - it('returns users with a lastRefreshTime', async () => { - const isUTCString = (s: string): boolean => { - return new Date(s).toUTCString() === s; - }; - - const newUserRecord = await getAuth().createUser({ - uid: 'lastRefreshTimeUser', - email: 'lastRefreshTimeUser@example.com', - password: 'p4ssword', - }); - - try { - // New users should not have a lastRefreshTime set. - expect(newUserRecord.metadata.lastRefreshTime).to.be.null; - - // Login to set the lastRefreshTime. - await firebase.auth!().signInWithEmailAndPassword('lastRefreshTimeUser@example.com', 'p4ssword') - .then(async () => { - // Attempt to retrieve the user 3 times (with a small delay between - // each attempt). Occassionally, this call retrieves the user data - // without the lastLoginTime/lastRefreshTime set; possibly because - // it's hitting a different server than the login request uses. - let userRecord: UserRecord | null = null; - - for (let i = 0; i < 3; i++) { - userRecord = await getAuth().getUser('lastRefreshTimeUser'); - if (userRecord!['metadata']['lastRefreshTime']) { - break; - } - - await new Promise((resolve) => { - setTimeout(resolve, 1000 * Math.pow(2, i)); - }); - } - - const metadata = userRecord!['metadata']; - expect(metadata['lastRefreshTime']).to.exist; - expect(isUTCString(metadata['lastRefreshTime']!)).to.be.true; - const creationTime = new Date(metadata['creationTime']).getTime(); - const lastRefreshTime = new Date(metadata['lastRefreshTime']!).getTime(); - expect(creationTime).lte(lastRefreshTime); - expect(lastRefreshTime).lte(creationTime + 3600 * 1000); - }); - } finally { - getAuth().deleteUser('lastRefreshTimeUser'); - } - }); - }); - - it('listUsers() returns up to the specified number of users', () => { - const promises: Array> = []; - uids.forEach((uid) => { - const tempUserData = { - uid, - password: 'password', - }; - promises.push(getAuth().createUser(tempUserData)); - }); - return Promise.all(promises) - .then(() => { - // Return 2 users with the provided page token. - // This test will fail if other users are created in between. - return getAuth().listUsers(2, uids[0]); - }) - .then((listUsersResult) => { - // Confirm expected number of users. - expect(listUsersResult.users.length).to.equal(2); - // Confirm next page token present. - expect(typeof listUsersResult.pageToken).to.equal('string'); - // Confirm each user's uid and the hashed passwords. - expect(listUsersResult.users[0].uid).to.equal(uids[1]); - - expect( - listUsersResult.users[0].passwordHash, - 'Missing passwordHash field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', - ).to.be.ok; - expect(listUsersResult.users[0].passwordHash!.length).greaterThan(0); - - expect( - listUsersResult.users[0].passwordSalt, - 'Missing passwordSalt field. A common cause would be forgetting to ' - + 'add the "Firebase Authentication Admin" permission. See ' - + 'instructions in CONTRIBUTING.md', - ).to.be.ok; - expect(listUsersResult.users[0].passwordSalt!.length).greaterThan(0); - - expect(listUsersResult.users[1].uid).to.equal(uids[2]); - expect(listUsersResult.users[1].passwordHash!.length).greaterThan(0); - expect(listUsersResult.users[1].passwordSalt!.length).greaterThan(0); - }); - }); - - it('revokeRefreshTokens() invalidates existing sessions and ID tokens', async () => { - let currentIdToken: string; - let currentUser: User; - // Sign in with an email and password account. - return clientAuth().signInWithEmailAndPassword(mockUserData.email, mockUserData.password) - .then(({ user }) => { - expect(user).to.exist; - currentUser = user!; - // Get user's ID token. - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - // Verify that user's ID token while checking for revocation. - return getAuth().verifyIdToken(currentIdToken, true); - }) - .then((decodedIdToken) => { - // Verification should succeed. Revoke that user's session. - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(decodedIdToken.sub), - ), 1000)); - }) - .then(() => { - const verifyingIdToken = getAuth().verifyIdToken(currentIdToken) - if (authEmulatorHost) { - // Check revocation is forced in emulator-mode and this should throw. - return verifyingIdToken.should.eventually.be.rejected; - } else { - // verifyIdToken without checking revocation should still succeed. - return verifyingIdToken.should.eventually.be.fulfilled; - } - }) - .then(() => { - // verifyIdToken while checking for revocation should fail. - return getAuth().verifyIdToken(currentIdToken, true) - .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-revoked'); - }) - .then(() => { - // Confirm token revoked on client. - return currentUser.reload() - .should.eventually.be.rejected.and.have.property('code', 'auth/user-token-expired'); - }) - .then(() => { - // New sign-in should succeed. - return clientAuth().signInWithEmailAndPassword( - mockUserData.email, mockUserData.password); - }) - .then(({ user }) => { - // Get new session's ID token. - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // ID token for new session should be valid even with revocation check. - return getAuth().verifyIdToken(idToken, true) - .should.eventually.be.fulfilled; - }); - }); - - it('setCustomUserClaims() sets claims that are accessible via user\'s ID token', () => { - // Set custom claims on the user. - return getAuth().setCustomUserClaims(newUserUid, customClaims) - .then(() => { - return getAuth().getUser(newUserUid); - }) - .then((userRecord) => { - // Confirm custom claims set on the UserRecord. - expect(userRecord.customClaims).to.deep.equal(customClaims); - expect(userRecord.email).to.exist; - return clientAuth().signInWithEmailAndPassword( - userRecord.email!, mockUserData.password); - }) - .then(({ user }) => { - // Get the user's ID token. - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // Verify ID token contents. - return getAuth().verifyIdToken(idToken); - }) - .then((decodedIdToken: { [key: string]: any }) => { - // Confirm expected claims set on the user's ID token. - for (const key in customClaims) { - if (Object.prototype.hasOwnProperty.call(customClaims, key)) { - expect(decodedIdToken[key]).to.equal(customClaims[key]); - } - } - // Test clearing of custom claims. - return getAuth().setCustomUserClaims(newUserUid, null); - }) - .then(() => { - return getAuth().getUser(newUserUid); - }) - .then((userRecord) => { - // Custom claims should be cleared. - expect(userRecord.customClaims).to.deep.equal({}); - // Force token refresh. All claims should be cleared. - expect(clientAuth().currentUser).to.exist; - return clientAuth().currentUser!.getIdToken(true); - }) - .then((idToken) => { - // Verify ID token contents. - return getAuth().verifyIdToken(idToken); - }) - .then((decodedIdToken: { [key: string]: any }) => { - // Confirm all custom claims are cleared. - for (const key in customClaims) { - if (Object.prototype.hasOwnProperty.call(customClaims, key)) { - expect(decodedIdToken[key]).to.be.undefined; - } - } - }); - }); - - describe('updateUser()', () => { - /** - * Creates a new user for testing purposes. The user's uid will be - * '$name_$tenRandomChars' and email will be - * '$name_$tenRandomChars@example.com'. - */ - // TODO(rsgowman): This function could usefully be employed throughout this file. - function createTestUser(name: string): Promise { - const tenRandomChars = generateRandomString(10); - return getAuth().createUser({ - uid: name + '_' + tenRandomChars, - displayName: name, - email: name + '_' + tenRandomChars + '@example.com', - }); - } - - let updateUser: UserRecord; - before(async () => { - updateUser = await createTestUser('UpdateUser'); - }); - - after(() => { - return safeDelete(updateUser.uid); - }); - - it('updates the user record with the given parameters', () => { - const updatedDisplayName = 'Updated User ' + updateUser.uid; - return getAuth().updateUser(updateUser.uid, { - email: updatedEmail, - phoneNumber: updatedPhone, - emailVerified: true, - displayName: updatedDisplayName, - }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - expect(userRecord.displayName).to.equal(updatedDisplayName); - // Confirm expected email. - expect(userRecord.email).to.equal(updatedEmail); - // Confirm expected phone number. - expect(userRecord.phoneNumber).to.equal(updatedPhone); - }); - }); - - it('creates, updates, and removes second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - - const now = new Date(1476235905000).toUTCString(); - // Update user with enrolled second factors. - const enrolledFactors = [ - { - uid: 'mfaUid1', - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - enrollmentTime: now, - }, - { - uid: 'mfaUid2', - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - enrollmentTime: now, - }, - ]; - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors, - }, - }) - .then((userRecord) => { - // Confirm second factors added to user. - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); - expect(actualUserRecord.multiFactor.enrolledFactors).to.deep.equal(enrolledFactors); - // Update list of second factors. - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors: [enrolledFactors[0]], - }, - }); - }) - .then((userRecord) => { - expect(userRecord.multiFactor!.enrolledFactors.length).to.equal(1); - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors[0]).to.deep.equal(enrolledFactors[0]); - // Remove all second factors. - return getAuth().updateUser(updateUser.uid, { - multiFactor: { - enrolledFactors: null, - }, - }); - }) - .then((userRecord) => { - // Confirm all second factors removed. - expect(userRecord.multiFactor).to.be.undefined; - }); - }); - - it('can link/unlink with a federated provider', async function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - const googleFederatedUid = 'google_uid_' + generateRandomString(10); - let userRecord = await getAuth().updateUser(updateUser.uid, { - providerToLink: { - providerId: 'google.com', - uid: googleFederatedUid, - }, - }); - - let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.deep.include(googleFederatedUid); - expect(providerIds).to.deep.include('google.com'); - - userRecord = await getAuth().updateUser(updateUser.uid, { - providersToUnlink: ['google.com'], - }); - - providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.not.deep.include(googleFederatedUid); - expect(providerIds).to.not.deep.include('google.com'); - }); - - it('can unlink multiple providers at once, incl a non-federated provider', async function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - await deletePhoneNumberUser('+15555550001'); - - const googleFederatedUid = 'google_uid_' + generateRandomString(10); - const facebookFederatedUid = 'facebook_uid_' + generateRandomString(10); - - let userRecord = await getAuth().updateUser(updateUser.uid, { - phoneNumber: '+15555550001', - providerToLink: { - providerId: 'google.com', - uid: googleFederatedUid, - }, - }); - userRecord = await getAuth().updateUser(updateUser.uid, { - providerToLink: { - providerId: 'facebook.com', - uid: facebookFederatedUid, - }, - }); - - let providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - let providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); - expect(providerIds).to.deep.include.members(['google.com', 'facebook.com', 'phone']); - - userRecord = await getAuth().updateUser(updateUser.uid, { - providersToUnlink: ['google.com', 'facebook.com', 'phone'], - }); - - providerUids = userRecord.providerData.map((userInfo) => userInfo.uid); - providerIds = userRecord.providerData.map((userInfo) => userInfo.providerId); - expect(providerUids).to.not.deep.include.members([googleFederatedUid, facebookFederatedUid, '+15555550001']); - expect(providerIds).to.not.deep.include.members(['google.com', 'facebook.com', 'phone']); - }); - - it('noops successfully when given an empty providersToUnlink list', async () => { - const userRecord = await createTestUser('NoopWithEmptyProvidersToDeleteUser'); - try { - const updatedUserRecord = await getAuth().updateUser(userRecord.uid, { - providersToUnlink: [], - }); - - expect(updatedUserRecord).to.deep.equal(userRecord); - } finally { - safeDelete(userRecord.uid); - } - }); - - it('A user with user record disabled is unable to sign in', async () => { - const password = 'password'; - const email = 'updatedEmail@example.com'; - return getAuth().updateUser(updateUser.uid, { disabled: true, password, email }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, password); - }) - .then(() => { - throw new Error('Unexpected success'); - }, (error) => { - expect(error).to.have.property('code', 'auth/user-disabled'); - }); - }); - }); - - it('getUser() fails when called with a non-existing UID', () => { - return getAuth().getUser(nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByEmail() fails when called with a non-existing email', () => { - return getAuth().getUserByEmail(nonexistentUid + '@example.com') - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByPhoneNumber() fails when called with a non-existing phone number', () => { - return getAuth().getUserByPhoneNumber(nonexistentPhoneNumber) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('getUserByProviderUid() fails when called with a non-existing provider id', () => { - return getAuth().getUserByProviderUid('google.com', nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('updateUser() fails when called with a non-existing UID', () => { - return getAuth().updateUser(nonexistentUid, { - emailVerified: true, - }).should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('deleteUser() fails when called with a non-existing UID', () => { - return getAuth().deleteUser(nonexistentUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - - it('createCustomToken() mints a JWT that can be used to sign in', () => { - return getAuth().createCustomToken(newUserUid, { - isAdmin: true, - }) - .then((customToken) => { - return clientAuth().signInWithCustomToken(customToken); - }) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth().verifyIdToken(idToken); - }) - .then((token) => { - expect(token.uid).to.equal(newUserUid); - expect(token.isAdmin).to.be.true; - }); - }); - - it('createCustomToken() can mint JWTs without a service account', () => { - return getAuth(noServiceAccountApp).createCustomToken(newUserUid, { - isAdmin: true, - }) - .then((customToken) => { - return clientAuth().signInWithCustomToken(customToken); - }) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth(noServiceAccountApp).verifyIdToken(idToken); - }) - .then((token) => { - expect(token.uid).to.equal(newUserUid); - expect(token.isAdmin).to.be.true; - }); - }); - - it('verifyIdToken() fails when called with an invalid token', () => { - return getAuth().verifyIdToken('invalid-token') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - if (authEmulatorHost) { - describe('Auth emulator support', () => { - const uid = 'authEmulatorUser'; - before(() => { - return getAuth().createUser({ - uid, - email: 'lastRefreshTimeUser@example.com', - password: 'p4ssword', - }); - }); - after(() => { - return getAuth().deleteUser(uid); - }); - - it('verifyIdToken() succeeds when called with an unsigned token', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: uid, - }, undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken); - }); - - it('verifyIdToken() fails when called with a token with wrong project', () => { - const unsignedToken = mocks.generateIdToken( - { algorithm: 'none', audience: 'nosuch' }, - undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('verifyIdToken() fails when called with a token that does not belong to a user', () => { - const unsignedToken = mocks.generateIdToken({ - algorithm: 'none', - audience: projectId, - issuer: 'https://securetoken.google.com/' + projectId, - subject: 'nosuch', - }, undefined, 'secret'); - return getAuth().verifyIdToken(unsignedToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - }); - } - - describe('Link operations', () => { - const uid = generateRandomString(20).toLowerCase(); - const email = uid + '@example.com'; - const newEmail = uid + 'new@example.com'; - const newPassword = 'newPassword'; - const userData = { - uid, - email, - emailVerified: false, - password: 'password', - }; - - // Create the test user before running this suite of tests. - before(() => { - return getAuth().createUser(userData); - }); - - // Sign out after each test. - afterEach(() => { - return clientAuth().signOut(); - }); - - // Delete test user at the end of test suite. - after(() => { - return safeDelete(uid); - }); - - it('generatePasswordResetLink() should return a password reset link', () => { - // Ensure old password set on created user. - return getAuth().updateUser(uid, { password: 'password' }) - .then(() => { - return getAuth().generatePasswordResetLink(email, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().confirmPasswordReset(code, newPassword); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, newPassword); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - // Password reset also verifies the user's email. - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateEmailVerificationLink() should return a verification link', () => { - // Ensure the user's email is unverified. - return getAuth().updateUser(uid, { password: 'password', emailVerified: false }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.false; - return getAuth().generateEmailVerificationLink(email, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().applyActionCode(code); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(email, userData.password); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateSignInWithEmailLink() should return a sign-in link', () => { - return getAuth().generateSignInWithEmailLink(email, actionCodeSettings) - .then((link) => { - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().signInWithEmailLink(email, link); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(email); - expect(result.user!.emailVerified).to.be.true; - }); - }); - - it('generateVerifyAndChangeEmailLink() should return a verification link', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - // Ensure the user's email is verified. - return getAuth().updateUser(uid, { password: 'password', emailVerified: true }) - .then((userRecord) => { - expect(userRecord.emailVerified).to.be.true; - return getAuth().generateVerifyAndChangeEmailLink(email, newEmail, actionCodeSettings); - }) - .then((link) => { - const code = getActionCode(link); - expect(getContinueUrl(link)).equal(actionCodeSettings.url); - return clientAuth().applyActionCode(code); - }) - .then(() => { - return clientAuth().signInWithEmailAndPassword(newEmail, 'password'); - }) - .then((result) => { - expect(result.user).to.exist; - expect(result.user!.email).to.equal(newEmail); - expect(result.user!.emailVerified).to.be.true; - }); - }); - }); - - describe('Project config management operations', () => { - before(function () { - if (authEmulatorHost) { - this.skip(); // getConfig is not supported in Auth Emulator - } - }); - - after(() => { - getAuth().projectConfigManager().updateProjectConfig({ - passwordPolicyConfig: { - enforcementState: 'OFF', - forceUpgradeOnSignin: false, - constraints: { - requireLowercase: false, - requireNonAlphanumeric: false, - requireNumeric: false, - requireUppercase: false, - maxLength: 4096, - minLength: 6, - } - } - }) - }); - - const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }; - const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - } - ], - }; - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'ENFORCE', - forceUpgradeOnSignin: true, - constraints: { - requireUppercase: true, - requireLowercase: true, - requireNonAlphanumeric: true, - requireNumeric: true, - minLength: 8, - maxLength: 30, - }, - }; - const smsRegionAllowByDefaultConfig: SmsRegionConfig = { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - }; - const smsRegionAllowlistOnlyConfig: SmsRegionConfig = { - allowlistOnly: { - allowedRegions: ['AC', 'AD'], - } - }; - const projectConfigOption1: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowByDefaultConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const projectConfigOption2: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - useAccountDefender: false, - }, - }; - const projectConfigOptionSmsEnabledTotpDisabled: UpdateProjectConfigRequest = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - }; - const expectedProjectConfig1: any = { - smsRegionConfig: smsRegionAllowByDefaultConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const expectedProjectConfig2: any = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - }, - }; - const expectedProjectConfigSmsEnabledTotpDisabled: any = { - smsRegionConfig: smsRegionAllowlistOnlyConfig, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - passwordPolicyConfig: passwordConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.1, - action: 'BLOCK', - }, - ], - }, - }; - - it('updateProjectConfig() should resolve with the updated project config', () => { - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption1) - .then((actualProjectConfig) => { - // ReCAPTCHA keys are generated differently each time. - delete actualProjectConfig.recaptchaConfig?.recaptchaKeys; - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfig1); - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOption2); - }) - .then((actualProjectConfig) => { - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfig2); - return getAuth().projectConfigManager().updateProjectConfig(projectConfigOptionSmsEnabledTotpDisabled); - }) - .then((actualProjectConfig) => { - expect(actualProjectConfig._toJson()).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); - }); - }); - - it('getProjectConfig() should resolve with expected project config', () => { - return getAuth().projectConfigManager().getProjectConfig() - .then((actualConfig) => { - const actualConfigObj = actualConfig._toJson(); - expect(actualConfigObj).to.deep.equal(expectedProjectConfigSmsEnabledTotpDisabled); - }); - }); - }); - - describe('Tenant management operations', () => { - let createdTenantId: string; - const createdTenants: string[] = []; - const mfaSmsEnabledTotpEnabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: { - adjacentIntervals: 5, - }, - }, - ], - }; - const mfaSmsEnabledTotpDisabledConfig: MultiFactorConfig = { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {}, - } - ], - } - const mfaSmsDisabledTotpEnabledConfig: MultiFactorConfig = { - state: 'DISABLED', - factorIds: [], - providerConfigs: [ - { - state: 'ENABLED', - totpProviderConfig: {}, - } - ], - } - const smsRegionAllowByDefaultConfig: SmsRegionConfig = { - allowByDefault: { - disallowedRegions: ['AC', 'AD'], - } - } - const tenantOptions: CreateTenantRequest = { - displayName: 'testTenant1', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - // Add random phone number / code pairs. - testPhoneNumbers: { - '+16505551234': '019287', - '+16505550676': '985235', - }, - }; - const expectedCreatedTenant: any = { - displayName: 'testTenant1', - emailSignInConfig: { - enabled: true, - passwordRequired: true, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - // These test phone numbers will not be checked when running integration - // tests against the emulator suite and are ignored in auth emulator - // altogether. For more information, please refer to this section of the - // auth emulator DD: go/firebase-auth-emulator-dd#heading=h.odk06so2ydjd - testPhoneNumbers: { - '+16505551234': '019287', - '+16505550676': '985235', - }, - }; - const expectedUpdatedTenant: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: false, - passwordRequired: true, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsDisabledTotpEnabledConfig, - // Test phone numbers will not be checked when running integration tests - // against emulator suite. For more information, please refer to: - // go/firebase-auth-emulator-dd#heading=h.odk06so2ydjd - testPhoneNumbers: { - '+16505551234': '123456', - }, - recaptchaConfig: { - emailPasswordEnforcementState: 'AUDIT', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: true, - }, - }; - const expectedUpdatedTenant2: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpEnabledConfig, - smsRegionConfig: smsRegionAllowByDefaultConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: false, - }, - }; - const expectedUpdatedTenantSmsEnabledTotpDisabled: any = { - displayName: 'testTenantUpdated', - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - anonymousSignInEnabled: false, - multiFactorConfig: mfaSmsEnabledTotpDisabledConfig, - smsRegionConfig: smsRegionAllowByDefaultConfig, - recaptchaConfig: { - emailPasswordEnforcementState: 'OFF', - managedRules: [ - { - endScore: 0.3, - action: 'BLOCK', - }, - ], - useAccountDefender: false, - }, - }; - - // https://mochajs.org/ - // Passing arrow functions (aka "lambdas") to Mocha is discouraged. - // Lambdas lexically bind this and cannot access the Mocha context. - before(function () { - /* tslint:disable:no-console */ - if (!cmdArgs.testMultiTenancy) { - // To enable, run: npm run test:integration -- --testMultiTenancy - // By default we skip multi-tenancy as it is a Google Cloud Identity Platform - // feature only and requires to be enabled via the Cloud Console. - console.log(chalk.yellow(' Skipping multi-tenancy tests.')); - this.skip(); - } - /* tslint:enable:no-console */ - }); - - // Delete test tenants at the end of test suite. - after(() => { - const promises: Array> = []; - createdTenants.forEach((tenantId) => { - promises.push( - getAuth().tenantManager().deleteTenant(tenantId) - .catch(() => {/** Ignore. */ })); - }); - return Promise.all(promises); - }); - - it('createTenant() should resolve with a new tenant', () => { - return getAuth().tenantManager().createTenant(tenantOptions) - .then((actualTenant) => { - createdTenantId = actualTenant.tenantId; - createdTenants.push(createdTenantId); - expectedCreatedTenant.tenantId = createdTenantId; - const actualTenantObj = actualTenant._toJson(); - if (authEmulatorHost) { - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedCreatedTenant.testPhoneNumbers; - } - expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); - }); - }); - - it('createTenant() can enable anonymous users', async () => { - const tenant = await getAuth().tenantManager().createTenant({ - displayName: 'testTenantWithAnon', - emailSignInConfig: { - enabled: false, - passwordRequired: true, - }, - anonymousSignInEnabled: true, - }); - createdTenants.push(tenant.tenantId); - - expect(tenant.anonymousSignInEnabled).to.be.true; - }); - - // Sanity check user management + email link generation + custom attribute APIs. - // TODO: Confirm behavior in client SDK when it starts supporting it. - describe('supports user management, email link generation, custom attribute and token revocation APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - let createdUserUid: string; - let lastValidSinceTime: number; - const newUserData = clone(mockUserData); - newUserData.email = generateRandomString(20).toLowerCase() + '@example.com'; - newUserData.phoneNumber = testPhoneNumber; - const importOptions: any = { - hash: { - algorithm: 'HMAC_SHA256', - key: Buffer.from('secret'), - }, - }; - const rawPassword = 'password'; - const rawSalt = 'NaCl'; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete test user at the end of test suite. - after(() => { - // If user successfully created, make sure it is deleted at the end of the test suite. - if (createdUserUid) { - return tenantAwareAuth.deleteUser(createdUserUid) - .catch(() => { - // Ignore error. - }); - } - }); - - it('createUser() should create a user in the expected tenant', () => { - return tenantAwareAuth.createUser(newUserData) - .then((userRecord) => { - createdUserUid = userRecord.uid; - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.email).to.equal(newUserData.email); - expect(userRecord.phoneNumber).to.equal(newUserData.phoneNumber); - }); - }); - - it('setCustomUserClaims() should set custom attributes on the tenant specific user', () => { - return tenantAwareAuth.setCustomUserClaims(createdUserUid, customClaims) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - expect(userRecord.uid).to.equal(createdUserUid); - expect(userRecord.tenantId).to.equal(createdTenantId); - // Confirm custom claims set on the UserRecord. - expect(userRecord.customClaims).to.deep.equal(customClaims); - }); - }); - - it('updateUser() should update the tenant specific user', () => { - return tenantAwareAuth.updateUser(createdUserUid, { - email: updatedEmail, - phoneNumber: updatedPhone, - }) - .then((userRecord) => { - expect(userRecord.uid).to.equal(createdUserUid); - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.email).to.equal(updatedEmail); - expect(userRecord.phoneNumber).to.equal(updatedPhone); - }); - }); - - it('generateEmailVerificationLink() should generate the link for tenant specific user', () => { - // Generate email verification link to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generateEmailVerificationLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('generatePasswordResetLink() should generate the link for tenant specific user', () => { - // Generate password reset link to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generatePasswordResetLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('generateSignInWithEmailLink() should generate the link for tenant specific user', () => { - // Generate link for sign-in to confirm it is generated in the expected - // tenant context. - return tenantAwareAuth.generateSignInWithEmailLink(updatedEmail, actionCodeSettings) - .then((link) => { - // Confirm tenant ID set in link. - expect(getTenantId(link)).equal(createdTenantId); - }); - }); - - it('revokeRefreshTokens() should revoke the tokens for the tenant specific user', () => { - // Revoke refresh tokens. - // On revocation, tokensValidAfterTime will be updated to current time. All tokens issued - // before that time will be rejected. As the underlying backend field is rounded to the nearest - // second, we are subtracting one second. - lastValidSinceTime = new Date().getTime() - 1000; - return tenantAwareAuth.revokeRefreshTokens(createdUserUid) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - expect(userRecord.tokensValidAfterTime).to.exist; - expect(new Date(userRecord.tokensValidAfterTime!).getTime()) - .to.be.greaterThan(lastValidSinceTime); - }); - }); - - it('listUsers() should list tenant specific users', () => { - return tenantAwareAuth.listUsers(100) - .then((listUsersResult) => { - // Confirm expected user returned in the list and all users returned - // belong to the expected tenant. - const allUsersBelongToTenant = - listUsersResult.users.every((user) => user.tenantId === createdTenantId); - expect(allUsersBelongToTenant).to.be.true; - const knownUserInTenant = - listUsersResult.users.some((user) => user.uid === createdUserUid); - expect(knownUserInTenant).to.be.true; - }); - }); - - it('deleteUser() should delete the tenant specific user', () => { - return tenantAwareAuth.deleteUser(createdUserUid) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-not-found'); - }); - }); - - it('importUsers() should upload a user to the specified tenant', () => { - const currentHashKey = importOptions.hash.key.toString('utf8'); - const passwordHash = - crypto.createHmac('sha256', currentHashKey).update(rawPassword + rawSalt).digest(); - const importUserRecord: any = { - uid: createdUserUid, - email: createdUserUid + '@example.com', - passwordHash, - passwordSalt: Buffer.from(rawSalt), - }; - return tenantAwareAuth.importUsers([importUserRecord], importOptions) - .then(() => { - return tenantAwareAuth.getUser(createdUserUid); - }) - .then((userRecord) => { - // Confirm user uploaded successfully. - expect(userRecord.tenantId).to.equal(createdTenantId); - expect(userRecord.uid).to.equal(createdUserUid); - }); - }); - - it('createCustomToken() mints a JWT that can be used to sign in tenant users', async () => { - try { - clientAuth().tenantId = createdTenantId; - - const customToken = await tenantAwareAuth.createCustomToken('uid1'); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.not.be.null; - const idToken = await user!.getIdToken(); - const token = await tenantAwareAuth.verifyIdToken(idToken); - - expect(token.uid).to.equal('uid1'); - expect(token.firebase.tenant).to.equal(createdTenantId); - } finally { - clientAuth().tenantId = null; - } - }); - }); - - // Sanity check OIDC/SAML config management API. - describe('SAML management APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - const authProviderConfig = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME1', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID1', - ssoURL: 'https://example.com/login1', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID1', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME3', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID3', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete SAML configuration at the end of test suite. - after(() => { - if (tenantAwareAuth) { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch(() => { - // Ignore error. - }); - } - }); - - it('should support CRUD operations', function () { - // TODO(lisajian): Unskip once auth emulator supports OIDC/SAML - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - return tenantAwareAuth.createProviderConfig(authProviderConfig) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); - }) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.updateProviderConfig( - authProviderConfig.providerId, modifiedConfigOptions); - }) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); - }) - .then(() => { - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - describe('OIDC management APIs', () => { - let tenantAwareAuth: TenantAwareAuth; - const authProviderConfig = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME1', - enabled: true, - issuer: 'https://oidc.com/issuer1', - clientId: 'CLIENT_ID1', - responseType: { - idToken: true, - }, - }; - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: true, - }, - }; - const modifiedConfigOptions = { - providerId: authProviderConfig.providerId, - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - - before(function () { - if (!createdTenantId) { - this.skip(); - } else { - tenantAwareAuth = getAuth().tenantManager().authForTenant(createdTenantId); - } - }); - - // Delete OIDC configuration at the end of test suite. - after(() => { - if (tenantAwareAuth) { - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId) - .catch(() => { - // Ignore error. - }); - } - }); - - it('should support CRUD operations', function () { - // TODO(lisajian): Unskip once auth emulator supports OIDC/SAML - if (authEmulatorHost) { - return this.skip(); // Not yet supported in Auth Emulator. - } - return tenantAwareAuth.createProviderConfig(authProviderConfig) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId); - }) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig, config); - return tenantAwareAuth.updateProviderConfig( - authProviderConfig.providerId, deltaChanges); - }) - .then((config) => { - assertDeepEqualUnordered(modifiedConfigOptions, config); - return tenantAwareAuth.deleteProviderConfig(authProviderConfig.providerId); - }) - .then(() => { - return tenantAwareAuth.getProviderConfig(authProviderConfig.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - it('getTenant() should resolve with expected tenant', () => { - return getAuth().tenantManager().getTenant(createdTenantId) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - if (authEmulatorHost) { - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedCreatedTenant.testPhoneNumbers; - } - expect(actualTenantObj).to.deep.equal(expectedCreatedTenant); - }); - }); - - it('updateTenant() should resolve with the updated tenant', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - expectedUpdatedTenant2.tenantId = createdTenantId; - const updatedOptions: UpdateTenantRequest = { - displayName: expectedUpdatedTenant.displayName, - emailSignInConfig: { - enabled: false, - }, - multiFactorConfig: deepCopy(expectedUpdatedTenant.multiFactorConfig), - testPhoneNumbers: deepCopy(expectedUpdatedTenant.testPhoneNumbers), - recaptchaConfig: deepCopy(expectedUpdatedTenant.recaptchaConfig), - }; - const updatedOptions2: UpdateTenantRequest = { - emailSignInConfig: { - enabled: true, - passwordRequired: false, - }, - multiFactorConfig: deepCopy(expectedUpdatedTenant2.multiFactorConfig), - // Test clearing of phone numbers. - testPhoneNumbers: null, - smsRegionConfig: deepCopy(expectedUpdatedTenant2.smsRegionConfig), - recaptchaConfig: deepCopy(expectedUpdatedTenant2.recaptchaConfig), - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant); - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2); - }) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions) - .then((actualTenant) => { - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenant); - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2); - }) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should not update tenant when SMS region config is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updatedOptions2: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - smsRegionConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should not update MFA-related config of tenant when MultiFactorConfig is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updateRequestNoMfaConfig: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - multiFactorConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Configuring test phone numbers are not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestNoMfaConfig) - }); - - it('updateTenant() should not update tenant reCAPTCHA config is undefined', () => { - expectedUpdatedTenant.tenantId = createdTenantId; - const updatedOptions2: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - recaptchaConfig: undefined, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenant2.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenant2); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updatedOptions2) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenant2); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - it('updateTenant() should not disable SMS MFA when TOTP is disabled', () => { - expectedUpdatedTenantSmsEnabledTotpDisabled.tenantId = createdTenantId; - const updateRequestSMSEnabledTOTPDisabled: UpdateTenantRequest = { - displayName: expectedUpdatedTenant2.displayName, - multiFactorConfig: { - state: 'ENABLED', - factorIds: ['phone'], - providerConfigs: [ - { - state: 'DISABLED', - totpProviderConfig: {} - }, - ], - }, - }; - if (authEmulatorHost) { - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) - .then((actualTenant) => { - const actualTenantObj = actualTenant._toJson(); - // Configuring test phone numbers are not supported in Auth Emulator - delete (actualTenantObj as { testPhoneNumbers?: Record }).testPhoneNumbers; - delete expectedUpdatedTenantSmsEnabledTotpDisabled.testPhoneNumbers; - expect(actualTenantObj).to.deep.equal(expectedUpdatedTenantSmsEnabledTotpDisabled); - }); - } - return getAuth().tenantManager().updateTenant(createdTenantId, updateRequestSMSEnabledTOTPDisabled) - .then((actualTenant) => { - // response from backend ignores account defender status is recaptcha status is OFF. - const expectedUpdatedTenantCopy = deepCopy(expectedUpdatedTenantSmsEnabledTotpDisabled); - delete expectedUpdatedTenantCopy.recaptchaConfig.useAccountDefender; - expect(actualTenant._toJson()).to.deep.equal(expectedUpdatedTenantCopy); - }); - }); - - it('updateTenant() should be able to enable/disable anon provider', async () => { - const tenantManager = getAuth().tenantManager(); - let tenant = await tenantManager.createTenant({ - displayName: 'testTenantUpdateAnon', - }); - createdTenants.push(tenant.tenantId); - expect(tenant.anonymousSignInEnabled).to.be.false; - - tenant = await tenantManager.updateTenant(tenant.tenantId, { - anonymousSignInEnabled: true, - }); - expect(tenant.anonymousSignInEnabled).to.be.true; - - tenant = await tenantManager.updateTenant(tenant.tenantId, { - anonymousSignInEnabled: false, - }); - expect(tenant.anonymousSignInEnabled).to.be.false; - }); - - it('updateTenant() should enforce password policies on tenant', () => { - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'ENFORCE', - forceUpgradeOnSignin: true, - constraints: { - requireLowercase: true, - requireNonAlphanumeric: true, - requireNumeric: true, - requireUppercase: true, - minLength: 6, - maxLength: 30, - }, - }; - return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) - .then((actualTenant) => { - expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(passwordConfig as any); - }); - }); - - it('updateTenant() should disable password policies on tenant', () => { - const passwordConfig: PasswordPolicyConfig = { - enforcementState: 'OFF', - }; - const expectedPasswordConfig: any = { - enforcementState: 'OFF', - forceUpgradeOnSignin: false, - constraints: { - requireLowercase: false, - requireNonAlphanumeric: false, - requireNumeric: false, - requireUppercase: false, - minLength: 6, - maxLength: 4096, - }, - }; - return getAuth().tenantManager().updateTenant(createdTenantId, { passwordPolicyConfig: passwordConfig }) - .then((actualTenant) => { - expect(deepCopy(actualTenant.passwordPolicyConfig)).to.deep.equal(expectedPasswordConfig); - }); - }); - - it('listTenants() should resolve with expected number of tenants', () => { - const allTenantIds: string[] = []; - const tenantOptions2 = deepCopy(tenantOptions); - tenantOptions2.displayName = 'testTenant2'; - const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { - return getAuth().tenantManager().listTenants(100, nextPageToken) - .then((result) => { - result.tenants.forEach((tenant) => { - tenantIds.push(tenant.tenantId); - }); - if (result.pageToken) { - return listAllTenantIds(tenantIds, result.pageToken); - } - }); - }; - return getAuth().tenantManager().createTenant(tenantOptions2) - .then((actualTenant) => { - createdTenants.push(actualTenant.tenantId); - // Test listTenants returns the expected tenants. - return listAllTenantIds(allTenantIds); - }) - .then(() => { - // All created tenants should be in the list of tenants. - createdTenants.forEach((tenantId) => { - expect(allTenantIds).to.contain(tenantId); - }); - }); - }); - - it('deleteTenant() should successfully delete the provided tenant', () => { - const allTenantIds: string[] = []; - const listAllTenantIds = (tenantIds: string[], nextPageToken?: string): Promise => { - return getAuth().tenantManager().listTenants(100, nextPageToken) - .then((result) => { - result.tenants.forEach((tenant) => { - tenantIds.push(tenant.tenantId); - }); - if (result.pageToken) { - return listAllTenantIds(tenantIds, result.pageToken); - } - }); - }; - - return getAuth().tenantManager().deleteTenant(createdTenantId) - .then(() => { - // Use listTenants() instead of getTenant() to check that the tenant - // is no longer present, because Auth Emulator implicitly creates the - // tenant in getTenant() when it is not found - return listAllTenantIds(allTenantIds); - }) - .then(() => { - expect(allTenantIds).to.not.contain(createdTenantId); - }); - }); - }); - - describe('SAML configuration operations', () => { - const authProviderConfig1 = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME1', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID1', - ssoURL: 'https://example.com/login1', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID1', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - const authProviderConfig2 = { - providerId: randomSamlProviderId(), - displayName: 'SAML_DISPLAY_NAME2', - enabled: true, - idpEntityId: 'IDP_ENTITY_ID2', - ssoURL: 'https://example.com/login2', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID2', - callbackURL: 'https://projectId.firebaseapp.com/__/auth/handler', - enableRequestSigning: true, - }; - - const removeTempConfigs = (): Promise => { - return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), - ]); - }; - - // Clean up temp configurations used for test. - before(function () { - if (authEmulatorHost) { - return this.skip(); // Not implemented. - } - return removeTempConfigs().then(() => getAuth().createProviderConfig(authProviderConfig1)); - }); - - after(() => { - return removeTempConfigs(); - }); - - it('createProviderConfig() successfully creates a SAML config', () => { - return getAuth().createProviderConfig(authProviderConfig2) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig2, config); - }); - }); - - it('getProviderConfig() successfully returns the expected SAML config', () => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig1, config); - }); - }); - - it('listProviderConfig() successfully returns the list of SAML providers', () => { - const configs: AuthProviderConfig[] = []; - const listProviders: any = (type: 'saml' | 'oidc', maxResults?: number, pageToken?: string) => { - return getAuth().listProviderConfigs({ type, maxResults, pageToken }) - .then((result) => { - result.providerConfigs.forEach((config: AuthProviderConfig) => { - configs.push(config); - }); - if (result.pageToken) { - return listProviders(type, maxResults, result.pageToken); - } - }); - }; - // In case the project already has existing providers, list all configurations and then - // check the 2 test configs are available. - return listProviders('saml', 1) - .then(() => { - let index1 = 0; - let index2 = 0; - for (let i = 0; i < configs.length; i++) { - if (configs[i].providerId === authProviderConfig1.providerId) { - index1 = i; - } else if (configs[i].providerId === authProviderConfig2.providerId) { - index2 = i; - } - } - assertDeepEqualUnordered(authProviderConfig1, configs[index1]); - assertDeepEqualUnordered(authProviderConfig2, configs[index2]); - }); - }); - - it('updateProviderConfig() successfully overwrites a SAML config', () => { - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME3', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[1].public], - rpEntityId: 'RP_ENTITY_ID3', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, modifiedConfigOptions) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig1.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - }); - }); - - it('updateProviderConfig() successfully partially modifies a SAML config', () => { - const deltaChanges = { - displayName: 'SAML_DISPLAY_NAME4', - x509Certificates: [mocks.x509CertPairs[0].public], - // Note, currently backend has a bug where error is thrown when callbackURL is not - // passed event though it is not required. Fix is on the way. - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - rpEntityId: 'RP_ENTITY_ID4', - }; - // Only above fields should be modified. - const modifiedConfigOptions = { - displayName: 'SAML_DISPLAY_NAME4', - enabled: false, - idpEntityId: 'IDP_ENTITY_ID3', - ssoURL: 'https://example.com/login3', - x509Certificates: [mocks.x509CertPairs[0].public], - rpEntityId: 'RP_ENTITY_ID4', - callbackURL: 'https://projectId3.firebaseapp.com/__/auth/handler', - enableRequestSigning: false, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges) - .then((config) => { - const modifiedConfig = deepExtend( - { providerId: authProviderConfig1.providerId }, modifiedConfigOptions); - assertDeepEqualUnordered(modifiedConfig, config); - }); - }); - - it('deleteProviderConfig() successfully deletes an existing SAML config', () => { - return getAuth().deleteProviderConfig(authProviderConfig1.providerId).then(() => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - describe('OIDC configuration operations', () => { - const authProviderConfig1 = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME1', - enabled: true, - issuer: 'https://oidc.com/issuer1', - clientId: 'CLIENT_ID1', - responseType: { - idToken: true, - }, - }; - const authProviderConfig2 = { - providerId: randomOidcProviderId(), - displayName: 'OIDC_DISPLAY_NAME2', - enabled: true, - issuer: 'https://oidc.com/issuer2', - clientId: 'CLIENT_ID2', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - - const removeTempConfigs = (): Promise => { - return Promise.all([ - getAuth().deleteProviderConfig(authProviderConfig1.providerId).catch(() => {/* empty */ }), - getAuth().deleteProviderConfig(authProviderConfig2.providerId).catch(() => {/* empty */ }), - ]); - }; - - // Clean up temp configurations used for test. - before(function () { - if (authEmulatorHost) { - return this.skip(); // Not implemented. - } - return removeTempConfigs().then(() => getAuth().createProviderConfig(authProviderConfig1)); - }); - - after(() => { - return removeTempConfigs(); - }); - - it('createProviderConfig() successfully creates an OIDC config', () => { - return getAuth().createProviderConfig(authProviderConfig2) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig2, config); - }); - }); - - it('getProviderConfig() successfully returns the expected OIDC config', () => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .then((config) => { - assertDeepEqualUnordered(authProviderConfig1, config); - }); - }); - - it('listProviderConfig() successfully returns the list of OIDC providers', () => { - const configs: AuthProviderConfig[] = []; - const listProviders: any = (type: 'saml' | 'oidc', maxResults?: number, pageToken?: string) => { - return getAuth().listProviderConfigs({ type, maxResults, pageToken }) - .then((result) => { - result.providerConfigs.forEach((config: AuthProviderConfig) => { - configs.push(config); - }); - if (result.pageToken) { - return listProviders(type, maxResults, result.pageToken); - } - }); - }; - // In case the project already has existing providers, list all configurations and then - // check the 2 test configs are available. - return listProviders('oidc', 1) - .then(() => { - let index1 = 0; - let index2 = 0; - for (let i = 0; i < configs.length; i++) { - if (configs[i].providerId === authProviderConfig1.providerId) { - index1 = i; - } else if (configs[i].providerId === authProviderConfig2.providerId) { - index2 = i; - } - } - assertDeepEqualUnordered(authProviderConfig1, configs[index1]); - assertDeepEqualUnordered(authProviderConfig2, configs[index2]); - }); - }); - - it('updateProviderConfig() successfully partially modifies an OIDC config', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: true, - }, - }; - // Only above fields should be modified. - const modifiedConfigOptions = { - providerId: authProviderConfig1.providerId, - displayName: 'OIDC_DISPLAY_NAME3', - enabled: false, - issuer: 'https://oidc.com/issuer3', - clientId: 'CLIENT_ID3', - clientSecret: 'CLIENT_SECRET', - responseType: { - code: true, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges) - .then((config) => { - assertDeepEqualUnordered(modifiedConfigOptions, config); - }); - }); - - it('updateProviderConfig() with invalid oauth response type should be rejected', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME4', - enabled: false, - issuer: 'https://oidc.com/issuer4', - clientId: 'CLIENT_ID4', - clientSecret: 'CLIENT_SECRET', - responseType: { - idToken: false, - code: false, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges). - should.eventually.be.rejected.and.have.property('code', 'auth/invalid-oauth-responsetype'); - }); - - it('updateProviderConfig() code flow with no client secret should be rejected', () => { - const deltaChanges = { - displayName: 'OIDC_DISPLAY_NAME5', - enabled: false, - issuer: 'https://oidc.com/issuer5', - clientId: 'CLIENT_ID5', - responseType: { - idToken: false, - code: true, - }, - }; - return getAuth().updateProviderConfig(authProviderConfig1.providerId, deltaChanges). - should.eventually.be.rejected.and.have.property('code', 'auth/missing-oauth-client-secret'); - }); - - it('deleteProviderConfig() successfully deletes an existing OIDC config', () => { - return getAuth().deleteProviderConfig(authProviderConfig1.providerId).then(() => { - return getAuth().getProviderConfig(authProviderConfig1.providerId) - .should.eventually.be.rejected.and.have.property('code', 'auth/configuration-not-found'); - }); - }); - }); - - it('deleteUser() deletes the user with the given UID', () => { - return Promise.all([ - getAuth().deleteUser(newUserUid), - getAuth().deleteUser(uidFromCreateUserWithoutUid), - ]).should.eventually.be.fulfilled; - }); - - describe('deleteUsers()', () => { - it('deletes users', async () => { - const uid1 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid2 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid3 = await getAuth().createUser({}).then((ur) => ur.uid); - const ids = [{ uid: uid1 }, { uid: uid2 }, { uid: uid3 }]; - - return deleteUsersWithDelay([uid1, uid2, uid3]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(3); - expect(deleteUsersResult.failureCount).to.equal(0); - expect(deleteUsersResult.errors).to.have.length(0); - - return getAuth().getUsers(ids); - }) - .then((getUsersResult) => { - expect(getUsersResult.users).to.have.length(0); - expect(getUsersResult.notFound).to.have.deep.members(ids); - }); - }); - - it('deletes users that exist even when non-existing users also specified', async () => { - const uid1 = await getAuth().createUser({}).then((ur) => ur.uid); - const uid2 = 'uid-that-doesnt-exist'; - const ids = [{ uid: uid1 }, { uid: uid2 }]; - - return deleteUsersWithDelay([uid1, uid2]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(2); - expect(deleteUsersResult.failureCount).to.equal(0); - expect(deleteUsersResult.errors).to.have.length(0); - - return getAuth().getUsers(ids); - }) - .then((getUsersResult) => { - expect(getUsersResult.users).to.have.length(0); - expect(getUsersResult.notFound).to.have.deep.members(ids); - }); - }); - - it('is idempotent', async () => { - const uid = await getAuth().createUser({}).then((ur) => ur.uid); - - return deleteUsersWithDelay([uid]) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(1); - expect(deleteUsersResult.failureCount).to.equal(0); - }) - // Delete the user again, ensuring that everything still counts as a success. - .then(() => deleteUsersWithDelay([uid])) - .then((deleteUsersResult) => { - expect(deleteUsersResult.successCount).to.equal(1); - expect(deleteUsersResult.failureCount).to.equal(0); - }); - }); - }); - - describe('createSessionCookie()', () => { - let expectedExp: number; - let expectedIat: number; - const expiresIn = 24 * 60 * 60 * 1000; - let payloadClaims: any; - let currentIdToken: string; - const uid = sessionCookieUids[0]; - const uid2 = sessionCookieUids[1]; - const uid3 = sessionCookieUids[2]; - const uid4 = sessionCookieUids[3]; - - it('creates a valid Firebase session cookie', () => { - return getAuth().createCustomToken(uid, { admin: true, groupId: '1234' }) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - return getAuth().verifyIdToken(idToken); - }).then((decodedIdTokenClaims) => { - expectedExp = Math.floor((new Date().getTime() + expiresIn) / 1000); - payloadClaims = decodedIdTokenClaims; - payloadClaims.iss = payloadClaims.iss.replace( - 'securetoken.google.com', 'session.firebase.google.com'); - delete payloadClaims.exp; - delete payloadClaims.iat; - expectedIat = Math.floor(new Date().getTime() / 1000); - // One day long session cookie. - return getAuth().createSessionCookie(currentIdToken, { expiresIn }); - }) - .then((sessionCookie) => getAuth().verifySessionCookie(sessionCookie)) - .then((decodedIdToken) => { - // Check for expected expiration with +/-5 seconds of variation. - expect(decodedIdToken.exp).to.be.within(expectedExp - 5, expectedExp + 5); - expect(decodedIdToken.iat).to.be.within(expectedIat - 5, expectedIat + 5); - // Not supported in ID token, - delete decodedIdToken.nonce; - // exp and iat may vary depending on network connection latency. - delete (decodedIdToken as any).exp; - delete (decodedIdToken as any).iat; - expect(decodedIdToken).to.deep.equal(payloadClaims); - }); - }); - - it('creates a revocable session cookie', () => { - let currentSessionCookie: string; - return getAuth().createCustomToken(uid2) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - // One day long session cookie. - return getAuth().createSessionCookie(idToken, { expiresIn }); - }) - .then((sessionCookie) => { - currentSessionCookie = sessionCookie; - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(uid2), - ), 1000)); - }) - .then(() => { - const verifyingSessionCookie = getAuth().verifySessionCookie(currentSessionCookie); - if (authEmulatorHost) { - // Check revocation is forced in emulator-mode and this should throw. - return verifyingSessionCookie.should.eventually.be.rejected; - } else { - // verifyIdToken without checking revocation should still succeed. - return verifyingSessionCookie.should.eventually.be.fulfilled; - } - }) - .then(() => { - return getAuth().verifySessionCookie(currentSessionCookie, true) - .should.eventually.be.rejected.and.have.property('code', 'auth/session-cookie-revoked'); - }); - }); - - it('fails when called with a revoked ID token', () => { - return getAuth().createCustomToken(uid3, { admin: true, groupId: '1234' }) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - currentIdToken = idToken; - return new Promise((resolve) => setTimeout(() => resolve( - getAuth().revokeRefreshTokens(uid3), - ), 1000)); - }) - .then(() => { - return getAuth().createSessionCookie(currentIdToken, { expiresIn }) - .should.eventually.be.rejected.and.have.property('code', 'auth/id-token-expired'); - }); - }); - - it('fails when called with user disabled', async () => { - const expiresIn = 24 * 60 * 60 * 1000; - const customToken = await getAuth().createCustomToken(uid4, { admin: true, groupId: '1234' }); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.exist; - - const idToken = await user!.getIdToken(); - const decodedIdTokenClaims = await getAuth().verifyIdToken(idToken); - expect(decodedIdTokenClaims.uid).to.be.equal(uid4); - - const sessionCookie = await getAuth().createSessionCookie(idToken, { expiresIn }); - const decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); - expect(decodedIdToken.uid).to.equal(uid4); - - const userRecord = await getAuth().updateUser(uid4, { disabled: true }); - // Ensure disabled field has been updated. - expect(userRecord.uid).to.equal(uid4); - expect(userRecord.disabled).to.equal(true); - - return getAuth().createSessionCookie(idToken, { expiresIn }) - .should.eventually.be.rejected.and.have.property('code', 'auth/user-disabled'); - }); - }); - - describe('verifySessionCookie()', () => { - const uid = sessionCookieUids[0]; - it('fails when called with an invalid session cookie', () => { - return getAuth().verifySessionCookie('invalid-token') - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - - it('fails when called with a Firebase ID token', () => { - return getAuth().createCustomToken(uid) - .then((customToken) => clientAuth().signInWithCustomToken(customToken)) - .then(({ user }) => { - expect(user).to.exist; - return user!.getIdToken(); - }) - .then((idToken) => { - return getAuth().verifySessionCookie(idToken) - .should.eventually.be.rejected.and.have.property('code', 'auth/argument-error'); - }); - }); - - it('fails with checkRevoked set to true and corresponding user disabled', async () => { - const expiresIn = 24 * 60 * 60 * 1000; - const customToken = await getAuth().createCustomToken(uid, { admin: true, groupId: '1234' }); - const { user } = await clientAuth().signInWithCustomToken(customToken); - expect(user).to.exist; - - const idToken = await user!.getIdToken(); - const decodedIdTokenClaims = await getAuth().verifyIdToken(idToken); - expect(decodedIdTokenClaims.uid).to.be.equal(uid); - - const sessionCookie = await getAuth().createSessionCookie(idToken, { expiresIn }); - let decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, true); - expect(decodedIdToken.uid).to.equal(uid); - - const userRecord = await getAuth().updateUser(uid, { disabled: true }); - // Ensure disabled field has been updated. - expect(userRecord.uid).to.equal(uid); - expect(userRecord.disabled).to.equal(true); - - try { - // If it is in emulator mode, a user-disabled error will be thrown. - decodedIdToken = await getAuth().verifySessionCookie(sessionCookie, false); - expect(decodedIdToken.uid).to.equal(uid); - } catch (error) { - if (authEmulatorHost) { - expect(error).to.have.property('code', 'auth/user-disabled'); - } else { - throw error; - } - } - - try { - await getAuth().verifySessionCookie(sessionCookie, true); - } catch (error) { - expect(error).to.have.property('code', 'auth/user-disabled'); - } - }); - }); - - describe('importUsers()', () => { - const randomUid = 'import_' + generateRandomString(20).toLowerCase(); - let importUserRecord: UserImportRecord; - const rawPassword = 'password'; - const rawSalt = 'NaCl'; - // Simulate a user stored using SCRYPT being migrated to Firebase Auth via importUsers. - // Obtained from https://github.com/firebase/scrypt. - const scryptHashKey = 'jxspr8Ki0RYycVU8zykbdLGjFQ3McFUH0uiiTvC8pVMXAn210wjLNmdZ' + - 'JzxUECKbm0QsEmYUSDzZvpjeJ9WmXA=='; - const scryptPasswordHash = 'V358E8LdWJXAO7muq0CufVpEOXaj8aFiC7T/rcaGieN04q/ZPJ0' + - '8WhJEHGjj9lz/2TT+/86N5VjVoc5DdBhBiw=='; - const scryptHashOptions = { - hash: { - algorithm: 'SCRYPT', - key: Buffer.from(scryptHashKey, 'base64'), - saltSeparator: Buffer.from('Bw==', 'base64'), - rounds: 8, - memoryCost: 14, - }, - }; - - afterEach(() => { - return safeDelete(randomUid); - }); - - const fixtures: UserImportTest[] = [ - { - name: 'HMAC_SHA256', - importOptions: { - hash: { - algorithm: 'HMAC_SHA256', - key: Buffer.from('secret'), - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - expect(userImportTest.importOptions.hash.key).to.exist; - const currentHashKey = userImportTest.importOptions.hash.key!.toString('utf8'); - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return crypto.createHmac('sha256', currentHashKey) - .update(currentRawPassword + currentRawSalt).digest(); - }, - rawPassword, - rawSalt, - }, - { - name: 'SHA256', - importOptions: { - hash: { - algorithm: 'SHA256', - rounds: 1, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return crypto.createHash('sha256').update(currentRawSalt + currentRawPassword).digest(); - }, - rawPassword, - rawSalt, - }, - { - name: 'MD5', - importOptions: { - hash: { - algorithm: 'MD5', - rounds: 0, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - const currentRawSalt = userImportTest.rawSalt; - return Buffer.from(crypto.createHash('md5') - .update(currentRawSalt + currentRawPassword).digest('hex')); - }, - rawPassword, - rawSalt, - }, - { - name: 'BCRYPT', - importOptions: { - hash: { - algorithm: 'BCRYPT', - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - return Buffer.from(bcrypt.hashSync(userImportTest.rawPassword, 10)); - }, - rawPassword, - }, - { - name: 'STANDARD_SCRYPT', - importOptions: { - hash: { - algorithm: 'STANDARD_SCRYPT', - memoryCost: 1024, - parallelization: 16, - blockSize: 8, - derivedKeyLength: 64, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - - expect(userImportTest.rawSalt).to.exist; - const currentRawSalt = userImportTest.rawSalt!; - - expect(userImportTest.importOptions.hash.memoryCost).to.exist; - const N = userImportTest.importOptions.hash.memoryCost!; - - expect(userImportTest.importOptions.hash.blockSize).to.exist; - const r = userImportTest.importOptions.hash.blockSize!; - - expect(userImportTest.importOptions.hash.parallelization).to.exist; - const p = userImportTest.importOptions.hash.parallelization!; - - expect(userImportTest.importOptions.hash.derivedKeyLength).to.exist; - const dkLen = userImportTest.importOptions.hash.derivedKeyLength!; - - return Buffer.from( - crypto.scryptSync( - currentRawPassword, - Buffer.from(currentRawSalt), - dkLen, - { - N, r, p, - })); - }, - rawPassword, - rawSalt, - }, - { - name: 'PBKDF2_SHA256', - importOptions: { - hash: { - algorithm: 'PBKDF2_SHA256', - rounds: 100000, - }, - } as any, - computePasswordHash: (userImportTest: UserImportTest): Buffer => { - const currentRawPassword = userImportTest.rawPassword; - expect(userImportTest.rawSalt).to.exist; - const currentRawSalt = userImportTest.rawSalt!; - expect(userImportTest.importOptions.hash.rounds).to.exist; - const currentRounds = userImportTest.importOptions.hash.rounds!; - return crypto.pbkdf2Sync( - currentRawPassword, currentRawSalt, currentRounds, 64, 'sha256'); - }, - rawPassword, - rawSalt, - }, - { - name: 'SCRYPT', - importOptions: scryptHashOptions as any, - computePasswordHash: (): Buffer => { - return Buffer.from(scryptPasswordHash, 'base64'); - }, - rawPassword, - rawSalt, - }, - ]; - - fixtures.forEach((fixture) => { - it(`successfully imports users with ${fixture.name} to Firebase Auth.`, function () { - if (authEmulatorHost) { - return this.skip(); // Auth Emulator does not support real hashes. - } - importUserRecord = { - uid: randomUid, - email: randomUid + '@example.com', - }; - importUserRecord.passwordHash = fixture.computePasswordHash(fixture); - if (typeof fixture.rawSalt !== 'undefined') { - importUserRecord.passwordSalt = Buffer.from(fixture.rawSalt); - } - return testImportAndSignInUser( - importUserRecord, fixture.importOptions, fixture.rawPassword) - .should.eventually.be.fulfilled; - - }); - }); - - it('successfully imports users with multiple OAuth providers', () => { - const uid = randomUid; - const email = uid + '@example.com'; - const now = new Date(1476235905000).toUTCString(); - const photoURL = 'http://www.example.com/' + uid + '/photo.png'; - importUserRecord = { - uid, - email, - emailVerified: true, - displayName: 'Test User', - photoURL, - phoneNumber: '+15554446666', - disabled: false, - customClaims: { admin: true }, - metadata: { - lastSignInTime: now, - creationTime: now, - // TODO(rsgowman): Enable once importing users supports lastRefreshTime - //lastRefreshTime: now, - }, - providerData: [ - { - uid: uid + '-facebook', - displayName: 'Facebook User', - email, - photoURL: photoURL + '?providerId=facebook.com', - providerId: 'facebook.com', - }, - { - uid: uid + '-twitter', - displayName: 'Twitter User', - photoURL: photoURL + '?providerId=twitter.com', - providerId: 'twitter.com', - }, - ], - }; - uids.push(importUserRecord.uid); - return getAuth().importUsers([importUserRecord]) - .then((result) => { - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - return getAuth().getUser(uid); - }).then((userRecord) => { - // The phone number provider will be appended to the list of accounts. - importUserRecord.providerData?.push({ - uid: importUserRecord.phoneNumber!, - providerId: 'phone', - phoneNumber: importUserRecord.phoneNumber!, - }); - // The lastRefreshTime should be set to null - type Writable = { - -readonly [k in keyof UserMetadata]: UserMetadata[k]; - }; - (importUserRecord.metadata as Writable).lastRefreshTime = null; - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - for (const key of Object.keys(importUserRecord)) { - expect(JSON.stringify(actualUserRecord[key])) - .to.be.equal(JSON.stringify((importUserRecord as any)[key])); - } - }); - }); - - it('successfully imports users with enrolled second factors', function () { - if (authEmulatorHost) { - return this.skip(); // Not yet implemented. - } - const uid = generateRandomString(20).toLowerCase(); - const email = uid + '@example.com'; - const now = new Date(1476235905000).toUTCString(); - const enrolledFactors: UpdatePhoneMultiFactorInfoRequest[] = [ - { - uid: 'mfaUid1', - phoneNumber: '+16505550001', - displayName: 'Work phone number', - factorId: 'phone', - enrollmentTime: now, - }, - { - uid: 'mfaUid2', - phoneNumber: '+16505550002', - displayName: 'Personal phone number', - factorId: 'phone', - enrollmentTime: now, - }, - ]; - - importUserRecord = { - uid, - email, - emailVerified: true, - displayName: 'Test User', - disabled: false, - metadata: { - lastSignInTime: now, - creationTime: now, - }, - providerData: [ - { - uid: uid + '-facebook', - displayName: 'Facebook User', - email, - providerId: 'facebook.com', - }, - ], - multiFactor: { - enrolledFactors, - }, - }; - uids.push(importUserRecord.uid); - - return getAuth().importUsers([importUserRecord]) - .then((result) => { - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - return getAuth().getUser(uid); - }).then((userRecord) => { - // Confirm second factors added to user. - const actualUserRecord: { [key: string]: any } = userRecord._toJson(); - expect(actualUserRecord.multiFactor.enrolledFactors.length).to.equal(2); - expect(actualUserRecord.multiFactor.enrolledFactors) - .to.deep.equal(importUserRecord.multiFactor?.enrolledFactors); - }).should.eventually.be.fulfilled; - }); - - it('fails when invalid users are provided', () => { - const users = [ - { uid: generateRandomString(20).toLowerCase(), email: 'invalid' }, - { uid: generateRandomString(20).toLowerCase(), emailVerified: 'invalid' } as any, - ]; - return getAuth().importUsers(users) - .then((result) => { - expect(result.successCount).to.equal(0); - expect(result.failureCount).to.equal(2); - expect(result.errors.length).to.equal(2); - expect(result.errors[0].index).to.equal(0); - expect(result.errors[0].error.code).to.equals('auth/invalid-email'); - expect(result.errors[1].index).to.equal(1); - expect(result.errors[1].error.code).to.equals('auth/invalid-email-verified'); - }); - }); - - it('fails when users with invalid phone numbers are provided', function () { - if (authEmulatorHost) { - // Auth Emulator's phoneNumber validation is also lax and won't throw. - return this.skip(); - } - const users = [ - // These phoneNumbers passes local (lax) validator but fails remotely. - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1error' }, - { uid: generateRandomString(20).toLowerCase(), phoneNumber: '+1invalid' }, - ]; - return getAuth().importUsers(users) - .then((result) => { - expect(result.successCount).to.equal(0); - expect(result.failureCount).to.equal(2); - expect(result.errors.length).to.equal(2); - expect(result.errors[0].index).to.equal(0); - expect(result.errors[0].error.code).to.equals('auth/invalid-user-import'); - expect(result.errors[1].index).to.equal(1); - expect(result.errors[1].error.code).to.equals('auth/invalid-user-import'); - }); - }); - }); -}); - -/** - * Imports the provided user record with the specified hashing options and then - * validates the import was successful by signing in to the imported account using - * the corresponding plain text password. - * @param importUserRecord The user record to import. - * @param importOptions The import hashing options. - * @param rawPassword The plain unhashed password string. - * @retunr A promise that resolved on success. - */ -function testImportAndSignInUser( - importUserRecord: UserImportRecord, - importOptions: any, - rawPassword: string): Promise { - const users = [importUserRecord]; - // Import the user record. - return getAuth().importUsers(users, importOptions) - .then((result) => { - // Verify the import result. - expect(result.failureCount).to.equal(0); - expect(result.successCount).to.equal(1); - expect(result.errors.length).to.equal(0); - // Sign in with an email and password to the imported account. - return clientAuth().signInWithEmailAndPassword(users[0].email!, rawPassword); - }) - .then(({ user }) => { - // Confirm successful sign-in. - expect(user).to.exist; - expect(user!.email).to.equal(users[0].email); - expect(user!.providerData[0]).to.exist; - expect(user!.providerData[0]!.providerId).to.equal('password'); - }); -} - -/** - * Helper function that deletes the user with the specified phone number - * if it exists. - * @param phoneNumber The phone number of the user to delete. - * @return A promise that resolves when the user is deleted - * or is found not to exist. - */ -function deletePhoneNumberUser(phoneNumber: string): Promise { - return getAuth().getUserByPhoneNumber(phoneNumber) - .then((userRecord) => { - return safeDelete(userRecord.uid); - }) - .catch((error) => { - // Suppress user not found error. - if (error.code !== 'auth/user-not-found') { - throw error; - } - }); -} - -/** - * Runs cleanup routine that could affect outcome of tests and removes any - * intermediate users created. - * - * @return A promise that resolves when test preparations are ready. - */ -function cleanup(): Promise { - // Delete any existing users that could affect the test outcome. - const promises: Array> = [ - deletePhoneNumberUser(testPhoneNumber), - deletePhoneNumberUser(testPhoneNumber2), - deletePhoneNumberUser(nonexistentPhoneNumber), - deletePhoneNumberUser(updatedPhone), - ]; - // Delete users created for session cookie tests. - sessionCookieUids.forEach((uid) => uids.push(uid)); - // Delete list of users for testing listUsers. - uids.forEach((uid) => { - // Use safeDelete to avoid getting throttled. - promises.push(safeDelete(uid)); - }); - return Promise.all(promises); -} - -/** - * Returns the action code corresponding to the link. - * - * @param link The link to parse for the action code. - * @return The link's corresponding action code. - */ -function getActionCode(link: string): string { - const parsedUrl = new url.URL(link); - const oobCode = parsedUrl.searchParams.get('oobCode'); - expect(oobCode).to.exist; - return oobCode!; -} - -/** - * Returns the continue URL corresponding to the link. - * - * @param link The link to parse for the continue URL. - * @return The link's corresponding continue URL. - */ -function getContinueUrl(link: string): string { - const parsedUrl = new url.URL(link); - const continueUrl = parsedUrl.searchParams.get('continueUrl'); - expect(continueUrl).to.exist; - return continueUrl!; -} - -/** - * Returns the tenant ID corresponding to the link. - * - * @param link The link to parse for the tenant ID. - * @return The link's corresponding tenant ID. - */ -function getTenantId(link: string): string { - const parsedUrl = new url.URL(link); - const tenantId = parsedUrl.searchParams.get('tenantId'); - expect(tenantId).to.exist; - return tenantId!; -} - -/** - * Safely deletes a specificed user identified by uid. This API chains all delete - * requests and throttles them as the Auth backend rate limits this endpoint. - * A bulk delete API is being designed to help solve this issue. - * - * @param uid The identifier of the user to delete. - * @return A promise that resolves when delete operation resolves. - */ -function safeDelete(uid: string): Promise { - // Wait for delete queue to empty. - const deletePromise = deleteQueue - .then(() => { - return getAuth().deleteUser(uid); - }) - .catch((error) => { - // Suppress user not found error. - if (error.code !== 'auth/user-not-found') { - throw error; - } - }); - // Suppress errors in delete queue to not spill over to next item in queue. - deleteQueue = deletePromise.catch(() => { - // Do nothing. - }); - return deletePromise; -} - -/** - * Deletes the specified list of users by calling the `deleteUsers()` API. This - * API is rate limited at 1 QPS, and therefore this helper function staggers - * subsequent invocations by adding 1 second delay to each call. - * - * @param uids The list of user identifiers to delete. - * @return A promise that resolves when delete operation resolves. - */ -async function deleteUsersWithDelay(uids: string[]): Promise { - if (!authEmulatorHost) { - await new Promise((resolve) => { setTimeout(resolve, 1000); }); - } - return getAuth().deleteUsers(uids); -} - -/** - * Asserts actual object is equal to expected object while ignoring key order. - * This is useful since to.deep.equal fails when order differs. - * - * @param expected object. - * @param actual object. - */ -function assertDeepEqualUnordered(expected: { [key: string]: any }, actual: { [key: string]: any }): void { - for (const key in expected) { - if (Object.prototype.hasOwnProperty.call(expected, key)) { - expect(actual[key]) - .to.deep.equal(expected[key]); - } - } - expect(Object.keys(actual).length).to.be.equal(Object.keys(expected).length); -} \ No newline at end of file diff --git a/packages/dart_firebase_admin/test/auth/integration_test.dart b/packages/dart_firebase_admin/test/auth/integration_test.dart index 617e8d9..dfcea25 100644 --- a/packages/dart_firebase_admin/test/auth/integration_test.dart +++ b/packages/dart_firebase_admin/test/auth/integration_test.dart @@ -10,13 +10,11 @@ void main() { late Auth auth; setUp(() { - final sdk = createApp(); + final sdk = createApp(tearDown: () => cleanup(auth)); sdk.useEmulator(); auth = Auth(sdk); }); - tearDown(() => cleanup(auth)); - group('createUser', () { test('supports no specified uid', () async { final user = await auth.createUser( diff --git a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart index e459875..5844e8a 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_group_test.dart @@ -30,10 +30,11 @@ void main() { firestore.doc('abc/def/with-converter-group/docC').set({'value': 10}), ]); - final group = firestore.collectionGroup('my-group').withConverter( - fromFirestore: (firestore) => firestore.data()['value']! as num, - toFirestore: (value) => {'value': value}, - ); + final group = + firestore.collectionGroup('with-converter-group').withConverter( + fromFirestore: (firestore) => firestore.data()['value']! as num, + toFirestore: (value) => {'value': value}, + ); final query = group.where('value', WhereFilter.greaterThan, 12); final snapshot = await query.get(); @@ -50,7 +51,7 @@ void main() { firestore.doc('abc/def/group/docC').set({'value': 10}), ]); - final group = firestore.collectionGroup('my-group'); + final group = firestore.collectionGroup('group'); final query = group.where('value', WhereFilter.greaterThan, 12); final snapshot = await query.get(); 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 index bb0ac49..17d6462 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/collection_test.dart @@ -163,9 +163,8 @@ void main() { expect(collection, isA>()); - final parent = collection.parent; + final DocumentReference? 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 index 34e51a1..bc8820a 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/document_test.dart @@ -535,11 +535,11 @@ void main() { final soon = DateTime.now().toUtc().millisecondsSinceEpoch + 5000; await expectLater( - firestore.doc('collectionId/lastupdatetimeprecondition').update( + firestore.doc('collectionId/invalidlastupdatetimeprecondition').update( {'foo': 'bar'}, Precondition.timestamp(Timestamp.fromMillis(soon)), ), - throwsA(isA()), + throwsA(isA()), ); }); 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 index d5e10cb..218d04d 100644 --- a/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart +++ b/packages/dart_firebase_admin/test/google_cloud_firestore/util/helpers.dart @@ -1,15 +1,24 @@ +import 'dart:async'; + import 'package:dart_firebase_admin/firestore.dart'; import 'package:dart_firebase_admin/src/app.dart'; import 'package:test/test.dart'; const projectId = 'dart-firebase-admin'; -FirebaseAdminApp createApp() { +FirebaseAdminApp createApp({ + FutureOr Function()? tearDown, +}) { final credential = Credential.fromApplicationDefaultCredentials(); final app = FirebaseAdminApp.initializeApp(projectId, credential) ..useEmulator(); - addTearDown(app.close); + addTearDown(() async { + if (tearDown != null) { + await tearDown(); + } + await app.close(); + }); return app; } diff --git a/scripts/coverage.sh b/scripts/coverage.sh index c2186dd..9c65223 100755 --- a/scripts/coverage.sh +++ b/scripts/coverage.sh @@ -5,6 +5,6 @@ set -e dart pub global activate coverage -firebase emulators:exec --project flutterfire-e2e-tests --only firestore,auth "dart test --coverage=coverage" +firebase emulators:exec --project dart-firebase-admin --only firestore,auth "dart test --coverage=coverage" format_coverage --lcov --in=coverage --out=coverage.lcov --packages=.dart_tool/package_config.json --report-on=lib \ No newline at end of file From 336de143792c7b29e378984f4a0b91d2e410ece9 Mon Sep 17 00:00:00 2001 From: "Sree (Taylor's Version)" <94184909+HeySreelal@users.noreply.github.com> Date: Tue, 9 Jul 2024 17:38:44 +0530 Subject: [PATCH 11/11] Fix #21: Pass localId to user update request (#25) --- .github/workflows/build.yml | 3 +-- packages/dart_firebase_admin/CHANGELOG.md | 4 ++++ .../lib/src/auth/auth_api_request.dart | 2 ++ .../test/auth/integration_test.dart | 22 +++++++++++++++++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6cc6f62..1e4c83a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -44,8 +44,7 @@ jobs: run: dart analyze - name: Run tests - run: | - ${{github.workspace}}/scripts/coverage.sh + run: ${{github.workspace}}/scripts/coverage.sh - name: Upload coverage to codecov run: curl -s https://codecov.io/bash | bash diff --git a/packages/dart_firebase_admin/CHANGELOG.md b/packages/dart_firebase_admin/CHANGELOG.md index 1bcd35e..8421191 100644 --- a/packages/dart_firebase_admin/CHANGELOG.md +++ b/packages/dart_firebase_admin/CHANGELOG.md @@ -1,3 +1,7 @@ +## Unreleased fix + +- Fixes crash when updating users (thanks to @HeySreelal) + ## 0.3.1 - **FEAT**: Use GOOGLE_APPLICATION_CREDENTIALS if json value (#32). 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 index fc5bfe1..8d6da4f 100644 --- a/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart +++ b/packages/dart_firebase_admin/lib/src/auth/auth_api_request.dart @@ -728,6 +728,8 @@ abstract class _AbstractAuthRequestHandler { phoneNumber: properties.phoneNumber?.value, // Will be null if deleted or set to null. "deleteAttribute" will take over photoUrl: properties.photoURL?.value, + // The UID of the user to be updated. + localId: uid, ); final response = await _httpClient.setAccountInfo(request); diff --git a/packages/dart_firebase_admin/test/auth/integration_test.dart b/packages/dart_firebase_admin/test/auth/integration_test.dart index dfcea25..4939f12 100644 --- a/packages/dart_firebase_admin/test/auth/integration_test.dart +++ b/packages/dart_firebase_admin/test/auth/integration_test.dart @@ -142,6 +142,28 @@ void main() { expect(user.uid, importUser.uid); }); }); + + group('updateUser', () { + test('supports updating email', () async { + final user = await auth.createUser( + CreateRequest( + email: 'testuser@example.com', + ), + ); + + final updatedUser = await auth.updateUser( + user.uid, + UpdateRequest( + email: 'updateduser@example.com', + ), + ); + + expect(updatedUser.email, equals('updateduser@example.com')); + + final user2 = await auth.getUserByEmail(updatedUser.email!); + expect(user2.uid, equals(user.uid)); + }); + }); } Future cleanup(Auth auth) async {