diff --git a/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE/bug_report.md similarity index 91% rename from ISSUE_TEMPLATE.md rename to .github/ISSUE_TEMPLATE/bug_report.md index 5de83b2cc..36eea6edb 100644 --- a/ISSUE_TEMPLATE.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,3 +1,12 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: '' +assignees: '' + +--- + ### [READ] Step 1: Are you in the right place? * For issues or feature requests related to __the code in this repository__ diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 000000000..3408a0aa1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '[FR]' +labels: 'type: feature request' +assignees: '' + +--- + +**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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..04ceb6e86 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "maven" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/resources/firebase.asc.gpg b/.github/resources/firebase.asc.gpg new file mode 100644 index 000000000..feb690edb Binary files /dev/null and b/.github/resources/firebase.asc.gpg differ diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg new file mode 100644 index 000000000..7740dccd8 Binary files /dev/null and b/.github/resources/integ-service-account.json.gpg differ diff --git a/.github/resources/settings.xml b/.github/resources/settings.xml new file mode 100644 index 000000000..da1d3057d --- /dev/null +++ b/.github/resources/settings.xml @@ -0,0 +1,29 @@ + + + + false + + + + central + ${env.CENTRAL_USERNAME} + ${env.CENTRAL_TOKEN} + + + + + + release + + true + + + gpg + 0A05D8FAD4287A36C53BE07714D6B82AEB1DD39C + ${env.GPG_PASSPHRASE} + + + + diff --git a/.github/scripts/generate_changelog.sh b/.github/scripts/generate_changelog.sh new file mode 100755 index 000000000..e393f40e4 --- /dev/null +++ b/.github/scripts/generate_changelog.sh @@ -0,0 +1,79 @@ +#!/bin/bash + +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e +set -u + +function printChangelog() { + local TITLE=$1 + shift + # Skip the sentinel value. + local ENTRIES=("${@:2}") + if [ ${#ENTRIES[@]} -ne 0 ]; then + echo "### ${TITLE}" + echo "" + for ((i = 0; i < ${#ENTRIES[@]}; i++)) + do + echo "* ${ENTRIES[$i]}" + done + echo "" + fi +} + +if [[ -z "${GITHUB_SHA}" ]]; then + GITHUB_SHA="HEAD" +fi + +LAST_TAG=`git describe --tags $(git rev-list --tags --max-count=1) 2> /dev/null` || true +if [[ -z "${LAST_TAG}" ]]; then + echo "[INFO] No tags found. Including all commits up to ${GITHUB_SHA}." + VERSION_RANGE="${GITHUB_SHA}" +else + echo "[INFO] Last release tag: ${LAST_TAG}." + COMMIT_SHA=`git show-ref -s ${LAST_TAG}` + echo "[INFO] Last release commit: ${COMMIT_SHA}." + VERSION_RANGE="${COMMIT_SHA}..${GITHUB_SHA}" + echo "[INFO] Including all commits in the range ${VERSION_RANGE}." +fi + +echo "" + +# Older versions of Bash (< 4.4) treat empty arrays as unbound variables, which triggers +# errors when referencing them. Therefore we initialize each of these arrays with an empty +# sentinel value, and later skip them. +CHANGES=("") +FIXES=("") +FEATS=("") +MISC=("") + +while read -r line +do + COMMIT_MSG=`echo ${line} | cut -d ' ' -f 2-` + if [[ $COMMIT_MSG =~ ^change(\(.*\))?: ]]; then + CHANGES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^fix(\(.*\))?: ]]; then + FIXES+=("$COMMIT_MSG") + elif [[ $COMMIT_MSG =~ ^feat(\(.*\))?: ]]; then + FEATS+=("$COMMIT_MSG") + else + MISC+=("${COMMIT_MSG}") + fi +done < <(git log ${VERSION_RANGE} --oneline) + +printChangelog "Breaking Changes" "${CHANGES[@]}" +printChangelog "New Features" "${FEATS[@]}" +printChangelog "Bug Fixes" "${FIXES[@]}" +printChangelog "Miscellaneous" "${MISC[@]}" diff --git a/.github/scripts/package_artifacts.sh b/.github/scripts/package_artifacts.sh new file mode 100755 index 000000000..30d998c75 --- /dev/null +++ b/.github/scripts/package_artifacts.sh @@ -0,0 +1,36 @@ +#!/bin/bash + +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e +set -u + +gpg --quiet --batch --yes --decrypt --passphrase="${FIREBASE_SERVICE_ACCT_KEY}" \ + --output integration_cert.json .github/resources/integ-service-account.json.gpg + +echo "${FIREBASE_API_KEY}" > integration_apikey.txt + +# Does the following: +# 1. Runs the Checkstyle plugin (validate phase) +# 2. Compiles the source (compile phase) +# 3. Runs the unit tests (test phase) +# 4. Packages the artifacts - src, bin, javadocs (package phase) +# 5. Runs the integration tests (verify phase) +mvn -DtrimStackTrace=false -B clean verify + +# Maven target directory can consist of many files. Just copy the jar artifacts +# into a new directory for upload. +mkdir -p dist +cp target/*.jar dist/ diff --git a/.github/scripts/publish_artifacts.sh b/.github/scripts/publish_artifacts.sh new file mode 100755 index 000000000..8e20af88f --- /dev/null +++ b/.github/scripts/publish_artifacts.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e +set -u + +gpg --quiet --batch --yes --decrypt --passphrase="${GPG_PRIVATE_KEY}" \ + --output firebase.asc .github/resources/firebase.asc.gpg + +gpg --import --no-tty --batch --yes firebase.asc + +# Does the following: +# 1. Compiles the source (compile phase) +# 2. Packages the artifacts - src, bin, javadocs (package phase) +# 3. Signs the artifacts (verify phase) +# 4. Publishes artifacts via Central Publisher Portal (deploy phase) +mvn -B clean deploy \ + -Dcheckstyle.skip \ + -DskipTests \ + -Prelease \ + --settings .github/resources/settings.xml + diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh new file mode 100755 index 000000000..7c8e384e2 --- /dev/null +++ b/.github/scripts/publish_preflight_check.sh @@ -0,0 +1,147 @@ +#!/bin/bash + +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +###################################### Outputs ##################################### + +# 1. version: The version of this release including the 'v' prefix (e.g. v1.2.3). +# 2. changelog: Formatted changelog text for this release. + +#################################################################################### + +set -e +set -u + +function echo_info() { + local MESSAGE=$1 + echo "[INFO] ${MESSAGE}" +} + +function echo_warn() { + local MESSAGE=$1 + echo "[WARN] ${MESSAGE}" +} + +function terminate() { + echo "" + echo_warn "--------------------------------------------" + echo_warn "PREFLIGHT FAILED" + echo_warn "--------------------------------------------" + exit 1 +} + + +echo_info "Starting publish preflight check..." +echo_info "Git revision : ${GITHUB_SHA}" +echo_info "Workflow triggered by : ${GITHUB_ACTOR}" +echo_info "GitHub event : ${GITHUB_EVENT_NAME}" + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Extracting release version" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "Loading version from: pom.xml" +readonly RELEASE_VERSION=`mvn help:evaluate -Dexpression=project.version -q -DforceStdout` || true +if [[ -z "${RELEASE_VERSION}" ]]; then + echo_warn "Failed to extract release version from: pom.xml" + terminate +fi + +if [[ ! "${RELEASE_VERSION}" =~ ^([0-9]*)\.([0-9]*)\.([0-9]*)$ ]]; then + echo_warn "Malformed release version string: ${RELEASE_VERSION}. Exiting." + terminate +fi + +echo_info "Extracted release version: ${RELEASE_VERSION}" +echo "version=v${RELEASE_VERSION}" >> $GITHUB_OUTPUT + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Checking previous releases" +echo_info "--------------------------------------------" +echo_info "" + +readonly MAVEN_CENTRAL_URL="https://repo1.maven.org/maven2/com/google/firebase/firebase-admin/${RELEASE_VERSION}" +readonly MAVEN_STATUS=`curl -s -o /dev/null -L -w "%{http_code}" ${MAVEN_CENTRAL_URL}` +if [[ $MAVEN_STATUS -eq 404 ]]; then + echo_info "Release version ${RELEASE_VERSION} not found in Maven Central." +elif [[ $MAVEN_STATUS -eq 200 ]]; then + echo_warn "Release version ${RELEASE_VERSION} already present in Maven Central." + terminate +else + echo_warn "Unexpected ${MAVEN_STATUS} response from Maven Central. Exiting." + terminate +fi + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Checking release tag" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch --depth=1 origin +refs/tags/*:refs/tags/* >---" +git fetch --depth=1 origin +refs/tags/*:refs/tags/* +echo "" + +readonly EXISTING_TAG=`git rev-parse -q --verify "refs/tags/v${RELEASE_VERSION}"` || true +if [[ -n "${EXISTING_TAG}" ]]; then + echo_warn "Tag v${RELEASE_VERSION} already exists. Exiting." + echo_warn "If the tag was created in a previous unsuccessful attempt, delete it and try again." + echo_warn " $ git tag -d v${RELEASE_VERSION}" + echo_warn " $ git push --delete origin v${RELEASE_VERSION}" + + readonly RELEASE_URL="https://github.com/firebase/firebase-admin-java/releases/tag/v${RELEASE_VERSION}" + echo_warn "Delete any corresponding releases at ${RELEASE_URL}." + terminate +fi + +echo_info "Tag v${RELEASE_VERSION} does not exist." + + +echo_info "" +echo_info "--------------------------------------------" +echo_info "Generating changelog" +echo_info "--------------------------------------------" +echo_info "" + +echo_info "---< git fetch origin master --prune --unshallow >---" +git fetch origin master --prune --unshallow +echo "" + +echo_info "Generating changelog from history..." +readonly CURRENT_DIR=$(dirname "$0") +readonly CHANGELOG=`${CURRENT_DIR}/generate_changelog.sh` +echo "$CHANGELOG" + +# Parse and preformat the text to handle multi-line output. +# See https://docs.github.com/en/actions/using-workflows/workflow-commands-for-github-actions#example-of-a-multiline-string +# and https://github.com/github/docs/issues/21529#issue-1418590935 +FILTERED_CHANGELOG=`echo "$CHANGELOG" | grep -v "\\[INFO\\]"` +FILTERED_CHANGELOG="${FILTERED_CHANGELOG//$'\''/'"'}" +echo "changelog<> $GITHUB_OUTPUT +echo -e "$FILTERED_CHANGELOG" >> $GITHUB_OUTPUT +echo "CHANGELOGEOF" >> $GITHUB_OUTPUT + + +echo "" +echo_info "--------------------------------------------" +echo_info "PREFLIGHT SUCCESSFUL" +echo_info "--------------------------------------------" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 10007c5c6..39948e9e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,15 +1,40 @@ +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. name: Continuous Integration -on: [push, pull_request] +on: pull_request jobs: build: runs-on: ubuntu-latest + + strategy: + matrix: + java-version: [8, 11, 17] + steps: - - uses: actions/checkout@v1 - - name: Set up JDK 1.7 - uses: actions/setup-java@v1 + - uses: actions/checkout@v4 + + - name: Set up JDK + uses: actions/setup-java@v4 with: - java-version: 1.7 + distribution: 'zulu' + java-version: ${{ matrix.java-version }} + + # Does the following: + # 1. Runs the Checkstyle plugin (validate phase) + # 2. Compiles the source (compile phase) + # 3. Runs the unit tests (test phase) - name: Build with Maven - run: mvn -B package --file pom.xml + run: mvn -B clean test diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml new file mode 100644 index 000000000..7bda5ba58 --- /dev/null +++ b/.github/workflows/nightly.yml @@ -0,0 +1,84 @@ +# 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. + +name: Nightly Builds + +on: + # Runs every day at 06:10 AM (PT) and 08:10 PM (PT) / 04:10 AM (UTC) and 02:10 PM (UTC) + # or on 'firebase_nightly_build' repository dispatch event. + schedule: + - cron: "10 4,14 * * *" + repository_dispatch: + types: [firebase_nightly_build] + +jobs: + nightly: + + runs-on: ubuntu-latest + + steps: + - name: Checkout source for staging + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 8 + + - name: Compile, test and package + run: ./.github/scripts/package_artifacts.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # Attach the packaged artifacts to the workflow output. These can be manually + # downloaded for later inspection if necessary. + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + + - name: Send email on failure + if: failure() + uses: firebase/firebase-admin-node/.github/actions/send-email@master + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} failed!' + html: > + Nightly workflow ${{github.run_id}} failed on: ${{github.repository}} +

Navigate to the + failed workflow. + continue-on-error: true + + - name: Send email on cancelled + if: cancelled() + uses: firebase/firebase-admin-node/.github/actions/send-email@master + with: + api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} + domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} + from: 'GitHub ' + to: ${{ secrets.FIREBASE_ADMIN_GITHUB_EMAIL }} + subject: 'Nightly build ${{github.run_id}} of ${{github.repository}} cancelled!' + html: > + Nightly workflow ${{github.run_id}} cancelled on: ${{github.repository}} +

Navigate to the + cancelled workflow. + continue-on-error: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..86774e50c --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +# Copyright 2020 Google Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: Release Candidate + +on: + # Only run the workflow when a PR is updated or when a developer explicitly requests + # a build by sending a 'firebase_build' event. + pull_request: + types: [opened, synchronize, closed] + + repository_dispatch: + types: + - firebase_build + +jobs: + stage_release: + # To publish a release, merge the release PR with the label 'release:publish'. + # To stage a release without publishing it, send a 'firebase_build' event or apply + # the 'release:stage' label to a PR. + if: github.event.action == 'firebase_build' || + contains(github.event.pull_request.labels.*.name, 'release:stage') || + (github.event.pull_request.merged && + contains(github.event.pull_request.labels.*.name, 'release:publish')) + + runs-on: ubuntu-latest + + # When manually triggering the build, the requester can specify a target branch or a tag + # via the 'ref' client parameter. + steps: + - name: Checkout source for staging + uses: actions/checkout@v4 + with: + ref: ${{ github.event.client_payload.ref || github.ref }} + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 8 + + - name: Compile, test and package + run: ./.github/scripts/package_artifacts.sh + env: + FIREBASE_SERVICE_ACCT_KEY: ${{ secrets.FIREBASE_SERVICE_ACCT_KEY }} + FIREBASE_API_KEY: ${{ secrets.FIREBASE_API_KEY }} + + # Attach the packaged artifacts to the workflow output. These can be manually + # downloaded for later inspection if necessary. + - name: Archive artifacts + uses: actions/upload-artifact@v4 + with: + name: dist + path: dist + + publish_release: + needs: stage_release + + # Check whether the release should be published. We publish only when the trigger PR is + # 1. merged + # 2. to the master branch + # 3. with the label 'release:publish', and + # 4. the title prefix '[chore] Release '. + if: github.event.pull_request.merged && + github.ref == 'refs/heads/master' && + contains(github.event.pull_request.labels.*.name, 'release:publish') && + startsWith(github.event.pull_request.title, '[chore] Release ') + + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout source for publish + uses: actions/checkout@v4 + + - name: Set up JDK 8 + uses: actions/setup-java@v4 + with: + distribution: 'zulu' + java-version: 8 + + - name: Publish preflight check + id: preflight + run: ./.github/scripts/publish_preflight_check.sh + + - name: Publish to Maven Central + run: ./.github/scripts/publish_artifacts.sh + env: + GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} + GPG_PRIVATE_KEY: ${{ secrets.GPG_PRIVATE_KEY }} + CENTRAL_USERNAME: ${{ secrets.CENTRAL_USERNAME }} + CENTRAL_TOKEN: ${{ secrets.CENTRAL_TOKEN }} + + # See: https://cli.github.com/manual/gh_release_create + - name: Create release tag + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release create ${{ steps.preflight.outputs.version }} + --title "Firebase Admin Java SDK ${{ steps.preflight.outputs.version }}" + --notes '${{ steps.preflight.outputs.changelog }}' + + # Post to Twitter if explicitly opted-in by adding the label 'release:tweet'. + - name: Post to Twitter + if: success() && + contains(github.event.pull_request.labels.*.name, 'release:tweet') + uses: firebase/firebase-admin-node/.github/actions/send-tweet@master + with: + status: > + ${{ steps.preflight.outputs.version }} of @Firebase Admin Java SDK is available. + https://github.com/firebase/firebase-admin-java/releases/tag/${{ steps.preflight.outputs.version }} + consumer-key: ${{ secrets.TWITTER_CONSUMER_KEY }} + consumer-secret: ${{ secrets.TWITTER_CONSUMER_SECRET }} + access-token: ${{ secrets.TWITTER_ACCESS_TOKEN }} + access-token-secret: ${{ secrets.TWITTER_ACCESS_TOKEN_SECRET }} + continue-on-error: true diff --git a/.gitignore b/.gitignore index a869aa65d..6e7d29cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ target/ release.properties integration_cert.json integration_apikey.txt +.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11012bfff..2e6914c5f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,9 +85,7 @@ information on using pull requests. ### Initial Setup -Install Java 7 or higher. You can also use Java 8, but please note that the Firebase Admin SDK must -maintain full Java 7 compatibility. Therefore make sure that you do not use any Java 8 features -(e.g. lambdas) when writing code for the Admin Java SDK. +Install Java 8 or higher. We use [Apache Maven](http://maven.apache.org/) for building, testing and releasing the Admin Java SDK code. Follow the [installation guide](http://maven.apache.org/install.html), and install Maven @@ -129,20 +127,92 @@ mvn test Integration tests are also written using Junit4. They coexist with the unit tests in the `src/test` subdirectory. Integration tests follow the naming convention `*IT.java` (e.g. `DataTestIT.java`), which enables the Maven Surefire and Failsafe plugins to differentiate between the two types of -tests. Integration tests are executed against a real life Firebase project, and therefore -requires an Internet connection. - -Create a new project in the [Firebase console](https://console.firebase.google.com/) if you do -not already have one. Use a separate, dedicated project for integration tests since the test suite -makes a large number of writes to the Firebase realtime database. Download the service account -private key from the "Settings > Service Accounts" page of the project, and save it as -`integration_cert.json` at the root of the codebase. Grant your service account the `Firebase -Authentication Admin` role at -[Google Cloud Platform Console / IAM & admin](https://console.cloud.google.com/iam-admin). This is -required to ensure that exported user records contain the password hashes of the user accounts. -Also obtain the web API key of the project from the "Settings > General" page, and save it as -`integration_apikey.txt` at the root of the codebase. Now run the following command to invoke the -integration test suite: +tests. + +Integration tests are executed against a real life Firebase project. If you do not already +have one suitable for running the tests against, you can create a new project in the +[Firebase Console](https://console.firebase.google.com) following the setup guide below. +If you already have a Firebase project, you'll need to obtain credentials to communicate and +authorize access to your Firebase project: + +1. Service account certificate: This allows access to your Firebase project through a service account +which is required for all integration tests. This can be downloaded as a JSON file from the +**Settings > Service Accounts** tab of the Firebase console when you click the +**Generate new private key** button. Copy the file into the repo so it's available at +`integration_cert.json`. + > **Note:** Service accounts should be carefully managed and their keys should never be stored in publicly accessible source code or repositories. + + +2. Web API key: This allows for Auth sign-in needed for some Authentication and Tenant Management +integration tests. This is displayed in the **Settings > General** tab of the Firebase console +after enabling Authentication as described in the steps below. Copy it and save to a new text +file at `integration_apikey.txt`. + + +Set up your Firebase project as follows: + + +1. Enable Authentication: + 1. Go to the Firebase Console, and select **Authentication** from the **Build** menu. + 2. Click on **Get Started**. + 3. Select **Sign-in method > Add new provider > Email/Password** then enable both the + **Email/Password** and **Email link (passwordless sign-in)** options. + + +2. Enable Firestore: + 1. Go to the Firebase Console, and select **Firestore Database** from the **Build** menu. + 2. Click on the **Create database** button. You can choose to set up Firestore either in + the production mode or in the test mode. + + +3. Enable Realtime Database: + 1. Go to the Firebase Console, and select **Realtime Database** from the **Build** menu. + 2. Click on the **Create Database** button. You can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + > **Note:** Integration tests are not run against the default Realtime Database reference and are + instead run against a database created at `https://{PROJECT_ID}.firebaseio.com`. + This second Realtime Database reference is created in the following steps. + + 3. In the **Data** tab click on the kebab menu (3 dots) and select **Create Database**. + 4. Enter your Project ID (Found in the **General** tab in **Account Settings**) as the + **Realtime Database reference**. Again, you can choose to set up the Realtime Database + either in the locked mode or in the test mode. + + +4. Enable Storage: + 1. Go to the Firebase Console, and select **Storage** from the **Build** menu. + 2. Click on the **Get started** button. You can choose to set up Cloud Storage + either in the production mode or in the test mode. + + +5. Enable the IAM API: + 1. Go to the [Google Cloud console](https://console.cloud.google.com) + and make sure your Firebase project is selected. + 2. Select **APIs & Services** from the main menu, and click the + **ENABLE APIS AND SERVICES** button. + 3. Search for and enable **Identity and Access Management (IAM) API** by Google Enterprise API. + + +6. Enable Tenant Management: + 1. Go to + [Google Cloud console | Identity Platform](https://console.cloud.google.com/customer-identity/) + and if it is not already enabled, click **Enable**. + 2. Then + [enable multi-tenancy](https://cloud.google.com/identity-platform/docs/multi-tenancy-quickstart#enabling_multi-tenancy) + for your project. + + +7. Ensure your service account has the **Firebase Authentication Admin** role. This is required +to ensure that exported user records contain the password hashes of the user accounts: + 1. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin). + 2. Find your service account in the list. If not added click the pencil icon to edit its + permissions. + 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. + 4. Click **SAVE**. + + +Now run the following command to invoke the integration test suite: ``` mvn verify @@ -153,14 +223,14 @@ tests, specify the `-DskipUTs` flag. ### Generating API Docs -Invoke the [Maven Javadoc plugin](https://maven.apache.org/plugins/maven-javadoc-plugin/) as +Invoke the [Maven Javadoc plugin](https://maven.apache.org/plugins/maven-javadoc-plugin/) as follows to generate API docs for all packages in the codebase: ``` mvn javadoc:javadoc ``` -This will generate the API docs, and place them in the `target/site/apidocs` directory. +This will generate the API docs, and place them in the `target/site/apidocs` directory. To generate API docs for only the public APIs (i.e. ones that are not marked with `@hide` tags), you need to trigger the `devsite-apidocs` Maven profile. This profile uses Maven Javadoc plugin diff --git a/README.md b/README.md index 406223d06..1ad5352e5 100644 --- a/README.md +++ b/README.md @@ -46,9 +46,21 @@ requests, code review feedback, and also pull requests. ## Supported Java Versions -We support Java 7 and higher. Firebase Admin Java SDK also runs on [Google App +We currently support Java 8 and higher. The Firebase Admin Java SDK also runs on [Google App Engine](https://cloud.google.com/appengine/). +The Firebase Admin Java SDK follows the [Oracle Java SE +support roadmap](https://www.oracle.com/java/technologies/java-se-support-roadmap.html) +(see the Oracle Java SE Product Releases section). + +### For new development + +In general, new feature development occurs with support for the lowest Java LTS version +covered by Oracle's Premier Support (which typically lasts 5 years from initial General +Availability). If the minimum required JVM for a given library is changed, it is +accompanied by a [semver](https://semver.org/) major release. + +Java 11 and Java 17 are the best choices for new development. ## Documentation diff --git a/checkstyle.xml b/checkstyle.xml index 663918173..77e2dba54 100644 --- a/checkstyle.xml +++ b/checkstyle.xml @@ -42,15 +42,15 @@ - + - - + + @@ -229,6 +229,9 @@ + + + diff --git a/pom.xml b/pom.xml index e82439e48..275a7830d 100644 --- a/pom.xml +++ b/pom.xml @@ -19,7 +19,7 @@ com.google.firebase firebase-admin - 6.12.2-SNAPSHOT + 9.7.0 jar firebase-admin @@ -59,7 +59,7 @@ UTF-8 UTF-8 ${skipTests} - 4.1.34.Final + 4.2.7.Final @@ -69,24 +69,7 @@ HEAD - - - ossrh - https://oss.sonatype.org/content/repositories/snapshots - - - - - - disable-java8-doclint - - [1.8,) - - - -Xdoclint:none - - devsite-apidocs @@ -99,6 +82,7 @@ maven-javadoc-plugin + 3.12.0 site @@ -122,24 +106,25 @@ 1.3 - - -hdf book.path /docs/reference/_book.yaml - -hdf project.path /_project.yaml - -hdf devsite.path /docs/reference/admin/java/reference/ - -d ${project.build.directory}/apidocs - -templatedir ${devsite.template} - -toroot /docs/reference/admin/java/reference/ - -yaml _toc.yaml - -warning 101 - + + -hdf book.path /docs/reference/_book.yaml + -hdf project.path /_project.yaml + -hdf devsite.path /docs/reference/admin/java/reference/ + -d ${project.build.directory}/apidocs + -templatedir ${devsite.template} + -toroot /docs/reference/admin/java/reference/ + -yaml _toc.yaml + -warning 101 + false -J-Xmx1024m + none maven-antrun-plugin - 1.7 + 3.1.0 site @@ -161,59 +146,11 @@ release - - - true - - - maven-javadoc-plugin - - - package - - jar - - - - - - com.google.doclava - doclava - 1.0.6 - - com.google.doclava.Doclava - ${sun.boot.class.path} - - - com.google.j2objc - j2objc-annotations - 1.3 - - - - -warning 101 - - false - -J-Xmx1024m - - - - maven-source-plugin - 2.2.1 - - - attach-sources - - jar-no-fork - - - - maven-gpg-plugin - 1.5 + 3.2.8 sign-artifacts @@ -221,6 +158,12 @@ sign + + + --pinentry-mode + loopback + + @@ -239,7 +182,7 @@ org.jacoco jacoco-maven-plugin - 0.7.9 + 0.8.13 pre-unit-test @@ -265,7 +208,7 @@ org.codehaus.mojo exec-maven-plugin - 1.6.0 + 3.6.2 test @@ -295,6 +238,7 @@ + maven-checkstyle-plugin 2.17 @@ -315,56 +259,98 @@ + + maven-compiler-plugin - 3.6.1 + 3.14.0 - 1.7 - 1.7 + 1.8 + 1.8 + + maven-surefire-plugin - 2.19.1 + 3.5.4 ${skipUTs} + + - maven-failsafe-plugin - 2.19.1 + maven-source-plugin + 3.3.1 + attach-sources - integration-test - verify + jar-no-fork - + maven-javadoc-plugin - 2.10.4 - - - maven-release-plugin - 2.5.3 + 3.12.0 + + + attach-javadocs + + jar + + + - false - release - v@{project.version} - deploy + + com.google.doclava + doclava + 1.0.6 + + com.google.doclava.Doclava + ${sun.boot.class.path} + + + com.google.j2objc + j2objc-annotations + 1.3 + + + + -warning 101 + + false + -J-Xmx1024m + none + + - org.sonatype.plugins - nexus-staging-maven-plugin - 1.6.7 + maven-failsafe-plugin + 3.5.3 + + + + integration-test + verify + + + + + + + + org.sonatype.central + central-publishing-maven-plugin + 0.8.0 true - ossrh - https://oss.sonatype.org/ - false + central + true + published @@ -374,7 +360,7 @@ maven-project-info-reports-plugin - 2.9 + 3.9.0 @@ -387,54 +373,58 @@ + + + + com.google.cloud + libraries-bom + 26.71.0 + pom + import + + + + + + com.google.auth + google-auth-library-oauth2-http + com.google.api-client google-api-client - 1.30.1 com.google.api-client google-api-client-gson - 1.30.1 com.google.http-client google-http-client - 1.30.1 com.google.api api-common - 1.8.1 - - - com.google.auth - google-auth-library-oauth2-http - 0.17.1 com.google.cloud google-cloud-storage - 1.91.0 com.google.cloud google-cloud-firestore - 1.31.0 com.google.guava guava - 26.0-android org.slf4j slf4j-api - 1.7.25 + 2.0.17 io.netty @@ -451,12 +441,17 @@ netty-transport ${netty.version} + + org.apache.httpcomponents.client5 + httpclient5 + 5.3.1 + org.mockito mockito-core - 2.23.4 + 4.11.0 test @@ -465,23 +460,23 @@ 0.6 test - - com.cedarsoftware - java-util - 1.26.0 - test - junit junit - 4.12 + 4.13.2 test javax.ws.rs javax.ws.rs-api - 2.0 + 2.1.1 + test + + + org.hamcrest + hamcrest + 3.0 test diff --git a/src/main/java/com/google/firebase/ErrorCode.java b/src/main/java/com/google/firebase/ErrorCode.java new file mode 100644 index 000000000..0eaa9cec2 --- /dev/null +++ b/src/main/java/com/google/firebase/ErrorCode.java @@ -0,0 +1,109 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase; + +/** + * Platform-wide error codes that can be raised by Admin SDK APIs. + */ +public enum ErrorCode { + + /** + * Client specified an invalid argument. + */ + INVALID_ARGUMENT, + + /** + * Request cannot be executed in the current system state, such as deleting a non-empty + * directory. + */ + FAILED_PRECONDITION, + + /** + * Client specified an invalid range. + */ + OUT_OF_RANGE, + + /** + * Request not authenticated due to missing, invalid, or expired OAuth token. + */ + UNAUTHENTICATED, + + /** + * Client does not have sufficient permission. This can happen because the OAuth token does + * not have the right scopes, the client doesn't have permission, or the API has not been + * enabled for the client project. + */ + PERMISSION_DENIED, + + /** + * A specified resource is not found, or the request is rejected for unknown reasons, + * such as a blocked network address. + */ + NOT_FOUND, + + /** + * Concurrency conflict, such as read-modify-write conflict. + */ + CONFLICT, + + /** + * Concurrency conflict, such as read-modify-write conflict. + */ + ABORTED, + + /** + * The resource that a client tried to create already exists. + */ + ALREADY_EXISTS, + + /** + * Either out of resource quota or rate limited. + */ + RESOURCE_EXHAUSTED, + + /** + * Request cancelled by the client. + */ + CANCELLED, + + /** + * Unrecoverable data loss or data corruption. The client should report the error to the user. + */ + DATA_LOSS, + + /** + * Unknown server error. Typically a server bug. + */ + UNKNOWN, + + /** + * Internal server error. Typically a server bug. + */ + INTERNAL, + + /** + * Service unavailable. Typically the server is down. + */ + UNAVAILABLE, + + /** + * Request deadline exceeded. This happens only if the caller sets a deadline that is + * shorter than the method's default deadline (i.e. requested deadline is not enough for the + * server to process the request) and the request did not finish within the deadline. + */ + DEADLINE_EXCEEDED, +} diff --git a/src/main/java/com/google/firebase/FirebaseApp.java b/src/main/java/com/google/firebase/FirebaseApp.java index c60f0ad5e..27727c366 100644 --- a/src/main/java/com/google/firebase/FirebaseApp.java +++ b/src/main/java/com/google/firebase/FirebaseApp.java @@ -21,7 +21,6 @@ import static com.google.common.base.Preconditions.checkState; import static com.google.firebase.FirebaseOptions.APPLICATION_DEFAULT_CREDENTIALS; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonParser; import com.google.api.core.ApiFuture; @@ -34,9 +33,9 @@ import com.google.common.base.Joiner; import com.google.common.base.MoreObjects; import com.google.common.base.Strings; -import com.google.common.base.Supplier; import com.google.common.collect.ImmutableList; -import com.google.firebase.internal.FirebaseAppStore; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.FirebaseProcessEnvironment; import com.google.firebase.internal.FirebaseScheduledExecutor; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.ListenableFuture2ApiFuture; @@ -48,10 +47,8 @@ import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.concurrent.Callable; import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; @@ -121,7 +118,6 @@ private FirebaseApp(String name, FirebaseOptions options, TokenRefresher.Factory /** Returns a list of all FirebaseApps. */ public static List getApps() { - // TODO: reenable persistence. See b/28158809. synchronized (appsLock) { return ImmutableList.copyOf(instances.values()); } @@ -221,21 +217,16 @@ public static FirebaseApp initializeApp(FirebaseOptions options, String name) { static FirebaseApp initializeApp(FirebaseOptions options, String name, TokenRefresher.Factory tokenRefresherFactory) { - FirebaseAppStore appStore = FirebaseAppStore.initialize(); String normalizedName = normalize(name); - final FirebaseApp firebaseApp; synchronized (appsLock) { checkState( !instances.containsKey(normalizedName), "FirebaseApp name " + normalizedName + " already exists!"); - firebaseApp = new FirebaseApp(normalizedName, options, tokenRefresherFactory); + FirebaseApp firebaseApp = new FirebaseApp(normalizedName, options, tokenRefresherFactory); instances.put(normalizedName, firebaseApp); + return firebaseApp; } - - appStore.persistApp(firebaseApp); - - return firebaseApp; } @VisibleForTesting @@ -251,19 +242,13 @@ static void clearInstancesForTest() { } private static List getAllAppNames() { - Set allAppNames = new HashSet<>(); + List allAppNames; synchronized (appsLock) { - for (FirebaseApp app : instances.values()) { - allAppNames.add(app.getName()); - } - FirebaseAppStore appStore = FirebaseAppStore.getInstance(); - if (appStore != null) { - allAppNames.addAll(appStore.getAllPersistedAppNames()); - } + allAppNames = new ArrayList<>(instances.keySet()); } - List sortedNameList = new ArrayList<>(allAppNames); - Collections.sort(sortedNameList); - return sortedNameList; + + Collections.sort(allAppNames); + return ImmutableList.copyOf(allAppNames); } /** Normalizes the app name. */ @@ -293,6 +278,8 @@ public FirebaseOptions getOptions() { */ @Nullable String getProjectId() { + checkNotDeleted(); + // Try to get project ID from user-specified options. String projectId = options.getProjectId(); @@ -306,10 +293,10 @@ String getProjectId() { // Try to get project ID from the environment. if (Strings.isNullOrEmpty(projectId)) { - projectId = System.getenv("GOOGLE_CLOUD_PROJECT"); + projectId = FirebaseProcessEnvironment.getenv("GOOGLE_CLOUD_PROJECT"); } if (Strings.isNullOrEmpty(projectId)) { - projectId = System.getenv("GCLOUD_PROJECT"); + projectId = FirebaseProcessEnvironment.getenv("GCLOUD_PROJECT"); } return projectId; } @@ -330,8 +317,10 @@ public String toString() { } /** - * Deletes the {@link FirebaseApp} and all its data. All calls to this {@link FirebaseApp} - * instance will throw once it has been called. + * Deletes this {@link FirebaseApp} object, and releases any local state and managed resources + * associated with it. All calls to this {@link FirebaseApp} instance will throw once this method + * has been called. This also releases any managed resources allocated by other services + * attached to this object instance (e.g. {@code FirebaseAuth}). * *

A no-op if delete was called before. */ @@ -359,11 +348,6 @@ public void delete() { synchronized (appsLock) { instances.remove(name); } - - FirebaseAppStore appStore = FirebaseAppStore.getInstance(); - if (appStore != null) { - appStore.removeApp(name); - } } private void checkNotDeleted() { @@ -580,20 +564,19 @@ enum State { } private static FirebaseOptions getOptionsFromEnvironment() throws IOException { - String defaultConfig = System.getenv(FIREBASE_CONFIG_ENV_VAR); + String defaultConfig = FirebaseProcessEnvironment.getenv(FIREBASE_CONFIG_ENV_VAR); if (Strings.isNullOrEmpty(defaultConfig)) { - return new FirebaseOptions.Builder() + return FirebaseOptions.builder() .setCredentials(APPLICATION_DEFAULT_CREDENTIALS) .build(); } - JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - FirebaseOptions.Builder builder = new FirebaseOptions.Builder(); + JsonFactory jsonFactory = ApiClientUtils.getDefaultJsonFactory(); + FirebaseOptions.Builder builder = FirebaseOptions.builder(); JsonParser parser; if (defaultConfig.startsWith("{")) { parser = jsonFactory.createJsonParser(defaultConfig); } else { - FileReader reader; - reader = new FileReader(defaultConfig); + FileReader reader = new FileReader(defaultConfig); parser = jsonFactory.createJsonParser(reader); } parser.parseAndClose(builder); diff --git a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java index 60118c249..6493edb60 100644 --- a/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java +++ b/src/main/java/com/google/firebase/FirebaseAppLifecycleListener.java @@ -19,7 +19,7 @@ /** * A listener which gets notified when {@link com.google.firebase.FirebaseApp} gets deleted. */ -// TODO: consider making it public in a future release. +@Deprecated interface FirebaseAppLifecycleListener { /** diff --git a/src/main/java/com/google/firebase/FirebaseException.java b/src/main/java/com/google/firebase/FirebaseException.java index f78b3fb98..a5bb80424 100644 --- a/src/main/java/com/google/firebase/FirebaseException.java +++ b/src/main/java/com/google/firebase/FirebaseException.java @@ -17,24 +17,55 @@ package com.google.firebase; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; -/** Base class for all Firebase exceptions. */ +/** + * Base class for all Firebase exceptions. + */ public class FirebaseException extends Exception { - // TODO(b/27677218): Exceptions should have non-empty messages. - @Deprecated - protected FirebaseException() {} + private final ErrorCode errorCode; + private final IncomingHttpResponse httpResponse; + + public FirebaseException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse httpResponse) { + super(message, cause); + checkArgument(!Strings.isNullOrEmpty(message), "Message must not be null or empty"); + this.errorCode = checkNotNull(errorCode, "ErrorCode must not be null"); + this.httpResponse = httpResponse; + } + + public FirebaseException( + @NonNull ErrorCode errorCode, + @NonNull String message, + @Nullable Throwable cause) { + this(errorCode, message, cause, null); + } - public FirebaseException(@NonNull String detailMessage) { - super(detailMessage); - checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty"); + /** + * Returns the platform-wide error code associated with this exception. + * + * @return A Firebase error code. + */ + public final ErrorCode getErrorCode() { + return errorCode; } - public FirebaseException(@NonNull String detailMessage, Throwable cause) { - super(detailMessage, cause); - checkArgument(!Strings.isNullOrEmpty(detailMessage), "Detail message must not be empty"); + /** + * Returns the HTTP response that resulted in this exception. If the exception was not caused by + * an HTTP error response, returns null. + * + * @return An HTTP response or null. + */ + @Nullable + public final IncomingHttpResponse getHttpResponse() { + return httpResponse; } } diff --git a/src/main/java/com/google/firebase/FirebaseOptions.java b/src/main/java/com/google/firebase/FirebaseOptions.java index f0561d5e5..03f1b34a4 100644 --- a/src/main/java/com/google/firebase/FirebaseOptions.java +++ b/src/main/java/com/google/firebase/FirebaseOptions.java @@ -19,7 +19,6 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.googleapis.util.Utils; import com.google.api.client.http.HttpTransport; import com.google.api.client.json.JsonFactory; import com.google.api.client.util.Key; @@ -29,6 +28,8 @@ import com.google.common.base.Supplier; import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ApplicationDefaultCredentialsProvider; import com.google.firebase.internal.FirebaseThreadManagers; import com.google.firebase.internal.NonNull; import com.google.firebase.internal.Nullable; @@ -64,7 +65,8 @@ public final class FirebaseOptions { @Override public GoogleCredentials get() { try { - return GoogleCredentials.getApplicationDefault().createScoped(FIREBASE_SCOPES); + return ApplicationDefaultCredentialsProvider.getApplicationDefault() + .createScoped(FIREBASE_SCOPES); } catch (IOException e) { throw new IllegalStateException(e); } @@ -80,6 +82,7 @@ public GoogleCredentials get() { private final HttpTransport httpTransport; private final int connectTimeout; private final int readTimeout; + private final int writeTimeout; private final JsonFactory jsonFactory; private final ThreadManager threadManager; private final FirestoreOptions firestoreOptions; @@ -100,16 +103,18 @@ private FirebaseOptions(@NonNull final FirebaseOptions.Builder builder) { this.serviceAccountId = null; } this.storageBucket = builder.storageBucket; - this.httpTransport = checkNotNull(builder.httpTransport, - "FirebaseOptions must be initialized with a non-null HttpTransport."); - this.jsonFactory = checkNotNull(builder.jsonFactory, - "FirebaseOptions must be initialized with a non-null JsonFactory."); - this.threadManager = checkNotNull(builder.threadManager, - "FirebaseOptions must be initialized with a non-null ThreadManager."); + this.httpTransport = builder.httpTransport != null ? builder.httpTransport + : ApiClientUtils.getDefaultTransport(); + this.jsonFactory = builder.jsonFactory != null ? builder.jsonFactory + : ApiClientUtils.getDefaultJsonFactory(); + this.threadManager = builder.threadManager != null ? builder.threadManager + : FirebaseThreadManagers.DEFAULT_THREAD_MANAGER; checkArgument(builder.connectTimeout >= 0); this.connectTimeout = builder.connectTimeout; checkArgument(builder.readTimeout >= 0); this.readTimeout = builder.readTimeout; + checkArgument(builder.writeTimeout >= 0); + this.writeTimeout = builder.writeTimeout; this.firestoreOptions = builder.firestoreOptions; } @@ -196,8 +201,7 @@ public int getConnectTimeout() { } /** - * Returns the read timeout in milliseconds, which is applied to outgoing REST calls - * made by the SDK. + * Returns the read timeout applied to outgoing REST calls in milliseconds. * * @return Read timeout in milliseconds. 0 indicates an infinite timeout. */ @@ -205,6 +209,15 @@ public int getReadTimeout() { return readTimeout; } + /** + * Returns the write timeout applied to outgoing REST calls in milliseconds. + * + * @return Write timeout in milliseconds. 0 indicates an infinite timeout. + */ + public int getWriteTimeout() { + return writeTimeout; + } + @NonNull ThreadManager getThreadManager() { return threadManager; @@ -224,18 +237,28 @@ public static Builder builder() { } /** - * Builder for constructing {@link FirebaseOptions}. + * Creates a new {@code Builder} from the options object. + * + *

The new builder is not backed by this object's values; that is, changes made to the new + * builder don't change the values of the origin object. + */ + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Builder for constructing {@link FirebaseOptions}. */ public static final class Builder { @Key("databaseAuthVariableOverride") private Map databaseAuthVariableOverride = new HashMap<>(); - + @Key("databaseUrl") private String databaseUrl; @Key("projectId") private String projectId; - + @Key("storageBucket") private String storageBucket; @@ -243,13 +266,19 @@ public static final class Builder { private String serviceAccountId; private Supplier credentialsSupplier; private FirestoreOptions firestoreOptions; - private HttpTransport httpTransport = Utils.getDefaultTransport(); - private JsonFactory jsonFactory = Utils.getDefaultJsonFactory(); - private ThreadManager threadManager = FirebaseThreadManagers.DEFAULT_THREAD_MANAGER; + private HttpTransport httpTransport; + private JsonFactory jsonFactory; + private ThreadManager threadManager; private int connectTimeout; private int readTimeout; + private int writeTimeout; - /** Constructs an empty builder. */ + /** + * Constructs an empty builder. + * + * @deprecated Use {@link FirebaseOptions#builder()} instead. + */ + @Deprecated public Builder() {} /** @@ -257,7 +286,10 @@ public Builder() {} * *

The new builder is not backed by this object's values, that is changes made to the new * builder don't change the values of the origin object. + * + * @deprecated Use {@link FirebaseOptions#toBuilder()} instead. */ + @Deprecated public Builder(FirebaseOptions options) { databaseUrl = options.databaseUrl; storageBucket = options.storageBucket; @@ -269,6 +301,7 @@ public Builder(FirebaseOptions options) { threadManager = options.threadManager; connectTimeout = options.connectTimeout; readTimeout = options.readTimeout; + writeTimeout = options.writeTimeout; firestoreOptions = options.firestoreOptions; } @@ -401,7 +434,8 @@ public Builder setServiceAccountId(@NonNull String serviceAccountId) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setHttpTransport(HttpTransport httpTransport) { - this.httpTransport = httpTransport; + this.httpTransport = checkNotNull(httpTransport, + "FirebaseOptions must be initialized with a non-null HttpTransport."); return this; } @@ -413,7 +447,8 @@ public Builder setHttpTransport(HttpTransport httpTransport) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setJsonFactory(JsonFactory jsonFactory) { - this.jsonFactory = jsonFactory; + this.jsonFactory = checkNotNull(jsonFactory, + "FirebaseOptions must be initialized with a non-null JsonFactory."); return this; } @@ -425,7 +460,8 @@ public Builder setJsonFactory(JsonFactory jsonFactory) { * @return This Builder instance is returned so subsequent calls can be chained. */ public Builder setThreadManager(ThreadManager threadManager) { - this.threadManager = threadManager; + this.threadManager = checkNotNull(threadManager, + "FirebaseOptions must be initialized with a non-null ThreadManager."); return this; } @@ -472,6 +508,19 @@ public Builder setReadTimeout(int readTimeout) { return this; } + /** + * Sets the write timeout for outgoing HTTP (REST) calls made by the SDK. This does not affect + * the {@link com.google.firebase.database.FirebaseDatabase} and + * {@link com.google.firebase.cloud.FirestoreClient} APIs. + * + * @param writeTimeout Write timeout in milliseconds. Must not be negative. + * @return This Builder instance is returned so subsequent calls can be chained. + */ + public Builder setWriteTimeout(int writeTimeout) { + this.writeTimeout = writeTimeout; + return this; + } + /** * Builds the {@link FirebaseOptions} instance from the previously set options. * diff --git a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java index 7a50e0b07..0d3008b18 100644 --- a/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java +++ b/src/main/java/com/google/firebase/ImplFirebaseTrampolines.java @@ -22,11 +22,12 @@ import com.google.api.core.ApiFutures; import com.google.auth.oauth2.GoogleCredentials; import com.google.cloud.firestore.FirestoreOptions; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.EmulatorCredentials; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; import java.util.concurrent.Callable; -import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.ThreadFactory; @@ -42,6 +43,9 @@ public final class ImplFirebaseTrampolines { private ImplFirebaseTrampolines() {} public static GoogleCredentials getCredentials(@NonNull FirebaseApp app) { + if (Utils.isEmulatorMode()) { + return new EmulatorCredentials(); + } return app.getOptions().getCredentials(); } diff --git a/src/main/java/com/google/firebase/IncomingHttpResponse.java b/src/main/java/com/google/firebase/IncomingHttpResponse.java new file mode 100644 index 000000000..cfeac5e70 --- /dev/null +++ b/src/main/java/com/google/firebase/IncomingHttpResponse.java @@ -0,0 +1,114 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.database.annotations.Nullable; +import java.util.Map; + +/** + * Contains information that describes an HTTP response received by the SDK. + */ +public final class IncomingHttpResponse { + + private final int statusCode; + private final String content; + private final Map headers; + private final OutgoingHttpRequest request; + + /** + * Creates an {@code IncomingHttpResponse} from a successful response and the content read + * from it. The caller is expected to read the content from the response, and handle any errors + * that may occur while reading. + * + * @param response A successful response. + * @param content Content read from the response. + */ + public IncomingHttpResponse(HttpResponse response, @Nullable String content) { + checkNotNull(response, "response must not be null"); + this.statusCode = response.getStatusCode(); + this.content = content; + this.headers = ImmutableMap.copyOf(response.getHeaders()); + this.request = new OutgoingHttpRequest(response.getRequest()); + } + + /** + * Creates an {@code IncomingHttpResponse} from an HTTP error response. + * + * @param e The exception representing the HTTP error response. + * @param request The request that resulted in the error. + */ + public IncomingHttpResponse(HttpResponseException e, HttpRequest request) { + this(e, new OutgoingHttpRequest(request)); + } + + /** + * Creates an {@code IncomingHttpResponse} from an HTTP error response. + * + * @param e The exception representing the HTTP error response. + * @param request The request that resulted in the error. + */ + public IncomingHttpResponse(HttpResponseException e, OutgoingHttpRequest request) { + checkNotNull(e, "exception must not be null"); + this.statusCode = e.getStatusCode(); + this.content = e.getContent(); + this.headers = ImmutableMap.copyOf(e.getHeaders()); + this.request = checkNotNull(request, "request must not be null"); + } + + /** + * Returns the status code of the response. + * + * @return An HTTP status code (e.g. 500). + */ + public int getStatusCode() { + return this.statusCode; + } + + /** + * Returns the content of the response as a string. + * + * @return HTTP content or null. + */ + @Nullable + public String getContent() { + return this.content; + } + + /** + * Returns the headers set on the response. + * + * @return An immutable map of headers (possibly empty). + */ + public Map getHeaders() { + return this.headers; + } + + /** + * Returns the request that resulted in this response. + * + * @return An HTTP request. + */ + public OutgoingHttpRequest getRequest() { + return request; + } +} diff --git a/src/main/java/com/google/firebase/OutgoingHttpRequest.java b/src/main/java/com/google/firebase/OutgoingHttpRequest.java new file mode 100644 index 000000000..44af4bff0 --- /dev/null +++ b/src/main/java/com/google/firebase/OutgoingHttpRequest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpRequest; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.Nullable; +import java.util.Map; + +/** + * Contains the information that describe an HTTP request made by the SDK. + */ +public final class OutgoingHttpRequest { + + private final String method; + private final String url; + private final HttpContent content; + private final Map headers; + + /** + * Creates an {@code OutgoingHttpRequest} from the HTTP method and URL. + * + * @param method HTTP method name. + * @param url Target HTTP URL of the request. + */ + public OutgoingHttpRequest(String method, String url) { + checkArgument(!Strings.isNullOrEmpty(method), "method must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(url), "url must not be empty"); + this.method = method; + this.url = url; + this.content = null; + this.headers = ImmutableMap.of(); + } + + OutgoingHttpRequest(HttpRequest request) { + checkNotNull(request, "request must not be null"); + this.method = request.getRequestMethod(); + this.url = request.getUrl().toString(); + this.content = request.getContent(); + this.headers = ImmutableMap.copyOf(request.getHeaders()); + } + + /** + * Returns the HTTP method of the request. + * + * @return An HTTP method string (e.g. GET). + */ + public String getMethod() { + return method; + } + + /** + * Returns the URL of the request. + * + * @return An absolute HTTP URL. + */ + public String getUrl() { + return url; + } + + /** + * Returns any content that was sent with the request. + * + * @return HTTP content or null. + */ + @Nullable + public HttpContent getContent() { + return content; + } + + /** + * Returns the headers set on the request. + * + * @return An immutable map of headers (possibly empty). + */ + public Map getHeaders() { + return headers; + } +} diff --git a/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java new file mode 100644 index 000000000..44fe9b7d2 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/AbstractFirebaseAuth.java @@ -0,0 +1,1847 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.firebase.auth.internal.Utils.isEmulatorMode; + +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Clock; +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.base.Supplier; +import com.google.common.base.Suppliers; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseUserManager.EmailLinkType; +import com.google.firebase.auth.FirebaseUserManager.UserImportRequest; +import com.google.firebase.auth.ListProviderConfigsPage.DefaultOidcProviderConfigSource; +import com.google.firebase.auth.ListProviderConfigsPage.DefaultSamlProviderConfigSource; +import com.google.firebase.auth.ListUsersPage.DefaultUserSource; +import com.google.firebase.auth.internal.FirebaseTokenFactory; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * This is the abstract class for server-side Firebase Authentication actions. + */ +public abstract class AbstractFirebaseAuth { + + private final Object lock = new Object(); + private final AtomicBoolean destroyed = new AtomicBoolean(false); + + private final FirebaseApp firebaseApp; + private final Supplier tokenFactory; + private final Supplier idTokenVerifier; + private final Supplier cookieVerifier; + private final Supplier userManager; + private final JsonFactory jsonFactory; + + protected AbstractFirebaseAuth(Builder builder) { + this.firebaseApp = checkNotNull(builder.firebaseApp); + this.tokenFactory = threadSafeMemoize(builder.tokenFactory); + this.idTokenVerifier = threadSafeMemoize(builder.idTokenVerifier); + this.cookieVerifier = threadSafeMemoize(builder.cookieVerifier); + this.userManager = threadSafeMemoize(builder.userManager); + this.jsonFactory = firebaseApp.getOptions().getJsonFactory(); + } + + /** + * Creates a Firebase custom token for the given UID. This token can then be sent back to a client + * application to be used with the signInWithCustomToken + * authentication API. + * + *

{@link FirebaseApp} must have been initialized with service account credentials to use call + * this method. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @return A Firebase custom token string. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + * @throws FirebaseAuthException If an error occurs while generating the custom token. + */ + public String createCustomToken(@NonNull String uid) throws FirebaseAuthException { + return createCustomToken(uid, null); + } + + /** + * Creates a Firebase custom token for the given UID, containing the specified additional claims. + * This token can then be sent back to a client application to be used with the signInWithCustomToken + * authentication API. + * + *

This method attempts to generate a token using: + * + *

    + *
  1. the private key of {@link FirebaseApp}'s service account credentials, if provided at + * initialization. + *
  2. the IAM + * service if a service account email was specified via {@link + * com.google.firebase.FirebaseOptions.Builder#setServiceAccountId(String)}. + *
  3. the App + * Identity service if the code is deployed in the Google App Engine standard + * environment. + *
  4. the local + * Metadata server if the code is deployed in a different GCP-managed environment like + * Google Compute Engine. + *
+ * + *

This method throws an exception when all the above fail. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @param developerClaims Additional claims to be stored in the token (and made available to + * security rules in Database, Storage, etc.). These must be able to be serialized to JSON + * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) + * @return A Firebase custom token string. + * @throws IllegalArgumentException If the specified uid is null or empty. + * @throws IllegalStateException If the SDK fails to discover a viable approach for signing + * tokens. + * @throws FirebaseAuthException If an error occurs while generating the custom token. + */ + public String createCustomToken( + @NonNull String uid, @Nullable Map developerClaims) + throws FirebaseAuthException { + return createCustomTokenOp(uid, developerClaims).call(); + } + + /** + * Similar to {@link #createCustomToken(String)} but performs the operation asynchronously. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. + * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom + * token, or unsuccessfully with the failure Exception. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + */ + public ApiFuture createCustomTokenAsync(@NonNull String uid) { + return createCustomTokenAsync(uid, null); + } + + /** + * Similar to {@link #createCustomToken(String, Map)} but performs the operation asynchronously. + * + * @param uid The UID to store in the token. This identifies the user to other Firebase services + * (Realtime Database, Storage, etc.). Should be less than 128 characters. + * @param developerClaims Additional claims to be stored in the token (and made available to + * security rules in Database, Storage, etc.). These must be able to be serialized to JSON + * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) + * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom + * token, or unsuccessfully with the failure Exception. + * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not + * been initialized with service account credentials. + */ + public ApiFuture createCustomTokenAsync( + @NonNull String uid, @Nullable Map developerClaims) { + return createCustomTokenOp(uid, developerClaims).callAsync(firebaseApp); + } + + private CallableOperation createCustomTokenOp( + final String uid, final Map developerClaims) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseTokenFactory tokenFactory = this.tokenFactory.get(); + return new CallableOperation() { + @Override + public String execute() throws FirebaseAuthException { + return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims); + } + }; + } + + /** + * Creates a new Firebase session cookie from the given ID token and options. The returned JWT can + * be set as a server-side session cookie with a custom cookie policy. + * + * @param idToken The Firebase ID token to exchange for a session cookie. + * @param options Additional options required to create the cookie. + * @return A Firebase session cookie string. + * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. + * @throws FirebaseAuthException If an error occurs while generating the session cookie. + */ + public String createSessionCookie(@NonNull String idToken, @NonNull SessionCookieOptions options) + throws FirebaseAuthException { + return createSessionCookieOp(idToken, options).call(); + } + + /** + * Similar to {@link #createSessionCookie(String, SessionCookieOptions)} but performs the + * operation asynchronously. + * + * @param idToken The Firebase ID token to exchange for a session cookie. + * @param options Additional options required to create the cookie. + * @return An {@code ApiFuture} which will complete successfully with a session cookie string. If + * an error occurs while generating the cookie or if the specified ID token is invalid, the + * future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. + */ + public ApiFuture createSessionCookieAsync( + @NonNull String idToken, @NonNull SessionCookieOptions options) { + return createSessionCookieOp(idToken, options).callAsync(firebaseApp); + } + + private CallableOperation createSessionCookieOp( + final String idToken, final SessionCookieOptions options) { + checkArgument(!Strings.isNullOrEmpty(idToken), "idToken must not be null or empty"); + checkNotNull(options, "options must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return userManager.createSessionCookie(idToken, options); + } + }; + } + + /** + * Parses and verifies a Firebase ID Token. + * + *

A Firebase application can identify itself to a trusted backend server by sending its + * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication + * client) with its requests. The backend server can then use the {@code verifyIdToken()} method + * to verify that the token is valid. This method ensures that the token is correctly signed, has + * not expired, and it was issued to the Firebase project associated with this {@link + * FirebaseAuth} instance. + * + *

This method does not check whether a token has been revoked. Use {@link + * #verifyIdToken(String, boolean)} to perform an additional revocation check. + * + * @param idToken A Firebase ID token string to parse and verify. + * @return A {@link FirebaseToken} representing the verified and decoded token. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token. + */ + public FirebaseToken verifyIdToken(@NonNull String idToken) throws FirebaseAuthException { + return verifyIdToken(idToken, false); + } + + /** + * Parses and verifies a Firebase ID Token. + * + *

A Firebase application can identify itself to a trusted backend server by sending its + * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication + * client) with its requests. The backend server can then use the {@code verifyIdToken()} method + * to verify that the token is valid. This method ensures that the token is correctly signed, has + * not expired, and it was issued to the Firebase project associated with this {@link + * FirebaseAuth} instance. + * + *

If {@code checkRevoked} is set to true, this method performs an additional check to see if + * the ID token has been revoked since it was issues. This requires making an additional remote + * API call. + * + * @param idToken A Firebase ID token string to parse and verify. + * @param checkRevoked A boolean denoting whether to check if the tokens were revoked or if + * the user is disabled. + * @return A {@link FirebaseToken} representing the verified and decoded token. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token, or if + * the user is disabled. + */ + public FirebaseToken verifyIdToken(@NonNull String idToken, boolean checkRevoked) + throws FirebaseAuthException { + return verifyIdTokenOp(idToken, checkRevoked).call(); + } + + /** + * Similar to {@link #verifyIdToken(String)} but performs the operation asynchronously. + * + * @param idToken A Firebase ID Token to verify and parse. + * @return An {@code ApiFuture} which will complete successfully with the parsed token, or + * unsuccessfully with a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + */ + public ApiFuture verifyIdTokenAsync(@NonNull String idToken) { + return verifyIdTokenAsync(idToken, false); + } + + /** + * Similar to {@link #verifyIdToken(String, boolean)} but performs the operation asynchronously. + * + * @param idToken A Firebase ID Token to verify and parse. + * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. + * @return An {@code ApiFuture} which will complete successfully with the parsed token, or + * unsuccessfully with a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} + * instance does not have a project ID associated with it. + */ + public ApiFuture + verifyIdTokenAsync(@NonNull String idToken, boolean checkRevoked) { + return verifyIdTokenOp(idToken, checkRevoked).callAsync(firebaseApp); + } + + private CallableOperation verifyIdTokenOp( + final String idToken, final boolean checkRevoked) { + checkArgument(!Strings.isNullOrEmpty(idToken), "ID token must not be null or empty"); + final FirebaseTokenVerifier verifier = getIdTokenVerifier(checkRevoked); + return new CallableOperation() { + @Override + protected FirebaseToken execute() throws FirebaseAuthException { + return verifier.verifyToken(idToken); + } + }; + } + + @VisibleForTesting + FirebaseTokenVerifier getIdTokenVerifier(boolean checkRevoked) { + FirebaseTokenVerifier verifier = idTokenVerifier.get(); + if (checkRevoked || isEmulatorMode()) { + FirebaseUserManager userManager = getUserManager(); + verifier = RevocationCheckDecorator.decorateIdTokenVerifier(verifier, userManager); + } + return verifier; + } + + /** + * Parses and verifies a Firebase session cookie. + * + *

If verified successfully, returns a parsed version of the cookie from which the UID and the + * other claims can be read. If the cookie is invalid, throws a {@link FirebaseAuthException}. + * + *

This method does not check whether the cookie has been revoked. See {@link + * #verifySessionCookie(String, boolean)}. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @return A {@link FirebaseToken} representing the verified and decoded cookie. + */ + public FirebaseToken verifySessionCookie(String cookie) throws FirebaseAuthException { + return verifySessionCookie(cookie, false); + } + + /** + * Parses and verifies a Firebase session cookie. + * + *

If {@code checkRevoked} is true, additionally verifies that the cookie has not been revoked. + * + *

If verified successfully, returns a parsed version of the cookie from which the UID and the + * other claims can be read. If the cookie is invalid or has been revoked while {@code + * checkRevoked} is true, throws a {@link FirebaseAuthException}. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly revoked + * or if the user is disabled. + * @return A {@link FirebaseToken} representing the verified and decoded cookie. + * @throws FirebaseAuthException If an error occurs while parsing or validating the token, or if + * the user is disabled. + */ + public FirebaseToken verifySessionCookie(String cookie, boolean checkRevoked) + throws FirebaseAuthException { + return verifySessionCookieOp(cookie, checkRevoked).call(); + } + + /** + * Similar to {@link #verifySessionCookie(String)} but performs the operation asynchronously. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or + * unsuccessfully with the failure Exception. + */ + public ApiFuture verifySessionCookieAsync(String cookie) { + return verifySessionCookieAsync(cookie, false); + } + + /** + * Similar to {@link #verifySessionCookie(String, boolean)} but performs the operation + * asynchronously. + * + * @param cookie A Firebase session cookie string to verify and parse. + * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly revoked. + * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or + * unsuccessfully with the failure Exception. + */ + public ApiFuture verifySessionCookieAsync(String cookie, boolean checkRevoked) { + return verifySessionCookieOp(cookie, checkRevoked).callAsync(firebaseApp); + } + + private CallableOperation verifySessionCookieOp( + final String cookie, final boolean checkRevoked) { + checkArgument(!Strings.isNullOrEmpty(cookie), "Session cookie must not be null or empty"); + final FirebaseTokenVerifier sessionCookieVerifier = getSessionCookieVerifier(checkRevoked); + return new CallableOperation() { + @Override + public FirebaseToken execute() throws FirebaseAuthException { + return sessionCookieVerifier.verifyToken(cookie); + } + }; + } + + @VisibleForTesting + FirebaseTokenVerifier getSessionCookieVerifier(boolean checkRevoked) { + FirebaseTokenVerifier verifier = cookieVerifier.get(); + if (checkRevoked || isEmulatorMode()) { + FirebaseUserManager userManager = getUserManager(); + verifier = RevocationCheckDecorator.decorateSessionCookieVerifier(verifier, userManager); + } + return verifier; + } + + /** + * Revokes all refresh tokens for the specified user. + * + *

Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in + * milliseconds since the epoch and truncated to 1 second accuracy. It is important that the + * server on which this is called has its clock set correctly and synchronized. + * + *

While this will revoke all sessions for a specified user and disable any new ID tokens for + * existing sessions from getting minted, existing ID tokens may remain active until their natural + * expiration (one hour). To verify that ID tokens are revoked, use {@link + * #verifyIdTokenAsync(String, boolean)}. + * + * @param uid The user id for which tokens are revoked. + * @throws IllegalArgumentException If the user ID is null or empty. + * @throws FirebaseAuthException If an error occurs while revoking tokens. + */ + public void revokeRefreshTokens(@NonNull String uid) throws FirebaseAuthException { + revokeRefreshTokensOp(uid).call(); + } + + /** + * Similar to {@link #revokeRefreshTokens(String)} but performs the operation asynchronously. + * + * @param uid The user id for which tokens are revoked. + * @return An {@code ApiFuture} which will complete successfully or fail with a {@link + * FirebaseAuthException} in the event of an error. + * @throws IllegalArgumentException If the user ID is null or empty. + */ + public ApiFuture revokeRefreshTokensAsync(@NonNull String uid) { + return revokeRefreshTokensOp(uid).callAsync(firebaseApp); + } + + private CallableOperation revokeRefreshTokensOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); + UserRecord.UpdateRequest request = + new UserRecord.UpdateRequest(uid).setValidSince(currentTimeSeconds); + userManager.updateUser(request, jsonFactory); + return null; + } + }; + } + + /** + * Gets the user data corresponding to the specified user ID. + * + * @param uid A user ID string. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the user ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUser(@NonNull String uid) throws FirebaseAuthException { + return getUserOp(uid).call(); + } + + /** + * Similar to {@link #getUser(String)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the specified user ID does + * not exist, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture getUserAsync(@NonNull String uid) { + return getUserOp(uid).callAsync(firebaseApp); + } + + private CallableOperation getUserOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserById(uid); + } + }; + } + + /** + * Gets the user data corresponding to the specified user email. + * + * @param email A user email address string. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the email is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByEmail(@NonNull String email) throws FirebaseAuthException { + return getUserByEmailOp(email).call(); + } + + /** + * Similar to {@link #getUserByEmail(String)} but performs the operation asynchronously. + * + * @param email A user email address string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the email address does not + * correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the email is null or empty. + */ + public ApiFuture getUserByEmailAsync(@NonNull String email) { + return getUserByEmailOp(email).callAsync(firebaseApp); + } + + private CallableOperation getUserByEmailOp( + final String email) { + checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByEmail(email); + } + }; + } + + /** + * Gets the user data corresponding to the specified user phone number. + * + * @param phoneNumber A user phone number string. + * @return A a {@link UserRecord} instance. + * @throws IllegalArgumentException If the phone number is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByPhoneNumber(@NonNull String phoneNumber) throws FirebaseAuthException { + return getUserByPhoneNumberOp(phoneNumber).call(); + } + + /** + * Gets the user data corresponding to the specified user phone number. + * + * @param phoneNumber A user phone number string. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the phone number does not + * correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the phone number is null or empty. + */ + public ApiFuture getUserByPhoneNumberAsync(@NonNull String phoneNumber) { + return getUserByPhoneNumberOp(phoneNumber).callAsync(firebaseApp); + } + + private CallableOperation getUserByPhoneNumberOp( + final String phoneNumber) { + checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByPhoneNumber(phoneNumber); + } + }; + } + + /** + * Gets the user data for the user corresponding to a given provider ID. + * + * @param providerId Identifier for the given federated provider: for example, + * "google.com" for the Google provider. + * @param uid The user identifier with the given provider. + * @return A {@link UserRecord} instance. + * @throws IllegalArgumentException If the uid is null or empty, or if + * the providerId is null, empty, or does not belong to a federated provider. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public UserRecord getUserByProviderUid( + @NonNull String providerId, @NonNull String uid) throws FirebaseAuthException { + return getUserByProviderUidOp(providerId, uid).call(); + } + + /** + * Gets the user data for the user corresponding to a given provider ID. + * + * @param providerId Identifer for the given federated provider: for example, + * "google.com" for the Google provider. + * @param uid The user identifier with the given provider. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance. If an error occurs while retrieving user data or if the provider ID and uid + * do not correspond to a user, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the uid is null or empty, or if + * the provider ID is null, empty, or does not belong to a federated provider. + */ + public ApiFuture getUserByProviderUidAsync( + @NonNull String providerId, @NonNull String uid) { + return getUserByProviderUidOp(providerId, uid).callAsync(firebaseApp); + } + + private CallableOperation getUserByProviderUidOp( + final String providerId, final String uid) { + checkArgument(!Strings.isNullOrEmpty(providerId), "providerId must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + + // Although we don't really advertise it, we want to also handle + // non-federated idps with this call. So if we detect one of them, we'll + // reroute this request appropriately. + if ("phone".equals(providerId)) { + return this.getUserByPhoneNumberOp(uid); + } + if ("email".equals(providerId)) { + return this.getUserByEmailOp(uid); + } + + checkArgument(!providerId.equals("password") + && !providerId.equals("anonymous"), "providerId must belong to a federated provider"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + return userManager.getUserByProviderUid(providerId, uid); + } + }; + } + + /** + * Gets a page of users starting from the specified {@code pageToken}. Page size is limited to + * 1000 users. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @return A {@link ListUsersPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public ListUsersPage listUsers(@Nullable String pageToken) throws FirebaseAuthException { + return listUsers(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); + } + + /** + * Gets a page of users starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @param maxResults Maximum number of users to include in the returned page. This may not exceed + * 1000. + * @return A {@link ListUsersPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public ListUsersPage listUsers(@Nullable String pageToken, int maxResults) + throws FirebaseAuthException { + return listUsersOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listUsers(String)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} + * instance. If an error occurs while retrieving user data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture listUsersAsync(@Nullable String pageToken) { + return listUsersAsync(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); + } + + /** + * Similar to {@link #listUsers(String, int)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of users. + * @param maxResults Maximum number of users to include in the returned page. This may not exceed + * 1000. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} + * instance. If an error occurs while retrieving user data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture listUsersAsync(@Nullable String pageToken, int maxResults) { + return listUsersOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation listUsersOp( + @Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultUserSource source = new DefaultUserSource(userManager, jsonFactory); + final ListUsersPage.Factory factory = new ListUsersPage.Factory(source, maxResults, pageToken); + return new CallableOperation() { + @Override + protected ListUsersPage execute() throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Creates a new user account with the attributes contained in the specified {@link + * UserRecord.CreateRequest}. + * + * @param request A non-null {@link UserRecord.CreateRequest} instance. + * @return A {@link UserRecord} instance corresponding to the newly created account. + * @throws NullPointerException if the provided request is null. + * @throws FirebaseAuthException if an error occurs while creating the user account. + */ + public UserRecord createUser(@NonNull UserRecord.CreateRequest request) + throws FirebaseAuthException { + return createUserOp(request).call(); + } + + /** + * Similar to {@link #createUser} but performs the operation asynchronously. + * + * @param request A non-null {@link UserRecord.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance corresponding to the newly created account. If an error occurs while creating the + * user account, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + */ + public ApiFuture createUserAsync(@NonNull UserRecord.CreateRequest request) { + return createUserOp(request).callAsync(firebaseApp); + } + + private CallableOperation createUserOp( + final UserRecord.CreateRequest request) { + checkNotNull(request, "create request must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + String uid = userManager.createUser(request); + return userManager.getUserById(uid); + } + }; + } + + /** + * Updates an existing user account with the attributes contained in the specified {@link + * UserRecord.UpdateRequest}. + * + * @param request A non-null {@link UserRecord.UpdateRequest} instance. + * @return A {@link UserRecord} instance corresponding to the updated user account. + * @throws NullPointerException if the provided update request is null. + * @throws FirebaseAuthException if an error occurs while updating the user account. + */ + public UserRecord updateUser(@NonNull UserRecord.UpdateRequest request) + throws FirebaseAuthException { + return updateUserOp(request).call(); + } + + /** + * Similar to {@link #updateUser} but performs the operation asynchronously. + * + * @param request A non-null {@link UserRecord.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} + * instance corresponding to the updated user account. If an error occurs while updating the + * user account, the future throws a {@link FirebaseAuthException}. + */ + public ApiFuture updateUserAsync(@NonNull UserRecord.UpdateRequest request) { + return updateUserOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateUserOp( + final UserRecord.UpdateRequest request) { + checkNotNull(request, "update request must not be null"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserRecord execute() throws FirebaseAuthException { + userManager.updateUser(request, jsonFactory); + return userManager.getUserById(request.getUid()); + } + }; + } + + /** + * Sets the specified custom claims on an existing user account. A null claims value removes any + * claims currently set on the user account. The claims should serialize into a valid JSON string. + * The serialized claims must not be larger than 1000 characters. + * + * @param uid A user ID string. + * @param claims A map of custom claims or null. + * @throws FirebaseAuthException If an error occurs while updating custom claims. + * @throws IllegalArgumentException If the user ID string is null or empty, or the claims payload + * is invalid or too large. + */ + public void setCustomUserClaims(@NonNull String uid, @Nullable Map claims) + throws FirebaseAuthException { + setCustomUserClaimsOp(uid, claims).call(); + } + + /** + * @deprecated Use {@link #setCustomUserClaims(String, Map)} instead. + */ + public void setCustomClaims(@NonNull String uid, @Nullable Map claims) + throws FirebaseAuthException { + setCustomUserClaims(uid, claims); + } + + /** + * Similar to {@link #setCustomUserClaims(String, Map)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @param claims A map of custom claims or null. + * @return An {@code ApiFuture} which will complete successfully when the user account has been + * updated. If an error occurs while deleting the user account, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture setCustomUserClaimsAsync( + @NonNull String uid, @Nullable Map claims) { + return setCustomUserClaimsOp(uid, claims).callAsync(firebaseApp); + } + + private CallableOperation setCustomUserClaimsOp( + final String uid, final Map claims) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + final UserRecord.UpdateRequest request = + new UserRecord.UpdateRequest(uid).setCustomClaims(claims); + userManager.updateUser(request, jsonFactory); + return null; + } + }; + } + + /** + * Deletes the user identified by the specified user ID. + * + * @param uid A user ID string. + * @throws IllegalArgumentException If the user ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while deleting the user. + */ + public void deleteUser(@NonNull String uid) throws FirebaseAuthException { + deleteUserOp(uid).call(); + } + + /** + * Similar to {@link #deleteUser(String)} but performs the operation asynchronously. + * + * @param uid A user ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified user account + * has been deleted. If an error occurs while deleting the user account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the user ID string is null or empty. + */ + public ApiFuture deleteUserAsync(String uid) { + return deleteUserOp(uid).callAsync(firebaseApp); + } + + private CallableOperation deleteUserOp(final String uid) { + checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteUser(uid); + return null; + } + }; + } + + /** + * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a + * time. This operation is optimized for bulk imports and will ignore checks on identifier + * uniqueness which could result in duplications. + * + *

{@link UserImportOptions} is required to import users with passwords. See {@link + * #importUsers(List, UserImportOptions)}. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @return A {@link UserImportResult} instance. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password. + * @throws FirebaseAuthException If an error occurs while importing users. + */ + public UserImportResult importUsers(List users) throws FirebaseAuthException { + return importUsers(users, null); + } + + /** + * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a + * time. This operation is optimized for bulk imports and will ignore checks on identifier + * uniqueness which could result in duplications. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @param options a {@link UserImportOptions} instance or null. Required when importing users with + * passwords. + * @return A {@link UserImportResult} instance. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password, and options is null. + * @throws FirebaseAuthException If an error occurs while importing users. + */ + public UserImportResult importUsers( + List users, @Nullable UserImportOptions options) + throws FirebaseAuthException { + return importUsersOp(users, options).call(); + } + + /** + * Similar to {@link #importUsers(List)} but performs the operation asynchronously. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @return An {@code ApiFuture} which will complete successfully when the user accounts are + * imported. If an error occurs while importing the users, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password. + */ + public ApiFuture importUsersAsync(List users) { + return importUsersAsync(users, null); + } + + /** + * Similar to {@link #importUsers(List, UserImportOptions)} but performs the operation + * asynchronously. + * + * @param users A non-empty list of users to be imported. Length must not exceed 1000. + * @param options a {@link UserImportOptions} instance or null. Required when importing users with + * passwords. + * @return An {@code ApiFuture} which will complete successfully when the user accounts are + * imported. If an error occurs while importing the users, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 + * elements. Or if at least one user specifies a password, and options is null. + */ + public ApiFuture importUsersAsync( + List users, @Nullable UserImportOptions options) { + return importUsersOp(users, options).callAsync(firebaseApp); + } + + private CallableOperation importUsersOp( + final List users, final UserImportOptions options) { + final UserImportRequest request = new UserImportRequest(users, options, jsonFactory); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected UserImportResult execute() throws FirebaseAuthException { + return userManager.importUsers(request); + } + }; + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + *

There are no ordering guarantees; in particular, the nth entry in the users result list is + * not guaranteed to correspond to the nth entry in the input parameters list. + * + *

A maximum of 100 identifiers may be specified. If more than 100 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + * @param identifiers The identifiers used to indicate which user records should be returned. Must + * have 100 or fewer entries. + * @return The corresponding user records. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + * @throws NullPointerException If the identifiers parameter is null. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public GetUsersResult getUsers(@NonNull Collection identifiers) + throws FirebaseAuthException { + return getUsersOp(identifiers).call(); + } + + /** + * Gets the user data corresponding to the specified identifiers. + * + *

There are no ordering guarantees; in particular, the nth entry in the users result list is + * not guaranteed to correspond to the nth entry in the input parameters list. + * + *

A maximum of 100 identifiers may be specified. If more than 100 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + * @param identifiers The identifiers used to indicate which user records should be returned. + * Must have 100 or fewer entries. + * @return An {@code ApiFuture} that resolves to the corresponding user records. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 100 + * identifiers are specified. + * @throws NullPointerException If the identifiers parameter is null. + */ + public ApiFuture getUsersAsync(@NonNull Collection identifiers) { + return getUsersOp(identifiers).callAsync(firebaseApp); + } + + private CallableOperation getUsersOp( + @NonNull final Collection identifiers) { + checkNotNull(identifiers, "identifiers must not be null"); + checkArgument(identifiers.size() <= FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE, + "identifiers parameter must have <= " + FirebaseUserManager.MAX_GET_ACCOUNTS_BATCH_SIZE + + " entries."); + + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected GetUsersResult execute() throws FirebaseAuthException { + Set users = userManager.getAccountInfo(identifiers); + Set notFound = new HashSet<>(); + for (UserIdentifier id : identifiers) { + if (!isUserFound(id, users)) { + notFound.add(id); + } + } + return new GetUsersResult(users, notFound); + } + }; + } + + private boolean isUserFound(UserIdentifier id, Collection userRecords) { + for (UserRecord userRecord : userRecords) { + if (id.matches(userRecord)) { + return true; + } + } + return false; + } + + /** + * Deletes the users specified by the given identifiers. + * + *

Deleting a non-existing user does not generate an error (the method is idempotent). + * Non-existing users are considered to be successfully deleted and are therefore included in the + * DeleteUsersResult.getSuccessCount() value. + * + *

A maximum of 1000 identifiers may be supplied. If more than 1000 identifiers are + * supplied, this method throws an {@code IllegalArgumentException}. + * + *

This API has a rate limit of 1 QPS. Exceeding the limit may result in a quota exceeded + * error. If you want to delete more than 1000 users, we suggest adding a delay to ensure you + * don't exceed this limit. + * + * @param uids The uids of the users to be deleted. Must have <= 1000 entries. + * @return The total number of successful/failed deletions, as well as the array of errors that + * correspond to the failed deletions. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 1000 + * identifiers are specified. + * @throws FirebaseAuthException If an error occurs while deleting users. + */ + public DeleteUsersResult deleteUsers(List uids) throws FirebaseAuthException { + return deleteUsersOp(uids).call(); + } + + /** + * Similar to {@link #deleteUsers(List)} but performs the operation asynchronously. + * + * @param uids The uids of the users to be deleted. Must have <= 1000 entries. + * @return An {@code ApiFuture} that resolves to the total number of successful/failed + * deletions, as well as the array of errors that correspond to the failed deletions. If an + * error occurs while deleting the user account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If any of the identifiers are invalid or if more than 1000 + * identifiers are specified. + */ + public ApiFuture deleteUsersAsync(List uids) { + return deleteUsersOp(uids).callAsync(firebaseApp); + } + + private CallableOperation deleteUsersOp( + final List uids) { + checkNotNull(uids, "uids must not be null"); + for (String uid : uids) { + UserRecord.checkUid(uid); + } + checkArgument(uids.size() <= FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE, + "uids parameter must have <= " + FirebaseUserManager.MAX_DELETE_ACCOUNTS_BATCH_SIZE + + " entries."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected DeleteUsersResult execute() throws FirebaseAuthException { + return userManager.deleteUsers(uids); + } + }; + } + + /** + * Generates the out-of-band email action link for password reset flows for the specified email + * address. + * + * @param email The email of the user whose password is to be reset. + * @return A password reset link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generatePasswordResetLink(@NonNull String email) throws FirebaseAuthException { + return generatePasswordResetLink(email, null); + } + + /** + * Generates the out-of-band email action link for password reset flows for the specified email + * address. + * + * @param email The email of the user whose password is to be reset. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return A password reset link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generatePasswordResetLink( + @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings).call(); + } + + /** + * Similar to {@link #generatePasswordResetLink(String)} but performs the operation + * asynchronously. + * + * @param email The email of the user whose password is to be reset. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generatePasswordResetLinkAsync(@NonNull String email) { + return generatePasswordResetLinkAsync(email, null); + } + + /** + * Similar to {@link #generatePasswordResetLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user whose password is to be reset. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generatePasswordResetLinkAsync( + @NonNull String email, @Nullable ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings) + .callAsync(firebaseApp); + } + + /** + * Generates the out-of-band email action link for email verification flows for the specified + * email address. + * + * @param email The email of the user to be verified. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateEmailVerificationLink(@NonNull String email) throws FirebaseAuthException { + return generateEmailVerificationLink(email, null); + } + + /** + * Generates the out-of-band email action link for email verification flows for the specified + * email address, using the action code settings provided. + * + * @param email The email of the user to be verified. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateEmailVerificationLink( + @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings).call(); + } + + /** + * Similar to {@link #generateEmailVerificationLink(String)} but performs the operation + * asynchronously. + * + * @param email The email of the user to be verified. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generateEmailVerificationLinkAsync(@NonNull String email) { + return generateEmailVerificationLinkAsync(email, null); + } + + /** + * Similar to {@link #generateEmailVerificationLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user to be verified. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + */ + public ApiFuture generateEmailVerificationLinkAsync( + @NonNull String email, @Nullable ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings) + .callAsync(firebaseApp); + } + + /** + * Generates the out-of-band email action link for email link sign-in flows, using the action code + * settings provided. + * + * @param email The email of the user signing in. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An email verification link. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws FirebaseAuthException If an error occurs while generating the link. + */ + public String generateSignInWithEmailLink( + @NonNull String email, @NonNull ActionCodeSettings settings) throws FirebaseAuthException { + return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings).call(); + } + + /** + * Similar to {@link #generateSignInWithEmailLink(String, ActionCodeSettings)} but performs the + * operation asynchronously. + * + * @param email The email of the user signing in. + * @param settings The action code settings object which defines whether the link is to be handled + * by a mobile app and the additional state information to be passed in the deep link. + * @return An {@code ApiFuture} which will complete successfully with the generated email action + * link. If an error occurs while generating the link, the future throws a {@link + * FirebaseAuthException}. + * @throws IllegalArgumentException If the email address is null or empty. + * @throws NullPointerException If the settings is null. + */ + public ApiFuture generateSignInWithEmailLinkAsync( + String email, @NonNull ActionCodeSettings settings) { + return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings) + .callAsync(firebaseApp); + } + + private CallableOperation generateEmailActionLinkOp( + final EmailLinkType type, final String email, final ActionCodeSettings settings) { + checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); + if (type == EmailLinkType.EMAIL_SIGNIN) { + checkNotNull(settings, "ActionCodeSettings must not be null when generating sign-in links"); + } + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected String execute() throws FirebaseAuthException { + return userManager.getEmailActionLink(type, email, settings); + } + }; + } + + /** + * Creates a new OpenID Connect auth provider config with the attributes contained in the + * specified {@link OidcProviderConfig.CreateRequest}. + * + * @param request A non-null {@link OidcProviderConfig.CreateRequest} instance. + * @return An {@link OidcProviderConfig} instance corresponding to the newly created provider + * config. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + * @throws FirebaseAuthException if an error occurs while creating the provider config. + */ + public OidcProviderConfig createOidcProviderConfig( + @NonNull OidcProviderConfig.CreateRequest request) throws FirebaseAuthException { + return createOidcProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #createOidcProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link OidcProviderConfig.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link OidcProviderConfig} + * instance corresponding to the newly created provider config. If an error occurs while + * creating the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + */ + public ApiFuture createOidcProviderConfigAsync( + @NonNull OidcProviderConfig.CreateRequest request) { + return createOidcProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation + createOidcProviderConfigOp(final OidcProviderConfig.CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + OidcProviderConfig.checkOidcProviderId(request.getProviderId()); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.createOidcProviderConfig(request); + } + }; + } + + /** + * Updates an existing OpenID Connect auth provider config with the attributes contained in the + * specified {@link OidcProviderConfig.UpdateRequest}. + * + * @param request A non-null {@link OidcProviderConfig.UpdateRequest} instance. + * @return A {@link OidcProviderConfig} instance corresponding to the updated provider config. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + * @throws FirebaseAuthException if an error occurs while updating the provider config. + */ + public OidcProviderConfig updateOidcProviderConfig( + @NonNull OidcProviderConfig.UpdateRequest request) throws FirebaseAuthException { + return updateOidcProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #updateOidcProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link OidcProviderConfig.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link OidcProviderConfig} + * instance corresponding to the updated provider config. If an error occurs while updating + * the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + */ + public ApiFuture updateOidcProviderConfigAsync( + @NonNull OidcProviderConfig.UpdateRequest request) { + return updateOidcProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateOidcProviderConfigOp( + final OidcProviderConfig.UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Update request must have at least one property set."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.updateOidcProviderConfig(request); + } + }; + } + + /** + * Gets the OpenID Connect auth provider corresponding to the specified provider ID. + * + * @param providerId A provider ID string. + * @return An {@link OidcProviderConfig} instance. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'oidc'. + * @throws FirebaseAuthException If an error occurs while retrieving the provider config. + */ + public OidcProviderConfig getOidcProviderConfig(@NonNull String providerId) + throws FirebaseAuthException { + return getOidcProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #getOidcProviderConfig(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully with an + * {@link OidcProviderConfig} instance. If an error occurs while retrieving the provider + * config or if the specified provider ID does not exist, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not + * prefixed with 'oidc.'. + */ + public ApiFuture getOidcProviderConfigAsync(@NonNull String providerId) { + return getOidcProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation + getOidcProviderConfigOp(final String providerId) { + OidcProviderConfig.checkOidcProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected OidcProviderConfig execute() throws FirebaseAuthException { + return userManager.getOidcProviderConfig(providerId); + } + }; + } + + /** + * Gets a page of OpenID Connect auth provider configs starting from the specified + * {@code pageToken}. Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listOidcProviderConfigs( + @Nullable String pageToken) throws FirebaseAuthException { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listOidcProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Gets a page of OpenID Connect auth provider configs starting from the specified + * {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listOidcProviderConfigs( + @Nullable String pageToken, int maxResults) throws FirebaseAuthException { + return listOidcProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listOidcProviderConfigs(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture> listOidcProviderConfigsAsync( + @Nullable String pageToken) { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listOidcProviderConfigsAsync(pageToken, maxResults); + } + + /** + * Similar to {@link #listOidcProviderConfigs(String, int)} but performs the operation + * asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture> listOidcProviderConfigsAsync( + @Nullable String pageToken, + int maxResults) { + return listOidcProviderConfigsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation, FirebaseAuthException> + listOidcProviderConfigsOp(@Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultOidcProviderConfigSource source = new DefaultOidcProviderConfigSource(userManager); + final ListProviderConfigsPage.Factory factory = + new ListProviderConfigsPage.Factory(source, maxResults, pageToken); + return + new CallableOperation, FirebaseAuthException>() { + @Override + protected ListProviderConfigsPage execute() + throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Deletes the OpenID Connect auth provider config identified by the specified provider ID. + * + * @param providerId A provider ID string. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'oidc'. + * @throws FirebaseAuthException If an error occurs while deleting the provider config. + */ + public void deleteOidcProviderConfig(@NonNull String providerId) throws FirebaseAuthException { + deleteOidcProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #deleteOidcProviderConfig} but performs the operation asynchronously. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified provider + * config has been deleted. If an error occurs while deleting the provider config, the future + * throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "oidc.". + */ + public ApiFuture deleteOidcProviderConfigAsync(String providerId) { + return deleteOidcProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation deleteOidcProviderConfigOp( + final String providerId) { + OidcProviderConfig.checkOidcProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteOidcProviderConfig(providerId); + return null; + } + }; + } + + /** + * Creates a new SAML Auth provider config with the attributes contained in the specified + * {@link SamlProviderConfig.CreateRequest}. + * + * @param request A non-null {@link SamlProviderConfig.CreateRequest} instance. + * @return An {@link SamlProviderConfig} instance corresponding to the newly created provider + * config. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + * @throws FirebaseAuthException if an error occurs while creating the provider config. + */ + public SamlProviderConfig createSamlProviderConfig( + @NonNull SamlProviderConfig.CreateRequest request) throws FirebaseAuthException { + return createSamlProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #createSamlProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link SamlProviderConfig.CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link SamlProviderConfig} + * instance corresponding to the newly created provider config. If an error occurs while + * creating the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + */ + public ApiFuture createSamlProviderConfigAsync( + @NonNull SamlProviderConfig.CreateRequest request) { + return createSamlProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation + createSamlProviderConfigOp(final SamlProviderConfig.CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + SamlProviderConfig.checkSamlProviderId(request.getProviderId()); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.createSamlProviderConfig(request); + } + }; + } + + /** + * Updates an existing SAML Auth provider config with the attributes contained in the specified + * {@link SamlProviderConfig.UpdateRequest}. + * + * @param request A non-null {@link SamlProviderConfig.UpdateRequest} instance. + * @return A {@link SamlProviderConfig} instance corresponding to the updated provider config. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + * @throws FirebaseAuthException if an error occurs while updating the provider config. + */ + public SamlProviderConfig updateSamlProviderConfig( + @NonNull SamlProviderConfig.UpdateRequest request) throws FirebaseAuthException { + return updateSamlProviderConfigOp(request).call(); + } + + /** + * Similar to {@link #updateSamlProviderConfig} but performs the operation asynchronously. + * + * @param request A non-null {@link SamlProviderConfig.UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link SamlProviderConfig} + * instance corresponding to the updated provider config. If an error occurs while updating + * the provider config, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided update request is null. + * @throws IllegalArgumentException If the provided update request is invalid. + */ + public ApiFuture updateSamlProviderConfigAsync( + @NonNull SamlProviderConfig.UpdateRequest request) { + return updateSamlProviderConfigOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateSamlProviderConfigOp( + final SamlProviderConfig.UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Update request must have at least one property set."); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.updateSamlProviderConfig(request); + } + }; + } + + /** + * Gets the SAML Auth provider config corresponding to the specified provider ID. + * + * @param providerId A provider ID string. + * @return An {@link SamlProviderConfig} instance. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + * @throws FirebaseAuthException If an error occurs while retrieving the provider config. + */ + public SamlProviderConfig getSamlProviderConfig(@NonNull String providerId) + throws FirebaseAuthException { + return getSamlProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #getSamlProviderConfig(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully with an + * {@link SamlProviderConfig} instance. If an error occurs while retrieving the provider + * config or if the specified provider ID does not exist, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with 'saml'. + */ + public ApiFuture getSamlProviderConfigAsync(@NonNull String providerId) { + return getSamlProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation + getSamlProviderConfigOp(final String providerId) { + SamlProviderConfig.checkSamlProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected SamlProviderConfig execute() throws FirebaseAuthException { + return userManager.getSamlProviderConfig(providerId); + } + }; + } + + /** + * Gets a page of SAML Auth provider configs starting from the specified {@code pageToken}. Page + * size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listSamlProviderConfigs( + @Nullable String pageToken) throws FirebaseAuthException { + return listSamlProviderConfigs( + pageToken, + FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS); + } + + /** + * Gets a page of SAML Auth provider configs starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return A {@link ListProviderConfigsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving provider config data. + */ + public ListProviderConfigsPage listSamlProviderConfigs( + @Nullable String pageToken, int maxResults) throws FirebaseAuthException { + return listSamlProviderConfigsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listSamlProviderConfigs(String)} but performs the operation asynchronously. + * Page size is limited to 100 provider configs. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture> listSamlProviderConfigsAsync( + @Nullable String pageToken) { + int maxResults = FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS; + return listSamlProviderConfigsAsync(pageToken, maxResults); + } + + /** + * Similar to {@link #listSamlProviderConfigs(String, int)} but performs the operation + * asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of provider + * configs. + * @param maxResults Maximum number of provider configs to include in the returned page. This may + * not exceed 100. + * @return An {@code ApiFuture} which will complete successfully with a + * {@link ListProviderConfigsPage} instance. If an error occurs while retrieving provider + * config data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture> listSamlProviderConfigsAsync( + @Nullable String pageToken, + int maxResults) { + return listSamlProviderConfigsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation, FirebaseAuthException> + listSamlProviderConfigsOp(@Nullable final String pageToken, final int maxResults) { + final FirebaseUserManager userManager = getUserManager(); + final DefaultSamlProviderConfigSource source = new DefaultSamlProviderConfigSource(userManager); + final ListProviderConfigsPage.Factory factory = + new ListProviderConfigsPage.Factory(source, maxResults, pageToken); + return + new CallableOperation, FirebaseAuthException>() { + @Override + protected ListProviderConfigsPage execute() + throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Deletes the SAML Auth provider config identified by the specified provider ID. + * + * @param providerId A provider ID string. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "saml.". + * @throws FirebaseAuthException If an error occurs while deleting the provider config. + */ + public void deleteSamlProviderConfig(@NonNull String providerId) throws FirebaseAuthException { + deleteSamlProviderConfigOp(providerId).call(); + } + + /** + * Similar to {@link #deleteSamlProviderConfig} but performs the operation asynchronously. + * + * @param providerId A provider ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified provider + * config has been deleted. If an error occurs while deleting the provider config, the future + * throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the provider ID string is null or empty, or is not prefixed + * with "saml.". + */ + public ApiFuture deleteSamlProviderConfigAsync(String providerId) { + return deleteSamlProviderConfigOp(providerId).callAsync(firebaseApp); + } + + private CallableOperation deleteSamlProviderConfigOp( + final String providerId) { + SamlProviderConfig.checkSamlProviderId(providerId); + final FirebaseUserManager userManager = getUserManager(); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + userManager.deleteSamlProviderConfig(providerId); + return null; + } + }; + } + + FirebaseUserManager getUserManager() { + return this.userManager.get(); + } + + Supplier threadSafeMemoize(final Supplier supplier) { + return Suppliers.memoize( + new Supplier() { + @Override + public T get() { + checkNotNull(supplier); + synchronized (lock) { + return supplier.get(); + } + } + }); + } + + protected abstract static class Builder> { + + private FirebaseApp firebaseApp; + private Supplier tokenFactory; + private Supplier idTokenVerifier; + private Supplier cookieVerifier; + private Supplier userManager; + + protected abstract T getThis(); + + public FirebaseApp getFirebaseApp() { + return firebaseApp; + } + + public T setFirebaseApp(FirebaseApp firebaseApp) { + this.firebaseApp = firebaseApp; + return getThis(); + } + + public T setIdTokenVerifier(Supplier idTokenVerifier) { + this.idTokenVerifier = idTokenVerifier; + return getThis(); + } + + public T setCookieVerifier(Supplier cookieVerifier) { + this.cookieVerifier = cookieVerifier; + return getThis(); + } + + T setUserManager(Supplier userManager) { + this.userManager = userManager; + return getThis(); + } + + T setTokenFactory(Supplier tokenFactory) { + this.tokenFactory = tokenFactory; + return getThis(); + } + } + + protected static > T populateBuilderFromApp( + Builder builder, final FirebaseApp app, @Nullable final String tenantId) { + return builder.setFirebaseApp(app) + .setTokenFactory( + new Supplier() { + @Override + public FirebaseTokenFactory get() { + return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM, tenantId); + } + }) + .setIdTokenVerifier( + new Supplier() { + @Override + public FirebaseTokenVerifier get() { + return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM, tenantId); + } + }) + .setCookieVerifier( + new Supplier() { + @Override + public FirebaseTokenVerifier get() { + return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM, tenantId); + } + }) + .setUserManager( + new Supplier() { + @Override + public FirebaseUserManager get() { + return FirebaseUserManager.createUserManager(app, tenantId); + } + }); + } +} diff --git a/src/main/java/com/google/firebase/auth/ActionCodeSettings.java b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java index 0b102b7a3..8ffccd542 100644 --- a/src/main/java/com/google/firebase/auth/ActionCodeSettings.java +++ b/src/main/java/com/google/firebase/auth/ActionCodeSettings.java @@ -51,6 +51,9 @@ private ActionCodeSettings(Builder builder) { if (!Strings.isNullOrEmpty(builder.dynamicLinkDomain)) { properties.put("dynamicLinkDomain", builder.dynamicLinkDomain); } + if (!Strings.isNullOrEmpty(builder.linkDomain)) { + properties.put("linkDomain", builder.linkDomain); + } if (!Strings.isNullOrEmpty(builder.iosBundleId)) { properties.put("iOSBundleId", builder.iosBundleId); } @@ -84,6 +87,7 @@ public static final class Builder { private String url; private boolean handleCodeInApp; private String dynamicLinkDomain; + private String linkDomain; private String iosBundleId; private String androidPackageName; private String androidMinimumVersion; @@ -135,12 +139,28 @@ public Builder setHandleCodeInApp(boolean handleCodeInApp) { * * @param dynamicLinkDomain Firebase Dynamic Link domain string. * @return This builder. + * @deprecated Use {@link #setLinkDomain(String)} instead. */ + @Deprecated public Builder setDynamicLinkDomain(String dynamicLinkDomain) { this.dynamicLinkDomain = dynamicLinkDomain; return this; } + /** + * Sets the link domain to use for the current link if it is to be opened using + * {@code handleCodeInApp}, as multiple link domains can be configured per project. This + * setting provides the ability to explicitly choose one. If none is provided, the default + * Firebase Hosting domain will be used. + * + * @param linkDomain Link domain string. + * @return This builder. + */ + public Builder setLinkDomain(String linkDomain) { + this.linkDomain = linkDomain; + return this; + } + /** * Sets the bundle ID of the iOS app where the link should be handled if the * application is already installed on the device. diff --git a/src/main/java/com/google/firebase/auth/AuthErrorCode.java b/src/main/java/com/google/firebase/auth/AuthErrorCode.java new file mode 100644 index 000000000..9f7ecebf1 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/AuthErrorCode.java @@ -0,0 +1,121 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +/** + * Error codes that can be raised by the Firebase Auth APIs. + */ +public enum AuthErrorCode { + + /** + * Failed to retrieve public key certificates required to verify JWTs. + */ + CERTIFICATE_FETCH_FAILED, + + /** + * No IdP configuration found for the given identifier. + */ + CONFIGURATION_NOT_FOUND, + + /** + * A user already exists with the provided email. + */ + EMAIL_ALREADY_EXISTS, + + /** + * No user record found for the given email, typically raised when + * generating a password reset link using an email for a user that + * is not already registered. + */ + EMAIL_NOT_FOUND, + + /** + * The specified ID token is expired. + */ + EXPIRED_ID_TOKEN, + + /** + * The specified session cookie is expired. + */ + EXPIRED_SESSION_COOKIE, + + /** + * The provided dynamic link domain is not configured or authorized for the current project. + */ + INVALID_DYNAMIC_LINK_DOMAIN, + + /** + * The provided hosting link domain is not configured or authorized for the current project. + */ + INVALID_HOSTING_LINK_DOMAIN, + + /** + * The specified ID token is invalid. + */ + INVALID_ID_TOKEN, + + /** + * The specified session cookie is invalid. + */ + INVALID_SESSION_COOKIE, + + /** + * A user already exists with the provided phone number. + */ + PHONE_NUMBER_ALREADY_EXISTS, + + /** + * The specified ID token has been revoked. + */ + REVOKED_ID_TOKEN, + + /** + * The specified session cookie has been revoked. + */ + REVOKED_SESSION_COOKIE, + + /** + * Tenant ID in the JWT does not match. + */ + TENANT_ID_MISMATCH, + + /** + * No tenant found for the given identifier. + */ + TENANT_NOT_FOUND, + + /** + * A user already exists with the provided UID. + */ + UID_ALREADY_EXISTS, + + /** + * The domain of the continue URL is not whitelisted. Whitelist the domain in the Firebase + * console. + */ + UNAUTHORIZED_CONTINUE_URL, + + /** + * No user record found for the given identifier. + */ + USER_NOT_FOUND, + + /** + * The user record is disabled. + */ + USER_DISABLED, +} diff --git a/src/main/java/com/google/firebase/auth/DeleteUsersResult.java b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java new file mode 100644 index 000000000..e8ca7dba5 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/DeleteUsersResult.java @@ -0,0 +1,74 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.internal.BatchDeleteResponse; +import com.google.firebase.internal.NonNull; +import java.util.List; + +/** + * Represents the result of the {@link FirebaseAuth#deleteUsersAsync(List)} API. + */ +public final class DeleteUsersResult { + + private final int successCount; + private final List errors; + + DeleteUsersResult(int users, BatchDeleteResponse response) { + ImmutableList.Builder errorsBuilder = ImmutableList.builder(); + List responseErrors = response.getErrors(); + if (responseErrors != null) { + checkArgument(users >= responseErrors.size()); + for (BatchDeleteResponse.ErrorInfo error : responseErrors) { + errorsBuilder.add(new ErrorInfo(error.getIndex(), error.getMessage())); + } + } + errors = errorsBuilder.build(); + successCount = users - errors.size(); + } + + /** + * Returns the number of users that were deleted successfully (possibly zero). Users that did not + * exist prior to calling {@link FirebaseAuth#deleteUsersAsync(List)} are considered to be + * successfully deleted. + */ + public int getSuccessCount() { + return successCount; + } + + /** + * Returns the number of users that failed to be deleted (possibly zero). + */ + public int getFailureCount() { + return errors.size(); + } + + /** + * A list of {@link ErrorInfo} instances describing the errors that were encountered during + * the deletion. Length of this list is equal to the return value of + * {@link #getFailureCount()}. + * + * @return A non-null list (possibly empty). + */ + @NonNull + public List getErrors() { + return errors; + } +} diff --git a/src/main/java/com/google/firebase/auth/EmailIdentifier.java b/src/main/java/com/google/firebase/auth/EmailIdentifier.java new file mode 100644 index 000000000..8e729c220 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/EmailIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by email. + * + * @see {FirebaseAuth#getUsers} + */ +public final class EmailIdentifier extends UserIdentifier { + private final String email; + + public EmailIdentifier(@NonNull String email) { + UserRecord.checkEmail(email); + this.email = email; + } + + @Override + public String toString() { + return "EmailIdentifier(" + email + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addEmail(email); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return email.equals(userRecord.getEmail()); + } +} diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuth.java b/src/main/java/com/google/firebase/auth/FirebaseAuth.java index f7f6231ad..27e79960d 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuth.java +++ b/src/main/java/com/google/firebase/auth/FirebaseAuth.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2017 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,35 +16,11 @@ package com.google.firebase.auth; -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; -import static com.google.common.base.Preconditions.checkState; - -import com.google.api.client.json.JsonFactory; -import com.google.api.client.util.Clock; -import com.google.api.core.ApiFuture; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Strings; import com.google.common.base.Supplier; -import com.google.common.base.Suppliers; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.auth.FirebaseUserManager.EmailLinkType; -import com.google.firebase.auth.FirebaseUserManager.UserImportRequest; -import com.google.firebase.auth.ListUsersPage.DefaultUserSource; -import com.google.firebase.auth.ListUsersPage.PageFactory; -import com.google.firebase.auth.UserRecord.CreateRequest; -import com.google.firebase.auth.UserRecord.UpdateRequest; -import com.google.firebase.auth.internal.FirebaseTokenFactory; -import com.google.firebase.internal.CallableOperation; +import com.google.firebase.auth.multitenancy.TenantManager; import com.google.firebase.internal.FirebaseService; -import com.google.firebase.internal.NonNull; -import com.google.firebase.internal.Nullable; - -import java.io.IOException; -import java.util.List; -import java.util.Map; -import java.util.concurrent.atomic.AtomicBoolean; /** * This class is the entry point for all server-side Firebase Authentication actions. @@ -54,29 +30,19 @@ * custom tokens for use by client-side code, verifying Firebase ID Tokens received from clients, or * creating new FirebaseApp instances that are scoped to a particular authentication UID. */ -public class FirebaseAuth { +public final class FirebaseAuth extends AbstractFirebaseAuth { private static final String SERVICE_ID = FirebaseAuth.class.getName(); - private static final String ERROR_CUSTOM_TOKEN = "ERROR_CUSTOM_TOKEN"; - - private final Object lock = new Object(); - private final AtomicBoolean destroyed = new AtomicBoolean(false); + private final Supplier tenantManager; - private final FirebaseApp firebaseApp; - private final Supplier tokenFactory; - private final Supplier idTokenVerifier; - private final Supplier cookieVerifier; - private final Supplier userManager; - private final JsonFactory jsonFactory; + private FirebaseAuth(final Builder builder) { + super(builder); + tenantManager = threadSafeMemoize(builder.tenantManager); + } - private FirebaseAuth(Builder builder) { - this.firebaseApp = checkNotNull(builder.firebaseApp); - this.tokenFactory = threadSafeMemoize(builder.tokenFactory); - this.idTokenVerifier = threadSafeMemoize(builder.idTokenVerifier); - this.cookieVerifier = threadSafeMemoize(builder.cookieVerifier); - this.userManager = threadSafeMemoize(builder.userManager); - this.jsonFactory = firebaseApp.getOptions().getJsonFactory(); + public TenantManager getTenantManager() { + return tenantManager.get(); } /** @@ -95,1123 +61,54 @@ public static FirebaseAuth getInstance() { * @return A FirebaseAuth instance. */ public static synchronized FirebaseAuth getInstance(FirebaseApp app) { - FirebaseAuthService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, - FirebaseAuthService.class); + FirebaseAuthService service = + ImplFirebaseTrampolines.getService(app, SERVICE_ID, FirebaseAuthService.class); if (service == null) { service = ImplFirebaseTrampolines.addService(app, new FirebaseAuthService(app)); } return service.getInstance(); } - /** - * Creates a new Firebase session cookie from the given ID token and options. The returned JWT - * can be set as a server-side session cookie with a custom cookie policy. - * - * @param idToken The Firebase ID token to exchange for a session cookie. - * @param options Additional options required to create the cookie. - * @return A Firebase session cookie string. - * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. - * @throws FirebaseAuthException If an error occurs while generating the session cookie. - */ - public String createSessionCookie( - @NonNull String idToken, @NonNull SessionCookieOptions options) throws FirebaseAuthException { - return createSessionCookieOp(idToken, options).call(); - } - - /** - * Similar to {@link #createSessionCookie(String, SessionCookieOptions)} but performs the - * operation asynchronously. - * - * @param idToken The Firebase ID token to exchange for a session cookie. - * @param options Additional options required to create the cookie. - * @return An {@code ApiFuture} which will complete successfully with a session cookie string. - * If an error occurs while generating the cookie or if the specified ID token is invalid, - * the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the ID token is null or empty, or if options is null. - */ - public ApiFuture createSessionCookieAsync( - @NonNull String idToken, @NonNull SessionCookieOptions options) { - return createSessionCookieOp(idToken, options).callAsync(firebaseApp); - } - - private CallableOperation createSessionCookieOp( - final String idToken, final SessionCookieOptions options) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(idToken), "idToken must not be null or empty"); - checkNotNull(options, "options must not be null"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected String execute() throws FirebaseAuthException { - return userManager.createSessionCookie(idToken, options); - } - }; - } - - /** - * Parses and verifies a Firebase session cookie. - * - *

If verified successfully, returns a parsed version of the cookie from which the UID and the - * other claims can be read. If the cookie is invalid, throws a {@link FirebaseAuthException}. - * - *

This method does not check whether the cookie has been revoked. See - * {@link #verifySessionCookie(String, boolean)}. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @return A {@link FirebaseToken} representing the verified and decoded cookie. - */ - public FirebaseToken verifySessionCookie(String cookie) throws FirebaseAuthException { - return verifySessionCookie(cookie, false); - } - - /** - * Parses and verifies a Firebase session cookie. - * - *

If {@code checkRevoked} is true, additionally verifies that the cookie has not been - * revoked. - * - *

If verified successfully, returns a parsed version of the cookie from which the UID and the - * other claims can be read. If the cookie is invalid or has been revoked while - * {@code checkRevoked} is true, throws a {@link FirebaseAuthException}. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly - * revoked. - * @return A {@link FirebaseToken} representing the verified and decoded cookie. - */ - public FirebaseToken verifySessionCookie( - String cookie, boolean checkRevoked) throws FirebaseAuthException { - return verifySessionCookieOp(cookie, checkRevoked).call(); - } - - /** - * Similar to {@link #verifySessionCookie(String)} but performs the operation asynchronously. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or - * unsuccessfully with the failure Exception. - */ - public ApiFuture verifySessionCookieAsync(String cookie) { - return verifySessionCookieAsync(cookie, false); - } - - /** - * Similar to {@link #verifySessionCookie(String, boolean)} but performs the operation - * asynchronously. - * - * @param cookie A Firebase session cookie string to verify and parse. - * @param checkRevoked A boolean indicating whether to check if the cookie was explicitly - * revoked. - * @return An {@code ApiFuture} which will complete successfully with the parsed cookie, or - * unsuccessfully with the failure Exception. - */ - public ApiFuture verifySessionCookieAsync(String cookie, boolean checkRevoked) { - return verifySessionCookieOp(cookie, checkRevoked).callAsync(firebaseApp); - } - - private CallableOperation verifySessionCookieOp( - final String cookie, final boolean checkRevoked) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(cookie), "Session cookie must not be null or empty"); - final FirebaseTokenVerifier sessionCookieVerifier = getSessionCookieVerifier(checkRevoked); - return new CallableOperation() { - @Override - public FirebaseToken execute() throws FirebaseAuthException { - return sessionCookieVerifier.verifyToken(cookie); - } - }; - } - - @VisibleForTesting - FirebaseTokenVerifier getSessionCookieVerifier(boolean checkRevoked) { - FirebaseTokenVerifier verifier = cookieVerifier.get(); - if (checkRevoked) { - FirebaseUserManager userManager = getUserManager(); - verifier = RevocationCheckDecorator.decorateSessionCookieVerifier(verifier, userManager); - } - return verifier; - } - - /** - * Creates a Firebase custom token for the given UID. This token can then be sent back to a client - * application to be used with the - * signInWithCustomToken - * authentication API. - * - *

{@link FirebaseApp} must have been initialized with service account credentials to use - * call this method. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @return A Firebase custom token string. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - * @throws FirebaseAuthException If an error occurs while generating the custom token. - */ - public String createCustomToken(@NonNull String uid) throws FirebaseAuthException { - return createCustomToken(uid, null); - } - - /** - * Creates a Firebase custom token for the given UID, containing the specified additional - * claims. This token can then be sent back to a client application to be used with the - * signInWithCustomToken - * authentication API. - * - *

This method attempts to generate a token using: - *

    - *
  1. the private key of {@link FirebaseApp}'s service account credentials, if provided at - * initialization. - *
  2. the IAM service - * if a service account email was specified via - * {@link com.google.firebase.FirebaseOptions.Builder#setServiceAccountId(String)}. - *
  3. the App Identity - * service if the code is deployed in the Google App Engine standard environment. - *
  4. the - * local Metadata server if the code is deployed in a different GCP-managed environment - * like Google Compute Engine. - *
- * - *

This method throws an exception when all the above fail. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @param developerClaims Additional claims to be stored in the token (and made available to - * security rules in Database, Storage, etc.). These must be able to be serialized to JSON - * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) - * @return A Firebase custom token string. - * @throws IllegalArgumentException If the specified uid is null or empty. - * @throws IllegalStateException If the SDK fails to discover a viable approach for signing - * tokens. - * @throws FirebaseAuthException If an error occurs while generating the custom token. - */ - public String createCustomToken(@NonNull String uid, - @Nullable Map developerClaims) throws FirebaseAuthException { - return createCustomTokenOp(uid, developerClaims).call(); - } - - /** - * Similar to {@link #createCustomToken(String)} but performs the operation asynchronously. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Firebase Auth, etc.). Should be less than 128 characters. - * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom - * token, or unsuccessfully with the failure Exception. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - */ - public ApiFuture createCustomTokenAsync(@NonNull String uid) { - return createCustomTokenAsync(uid, null); - } - - /** - * Similar to {@link #createCustomToken(String, Map)} but performs the operation - * asynchronously. - * - * @param uid The UID to store in the token. This identifies the user to other Firebase services - * (Realtime Database, Storage, etc.). Should be less than 128 characters. - * @param developerClaims Additional claims to be stored in the token (and made available to - * security rules in Database, Storage, etc.). These must be able to be serialized to JSON - * (e.g. contain only Maps, Arrays, Strings, Booleans, Numbers, etc.) - * @return An {@code ApiFuture} which will complete successfully with the created Firebase custom - * token, or unsuccessfully with the failure Exception. - * @throws IllegalArgumentException If the specified uid is null or empty, or if the app has not - * been initialized with service account credentials. - */ - public ApiFuture createCustomTokenAsync( - @NonNull String uid, @Nullable Map developerClaims) { - return createCustomTokenOp(uid, developerClaims).callAsync(firebaseApp); - } - - private CallableOperation createCustomTokenOp( - final String uid, final Map developerClaims) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseTokenFactory tokenFactory = this.tokenFactory.get(); - return new CallableOperation() { - @Override - public String execute() throws FirebaseAuthException { - try { - return tokenFactory.createSignedCustomAuthTokenForUser(uid, developerClaims); - } catch (IOException e) { - throw new FirebaseAuthException(ERROR_CUSTOM_TOKEN, - "Failed to generate a custom token", e); - } - } - }; - } - - /** - * Parses and verifies a Firebase ID Token. - * - *

A Firebase application can identify itself to a trusted backend server by sending its - * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication - * client) with its requests. The backend server can then use the {@code verifyIdToken()} method - * to verify that the token is valid. This method ensures that the token is correctly signed, - * has not expired, and it was issued to the Firebase project associated with this - * {@link FirebaseAuth} instance. - * - *

This method does not check whether a token has been revoked. Use - * {@link #verifyIdToken(String, boolean)} to perform an additional revocation check. - * - * @param token A Firebase ID token string to parse and verify. - * @return A {@link FirebaseToken} representing the verified and decoded token. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - * @throws FirebaseAuthException If an error occurs while parsing or validating the token. - */ - public FirebaseToken verifyIdToken(@NonNull String token) throws FirebaseAuthException { - return verifyIdToken(token, false); - } - - /** - * Parses and verifies a Firebase ID Token. - * - *

A Firebase application can identify itself to a trusted backend server by sending its - * Firebase ID Token (accessible via the {@code getToken} API in the Firebase Authentication - * client) with its requests. The backend server can then use the {@code verifyIdToken()} method - * to verify that the token is valid. This method ensures that the token is correctly signed, - * has not expired, and it was issued to the Firebase project associated with this - * {@link FirebaseAuth} instance. - * - *

If {@code checkRevoked} is set to true, this method performs an additional check to see - * if the ID token has been revoked since it was issues. This requires making an additional - * remote API call. - * - * @param token A Firebase ID token string to parse and verify. - * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. - * @return A {@link FirebaseToken} representing the verified and decoded token. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - * @throws FirebaseAuthException If an error occurs while parsing or validating the token. - */ - public FirebaseToken verifyIdToken( - @NonNull String token, boolean checkRevoked) throws FirebaseAuthException { - return verifyIdTokenOp(token, checkRevoked).call(); - } - - /** - * Similar to {@link #verifyIdToken(String)} but performs the operation asynchronously. - * - * @param token A Firebase ID Token to verify and parse. - * @return An {@code ApiFuture} which will complete successfully with the parsed token, or - * unsuccessfully with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - */ - public ApiFuture verifyIdTokenAsync(@NonNull String token) { - return verifyIdTokenAsync(token, false); - } - - /** - * Similar to {@link #verifyIdToken(String, boolean)} but performs the operation asynchronously. - * - * @param token A Firebase ID Token to verify and parse. - * @param checkRevoked A boolean denoting whether to check if the tokens were revoked. - * @return An {@code ApiFuture} which will complete successfully with the parsed token, or - * unsuccessfully with a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the token is null, empty, or if the {@link FirebaseApp} - * instance does not have a project ID associated with it. - */ - public ApiFuture verifyIdTokenAsync(@NonNull String token, boolean checkRevoked) { - return verifyIdTokenOp(token, checkRevoked).callAsync(firebaseApp); - } - - private CallableOperation verifyIdTokenOp( - final String token, final boolean checkRevoked) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(token), "ID token must not be null or empty"); - final FirebaseTokenVerifier verifier = getIdTokenVerifier(checkRevoked); - return new CallableOperation() { - @Override - protected FirebaseToken execute() throws FirebaseAuthException { - return verifier.verifyToken(token); - } - }; - } - - @VisibleForTesting - FirebaseTokenVerifier getIdTokenVerifier(boolean checkRevoked) { - FirebaseTokenVerifier verifier = idTokenVerifier.get(); - if (checkRevoked) { - FirebaseUserManager userManager = getUserManager(); - verifier = RevocationCheckDecorator.decorateIdTokenVerifier(verifier, userManager); - } - return verifier; - } - - /** - * Revokes all refresh tokens for the specified user. - * - *

Updates the user's tokensValidAfterTimestamp to the current UTC time expressed in - * milliseconds since the epoch and truncated to 1 second accuracy. It is important that the - * server on which this is called has its clock set correctly and synchronized. - * - *

While this will revoke all sessions for a specified user and disable any new ID tokens for - * existing sessions from getting minted, existing ID tokens may remain active until their - * natural expiration (one hour). - * To verify that ID tokens are revoked, use {@link #verifyIdTokenAsync(String, boolean)}. - * - * @param uid The user id for which tokens are revoked. - * @throws IllegalArgumentException If the user ID is null or empty. - * @throws FirebaseAuthException If an error occurs while revoking tokens. - */ - public void revokeRefreshTokens(@NonNull String uid) throws FirebaseAuthException { - revokeRefreshTokensOp(uid).call(); - } - - /** - * Similar to {@link #revokeRefreshTokens(String)} but performs the operation asynchronously. - * - * @param uid The user id for which tokens are revoked. - * @return An {@code ApiFuture} which will complete successfully or fail with a - * {@link FirebaseAuthException} in the event of an error. - * @throws IllegalArgumentException If the user ID is null or empty. - */ - public ApiFuture revokeRefreshTokensAsync(@NonNull String uid) { - return revokeRefreshTokensOp(uid).callAsync(firebaseApp); - } - - private CallableOperation revokeRefreshTokensOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - int currentTimeSeconds = (int) (System.currentTimeMillis() / 1000); - UpdateRequest request = new UpdateRequest(uid).setValidSince(currentTimeSeconds); - userManager.updateUser(request, jsonFactory); - return null; - } - }; - } - - /** - * Gets the user data corresponding to the specified user ID. - * - * @param uid A user ID string. - * @return A {@link UserRecord} instance. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUser(@NonNull String uid) throws FirebaseAuthException { - return getUserOp(uid).call(); - } - - /** - * Similar to {@link #getUser(String)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the specified user ID does - * not exist, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture getUserAsync(@NonNull String uid) { - return getUserOp(uid).callAsync(firebaseApp); - } - - private CallableOperation getUserOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserById(uid); - } - }; - } - - /** - * Gets the user data corresponding to the specified user email. - * - * @param email A user email address string. - * @return A {@link UserRecord} instance. - * @throws IllegalArgumentException If the email is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUserByEmail(@NonNull String email) throws FirebaseAuthException { - return getUserByEmailOp(email).call(); - } - - /** - * Similar to {@link #getUserByEmail(String)} but performs the operation asynchronously. - * - * @param email A user email address string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the email address does not - * correspond to a user, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email is null or empty. - */ - public ApiFuture getUserByEmailAsync(@NonNull String email) { - return getUserByEmailOp(email).callAsync(firebaseApp); - } - - private CallableOperation getUserByEmailOp( - final String email) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserByEmail(email); - } - }; - } - - /** - * Gets the user data corresponding to the specified user phone number. - * - * @param phoneNumber A user phone number string. - * @return A a {@link UserRecord} instance. - * @throws IllegalArgumentException If the phone number is null or empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public UserRecord getUserByPhoneNumber(@NonNull String phoneNumber) throws FirebaseAuthException { - return getUserByPhoneNumberOp(phoneNumber).call(); - } - - /** - * Gets the user data corresponding to the specified user phone number. - * - * @param phoneNumber A user phone number string. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance. If an error occurs while retrieving user data or if the phone number does not - * correspond to a user, the future throws a {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the phone number is null or empty. - */ - public ApiFuture getUserByPhoneNumberAsync(@NonNull String phoneNumber) { - return getUserByPhoneNumberOp(phoneNumber).callAsync(firebaseApp); - } - - private CallableOperation getUserByPhoneNumberOp( - final String phoneNumber) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(phoneNumber), "phone number must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - return userManager.getUserByPhoneNumber(phoneNumber); - } - }; - } - - /** - * Gets a page of users starting from the specified {@code pageToken}. Page size will be - * limited to 1000 users. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @return A {@link ListUsersPage} instance. - * @throws IllegalArgumentException If the specified page token is empty. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public ListUsersPage listUsers(@Nullable String pageToken) throws FirebaseAuthException { - return listUsers(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); - } - - /** - * Gets a page of users starting from the specified {@code pageToken}. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @param maxResults Maximum number of users to include in the returned page. This may not - * exceed 1000. - * @return A {@link ListUsersPage} instance. - * @throws IllegalArgumentException If the specified page token is empty, or max results value - * is invalid. - * @throws FirebaseAuthException If an error occurs while retrieving user data. - */ - public ListUsersPage listUsers( - @Nullable String pageToken, int maxResults) throws FirebaseAuthException { - return listUsersOp(pageToken, maxResults).call(); - } - - /** - * Similar to {@link #listUsers(String)} but performs the operation asynchronously. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} - * instance. If an error occurs while retrieving user data, the future throws an exception. - * @throws IllegalArgumentException If the specified page token is empty. - */ - public ApiFuture listUsersAsync(@Nullable String pageToken) { - return listUsersAsync(pageToken, FirebaseUserManager.MAX_LIST_USERS_RESULTS); - } - - /** - * Similar to {@link #listUsers(String, int)} but performs the operation asynchronously. - * - * @param pageToken A non-empty page token string, or null to retrieve the first page of users. - * @param maxResults Maximum number of users to include in the returned page. This may not - * exceed 1000. - * @return An {@code ApiFuture} which will complete successfully with a {@link ListUsersPage} - * instance. If an error occurs while retrieving user data, the future throws an exception. - * @throws IllegalArgumentException If the specified page token is empty, or max results value - * is invalid. - */ - public ApiFuture listUsersAsync(@Nullable String pageToken, int maxResults) { - return listUsersOp(pageToken, maxResults).callAsync(firebaseApp); - } - - private CallableOperation listUsersOp( - @Nullable final String pageToken, final int maxResults) { - checkNotDestroyed(); - final FirebaseUserManager userManager = getUserManager(); - final PageFactory factory = new PageFactory( - new DefaultUserSource(userManager, jsonFactory), maxResults, pageToken); - return new CallableOperation() { - @Override - protected ListUsersPage execute() throws FirebaseAuthException { - return factory.create(); - } - }; - } - - /** - * Creates a new user account with the attributes contained in the specified - * {@link CreateRequest}. - * - * @param request A non-null {@link CreateRequest} instance. - * @return A {@link UserRecord} instance corresponding to the newly created account. - * @throws NullPointerException if the provided request is null. - * @throws FirebaseAuthException if an error occurs while creating the user account. - */ - public UserRecord createUser(@NonNull CreateRequest request) throws FirebaseAuthException { - return createUserOp(request).call(); - } - - /** - * Similar to {@link #createUser(CreateRequest)} but performs the operation asynchronously. - * - * @param request A non-null {@link CreateRequest} instance. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance corresponding to the newly created account. If an error occurs while creating the - * user account, the future throws a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided request is null. - */ - public ApiFuture createUserAsync(@NonNull CreateRequest request) { - return createUserOp(request).callAsync(firebaseApp); - } - - private CallableOperation createUserOp( - final CreateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "create request must not be null"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - String uid = userManager.createUser(request); - return userManager.getUserById(uid); - } - }; - } - - /** - * Updates an existing user account with the attributes contained in the specified - * {@link UpdateRequest}. - * - * @param request A non-null {@link UpdateRequest} instance. - * @return A {@link UserRecord} instance corresponding to the updated user account. - * account, the task fails with a {@link FirebaseAuthException}. - * @throws NullPointerException if the provided update request is null. - * @throws FirebaseAuthException if an error occurs while updating the user account. - */ - public UserRecord updateUser(@NonNull UpdateRequest request) throws FirebaseAuthException { - return updateUserOp(request).call(); - } - - /** - * Similar to {@link #updateUser(UpdateRequest)} but performs the operation asynchronously. - * - * @param request A non-null {@link UpdateRequest} instance. - * @return An {@code ApiFuture} which will complete successfully with a {@link UserRecord} - * instance corresponding to the updated user account. If an error occurs while updating the - * user account, the future throws a {@link FirebaseAuthException}. - */ - public ApiFuture updateUserAsync(@NonNull UpdateRequest request) { - return updateUserOp(request).callAsync(firebaseApp); - } - - private CallableOperation updateUserOp( - final UpdateRequest request) { - checkNotDestroyed(); - checkNotNull(request, "update request must not be null"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserRecord execute() throws FirebaseAuthException { - userManager.updateUser(request, jsonFactory); - return userManager.getUserById(request.getUid()); - } - }; - } - - /** - * Sets the specified custom claims on an existing user account. A null claims value removes - * any claims currently set on the user account. The claims should serialize into a valid JSON - * string. The serialized claims must not be larger than 1000 characters. - * - * @param uid A user ID string. - * @param claims A map of custom claims or null. - * @throws FirebaseAuthException If an error occurs while updating custom claims. - * @throws IllegalArgumentException If the user ID string is null or empty, or the claims - * payload is invalid or too large. - */ - public void setCustomUserClaims(@NonNull String uid, - @Nullable Map claims) throws FirebaseAuthException { - setCustomUserClaimsOp(uid, claims).call(); - } - - /** - * @deprecated Use {@link #setCustomUserClaims(String, Map)} instead. - */ - public void setCustomClaims(@NonNull String uid, - @Nullable Map claims) throws FirebaseAuthException { - setCustomUserClaims(uid, claims); - } - - /** - * Similar to {@link #setCustomUserClaims(String, Map)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @param claims A map of custom claims or null. - * @return An {@code ApiFuture} which will complete successfully when the user account has been - * updated. If an error occurs while deleting the user account, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture setCustomUserClaimsAsync( - @NonNull String uid, @Nullable Map claims) { - return setCustomUserClaimsOp(uid, claims).callAsync(firebaseApp); - } - - private CallableOperation setCustomUserClaimsOp( - final String uid, final Map claims) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - final UpdateRequest request = new UpdateRequest(uid).setCustomClaims(claims); - userManager.updateUser(request, jsonFactory); - return null; - } - }; - } - - /** - * Deletes the user identified by the specified user ID. - * - * @param uid A user ID string. - * @throws IllegalArgumentException If the user ID string is null or empty. - * @throws FirebaseAuthException If an error occurs while deleting the user. - */ - public void deleteUser(@NonNull String uid) throws FirebaseAuthException { - deleteUserOp(uid).call(); - } - - /** - * Similar to {@link #deleteUser(String)} but performs the operation asynchronously. - * - * @param uid A user ID string. - * @return An {@code ApiFuture} which will complete successfully when the specified user account - * has been deleted. If an error occurs while deleting the user account, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the user ID string is null or empty. - */ - public ApiFuture deleteUserAsync(String uid) { - return deleteUserOp(uid).callAsync(firebaseApp); - } - - private CallableOperation deleteUserOp(final String uid) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(uid), "uid must not be null or empty"); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected Void execute() throws FirebaseAuthException { - userManager.deleteUser(uid); - return null; - } - }; - } - - /** - * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a - * time. This operation is optimized for bulk imports and will ignore checks on identifier - * uniqueness which could result in duplications. - * - *

{@link UserImportOptions} is required to import users with passwords. See - * {@link #importUsers(List, UserImportOptions)}. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @return A {@link UserImportResult} instance. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password. - * @throws FirebaseAuthException If an error occurs while importing users. - */ - public UserImportResult importUsers(List users) throws FirebaseAuthException { - return importUsers(users, null); - } - - /** - * Imports the provided list of users into Firebase Auth. At most 1000 users can be imported at a - * time. This operation is optimized for bulk imports and will ignore checks on identifier - * uniqueness which could result in duplications. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @param options a {@link UserImportOptions} instance or null. Required when importing users - * with passwords. - * @return A {@link UserImportResult} instance. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password, and options is null. - * @throws FirebaseAuthException If an error occurs while importing users. - */ - public UserImportResult importUsers(List users, - @Nullable UserImportOptions options) throws FirebaseAuthException { - return importUsersOp(users, options).call(); - } - - /** - * Similar to {@link #importUsers(List)} but performs the operation asynchronously. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @return An {@code ApiFuture} which will complete successfully when the user accounts are - * imported. If an error occurs while importing the users, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password. - */ - public ApiFuture importUsersAsync(List users) { - return importUsersAsync(users, null); - } - - /** - * Similar to {@link #importUsers(List, UserImportOptions)} but performs the operation - * asynchronously. - * - * @param users A non-empty list of users to be imported. Length must not exceed 1000. - * @param options a {@link UserImportOptions} instance or null. Required when importing users - * with passwords. - * @return An {@code ApiFuture} which will complete successfully when the user accounts are - * imported. If an error occurs while importing the users, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the users list is null, empty or has more than 1000 - * elements. Or if at least one user specifies a password, and options is null. - */ - public ApiFuture importUsersAsync(List users, - @Nullable UserImportOptions options) { - return importUsersOp(users, options).callAsync(firebaseApp); - } - - private CallableOperation importUsersOp( - final List users, final UserImportOptions options) { - checkNotDestroyed(); - final UserImportRequest request = new UserImportRequest(users, options, jsonFactory); - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected UserImportResult execute() throws FirebaseAuthException { - return userManager.importUsers(request); - } - }; - } - - /** - * Generates the out-of-band email action link for password reset flows for the specified email - * address. - * - * @param email The email of the user whose password is to be reset. - * @return A password reset link. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws FirebaseAuthException If an error occurs while generating the link. - */ - public String generatePasswordResetLink(@NonNull String email) throws FirebaseAuthException { - return generatePasswordResetLink(email, null); - } - - /** - * Generates the out-of-band email action link for password reset flows for the specified email - * address. - * - * @param email The email of the user whose password is to be reset. - * @param settings The action code settings object which defines whether - * the link is to be handled by a mobile app and the additional state information to be - * passed in the deep link. - * @return A password reset link. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws FirebaseAuthException If an error occurs while generating the link. - */ - public String generatePasswordResetLink( - @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { - return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings).call(); - } - - /** - * Similar to {@link #generatePasswordResetLink(String)} but performs the operation - * asynchronously. - * - * @param email The email of the user whose password is to be reset. - * @return An {@code ApiFuture} which will complete successfully with the generated email action - * link. If an error occurs while generating the link, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email address is null or empty. - */ - public ApiFuture generatePasswordResetLinkAsync(@NonNull String email) { - return generatePasswordResetLinkAsync(email, null); - } - - /** - * Similar to {@link #generatePasswordResetLink(String, ActionCodeSettings)} but performs the - * operation asynchronously. - * - * @param email The email of the user whose password is to be reset. - * @param settings The action code settings object which defines whether - * the link is to be handled by a mobile app and the additional state information to be - * passed in the deep link. - * @return An {@code ApiFuture} which will complete successfully with the generated email action - * link. If an error occurs while generating the link, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email address is null or empty. - */ - public ApiFuture generatePasswordResetLinkAsync( - @NonNull String email, @Nullable ActionCodeSettings settings) { - return generateEmailActionLinkOp(EmailLinkType.PASSWORD_RESET, email, settings) - .callAsync(firebaseApp); - } - - /** - * Generates the out-of-band email action link for email verification flows for the specified - * email address. - * - * @param email The email of the user to be verified. - * @return An email verification link. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws FirebaseAuthException If an error occurs while generating the link. - */ - public String generateEmailVerificationLink(@NonNull String email) throws FirebaseAuthException { - return generateEmailVerificationLink(email, null); - } - - /** - * Generates the out-of-band email action link for email verification flows for the specified - * email address, using the action code settings provided. - * - * @param email The email of the user to be verified. - * @return An email verification link. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws FirebaseAuthException If an error occurs while generating the link. - */ - public String generateEmailVerificationLink( - @NonNull String email, @Nullable ActionCodeSettings settings) throws FirebaseAuthException { - return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings).call(); - } - - /** - * Similar to {@link #generateEmailVerificationLink(String)} but performs the - * operation asynchronously. - * - * @param email The email of the user to be verified. - * @return An {@code ApiFuture} which will complete successfully with the generated email action - * link. If an error occurs while generating the link, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email address is null or empty. - */ - public ApiFuture generateEmailVerificationLinkAsync(@NonNull String email) { - return generateEmailVerificationLinkAsync(email, null); - } - - /** - * Similar to {@link #generateEmailVerificationLink(String, ActionCodeSettings)} but performs the - * operation asynchronously. - * - * @param email The email of the user to be verified. - * @param settings The action code settings object which defines whether - * the link is to be handled by a mobile app and the additional state information to be - * passed in the deep link. - * @return An {@code ApiFuture} which will complete successfully with the generated email action - * link. If an error occurs while generating the link, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email address is null or empty. - */ - public ApiFuture generateEmailVerificationLinkAsync( - @NonNull String email, @Nullable ActionCodeSettings settings) { - return generateEmailActionLinkOp(EmailLinkType.VERIFY_EMAIL, email, settings) - .callAsync(firebaseApp); - } - - /** - * Generates the out-of-band email action link for email link sign-in flows, using the action - * code settings provided. - * - * @param email The email of the user signing in. - * @param settings The action code settings object which defines whether - * the link is to be handled by a mobile app and the additional state information to be - * passed in the deep link. - * @return An email verification link. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws FirebaseAuthException If an error occurs while generating the link. - */ - public String generateSignInWithEmailLink( - @NonNull String email, @NonNull ActionCodeSettings settings) throws FirebaseAuthException { - return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings).call(); - } - - /** - * Similar to {@link #generateSignInWithEmailLink(String, ActionCodeSettings)} but performs the - * operation asynchronously. - * - * @param email The email of the user signing in. - * @param settings The action code settings object which defines whether - * the link is to be handled by a mobile app and the additional state information to be - * passed in the deep link. - * @return An {@code ApiFuture} which will complete successfully with the generated email action - * link. If an error occurs while generating the link, the future throws a - * {@link FirebaseAuthException}. - * @throws IllegalArgumentException If the email address is null or empty. - * @throws NullPointerException If the settings is null. - */ - public ApiFuture generateSignInWithEmailLinkAsync( - String email, @NonNull ActionCodeSettings settings) { - return generateEmailActionLinkOp(EmailLinkType.EMAIL_SIGNIN, email, settings) - .callAsync(firebaseApp); - } - - @VisibleForTesting - FirebaseUserManager getUserManager() { - return this.userManager.get(); - } - - private CallableOperation generateEmailActionLinkOp( - final EmailLinkType type, final String email, final ActionCodeSettings settings) { - checkNotDestroyed(); - checkArgument(!Strings.isNullOrEmpty(email), "email must not be null or empty"); - if (type == EmailLinkType.EMAIL_SIGNIN) { - checkNotNull(settings, "ActionCodeSettings must not be null when generating sign-in links"); - } - final FirebaseUserManager userManager = getUserManager(); - return new CallableOperation() { - @Override - protected String execute() throws FirebaseAuthException { - return userManager.getEmailActionLink(type, email, settings); - } - }; - } - - private Supplier threadSafeMemoize(final Supplier supplier) { - return Suppliers.memoize(new Supplier() { - @Override - public T get() { - checkNotNull(supplier); - synchronized (lock) { - checkNotDestroyed(); - return supplier.get(); - } - } - }); - } - - private void checkNotDestroyed() { - synchronized (lock) { - checkState(!destroyed.get(), "FirebaseAuth instance is no longer alive. This happens when " - + "the parent FirebaseApp instance has been deleted."); - } - } - - private void destroy() { - synchronized (lock) { - destroyed.set(true); - } - } - private static FirebaseAuth fromApp(final FirebaseApp app) { - return FirebaseAuth.builder() - .setFirebaseApp(app) - .setTokenFactory(new Supplier() { - @Override - public FirebaseTokenFactory get() { - return FirebaseTokenUtils.createTokenFactory(app, Clock.SYSTEM); - } - }) - .setIdTokenVerifier(new Supplier() { - @Override - public FirebaseTokenVerifier get() { - return FirebaseTokenUtils.createIdTokenVerifier(app, Clock.SYSTEM); - } - }) - .setCookieVerifier(new Supplier() { - @Override - public FirebaseTokenVerifier get() { - return FirebaseTokenUtils.createSessionCookieVerifier(app, Clock.SYSTEM); - } - }) - .setUserManager(new Supplier() { + return populateBuilderFromApp(builder(), app, null) + .setTenantManager(new Supplier() { @Override - public FirebaseUserManager get() { - return new FirebaseUserManager(app); + public TenantManager get() { + return new TenantManager(app); } }) .build(); } - @VisibleForTesting + private static class FirebaseAuthService extends FirebaseService { + + FirebaseAuthService(FirebaseApp app) { + super(SERVICE_ID, FirebaseAuth.fromApp(app)); + } + } + static Builder builder() { return new Builder(); } - static class Builder { - private FirebaseApp firebaseApp; - private Supplier tokenFactory; - private Supplier idTokenVerifier; - private Supplier cookieVerifier; - private Supplier userManager; + static class Builder extends AbstractFirebaseAuth.Builder { - private Builder() { } - - Builder setFirebaseApp(FirebaseApp firebaseApp) { - this.firebaseApp = firebaseApp; - return this; - } + private Supplier tenantManager; - Builder setTokenFactory(Supplier tokenFactory) { - this.tokenFactory = tokenFactory; - return this; - } - - Builder setIdTokenVerifier(Supplier idTokenVerifier) { - this.idTokenVerifier = idTokenVerifier; - return this; - } + private Builder() { } - Builder setCookieVerifier(Supplier cookieVerifier) { - this.cookieVerifier = cookieVerifier; + @Override + protected Builder getThis() { return this; } - Builder setUserManager(Supplier userManager) { - this.userManager = userManager; + public Builder setTenantManager(Supplier tenantManager) { + this.tenantManager = tenantManager; return this; } - FirebaseAuth build() { + public FirebaseAuth build() { return new FirebaseAuth(this); } } - - private static class FirebaseAuthService extends FirebaseService { - - FirebaseAuthService(FirebaseApp app) { - super(SERVICE_ID, FirebaseAuth.fromApp(app)); - } - - @Override - public void destroy() { - instance.destroy(); - } - } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java index 2314a69d2..53c980668 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseAuthException.java +++ b/src/main/java/com/google/firebase/auth/FirebaseAuthException.java @@ -16,17 +16,11 @@ package com.google.firebase.auth; -// TODO: Move it out from firebase-common. Temporary host it their for -// database's integration.http://b/27624510. - -// TODO: Decide if changing this not enforcing an error code. Need to align -// with the decision in http://b/27677218. Also, need to turn this into abstract later. - -import static com.google.common.base.Preconditions.checkArgument; - -import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; /** * Generic exception related to Firebase Authentication. Check the error code and message for more @@ -34,22 +28,24 @@ */ public class FirebaseAuthException extends FirebaseException { - private final String errorCode; + private final AuthErrorCode errorCode; - public FirebaseAuthException(@NonNull String errorCode, @NonNull String detailMessage) { - this(errorCode, detailMessage, null); + public FirebaseAuthException( + @NonNull ErrorCode errorCode, + @NonNull String message, + Throwable cause, + IncomingHttpResponse response, + AuthErrorCode authErrorCode) { + super(errorCode, message, cause, response); + this.errorCode = authErrorCode; } - public FirebaseAuthException(@NonNull String errorCode, @NonNull String detailMessage, - Throwable throwable) { - super(detailMessage, throwable); - checkArgument(!Strings.isNullOrEmpty(errorCode)); - this.errorCode = errorCode; + public FirebaseAuthException(FirebaseException base) { + this(base.getErrorCode(), base.getMessage(), base.getCause(), base.getHttpResponse(), null); } - /** Returns an error code that may provide more information about the error. */ - @NonNull - public String getErrorCode() { + @Nullable + public AuthErrorCode getAuthErrorCode() { return errorCode; } } diff --git a/src/main/java/com/google/firebase/auth/FirebaseToken.java b/src/main/java/com/google/firebase/auth/FirebaseToken.java index 3d7b0b254..6950bf16f 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseToken.java +++ b/src/main/java/com/google/firebase/auth/FirebaseToken.java @@ -37,12 +37,21 @@ public final class FirebaseToken { this.claims = ImmutableMap.copyOf(claims); } - /** Returns the Uid for the this token. */ + /** Returns the Uid for this token. */ public String getUid() { return (String) claims.get("sub"); } - /** Returns the Issuer for the this token. */ + /** Returns the tenant ID for this token. */ + public String getTenantId() { + Map firebase = (Map) claims.get("firebase"); + if (firebase == null) { + return null; + } + return (String) firebase.get("tenant"); + } + + /** Returns the Issuer for this token. */ public String getIssuer() { return (String) claims.get("iss"); } @@ -57,14 +66,14 @@ public String getPicture() { return (String) claims.get("picture"); } - /** + /** * Returns the e-mail address for this user, or {@code null} if it's unavailable. */ public String getEmail() { return (String) claims.get("email"); } - /** + /** * Indicates if the email address returned by {@link #getEmail()} has been verified as good. */ public boolean isEmailVerified() { diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java index dbb562872..873dbe7ac 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenUtils.java @@ -30,6 +30,7 @@ import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.auth.internal.CryptoSigners; import com.google.firebase.auth.internal.FirebaseTokenFactory; +import com.google.firebase.internal.Nullable; import java.io.IOException; @@ -52,11 +53,17 @@ final class FirebaseTokenUtils { private FirebaseTokenUtils() { } static FirebaseTokenFactory createTokenFactory(FirebaseApp firebaseApp, Clock clock) { + return createTokenFactory(firebaseApp, clock, null); + } + + static FirebaseTokenFactory createTokenFactory( + FirebaseApp firebaseApp, Clock clock, @Nullable String tenantId) { try { return new FirebaseTokenFactory( firebaseApp.getOptions().getJsonFactory(), clock, - CryptoSigners.getCryptoSigner(firebaseApp)); + CryptoSigners.getCryptoSigner(firebaseApp), + tenantId); } catch (IOException e) { throw new IllegalStateException( "Failed to initialize FirebaseTokenFactory. Make sure to initialize the SDK " @@ -68,6 +75,11 @@ static FirebaseTokenFactory createTokenFactory(FirebaseApp firebaseApp, Clock cl } static FirebaseTokenVerifierImpl createIdTokenVerifier(FirebaseApp app, Clock clock) { + return createIdTokenVerifier(app, clock, null); + } + + static FirebaseTokenVerifierImpl createIdTokenVerifier( + FirebaseApp app, Clock clock, @Nullable String tenantId) { String projectId = ImplFirebaseTrampolines.getProjectId(app); checkState(!Strings.isNullOrEmpty(projectId), "Must initialize FirebaseApp with a project ID to call verifyIdToken()"); @@ -82,10 +94,18 @@ static FirebaseTokenVerifierImpl createIdTokenVerifier(FirebaseApp app, Clock cl .setJsonFactory(app.getOptions().getJsonFactory()) .setPublicKeysManager(publicKeysManager) .setIdTokenVerifier(idTokenVerifier) + .setInvalidTokenErrorCode(AuthErrorCode.INVALID_ID_TOKEN) + .setExpiredTokenErrorCode(AuthErrorCode.EXPIRED_ID_TOKEN) + .setTenantId(tenantId) .build(); } static FirebaseTokenVerifierImpl createSessionCookieVerifier(FirebaseApp app, Clock clock) { + return createSessionCookieVerifier(app, clock, null); + } + + static FirebaseTokenVerifierImpl createSessionCookieVerifier( + FirebaseApp app, Clock clock, @Nullable String tenantId) { String projectId = ImplFirebaseTrampolines.getProjectId(app); checkState(!Strings.isNullOrEmpty(projectId), "Must initialize FirebaseApp with a project ID to call verifySessionCookie()"); @@ -94,12 +114,15 @@ static FirebaseTokenVerifierImpl createSessionCookieVerifier(FirebaseApp app, Cl GooglePublicKeysManager publicKeysManager = newPublicKeysManager( app.getOptions(), clock, SESSION_COOKIE_CERT_URL); return FirebaseTokenVerifierImpl.builder() - .setJsonFactory(app.getOptions().getJsonFactory()) - .setPublicKeysManager(publicKeysManager) - .setIdTokenVerifier(idTokenVerifier) .setShortName("session cookie") .setMethod("verifySessionCookie()") .setDocUrl("https://firebase.google.com/docs/auth/admin/manage-cookies") + .setInvalidTokenErrorCode(AuthErrorCode.INVALID_SESSION_COOKIE) + .setExpiredTokenErrorCode(AuthErrorCode.EXPIRED_SESSION_COOKIE) + .setJsonFactory(app.getOptions().getJsonFactory()) + .setPublicKeysManager(publicKeysManager) + .setIdTokenVerifier(idTokenVerifier) + .setTenantId(tenantId) .build(); } diff --git a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java index c164173a6..9a3e1ae15 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java +++ b/src/main/java/com/google/firebase/auth/FirebaseTokenVerifierImpl.java @@ -28,10 +28,14 @@ import com.google.api.client.util.ArrayMap; import com.google.common.base.Joiner; import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.Nullable; import java.io.IOException; import java.math.BigDecimal; import java.security.GeneralSecurityException; import java.security.PublicKey; +import java.util.List; /** * The default implementation of the {@link FirebaseTokenVerifier} interface. Uses the Google API @@ -43,8 +47,6 @@ final class FirebaseTokenVerifierImpl implements FirebaseTokenVerifier { private static final String RS256 = "RS256"; private static final String FIREBASE_AUDIENCE = "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"; - private static final String ERROR_INVALID_CREDENTIAL = "ERROR_INVALID_CREDENTIAL"; - private static final String ERROR_RUNTIME_EXCEPTION = "ERROR_RUNTIME_EXCEPTION"; private final JsonFactory jsonFactory; private final GooglePublicKeysManager publicKeysManager; @@ -53,6 +55,9 @@ final class FirebaseTokenVerifierImpl implements FirebaseTokenVerifier { private final String shortName; private final String articledShortName; private final String docUrl; + private final AuthErrorCode invalidTokenErrorCode; + private final AuthErrorCode expiredTokenErrorCode; + private final String tenantId; private FirebaseTokenVerifierImpl(Builder builder) { this.jsonFactory = checkNotNull(builder.jsonFactory); @@ -65,6 +70,9 @@ private FirebaseTokenVerifierImpl(Builder builder) { this.shortName = builder.shortName; this.articledShortName = prefixWithIndefiniteArticle(this.shortName); this.docUrl = builder.docUrl; + this.invalidTokenErrorCode = checkNotNull(builder.invalidTokenErrorCode); + this.expiredTokenErrorCode = checkNotNull(builder.expiredTokenErrorCode); + this.tenantId = builder.tenantId; } /** @@ -87,10 +95,15 @@ private FirebaseTokenVerifierImpl(Builder builder) { */ @Override public FirebaseToken verifyToken(String token) throws FirebaseAuthException { + boolean isEmulatorMode = Utils.isEmulatorMode(); IdToken idToken = parse(token); - checkContents(idToken); - checkSignature(idToken); - return new FirebaseToken(idToken.getPayload()); + checkContents(idToken, isEmulatorMode); + if (!isEmulatorMode) { + checkSignature(idToken); + } + FirebaseToken firebaseToken = new FirebaseToken(idToken.getPayload()); + checkTenantId(firebaseToken); + return firebaseToken; } GooglePublicKeysManager getPublicKeysManager() { @@ -137,41 +150,32 @@ private IdToken parse(String token) throws FirebaseAuthException { shortName, docUrl, articledShortName); - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, detailedError, e); - } - } - - private void checkContents(final IdToken token) throws FirebaseAuthException { - String errorMessage = getErrorIfContentInvalid(token); - if (errorMessage != null) { - String detailedError = String.format("%s %s", errorMessage, getVerifyTokenMessage()); - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, detailedError); + throw newException(detailedError, invalidTokenErrorCode, e); } } private void checkSignature(IdToken token) throws FirebaseAuthException { - try { - if (!isSignatureValid(token)) { - throw new FirebaseAuthException(ERROR_INVALID_CREDENTIAL, - String.format( - "Failed to verify the signature of Firebase %s. %s", - shortName, - getVerifyTokenMessage())); - } - } catch (GeneralSecurityException | IOException e) { - throw new FirebaseAuthException( - ERROR_RUNTIME_EXCEPTION, "Error while verifying signature.", e); + if (!isSignatureValid(token)) { + String message = String.format( + "Failed to verify the signature of Firebase %s. %s", + shortName, + getVerifyTokenMessage()); + throw newException(message, invalidTokenErrorCode); } } - private String getErrorIfContentInvalid(final IdToken idToken) { + private void checkContents(final IdToken idToken, boolean isEmulatorMode) + throws FirebaseAuthException { final Header header = idToken.getHeader(); final Payload payload = idToken.getPayload(); + final long currentTimeMillis = idTokenVerifier.getClock().currentTimeMillis(); String errorMessage = null; - if (header.getKeyId() == null) { + AuthErrorCode errorCode = invalidTokenErrorCode; + + if (!isEmulatorMode && header.getKeyId() == null) { errorMessage = getErrorForTokenWithoutKid(header, payload); - } else if (!RS256.equals(header.getAlgorithm())) { + } else if (!isEmulatorMode && !RS256.equals(header.getAlgorithm())) { errorMessage = String.format( "Firebase %s has incorrect algorithm. Expected \"%s\" but got \"%s\".", shortName, @@ -203,14 +207,35 @@ private String getErrorIfContentInvalid(final IdToken idToken) { errorMessage = String.format( "Firebase %s has \"sub\" (subject) claim longer than 128 characters.", shortName); - } else if (!verifyTimestamps(idToken)) { + } else if (!idToken.verifyExpirationTime( + currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds())) { errorMessage = String.format( - "Firebase %s has expired or is not yet valid. Get a fresh %s and try again.", + "Firebase %s has expired. Get a fresh %s and try again.", shortName, shortName); + // Also set the expired error code. + errorCode = expiredTokenErrorCode; + } else if (!idToken.verifyIssuedAtTime( + currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds())) { + errorMessage = String.format( + "Firebase %s is not yet valid.", + shortName); + } + + if (errorMessage != null) { + String detailedError = String.format("%s %s", errorMessage, getVerifyTokenMessage()); + throw newException(detailedError, errorCode); } + } + + private FirebaseAuthException newException(String message, AuthErrorCode errorCode) { + return newException(message, errorCode, null); + } - return errorMessage; + private FirebaseAuthException newException( + String message, AuthErrorCode errorCode, Throwable cause) { + return new FirebaseAuthException( + ErrorCode.INVALID_ARGUMENT, message, cause, null, errorCode); } private String getVerifyTokenMessage() { @@ -224,15 +249,44 @@ private String getVerifyTokenMessage() { * Verifies the cryptographic signature on the FirebaseToken. Can block on a web request to fetch * the keys if they have expired. */ - private boolean isSignatureValid(IdToken token) throws GeneralSecurityException, IOException { - for (PublicKey key : publicKeysManager.getPublicKeys()) { - if (token.verifySignature(key)) { + private boolean isSignatureValid(IdToken token) throws FirebaseAuthException { + for (PublicKey key : fetchPublicKeys()) { + if (isSignatureValid(token, key)) { return true; } } + return false; } + private boolean isSignatureValid(IdToken token, PublicKey key) throws FirebaseAuthException { + try { + return token.verifySignature(key); + } catch (GeneralSecurityException e) { + // This doesn't happen under usual circumstances. Seems to only happen if the crypto + // setup of the runtime is incorrect in some way. + throw new FirebaseAuthException( + ErrorCode.UNKNOWN, + String.format("Unexpected error while verifying %s: %s", shortName, e.getMessage()), + e, + null, + invalidTokenErrorCode); + } + } + + private List fetchPublicKeys() throws FirebaseAuthException { + try { + return publicKeysManager.getPublicKeys(); + } catch (GeneralSecurityException | IOException e) { + throw new FirebaseAuthException( + ErrorCode.UNKNOWN, + "Error while fetching public key certificates: " + e.getMessage(), + e, + null, + AuthErrorCode.CERTIFICATE_FETCH_FAILED); + } + } + private String getErrorForTokenWithoutKid(IdToken.Header header, IdToken.Payload payload) { if (isCustomToken(payload)) { return String.format("%s expects %s, but was given a custom token.", @@ -255,11 +309,6 @@ private String getProjectIdMatchMessage() { shortName); } - private boolean verifyTimestamps(IdToken token) { - long currentTimeMillis = idTokenVerifier.getClock().currentTimeMillis(); - return token.verifyTime(currentTimeMillis, idTokenVerifier.getAcceptableTimeSkewSeconds()); - } - private boolean isCustomToken(IdToken.Payload payload) { return FIREBASE_AUDIENCE.equals(payload.getAudience()); } @@ -278,6 +327,17 @@ private boolean containsLegacyUidField(IdToken.Payload payload) { return false; } + private void checkTenantId(final FirebaseToken firebaseToken) throws FirebaseAuthException { + String tokenTenantId = firebaseToken.getTenantId(); + if (this.tenantId != null && !this.tenantId.equals(tokenTenantId)) { + String message = String.format( + "The tenant ID ('%s') of the token did not match the expected value ('%s')", + Strings.nullToEmpty(tokenTenantId), + tenantId); + throw newException(message, AuthErrorCode.TENANT_ID_MISMATCH); + } + } + static Builder builder() { return new Builder(); } @@ -290,6 +350,9 @@ static final class Builder { private String shortName; private IdTokenVerifier idTokenVerifier; private String docUrl; + private AuthErrorCode invalidTokenErrorCode; + private AuthErrorCode expiredTokenErrorCode; + private String tenantId; private Builder() { } @@ -323,6 +386,21 @@ Builder setDocUrl(String docUrl) { return this; } + Builder setInvalidTokenErrorCode(AuthErrorCode invalidTokenErrorCode) { + this.invalidTokenErrorCode = invalidTokenErrorCode; + return this; + } + + Builder setExpiredTokenErrorCode(AuthErrorCode expiredTokenErrorCode) { + this.expiredTokenErrorCode = expiredTokenErrorCode; + return this; + } + + Builder setTenantId(@Nullable String tenantId) { + this.tenantId = tenantId; + return this; + } + FirebaseTokenVerifierImpl build() { return new FirebaseTokenVerifierImpl(this); } diff --git a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java index 03c2813bc..195527841 100644 --- a/src/main/java/com/google/firebase/auth/FirebaseUserManager.java +++ b/src/main/java/com/google/firebase/auth/FirebaseUserManager.java @@ -19,39 +19,38 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpContent; -import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.Key; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.auth.UserRecord.CreateRequest; -import com.google.firebase.auth.UserRecord.UpdateRequest; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.auth.internal.AuthHttpClient; +import com.google.firebase.auth.internal.BatchDeleteResponse; import com.google.firebase.auth.internal.DownloadAccountResponse; +import com.google.firebase.auth.internal.GetAccountInfoRequest; import com.google.firebase.auth.internal.GetAccountInfoResponse; - -import com.google.firebase.auth.internal.HttpErrorResponse; +import com.google.firebase.auth.internal.ListOidcProviderConfigsResponse; +import com.google.firebase.auth.internal.ListSamlProviderConfigsResponse; import com.google.firebase.auth.internal.UploadAccountResponse; +import com.google.firebase.auth.internal.Utils; import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; import com.google.firebase.internal.Nullable; -import com.google.firebase.internal.SdkUtils; - -import java.io.IOException; +import java.util.Collection; +import java.util.HashSet; import java.util.List; import java.util.Map; +import java.util.Set; /** * FirebaseUserManager provides methods for interacting with the Google Identity Toolkit via its @@ -60,32 +59,11 @@ * @see * Google Identity Toolkit */ -class FirebaseUserManager { - - static final String USER_NOT_FOUND_ERROR = "user-not-found"; - static final String INTERNAL_ERROR = "internal-error"; - - // Map of server-side error codes to SDK error codes. - // SDK error codes defined at: https://firebase.google.com/docs/auth/admin/errors - private static final Map ERROR_CODES = ImmutableMap.builder() - .put("CLAIMS_TOO_LARGE", "claims-too-large") - .put("CONFIGURATION_NOT_FOUND", "project-not-found") - .put("INSUFFICIENT_PERMISSION", "insufficient-permission") - .put("DUPLICATE_EMAIL", "email-already-exists") - .put("DUPLICATE_LOCAL_ID", "uid-already-exists") - .put("EMAIL_EXISTS", "email-already-exists") - .put("INVALID_CLAIMS", "invalid-claims") - .put("INVALID_EMAIL", "invalid-email") - .put("INVALID_PAGE_SELECTION", "invalid-page-token") - .put("INVALID_PHONE_NUMBER", "invalid-phone-number") - .put("PHONE_NUMBER_EXISTS", "phone-number-already-exists") - .put("PROJECT_NOT_FOUND", "project-not-found") - .put("USER_NOT_FOUND", USER_NOT_FOUND_ERROR) - .put("WEAK_PASSWORD", "invalid-password") - .put("UNAUTHORIZED_DOMAIN", "unauthorized-continue-uri") - .put("INVALID_DYNAMIC_LINK_DOMAIN", "invalid-dynamic-link-domain") - .build(); +final class FirebaseUserManager { + static final int MAX_LIST_PROVIDER_CONFIGS_RESULTS = 100; + static final int MAX_GET_ACCOUNTS_BATCH_SIZE = 100; + static final int MAX_DELETE_ACCOUNTS_BATCH_SIZE = 1000; static final int MAX_LIST_USERS_RESULTS = 1000; static final int MAX_IMPORT_USERS = 1000; @@ -94,110 +72,120 @@ class FirebaseUserManager { "iss", "jti", "nbf", "nonce", "sub", "firebase"); private static final String ID_TOOLKIT_URL = - "https://identitytoolkit.googleapis.com/v1/projects/%s"; - private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; + "https://identitytoolkit.googleapis.com/%s/projects/%s"; + private static final String ID_TOOLKIT_URL_EMULATOR = + "http://%s/identitytoolkit.googleapis.com/%s/projects/%s"; - private final String baseUrl; + private final String userMgtBaseUrl; + private final String idpConfigMgtBaseUrl; private final JsonFactory jsonFactory; - private final HttpRequestFactory requestFactory; - private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion(); - - private HttpResponseInterceptor interceptor; + private final AuthHttpClient httpClient; - /** - * Creates a new FirebaseUserManager instance. - * - * @param app A non-null {@link FirebaseApp}. - */ - FirebaseUserManager(@NonNull FirebaseApp app) { - this(app, null); - } - - FirebaseUserManager(@NonNull FirebaseApp app, @Nullable HttpRequestFactory requestFactory) { - checkNotNull(app, "FirebaseApp must not be null"); - String projectId = ImplFirebaseTrampolines.getProjectId(app); + private FirebaseUserManager(Builder builder) { + String projectId = builder.projectId; checkArgument(!Strings.isNullOrEmpty(projectId), "Project ID is required to access the auth service. Use a service account credential or " + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); - this.baseUrl = String.format(ID_TOOLKIT_URL, projectId); - this.jsonFactory = app.getOptions().getJsonFactory(); - - if (requestFactory == null) { - requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app); + this.jsonFactory = checkNotNull(builder.jsonFactory, "JsonFactory must not be null"); + final String idToolkitUrlV1 = getIdToolkitUrl(projectId, "v1"); + final String idToolkitUrlV2 = getIdToolkitUrl(projectId, "v2"); + final String tenantId = builder.tenantId; + if (tenantId == null) { + this.userMgtBaseUrl = idToolkitUrlV1; + this.idpConfigMgtBaseUrl = idToolkitUrlV2; + } else { + checkArgument(!tenantId.isEmpty(), "Tenant ID must not be empty."); + this.userMgtBaseUrl = idToolkitUrlV1 + "/tenants/" + tenantId; + this.idpConfigMgtBaseUrl = idToolkitUrlV2 + "/tenants/" + tenantId; } - this.requestFactory = requestFactory; + this.httpClient = new AuthHttpClient(jsonFactory, builder.requestFactory); + } + + private String getIdToolkitUrl(String projectId, String version) { + if (Utils.isEmulatorMode()) { + return String.format(ID_TOOLKIT_URL_EMULATOR, Utils.getEmulatorHost(), version, projectId); + } + return String.format(ID_TOOLKIT_URL, version, projectId); } @VisibleForTesting void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } UserRecord getUserById(String uid) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "localId", ImmutableList.of(uid)); - GetAccountInfoResponse response = post( - "/accounts:lookup", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided user ID: " + uid); - } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return lookupUserAccount(payload, "user ID: " + uid); } UserRecord getUserByEmail(String email) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "email", ImmutableList.of(email)); - GetAccountInfoResponse response = post( - "/accounts:lookup", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided email: " + email); - } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return lookupUserAccount(payload, "email: " + email); } UserRecord getUserByPhoneNumber(String phoneNumber) throws FirebaseAuthException { final Map payload = ImmutableMap.of( "phoneNumber", ImmutableList.of(phoneNumber)); + return lookupUserAccount(payload, "phone number: " + phoneNumber); + } + + Set getAccountInfo(@NonNull Collection identifiers) + throws FirebaseAuthException { + if (identifiers.isEmpty()) { + return new HashSet<>(); + } + + GetAccountInfoRequest payload = new GetAccountInfoRequest(); + for (UserIdentifier id : identifiers) { + id.populate(payload); + } + GetAccountInfoResponse response = post( "/accounts:lookup", payload, GetAccountInfoResponse.class); - if (response == null || response.getUsers() == null || response.getUsers().isEmpty()) { - throw new FirebaseAuthException(USER_NOT_FOUND_ERROR, - "No user record found for the provided phone number: " + phoneNumber); + Set results = new HashSet<>(); + if (response.getUsers() != null) { + for (GetAccountInfoResponse.User user : response.getUsers()) { + results.add(new UserRecord(user, jsonFactory)); + } } - return new UserRecord(response.getUsers().get(0), jsonFactory); + return results; } - String createUser(CreateRequest request) throws FirebaseAuthException { - GenericJson response = post( - "/accounts", request.getProperties(), GenericJson.class); - if (response != null) { - String uid = (String) response.get("localId"); - if (!Strings.isNullOrEmpty(uid)) { - return uid; - } - } - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create new user"); + UserRecord getUserByProviderUid( + String providerId, String uid) throws FirebaseAuthException { + final Map payload = ImmutableMap.of( + "federatedUserId", ImmutableList.of( + ImmutableMap.builder() + .put("rawId", uid).put("providerId", providerId).build())); + return lookupUserAccount(payload, uid); } - void updateUser(UpdateRequest request, JsonFactory jsonFactory) throws FirebaseAuthException { - GenericJson response = post( - "/accounts:update", request.getProperties(jsonFactory), GenericJson.class); - if (response == null || !request.getUid().equals(response.get("localId"))) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to update user: " + request.getUid()); - } + String createUser(UserRecord.CreateRequest request) throws FirebaseAuthException { + GenericJson response = post("/accounts", request.getProperties(), GenericJson.class); + return (String) response.get("localId"); + } + + void updateUser(UserRecord.UpdateRequest request, JsonFactory jsonFactory) + throws FirebaseAuthException { + post("/accounts:update", request.getProperties(jsonFactory), GenericJson.class); } void deleteUser(String uid) throws FirebaseAuthException { final Map payload = ImmutableMap.of("localId", uid); - GenericJson response = post( - "/accounts:delete", payload, GenericJson.class); - if (response == null || !response.containsKey("kind")) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to delete user: " + uid); - } + post("/accounts:delete", payload, GenericJson.class); + } + + DeleteUsersResult deleteUsers(@NonNull List uids) throws FirebaseAuthException { + final Map payload = ImmutableMap.of( + "localIds", uids, + "force", true); + BatchDeleteResponse response = post( + "/accounts:batchDelete", payload, BatchDeleteResponse.class); + return new DeleteUsersResult(uids.size(), response); } DownloadAccountResponse listUsers(int maxResults, String pageToken) throws FirebaseAuthException { @@ -208,23 +196,16 @@ DownloadAccountResponse listUsers(int maxResults, String pageToken) throws Fireb builder.put("nextPageToken", pageToken); } - GenericUrl url = new GenericUrl(baseUrl + "/accounts:batchGet"); - url.putAll(builder.build()); - DownloadAccountResponse response = sendRequest( - "GET", url, null, DownloadAccountResponse.class); - if (response == null) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to retrieve users."); - } - return response; + String url = userMgtBaseUrl + "/accounts:batchGet"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, DownloadAccountResponse.class); } UserImportResult importUsers(UserImportRequest request) throws FirebaseAuthException { checkNotNull(request); UploadAccountResponse response = post( "/accounts:batchCreate", request, UploadAccountResponse.class); - if (response == null) { - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to import users."); - } return new UserImportResult(request.getUsersCount(), response); } @@ -233,13 +214,7 @@ String createSessionCookie(String idToken, final Map payload = ImmutableMap.of( "idToken", idToken, "validDuration", options.getExpiresInSeconds()); GenericJson response = post(":createSessionCookie", payload, GenericJson.class); - if (response != null) { - String cookie = (String) response.get("sessionCookie"); - if (!Strings.isNullOrEmpty(cookie)) { - return cookie; - } - } - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create session cookie"); + return (String) response.get("sessionCookie"); } String getEmailActionLink(EmailLinkType type, String email, @@ -251,71 +226,129 @@ String getEmailActionLink(EmailLinkType type, String email, if (settings != null) { payload.putAll(settings.getProperties()); } + GenericJson response = post("/accounts:sendOobCode", payload.build(), GenericJson.class); - if (response != null) { - String link = (String) response.get("oobLink"); - if (!Strings.isNullOrEmpty(link)) { - return link; - } + return (String) response.get("oobLink"); + } + + private UserRecord lookupUserAccount( + Map payload, String identifier) throws FirebaseAuthException { + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest( + userMgtBaseUrl + "/accounts:lookup", payload); + IncomingHttpResponse response = httpClient.sendRequest(requestInfo); + GetAccountInfoResponse parsed = httpClient.parse(response, GetAccountInfoResponse.class); + if (parsed.getUsers() == null || parsed.getUsers().isEmpty()) { + throw new FirebaseAuthException(ErrorCode.NOT_FOUND, + "No user record found for the provided " + identifier, + null, + response, + AuthErrorCode.USER_NOT_FOUND); } - throw new FirebaseAuthException(INTERNAL_ERROR, "Failed to create email action link"); + + return new UserRecord(parsed.getUsers().get(0), jsonFactory); } - private T post(String path, Object content, Class clazz) throws FirebaseAuthException { - checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty"); - checkNotNull(content, "content must not be null for POST requests"); - GenericUrl url = new GenericUrl(baseUrl + path); - return sendRequest("POST", url, content, clazz); - } - - private T sendRequest( - String method, GenericUrl url, - @Nullable Object content, Class clazz) throws FirebaseAuthException { - - checkArgument(!Strings.isNullOrEmpty(method), "method must not be null or empty"); - checkNotNull(url, "url must not be null"); - checkNotNull(clazz, "response class must not be null"); - HttpResponse response = null; - try { - HttpContent httpContent = content != null ? new JsonHttpContent(jsonFactory, content) : null; - HttpRequest request = requestFactory.buildRequest(method, url, httpContent); - request.setParser(new JsonObjectParser(jsonFactory)); - request.getHeaders().set(CLIENT_VERSION_HEADER, clientVersion); - request.setResponseInterceptor(interceptor); - response = request.execute(); - return response.parseAs(clazz); - } catch (HttpResponseException e) { - // Server responded with an HTTP error - handleHttpError(e); - return null; - } catch (IOException e) { - // All other IO errors (Connection refused, reset, parse error etc.) - throw new FirebaseAuthException( - INTERNAL_ERROR, "Error while calling user management backend service", e); - } finally { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // Ignored - } - } + OidcProviderConfig createOidcProviderConfig( + OidcProviderConfig.CreateRequest request) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + "/oauthIdpConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()) + .addParameter("oauthIdpConfigId", request.getProviderId()); + return httpClient.sendRequest(requestInfo, OidcProviderConfig.class); + } + + SamlProviderConfig createSamlProviderConfig( + SamlProviderConfig.CreateRequest request) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + "/inboundSamlConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()) + .addParameter("inboundSamlConfigId", request.getProviderId()); + return httpClient.sendRequest(requestInfo, SamlProviderConfig.class); + } + + OidcProviderConfig updateOidcProviderConfig(OidcProviderConfig.UpdateRequest request) + throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(request.getProviderId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, OidcProviderConfig.class); + } + + SamlProviderConfig updateSamlProviderConfig(SamlProviderConfig.UpdateRequest request) + throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(request.getProviderId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, SamlProviderConfig.class); + } + + OidcProviderConfig getOidcProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(providerId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), OidcProviderConfig.class); + } + + SamlProviderConfig getSamlProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(providerId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), SamlProviderConfig.class); + } + + ListOidcProviderConfigsResponse listOidcProviderConfigs(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListProviderConfigsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("nextPageToken", pageToken); } + + String url = idpConfigMgtBaseUrl + "/oauthIdpConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListOidcProviderConfigsResponse.class); } - private void handleHttpError(HttpResponseException e) throws FirebaseAuthException { - try { - HttpErrorResponse response = jsonFactory.fromString(e.getContent(), HttpErrorResponse.class); - String code = ERROR_CODES.get(response.getErrorCode()); - if (code != null) { - throw new FirebaseAuthException(code, "User management service responded with an error", e); - } - } catch (IOException ignored) { - // Ignored + ListSamlProviderConfigsResponse listSamlProviderConfigs(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListProviderConfigsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("nextPageToken", pageToken); } - String msg = String.format( - "Unexpected HTTP response with status: %d; body: %s", e.getStatusCode(), e.getContent()); - throw new FirebaseAuthException(INTERNAL_ERROR, msg, e); + + String url = idpConfigMgtBaseUrl + "/inboundSamlConfigs"; + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(url) + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListSamlProviderConfigsResponse.class); + } + + void deleteOidcProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getOidcUrlSuffix(providerId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url)); + } + + void deleteSamlProviderConfig(String providerId) throws FirebaseAuthException { + String url = idpConfigMgtBaseUrl + getSamlUrlSuffix(providerId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url)); + } + + private static String getOidcUrlSuffix(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + return "/oauthIdpConfigs/" + providerId; + } + + private static String getSamlUrlSuffix(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + return "/inboundSamlConfigs/" + providerId; + } + + private T post(String path, Object content, Class clazz) throws FirebaseAuthException { + checkArgument(!Strings.isNullOrEmpty(path), "path must not be null or empty"); + checkNotNull(content, "content must not be null for POST requests"); + String url = userMgtBaseUrl + path; + return httpClient.sendRequest(HttpRequestInfo.buildJsonPostRequest(url, content), clazz); } static class UserImportRequest extends GenericJson { @@ -357,4 +390,51 @@ enum EmailLinkType { EMAIL_SIGNIN, PASSWORD_RESET, } + + static FirebaseUserManager createUserManager(FirebaseApp app, String tenantId) { + return FirebaseUserManager.builder() + .setProjectId(ImplFirebaseTrampolines.getProjectId(app)) + .setTenantId(tenantId) + .setHttpRequestFactory(ApiClientUtils.newAuthorizedRequestFactory(app)) + .setJsonFactory(app.getOptions().getJsonFactory()) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + static class Builder { + + private String projectId; + private String tenantId; + private HttpRequestFactory requestFactory; + private JsonFactory jsonFactory; + + private Builder() { } + + public Builder setProjectId(String projectId) { + this.projectId = projectId; + return this; + } + + Builder setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + Builder setHttpRequestFactory(HttpRequestFactory requestFactory) { + this.requestFactory = requestFactory; + return this; + } + + public Builder setJsonFactory(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + return this; + } + + FirebaseUserManager build() { + return new FirebaseUserManager(this); + } + } } diff --git a/src/main/java/com/google/firebase/auth/GetUsersResult.java b/src/main/java/com/google/firebase/auth/GetUsersResult.java new file mode 100644 index 000000000..3ceec01cb --- /dev/null +++ b/src/main/java/com/google/firebase/auth/GetUsersResult.java @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.firebase.internal.NonNull; +import java.util.Set; + +/** + * Represents the result of the {@link FirebaseAuth#getUsersAsync(Collection)} API. + */ +public final class GetUsersResult { + private final Set users; + private final Set notFound; + + GetUsersResult(@NonNull Set users, @NonNull Set notFound) { + this.users = checkNotNull(users); + this.notFound = checkNotNull(notFound); + } + + /** + * Set of user records corresponding to the set of users that were requested. Only users + * that were found are listed here. The result set is unordered. + */ + @NonNull + public Set getUsers() { + return this.users; + } + + /** + * Set of identifiers that were requested, but not found. + */ + @NonNull + public Set getNotFound() { + return this.notFound; + } +} diff --git a/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java b/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java new file mode 100644 index 000000000..0e35337a9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ListProviderConfigsPage.java @@ -0,0 +1,268 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.gax.paging.Page; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.internal.ListOidcProviderConfigsResponse; +import com.google.firebase.auth.internal.ListProviderConfigsResponse; +import com.google.firebase.auth.internal.ListSamlProviderConfigsResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Represents a page of {@link ProviderConfig} instances. + * + *

Provides methods for iterating over the provider configs in the current page, and calling up + * subsequent pages of provider configs. + * + *

Instances of this class are thread-safe and immutable. + */ +public class ListProviderConfigsPage implements Page { + + static final String END_OF_LIST = ""; + + private final ListProviderConfigsResponse currentBatch; + private final ProviderConfigSource source; + private final int maxResults; + + private ListProviderConfigsPage( + @NonNull ListProviderConfigsResponse currentBatch, + @NonNull ProviderConfigSource source, + int maxResults) { + this.currentBatch = checkNotNull(currentBatch); + this.source = checkNotNull(source); + this.maxResults = maxResults; + } + + /** + * Checks if there is another page of provider configs available to retrieve. + * + * @return true if another page is available, or false otherwise. + */ + @Override + public boolean hasNextPage() { + return !END_OF_LIST.equals(currentBatch.getPageToken()); + } + + /** + * Returns the string token that identifies the next page. + * + *

Never returns null. Returns empty string if there are no more pages available to be + * retrieved. + * + * @return A non-null string token (possibly empty, representing no more pages) + */ + @NonNull + @Override + public String getNextPageToken() { + return currentBatch.getPageToken(); + } + + /** + * Returns the next page of provider configs. + * + * @return A new {@link ListProviderConfigsPage} instance, or null if there are no more pages. + */ + @Nullable + @Override + public ListProviderConfigsPage getNextPage() { + if (hasNextPage()) { + Factory factory = new Factory<>(source, maxResults, currentBatch.getPageToken()); + try { + return factory.create(); + } catch (FirebaseAuthException e) { + throw new RuntimeException(e); + } + } + return null; + } + + /** + * Returns an {@code Iterable} that facilitates transparently iterating over all the provider + * configs in the current Firebase project, starting from this page. + * + *

The {@code Iterator} instances produced by the returned {@code Iterable} never buffers more + * than one page of provider configs at a time. It is safe to abandon the iterators (i.e. break + * the loops) at any time. + * + * @return a new {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable iterateAll() { + return new ProviderConfigIterable<>(this); + } + + /** + * Returns an {@code Iterable} over the provider configs in this page. + * + * @return a {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable getValues() { + return currentBatch.getProviderConfigs(); + } + + private static class ProviderConfigIterable implements Iterable { + + private final ListProviderConfigsPage startingPage; + + ProviderConfigIterable(@NonNull ListProviderConfigsPage startingPage) { + this.startingPage = checkNotNull(startingPage, "starting page must not be null"); + } + + @Override + @NonNull + public Iterator iterator() { + return new ProviderConfigIterator<>(startingPage); + } + + /** + * An {@link Iterator} that cycles through provider configs, one at a time. + * + *

It buffers the last retrieved batch of provider configs in memory. The {@code maxResults} + * parameter is an upper bound on the batch size. + */ + private static class ProviderConfigIterator implements Iterator { + + private ListProviderConfigsPage currentPage; + private List batch; + private int index = 0; + + private ProviderConfigIterator(ListProviderConfigsPage startingPage) { + setCurrentPage(startingPage); + } + + @Override + public boolean hasNext() { + if (index == batch.size()) { + if (currentPage.hasNextPage()) { + setCurrentPage(currentPage.getNextPage()); + } else { + return false; + } + } + + return index < batch.size(); + } + + @Override + public T next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return batch.get(index++); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove operation not supported"); + } + + private void setCurrentPage(ListProviderConfigsPage page) { + this.currentPage = checkNotNull(page); + this.batch = ImmutableList.copyOf(page.getValues()); + this.index = 0; + } + } + } + + /** + * Represents a source of provider config data that can be queried to load a batch of provider + * configs. + */ + interface ProviderConfigSource { + @NonNull + ListProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException; + } + + static class DefaultOidcProviderConfigSource implements ProviderConfigSource { + + private final FirebaseUserManager userManager; + + DefaultOidcProviderConfigSource(FirebaseUserManager userManager) { + this.userManager = checkNotNull(userManager, "User manager must not be null."); + } + + @Override + public ListOidcProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return userManager.listOidcProviderConfigs(maxResults, pageToken); + } + } + + static class DefaultSamlProviderConfigSource implements ProviderConfigSource { + + private final FirebaseUserManager userManager; + + DefaultSamlProviderConfigSource(FirebaseUserManager userManager) { + this.userManager = checkNotNull(userManager, "User manager must not be null."); + } + + @Override + public ListSamlProviderConfigsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return userManager.listSamlProviderConfigs(maxResults, pageToken); + } + } + + /** + * A simple factory class for {@link ListProviderConfigsPage} instances. + * + *

Performs argument validation before attempting to load any provider config data (which is + * expensive, and hence may be performed asynchronously on a separate thread). + */ + static class Factory { + + private final ProviderConfigSource source; + private final int maxResults; + private final String pageToken; + + Factory(@NonNull ProviderConfigSource source) { + this(source, FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS, null); + } + + Factory( + @NonNull ProviderConfigSource source, + int maxResults, + @Nullable String pageToken) { + checkArgument( + maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS, + "maxResults must be a positive integer that does not exceed %s", + FirebaseUserManager.MAX_LIST_PROVIDER_CONFIGS_RESULTS); + checkArgument(!END_OF_LIST.equals(pageToken), "invalid end of list page token"); + this.source = checkNotNull(source, "source must not be null"); + this.maxResults = maxResults; + this.pageToken = pageToken; + } + + ListProviderConfigsPage create() throws FirebaseAuthException { + ListProviderConfigsResponse batch = source.fetch(maxResults, pageToken); + return new ListProviderConfigsPage<>(batch, source, maxResults); + } + } +} + diff --git a/src/main/java/com/google/firebase/auth/ListUsersPage.java b/src/main/java/com/google/firebase/auth/ListUsersPage.java index f406366ba..ba727af5a 100644 --- a/src/main/java/com/google/firebase/auth/ListUsersPage.java +++ b/src/main/java/com/google/firebase/auth/ListUsersPage.java @@ -80,7 +80,7 @@ public String getNextPageToken() { @Override public ListUsersPage getNextPage() { if (hasNextPage()) { - PageFactory factory = new PageFactory(source, maxResults, currentBatch.getNextPageToken()); + Factory factory = new Factory(source, maxResults, currentBatch.getNextPageToken()); try { return factory.create(); } catch (FirebaseAuthException e) { @@ -237,17 +237,17 @@ String getNextPageToken() { * before attempting to load any user data (which is expensive, and hence may be performed * asynchronously on a separate thread). */ - static class PageFactory { + static class Factory { private final UserSource source; private final int maxResults; private final String pageToken; - PageFactory(@NonNull UserSource source) { + Factory(@NonNull UserSource source) { this(source, FirebaseUserManager.MAX_LIST_USERS_RESULTS, null); } - PageFactory(@NonNull UserSource source, int maxResults, @Nullable String pageToken) { + Factory(@NonNull UserSource source, int maxResults, @Nullable String pageToken) { checkArgument(maxResults > 0 && maxResults <= FirebaseUserManager.MAX_LIST_USERS_RESULTS, "maxResults must be a positive integer that does not exceed %s", FirebaseUserManager.MAX_LIST_USERS_RESULTS); diff --git a/src/main/java/com/google/firebase/auth/OidcProviderConfig.java b/src/main/java/com/google/firebase/auth/OidcProviderConfig.java new file mode 100644 index 000000000..0a4f7c0b8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/OidcProviderConfig.java @@ -0,0 +1,291 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import java.util.HashMap; +import java.util.Map; + +/** + * Contains metadata associated with an OIDC Auth provider. + * + *

Instances of this class are immutable and thread safe. + */ +public final class OidcProviderConfig extends ProviderConfig { + + @Key("clientId") + private String clientId; + + @Key("clientSecret") + private String clientSecret; + + @Key("issuer") + private String issuer; + + @Key("responseType") + private GenericJson responseType; + + public String getClientId() { + return clientId; + } + + public String getClientSecret() { + return clientSecret; + } + + public String getIssuer() { + return issuer; + } + + public boolean isCodeResponseType() { + return (responseType.containsKey("code") && (boolean) responseType.get("code")); + } + + public boolean isIdTokenResponseType() { + return (responseType.containsKey("idToken") && (boolean) responseType.get("idToken")); + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this + * provider config. + * + * @return A non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getProviderId()); + } + + static void checkOidcProviderId(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + checkArgument(providerId.startsWith("oidc."), + "Invalid OIDC provider ID (must be prefixed with 'oidc.'): " + providerId); + } + + static Map ensureResponseType(Map properties) { + if (properties.get("responseType") == null) { + properties.put("responseType", new HashMap()); + } + return (Map) properties.get("responseType"); + } + + /** + * A specification class for creating a new OIDC Auth provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public static final class CreateRequest extends AbstractCreateRequest { + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new OIDC Auth provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#createOidcProviderConfig(CreateRequest)} to save the config. + */ + public CreateRequest() { } + + /** + * Sets the ID for the new provider. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'oidc.'. + */ + @Override + public CreateRequest setProviderId(String providerId) { + checkOidcProviderId(providerId); + return super.setProviderId(providerId); + } + + /** + * Sets the client ID for the new provider. + * + * @param clientId A non-null, non-empty client ID string. + * @throws IllegalArgumentException If the client ID is null or empty. + */ + public CreateRequest setClientId(String clientId) { + checkArgument(!Strings.isNullOrEmpty(clientId), "Client ID must not be null or empty."); + properties.put("clientId", clientId); + return this; + } + + /** + * Sets the client secret for the new provider. This is required for the code flow. + * + * @param clientSecret A non-null, non-empty client secret string. + * @throws IllegalArgumentException If the client secret is null or empty. + */ + public CreateRequest setClientSecret(String clientSecret) { + checkArgument(!Strings.isNullOrEmpty(clientSecret), + "Client Secret must not be null or empty."); + properties.put("clientSecret", clientSecret); + return this; + } + + /** + * Sets the issuer for the new provider. + * + * @param issuer A non-null, non-empty issuer URL string. + * @throws IllegalArgumentException If the issuer URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setIssuer(String issuer) { + checkArgument(!Strings.isNullOrEmpty(issuer), "Issuer must not be null or empty."); + assertValidUrl(issuer); + properties.put("issuer", issuer); + return this; + } + + /** + * Sets whether to enable the code response flow for the new provider. By default, this is not + * enabled if no response type is specified. + * + *

A client secret must be set for this response type. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the code response type is supported. + */ + public CreateRequest setCodeResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("code", enabled); + return this; + } + + /** + * Sets whether to enable the ID token response flow for the new provider. By default, this is + * enabled if no response type is specified. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the ID token response type is supported. + */ + public CreateRequest setIdTokenResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("idToken", enabled); + return this; + } + + CreateRequest getThis() { + return this; + } + } + + /** + * A specification class for updating an existing OIDC Auth provider. + * + *

An instance of this class can be obtained via a {@link OidcProviderConfig} object, or from + * a provider ID string. Specify the changes to be made to the provider config by calling the + * various setter methods available in this class. + */ + public static final class UpdateRequest extends AbstractUpdateRequest { + + /** + * Creates a new {@link UpdateRequest}, which can be used to updates an existing OIDC Auth + * provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#updateOidcProviderConfig(UpdateRequest)} to save the updated + * config. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * "oidc.". + */ + public UpdateRequest(String providerId) { + super(providerId); + checkOidcProviderId(providerId); + } + + /** + * Sets the client ID for the exsting provider. + * + * @param clientId A non-null, non-empty client ID string. + * @throws IllegalArgumentException If the client ID is null or empty. + */ + public UpdateRequest setClientId(String clientId) { + checkArgument(!Strings.isNullOrEmpty(clientId), "Client ID must not be null or empty."); + properties.put("clientId", clientId); + return this; + } + + /** + * Sets the client secret for the new provider. This is required for the code flow. + * + * @param clientSecret A non-null, non-empty client secret string. + * @throws IllegalArgumentException If the client secret is null or empty. + */ + public UpdateRequest setClientSecret(String clientSecret) { + checkArgument(!Strings.isNullOrEmpty(clientSecret), + "Client Secret must not be null or empty."); + properties.put("clientSecret", clientSecret); + return this; + } + + /** + * Sets the issuer for the existing provider. + * + * @param issuer A non-null, non-empty issuer URL string. + * @throws IllegalArgumentException If the issuer URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setIssuer(String issuer) { + checkArgument(!Strings.isNullOrEmpty(issuer), "Issuer must not be null or empty."); + assertValidUrl(issuer); + properties.put("issuer", issuer); + return this; + } + + /** + * Sets whether to enable the code response flow for the new provider. By default, this is not + * enabled if no response type is specified. + * + *

A client secret must be set for this response type. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the code response type is supported. + */ + public UpdateRequest setCodeResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("code", enabled); + return this; + } + + /** + * Sets whether to enable the ID token response flow for the new provider. By default, this is + * enabled if no response type is specified. + * + *

Having both the code and ID token response flows is currently not supported. + * + * @param enabled A boolean signifying whether the ID token response type is supported. + */ + public UpdateRequest setIdTokenResponseType(boolean enabled) { + Map map = ensureResponseType(properties); + map.put("idToken", enabled); + return this; + } + + UpdateRequest getThis() { + return this; + } + } +} diff --git a/src/main/java/com/google/firebase/auth/PhoneIdentifier.java b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java new file mode 100644 index 000000000..bdc84fe92 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/PhoneIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by phone number. + * + * @see {FirebaseAuth#getUsers} + */ +public final class PhoneIdentifier extends UserIdentifier { + private final String phoneNumber; + + public PhoneIdentifier(@NonNull String phoneNumber) { + UserRecord.checkPhoneNumber(phoneNumber); + this.phoneNumber = phoneNumber; + } + + @Override + public String toString() { + return "PhoneIdentifier(" + phoneNumber + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addPhoneNumber(phoneNumber); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return phoneNumber.equals(userRecord.getPhoneNumber()); + } +} diff --git a/src/main/java/com/google/firebase/auth/ProviderConfig.java b/src/main/java/com/google/firebase/auth/ProviderConfig.java new file mode 100644 index 000000000..921a07b5b --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ProviderConfig.java @@ -0,0 +1,157 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.HashMap; +import java.util.Map; + +/** + * The base class for Auth providers. + */ +public abstract class ProviderConfig { + + @Key("name") + private String resourceName; + + @Key("displayName") + private String displayName; + + @Key("enabled") + private boolean enabled; + + public String getProviderId() { + return resourceName.substring(resourceName.lastIndexOf("/") + 1); + } + + public String getDisplayName() { + return displayName; + } + + public boolean isEnabled() { + return enabled; + } + + static void assertValidUrl(String url) throws IllegalArgumentException { + try { + new URL(url); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(url + " is a malformed URL.", e); + } + } + + /** + * A base specification class for creating a new provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public abstract static class AbstractCreateRequest> { + + final Map properties = new HashMap<>(); + String providerId; + + T setProviderId(String providerId) { + this.providerId = providerId; + return getThis(); + } + + String getProviderId() { + return providerId; + } + + /** + * Sets the display name for the new provider. + * + * @param displayName A non-null, non-empty display name string. + * @throws IllegalArgumentException If the display name is null or empty. + */ + public T setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "Display name must not be null or empty."); + properties.put("displayName", displayName); + return getThis(); + } + + /** + * Sets whether to allow the user to sign in with the provider. + * + * @param enabled A boolean indicating whether the user can sign in with the provider. + */ + public T setEnabled(boolean enabled) { + properties.put("enabled", enabled); + return getThis(); + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + + abstract T getThis(); + } + + /** + * A base class for updating the attributes of an existing provider. + */ + public abstract static class AbstractUpdateRequest> { + + final String providerId; + final Map properties = new HashMap<>(); + + AbstractUpdateRequest(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + this.providerId = providerId; + } + + String getProviderId() { + return providerId; + } + + /** + * Sets the display name for the existing provider. + * + * @param displayName A non-null, non-empty display name string. + * @throws IllegalArgumentException If the display name is null or empty. + */ + public T setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "Display name must not be null or empty."); + properties.put("displayName", displayName); + return getThis(); + } + + /** + * Sets whether to allow the user to sign in with the provider. + * + * @param enabled A boolean indicating whether the user can sign in with the provider. + */ + public T setEnabled(boolean enabled) { + properties.put("enabled", enabled); + return getThis(); + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + + abstract T getThis(); + } +} diff --git a/src/main/java/com/google/firebase/auth/ProviderIdentifier.java b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java new file mode 100644 index 000000000..25e00026d --- /dev/null +++ b/src/main/java/com/google/firebase/auth/ProviderIdentifier.java @@ -0,0 +1,56 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by provider. + * + * @see {FirebaseAuth#getUsers} + */ +public final class ProviderIdentifier extends UserIdentifier { + private final String providerId; + private final String providerUid; + + public ProviderIdentifier(@NonNull String providerId, @NonNull String providerUid) { + UserRecord.checkProvider(providerId, providerUid); + this.providerId = providerId; + this.providerUid = providerUid; + } + + @Override + public String toString() { + return "ProviderIdentifier(" + providerId + ", " + providerUid + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addFederatedUserId(providerId, providerUid); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + for (UserInfo userInfo : userRecord.getProviderData()) { + if (providerId.equals(userInfo.getProviderId()) && providerUid.equals(userInfo.getUid())) { + return true; + } + } + return false; + } +} diff --git a/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java index e53ad25c4..9a99fd5df 100644 --- a/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java +++ b/src/main/java/com/google/firebase/auth/RevocationCheckDecorator.java @@ -20,30 +20,27 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; /** * A decorator for adding token revocation checks to an existing {@link FirebaseTokenVerifier}. */ class RevocationCheckDecorator implements FirebaseTokenVerifier { - static final String ID_TOKEN_REVOKED_ERROR = "id-token-revoked"; - static final String SESSION_COOKIE_REVOKED_ERROR = "session-cookie-revoked"; - private final FirebaseTokenVerifier tokenVerifier; private final FirebaseUserManager userManager; - private final String errorCode; + private final AuthErrorCode errorCode; private final String shortName; private RevocationCheckDecorator( FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager, - String errorCode, + AuthErrorCode errorCode, String shortName) { this.tokenVerifier = checkNotNull(tokenVerifier); this.userManager = checkNotNull(userManager); - checkArgument(!Strings.isNullOrEmpty(errorCode)); + this.errorCode = checkNotNull(errorCode); checkArgument(!Strings.isNullOrEmpty(shortName)); - this.errorCode = errorCode; this.shortName = shortName; } @@ -54,27 +51,39 @@ private RevocationCheckDecorator( @Override public FirebaseToken verifyToken(String token) throws FirebaseAuthException { FirebaseToken firebaseToken = tokenVerifier.verifyToken(token); - if (isRevoked(firebaseToken)) { - throw new FirebaseAuthException(errorCode, "Firebase " + shortName + " revoked"); - } + validateDisabledOrRevoked(firebaseToken); return firebaseToken; } - private boolean isRevoked(FirebaseToken firebaseToken) throws FirebaseAuthException { + private void validateDisabledOrRevoked(FirebaseToken firebaseToken) throws FirebaseAuthException { UserRecord user = userManager.getUserById(firebaseToken.getUid()); + if (user.isDisabled()) { + throw new FirebaseAuthException(ErrorCode.INVALID_ARGUMENT, + "The user record is disabled.", + /* cause= */ null, + /* response= */ null, + AuthErrorCode.USER_DISABLED); + } long issuedAtInSeconds = (long) firebaseToken.getClaims().get("iat"); - return user.getTokensValidAfterTimestamp() > issuedAtInSeconds * 1000; + if (user.getTokensValidAfterTimestamp() > issuedAtInSeconds * 1000) { + throw new FirebaseAuthException( + ErrorCode.INVALID_ARGUMENT, + "Firebase " + shortName + " is revoked.", + null, + null, + errorCode); + } } static RevocationCheckDecorator decorateIdTokenVerifier( FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) { return new RevocationCheckDecorator( - tokenVerifier, userManager, ID_TOKEN_REVOKED_ERROR, "id token"); + tokenVerifier, userManager, AuthErrorCode.REVOKED_ID_TOKEN, "id token"); } static RevocationCheckDecorator decorateSessionCookieVerifier( FirebaseTokenVerifier tokenVerifier, FirebaseUserManager userManager) { return new RevocationCheckDecorator( - tokenVerifier, userManager, SESSION_COOKIE_REVOKED_ERROR, "session cookie"); + tokenVerifier, userManager, AuthErrorCode.REVOKED_SESSION_COOKIE, "session cookie"); } } diff --git a/src/main/java/com/google/firebase/auth/SamlProviderConfig.java b/src/main/java/com/google/firebase/auth/SamlProviderConfig.java new file mode 100644 index 000000000..76f8594a8 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/SamlProviderConfig.java @@ -0,0 +1,343 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Contains metadata associated with a SAML Auth provider. + * + *

Instances of this class are immutable and thread safe. + */ +public final class SamlProviderConfig extends ProviderConfig { + + @Key("idpConfig") + private GenericJson idpConfig; + + @Key("spConfig") + private GenericJson spConfig; + + public String getIdpEntityId() { + return (String) idpConfig.get("idpEntityId"); + } + + public String getSsoUrl() { + return (String) idpConfig.get("ssoUrl"); + } + + public List getX509Certificates() { + List> idpCertificates = + (List>) idpConfig.get("idpCertificates"); + checkNotNull(idpCertificates); + ImmutableList.Builder certificates = ImmutableList.builder(); + for (Map idpCertificate : idpCertificates) { + certificates.add(idpCertificate.get("x509Certificate")); + } + return certificates.build(); + } + + public String getRpEntityId() { + return (String) spConfig.get("spEntityId"); + } + + public String getCallbackUrl() { + return (String) spConfig.get("callbackUri"); + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this + * provider config. + * + * @return a non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getProviderId()); + } + + static void checkSamlProviderId(String providerId) { + checkArgument(!Strings.isNullOrEmpty(providerId), "Provider ID must not be null or empty."); + checkArgument(providerId.startsWith("saml."), + "Invalid SAML provider ID (must be prefixed with 'saml.'): " + providerId); + } + + private static List ensureNestedList(Map outerMap, String id) { + List list = (List) outerMap.get(id); + if (list == null) { + list = new ArrayList(); + outerMap.put(id, list); + } + return list; + } + + private static Map ensureNestedMap(Map outerMap, String id) { + Map map = (Map) outerMap.get(id); + if (map == null) { + map = new HashMap(); + outerMap.put(id, map); + } + return map; + } + + /** + * A specification class for creating a new SAML Auth provider. + * + *

Set the initial attributes of the new provider by calling various setter methods available + * in this class. + */ + public static final class CreateRequest extends AbstractCreateRequest { + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new SAML Auth provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#createSamlProviderConfig(CreateRequest)} to register the provider + * information persistently. + */ + public CreateRequest() { } + + /** + * Sets the ID for the new provider. + * + * @param providerId A non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'saml.'. + */ + @Override + public CreateRequest setProviderId(String providerId) { + checkSamlProviderId(providerId); + return super.setProviderId(providerId); + } + + /** + * Sets the IDP entity ID for the new provider. + * + * @param idpEntityId A non-null, non-empty IDP entity ID string. + * @throws IllegalArgumentException If the IDP entity ID is null or empty. + */ + public CreateRequest setIdpEntityId(String idpEntityId) { + checkArgument(!Strings.isNullOrEmpty(idpEntityId), + "IDP entity ID must not be null or empty."); + ensureNestedMap(properties, "idpConfig").put("idpEntityId", idpEntityId); + return this; + } + + /** + * Sets the SSO URL for the new provider. + * + * @param ssoUrl A non-null, non-empty SSO URL string. + * @throws IllegalArgumentException If the SSO URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setSsoUrl(String ssoUrl) { + checkArgument(!Strings.isNullOrEmpty(ssoUrl), "SSO URL must not be null or empty."); + assertValidUrl(ssoUrl); + ensureNestedMap(properties, "idpConfig").put("ssoUrl", ssoUrl); + return this; + } + + /** + * Adds a x509 certificate to the new provider. + * + * @param x509Certificate A non-null, non-empty x509 certificate string. + * @throws IllegalArgumentException If the x509 certificate is null or empty. + */ + public CreateRequest addX509Certificate(String x509Certificate) { + checkArgument(!Strings.isNullOrEmpty(x509Certificate), + "The x509 certificate must not be null or empty."); + Map idpConfigProperties = ensureNestedMap(properties, "idpConfig"); + List x509Certificates = ensureNestedList(idpConfigProperties, "idpCertificates"); + x509Certificates.add(ImmutableMap.of("x509Certificate", x509Certificate)); + return this; + } + + /** + * Adds a collection of x509 certificates to the new provider. + * + * @param x509Certificates A non-null, non-empty collection of x509 certificate strings. + * @throws IllegalArgumentException If the collection is null or empty, or if any x509 + * certificates are null or empty. + */ + public CreateRequest addAllX509Certificates(Collection x509Certificates) { + checkArgument(x509Certificates != null, + "The collection of x509 certificates must not be null."); + checkArgument(!x509Certificates.isEmpty(), + "The collection of x509 certificates must not be empty."); + for (String certificate : x509Certificates) { + addX509Certificate(certificate); + } + return this; + } + + /** + * Sets the RP entity ID for the new provider. + * + * @param rpEntityId A non-null, non-empty RP entity ID string. + * @throws IllegalArgumentException If the RP entity ID is null or empty. + */ + public CreateRequest setRpEntityId(String rpEntityId) { + checkArgument(!Strings.isNullOrEmpty(rpEntityId), "RP entity ID must not be null or empty."); + ensureNestedMap(properties, "spConfig").put("spEntityId", rpEntityId); + return this; + } + + /** + * Sets the callback URL for the new provider. + * + * @param callbackUrl A non-null, non-empty callback URL string. + * @throws IllegalArgumentException If the callback URL is null or empty, or if the format is + * invalid. + */ + public CreateRequest setCallbackUrl(String callbackUrl) { + checkArgument(!Strings.isNullOrEmpty(callbackUrl), "Callback URL must not be null or empty."); + assertValidUrl(callbackUrl); + ensureNestedMap(properties, "spConfig").put("callbackUri", callbackUrl); + return this; + } + + CreateRequest getThis() { + return this; + } + } + + /** + * A specification class for updating an existing SAML Auth provider. + * + *

An instance of this class can be obtained via a {@link SamlProviderConfig} object, or from + * a provider ID string. Specify the changes to be made to the provider config by calling the + * various setter methods available in this class. + */ + public static final class UpdateRequest extends AbstractUpdateRequest { + /** + * Creates a new {@link UpdateRequest}, which can be used to updates an existing SAML Auth + * provider. + * + *

The returned object should be passed to + * {@link AbstractFirebaseAuth#updateSamlProviderConfig(UpdateRequest)} to update the provider + * information persistently. + * + * @param providerId a non-null, non-empty provider ID string. + * @throws IllegalArgumentException If the provider ID is null or empty, or is not prefixed with + * 'saml.'. + */ + public UpdateRequest(String providerId) { + super(providerId); + checkSamlProviderId(providerId); + } + + /** + * Sets the IDP entity ID for the existing provider. + * + * @param idpEntityId A non-null, non-empty IDP entity ID string. + * @throws IllegalArgumentException If the IDP entity ID is null or empty. + */ + public UpdateRequest setIdpEntityId(String idpEntityId) { + checkArgument(!Strings.isNullOrEmpty(idpEntityId), + "IDP entity ID must not be null or empty."); + ensureNestedMap(properties, "idpConfig").put("idpEntityId", idpEntityId); + return this; + } + + /** + * Sets the SSO URL for the existing provider. + * + * @param ssoUrl A non-null, non-empty SSO URL string. + * @throws IllegalArgumentException If the SSO URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setSsoUrl(String ssoUrl) { + checkArgument(!Strings.isNullOrEmpty(ssoUrl), "SSO URL must not be null or empty."); + assertValidUrl(ssoUrl); + ensureNestedMap(properties, "idpConfig").put("ssoUrl", ssoUrl); + return this; + } + + /** + * Adds a x509 certificate to the existing provider. + * + * @param x509Certificate A non-null, non-empty x509 certificate string. + * @throws IllegalArgumentException If the x509 certificate is null or empty. + */ + public UpdateRequest addX509Certificate(String x509Certificate) { + checkArgument(!Strings.isNullOrEmpty(x509Certificate), + "The x509 certificate must not be null or empty."); + Map idpConfigProperties = ensureNestedMap(properties, "idpConfig"); + List x509Certificates = ensureNestedList(idpConfigProperties, "idpCertificates"); + x509Certificates.add(ImmutableMap.of("x509Certificate", x509Certificate)); + return this; + } + + /** + * Adds a collection of x509 certificates to the existing provider. + * + * @param x509Certificates A non-null, non-empty collection of x509 certificate strings. + * @throws IllegalArgumentException If the collection is null or empty, or if any x509 + * certificates are null or empty. + */ + public UpdateRequest addAllX509Certificates(Collection x509Certificates) { + checkArgument(x509Certificates != null, + "The collection of x509 certificates must not be null."); + checkArgument(!x509Certificates.isEmpty(), + "The collection of x509 certificates must not be empty."); + for (String certificate : x509Certificates) { + addX509Certificate(certificate); + } + return this; + } + + /** + * Sets the RP entity ID for the existing provider. + * + * @param rpEntityId A non-null, non-empty RP entity ID string. + * @throws IllegalArgumentException If the RP entity ID is null or empty. + */ + public UpdateRequest setRpEntityId(String rpEntityId) { + checkArgument(!Strings.isNullOrEmpty(rpEntityId), "RP entity ID must not be null or empty."); + ensureNestedMap(properties, "spConfig").put("spEntityId", rpEntityId); + return this; + } + + /** + * Sets the callback URL for the exising provider. + * + * @param callbackUrl A non-null, non-empty callback URL string. + * @throws IllegalArgumentException If the callback URL is null or empty, or if the format is + * invalid. + */ + public UpdateRequest setCallbackUrl(String callbackUrl) { + checkArgument(!Strings.isNullOrEmpty(callbackUrl), "Callback URL must not be null or empty."); + assertValidUrl(callbackUrl); + ensureNestedMap(properties, "spConfig").put("callbackUri", callbackUrl); + return this; + } + + UpdateRequest getThis() { + return this; + } + } +} diff --git a/src/main/java/com/google/firebase/auth/UidIdentifier.java b/src/main/java/com/google/firebase/auth/UidIdentifier.java new file mode 100644 index 000000000..a4f7069d9 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UidIdentifier.java @@ -0,0 +1,49 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Used for looking up an account by uid. + * + * @see {FirebaseAuth#getUsers} + */ +public final class UidIdentifier extends UserIdentifier { + private final String uid; + + public UidIdentifier(@NonNull String uid) { + UserRecord.checkUid(uid); + this.uid = uid; + } + + @Override + public String toString() { + return "UidIdentifier(" + uid + ")"; + } + + @Override + void populate(@NonNull GetAccountInfoRequest payload) { + payload.addUid(uid); + } + + @Override + boolean matches(@NonNull UserRecord userRecord) { + return uid.equals(userRecord.getUid()); + } +} diff --git a/src/main/java/com/google/firebase/auth/UserIdentifier.java b/src/main/java/com/google/firebase/auth/UserIdentifier.java new file mode 100644 index 000000000..7ec9699e6 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/UserIdentifier.java @@ -0,0 +1,31 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth; + +import com.google.firebase.auth.internal.GetAccountInfoRequest; +import com.google.firebase.internal.NonNull; + +/** + * Identifies a user to be looked up. + */ +public abstract class UserIdentifier { + public abstract String toString(); + + abstract void populate(@NonNull GetAccountInfoRequest payload); + + abstract boolean matches(@NonNull UserRecord userRecord); +} diff --git a/src/main/java/com/google/firebase/auth/UserMetadata.java b/src/main/java/com/google/firebase/auth/UserMetadata.java index a2872371f..85a24a0fd 100644 --- a/src/main/java/com/google/firebase/auth/UserMetadata.java +++ b/src/main/java/com/google/firebase/auth/UserMetadata.java @@ -23,14 +23,16 @@ public class UserMetadata { private final long creationTimestamp; private final long lastSignInTimestamp; + private final long lastRefreshTimestamp; public UserMetadata(long creationTimestamp) { - this(creationTimestamp, 0L); + this(creationTimestamp, 0L, 0L); } - public UserMetadata(long creationTimestamp, long lastSignInTimestamp) { + public UserMetadata(long creationTimestamp, long lastSignInTimestamp, long lastRefreshTimestamp) { this.creationTimestamp = creationTimestamp; this.lastSignInTimestamp = lastSignInTimestamp; + this.lastRefreshTimestamp = lastRefreshTimestamp; } /** @@ -50,4 +52,13 @@ public long getCreationTimestamp() { public long getLastSignInTimestamp() { return lastSignInTimestamp; } + + /** + * Returns the time at which the user was last active (ID token refreshed). + *  + * @return Milliseconds since epoch timestamp, or 0 if the user was never active. + */ + public long getLastRefreshTimestamp() { + return lastRefreshTimestamp; + } } diff --git a/src/main/java/com/google/firebase/auth/UserRecord.java b/src/main/java/com/google/firebase/auth/UserRecord.java index e00450079..33c5fbe74 100644 --- a/src/main/java/com/google/firebase/auth/UserRecord.java +++ b/src/main/java/com/google/firebase/auth/UserRecord.java @@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.DateTime; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; @@ -49,6 +50,7 @@ public class UserRecord implements UserInfo { private static final int MAX_CLAIMS_PAYLOAD_SIZE = 1000; private final String uid; + private final String tenantId; private final String email; private final String phoneNumber; private final boolean emailVerified; @@ -65,6 +67,7 @@ public class UserRecord implements UserInfo { checkNotNull(jsonFactory, "jsonFactory must not be null"); checkArgument(!Strings.isNullOrEmpty(response.getUid()), "uid must not be null or empty"); this.uid = response.getUid(); + this.tenantId = response.getTenantId(); this.email = response.getEmail(); this.phoneNumber = response.getPhoneNumber(); this.emailVerified = response.isEmailVerified(); @@ -80,7 +83,15 @@ public class UserRecord implements UserInfo { } } this.tokensValidAfterTimestamp = response.getValidSince() * 1000; - this.userMetadata = new UserMetadata(response.getCreatedAt(), response.getLastLoginAt()); + + String lastRefreshAtRfc3339 = response.getLastRefreshAt(); + long lastRefreshAtMillis = 0; + if (!Strings.isNullOrEmpty(lastRefreshAtRfc3339)) { + lastRefreshAtMillis = DateTime.parseRfc3339(lastRefreshAtRfc3339).getValue(); + } + + this.userMetadata = new UserMetadata( + response.getCreatedAt(), response.getLastLoginAt(), lastRefreshAtMillis); this.customClaims = parseCustomClaims(response.getCustomClaims(), jsonFactory); } @@ -107,6 +118,16 @@ public String getUid() { return uid; } + /** + * Returns the tenant ID associated with this user, if one exists. + * + * @return a tenant ID string or null. + */ + @Nullable + public String getTenantId() { + return this.tenantId; + } + /** * Returns the provider ID of this user. * @@ -190,9 +211,9 @@ public UserInfo[] getProviderData() { } /** - * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. + * Returns a timestamp in milliseconds since epoch, truncated down to the closest second. * Tokens minted before this timestamp are considered invalid. - * + * * @return Timestamp in milliseconds since the epoch. Tokens minted before this timestamp are * considered invalid. */ @@ -247,6 +268,11 @@ static void checkPhoneNumber(String phoneNumber) { "phone number must be a valid, E.164 compliant identifier starting with a '+' sign"); } + static void checkProvider(String providerId, String providerUid) { + checkArgument(!Strings.isNullOrEmpty(providerId), "providerId must be a non-empty string"); + checkArgument(!Strings.isNullOrEmpty(providerUid), "providerUid must be a non-empty string"); + } + static void checkUrl(String photoUrl) { checkArgument(!Strings.isNullOrEmpty(photoUrl), "url cannot be null or empty"); try { @@ -357,10 +383,10 @@ public CreateRequest setEmailVerified(boolean emailVerified) { /** * Sets the display name for the new user. * - * @param displayName a non-null, non-empty display name string. + * @param displayName a non-null display name string. */ public CreateRequest setDisplayName(String displayName) { - checkNotNull(displayName, "displayName cannot be null or empty"); + checkNotNull(displayName, "displayName cannot be null"); properties.put("displayName", displayName); return this; } @@ -449,6 +475,29 @@ public UpdateRequest setPhoneNumber(@Nullable String phone) { if (phone != null) { checkPhoneNumber(phone); } + + if (phone == null && properties.containsKey("deleteProvider")) { + Object deleteProvider = properties.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + for (String dp : deleteProviderIterable) { + if ("phone".equals(dp)) { + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a " + + "phone provider, only specify UpdateRequest.setPhoneNumber(null)."); + + } + } + } + } + properties.put("phoneNumber", phone); return this; } @@ -522,6 +571,52 @@ public UpdateRequest setCustomClaims(Map customClaims) { return this; } + /** + * Links this user to the specified provider. + * + *

Linking a provider to an existing user account does not invalidate the + * refresh token of that account. In other words, the existing account + * continues to be able to access resources, despite not having used + * the newly linked provider to sign in. If you wish to force the user to + * authenticate with this new provider, you need to (a) revoke their + * refresh token (see + * https://firebase.google.com/docs/auth/admin/manage-sessions#revoke_refresh_tokens), + * and (b) ensure no other authentication methods are present on this + * account. + * + * @param providerToLink provider info to be linked to this user\'s account. + */ + public UpdateRequest setProviderToLink(@NonNull UserProvider providerToLink) { + properties.put("linkProviderUserInfo", checkNotNull(providerToLink)); + return this; + } + + /** + * Unlinks this user from the specified providers. + * + * @param providerIds list of identifiers for the identity providers. + */ + public UpdateRequest setProvidersToUnlink(Iterable providerIds) { + checkNotNull(providerIds); + for (String id : providerIds) { + checkArgument(!Strings.isNullOrEmpty(id), "providerIds must not be null or empty"); + + if ("phone".equals(id) && properties.containsKey("phoneNumber") + && properties.get("phoneNumber") == null) { + // If we've been told to unlink the phone provider both via setting phoneNumber to null + // *and* by setting providersToUnlink to include 'phone', then we'll reject that. Though + // it might also be reasonable to relax this restriction and just unlink it. + throw new IllegalArgumentException( + "Both UpdateRequest.setPhoneNumber(null) and " + + "UpdateRequest.setProvidersToUnlink(['phone']) were set. To unlink from a phone " + + "provider, only specify UpdateRequest.setPhoneNumber(null)."); + } + } + + properties.put("deleteProvider", providerIds); + return this; + } + UpdateRequest setValidSince(long epochSeconds) { checkValidSince(epochSeconds); properties.put("validSince", epochSeconds); @@ -543,7 +638,20 @@ Map getProperties(JsonFactory jsonFactory) { } if (copy.containsKey("phoneNumber") && copy.get("phoneNumber") == null) { - copy.put("deleteProvider", ImmutableList.of("phone")); + Object deleteProvider = copy.get("deleteProvider"); + if (deleteProvider != null) { + // Due to java's type erasure, we can't fully check the type. :( + @SuppressWarnings("unchecked") + Iterable deleteProviderIterable = (Iterable)deleteProvider; + + copy.put("deleteProvider", new ImmutableList.Builder() + .addAll(deleteProviderIterable) + .add("phone") + .build()); + } else { + copy.put("deleteProvider", ImmutableList.of("phone")); + } + copy.remove("phoneNumber"); } diff --git a/src/main/java/com/google/firebase/auth/hash/Argon2.java b/src/main/java/com/google/firebase/auth/hash/Argon2.java new file mode 100644 index 000000000..8edcf11b7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/hash/Argon2.java @@ -0,0 +1,217 @@ +/* + * Copyright 2022 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. + */ + +package com.google.firebase.auth.hash; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.collect.ImmutableMap; +import com.google.common.io.BaseEncoding; +import com.google.firebase.auth.UserImportHash; +import java.util.Map; + +/** + * Represents the Argon2 password hashing algorithm. Can be used as an instance of {@link + * com.google.firebase.auth.UserImportHash} when importing users. + */ +public final class Argon2 extends UserImportHash { + + private static final int MIN_HASH_LENGTH_BYTES = 4; + private static final int MAX_HASH_LENGTH_BYTES = 1024; + private static final int MIN_PARALLELISM = 1; + private static final int MAX_PARALLELISM = 16; + private static final int MIN_ITERATIONS = 1; + private static final int MAX_ITERATIONS = 16; + private static final int MIN_MEMORY_COST_KIB = 1; + private static final int MAX_MEMORY_COST_KIB = 32768; + + private final int hashLengthBytes; + private final Argon2HashType hashType; + private final int parallelism; + private final int iterations; + private final int memoryCostKib; + private final Argon2Version version; + private final String associatedData; + + private Argon2(Builder builder) { + super("ARGON2"); + checkArgument(intShouldBeBetweenLimitsInclusive(builder.hashLengthBytes, MIN_HASH_LENGTH_BYTES, + MAX_HASH_LENGTH_BYTES), + "hashLengthBytes is required for Argon2 and must be between %s and %s", + MIN_HASH_LENGTH_BYTES, MAX_HASH_LENGTH_BYTES); + checkArgument(builder.hashType != null, + "A hashType is required for Argon2"); + checkArgument( + intShouldBeBetweenLimitsInclusive(builder.parallelism, MIN_PARALLELISM, MAX_PARALLELISM), + "parallelism is required for Argon2 and must be between %s and %s", MIN_PARALLELISM, + MAX_PARALLELISM); + checkArgument( + intShouldBeBetweenLimitsInclusive(builder.iterations, MIN_ITERATIONS, MAX_ITERATIONS), + "iterations is required for Argon2 and must be between %s and %s", MIN_ITERATIONS, + MAX_ITERATIONS); + checkArgument(intShouldBeBetweenLimitsInclusive(builder.memoryCostKib, MIN_MEMORY_COST_KIB, + MAX_MEMORY_COST_KIB), + "memoryCostKib is required for Argon2 and must be less than or equal to %s", + MAX_MEMORY_COST_KIB); + this.hashLengthBytes = builder.hashLengthBytes; + this.hashType = builder.hashType; + this.parallelism = builder.parallelism; + this.iterations = builder.iterations; + this.memoryCostKib = builder.memoryCostKib; + if (builder.version != null) { + this.version = builder.version; + } else { + this.version = null; + } + if (builder.associatedData != null) { + this.associatedData = BaseEncoding.base64Url().encode(builder.associatedData); + } else { + this.associatedData = null; + } + } + + private static boolean intShouldBeBetweenLimitsInclusive(int property, int fromInclusive, + int toInclusive) { + return property >= fromInclusive && property <= toInclusive; + } + + @Override + protected Map getOptions() { + ImmutableMap.Builder argon2Parameters = ImmutableMap.builder() + .put("hashLengthBytes", hashLengthBytes) + .put("hashType", hashType.toString()) + .put("parallelism", parallelism) + .put("iterations", iterations) + .put("memoryCostKib", memoryCostKib); + if (this.associatedData != null) { + argon2Parameters.put("associatedData", associatedData); + } + if (this.version != null) { + argon2Parameters.put("version", version.toString()); + } + return ImmutableMap.of("argon2Parameters", argon2Parameters.build()); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int hashLengthBytes; + private Argon2HashType hashType; + private int parallelism; + private int iterations; + private int memoryCostKib; + private Argon2Version version; + private byte[] associatedData; + + private Builder() {} + + /** + * Sets the hash length in bytes. Required field. + * + * @param hashLengthBytes an integer between 4 and 1024 (inclusive). + * @return This builder. + */ + public Builder setHashLengthBytes(int hashLengthBytes) { + this.hashLengthBytes = hashLengthBytes; + return this; + } + + /** + * Sets the Argon2 hash type. Required field. + * + * @param hashType a value from the {@link Argon2HashType} enum. + * @return This builder. + */ + public Builder setHashType(Argon2HashType hashType) { + this.hashType = hashType; + return this; + } + + /** + * Sets the degree of parallelism, also called threads or lanes. Required field. + * + * @param parallelism an integer between 1 and 16 (inclusive). + * @return This builder. + */ + public Builder setParallelism(int parallelism) { + this.parallelism = parallelism; + return this; + } + + /** + * Sets the number of iterations to perform. Required field. + * + * @param iterations an integer between 1 and 16 (inclusive). + * @return This builder. + */ + public Builder setIterations(int iterations) { + this.iterations = iterations; + return this; + } + + /** + * Sets the memory cost in kibibytes. Required field. + * + * @param memoryCostKib an integer between 1 and 32768 (inclusive). + * @return This builder. + */ + public Builder setMemoryCostKib(int memoryCostKib) { + this.memoryCostKib = memoryCostKib; + return this; + } + + /** + * Sets the version of the Argon2 algorithm. + * + * @param version a value from the {@link Argon2Version} enum. + * @return This builder. + */ + public Builder setVersion(Argon2Version version) { + this.version = version; + return this; + } + + /** + * Sets additional associated data, if provided, to append to the hash value for additional + * security. This data is base64 encoded before it is sent to the API. + * + * @param associatedData Associated data as a byte array. + * @return This builder. + */ + public Builder setAssociatedData(byte[] associatedData) { + this.associatedData = associatedData; + return this; + } + + public Argon2 build() { + return new Argon2(this); + } + } + + public enum Argon2HashType { + ARGON2_D, + ARGON2_ID, + ARGON2_I + } + + public enum Argon2Version { + VERSION_10, + VERSION_13 + } +} diff --git a/src/main/java/com/google/firebase/auth/hash/Bcrypt.java b/src/main/java/com/google/firebase/auth/hash/Bcrypt.java index 2b5f89029..9c55d8d56 100644 --- a/src/main/java/com/google/firebase/auth/hash/Bcrypt.java +++ b/src/main/java/com/google/firebase/auth/hash/Bcrypt.java @@ -24,7 +24,7 @@ * Represents the Bcrypt password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Bcrypt extends UserImportHash { +public final class Bcrypt extends UserImportHash { private Bcrypt() { super("BCRYPT"); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacMd5.java b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java index b67574358..b2ffdb852 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacMd5.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacMd5.java @@ -20,7 +20,7 @@ * Represents the HMAC MD5 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacMd5 extends Hmac { +public final class HmacMd5 extends Hmac { private HmacMd5(Builder builder) { super("HMAC_MD5", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha1.java b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java index a9ecefd6f..964e5e60d 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha1.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha1.java @@ -20,7 +20,7 @@ * Represents the HMAC SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha1 extends Hmac { +public final class HmacSha1 extends Hmac { private HmacSha1(Builder builder) { super("HMAC_SHA1", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha256.java b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java index 78f131cff..92917e6f3 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha256.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha256.java @@ -20,7 +20,7 @@ * Represents the HMAC SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha256 extends Hmac { +public final class HmacSha256 extends Hmac { private HmacSha256(Builder builder) { super("HMAC_SHA256", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/HmacSha512.java b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java index 21e6a2b25..b5a0e09ec 100644 --- a/src/main/java/com/google/firebase/auth/hash/HmacSha512.java +++ b/src/main/java/com/google/firebase/auth/hash/HmacSha512.java @@ -20,7 +20,7 @@ * Represents the HMAC SHA512 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class HmacSha512 extends Hmac { +public final class HmacSha512 extends Hmac { private HmacSha512(Builder builder) { super("HMAC_SHA512", builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Md5.java b/src/main/java/com/google/firebase/auth/hash/Md5.java index 2abbe55ba..353b07f01 100644 --- a/src/main/java/com/google/firebase/auth/hash/Md5.java +++ b/src/main/java/com/google/firebase/auth/hash/Md5.java @@ -20,7 +20,7 @@ * Represents the MD5 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Md5 extends RepeatableHash { +public final class Md5 extends RepeatableHash { private Md5(Builder builder) { super("MD5", 0, 8192, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java index 4c5108e35..6c3ffeff2 100644 --- a/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java +++ b/src/main/java/com/google/firebase/auth/hash/Pbkdf2Sha256.java @@ -20,7 +20,7 @@ * Represents the PBKDF2 SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Pbkdf2Sha256 extends RepeatableHash { +public final class Pbkdf2Sha256 extends RepeatableHash { private Pbkdf2Sha256(Builder builder) { super("PBKDF2_SHA256", 0, 120000, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java index 8afe3f4ab..647a365b3 100644 --- a/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java +++ b/src/main/java/com/google/firebase/auth/hash/PbkdfSha1.java @@ -20,7 +20,7 @@ * Represents the PBKDF SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class PbkdfSha1 extends RepeatableHash { +public final class PbkdfSha1 extends RepeatableHash { private PbkdfSha1(Builder builder) { super("PBKDF_SHA1", 0, 120000, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Sha1.java b/src/main/java/com/google/firebase/auth/hash/Sha1.java index 385f4310c..9de01b0b8 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha1.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha1.java @@ -20,7 +20,7 @@ * Represents the SHA1 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha1 extends RepeatableHash { +public final class Sha1 extends RepeatableHash { private Sha1(Builder builder) { super("SHA1", 1, 8192, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Sha256.java b/src/main/java/com/google/firebase/auth/hash/Sha256.java index f65aee19a..d0185195e 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha256.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha256.java @@ -20,7 +20,7 @@ * Represents the SHA256 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha256 extends RepeatableHash { +public final class Sha256 extends RepeatableHash { private Sha256(Builder builder) { super("SHA256", 1, 8192, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/Sha512.java b/src/main/java/com/google/firebase/auth/hash/Sha512.java index e582520a9..f468abe1c 100644 --- a/src/main/java/com/google/firebase/auth/hash/Sha512.java +++ b/src/main/java/com/google/firebase/auth/hash/Sha512.java @@ -20,7 +20,7 @@ * Represents the SHA512 password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class Sha512 extends RepeatableHash { +public final class Sha512 extends RepeatableHash { private Sha512(Builder builder) { super("SHA512", 1, 8192, builder); diff --git a/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java index 49f7d72f5..139fd114f 100644 --- a/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java +++ b/src/main/java/com/google/firebase/auth/hash/StandardScrypt.java @@ -24,7 +24,7 @@ * Represents the Standard Scrypt password hashing algorithm. Can be used as an instance of * {@link com.google.firebase.auth.UserImportHash} when importing users. */ -public class StandardScrypt extends UserImportHash { +public final class StandardScrypt extends UserImportHash { private final int derivedKeyLength; private final int blockSize; diff --git a/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java b/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java new file mode 100644 index 000000000..cd570c64d --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthErrorHandler.java @@ -0,0 +1,235 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.auth.AuthErrorCode; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.AbstractHttpErrorHandler; +import com.google.firebase.internal.Nullable; +import java.io.IOException; +import java.util.Map; + +final class AuthErrorHandler extends AbstractHttpErrorHandler { + + private static final Map ERROR_CODES = + ImmutableMap.builder() + .put( + "CONFIGURATION_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No IdP configuration found corresponding to the provided identifier", + AuthErrorCode.CONFIGURATION_NOT_FOUND)) + .put( + "DUPLICATE_EMAIL", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided email already exists", + AuthErrorCode.EMAIL_ALREADY_EXISTS)) + .put( + "DUPLICATE_LOCAL_ID", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided uid already exists", + AuthErrorCode.UID_ALREADY_EXISTS)) + .put( + "EMAIL_EXISTS", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided email already exists", + AuthErrorCode.EMAIL_ALREADY_EXISTS)) + .put( + "EMAIL_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No user record found for the given email", + AuthErrorCode.EMAIL_NOT_FOUND)) + .put( + "INVALID_DYNAMIC_LINK_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The provided dynamic link domain is not " + + "configured or authorized for the current project", + AuthErrorCode.INVALID_DYNAMIC_LINK_DOMAIN)) + .put( + "INVALID_HOSTING_LINK_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The provided hosting link domain is not configured in Firebase Hosting or is " + + "not owned by the current project", + AuthErrorCode.INVALID_HOSTING_LINK_DOMAIN)) + .put( + "PHONE_NUMBER_EXISTS", + new AuthError( + ErrorCode.ALREADY_EXISTS, + "The user with the provided phone number already exists", + AuthErrorCode.PHONE_NUMBER_ALREADY_EXISTS)) + .put( + "TENANT_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No tenant found for the given identifier", + AuthErrorCode.TENANT_NOT_FOUND)) + .put( + "UNAUTHORIZED_DOMAIN", + new AuthError( + ErrorCode.INVALID_ARGUMENT, + "The domain of the continue URL is not whitelisted", + AuthErrorCode.UNAUTHORIZED_CONTINUE_URL)) + .put( + "USER_NOT_FOUND", + new AuthError( + ErrorCode.NOT_FOUND, + "No user record found for the given identifier", + AuthErrorCode.USER_NOT_FOUND)) + .build(); + + private final JsonFactory jsonFactory; + + AuthErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = checkNotNull(jsonFactory); + } + + @Override + protected FirebaseAuthException createException(FirebaseException base) { + String response = getResponse(base); + AuthServiceErrorResponse parsed = safeParse(response); + AuthError errorInfo = ERROR_CODES.get(parsed.getCode()); + if (errorInfo != null) { + return new FirebaseAuthException( + errorInfo.getErrorCode(), + errorInfo.buildMessage(parsed), + base.getCause(), + base.getHttpResponse(), + errorInfo.getAuthErrorCode()); + } + + return new FirebaseAuthException(base); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private AuthServiceErrorResponse safeParse(String response) { + AuthServiceErrorResponse parsed = new AuthServiceErrorResponse(); + if (!Strings.isNullOrEmpty(response)) { + try { + jsonFactory.createJsonParser(response).parse(parsed); + } catch (IOException ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return parsed; + } + + private static class AuthError { + + private final ErrorCode errorCode; + private final String message; + private final AuthErrorCode authErrorCode; + + AuthError(ErrorCode errorCode, String message, AuthErrorCode authErrorCode) { + this.errorCode = errorCode; + this.message = message; + this.authErrorCode = authErrorCode; + } + + ErrorCode getErrorCode() { + return errorCode; + } + + AuthErrorCode getAuthErrorCode() { + return authErrorCode; + } + + String buildMessage(AuthServiceErrorResponse response) { + StringBuilder builder = new StringBuilder(this.message) + .append(" (").append(response.getCode()).append(")"); + String detail = response.getDetail(); + if (!Strings.isNullOrEmpty(detail)) { + builder.append(": ").append(detail); + } else { + builder.append("."); + } + + return builder.toString(); + } + } + + /** + * JSON data binding for JSON error messages sent by Google identity toolkit service. These + * error messages take the form `{"error": {"message": "CODE : OPTIONAL DETAILS"}}`. + */ + private static class AuthServiceErrorResponse { + + @Key("error") + private GenericJson error; + + @Nullable + public String getCode() { + String message = getMessage(); + if (Strings.isNullOrEmpty(message)) { + return null; + } + + int separator = message.indexOf(':'); + if (separator != -1) { + return message.substring(0, separator).trim(); + } + + return message; + } + + @Nullable + public String getDetail() { + String message = getMessage(); + if (Strings.isNullOrEmpty(message)) { + return null; + } + + int separator = message.indexOf(':'); + if (separator != -1) { + return message.substring(separator + 1).trim(); + } + + return null; + } + + private String getMessage() { + if (error == null) { + return null; + } + + return (String) error.get("message"); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java b/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java new file mode 100644 index 000000000..e8413f13f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/AuthHttpClient.java @@ -0,0 +1,81 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.JsonFactory; +import com.google.common.collect.ImmutableSortedSet; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; +import com.google.firebase.internal.SdkUtils; +import java.util.Map; +import java.util.Set; + +/** + * Provides a convenient API for making REST calls to the Firebase Auth backend servers. + */ +public final class AuthHttpClient { + + private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; + + private static final String CLIENT_VERSION = "Java/Admin/" + SdkUtils.getVersion(); + + private final ErrorHandlingHttpClient httpClient; + private final JsonFactory jsonFactory; + + public AuthHttpClient(JsonFactory jsonFactory, HttpRequestFactory requestFactory) { + AuthErrorHandler authErrorHandler = new AuthErrorHandler(jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, authErrorHandler); + this.jsonFactory = jsonFactory; + } + + public static Set generateMask(Map properties) { + ImmutableSortedSet.Builder maskBuilder = ImmutableSortedSet.naturalOrder(); + for (Map.Entry entry : properties.entrySet()) { + if (entry.getValue() instanceof Map) { + Set childMask = generateMask((Map) entry.getValue()); + for (String childProperty : childMask) { + maskBuilder.add(entry.getKey() + "." + childProperty); + } + } else { + maskBuilder.add(entry.getKey()); + } + } + return maskBuilder.build(); + } + + public void setInterceptor(HttpResponseInterceptor interceptor) { + this.httpClient.setInterceptor(interceptor); + } + + public T sendRequest(HttpRequestInfo request, Class clazz) throws FirebaseAuthException { + IncomingHttpResponse response = this.sendRequest(request); + return this.parse(response, clazz); + } + + public IncomingHttpResponse sendRequest(HttpRequestInfo request) throws FirebaseAuthException { + request.addHeader(CLIENT_VERSION_HEADER, CLIENT_VERSION); + return httpClient.send(request); + } + + public T parse(IncomingHttpResponse response, Class clazz) throws FirebaseAuthException { + return httpClient.parse(response, clazz); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java similarity index 54% rename from src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java rename to src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java index d4be4b4a6..728cf6358 100644 --- a/src/main/java/com/google/firebase/auth/internal/HttpErrorResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/BatchDeleteResponse.java @@ -1,5 +1,5 @@ /* - * Copyright 2017 Google Inc. + * Copyright 2020 Google Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,33 +17,35 @@ package com.google.firebase.auth.internal; import com.google.api.client.util.Key; -import com.google.common.base.Strings; +import java.util.List; /** - * JSON data binding for JSON error messages sent by Google identity toolkit service. + * Represents the response from Google identity Toolkit for a batch delete request. */ -public class HttpErrorResponse { +public class BatchDeleteResponse { - @Key("error") - private Error error; + @Key("errors") + private List errors; - public String getErrorCode() { - if (error != null) { - if (!Strings.isNullOrEmpty(error.getCode())) { - return error.getCode(); - } - } - return "unknown"; + public List getErrors() { + return errors; } - public static class Error { + public static class ErrorInfo { + @Key("index") + private int index; @Key("message") - private String code; + private String message; + + // A 'localId' field also exists here, but is not currently exposed in the Admin SDK. - public String getCode() { - return code; + public int getIndex() { + return index; } - } + public String getMessage() { + return message; + } + } } diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java index 3036f9f28..2ff30a20c 100644 --- a/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigner.java @@ -16,6 +16,7 @@ package com.google.firebase.auth.internal; +import com.google.firebase.auth.FirebaseAuthException; import com.google.firebase.internal.NonNull; import java.io.IOException; @@ -32,10 +33,10 @@ interface CryptoSigner { * * @param payload Data to be signed * @return Signature as a byte array - * @throws IOException If an error occurs during signing + * @throws FirebaseAuthException If an error occurs during signing */ @NonNull - byte[] sign(@NonNull byte[] payload) throws IOException; + byte[] sign(@NonNull byte[] payload) throws FirebaseAuthException; /** * Returns the client email of the service account used to sign payloads. diff --git a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java index 6ea70b880..543f955c3 100644 --- a/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java +++ b/src/main/java/com/google/firebase/auth/internal/CryptoSigners.java @@ -8,10 +8,8 @@ import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.util.Key; import com.google.api.client.util.StringUtils; import com.google.auth.ServiceAccountSigner; import com.google.auth.oauth2.GoogleCredentials; @@ -21,9 +19,14 @@ import com.google.common.io.BaseEncoding; import com.google.common.io.ByteStreams; import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; +import com.google.firebase.FirebaseException; import com.google.firebase.ImplFirebaseTrampolines; -import com.google.firebase.internal.FirebaseRequestInitializer; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.AbstractPlatformErrorHandler; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; import java.io.IOException; import java.util.Map; @@ -34,7 +37,9 @@ public class CryptoSigners { private static final String METADATA_SERVICE_URL = - "http://metadata/computeMetadata/v1/instance/service-accounts/default/email"; + "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/email"; + + private CryptoSigners() { } /** * A {@link CryptoSigner} implementation that uses service account credentials or equivalent @@ -61,56 +66,41 @@ public String getAccount() { /** * @ {@link CryptoSigner} implementation that uses the - * - * Google IAM service to sign data. + * + * Google IAMCredentials service to sign data. */ static class IAMCryptoSigner implements CryptoSigner { private static final String IAM_SIGN_BLOB_URL = - "https://iam.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/%s:signBlob"; - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; private final String serviceAccount; - private HttpResponseInterceptor interceptor; + private final ErrorHandlingHttpClient httpClient; IAMCryptoSigner( @NonNull HttpRequestFactory requestFactory, @NonNull JsonFactory jsonFactory, @NonNull String serviceAccount) { - this.requestFactory = checkNotNull(requestFactory); - this.jsonFactory = checkNotNull(jsonFactory); checkArgument(!Strings.isNullOrEmpty(serviceAccount)); this.serviceAccount = serviceAccount; + this.httpClient = new ErrorHandlingHttpClient<>( + requestFactory, + jsonFactory, + new IAMErrorHandler(jsonFactory)); } void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } @Override - public byte[] sign(byte[] payload) throws IOException { - String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount); - HttpResponse response = null; + public byte[] sign(byte[] payload) throws FirebaseAuthException { String encodedPayload = BaseEncoding.base64().encode(payload); - Map content = ImmutableMap.of("bytesToSign", encodedPayload); - try { - HttpRequest request = requestFactory.buildPostRequest(new GenericUrl(encodedUrl), - new JsonHttpContent(jsonFactory, content)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - SignBlobResponse parsed = response.parseAs(SignBlobResponse.class); - return BaseEncoding.base64().decode(parsed.signature); - } finally { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // Ignored - } - } - } + Map content = ImmutableMap.of("payload", encodedPayload); + String encodedUrl = String.format(IAM_SIGN_BLOB_URL, serviceAccount); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPostRequest(encodedUrl, content); + GenericJson parsed = httpClient.sendAndParse(requestInfo, GenericJson.class); + return BaseEncoding.base64().decode((String) parsed.get("signedBlob")); } @Override @@ -119,9 +109,36 @@ public String getAccount() { } } - public static class SignBlobResponse { - @Key("signature") - private String signature; + /** + * A {@link CryptoSigner} implementation that doesn't sign data. For use with the Auth Emulator + * only + */ + public static class EmulatorCryptoSigner implements CryptoSigner { + + private static final String ACCOUNT = "firebase-auth-emulator@example.com"; + + @Override + public byte[] sign(byte[] payload) { + return "".getBytes(); + } + + @Override + public String getAccount() { + return ACCOUNT; + } + } + + private static class IAMErrorHandler + extends AbstractPlatformErrorHandler { + + IAMErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); + } + + @Override + protected FirebaseAuthException createException(FirebaseException base) { + return new FirebaseAuthException(base); + } } /** @@ -129,6 +146,10 @@ public static class SignBlobResponse { * documented at go/firebase-admin-sign. */ public static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOException { + if (Utils.isEmulatorMode()) { + return new EmulatorCryptoSigner(); + } + GoogleCredentials credentials = ImplFirebaseTrampolines.getCredentials(firebaseApp); // If the SDK was initialized with a service account, use it to sign bytes. @@ -136,14 +157,12 @@ public static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOExc return new ServiceAccountCryptoSigner((ServiceAccountCredentials) credentials); } - FirebaseOptions options = firebaseApp.getOptions(); - HttpRequestFactory requestFactory = options.getHttpTransport().createRequestFactory( - new FirebaseRequestInitializer(firebaseApp)); - JsonFactory jsonFactory = options.getJsonFactory(); + HttpRequestFactory requestFactory = ApiClientUtils.newAuthorizedRequestFactory(firebaseApp); + JsonFactory jsonFactory = firebaseApp.getOptions().getJsonFactory(); // If the SDK was initialized with a service account email, use it with the IAM service // to sign bytes. - String serviceAccountId = options.getServiceAccountId(); + String serviceAccountId = firebaseApp.getOptions().getServiceAccountId(); if (!Strings.isNullOrEmpty(serviceAccountId)) { return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); } @@ -156,15 +175,22 @@ public static CryptoSigner getCryptoSigner(FirebaseApp firebaseApp) throws IOExc // Attempt to discover a service account email from the local Metadata service. Use it // with the IAM service to sign bytes. - HttpRequest request = requestFactory.buildGetRequest(new GenericUrl(METADATA_SERVICE_URL)); + serviceAccountId = discoverServiceAccountId(firebaseApp); + return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); + } + + private static String discoverServiceAccountId(FirebaseApp firebaseApp) throws IOException { + HttpRequestFactory metadataRequestFactory = + ApiClientUtils.newUnauthorizedRequestFactory(firebaseApp); + HttpRequest request = metadataRequestFactory.buildGetRequest( + new GenericUrl(METADATA_SERVICE_URL)); request.getHeaders().set("Metadata-Flavor", "Google"); HttpResponse response = request.execute(); try { byte[] output = ByteStreams.toByteArray(response.getContent()); - serviceAccountId = StringUtils.newStringUtf8(output).trim(); - return new IAMCryptoSigner(requestFactory, jsonFactory, serviceAccountId); + return StringUtils.newStringUtf8(output).trim(); } finally { - response.disconnect(); + ApiClientUtils.disconnectQuietly(response); } } } diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java index e67576464..2fe0b1859 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseCustomAuthToken.java @@ -22,6 +22,7 @@ import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.api.client.util.Key; import com.google.firebase.auth.FirebaseToken; +import com.google.firebase.internal.Nullable; import java.io.IOException; @@ -77,6 +78,9 @@ public static class Payload extends IdToken.Payload { @Key("claims") private GenericJson developerClaims; + @Key("tenant_id") + private String tenantId; + public final String getUid() { return uid; } @@ -95,6 +99,15 @@ public Payload setDeveloperClaims(GenericJson developerClaims) { return this; } + public final String getTenantId() { + return tenantId; + } + + public Payload setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + @Override public Payload setIssuer(String issuer) { return (Payload) super.setIssuer(issuer); diff --git a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java index 95d313134..b5aa1e31a 100644 --- a/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java +++ b/src/main/java/com/google/firebase/auth/internal/FirebaseTokenFactory.java @@ -25,8 +25,11 @@ import com.google.api.client.util.Base64; import com.google.api.client.util.Clock; import com.google.api.client.util.StringUtils; - +import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.internal.Nullable; + import java.io.IOException; import java.util.Collection; import java.util.Map; @@ -41,19 +44,27 @@ public class FirebaseTokenFactory { private final JsonFactory jsonFactory; private final Clock clock; private final CryptoSigner signer; + private final String tenantId; - public FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) { + public FirebaseTokenFactory( + JsonFactory jsonFactory, Clock clock, CryptoSigner signer, @Nullable String tenantId) { this.jsonFactory = checkNotNull(jsonFactory); this.clock = checkNotNull(clock); this.signer = checkNotNull(signer); + this.tenantId = tenantId; } - String createSignedCustomAuthTokenForUser(String uid) throws IOException { + @VisibleForTesting + FirebaseTokenFactory(JsonFactory jsonFactory, Clock clock, CryptoSigner signer) { + this(jsonFactory, clock, signer, null); + } + + String createSignedCustomAuthTokenForUser(String uid) throws FirebaseAuthException { return createSignedCustomAuthTokenForUser(uid, null); } public String createSignedCustomAuthTokenForUser( - String uid, Map developerClaims) throws IOException { + String uid, Map developerClaims) throws FirebaseAuthException { checkArgument(!Strings.isNullOrEmpty(uid), "Uid must be provided."); checkArgument(uid.length() <= 128, "Uid must be shorter than 128 characters."); @@ -68,6 +79,9 @@ public String createSignedCustomAuthTokenForUser( .setAudience(FirebaseCustomAuthToken.FIREBASE_AUDIENCE) .setIssuedAtTimeSeconds(issuedAt) .setExpirationTimeSeconds(issuedAt + FirebaseCustomAuthToken.TOKEN_DURATION_SECONDS); + if (!Strings.isNullOrEmpty(tenantId)) { + payload.setTenantId(tenantId); + } if (developerClaims != null) { Collection reservedNames = payload.getClassInfo().getNames(); @@ -77,20 +91,33 @@ public String createSignedCustomAuthTokenForUser( String.format("developerClaims must not contain a reserved key: %s", key)); } } + GenericJson jsonObject = new GenericJson(); jsonObject.putAll(developerClaims); payload.setDeveloperClaims(jsonObject); } + return signPayload(header, payload); } - private String signPayload(JsonWebSignature.Header header, - FirebaseCustomAuthToken.Payload payload) throws IOException { - String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header)); - String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload)); - String content = headerString + "." + payloadString; + private String signPayload( + JsonWebSignature.Header header, + FirebaseCustomAuthToken.Payload payload) throws FirebaseAuthException { + String content = encodePayload(header, payload); byte[] contentBytes = StringUtils.getBytesUtf8(content); String signature = Base64.encodeBase64URLSafeString(signer.sign(contentBytes)); return content + "." + signature; } + + private String encodePayload( + JsonWebSignature.Header header, FirebaseCustomAuthToken.Payload payload) { + try { + String headerString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(header)); + String payloadString = Base64.encodeBase64URLSafeString(jsonFactory.toByteArray(payload)); + return headerString + "." + payloadString; + } catch (IOException e) { + throw new IllegalArgumentException( + "Failed to encode JWT with the given claims: " + e.getMessage(), e); + } + } } diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java new file mode 100644 index 000000000..67c4d0ee7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoRequest.java @@ -0,0 +1,80 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.api.client.util.Key; +import java.util.ArrayList; +import java.util.List; + +/** + * Represents the request to look up account information. + */ +public final class GetAccountInfoRequest { + + @Key("localId") + private List uids = null; + + @Key("email") + private List emails = null; + + @Key("phoneNumber") + private List phoneNumbers = null; + + @Key("federatedUserId") + private List federatedUserIds = null; + + private static final class FederatedUserId { + @Key("providerId") + private String providerId = null; + + @Key("rawId") + private String rawId = null; + + FederatedUserId(String providerId, String rawId) { + this.providerId = providerId; + this.rawId = rawId; + } + } + + public void addUid(String uid) { + if (uids == null) { + uids = new ArrayList<>(); + } + uids.add(uid); + } + + public void addEmail(String email) { + if (emails == null) { + emails = new ArrayList<>(); + } + emails.add(email); + } + + public void addPhoneNumber(String phoneNumber) { + if (phoneNumbers == null) { + phoneNumbers = new ArrayList<>(); + } + phoneNumbers.add(phoneNumber); + } + + public void addFederatedUserId(String providerId, String providerUid) { + if (federatedUserIds == null) { + federatedUserIds = new ArrayList<>(); + } + federatedUserIds.add(new FederatedUserId(providerId, providerUid)); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java index 3d17c50f6..7bde3eb39 100644 --- a/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java +++ b/src/main/java/com/google/firebase/auth/internal/GetAccountInfoResponse.java @@ -46,6 +46,9 @@ public static class User { @Key("localId") private String uid; + @Key("tenantId") + private String tenantId; + @Key("email") private String email; @@ -73,6 +76,9 @@ public static class User { @Key("lastLoginAt") private long lastLoginAt; + @Key("lastRefreshAt") + private String lastRefreshAt; + @Key("validSince") private long validSince; @@ -83,6 +89,10 @@ public String getUid() { return uid; } + public String getTenantId() { + return tenantId; + } + public String getEmail() { return email; } @@ -119,10 +129,14 @@ public long getLastLoginAt() { return lastLoginAt; } + public String getLastRefreshAt() { + return lastRefreshAt; + } + public long getValidSince() { return validSince; } - + public String getCustomClaims() { return customClaims; } diff --git a/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java new file mode 100644 index 000000000..187f98cf5 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListOidcProviderConfigsResponse.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.OidcProviderConfig; +import java.util.List; + +/** + * JSON data binding for ListOAuthIdpConfigsResponse messages sent by Google identity toolkit + * service. + */ +public final class ListOidcProviderConfigsResponse + implements ListProviderConfigsResponse { + + @Key("oauthIdpConfigs") + private List providerConfigs; + + @Key("nextPageToken") + private String pageToken; + + @VisibleForTesting + public ListOidcProviderConfigsResponse( + List providerConfigs, String pageToken) { + this.providerConfigs = providerConfigs; + this.pageToken = pageToken; + } + + public ListOidcProviderConfigsResponse() { } + + @Override + public List getProviderConfigs() { + return providerConfigs == null ? ImmutableList.of() : providerConfigs; + } + + @Override + public boolean hasProviderConfigs() { + return providerConfigs != null && !providerConfigs.isEmpty(); + } + + @Override + public String getPageToken() { + return Strings.nullToEmpty(pageToken); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java new file mode 100644 index 000000000..2f25ae623 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListProviderConfigsResponse.java @@ -0,0 +1,32 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.firebase.auth.ProviderConfig; +import java.util.List; + +/** + * Interface for config list response messages sent by Google identity toolkit service. + */ +public interface ListProviderConfigsResponse { + + public List getProviderConfigs(); + + public boolean hasProviderConfigs(); + + public String getPageToken(); +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java new file mode 100644 index 000000000..55b944d53 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListSamlProviderConfigsResponse.java @@ -0,0 +1,62 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.SamlProviderConfig; +import java.util.List; + +/** + * JSON data binding for ListInboundSamlConfigsResponse messages sent by Google identity toolkit + * service. + */ +public final class ListSamlProviderConfigsResponse + implements ListProviderConfigsResponse { + + @Key("inboundSamlConfigs") + private List providerConfigs; + + @Key("nextPageToken") + private String pageToken; + + @VisibleForTesting + public ListSamlProviderConfigsResponse( + List providerConfigs, String pageToken) { + this.providerConfigs = providerConfigs; + this.pageToken = pageToken; + } + + public ListSamlProviderConfigsResponse() { } + + @Override + public List getProviderConfigs() { + return providerConfigs == null ? ImmutableList.of() : providerConfigs; + } + + @Override + public boolean hasProviderConfigs() { + return providerConfigs != null && !providerConfigs.isEmpty(); + } + + @Override + public String getPageToken() { + return Strings.nullToEmpty(pageToken); + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java new file mode 100644 index 000000000..f1086921f --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/ListTenantsResponse.java @@ -0,0 +1,55 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.internal; + +import com.google.api.client.util.Key; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.multitenancy.Tenant; +import java.util.List; + +/** + * JSON data binding for ListTenantsResponse messages sent by Google identity toolkit service. + */ +public final class ListTenantsResponse { + + @Key("tenants") + private List tenants; + + @Key("pageToken") + private String pageToken; + + @VisibleForTesting + public ListTenantsResponse(List tenants, String pageToken) { + this.tenants = tenants; + this.pageToken = pageToken; + } + + public ListTenantsResponse() { } + + public List getTenants() { + return tenants == null ? ImmutableList.of() : tenants; + } + + public boolean hasTenants() { + return tenants != null && !tenants.isEmpty(); + } + + public String getPageToken() { + return pageToken == null ? "" : pageToken; + } +} diff --git a/src/main/java/com/google/firebase/auth/internal/Utils.java b/src/main/java/com/google/firebase/auth/internal/Utils.java new file mode 100644 index 000000000..f4d082917 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/internal/Utils.java @@ -0,0 +1,35 @@ +/* + * 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. + */ + +package com.google.firebase.auth.internal; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.internal.FirebaseProcessEnvironment; + +public class Utils { + @VisibleForTesting + public static final String AUTH_EMULATOR_HOST = "FIREBASE_AUTH_EMULATOR_HOST"; + + public static boolean isEmulatorMode() { + return !Strings.isNullOrEmpty(getEmulatorHost()); + } + + public static String getEmulatorHost() { + return FirebaseProcessEnvironment.getenv(AUTH_EMULATOR_HOST); + } + +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java new file mode 100644 index 000000000..56ef04eb7 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/FirebaseTenantClient.java @@ -0,0 +1,124 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Joiner; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.AuthHttpClient; +import com.google.firebase.auth.internal.ListTenantsResponse; +import com.google.firebase.auth.internal.Utils; +import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.HttpRequestInfo; +import java.util.Map; + +final class FirebaseTenantClient { + + static final int MAX_LIST_TENANTS_RESULTS = 100; + + private static final String ID_TOOLKIT_URL = + "https://identitytoolkit.googleapis.com/%s/projects/%s"; + + private static final String ID_TOOLKIT_URL_EMULATOR = + "http://%s/identitytoolkit.googleapis.com/%s/projects/%s"; + + private final String tenantMgtBaseUrl; + private final AuthHttpClient httpClient; + + FirebaseTenantClient(FirebaseApp app) { + this( + ImplFirebaseTrampolines.getProjectId(checkNotNull(app)), + app.getOptions().getJsonFactory(), + ApiClientUtils.newAuthorizedRequestFactory(app)); + } + + FirebaseTenantClient( + String projectId, JsonFactory jsonFactory, HttpRequestFactory requestFactory) { + checkArgument(!Strings.isNullOrEmpty(projectId), + "Project ID is required to access the auth service. Use a service account credential or " + + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + this.tenantMgtBaseUrl = getTenantMgtBaseUrl(projectId); + this.httpClient = new AuthHttpClient(jsonFactory, requestFactory); + } + + private String getTenantMgtBaseUrl(String projectId) { + if (Utils.isEmulatorMode()) { + return String.format(ID_TOOLKIT_URL_EMULATOR, Utils.getEmulatorHost(), "v2", projectId); + } + return String.format(ID_TOOLKIT_URL, "v2", projectId); + } + + void setInterceptor(HttpResponseInterceptor interceptor) { + httpClient.setInterceptor(interceptor); + } + + Tenant getTenant(String tenantId) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + getTenantUrlSuffix(tenantId); + return httpClient.sendRequest(HttpRequestInfo.buildGetRequest(url), Tenant.class); + } + + Tenant createTenant(Tenant.CreateRequest request) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + "/tenants"; + return httpClient.sendRequest( + HttpRequestInfo.buildJsonPostRequest(url, request.getProperties()), + Tenant.class); + } + + Tenant updateTenant(Tenant.UpdateRequest request) throws FirebaseAuthException { + Map properties = request.getProperties(); + String url = tenantMgtBaseUrl + getTenantUrlSuffix(request.getTenantId()); + HttpRequestInfo requestInfo = HttpRequestInfo.buildJsonPatchRequest(url, properties) + .addParameter("updateMask", Joiner.on(",").join(AuthHttpClient.generateMask(properties))); + return httpClient.sendRequest(requestInfo, Tenant.class); + } + + void deleteTenant(String tenantId) throws FirebaseAuthException { + String url = tenantMgtBaseUrl + getTenantUrlSuffix(tenantId); + httpClient.sendRequest(HttpRequestInfo.buildDeleteRequest(url), GenericJson.class); + } + + ListTenantsResponse listTenants(int maxResults, String pageToken) + throws FirebaseAuthException { + ImmutableMap.Builder builder = + ImmutableMap.builder().put("pageSize", maxResults); + if (pageToken != null) { + checkArgument(!pageToken.equals( + ListTenantsPage.END_OF_LIST), "Invalid end of list page token."); + builder.put("pageToken", pageToken); + } + + HttpRequestInfo requestInfo = HttpRequestInfo.buildGetRequest(tenantMgtBaseUrl + "/tenants") + .addAllParameters(builder.build()); + return httpClient.sendRequest(requestInfo, ListTenantsResponse.class); + } + + private static String getTenantUrlSuffix(String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return "/tenants/" + tenantId; + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java b/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java new file mode 100644 index 000000000..5f9917bce --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/ListTenantsPage.java @@ -0,0 +1,245 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.gax.paging.Page; +import com.google.common.collect.ImmutableList; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.internal.ListTenantsResponse; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.Iterator; +import java.util.List; +import java.util.NoSuchElementException; + +/** + * Represents a page of {@link Tenant} instances. + * + *

Provides methods for iterating over the tenants in the current page, and calling up + * subsequent pages of tenants. + * + *

Instances of this class are thread-safe and immutable. + */ +public class ListTenantsPage implements Page { + + static final String END_OF_LIST = ""; + + private final ListTenantsResponse currentBatch; + private final TenantSource source; + private final int maxResults; + + private ListTenantsPage( + @NonNull ListTenantsResponse currentBatch, @NonNull TenantSource source, int maxResults) { + this.currentBatch = checkNotNull(currentBatch); + this.source = checkNotNull(source); + this.maxResults = maxResults; + } + + /** + * Checks if there is another page of tenants available to retrieve. + * + * @return true if another page is available, or false otherwise. + */ + @Override + public boolean hasNextPage() { + return !END_OF_LIST.equals(currentBatch.getPageToken()); + } + + /** + * Returns the string token that identifies the next page. + * + *

Never returns null. Returns empty string if there are no more pages available to be + * retrieved. + * + * @return A non-null string token (possibly empty, representing no more pages) + */ + @NonNull + @Override + public String getNextPageToken() { + return currentBatch.getPageToken(); + } + + /** + * Returns the next page of tenants. + * + * @return A new {@link ListTenantsPage} instance, or null if there are no more pages. + */ + @Nullable + @Override + public ListTenantsPage getNextPage() { + if (hasNextPage()) { + PageFactory factory = new PageFactory(source, maxResults, currentBatch.getPageToken()); + try { + return factory.create(); + } catch (FirebaseAuthException e) { + throw new RuntimeException(e); + } + } + return null; + } + + /** + * Returns an {@code Iterable} that facilitates transparently iterating over all the tenants in + * the current Firebase project, starting from this page. + * + *

The {@code Iterator} instances produced by the returned {@code Iterable} never buffers more + * than one page of tenants at a time. It is safe to abandon the iterators (i.e. break the loops) + * at any time. + * + * @return a new {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable iterateAll() { + return new TenantIterable(this); + } + + /** + * Returns an {@code Iterable} over the tenants in this page. + * + * @return a {@code Iterable} instance. + */ + @NonNull + @Override + public Iterable getValues() { + return currentBatch.getTenants(); + } + + private static class TenantIterable implements Iterable { + + private final ListTenantsPage startingPage; + + TenantIterable(@NonNull ListTenantsPage startingPage) { + this.startingPage = checkNotNull(startingPage, "starting page must not be null"); + } + + @Override + @NonNull + public Iterator iterator() { + return new TenantIterator(startingPage); + } + + /** + * An {@code Iterator} that cycles through tenants, one at a time. + * + *

It buffers the last retrieved batch of tenants in memory. The {@code maxResults} parameter + * is an upper bound on the batch size. + */ + private static class TenantIterator implements Iterator { + + private ListTenantsPage currentPage; + private List batch; + private int index = 0; + + private TenantIterator(ListTenantsPage startingPage) { + setCurrentPage(startingPage); + } + + @Override + public boolean hasNext() { + if (index == batch.size()) { + if (currentPage.hasNextPage()) { + setCurrentPage(currentPage.getNextPage()); + } else { + return false; + } + } + + return index < batch.size(); + } + + @Override + public Tenant next() { + if (!hasNext()) { + throw new NoSuchElementException(); + } + return batch.get(index++); + } + + @Override + public void remove() { + throw new UnsupportedOperationException("remove operation not supported"); + } + + private void setCurrentPage(ListTenantsPage page) { + this.currentPage = checkNotNull(page); + this.batch = ImmutableList.copyOf(page.getValues()); + this.index = 0; + } + } + } + + /** + * Represents a source of tenant data that can be queried to load a batch of tenants. + */ + interface TenantSource { + @NonNull + ListTenantsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException; + } + + static class DefaultTenantSource implements TenantSource { + + private final FirebaseTenantClient tenantClient; + + DefaultTenantSource(FirebaseTenantClient tenantClient) { + this.tenantClient = checkNotNull(tenantClient, "Tenant client must not be null."); + } + + @Override + public ListTenantsResponse fetch(int maxResults, String pageToken) + throws FirebaseAuthException { + return tenantClient.listTenants(maxResults, pageToken); + } + } + + /** + * A simple factory class for {@link ListTenantsPage} instances. + * + *

Performs argument validation before attempting to load any tenant data (which is expensive, + * and hence may be performed asynchronously on a separate thread). + */ + static class PageFactory { + + private final TenantSource source; + private final int maxResults; + private final String pageToken; + + PageFactory(@NonNull TenantSource source) { + this(source, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS, null); + } + + PageFactory(@NonNull TenantSource source, int maxResults, @Nullable String pageToken) { + checkArgument(maxResults > 0 && maxResults <= FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS, + "maxResults must be a positive integer that does not exceed %s", + FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + checkArgument(!END_OF_LIST.equals(pageToken), "Invalid end of list page token."); + this.source = checkNotNull(source, "Source must not be null."); + this.maxResults = maxResults; + this.pageToken = pageToken; + } + + ListTenantsPage create() throws FirebaseAuthException { + ListTenantsResponse batch = source.fetch(maxResults, pageToken); + return new ListTenantsPage(batch, source, maxResults); + } + } +} + diff --git a/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java b/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java new file mode 100644 index 000000000..57d215e96 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/Tenant.java @@ -0,0 +1,195 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; +import java.util.HashMap; +import java.util.Map; + +/** + * Contains metadata associated with a Firebase tenant. + * + *

Instances of this class are immutable and thread safe. + */ +public final class Tenant { + + @Key("name") + private String resourceName; + + @Key("displayName") + private String displayName; + + @Key("allowPasswordSignup") + private boolean passwordSignInAllowed; + + @Key("enableEmailLinkSignin") + private boolean emailLinkSignInEnabled; + + public String getTenantId() { + return resourceName.substring(resourceName.lastIndexOf("/") + 1); + } + + public String getDisplayName() { + return displayName; + } + + public boolean isPasswordSignInAllowed() { + return passwordSignInAllowed; + } + + public boolean isEmailLinkSignInEnabled() { + return emailLinkSignInEnabled; + } + + /** + * Returns a new {@link UpdateRequest}, which can be used to update the attributes of this tenant. + * + * @return a non-null {@link UpdateRequest} instance. + */ + public UpdateRequest updateRequest() { + return new UpdateRequest(getTenantId()); + } + + /** + * A specification class for creating a new tenant. + * + *

Set the initial attributes of the new tenant by calling various setter methods available in + * this class. None of the attributes are required. + */ + public static final class CreateRequest { + + private final Map properties = new HashMap<>(); + + /** + * Creates a new {@link CreateRequest}, which can be used to create a new tenant. + * + *

The returned object should be passed to {@link TenantManager#createTenant(CreateRequest)} + * to register the tenant information persistently. + */ + public CreateRequest() { } + + /** + * Sets the display name for the new tenant. + * + * @param displayName a non-null, non-empty display name string. + */ + public CreateRequest setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "display name must not be null or empty"); + properties.put("displayName", displayName); + return this; + } + + /** + * Sets whether to allow email/password user authentication. + * + * @param passwordSignInAllowed a boolean indicating whether users can be authenticated using + * an email and password. + */ + public CreateRequest setPasswordSignInAllowed(boolean passwordSignInAllowed) { + properties.put("allowPasswordSignup", passwordSignInAllowed); + return this; + } + + /** + * Sets whether to enable email link user authentication. + * + * @param emailLinkSignInEnabled a boolean indicating whether users can be authenticated using + * an email link. + */ + public CreateRequest setEmailLinkSignInEnabled(boolean emailLinkSignInEnabled) { + properties.put("enableEmailLinkSignin", emailLinkSignInEnabled); + return this; + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + } + + /** + * A class for updating the attributes of an existing tenant. + * + *

An instance of this class can be obtained via a {@link Tenant} object, or from a tenant ID + * string. Specify the changes to be made to the tenant by calling the various setter methods + * available in this class. + */ + public static final class UpdateRequest { + + private final String tenantId; + private final Map properties = new HashMap<>(); + + /** + * Creates a new {@link UpdateRequest}, which can be used to update the attributes of the + * of the tenant identified by the specified tenant ID. + * + *

This method allows updating attributes of a tenant account, without first having to call + * {@link TenantManager#getTenant(String)}. + * + * @param tenantId a non-null, non-empty tenant ID string. + * @throws IllegalArgumentException If the tenant ID is null or empty. + */ + public UpdateRequest(String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "tenant ID must not be null or empty"); + this.tenantId = tenantId; + } + + String getTenantId() { + return tenantId; + } + + /** + * Sets the display name of the existing tenant. + * + * @param displayName a non-null, non-empty display name string. + */ + public UpdateRequest setDisplayName(String displayName) { + checkArgument(!Strings.isNullOrEmpty(displayName), "display name must not be null or empty"); + properties.put("displayName", displayName); + return this; + } + + /** + * Sets whether to allow email/password user authentication. + * + * @param passwordSignInAllowed a boolean indicating whether users can be authenticated using + * an email and password. + */ + public UpdateRequest setPasswordSignInAllowed(boolean passwordSignInAllowed) { + properties.put("allowPasswordSignup", passwordSignInAllowed); + return this; + } + + /** + * Sets whether to enable email link user authentication. + * + * @param emailLinkSignInEnabled a boolean indicating whether users can be authenticated using + * an email link. + */ + public UpdateRequest setEmailLinkSignInEnabled(boolean emailLinkSignInEnabled) { + properties.put("enableEmailLinkSignin", emailLinkSignInEnabled); + return this; + } + + Map getProperties() { + return ImmutableMap.copyOf(properties); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java b/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java new file mode 100644 index 000000000..bc374c036 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/TenantAwareFirebaseAuth.java @@ -0,0 +1,102 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.api.core.ApiAsyncFunction; +import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; +import com.google.common.base.Strings; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.AbstractFirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.FirebaseToken; +import com.google.firebase.auth.SessionCookieOptions; + +/** + * The tenant-aware Firebase client. + * + *

This can be used to perform a variety of authentication-related operations, scoped to a + * particular tenant. + */ +public final class TenantAwareFirebaseAuth extends AbstractFirebaseAuth { + + private final String tenantId; + + private TenantAwareFirebaseAuth(Builder builder) { + super(builder); + checkArgument(!Strings.isNullOrEmpty(builder.tenantId)); + this.tenantId = builder.tenantId; + } + + /** Returns the client's tenant ID. */ + public String getTenantId() { + return tenantId; + } + + @Override + public String createSessionCookie( + String idToken, SessionCookieOptions options) throws FirebaseAuthException { + verifyIdToken(idToken); + return super.createSessionCookie(idToken, options); + } + + @Override + public ApiFuture createSessionCookieAsync( + final String idToken, final SessionCookieOptions options) { + ApiFuture future = verifyIdTokenAsync(idToken); + return ApiFutures.transformAsync(future, new ApiAsyncFunction() { + @Override + public ApiFuture apply(FirebaseToken input) { + return TenantAwareFirebaseAuth.super.createSessionCookieAsync(idToken, options); + } + }, MoreExecutors.directExecutor()); + } + + static TenantAwareFirebaseAuth fromApp(FirebaseApp app, String tenantId) { + return populateBuilderFromApp(builder(), app, tenantId) + .setTenantId(tenantId) + .build(); + } + + static Builder builder() { + return new Builder(); + } + + static class Builder extends AbstractFirebaseAuth.Builder { + + private String tenantId; + + private Builder() { } + + @Override + protected Builder getThis() { + return this; + } + + public Builder setTenantId(String tenantId) { + this.tenantId = tenantId; + return this; + } + + TenantAwareFirebaseAuth build() { + return new TenantAwareFirebaseAuth(this); + } + } +} diff --git a/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java b/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java new file mode 100644 index 000000000..a30c0b884 --- /dev/null +++ b/src/main/java/com/google/firebase/auth/multitenancy/TenantManager.java @@ -0,0 +1,292 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.auth.multitenancy; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.firebase.FirebaseApp; +import com.google.firebase.auth.FirebaseAuth; +import com.google.firebase.auth.FirebaseAuthException; +import com.google.firebase.auth.multitenancy.ListTenantsPage.DefaultTenantSource; +import com.google.firebase.auth.multitenancy.ListTenantsPage.PageFactory; +import com.google.firebase.auth.multitenancy.ListTenantsPage.TenantSource; +import com.google.firebase.auth.multitenancy.Tenant.CreateRequest; +import com.google.firebase.auth.multitenancy.Tenant.UpdateRequest; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.util.HashMap; +import java.util.Map; + +/** + * This class can be used to perform a variety of tenant-related operations, including creating, + * updating, and listing tenants. + */ +public final class TenantManager { + + private final FirebaseApp firebaseApp; + private final FirebaseTenantClient tenantClient; + private final Map tenantAwareAuths; + + /** + * Creates a new {@link TenantManager} instance. For internal use only. Use + * {@link FirebaseAuth#getTenantManager()} to obtain an instance for regular use. + * + * @hide + */ + public TenantManager(FirebaseApp firebaseApp) { + this(firebaseApp, new FirebaseTenantClient(firebaseApp)); + } + + @VisibleForTesting + TenantManager(FirebaseApp firebaseApp, FirebaseTenantClient tenantClient) { + this.firebaseApp = checkNotNull(firebaseApp); + this.tenantClient = checkNotNull(tenantClient); + this.tenantAwareAuths = new HashMap<>(); + } + + @VisibleForTesting + void setInterceptor(HttpResponseInterceptor interceptor) { + this.tenantClient.setInterceptor(interceptor); + } + + /** + * Gets the tenant corresponding to the specified tenant ID. + * + * @param tenantId A tenant ID string. + * @return A {@link Tenant} instance. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while retrieving user data. + */ + public Tenant getTenant(@NonNull String tenantId) throws FirebaseAuthException { + return getTenantOp(tenantId).call(); + } + + public synchronized TenantAwareFirebaseAuth getAuthForTenant(@NonNull String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + if (!tenantAwareAuths.containsKey(tenantId)) { + tenantAwareAuths.put(tenantId, TenantAwareFirebaseAuth.fromApp(firebaseApp, tenantId)); + } + return tenantAwareAuths.get(tenantId); + } + + /** + * Similar to {@link #getTenant(String)} but performs the operation asynchronously. + * + * @param tenantId A tenantId string. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} instance + * If an error occurs while retrieving tenant data or if the specified tenant ID does not + * exist, the future throws a {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + */ + public ApiFuture getTenantAsync(@NonNull String tenantId) { + return getTenantOp(tenantId).callAsync(firebaseApp); + } + + private CallableOperation getTenantOp(final String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.getTenant(tenantId); + } + }; + } + + /** + * Gets a page of tenants starting from the specified {@code pageToken}. Page size will be limited + * to 1000 tenants. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @return A {@link ListTenantsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty. + * @throws FirebaseAuthException If an error occurs while retrieving tenant data. + */ + public ListTenantsPage listTenants(@Nullable String pageToken) throws FirebaseAuthException { + return listTenants(pageToken, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + } + + /** + * Gets a page of tenants starting from the specified {@code pageToken}. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @param maxResults Maximum number of tenants to include in the returned page. This may not + * exceed 1000. + * @return A {@link ListTenantsPage} instance. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + * @throws FirebaseAuthException If an error occurs while retrieving tenant data. + */ + public ListTenantsPage listTenants(@Nullable String pageToken, int maxResults) + throws FirebaseAuthException { + return listTenantsOp(pageToken, maxResults).call(); + } + + /** + * Similar to {@link #listTenants(String)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage} + * instance. If an error occurs while retrieving tenant data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty. + */ + public ApiFuture listTenantsAsync(@Nullable String pageToken) { + return listTenantsAsync(pageToken, FirebaseTenantClient.MAX_LIST_TENANTS_RESULTS); + } + + /** + * Similar to {@link #listTenants(String, int)} but performs the operation asynchronously. + * + * @param pageToken A non-empty page token string, or null to retrieve the first page of tenants. + * @param maxResults Maximum number of tenants to include in the returned page. This may not + * exceed 1000. + * @return An {@code ApiFuture} which will complete successfully with a {@link ListTenantsPage} + * instance. If an error occurs while retrieving tenant data, the future throws an exception. + * @throws IllegalArgumentException If the specified page token is empty, or max results value is + * invalid. + */ + public ApiFuture listTenantsAsync(@Nullable String pageToken, int maxResults) { + return listTenantsOp(pageToken, maxResults).callAsync(firebaseApp); + } + + private CallableOperation listTenantsOp( + @Nullable final String pageToken, final int maxResults) { + final TenantSource tenantSource = new DefaultTenantSource(tenantClient); + final PageFactory factory = new PageFactory(tenantSource, maxResults, pageToken); + return new CallableOperation() { + @Override + protected ListTenantsPage execute() throws FirebaseAuthException { + return factory.create(); + } + }; + } + + /** + * Creates a new tenant with the attributes contained in the specified {@link CreateRequest}. + * + * @param request A non-null {@link CreateRequest} instance. + * @return A {@link Tenant} instance corresponding to the newly created tenant. + * @throws NullPointerException if the provided request is null. + * @throws FirebaseAuthException if an error occurs while creating the tenant. + */ + public Tenant createTenant(@NonNull CreateRequest request) throws FirebaseAuthException { + return createTenantOp(request).call(); + } + + /** + * Similar to {@link #createTenant(CreateRequest)} but performs the operation asynchronously. + * + * @param request A non-null {@link CreateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} + * instance corresponding to the newly created tenant. If an error occurs while creating the + * tenant, the future throws a {@link FirebaseAuthException}. + * @throws NullPointerException if the provided request is null. + */ + public ApiFuture createTenantAsync(@NonNull CreateRequest request) { + return createTenantOp(request).callAsync(firebaseApp); + } + + private CallableOperation createTenantOp( + final CreateRequest request) { + checkNotNull(request, "Create request must not be null."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.createTenant(request); + } + }; + } + + + /** + * Updates an existing user account with the attributes contained in the specified {@link + * UpdateRequest}. + * + * @param request A non-null {@link UpdateRequest} instance. + * @return A {@link Tenant} instance corresponding to the updated user account. + * @throws NullPointerException if the provided update request is null. + * @throws FirebaseAuthException if an error occurs while updating the user account. + */ + public Tenant updateTenant(@NonNull UpdateRequest request) throws FirebaseAuthException { + return updateTenantOp(request).call(); + } + + /** + * Similar to {@link #updateTenant(UpdateRequest)} but performs the operation asynchronously. + * + * @param request A non-null {@link UpdateRequest} instance. + * @return An {@code ApiFuture} which will complete successfully with a {@link Tenant} + * instance corresponding to the updated user account. If an error occurs while updating the + * user account, the future throws a {@link FirebaseAuthException}. + */ + public ApiFuture updateTenantAsync(@NonNull UpdateRequest request) { + return updateTenantOp(request).callAsync(firebaseApp); + } + + private CallableOperation updateTenantOp( + final UpdateRequest request) { + checkNotNull(request, "Update request must not be null."); + checkArgument(!request.getProperties().isEmpty(), + "Tenant update must have at least one property set."); + return new CallableOperation() { + @Override + protected Tenant execute() throws FirebaseAuthException { + return tenantClient.updateTenant(request); + } + }; + } + + /** + * Deletes the tenant identified by the specified tenant ID. + * + * @param tenantId A tenant ID string. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + * @throws FirebaseAuthException If an error occurs while deleting the tenant. + */ + public void deleteTenant(@NonNull String tenantId) throws FirebaseAuthException { + deleteTenantOp(tenantId).call(); + } + + /** + * Similar to {@link #deleteTenant(String)} but performs the operation asynchronously. + * + * @param tenantId A tenant ID string. + * @return An {@code ApiFuture} which will complete successfully when the specified tenant account + * has been deleted. If an error occurs while deleting the tenant account, the future throws a + * {@link FirebaseAuthException}. + * @throws IllegalArgumentException If the tenant ID string is null or empty. + */ + public ApiFuture deleteTenantAsync(String tenantId) { + return deleteTenantOp(tenantId).callAsync(firebaseApp); + } + + private CallableOperation deleteTenantOp(final String tenantId) { + checkArgument(!Strings.isNullOrEmpty(tenantId), "Tenant ID must not be null or empty."); + return new CallableOperation() { + @Override + protected Void execute() throws FirebaseAuthException { + tenantClient.deleteTenant(tenantId); + return null; + } + }; + } +} diff --git a/src/main/java/com/google/firebase/cloud/FirestoreClient.java b/src/main/java/com/google/firebase/cloud/FirestoreClient.java index fddf3320d..682c39f07 100644 --- a/src/main/java/com/google/firebase/cloud/FirestoreClient.java +++ b/src/main/java/com/google/firebase/cloud/FirestoreClient.java @@ -11,12 +11,15 @@ import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * {@code FirestoreClient} provides access to Google Cloud Firestore. Use this API to obtain a - * {@code Firestore} + * {@code Firestore} * instance, which provides methods for updating and querying data in Firestore. * *

A Google Cloud project ID is required to access Firestore. FirestoreClient determines the @@ -32,7 +35,7 @@ public class FirestoreClient { private final Firestore firestore; - private FirestoreClient(FirebaseApp app) { + private FirestoreClient(FirebaseApp app, String databaseId) { checkNotNull(app, "FirebaseApp must not be null"); String projectId = ImplFirebaseTrampolines.getProjectId(app); checkArgument(!Strings.isNullOrEmpty(projectId), @@ -47,16 +50,17 @@ private FirestoreClient(FirebaseApp app) { .setCredentialsProvider( FixedCredentialsProvider.create(ImplFirebaseTrampolines.getCredentials(app))) .setProjectId(projectId) + .setDatabaseId(databaseId) .build() .getService(); } /** - * Returns the Firestore instance associated with the default Firebase app. Returns the same - * instance for all invocations. The Firestore instance and all references obtained from it + * Returns the default Firestore instance associated with the default Firebase app. Returns the + * same instance for all invocations. The Firestore instance and all references obtained from it * becomes unusable, once the default app is deleted. * - * @return A non-null {@code Firestore} + * @return A non-null {@code Firestore} * instance. */ @NonNull @@ -65,42 +69,97 @@ public static Firestore getFirestore() { } /** - * Returns the Firestore instance associated with the specified Firebase app. For a given app, - * always returns the same instance. The Firestore instance and all references obtained from it - * becomes unusable, once the specified app is deleted. + * Returns the default Firestore instance associated with the specified Firebase app. For a given + * app, invocation always returns the same instance. The Firestore instance and all references + * obtained from it becomes unusable, once the specified app is deleted. * * @param app A non-null {@link FirebaseApp}. - * @return A non-null {@code Firestore} + * @return A non-null {@code Firestore} * instance. */ @NonNull public static Firestore getFirestore(FirebaseApp app) { - return getInstance(app).firestore; + final FirestoreOptions firestoreOptions = ImplFirebaseTrampolines.getFirestoreOptions(app); + return getFirestore(app, firestoreOptions == null ? null : firestoreOptions.getDatabaseId()); + } + + /** + * Returns the Firestore instance associated with the specified Firebase app. Returns the same + * instance for all invocations given the same app and database parameter. The Firestore instance + * and all references obtained from it becomes unusable, once the specified app is deleted. + * + * @param app A non-null {@link FirebaseApp}. + * @param database - The name of database. + * @return A non-null {@code Firestore} + * instance. + */ + @NonNull + public static Firestore getFirestore(FirebaseApp app, String database) { + return getInstance(app, database).firestore; + } + + /** + * Returns the Firestore instance associated with the default Firebase app. Returns the same + * instance for all invocations given the same database parameter. The Firestore instance and all + * references obtained from it becomes unusable, once the default app is deleted. + * + * @param database - The name of database. + * @return A non-null {@code Firestore} + * instance. + */ + @NonNull + public static Firestore getFirestore(String database) { + return getFirestore(FirebaseApp.getInstance(), database); } - private static synchronized FirestoreClient getInstance(FirebaseApp app) { + private static synchronized FirestoreClient getInstance(FirebaseApp app, String database) { FirestoreClientService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, FirestoreClientService.class); if (service == null) { service = ImplFirebaseTrampolines.addService(app, new FirestoreClientService(app)); } - return service.getInstance(); + return service.getInstance().get(database); } private static final String SERVICE_ID = FirestoreClient.class.getName(); - private static class FirestoreClientService extends FirebaseService { + private static class FirestoreClientService extends FirebaseService { FirestoreClientService(FirebaseApp app) { - super(SERVICE_ID, new FirestoreClient(app)); + super(SERVICE_ID, new FirestoreInstances(app)); } @Override public void destroy() { - try { - instance.firestore.close(); - } catch (Exception e) { - logger.warn("Error while closing the Firestore instance", e); + instance.destroy(); + } + } + + private static class FirestoreInstances { + + private final FirebaseApp app; + + private final Map clients = + Collections.synchronizedMap(new HashMap<>()); + + private FirestoreInstances(FirebaseApp app) { + this.app = app; + } + + FirestoreClient get(String databaseId) { + return clients.computeIfAbsent(databaseId, id -> new FirestoreClient(app, id)); + } + + void destroy() { + synchronized (clients) { + for (FirestoreClient client : clients.values()) { + try { + client.firestore.close(); + } catch (Exception e) { + logger.warn("Error while closing the Firestore instance", e); + } + } + clients.clear(); } } } diff --git a/src/main/java/com/google/firebase/cloud/StorageClient.java b/src/main/java/com/google/firebase/cloud/StorageClient.java index d6a1567f4..172279990 100644 --- a/src/main/java/com/google/firebase/cloud/StorageClient.java +++ b/src/main/java/com/google/firebase/cloud/StorageClient.java @@ -71,7 +71,7 @@ public static synchronized StorageClient getInstance(FirebaseApp app) { * configured via {@link com.google.firebase.FirebaseOptions} when initializing the app. If * no bucket was configured via options, this method throws an exception. * - * @return a cloud storage {@code Bucket} + * @return a cloud storage {@code Bucket} * instance. * @throws IllegalArgumentException If no bucket is configured via FirebaseOptions, * or if the bucket does not exist. @@ -84,7 +84,7 @@ public Bucket bucket() { * Returns a cloud storage Bucket instance for the specified bucket name. * * @param name a non-null, non-empty bucket name. - * @return a cloud storage {@code Bucket} + * @return a cloud storage {@code Bucket} * instance. * @throws IllegalArgumentException If the bucket name is null, empty, or if the specified * bucket does not exist. @@ -106,13 +106,5 @@ private static class StorageClientService extends FirebaseService StorageClientService(StorageClient client) { super(SERVICE_ID, client); } - - @Override - public void destroy() { - // NOTE: We don't explicitly tear down anything here, but public methods of StorageClient - // will now fail because calls to getOptions() and getToken() will hit FirebaseApp, - // which will throw once the app is deleted. - } } - } diff --git a/src/main/java/com/google/firebase/database/FirebaseDatabase.java b/src/main/java/com/google/firebase/database/FirebaseDatabase.java index 0a79932db..e59cd38fa 100644 --- a/src/main/java/com/google/firebase/database/FirebaseDatabase.java +++ b/src/main/java/com/google/firebase/database/FirebaseDatabase.java @@ -35,6 +35,7 @@ import com.google.firebase.database.utilities.ParsedUrl; import com.google.firebase.database.utilities.Utilities; import com.google.firebase.database.utilities.Validation; +import com.google.firebase.internal.EmulatorCredentials; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.SdkUtils; @@ -173,7 +174,7 @@ static FirebaseDatabase createForTests( return db; } - /** + /** * @return The version for this build of the Firebase Database client */ public static String getSdkVersion() { @@ -358,6 +359,10 @@ DatabaseConfig getConfig() { return this.config; } + /** + * Tears down the WebSocket connections and background threads started by this {@code + * FirebaseDatabase} instance thus disconnecting from the remote database. + */ void destroy() { synchronized (lock) { if (destroyed.get()) { @@ -402,26 +407,4 @@ public void destroy() { instance.destroy(); } } - - private static class EmulatorCredentials extends GoogleCredentials { - - EmulatorCredentials() { - super(newToken()); - } - - private static AccessToken newToken() { - return new AccessToken("owner", - new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))); - } - - @Override - public AccessToken refreshAccessToken() { - return newToken(); - } - - @Override - public Map> getRequestMetadata() throws IOException { - return ImmutableMap.of(); - } - } } diff --git a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java index a1cb79688..9b862ba86 100644 --- a/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java +++ b/src/main/java/com/google/firebase/database/core/JvmAuthTokenProvider.java @@ -112,7 +112,7 @@ private static class TokenChangeListenerWrapper implements CredentialsChangedLis } @Override - public void onChanged(OAuth2Credentials credentials) throws IOException { + public void onChanged(OAuth2Credentials credentials) { // When this event fires, it is guaranteed that credentials.getAccessToken() will return a // valid OAuth2 token. final AccessToken accessToken = credentials.getAccessToken(); diff --git a/src/main/java/com/google/firebase/database/util/EmulatorHelper.java b/src/main/java/com/google/firebase/database/util/EmulatorHelper.java index c4ef2ff25..11c358fc6 100644 --- a/src/main/java/com/google/firebase/database/util/EmulatorHelper.java +++ b/src/main/java/com/google/firebase/database/util/EmulatorHelper.java @@ -22,6 +22,7 @@ import com.google.firebase.database.core.RepoInfo; import com.google.firebase.database.utilities.ParsedUrl; import com.google.firebase.database.utilities.Utilities; +import com.google.firebase.internal.FirebaseProcessEnvironment; public final class EmulatorHelper { @@ -33,7 +34,7 @@ private EmulatorHelper() { "FIREBASE_DATABASE_EMULATOR_HOST"; public static String getEmulatorHostFromEnv() { - return System.getenv(FIREBASE_RTDB_EMULATOR_HOST_ENV_VAR); + return FirebaseProcessEnvironment.getenv(FIREBASE_RTDB_EMULATOR_HOST_ENV_VAR); } @VisibleForTesting diff --git a/src/main/java/com/google/firebase/database/utilities/Utilities.java b/src/main/java/com/google/firebase/database/utilities/Utilities.java index 5a3c64822..6181071ec 100644 --- a/src/main/java/com/google/firebase/database/utilities/Utilities.java +++ b/src/main/java/com/google/firebase/database/utilities/Utilities.java @@ -19,7 +19,6 @@ import com.google.api.core.ApiFuture; import com.google.api.core.SettableApiFuture; import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Charsets; import com.google.common.base.Strings; import com.google.common.io.BaseEncoding; import com.google.common.net.UrlEscapers; @@ -32,6 +31,7 @@ import java.net.URI; import java.net.URLDecoder; import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.util.ArrayList; @@ -108,8 +108,8 @@ static Map getQueryParamsMap(String queryString) for (String paramPair : paramPairs) { String[] pairParts = paramPair.split("="); // both the first and second part will be encoded now, we must decode them - String decodedKey = URLDecoder.decode(pairParts[0], Charsets.UTF_8.name()); - String decodedValue = URLDecoder.decode(pairParts[1], Charsets.UTF_8.name()); + String decodedKey = URLDecoder.decode(pairParts[0], StandardCharsets.UTF_8.name()); + String decodedValue = URLDecoder.decode(pairParts[1], StandardCharsets.UTF_8.name()); String runningValue = paramsMap.get(decodedKey); if (Strings.isNullOrEmpty(runningValue)) { runningValue = decodedValue; diff --git a/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java index 7ac527778..47026e6f1 100644 --- a/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java +++ b/src/main/java/com/google/firebase/iid/FirebaseInstanceId.java @@ -17,29 +17,27 @@ package com.google.firebase.iid; import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.HttpTransport; -import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; import com.google.api.core.ApiFuture; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; -import com.google.common.io.ByteStreams; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.internal.AbstractHttpErrorHandler; +import com.google.firebase.internal.ApiClientUtils; import com.google.firebase.internal.CallableOperation; -import com.google.firebase.internal.FirebaseRequestInitializer; +import com.google.firebase.internal.ErrorHandlingHttpClient; import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.NonNull; -import java.io.IOException; import java.util.Map; /** @@ -64,22 +62,30 @@ public class FirebaseInstanceId { .build(); private final FirebaseApp app; - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; private final String projectId; - - private HttpResponseInterceptor interceptor; + private final ErrorHandlingHttpClient httpClient; private FirebaseInstanceId(FirebaseApp app) { - HttpTransport httpTransport = app.getOptions().getHttpTransport(); - this.app = app; - this.requestFactory = httpTransport.createRequestFactory(new FirebaseRequestInitializer(app)); - this.jsonFactory = app.getOptions().getJsonFactory(); - this.projectId = ImplFirebaseTrampolines.getProjectId(app); + this(app, null); + } + + @VisibleForTesting + FirebaseInstanceId(FirebaseApp app, @Nullable HttpRequestFactory requestFactory) { + this.app = checkNotNull(app, "app must not be null"); + String projectId = ImplFirebaseTrampolines.getProjectId(app); checkArgument(!Strings.isNullOrEmpty(projectId), "Project ID is required to access instance ID service. Use a service account credential or " + "set the project ID explicitly via FirebaseOptions. Alternatively you can also " + "set the project ID via the GOOGLE_CLOUD_PROJECT environment variable."); + this.projectId = projectId; + if (requestFactory == null) { + requestFactory = ApiClientUtils.newAuthorizedRequestFactory(app); + } + + this.httpClient = new ErrorHandlingHttpClient<>( + requestFactory, + app.getOptions().getJsonFactory(), + new InstanceIdErrorHandler()); } /** @@ -107,7 +113,7 @@ public static synchronized FirebaseInstanceId getInstance(FirebaseApp app) { @VisibleForTesting void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } /** @@ -146,42 +152,45 @@ private CallableOperation deleteInstanceIdOp( protected Void execute() throws FirebaseInstanceIdException { String url = String.format( "%s/project/%s/instanceId/%s", IID_SERVICE_URL, projectId, instanceId); - HttpResponse response = null; - try { - HttpRequest request = requestFactory.buildDeleteRequest(new GenericUrl(url)); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(interceptor); - response = request.execute(); - ByteStreams.exhaust(response.getContent()); - } catch (Exception e) { - handleError(instanceId, e); - } finally { - disconnectQuietly(response); - } + HttpRequestInfo request = HttpRequestInfo.buildDeleteRequest(url); + httpClient.send(request); return null; } }; } - private static void disconnectQuietly(HttpResponse response) { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // ignored + private static class InstanceIdErrorHandler + extends AbstractHttpErrorHandler { + + @Override + protected FirebaseInstanceIdException createException(FirebaseException base) { + String message = base.getMessage(); + String customMessage = getCustomMessage(base); + if (!Strings.isNullOrEmpty(customMessage)) { + message = customMessage; } + + return new FirebaseInstanceIdException(base, message); } - } - private void handleError(String instanceId, Exception e) throws FirebaseInstanceIdException { - String msg = "Error while invoking instance ID service."; - if (e instanceof HttpResponseException) { - int statusCode = ((HttpResponseException) e).getStatusCode(); - if (ERROR_CODES.containsKey(statusCode)) { - msg = String.format("Instance ID \"%s\": %s", instanceId, ERROR_CODES.get(statusCode)); + private String getCustomMessage(FirebaseException base) { + IncomingHttpResponse response = base.getHttpResponse(); + if (response != null) { + String instanceId = extractInstanceId(response); + String description = ERROR_CODES.get(response.getStatusCode()); + if (description != null) { + return String.format("Instance ID \"%s\": %s", instanceId, description); + } } + + return null; + } + + private String extractInstanceId(IncomingHttpResponse response) { + String url = response.getRequest().getUrl(); + int index = url.lastIndexOf('/'); + return url.substring(index + 1); } - throw new FirebaseInstanceIdException(msg, e); } private static final String SERVICE_ID = FirebaseInstanceId.class.getName(); @@ -191,12 +200,5 @@ private static class FirebaseInstanceIdService extends FirebaseService + implements HttpErrorHandler { + + private static final Map HTTP_ERROR_CODES = + ImmutableMap.builder() + .put(HttpStatusCodes.STATUS_CODE_BAD_REQUEST, ErrorCode.INVALID_ARGUMENT) + .put(HttpStatusCodes.STATUS_CODE_UNAUTHORIZED, ErrorCode.UNAUTHENTICATED) + .put(HttpStatusCodes.STATUS_CODE_FORBIDDEN, ErrorCode.PERMISSION_DENIED) + .put(HttpStatusCodes.STATUS_CODE_NOT_FOUND, ErrorCode.NOT_FOUND) + .put(HttpStatusCodes.STATUS_CODE_CONFLICT, ErrorCode.CONFLICT) + .put(429, ErrorCode.RESOURCE_EXHAUSTED) + .put(HttpStatusCodes.STATUS_CODE_SERVER_ERROR, ErrorCode.INTERNAL) + .put(HttpStatusCodes.STATUS_CODE_SERVICE_UNAVAILABLE, ErrorCode.UNAVAILABLE) + .build(); + + @Override + public final T handleHttpResponseException( + HttpResponseException e, IncomingHttpResponse response) { + FirebaseException base = this.httpResponseErrorToBaseException(e, response); + return this.createException(base); + } + + @Override + public final T handleIOException(IOException e) { + FirebaseException base = this.ioErrorToBaseException(e); + return this.createException(base); + } + + @Override + public final T handleParseException(IOException e, IncomingHttpResponse response) { + FirebaseException base = this.parseErrorToBaseException(e, response); + return this.createException(base); + } + + /** + * Creates a FirebaseException from the given HTTP response error. Error code is determined from + * the HTTP status code of the response. Error message includes both the status code and full + * response payload to aid in debugging. + * + * @param e HTTP response exception. + * @param response Incoming HTTP response. + * @return A FirebaseException instance. + */ + protected FirebaseException httpResponseErrorToBaseException( + HttpResponseException e, IncomingHttpResponse response) { + ErrorCode code = HTTP_ERROR_CODES.get(e.getStatusCode()); + if (code == null) { + code = ErrorCode.UNKNOWN; + } + + String message = String.format("Unexpected HTTP response with status: %d\n%s", + e.getStatusCode(), e.getContent()); + return new FirebaseException(code, message, e, response); + } + + /** + * Creates a FirebaseException from the given IOException. If IOException resulted from a socket + * timeout, sets the error code DEADLINE_EXCEEDED. If the IOException resulted from a network + * outage or other connectivity issue, sets the error code to UNAVAILABLE. In all other cases sets + * the error code to UNKNOWN. + * + * @param e IOException to create the new exception from. + * @return A FirebaseException instance. + */ + protected FirebaseException ioErrorToBaseException(IOException e) { + ErrorCode code = ErrorCode.UNKNOWN; + String message = "Unknown error while making a remote service call" ; + if (isInstance(e, SocketTimeoutException.class)) { + code = ErrorCode.DEADLINE_EXCEEDED; + message = "Timed out while making an API call"; + } + + if (isInstance(e, UnknownHostException.class) || isInstance(e, NoRouteToHostException.class)) { + code = ErrorCode.UNAVAILABLE; + message = "Failed to establish a connection"; + } + + return new FirebaseException(code, message + ": " + e.getMessage(), e); + } + + protected FirebaseException parseErrorToBaseException( + IOException e, IncomingHttpResponse response) { + return new FirebaseException( + ErrorCode.UNKNOWN, "Error while parsing HTTP response: " + e.getMessage(), e, response); + } + + /** + * Converts the given base FirebaseException to a more specific exception type. The base exception + * is guaranteed to have an error code, a message and a cause. But the HTTP response is only set + * if the exception occurred after receiving a response from a remote server. + * + * @param base A FirebaseException. + * @return A more specific exception created from the base. + */ + protected abstract T createException(FirebaseException base); + + /** + * Checks if the given exception stack t contains an instance of type. + */ + private boolean isInstance(IOException t, Class type) { + Throwable current = t; + Set chain = new HashSet<>(); + while (current != null) { + if (!chain.add(current)) { + break; + } + + if (type.isInstance(current)) { + return true; + } + + current = current.getCause(); + } + + return false; + } +} diff --git a/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java new file mode 100644 index 000000000..b0909e4f7 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/AbstractPlatformErrorHandler.java @@ -0,0 +1,98 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.util.Key; +import com.google.common.base.Strings; +import com.google.firebase.ErrorCode; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; + +/** + * An abstract HttpErrorHandler that handles Google Cloud error responses. Format of these + * error responses are defined at https://cloud.google.com/apis/design/errors. + */ +public abstract class AbstractPlatformErrorHandler + extends AbstractHttpErrorHandler { + + protected final JsonFactory jsonFactory; + + public AbstractPlatformErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null"); + } + + @Override + protected final FirebaseException httpResponseErrorToBaseException( + HttpResponseException e, IncomingHttpResponse response) { + FirebaseException base = super.httpResponseErrorToBaseException(e, response); + PlatformErrorResponse parsedError = this.parseErrorResponse(e.getContent()); + + ErrorCode code = base.getErrorCode(); + String status = parsedError.getStatus(); + if (!Strings.isNullOrEmpty(status)) { + code = Enum.valueOf(ErrorCode.class, parsedError.getStatus()); + } + + String message = parsedError.getMessage(); + if (Strings.isNullOrEmpty(message)) { + message = base.getMessage(); + } + + return new FirebaseException(code, message, e, response); + } + + private PlatformErrorResponse parseErrorResponse(String content) { + PlatformErrorResponse response = new PlatformErrorResponse(); + if (!Strings.isNullOrEmpty(content)) { + try { + jsonFactory.createJsonParser(content).parseAndClose(response); + } catch (IOException e) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. Return an empty return value, and + // let the base class logic come into play. + } + } + + return response; + } + + public static class PlatformErrorResponse { + @Key("error") + private PlatformError error; + + String getStatus() { + return error != null ? error.status : null; + } + + String getMessage() { + return error != null ? error.message : null; + } + } + + public static class PlatformError { + @Key("status") + private String status; + + @Key("message") + private String message; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java new file mode 100644 index 000000000..9bdf208c7 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2AsyncEntityProducer.java @@ -0,0 +1,150 @@ +/* + * Copyright 2024 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. + */ + +package com.google.firebase.internal; + +import com.google.api.client.util.StreamingContent; +import com.google.common.annotations.VisibleForTesting; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.nio.AsyncEntityProducer; +import org.apache.hc.core5.http.nio.DataStreamChannel; + +public class ApacheHttp2AsyncEntityProducer implements AsyncEntityProducer { + private ByteBuffer bytebuf; + private ByteArrayOutputStream baos; + private final StreamingContent content; + private final ContentType contentType; + private final long contentLength; + private final String contentEncoding; + private final CompletableFuture writeFuture; + private final AtomicReference exception; + + public ApacheHttp2AsyncEntityProducer(StreamingContent content, ContentType contentType, + String contentEncoding, long contentLength, CompletableFuture writeFuture) { + this.content = content; + this.contentType = contentType; + this.contentEncoding = contentEncoding; + this.contentLength = contentLength; + this.writeFuture = writeFuture; + this.bytebuf = null; + + this.baos = new ByteArrayOutputStream((int) (contentLength < 0 ? 0 : contentLength)); + this.exception = new AtomicReference<>(); + } + + public ApacheHttp2AsyncEntityProducer(ApacheHttp2Request request, + CompletableFuture writeFuture) { + this( + request.getStreamingContent(), + ContentType.parse(request.getContentType()), + request.getContentEncoding(), + request.getContentLength(), + writeFuture); + } + + @Override + public boolean isRepeatable() { + return true; + } + + @Override + public String getContentType() { + return contentType != null ? contentType.toString() : null; + } + + @Override + public long getContentLength() { + return contentLength; + } + + @Override + public int available() { + return Integer.MAX_VALUE; + } + + @Override + public String getContentEncoding() { + return contentEncoding; + } + + @Override + public boolean isChunked() { + return contentLength == -1; + } + + @Override + public Set getTrailerNames() { + return null; + } + + @Override + public void produce(DataStreamChannel channel) throws IOException { + if (bytebuf == null) { + if (content != null) { + try { + content.writeTo(baos); + } catch (IOException e) { + failed(e); + throw e; + } + } + + this.bytebuf = ByteBuffer.wrap(baos.toByteArray()); + } + + if (bytebuf.hasRemaining()) { + channel.write(bytebuf); + } + + if (!bytebuf.hasRemaining()) { + channel.endStream(); + writeFuture.complete(null); + releaseResources(); + } + } + + @Override + public void failed(Exception cause) { + if (exception.compareAndSet(null, cause)) { + releaseResources(); + writeFuture.completeExceptionally(cause); + } + } + + public final Exception getException() { + return exception.get(); + } + + @Override + public void releaseResources() { + if (bytebuf != null) { + bytebuf.clear(); + } + } + + @VisibleForTesting + ByteBuffer getBytebuf() { + return bytebuf; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java new file mode 100644 index 000000000..56c2c9034 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Request.java @@ -0,0 +1,147 @@ +/* + * Copyright 2024 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. + */ + +package com.google.firebase.internal; + +import com.google.api.client.http.LowLevelHttpRequest; +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.common.annotations.VisibleForTesting; + +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.apache.hc.client5.http.ConnectTimeoutException; +import org.apache.hc.client5.http.HttpHostConnectException; +import org.apache.hc.client5.http.async.methods.SimpleHttpRequest; +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.async.methods.SimpleResponseConsumer; +import org.apache.hc.client5.http.config.RequestConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.http.nio.support.BasicRequestProducer; +import org.apache.hc.core5.http2.H2StreamResetException; +import org.apache.hc.core5.util.Timeout; + +final class ApacheHttp2Request extends LowLevelHttpRequest { + private final CloseableHttpAsyncClient httpAsyncClient; + private final SimpleRequestBuilder requestBuilder; + private SimpleHttpRequest request; + private final RequestConfig.Builder requestConfig; + private int writeTimeout; + private ApacheHttp2AsyncEntityProducer entityProducer; + + ApacheHttp2Request( + CloseableHttpAsyncClient httpAsyncClient, SimpleRequestBuilder requestBuilder) { + this.httpAsyncClient = httpAsyncClient; + this.requestBuilder = requestBuilder; + this.writeTimeout = 0; + + this.requestConfig = RequestConfig.custom() + .setRedirectsEnabled(false); + } + + @Override + public void addHeader(String name, String value) { + requestBuilder.addHeader(name, value); + } + + @Override + public void setTimeout(int connectionTimeout, int readTimeout) throws IOException { + requestConfig + .setConnectTimeout(Timeout.ofMilliseconds(connectionTimeout)) + .setResponseTimeout(Timeout.ofMilliseconds(readTimeout)); + } + + @Override + public void setWriteTimeout(int writeTimeout) throws IOException { + this.writeTimeout = writeTimeout; + } + + @Override + public LowLevelHttpResponse execute() throws IOException { + // Set request configs + requestBuilder.setRequestConfig(requestConfig.build()); + + // Build request + request = requestBuilder.build(); + + // Make Producer + CompletableFuture writeFuture = new CompletableFuture<>(); + entityProducer = new ApacheHttp2AsyncEntityProducer(this, writeFuture); + + // Execute + final Future responseFuture = httpAsyncClient.execute( + new BasicRequestProducer(request, entityProducer), + SimpleResponseConsumer.create(), + new FutureCallback() { + @Override + public void completed(final SimpleHttpResponse response) { + } + + @Override + public void failed(final Exception exception) { + } + + @Override + public void cancelled() { + } + }); + + // Wait for write + try { + if (writeTimeout != 0) { + writeFuture.get(writeTimeout, TimeUnit.MILLISECONDS); + } + } catch (TimeoutException e) { + throw new IOException("Write Timeout", e.getCause()); + } catch (Exception e) { + throw new IOException("Exception in write", e.getCause()); + } + + // Wait for response + try { + final SimpleHttpResponse response = responseFuture.get(); + return new ApacheHttp2Response(response); + } catch (ExecutionException e) { + if (e.getCause() instanceof ConnectTimeoutException + || e.getCause() instanceof SocketTimeoutException) { + throw new IOException("Connection Timeout", e.getCause()); + } else if (e.getCause() instanceof HttpHostConnectException) { + throw new IOException("Connection exception in request", e.getCause()); + } else if (e.getCause() instanceof H2StreamResetException) { + throw new IOException("Stream exception in request", e.getCause()); + } else { + throw new IOException("Unknown exception in request", e); + } + } catch (InterruptedException e) { + throw new IOException("Request Interrupted", e); + } catch (CancellationException e) { + throw new IOException("Request Cancelled", e); + } + } + + @VisibleForTesting + ApacheHttp2AsyncEntityProducer getEntityProducer() { + return entityProducer; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java new file mode 100644 index 000000000..4c05b0e03 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Response.java @@ -0,0 +1,100 @@ +/* + * Copyright 2024 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. + */ + +package com.google.firebase.internal; + +import com.google.api.client.http.LowLevelHttpResponse; +import com.google.common.annotations.VisibleForTesting; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; + +import org.apache.hc.client5.http.async.methods.SimpleHttpResponse; +import org.apache.hc.core5.http.ContentType; +import org.apache.hc.core5.http.Header; + +public class ApacheHttp2Response extends LowLevelHttpResponse { + private final SimpleHttpResponse response; + private final Header[] allHeaders; + + ApacheHttp2Response(SimpleHttpResponse response) { + this.response = response; + allHeaders = response.getHeaders(); + } + + @Override + public int getStatusCode() { + return response.getCode(); + } + + @Override + public InputStream getContent() throws IOException { + return new ByteArrayInputStream(response.getBodyBytes()); + } + + @Override + public String getContentEncoding() { + Header contentEncodingHeader = response.getFirstHeader("Content-Encoding"); + return contentEncodingHeader == null ? null : contentEncodingHeader.getValue(); + } + + @Override + public long getContentLength() { + String bodyText = response.getBodyText(); + return bodyText == null ? 0 : bodyText.length(); + } + + @Override + public String getContentType() { + ContentType contentType = response.getContentType(); + return contentType == null ? null : contentType.toString(); + } + + @Override + public String getReasonPhrase() { + return response.getReasonPhrase(); + } + + @Override + public String getStatusLine() { + return response.toString(); + } + + public String getHeaderValue(String name) { + return response.getLastHeader(name).getValue(); + } + + @Override + public String getHeaderValue(int index) { + return allHeaders[index].getValue(); + } + + @Override + public int getHeaderCount() { + return allHeaders.length; + } + + @Override + public String getHeaderName(int index) { + return allHeaders[index].getName(); + } + + @VisibleForTesting + public SimpleHttpResponse getResponse() { + return response; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java b/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java new file mode 100644 index 000000000..9c0e413fb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApacheHttp2Transport.java @@ -0,0 +1,125 @@ +/* + * Copyright 2024 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. + */ + +package com.google.firebase.internal; + +import com.google.api.client.http.HttpTransport; + +import java.io.IOException; +import java.net.ProxySelector; +import java.util.concurrent.TimeUnit; + +import org.apache.hc.client5.http.async.HttpAsyncClient; +import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder; +import org.apache.hc.client5.http.config.ConnectionConfig; +import org.apache.hc.client5.http.config.TlsConfig; +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.client5.http.impl.async.HttpAsyncClientBuilder; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager; +import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder; +import org.apache.hc.client5.http.impl.routing.SystemDefaultRoutePlanner; +import org.apache.hc.client5.http.ssl.ClientTlsStrategyBuilder; +import org.apache.hc.core5.http.config.Http1Config; +import org.apache.hc.core5.http2.HttpVersionPolicy; +import org.apache.hc.core5.http2.config.H2Config; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.ssl.SSLContexts; + +/** + * HTTP/2 enabled async transport based on the Apache HTTP Client library + */ +public final class ApacheHttp2Transport extends HttpTransport { + + private final CloseableHttpAsyncClient httpAsyncClient; + private final boolean isMtls; + + public ApacheHttp2Transport() { + this(newDefaultHttpAsyncClient(), false); + } + + public ApacheHttp2Transport(CloseableHttpAsyncClient httpAsyncClient) { + this(httpAsyncClient, false); + } + + public ApacheHttp2Transport(CloseableHttpAsyncClient httpAsyncClient, boolean isMtls) { + this.httpAsyncClient = httpAsyncClient; + this.isMtls = isMtls; + + httpAsyncClient.start(); + } + + public static CloseableHttpAsyncClient newDefaultHttpAsyncClient() { + return defaultHttpAsyncClientBuilder().build(); + } + + public static HttpAsyncClientBuilder defaultHttpAsyncClientBuilder() { + PoolingAsyncClientConnectionManager connectionManager = + PoolingAsyncClientConnectionManagerBuilder.create() + // Set Max total connections to match google api client limits + // https://github.com/googleapis/google-http-java-client/blob/f9d4e15bd3c784b1fd3b0f3468000a91c6f79715/google-http-client-apache-v5/src/main/java/com/google/api/client/http/apache/v5/Apache5HttpTransport.java#L151 + .setMaxConnTotal(200) + // Set max connections per route to match the concurrent stream limit of the FCM backend. + .setMaxConnPerRoute(100) + .setDefaultConnectionConfig( + ConnectionConfig.custom().setTimeToLive(-1, TimeUnit.MILLISECONDS).build()) + .setDefaultTlsConfig( + TlsConfig.custom().setVersionPolicy(HttpVersionPolicy.NEGOTIATE).build()) + .setTlsStrategy(ClientTlsStrategyBuilder.create() + .setSslContext(SSLContexts.createSystemDefault()) + .build()) + .build(); + + return HttpAsyncClientBuilder.create() + // Set maxConcurrentStreams to 100 to match the concurrent stream limit of the FCM backend. + .setH2Config(H2Config.custom().setMaxConcurrentStreams(100).build()) + .setHttp1Config(Http1Config.DEFAULT) + .setConnectionManager(connectionManager) + .setRoutePlanner(new SystemDefaultRoutePlanner(ProxySelector.getDefault())) + .disableRedirectHandling() + .disableAutomaticRetries(); + } + + @Override + public boolean supportsMethod(String method) { + return true; + } + + @Override + protected ApacheHttp2Request buildRequest(String method, String url) { + SimpleRequestBuilder requestBuilder = SimpleRequestBuilder.create(method).setUri(url); + return new ApacheHttp2Request(httpAsyncClient, requestBuilder); + } + + /** + * Gracefully shuts down the connection manager and releases allocated resources. This closes all + * connections, whether they are currently used or not. + */ + @Override + public void shutdown() throws IOException { + httpAsyncClient.close(CloseMode.GRACEFUL); + } + + /** Returns the Apache HTTP client. */ + public HttpAsyncClient getHttpClient() { + return httpAsyncClient; + } + + /** Returns if the underlying HTTP client is mTLS. */ + @Override + public boolean isMtls() { + return isMtls; + } +} diff --git a/src/main/java/com/google/firebase/internal/ApiClientUtils.java b/src/main/java/com/google/firebase/internal/ApiClientUtils.java index 7506ff8ee..9d501fae3 100644 --- a/src/main/java/com/google/firebase/internal/ApiClientUtils.java +++ b/src/main/java/com/google/firebase/internal/ApiClientUtils.java @@ -16,9 +16,12 @@ package com.google.firebase.internal; +import com.google.api.client.googleapis.util.Utils; import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponse; import com.google.api.client.http.HttpTransport; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.jackson2.JacksonFactory; import com.google.common.collect.ImmutableList; import com.google.firebase.FirebaseApp; @@ -29,12 +32,14 @@ */ public class ApiClientUtils { - private static final RetryConfig DEFAULT_RETRY_CONFIG = RetryConfig.builder() + static final RetryConfig DEFAULT_RETRY_CONFIG = RetryConfig.builder() .setMaxRetries(4) - .setRetryStatusCodes(ImmutableList.of(500, 503)) + .setRetryStatusCodes(ImmutableList.of(503)) .setMaxIntervalMillis(60 * 1000) .build(); + private ApiClientUtils() { } + /** * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and * automatic retries. @@ -43,9 +48,21 @@ public class ApiClientUtils { * @return A new {@code HttpRequestFactory} instance. */ public static HttpRequestFactory newAuthorizedRequestFactory(FirebaseApp app) { + return newAuthorizedRequestFactory(app, DEFAULT_RETRY_CONFIG); + } + + /** + * Creates a new {@code HttpRequestFactory} which provides authorization (OAuth2), timeouts and + * automatic retries. + * + * @param app {@link FirebaseApp} from which to obtain authorization credentials. + * @param retryConfig {@link RetryConfig} instance or null to disable retries. + * @return A new {@code HttpRequestFactory} instance. + */ + public static HttpRequestFactory newAuthorizedRequestFactory( + FirebaseApp app, @Nullable RetryConfig retryConfig) { HttpTransport transport = app.getOptions().getHttpTransport(); - return transport.createRequestFactory( - new FirebaseRequestInitializer(app, DEFAULT_RETRY_CONFIG)); + return transport.createRequestFactory(new FirebaseRequestInitializer(app, retryConfig)); } public static HttpRequestFactory newUnauthorizedRequestFactory(FirebaseApp app) { @@ -62,4 +79,19 @@ public static void disconnectQuietly(HttpResponse response) { } } } + + public static JsonFactory getDefaultJsonFactory() { + // Force using the Jackson2 parser for this project for now. Eventually we should switch + // to Gson, but there are some issues that's preventing this migration at the moment. + // See https://github.com/googleapis/google-api-java-client/issues/1779 for details. + return JacksonFactory.getDefaultInstance(); + } + + public static HttpTransport getDefaultTransport() { + return TransportInstanceHolder.INSTANCE; + } + + private static class TransportInstanceHolder { + static final HttpTransport INSTANCE = new ApacheHttp2Transport(); + } } diff --git a/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java b/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java new file mode 100644 index 000000000..93672bbe9 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ApplicationDefaultCredentialsProvider.java @@ -0,0 +1,46 @@ +/* + * 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. + */ + +package com.google.firebase.internal; + +import com.google.auth.oauth2.GoogleCredentials; +import java.io.IOException; + +/** + * Provides a hook to override application default credentials (ADC) lookup for tests. ADC has + * a dependency on environment variables, and Java famously doesn't support environment variable + * manipulation at runtime. With this class, the test cases that require ADC has a way to register + * their own mock credentials as ADC. + * + *

Once we are able to upgrade to Mockito 3.x (requires Java 8+), we can drop this class + * altogether, and use Mockito tools to mock the behavior of the GoogleCredentials static methods. + */ +public class ApplicationDefaultCredentialsProvider { + + private static GoogleCredentials cachedCredentials; + + public static GoogleCredentials getApplicationDefault() throws IOException { + if (cachedCredentials != null) { + return cachedCredentials; + } + + return GoogleCredentials.getApplicationDefault(); + } + + public static void setApplicationDefault(GoogleCredentials credentials) { + cachedCredentials = credentials; + } +} diff --git a/src/main/java/com/google/firebase/internal/EmulatorCredentials.java b/src/main/java/com/google/firebase/internal/EmulatorCredentials.java new file mode 100644 index 000000000..b13e971d0 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/EmulatorCredentials.java @@ -0,0 +1,52 @@ +/* + * Copyright 2022 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. + */ + +package com.google.firebase.internal; + +import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.GoogleCredentials; +import com.google.common.collect.ImmutableMap; + +import java.io.IOException; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * A Mock Credentials implementation that can be used with the Emulator Suite + */ +public final class EmulatorCredentials extends GoogleCredentials { + + public EmulatorCredentials() { + super(newToken()); + } + + private static AccessToken newToken() { + return new AccessToken("owner", + new Date(System.currentTimeMillis() + TimeUnit.HOURS.toMillis(1))); + } + + @Override + public AccessToken refreshAccessToken() { + return newToken(); + } + + @Override + public Map> getRequestMetadata() throws IOException { + return ImmutableMap.of(); + } +} diff --git a/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java new file mode 100644 index 000000000..cccd0cedb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/ErrorHandlingHttpClient.java @@ -0,0 +1,145 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.internal; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.HttpResponse; +import com.google.api.client.http.HttpResponseException; +import com.google.api.client.http.HttpResponseInterceptor; +import com.google.api.client.json.JsonFactory; +import com.google.api.client.json.JsonParser; +import com.google.common.io.CharStreams; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; + +/** + * An HTTP client implementation that handles any errors that may occur during HTTP calls, and + * converts them into an instance of FirebaseException. + */ +public final class ErrorHandlingHttpClient { + + private final HttpRequestFactory requestFactory; + private final JsonFactory jsonFactory; + private final HttpErrorHandler errorHandler; + + private HttpResponseInterceptor interceptor; + + public ErrorHandlingHttpClient( + HttpRequestFactory requestFactory, + JsonFactory jsonFactory, + HttpErrorHandler errorHandler) { + this.requestFactory = checkNotNull(requestFactory, "requestFactory must not be null"); + this.jsonFactory = checkNotNull(jsonFactory, "jsonFactory must not be null"); + this.errorHandler = checkNotNull(errorHandler, "errorHandler must not be null"); + } + + public ErrorHandlingHttpClient setInterceptor(HttpResponseInterceptor interceptor) { + this.interceptor = interceptor; + return this; + } + + /** + * Sends the given HTTP request to the target endpoint, and parses the response while handling + * any errors that may occur along the way. + * + * @param requestInfo Outgoing request configuration. + * @param responseType Class to parse the response into. + * @param Parsed response type. + * @return Parsed response object. + * @throws T If any error occurs while making the request. + */ + public V sendAndParse(HttpRequestInfo requestInfo, Class responseType) throws T { + IncomingHttpResponse response = send(requestInfo); + return parse(response, responseType); + } + + /** + * Sends the given HTTP request to the target endpoint, and parses the response while handling + * any errors that may occur along the way. This method can be used when the response should + * be parsed into an instance of a private or protected class, which cannot be instantiated + * outside the call-site. + * + * @param requestInfo Outgoing request configuration. + * @param destination Object to parse the response into. + * @throws T If any error occurs while making the request. + */ + public void sendAndParse(HttpRequestInfo requestInfo, Object destination) throws T { + IncomingHttpResponse response = send(requestInfo); + parse(response, destination); + } + + public IncomingHttpResponse send(HttpRequestInfo requestInfo) throws T { + requestInfo.addHeader("X-Goog-Api-Client", SdkUtils.getMetricsHeader()); + HttpRequest request = createHttpRequest(requestInfo); + + HttpResponse response = null; + try { + response = request.execute(); + // Read and buffer the content. Otherwise if a parse error occurs later, + // we lose the content stream. + String content = null; + InputStream stream = response.getContent(); + if (stream != null) { + // Stream is null when the response body is empty (e.g. 204 No Content responses). + content = CharStreams.toString(new InputStreamReader(stream, response.getContentCharset())); + } + + return new IncomingHttpResponse(response, content); + } catch (HttpResponseException e) { + throw errorHandler.handleHttpResponseException(e, new IncomingHttpResponse(e, request)); + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } finally { + ApiClientUtils.disconnectQuietly(response); + } + } + + public V parse(IncomingHttpResponse response, Class responseType) throws T { + checkNotNull(responseType, "responseType must not be null"); + try { + JsonParser parser = jsonFactory.createJsonParser(response.getContent()); + return parser.parseAndClose(responseType); + } catch (IOException e) { + throw errorHandler.handleParseException(e, response); + } + } + + public void parse(IncomingHttpResponse response, Object destination) throws T { + try { + JsonParser parser = jsonFactory.createJsonParser(response.getContent()); + parser.parse(destination); + } catch (IOException e) { + throw errorHandler.handleParseException(e, response); + } + } + + private HttpRequest createHttpRequest(HttpRequestInfo requestInfo) throws T { + try { + return requestInfo.newHttpRequest(requestFactory, jsonFactory) + .setResponseInterceptor(interceptor); + } catch (IOException e) { + // Handle request initialization errors (credential loading and other config errors) + throw errorHandler.handleIOException(e); + } + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseAppStore.java b/src/main/java/com/google/firebase/internal/FirebaseAppStore.java deleted file mode 100644 index 778295655..000000000 --- a/src/main/java/com/google/firebase/internal/FirebaseAppStore.java +++ /dev/null @@ -1,78 +0,0 @@ -/* - * 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. - */ - -package com.google.firebase.internal; - -import com.google.common.annotations.VisibleForTesting; -import com.google.firebase.FirebaseApp; -import com.google.firebase.FirebaseOptions; - -import java.util.Collections; -import java.util.Set; -import java.util.concurrent.atomic.AtomicReference; - -/** No-op base class of FirebaseAppStore. */ -public class FirebaseAppStore { - - private static final AtomicReference sInstance = new AtomicReference<>(); - - FirebaseAppStore() {} - - @Nullable - public static FirebaseAppStore getInstance() { - return sInstance.get(); - } - - // TODO: reenable persistence. See b/28158809. - public static FirebaseAppStore initialize() { - sInstance.compareAndSet(null /* expected */, new FirebaseAppStore()); - return sInstance.get(); - } - - /** - * @hide - */ - public static void setInstanceForTest(FirebaseAppStore firebaseAppStore) { - sInstance.set(firebaseAppStore); - } - - @VisibleForTesting - public static void clearInstanceForTest() { - FirebaseAppStore instance = sInstance.get(); - if (instance != null) { - instance.resetStore(); - } - sInstance.set(null); - } - - /** The returned set is mutable. */ - public Set getAllPersistedAppNames() { - return Collections.emptySet(); - } - - public void persistApp(@NonNull FirebaseApp app) {} - - public void removeApp(@NonNull String name) {} - - /** - * @return The restored {@link FirebaseOptions}, or null if it doesn't exist. - */ - public FirebaseOptions restoreAppOptions(@NonNull String name) { - return null; - } - - protected void resetStore() {} -} diff --git a/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java b/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java new file mode 100644 index 000000000..a9c3022d4 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/FirebaseProcessEnvironment.java @@ -0,0 +1,46 @@ +/* + * 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. + */ + +package com.google.firebase.internal; + +import com.google.common.base.Strings; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * A utility for overriding environment variables during tests. + */ +public class FirebaseProcessEnvironment { + + private static final Map localCache = new ConcurrentHashMap<>(); + + public static String getenv(String name) { + String cachedValue = localCache.get(name); + if (!Strings.isNullOrEmpty(cachedValue)) { + return cachedValue; + } + + return System.getenv(name); + } + + public static void setenv(String name, String value) { + localCache.put(name, value); + } + + public static void clearCache() { + localCache.clear(); + } +} diff --git a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java index e73f93953..e12690629 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java +++ b/src/main/java/com/google/firebase/internal/FirebaseRequestInitializer.java @@ -60,16 +60,19 @@ private static class TimeoutInitializer implements HttpRequestInitializer { private final int connectTimeoutMillis; private final int readTimeoutMillis; + private final int writeTimeoutMillis; TimeoutInitializer(FirebaseOptions options) { this.connectTimeoutMillis = options.getConnectTimeout(); this.readTimeoutMillis = options.getReadTimeout(); + this.writeTimeoutMillis = options.getWriteTimeout(); } @Override public void initialize(HttpRequest request) { request.setConnectTimeout(connectTimeoutMillis); request.setReadTimeout(readTimeoutMillis); + request.setWriteTimeout(writeTimeoutMillis); } } } diff --git a/src/main/java/com/google/firebase/internal/FirebaseService.java b/src/main/java/com/google/firebase/internal/FirebaseService.java index d3e87551f..2b802aee8 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseService.java +++ b/src/main/java/com/google/firebase/internal/FirebaseService.java @@ -28,7 +28,7 @@ * * @param Type of the service */ -public abstract class FirebaseService { +public class FirebaseService { private final String id; protected final T instance; @@ -62,5 +62,7 @@ public final T getInstance() { * Tear down this FirebaseService instance and the service object wrapped in it, cleaning up * any allocated resources in the process. */ - public abstract void destroy(); + public void destroy() { + // Child classes can override this method to implement any service-specific cleanup logic. + } } diff --git a/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java b/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java index 877450fcc..c14fbd611 100644 --- a/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java +++ b/src/main/java/com/google/firebase/internal/FirebaseThreadManagers.java @@ -23,7 +23,10 @@ import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.ThreadFactory; +import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.TimeUnit; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -84,7 +87,11 @@ private static class DefaultThreadManager extends GlobalThreadManager { protected ExecutorService doInit() { ThreadFactory threadFactory = FirebaseScheduledExecutor.getThreadFactoryWithName( getThreadFactory(), "firebase-default-%d"); - return Executors.newCachedThreadPool(threadFactory); + + ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(100, 100, 60L, + TimeUnit.SECONDS, new LinkedBlockingQueue(), threadFactory); + threadPoolExecutor.allowCoreThreadTimeOut(true); + return threadPoolExecutor; } @Override diff --git a/src/main/java/com/google/firebase/internal/HttpErrorHandler.java b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java new file mode 100644 index 000000000..988b45a45 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpErrorHandler.java @@ -0,0 +1,44 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.internal; + +import com.google.api.client.http.HttpResponseException; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import java.io.IOException; + +/** + * An interface for handling all sorts of exceptions that may occur while making an HTTP call and + * converting them into some instance of FirebaseException. + */ +public interface HttpErrorHandler { + + /** + * Handle any low-level transport and initialization errors. + */ + T handleIOException(IOException e); + + /** + * Handle HTTP response exceptions (caused by HTTP error responses). + */ + T handleHttpResponseException(HttpResponseException e, IncomingHttpResponse response); + + /** + * Handle any errors that may occur while parsing the response payload. + */ + T handleParseException(IOException e, IncomingHttpResponse response); +} diff --git a/src/main/java/com/google/firebase/internal/HttpRequestInfo.java b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java new file mode 100644 index 000000000..375e332fb --- /dev/null +++ b/src/main/java/com/google/firebase/internal/HttpRequestInfo.java @@ -0,0 +1,132 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.internal; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.client.http.GenericUrl; +import com.google.api.client.http.HttpContent; +import com.google.api.client.http.HttpMethods; +import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpRequestFactory; +import com.google.api.client.http.json.JsonHttpContent; +import com.google.api.client.json.JsonFactory; +import com.google.common.base.Strings; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +/** + * Internal API for configuring outgoing HTTP requests. To be used with the + * {@link ErrorHandlingHttpClient} class. + */ +public final class HttpRequestInfo { + + private final String method; + private final GenericUrl url; + private final HttpContent content; + private final Object jsonContent; + private final Map headers = new HashMap<>(); + + private HttpRequestInfo(String method, GenericUrl url, HttpContent content, Object jsonContent) { + checkArgument(!Strings.isNullOrEmpty(method), "method must not be null"); + this.method = method; + this.url = checkNotNull(url, "url must not be null"); + this.content = content; + this.jsonContent = jsonContent; + } + + public HttpRequestInfo addHeader(String name, String value) { + this.headers.put(name, value); + return this; + } + + public HttpRequestInfo addAllHeaders(Map headers) { + this.headers.putAll(headers); + return this; + } + + public HttpRequestInfo addParameter(String name, Object value) { + this.url.put(name, value); + return this; + } + + public HttpRequestInfo addAllParameters(Map params) { + this.url.putAll(params); + return this; + } + + public static HttpRequestInfo buildGetRequest(String url) { + return buildRequest(HttpMethods.GET, url, null); + } + + public static HttpRequestInfo buildDeleteRequest(String url) { + return buildRequest(HttpMethods.DELETE, url, null); + } + + public static HttpRequestInfo buildRequest( + String method, String url, @Nullable HttpContent content) { + return new HttpRequestInfo(method, new GenericUrl(url), content, null); + } + + public static HttpRequestInfo buildJsonPostRequest(String url, @Nullable Object content) { + return buildJsonRequest(HttpMethods.POST, url, content); + } + + public static HttpRequestInfo buildJsonPatchRequest(String url, @Nullable Object content) { + return buildJsonRequest(HttpMethods.PATCH, url, content); + } + + public static HttpRequestInfo buildJsonRequest( + String method, String url, @Nullable Object content) { + return new HttpRequestInfo(method, new GenericUrl(url), null, content); + } + + HttpRequest newHttpRequest( + HttpRequestFactory factory, JsonFactory jsonFactory) throws IOException { + HttpRequest request; + HttpContent httpContent = getContent(jsonFactory); + if (factory.getTransport().supportsMethod(method)) { + request = factory.buildRequest(method, url, httpContent); + } else { + // Some HttpTransport implementations (notably NetHttpTransport) don't support new methods + // like PATCH. We try to emulate such requests over POST by setting the method override + // header, which is recognized by most Google backend APIs. + request = factory.buildPostRequest(url, httpContent); + request.getHeaders().set("X-HTTP-Method-Override", method); + } + + for (Map.Entry entry : headers.entrySet()) { + request.getHeaders().set(entry.getKey(), entry.getValue()); + } + + return request; + } + + private HttpContent getContent(JsonFactory jsonFactory) { + if (content != null) { + return content; + } + + if (jsonContent != null) { + return new JsonHttpContent(jsonFactory, jsonContent); + } + + return null; + } +} diff --git a/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java b/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java new file mode 100644 index 000000000..c859ec485 --- /dev/null +++ b/src/main/java/com/google/firebase/internal/MockApacheHttp2AsyncClient.java @@ -0,0 +1,73 @@ +/* + * Copyright 2024 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. + */ + +package com.google.firebase.internal; + +import java.io.IOException; +import java.util.concurrent.Future; + +import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient; +import org.apache.hc.core5.concurrent.FutureCallback; +import org.apache.hc.core5.function.Supplier; +import org.apache.hc.core5.http.HttpHost; +import org.apache.hc.core5.http.nio.AsyncPushConsumer; +import org.apache.hc.core5.http.nio.AsyncRequestProducer; +import org.apache.hc.core5.http.nio.AsyncResponseConsumer; +import org.apache.hc.core5.http.nio.HandlerFactory; +import org.apache.hc.core5.http.protocol.HttpContext; +import org.apache.hc.core5.io.CloseMode; +import org.apache.hc.core5.reactor.IOReactorStatus; +import org.apache.hc.core5.util.TimeValue; + +public class MockApacheHttp2AsyncClient extends CloseableHttpAsyncClient { + + @Override + public void close(CloseMode closeMode) { + } + + @Override + public void close() throws IOException { + } + + @Override + public void start() { + } + + @Override + public IOReactorStatus getStatus() { + return null; + } + + @Override + public void awaitShutdown(TimeValue waitTime) throws InterruptedException { + } + + @Override + public void initiateShutdown() { + } + + @Override + protected Future doExecute(HttpHost target, AsyncRequestProducer requestProducer, + AsyncResponseConsumer responseConsumer, + HandlerFactory pushHandlerFactory, + HttpContext context, FutureCallback callback) { + return null; + } + + @Override + public void register(String hostname, String uriPattern, Supplier supplier) { + } +} diff --git a/src/main/java/com/google/firebase/internal/SdkUtils.java b/src/main/java/com/google/firebase/internal/SdkUtils.java index 2fa5e5767..cd9619087 100644 --- a/src/main/java/com/google/firebase/internal/SdkUtils.java +++ b/src/main/java/com/google/firebase/internal/SdkUtils.java @@ -39,6 +39,14 @@ public static String getVersion() { return SDK_VERSION; } + public static String getJavaVersion() { + return System.getProperty("java.version"); + } + + public static String getMetricsHeader() { + return String.format("gl-java/%s fire-admin/%s", getJavaVersion(), getVersion()); + } + private static String loadSdkVersion() { try (InputStream in = SdkUtils.class.getClassLoader() .getResourceAsStream(ADMIN_SDK_PROPERTIES)) { diff --git a/src/main/java/com/google/firebase/messaging/AndroidConfig.java b/src/main/java/com/google/firebase/messaging/AndroidConfig.java index dc0a45526..e38e561d0 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidConfig.java +++ b/src/main/java/com/google/firebase/messaging/AndroidConfig.java @@ -51,6 +51,9 @@ public class AndroidConfig { @Key("fcm_options") private final AndroidFcmOptions fcmOptions; + + @Key("direct_boot_ok") + private final Boolean directBootOk; private AndroidConfig(Builder builder) { this.collapseKey = builder.collapseKey; @@ -75,6 +78,7 @@ private AndroidConfig(Builder builder) { this.data = builder.data.isEmpty() ? null : ImmutableMap.copyOf(builder.data); this.notification = builder.notification; this.fcmOptions = builder.fcmOptions; + this.directBootOk = builder.directBootOk; } /** @@ -103,13 +107,17 @@ public static class Builder { private final Map data = new HashMap<>(); private AndroidNotification notification; private AndroidFcmOptions fcmOptions; + private Boolean directBootOk; private Builder() {} /** - * Sets a collapse key for the message. Collapse key serves as an identifier for a group of + * Sets a collapse key for the message. The 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 4 different collapse keys may be active at any given time. + * + *

By default, the collapse key is the app package name registered in + * the Firebase console.

* * @param collapseKey A collapse key string. * @return This builder. @@ -193,7 +201,7 @@ public Builder setNotification(AndroidNotification notification) { } /** - * Sets the {@link AndroidFcmOptions}, which will override values set in the {@link FcmOptions} + * Sets the {@link AndroidFcmOptions}, which overrides values set in the {@link FcmOptions} * for Android messages. */ public Builder setFcmOptions(AndroidFcmOptions androidFcmOptions) { @@ -201,6 +209,15 @@ public Builder setFcmOptions(AndroidFcmOptions androidFcmOptions) { return this; } + /** + * Sets the {@code direct_boot_ok} flag. If set to true, messages are delivered to + * the app while the device is in direct boot mode. + */ + public Builder setDirectBootOk(boolean directBootOk) { + this.directBootOk = directBootOk; + return this; + } + /** * Creates a new {@link AndroidConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/AndroidNotification.java b/src/main/java/com/google/firebase/messaging/AndroidNotification.java index 05e680910..b509a9843 100644 --- a/src/main/java/com/google/firebase/messaging/AndroidNotification.java +++ b/src/main/java/com/google/firebase/messaging/AndroidNotification.java @@ -112,6 +112,9 @@ public class AndroidNotification { @Key("notification_count") private final Integer notificationCount; + @Key("proxy") + private final String proxy; + private static final Map PRIORITY_MAP = ImmutableMap.builder() .put(Priority.MIN, "PRIORITY_MIN") @@ -179,6 +182,11 @@ private AndroidNotification(Builder builder) { checkArgument(builder.notificationCount >= 0, "notificationCount if specified must be zero or positive valued"); } + if (builder.proxy != null) { + this.proxy = builder.proxy.name(); + } else { + this.proxy = null; + } this.notificationCount = builder.notificationCount; } @@ -194,13 +202,19 @@ public String toString() { return PRIORITY_MAP.get(this); } } - + public enum Visibility { PRIVATE, PUBLIC, SECRET, } + public enum Proxy { + ALLOW, + DENY, + IF_PRIORITY_LOWERED + } + /** * Creates a new {@link AndroidNotification.Builder}. * @@ -237,6 +251,7 @@ public static class Builder { private LightSettings lightSettings; private Boolean defaultLightSettings; private Visibility visibility; + private Proxy proxy; private Builder() {} @@ -602,6 +617,18 @@ public Builder setNotificationCount(int notificationCount) { return this; } + /** + * Sets the proxy of this notification. + * + * @param proxy The proxy value, one of the values in {ALLOW, DENY, IF_PRIORITY_LOWERED} + * + * @return This builder. + */ + public Builder setProxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + /** * Creates a new {@link AndroidNotification} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/ApnsConfig.java b/src/main/java/com/google/firebase/messaging/ApnsConfig.java index 0e5de8619..39092ac17 100644 --- a/src/main/java/com/google/firebase/messaging/ApnsConfig.java +++ b/src/main/java/com/google/firebase/messaging/ApnsConfig.java @@ -41,6 +41,9 @@ public class ApnsConfig { @Key("fcm_options") private final ApnsFcmOptions fcmOptions; + @Key("live_activity_token") + private final String liveActivityToken; + private ApnsConfig(Builder builder) { checkArgument(builder.aps != null, "aps must be specified"); checkArgument(!builder.customData.containsKey("aps"), @@ -51,6 +54,7 @@ private ApnsConfig(Builder builder) { .put("aps", builder.aps.getFields()) .build(); this.fcmOptions = builder.fcmOptions; + this.liveActivityToken = builder.liveActivityToken; } /** @@ -68,6 +72,7 @@ public static class Builder { private final Map customData = new HashMap<>(); private Aps aps; private ApnsFcmOptions fcmOptions; + private String liveActivityToken; private Builder() {} @@ -137,6 +142,17 @@ public Builder setFcmOptions(ApnsFcmOptions apnsFcmOptions) { return this; } + /** + * Sets the Live Activity token. + * + * @param liveActivityToken Live Activity token. + * @return This builder. + */ + public Builder setLiveActivityToken(String liveActivityToken) { + this.liveActivityToken = liveActivityToken; + return this; + } + /** * Creates a new {@link ApnsConfig} instance from the parameters set on this builder. * diff --git a/src/main/java/com/google/firebase/messaging/BatchResponse.java b/src/main/java/com/google/firebase/messaging/BatchResponse.java index bd5069f4c..164403be4 100644 --- a/src/main/java/com/google/firebase/messaging/BatchResponse.java +++ b/src/main/java/com/google/firebase/messaging/BatchResponse.java @@ -16,7 +16,6 @@ package com.google.firebase.messaging; -import com.google.common.collect.ImmutableList; import com.google.firebase.internal.NonNull; import java.util.List; @@ -25,32 +24,12 @@ * See {@link FirebaseMessaging#sendAll(List)} and {@link * FirebaseMessaging#sendMulticast(MulticastMessage)}. */ -public final class BatchResponse { - - private final List responses; - private final int successCount; - - BatchResponse(List responses) { - this.responses = ImmutableList.copyOf(responses); - int successCount = 0; - for (SendResponse response : this.responses) { - if (response.isSuccessful()) { - successCount++; - } - } - this.successCount = successCount; - } +public interface BatchResponse { @NonNull - public List getResponses() { - return responses; - } + List getResponses(); - public int getSuccessCount() { - return successCount; - } + int getSuccessCount(); - public int getFailureCount() { - return responses.size() - successCount; - } + int getFailureCount(); } diff --git a/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java b/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java new file mode 100644 index 000000000..99cf63df1 --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/BatchResponseImpl.java @@ -0,0 +1,58 @@ +/* + * Copyright 2020 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.messaging; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; + +import java.util.List; + +/** + * Response from an operation that sends FCM messages to multiple recipients. + * See {@link FirebaseMessaging#sendAll(List)} and {@link + * FirebaseMessaging#sendMulticast(MulticastMessage)}. + */ +class BatchResponseImpl implements BatchResponse { + + private final List responses; + private final int successCount; + + BatchResponseImpl(List responses) { + this.responses = ImmutableList.copyOf(responses); + int successCount = 0; + for (SendResponse response : this.responses) { + if (response.isSuccessful()) { + successCount++; + } + } + this.successCount = successCount; + } + + @NonNull + public List getResponses() { + return responses; + } + + public int getSuccessCount() { + return successCount; + } + + public int getFailureCount() { + return responses.size() - successCount; + } + +} diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java index ee25957b3..870940f77 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessaging.java @@ -20,18 +20,23 @@ import static com.google.common.base.Preconditions.checkNotNull; import com.google.api.core.ApiFuture; +import com.google.api.core.ApiFutures; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.base.Supplier; import com.google.common.base.Suppliers; import com.google.common.collect.ImmutableList; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; import com.google.firebase.internal.CallableOperation; import com.google.firebase.internal.FirebaseService; import com.google.firebase.internal.NonNull; +import java.util.ArrayList; import java.util.List; +import java.util.concurrent.ExecutionException; /** * This class is the entry point for all server-side Firebase Cloud Messaging actions. @@ -41,10 +46,6 @@ */ public class FirebaseMessaging { - static final String INTERNAL_ERROR = "internal-error"; - - static final String UNKNOWN_ERROR = "unknown-error"; - private final FirebaseApp app; private final Supplier messagingClient; private final Supplier instanceIdClient; @@ -94,7 +95,9 @@ public String send(@NonNull Message message) throws FirebaseMessagingException { * Sends the given {@link Message} via Firebase Cloud Messaging. * *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. + * FCM performs all the necessary validations and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. However, it + * cannot be used to validate APNs tokens. * * @param message A non-null {@link Message} to be sent. * @param dryRun a boolean indicating whether to perform a dry run (validation only) of the send. @@ -141,6 +144,200 @@ protected String execute() throws FirebaseMessagingException { }; } + /** + * Sends each message in the given list via Firebase Cloud Messaging. + * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the + * given array. + * + *

The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return + * value is in the same order as the input list. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEach(@NonNull List messages) throws FirebaseMessagingException { + return sendEach(messages, false); + } + + + /** + * Sends each message in the given list via Firebase Cloud Messaging. + * Unlike {@link #sendAll(List)}, this method makes an HTTP call for each message in the + * given array. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. + * + *

The list of responses obtained by calling {@link BatchResponse#getResponses()} on the return + * value is in the same order as the input list. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEach( + @NonNull List messages, boolean dryRun) throws FirebaseMessagingException { + try { + return sendEachOpAsync(messages, dryRun).get(); + } catch (InterruptedException | ExecutionException e) { + throw new FirebaseMessagingException(ErrorCode.CANCELLED, SERVICE_ID); + } + } + + /** + * Similar to {@link #sendEach(List)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachAsync(@NonNull List messages) { + return sendEachOpAsync(messages, false); + } + + /** + * Similar to {@link #sendEach(List, boolean)} but performs the operation asynchronously. + * + * @param messages A non-null, non-empty list containing up to 500 messages. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachAsync(@NonNull List messages, boolean dryRun) { + return sendEachOpAsync(messages, dryRun); + } + + // Returns an ApiFuture directly since this function is non-blocking. Individual child send + // requests are still called async and run in background threads. + private ApiFuture sendEachOpAsync( + final List messages, final boolean dryRun) { + final List immutableMessages = ImmutableList.copyOf(messages); + checkArgument(!immutableMessages.isEmpty(), "messages list must not be empty"); + checkArgument(immutableMessages.size() <= 500, + "messages list must not contain more than 500 elements"); + + List> list = new ArrayList<>(); + for (Message message : immutableMessages) { + // Make async send calls per message + ApiFuture messageId = sendOpForSendResponse(message, dryRun).callAsync(app); + list.add(messageId); + } + + // Gather all futures and combine into a list + ApiFuture> responsesFuture = ApiFutures.allAsList(list); + + // Chain this future to wrap the eventual responses in a BatchResponse without blocking + // the main thread. This uses the current thread to execute, but since the transformation + // function is non-blocking the transformation itself is also non-blocking. + return ApiFutures.transform( + responsesFuture, + (responses) -> { + return new BatchResponseImpl(responses); + }, + MoreExecutors.directExecutor()); + } + + private CallableOperation sendOpForSendResponse( + final Message message, final boolean dryRun) { + checkNotNull(message, "message must not be null"); + final FirebaseMessagingClient messagingClient = getMessagingClient(); + return new CallableOperation() { + @Override + protected SendResponse execute() { + try { + String messageId = messagingClient.send(message, dryRun); + return SendResponse.fromMessageId(messageId); + } catch (FirebaseMessagingException e) { + return SendResponse.fromException(e); + } + } + }; + } + + /** + * Sends the given multicast message to all the FCM registration tokens specified in it. + * + *

This method uses the {@link #sendEach(List)} API under the hood to send the given + * message to all the target recipients. The list of responses obtained by calling + * {@link BatchResponse#getResponses()} on the return value is in the same order as the + * tokens in the {@link MulticastMessage}. + * + * @param message A non-null {@link MulticastMessage} + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEachForMulticast( + @NonNull MulticastMessage message) throws FirebaseMessagingException { + return sendEachForMulticast(message, false); + } + + /** + * Sends the given multicast message to all the FCM registration tokens specified in it. + * + *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. + * + *

This method uses the {@link #sendEach(List)} API under the hood to send the given + * message to all the target recipients. The list of responses obtained by calling + * {@link BatchResponse#getResponses()} on the return value is in the same order as the + * tokens in the {@link MulticastMessage}. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return A {@link BatchResponse} indicating the result of the operation. + * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for + * delivery. An exception here or a {@link BatchResponse} with all failures indicates a total + * failure, meaning that none of the messages in the list could be sent. Partial failures or + * no failures are only indicated by a {@link BatchResponse}. + */ + public BatchResponse sendEachForMulticast(@NonNull MulticastMessage message, boolean dryRun) + throws FirebaseMessagingException { + checkNotNull(message, "multicast message must not be null"); + return sendEach(message.getMessageList(), dryRun); + } + + /** + * Similar to {@link #sendEachForMulticast(MulticastMessage)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachForMulticastAsync(@NonNull MulticastMessage message) { + return sendEachForMulticastAsync(message, false); + } + + /** + * Similar to {@link #sendEachForMulticast(MulticastMessage, boolean)} but performs the operation + * asynchronously. + * + * @param message A non-null {@link MulticastMessage}. + * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * the messages have been sent. + */ + public ApiFuture sendEachForMulticastAsync( + @NonNull MulticastMessage message, boolean dryRun) { + checkNotNull(message, "multicast message must not be null"); + return sendEachAsync(message.getMessageList(), dryRun); + } + /** * Sends all the messages in the given list via Firebase Cloud Messaging. Employs batching to * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this @@ -152,9 +349,12 @@ protected String execute() throws FirebaseMessagingException { * @param messages A non-null, non-empty list containing up to 500 messages. * @return A {@link BatchResponse} indicating the result of the operation. * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for - * delivery. An exception here indicates a total failure -- i.e. none of the messages in the - * list could be sent. Partial failures are indicated by a {@link BatchResponse} return value. + * delivery. An exception here indicates a total failure, meaning that none of the messages in + * the list could be sent. Partial failures are indicated by a {@link BatchResponse} return + * value. + * @deprecated Use {@link #sendEach(List)} instead. */ + @Deprecated public BatchResponse sendAll( @NonNull List messages) throws FirebaseMessagingException { return sendAll(messages, false); @@ -165,8 +365,10 @@ public BatchResponse sendAll( * send the entire list as a single RPC call. Compared to the {@link #send(Message)} method, this * is a significantly more efficient way to send multiple messages. * - *

If the {@code dryRun} option is set to true, the messages will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. + *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. * *

The responses list obtained by calling {@link BatchResponse#getResponses()} on the return * value corresponds to the order of input messages. @@ -175,9 +377,12 @@ public BatchResponse sendAll( * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. * @return A {@link BatchResponse} indicating the result of the operation. * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for - * delivery. An exception here indicates a total failure -- i.e. none of the messages in the - * list could be sent. Partial failures are indicated by a {@link BatchResponse} return value. + * delivery. An exception here indicates a total failure, meaning that none of the messages in + * the list could be sent. Partial failures are indicated by a {@link BatchResponse} return + * value. + * @deprecated Use {@link #sendEach(List, boolean)} instead. */ + @Deprecated public BatchResponse sendAll( @NonNull List messages, boolean dryRun) throws FirebaseMessagingException { return sendAllOp(messages, dryRun).call(); @@ -187,9 +392,11 @@ public BatchResponse sendAll( * Similar to {@link #sendAll(List)} but performs the operation asynchronously. * * @param messages A non-null, non-empty list containing up to 500 messages. - * @return @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when * the messages have been sent. + * @deprecated Use {@link #sendEachAsync(List)} instead. */ + @Deprecated public ApiFuture sendAllAsync(@NonNull List messages) { return sendAllAsync(messages, false); } @@ -199,9 +406,11 @@ public ApiFuture sendAllAsync(@NonNull List messages) { * * @param messages A non-null, non-empty list containing up to 500 messages. * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. - * @return @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when + * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when * the messages have been sent, or when the emulation has finished. + * @deprecated Use {@link #sendEachAsync(List, boolean)} instead. */ + @Deprecated public ApiFuture sendAllAsync( @NonNull List messages, boolean dryRun) { return sendAllOp(messages, dryRun).callAsync(app); @@ -218,10 +427,12 @@ public ApiFuture sendAllAsync( * @param message A non-null {@link MulticastMessage} * @return A {@link BatchResponse} indicating the result of the operation. * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for - * delivery. An exception here indicates a total failure -- i.e. the messages could not be - * delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} + * delivery. An exception here indicates a total failure, meaning that the messages could not + * be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} * return value. + * @deprecated Use {@link #sendEachForMulticast(MulticastMessage)} instead. */ + @Deprecated public BatchResponse sendMulticast( @NonNull MulticastMessage message) throws FirebaseMessagingException { return sendMulticast(message, false); @@ -231,7 +442,9 @@ public BatchResponse sendMulticast( * Sends the given multicast message to all the FCM registration tokens specified in it. * *

If the {@code dryRun} option is set to true, the message will not be actually sent. Instead - * FCM performs all the necessary validations, and emulates the send operation. + * FCM performs all the necessary validations, and emulates the send operation. The {@code dryRun} + * option is useful for determining whether an FCM registration has been deleted. But it cannot be + * used to validate APNs tokens. * *

This method uses the {@link #sendAll(List)} API under the hood to send the given * message to all the target recipients. The responses list obtained by calling @@ -242,10 +455,12 @@ public BatchResponse sendMulticast( * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. * @return A {@link BatchResponse} indicating the result of the operation. * @throws FirebaseMessagingException If an error occurs while handing the messages off to FCM for - * delivery. An exception here indicates a total failure -- i.e. the messages could not be - * delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} + * delivery. An exception here indicates a total failure, meaning that the messages could not + * be delivered to any recipient. Partial failures are indicated by a {@link BatchResponse} * return value. + * @deprecated Use {@link #sendEachForMulticast(MulticastMessage, boolean)} instead. */ + @Deprecated public BatchResponse sendMulticast( @NonNull MulticastMessage message, boolean dryRun) throws FirebaseMessagingException { checkNotNull(message, "multicast message must not be null"); @@ -259,7 +474,9 @@ public BatchResponse sendMulticast( * @param message A non-null {@link MulticastMessage}. * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when * the messages have been sent. + * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage)} instead. */ + @Deprecated public ApiFuture sendMulticastAsync(@NonNull MulticastMessage message) { return sendMulticastAsync(message, false); } @@ -272,7 +489,9 @@ public ApiFuture sendMulticastAsync(@NonNull MulticastMessage mes * @param dryRun A boolean indicating whether to perform a dry run (validation only) of the send. * @return An {@code ApiFuture} that will complete with a {@link BatchResponse} when * the messages have been sent. + * @deprecated Use {@link #sendEachForMulticastAsync(MulticastMessage, boolean)} instead. */ + @Deprecated public ApiFuture sendMulticastAsync( @NonNull MulticastMessage message, boolean dryRun) { checkNotNull(message, "multicast message must not be null"); @@ -407,13 +626,6 @@ private static class FirebaseMessagingService extends FirebaseService FCM_ERROR_CODES = - ImmutableMap.builder() - // FCM v1 canonical error codes - .put("NOT_FOUND", "registration-token-not-registered") - .put("PERMISSION_DENIED", "mismatched-credential") - .put("RESOURCE_EXHAUSTED", "message-rate-exceeded") - .put("UNAUTHENTICATED", "third-party-auth-error") - - // FCM v1 new error codes - .put("APNS_AUTH_ERROR", "third-party-auth-error") - .put("INTERNAL", FirebaseMessaging.INTERNAL_ERROR) - .put("INVALID_ARGUMENT", "invalid-argument") - .put("QUOTA_EXCEEDED", "message-rate-exceeded") - .put("SENDER_ID_MISMATCH", "mismatched-credential") - .put("THIRD_PARTY_AUTH_ERROR", "third-party-auth-error") - .put("UNAVAILABLE", "server-unavailable") - .put("UNREGISTERED", "registration-token-not-registered") - .build(); + private static final Map COMMON_HEADERS = + ImmutableMap.of( + "X-GOOG-API-FORMAT-VERSION", "2", + "X-Firebase-Client", "fire-admin-java/" + SdkUtils.getVersion()); private final String fcmSendUrl; private final HttpRequestFactory requestFactory; private final HttpRequestFactory childRequestFactory; private final JsonFactory jsonFactory; private final HttpResponseInterceptor responseInterceptor; - private final String clientVersion = "fire-admin-java/" + SdkUtils.getVersion(); + private final MessagingErrorHandler errorHandler; + private final ErrorHandlingHttpClient httpClient; + private final MessagingBatchClient batchClient; private FirebaseMessagingClientImpl(Builder builder) { checkArgument(!Strings.isNullOrEmpty(builder.projectId)); @@ -94,6 +83,10 @@ private FirebaseMessagingClientImpl(Builder builder) { this.childRequestFactory = checkNotNull(builder.childRequestFactory); this.jsonFactory = checkNotNull(builder.jsonFactory); this.responseInterceptor = builder.responseInterceptor; + this.errorHandler = new MessagingErrorHandler(this.jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler) + .setInterceptor(responseInterceptor); + this.batchClient = new MessagingBatchClient(requestFactory.getTransport(), jsonFactory); } @VisibleForTesting @@ -116,67 +109,48 @@ JsonFactory getJsonFactory() { return jsonFactory; } - @VisibleForTesting - String getClientVersion() { - return clientVersion; - } - public String send(Message message, boolean dryRun) throws FirebaseMessagingException { - try { - return sendSingleRequest(message, dryRun); - } catch (HttpResponseException e) { - throw createExceptionFromResponse(e); - } catch (IOException e) { - throw new FirebaseMessagingException( - FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e); - } + return sendSingleRequest(message, dryRun); } public BatchResponse sendAll( List messages, boolean dryRun) throws FirebaseMessagingException { - try { - return sendBatchRequest(messages, dryRun); - } catch (HttpResponseException e) { - throw createExceptionFromResponse(e); - } catch (IOException e) { - throw new FirebaseMessagingException( - FirebaseMessaging.INTERNAL_ERROR, "Error while calling FCM backend service", e); - } + return sendBatchRequest(messages, dryRun); } - private String sendSingleRequest(Message message, boolean dryRun) throws IOException { - HttpRequest request = requestFactory.buildPostRequest( - new GenericUrl(fcmSendUrl), - new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun))); - setCommonFcmHeaders(request.getHeaders()); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(responseInterceptor); - HttpResponse response = request.execute(); - try { - MessagingServiceResponse parsed = new MessagingServiceResponse(); - jsonFactory.createJsonParser(response.getContent()).parseAndClose(parsed); - return parsed.getMessageId(); - } finally { - ApiClientUtils.disconnectQuietly(response); - } + private String sendSingleRequest( + Message message, boolean dryRun) throws FirebaseMessagingException { + HttpRequestInfo request = + HttpRequestInfo.buildJsonPostRequest( + fcmSendUrl, message.wrapForTransport(dryRun)) + .addAllHeaders(COMMON_HEADERS); + MessagingServiceResponse parsed = httpClient.sendAndParse( + request, MessagingServiceResponse.class); + return parsed.getMessageId(); } private BatchResponse sendBatchRequest( - List messages, boolean dryRun) throws IOException { + List messages, boolean dryRun) throws FirebaseMessagingException { MessagingBatchCallback callback = new MessagingBatchCallback(); - BatchRequest batch = newBatchRequest(messages, dryRun, callback); - batch.execute(); - return new BatchResponse(callback.getResponses()); + try { + BatchRequest batch = newBatchRequest(messages, dryRun, callback); + batch.execute(); + return new BatchResponseImpl(callback.getResponses()); + } catch (HttpResponseException e) { + OutgoingHttpRequest req = new OutgoingHttpRequest( + HttpMethods.POST, MessagingBatchClient.FCM_BATCH_URL); + IncomingHttpResponse resp = new IncomingHttpResponse(e, req); + throw errorHandler.handleHttpResponseException(e, resp); + } catch (IOException e) { + throw errorHandler.handleIOException(e); + } } private BatchRequest newBatchRequest( List messages, boolean dryRun, MessagingBatchCallback callback) throws IOException { - BatchRequest batch = new BatchRequest( - requestFactory.getTransport(), getBatchRequestInitializer()); - batch.setBatchUrl(new GenericUrl(FCM_BATCH_URL)); - + BatchRequest batch = batchClient.batch(getBatchRequestInitializer()); final JsonObjectParser jsonParser = new JsonObjectParser(this.jsonFactory); final GenericUrl sendUrl = new GenericUrl(fcmSendUrl); for (Message message : messages) { @@ -186,36 +160,19 @@ private BatchRequest newBatchRequest( sendUrl, new JsonHttpContent(jsonFactory, message.wrapForTransport(dryRun))); request.setParser(jsonParser); - setCommonFcmHeaders(request.getHeaders()); + request.getHeaders().putAll(COMMON_HEADERS); batch.queue( request, MessagingServiceResponse.class, MessagingServiceErrorResponse.class, callback); } return batch; } - private void setCommonFcmHeaders(HttpHeaders headers) { - headers.set(API_FORMAT_VERSION_HEADER, "2"); - headers.set(CLIENT_VERSION_HEADER, clientVersion); - } - - private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) { - MessagingServiceErrorResponse response = new MessagingServiceErrorResponse(); - if (e.getContent() != null) { - try { - JsonParser parser = jsonFactory.createJsonParser(e.getContent()); - parser.parseAndClose(response); - } catch (IOException ignored) { - // ignored - } - } - - return newException(response, e); - } - private HttpRequestInitializer getBatchRequestInitializer() { return new HttpRequestInitializer() { @Override public void initialize(HttpRequest request) throws IOException { + // Batch requests are not executed on the ErrorHandlingHttpClient. Therefore, they + // require some special handling at initialization. HttpRequestInitializer initializer = requestFactory.getInitializer(); if (initializer != null) { initializer.initialize(request); @@ -283,30 +240,6 @@ FirebaseMessagingClientImpl build() { } } - private static FirebaseMessagingException newException(MessagingServiceErrorResponse response) { - return newException(response, null); - } - - private static FirebaseMessagingException newException( - MessagingServiceErrorResponse response, @Nullable HttpResponseException e) { - String code = FCM_ERROR_CODES.get(response.getErrorCode()); - if (code == null) { - code = FirebaseMessaging.UNKNOWN_ERROR; - } - - String msg = response.getErrorMessage(); - if (Strings.isNullOrEmpty(msg)) { - if (e != null) { - msg = String.format("Unexpected HTTP response with status: %d; body: %s", - e.getStatusCode(), e.getContent()); - } else { - msg = String.format("Unexpected HTTP response: %s", response.toString()); - } - } - - return new FirebaseMessagingException(code, msg, e); - } - private static class MessagingBatchCallback implements BatchCallback { @@ -319,13 +252,97 @@ public void onSuccess( } @Override - public void onFailure( - MessagingServiceErrorResponse error, HttpHeaders responseHeaders) { - responses.add(SendResponse.fromException(newException(error))); + public void onFailure(MessagingServiceErrorResponse error, HttpHeaders responseHeaders) { + // We only specify error codes and message for these partial failures. Recall that these + // exceptions are never actually thrown, but only made accessible via SendResponse. + FirebaseException base = createFirebaseException(error); + FirebaseMessagingException exception = FirebaseMessagingException.withMessagingErrorCode( + base, error.getMessagingErrorCode()); + responses.add(SendResponse.fromException(exception)); } List getResponses() { return this.responses.build(); } + + private FirebaseException createFirebaseException(MessagingServiceErrorResponse error) { + String status = error.getStatus(); + ErrorCode errorCode = Strings.isNullOrEmpty(status) + ? ErrorCode.UNKNOWN : Enum.valueOf(ErrorCode.class, status); + + String msg = error.getErrorMessage(); + if (Strings.isNullOrEmpty(msg)) { + msg = String.format("Unexpected HTTP response: %s", error.toString()); + } + + return new FirebaseException(errorCode, msg, null); + } + } + + private static class MessagingErrorHandler + extends AbstractPlatformErrorHandler { + + private MessagingErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); + } + + @Override + protected FirebaseMessagingException createException(FirebaseException base) { + String response = getResponse(base); + MessagingServiceErrorResponse parsed = safeParse(response); + return FirebaseMessagingException.withMessagingErrorCode( + base, parsed.getMessagingErrorCode()); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private MessagingServiceErrorResponse safeParse(String response) { + if (!Strings.isNullOrEmpty(response)) { + try { + return jsonFactory.createJsonParser(response) + .parseAndClose(MessagingServiceErrorResponse.class); + } catch (Exception ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return new MessagingServiceErrorResponse(); + } + } + + private static class MessagingBatchClient extends AbstractGoogleJsonClient { + + private static final String FCM_ROOT_URL = "https://fcm.googleapis.com"; + private static final String FCM_BATCH_PATH = "batch"; + private static final String FCM_BATCH_URL = String.format( + "%s/%s", FCM_ROOT_URL, FCM_BATCH_PATH); + + MessagingBatchClient(HttpTransport transport, JsonFactory jsonFactory) { + super(new Builder(transport, jsonFactory)); + } + + private MessagingBatchClient(Builder builder) { + super(builder); + } + + private static class Builder extends AbstractGoogleJsonClient.Builder { + Builder(HttpTransport transport, JsonFactory jsonFactory) { + super(transport, jsonFactory, FCM_ROOT_URL, "", null, false); + setBatchPath(FCM_BATCH_PATH); + setApplicationName("fire-admin-java"); + } + + @Override + public AbstractGoogleJsonClient build() { + return new MessagingBatchClient(this); + } + } } } diff --git a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java index 5f57474ea..a3d92788a 100644 --- a/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java +++ b/src/main/java/com/google/firebase/messaging/FirebaseMessagingException.java @@ -16,26 +16,54 @@ package com.google.firebase.messaging; -import static com.google.common.base.Preconditions.checkArgument; - -import com.google.common.base.Strings; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; + +public final class FirebaseMessagingException extends FirebaseException { -public class FirebaseMessagingException extends FirebaseException { + private final MessagingErrorCode errorCode; - private final String errorCode; + @VisibleForTesting + FirebaseMessagingException(@NonNull ErrorCode code, @NonNull String message) { + this(code, message, null, null, null); + } - FirebaseMessagingException(String errorCode, String message, Throwable cause) { - super(message, cause); - checkArgument(!Strings.isNullOrEmpty(errorCode)); + private FirebaseMessagingException( + @NonNull ErrorCode code, + @NonNull String message, + @Nullable Throwable cause, + @Nullable IncomingHttpResponse response, + @Nullable MessagingErrorCode errorCode) { + super(code, message, cause, response); this.errorCode = errorCode; } + static FirebaseMessagingException withMessagingErrorCode( + FirebaseException base, @Nullable MessagingErrorCode errorCode) { + return new FirebaseMessagingException( + base.getErrorCode(), + base.getMessage(), + base.getCause(), + base.getHttpResponse(), + errorCode); + } + + static FirebaseMessagingException withCustomMessage(FirebaseException base, String message) { + return new FirebaseMessagingException( + base.getErrorCode(), + message, + base.getCause(), + base.getHttpResponse(), + null); + } /** Returns an error code that may provide more information about the error. */ - @NonNull - public String getErrorCode() { + @Nullable + public MessagingErrorCode getMessagingErrorCode() { return errorCode; } } diff --git a/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java index 15f8158a5..5648fcf0c 100644 --- a/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java +++ b/src/main/java/com/google/firebase/messaging/InstanceIdClientImpl.java @@ -16,27 +16,20 @@ package com.google.firebase.messaging; -import static com.google.common.base.Preconditions.checkNotNull; - -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; -import com.google.api.client.json.JsonParser; import com.google.api.client.util.Key; -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableMap; import com.google.firebase.FirebaseApp; +import com.google.firebase.FirebaseException; +import com.google.firebase.internal.AbstractHttpErrorHandler; import com.google.firebase.internal.ApiClientUtils; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.Nullable; - import java.io.IOException; import java.util.List; import java.util.Map; @@ -53,18 +46,7 @@ final class InstanceIdClientImpl implements InstanceIdClient { private static final String IID_UNSUBSCRIBE_PATH = "iid/v1:batchRemove"; - static final Map IID_ERROR_CODES = - ImmutableMap.builder() - .put(400, "invalid-argument") - .put(401, "authentication-error") - .put(403, "authentication-error") - .put(500, FirebaseMessaging.INTERNAL_ERROR) - .put(503, "server-unavailable") - .build(); - - private final HttpRequestFactory requestFactory; - private final JsonFactory jsonFactory; - private final HttpResponseInterceptor responseInterceptor; + private final ErrorHandlingHttpClient requestFactory; InstanceIdClientImpl(HttpRequestFactory requestFactory, JsonFactory jsonFactory) { this(requestFactory, jsonFactory, null); @@ -74,9 +56,9 @@ final class InstanceIdClientImpl implements InstanceIdClient { HttpRequestFactory requestFactory, JsonFactory jsonFactory, @Nullable HttpResponseInterceptor responseInterceptor) { - this.requestFactory = checkNotNull(requestFactory); - this.jsonFactory = checkNotNull(jsonFactory); - this.responseInterceptor = responseInterceptor; + InstanceIdErrorHandler errorHandler = new InstanceIdErrorHandler(jsonFactory); + this.requestFactory = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler) + .setInterceptor(responseInterceptor); } static InstanceIdClientImpl fromApp(FirebaseApp app) { @@ -85,76 +67,32 @@ static InstanceIdClientImpl fromApp(FirebaseApp app) { app.getOptions().getJsonFactory()); } - @VisibleForTesting - HttpRequestFactory getRequestFactory() { - return requestFactory; - } - - @VisibleForTesting - JsonFactory getJsonFactory() { - return jsonFactory; - } - public TopicManagementResponse subscribeToTopic( String topic, List registrationTokens) throws FirebaseMessagingException { - try { - return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH); - } catch (HttpResponseException e) { - throw createExceptionFromResponse(e); - } catch (IOException e) { - throw new FirebaseMessagingException( - FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e); - } + return sendInstanceIdRequest(topic, registrationTokens, IID_SUBSCRIBE_PATH); } public TopicManagementResponse unsubscribeFromTopic( String topic, List registrationTokens) throws FirebaseMessagingException { - try { - return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH); - } catch (HttpResponseException e) { - throw createExceptionFromResponse(e); - } catch (IOException e) { - throw new FirebaseMessagingException( - FirebaseMessaging.INTERNAL_ERROR, "Error while calling IID backend service", e); - } + return sendInstanceIdRequest(topic, registrationTokens, IID_UNSUBSCRIBE_PATH); } private TopicManagementResponse sendInstanceIdRequest( - String topic, List registrationTokens, String path) throws IOException { + String topic, + List registrationTokens, + String path) throws FirebaseMessagingException { + String url = String.format("%s/%s", IID_HOST, path); Map payload = ImmutableMap.of( "to", getPrefixedTopic(topic), "registration_tokens", registrationTokens ); - HttpResponse response = null; - try { - HttpRequest request = requestFactory.buildPostRequest( - new GenericUrl(url), new JsonHttpContent(jsonFactory, payload)); - request.getHeaders().set("access_token_auth", "true"); - request.setParser(new JsonObjectParser(jsonFactory)); - request.setResponseInterceptor(responseInterceptor); - response = request.execute(); - - JsonParser parser = jsonFactory.createJsonParser(response.getContent()); - InstanceIdServiceResponse parsedResponse = new InstanceIdServiceResponse(); - parser.parse(parsedResponse); - return new TopicManagementResponse(parsedResponse.results); - } finally { - ApiClientUtils.disconnectQuietly(response); - } - } - private FirebaseMessagingException createExceptionFromResponse(HttpResponseException e) { - InstanceIdServiceErrorResponse response = new InstanceIdServiceErrorResponse(); - if (e.getContent() != null) { - try { - JsonParser parser = jsonFactory.createJsonParser(e.getContent()); - parser.parseAndClose(response); - } catch (IOException ignored) { - // ignored - } - } - return newException(response, e); + HttpRequestInfo request = HttpRequestInfo.buildJsonPostRequest(url, payload) + .addHeader("access_token_auth", "true"); + InstanceIdServiceResponse response = new InstanceIdServiceResponse(); + requestFactory.sendAndParse(request, response); + return new TopicManagementResponse(response.results); } private String getPrefixedTopic(String topic) { @@ -165,21 +103,6 @@ private String getPrefixedTopic(String topic) { } } - private static FirebaseMessagingException newException( - InstanceIdServiceErrorResponse response, HttpResponseException e) { - // Infer error code from HTTP status - String code = IID_ERROR_CODES.get(e.getStatusCode()); - if (code == null) { - code = FirebaseMessaging.UNKNOWN_ERROR; - } - String msg = response.error; - if (Strings.isNullOrEmpty(msg)) { - msg = String.format("Unexpected HTTP response with status: %d; body: %s", - e.getStatusCode(), e.getContent()); - } - return new FirebaseMessagingException(code, msg, e); - } - private static class InstanceIdServiceResponse { @Key("results") private List results; @@ -189,4 +112,54 @@ private static class InstanceIdServiceErrorResponse { @Key("error") private String error; } + + private static class InstanceIdErrorHandler + extends AbstractHttpErrorHandler { + + private final JsonFactory jsonFactory; + + InstanceIdErrorHandler(JsonFactory jsonFactory) { + this.jsonFactory = jsonFactory; + } + + @Override + protected FirebaseMessagingException createException(FirebaseException base) { + String message = getCustomMessage(base); + return FirebaseMessagingException.withCustomMessage(base, message); + } + + private String getCustomMessage(FirebaseException base) { + String response = getResponse(base); + InstanceIdServiceErrorResponse parsed = safeParse(response); + if (!Strings.isNullOrEmpty(parsed.error)) { + return "Error while calling the IID service: " + parsed.error; + } + + return base.getMessage(); + } + + private String getResponse(FirebaseException base) { + if (base.getHttpResponse() == null) { + return null; + } + + return base.getHttpResponse().getContent(); + } + + private InstanceIdServiceErrorResponse safeParse(String response) { + InstanceIdServiceErrorResponse parsed = new InstanceIdServiceErrorResponse(); + if (!Strings.isNullOrEmpty(response)) { + // Parse the error response from the IID service. + // Sample response: {"error": "error message text"} + try { + jsonFactory.createJsonParser(response).parse(parsed); + } catch (IOException ignore) { + // Ignore any error that may occur while parsing the error response. The server + // may have responded with a non-json payload. + } + } + + return parsed; + } + } } diff --git a/src/main/java/com/google/firebase/messaging/LightSettings.java b/src/main/java/com/google/firebase/messaging/LightSettings.java index 75a692090..e0e898374 100644 --- a/src/main/java/com/google/firebase/messaging/LightSettings.java +++ b/src/main/java/com/google/firebase/messaging/LightSettings.java @@ -61,7 +61,7 @@ private Builder() {} /** * Sets the lightSettingsColor value with a string. * - * @param lightSettingsColor LightSettingsColor specified in the {@code #rrggbb} format. + * @param color LightSettingsColor specified in the {@code #rrggbb} format. * @return This builder. */ public Builder setColorFromString(String color) { @@ -72,7 +72,7 @@ public Builder setColorFromString(String color) { /** * Sets the lightSettingsColor value in the light settings. * - * @param lightSettingsColor Color to be used in the light settings. + * @param color Color to be used in the light settings. * @return This builder. */ public Builder setColor(LightSettingsColor color) { diff --git a/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java b/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java new file mode 100644 index 000000000..b566befbd --- /dev/null +++ b/src/main/java/com/google/firebase/messaging/MessagingErrorCode.java @@ -0,0 +1,43 @@ +package com.google.firebase.messaging; + +/** + * Error codes that can be raised by the Cloud Messaging APIs. + */ +public enum MessagingErrorCode { + + /** + * APNs certificate or web push auth key was invalid or missing. + */ + THIRD_PARTY_AUTH_ERROR, + + /** + * One or more arguments specified in the request were invalid. + */ + INVALID_ARGUMENT, + + /** + * Internal server error. + */ + INTERNAL, + + /** + * Sending limit exceeded for the message target. + */ + QUOTA_EXCEEDED, + + /** + * The authenticated sender ID is different from the sender ID for the registration token. + */ + SENDER_ID_MISMATCH, + + /** + * Cloud Messaging service is temporarily unavailable. + */ + UNAVAILABLE, + + /** + * App instance was unregistered from FCM. This usually means that the token used is no longer + * valid and a new one must be used. + */ + UNREGISTERED, +} diff --git a/src/main/java/com/google/firebase/messaging/MulticastMessage.java b/src/main/java/com/google/firebase/messaging/MulticastMessage.java index 96a6f19db..b15e58e5f 100644 --- a/src/main/java/com/google/firebase/messaging/MulticastMessage.java +++ b/src/main/java/com/google/firebase/messaging/MulticastMessage.java @@ -51,6 +51,7 @@ public class MulticastMessage { private final AndroidConfig androidConfig; private final WebpushConfig webpushConfig; private final ApnsConfig apnsConfig; + private final FcmOptions fcmOptions; private MulticastMessage(Builder builder) { this.tokens = builder.tokens.build(); @@ -64,6 +65,7 @@ private MulticastMessage(Builder builder) { this.androidConfig = builder.androidConfig; this.webpushConfig = builder.webpushConfig; this.apnsConfig = builder.apnsConfig; + this.fcmOptions = builder.fcmOptions; } List getMessageList() { @@ -71,7 +73,8 @@ List getMessageList() { .setNotification(this.notification) .setAndroidConfig(this.androidConfig) .setApnsConfig(this.apnsConfig) - .setWebpushConfig(this.webpushConfig); + .setWebpushConfig(this.webpushConfig) + .setFcmOptions(this.fcmOptions); if (this.data != null) { builder.putAllData(this.data); } @@ -99,6 +102,7 @@ public static class Builder { private AndroidConfig androidConfig; private WebpushConfig webpushConfig; private ApnsConfig apnsConfig; + private FcmOptions fcmOptions; private Builder() {} @@ -170,6 +174,15 @@ public Builder setApnsConfig(ApnsConfig apnsConfig) { return this; } + /** + * Sets the {@link FcmOptions}, which can be overridden by the platform-specific {@code + * fcm_options} fields. + */ + public Builder setFcmOptions(FcmOptions fcmOptions) { + this.fcmOptions = fcmOptions; + return this; + } + /** * Adds the given key-value pair to the message as a data field. Key or the value may not be * null. diff --git a/src/main/java/com/google/firebase/messaging/Notification.java b/src/main/java/com/google/firebase/messaging/Notification.java index d9b2034ee..a8f48c2ef 100644 --- a/src/main/java/com/google/firebase/messaging/Notification.java +++ b/src/main/java/com/google/firebase/messaging/Notification.java @@ -33,33 +33,6 @@ public class Notification { @Key("image") private final String image; - /** - * Creates a new {@code Notification} using the given title and body. - * - * @param title Title of the notification. - * @param body Body of the notification. - * - * @deprecated Use {@link #Notification(Builder)} instead. - */ - public Notification(String title, String body) { - this(title, body, null); - } - - /** - * Creates a new {@code Notification} using the given title, body, and image. - * - * @param title Title of the notification. - * @param body Body of the notification. - * @param imageUrl URL of the image that is going to be displayed in the notification. - * - * @deprecated Use {@link #Notification(Builder)} instead. - */ - public Notification(String title, String body, String imageUrl) { - this.title = title; - this.body = body; - this.image = imageUrl; - } - private Notification(Builder builder) { this.title = builder.title; this.body = builder.body; @@ -67,9 +40,9 @@ private Notification(Builder builder) { } /** - * Creates a new {@link Notification.Builder}. + * Creates a new {@link Builder}. * - * @return A {@link Notification.Builder} instance. + * @return A {@link Builder} instance. */ public static Builder builder() { return new Builder(); diff --git a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java index b8f92576e..9f75f5871 100644 --- a/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java +++ b/src/main/java/com/google/firebase/messaging/TopicManagementResponse.java @@ -20,6 +20,7 @@ import static com.google.common.base.Preconditions.checkState; import com.google.api.client.json.GenericJson; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.NonNull; @@ -36,10 +37,8 @@ public class TopicManagementResponse { // Server error codes as defined in https://developers.google.com/instance-id/reference/server // TODO: Should we handle other error codes here (e.g. PERMISSION_DENIED)? private static final Map ERROR_CODES = ImmutableMap.builder() - .put("INVALID_ARGUMENT", "invalid-argument") .put("NOT_FOUND", "registration-token-not-registered") .put("INTERNAL", "internal-error") - .put("TOO_MANY_TOPICS", "too-many-topics") .build(); private final int successCount; @@ -100,8 +99,11 @@ public static class Error { private Error(int index, String reason) { this.index = index; - this.reason = ERROR_CODES.containsKey(reason) - ? ERROR_CODES.get(reason) : UNKNOWN_ERROR; + if (reason == null || reason.trim().isEmpty()) { + this.reason = UNKNOWN_ERROR; + } else { + this.reason = ERROR_CODES.getOrDefault(reason, reason.toLowerCase().replace('_', '-')); + } } /** @@ -122,5 +124,13 @@ public int getIndex() { public String getReason() { return reason; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("index", index) + .add("reason", reason) + .toString(); + } } } diff --git a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java index d63f3af95..7c21199cc 100644 --- a/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java +++ b/src/main/java/com/google/firebase/messaging/internal/MessagingServiceErrorResponse.java @@ -2,14 +2,28 @@ import com.google.api.client.json.GenericJson; import com.google.api.client.util.Key; +import com.google.common.collect.ImmutableMap; import com.google.firebase.internal.Nullable; +import com.google.firebase.messaging.MessagingErrorCode; import java.util.List; import java.util.Map; /** * The DTO for parsing error responses from the FCM service. */ -public class MessagingServiceErrorResponse extends GenericJson { +public final class MessagingServiceErrorResponse extends GenericJson { + + private static final Map MESSAGING_ERROR_CODES = + ImmutableMap.builder() + .put("APNS_AUTH_ERROR", MessagingErrorCode.THIRD_PARTY_AUTH_ERROR) + .put("INTERNAL", MessagingErrorCode.INTERNAL) + .put("INVALID_ARGUMENT", MessagingErrorCode.INVALID_ARGUMENT) + .put("QUOTA_EXCEEDED", MessagingErrorCode.QUOTA_EXCEEDED) + .put("SENDER_ID_MISMATCH", MessagingErrorCode.SENDER_ID_MISMATCH) + .put("THIRD_PARTY_AUTH_ERROR", MessagingErrorCode.THIRD_PARTY_AUTH_ERROR) + .put("UNAVAILABLE", MessagingErrorCode.UNAVAILABLE) + .put("UNREGISTERED", MessagingErrorCode.UNREGISTERED) + .build(); private static final String FCM_ERROR_TYPE = "type.googleapis.com/google.firebase.fcm.v1.FcmError"; @@ -17,23 +31,35 @@ public class MessagingServiceErrorResponse extends GenericJson { @Key("error") private Map error; + public String getStatus() { + if (error == null) { + return null; + } + + return (String) error.get("status"); + } + + @Nullable - public String getErrorCode() { + public MessagingErrorCode getMessagingErrorCode() { if (error == null) { return null; } + Object details = error.get("details"); - if (details != null && details instanceof List) { + if (details instanceof List) { for (Object detail : (List) details) { if (detail instanceof Map) { Map detailMap = (Map) detail; if (FCM_ERROR_TYPE.equals(detailMap.get("@type"))) { - return (String) detailMap.get("errorCode"); + String errorCode = (String) detailMap.get("errorCode"); + return MESSAGING_ERROR_CODES.get(errorCode); } } } } - return (String) error.get("status"); + + return null; } @Nullable @@ -41,6 +67,7 @@ public String getErrorMessage() { if (error != null) { return (String) error.get("message"); } + return null; } } diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java index fe5472ef1..9e294e099 100644 --- a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagement.java @@ -286,10 +286,5 @@ private FirebaseProjectManagementService(FirebaseApp app) { serviceInstance.setAndroidAppService(serviceImpl); serviceInstance.setIosAppService(serviceImpl); } - - @Override - public void destroy() { - serviceImpl.destroy(); - } } } diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java index fa939b0c4..580ae76f6 100644 --- a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementException.java @@ -15,18 +15,27 @@ package com.google.firebase.projectmanagement; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseException; -import com.google.firebase.internal.Nullable; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.database.annotations.Nullable; +import com.google.firebase.internal.NonNull; /** * An exception encountered while interacting with the Firebase Project Management Service. */ -public class FirebaseProjectManagementException extends FirebaseException { - FirebaseProjectManagementException(String detailMessage) { - this(detailMessage, null); +public final class FirebaseProjectManagementException extends FirebaseException { + + FirebaseProjectManagementException(@NonNull FirebaseException base) { + this(base, base.getMessage()); + } + + FirebaseProjectManagementException(@NonNull FirebaseException base, @NonNull String message) { + super(base.getErrorCode(), message, base.getCause(), base.getHttpResponse()); } - FirebaseProjectManagementException(String detailMessage, @Nullable Throwable cause) { - super(detailMessage, cause); + FirebaseProjectManagementException( + @NonNull ErrorCode code, @NonNull String message, @Nullable IncomingHttpResponse response) { + super(code, message, null, response); } } diff --git a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java index 72c570c6d..2bf927008 100644 --- a/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java +++ b/src/main/java/com/google/firebase/projectmanagement/FirebaseProjectManagementServiceImpl.java @@ -18,6 +18,7 @@ import static com.google.common.base.Preconditions.checkArgument; import static com.google.common.base.Preconditions.checkNotNull; +import com.google.api.client.http.HttpRequestFactory; import com.google.api.client.http.HttpResponseInterceptor; import com.google.api.client.util.Base64; import com.google.api.client.util.Key; @@ -30,10 +31,12 @@ import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; +import com.google.firebase.ErrorCode; import com.google.firebase.FirebaseApp; import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.ApiClientUtils; import com.google.firebase.internal.CallableOperation; -import com.google.firebase.internal.FirebaseRequestInitializer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; @@ -63,17 +66,20 @@ class FirebaseProjectManagementServiceImpl implements AndroidAppService, IosAppS new CreateIosAppFromAppIdFunction(); FirebaseProjectManagementServiceImpl(FirebaseApp app) { - this(app, Sleeper.DEFAULT, new FirebaseAppScheduler(app)); + this( + app, + Sleeper.DEFAULT, + new FirebaseAppScheduler(app), + ApiClientUtils.newAuthorizedRequestFactory(app)); } - FirebaseProjectManagementServiceImpl(FirebaseApp app, Sleeper sleeper, Scheduler scheduler) { + @VisibleForTesting + FirebaseProjectManagementServiceImpl( + FirebaseApp app, Sleeper sleeper, Scheduler scheduler, HttpRequestFactory requestFactory) { this.app = checkNotNull(app); this.sleeper = checkNotNull(sleeper); this.scheduler = checkNotNull(scheduler); - this.httpHelper = new HttpHelper( - app.getOptions().getJsonFactory(), - app.getOptions().getHttpTransport().createRequestFactory( - new FirebaseRequestInitializer(app))); + this.httpHelper = new HttpHelper(app.getOptions().getJsonFactory(), requestFactory); } @VisibleForTesting @@ -81,14 +87,6 @@ void setInterceptor(HttpResponseInterceptor interceptor) { httpHelper.setInterceptor(interceptor); } - void destroy() { - // NOTE: We don't explicitly tear down anything here. Any instance of IosApp, AndroidApp, or - // FirebaseProjectManagement that depends on this instance will no longer be able to make RPC - // calls. All polling or waiting iOS or Android App creations will be interrupted, even though - // the initial creation RPC (if made successfully) is still processed normally (asynchronously) - // by the server. - } - /* getAndroidApp */ @Override @@ -307,14 +305,14 @@ protected String execute() throws FirebaseProjectManagementException { payloadBuilder.put("display_name", displayName); } OperationResponse operationResponseInstance = new OperationResponse(); - httpHelper.makePostRequest( + IncomingHttpResponse response = httpHelper.makePostRequest( url, payloadBuilder.build(), operationResponseInstance, projectId, "Project ID"); if (Strings.isNullOrEmpty(operationResponseInstance.name)) { - throw HttpHelper.createFirebaseProjectManagementException( + String message = buildMessage( namespace, "Bundle ID", - "Unable to create App: server returned null operation name.", - /* cause= */ null); + "Unable to create App: server returned null operation name."); + throw new FirebaseProjectManagementException(ErrorCode.INTERNAL, message, response); } return operationResponseInstance.name; } @@ -330,7 +328,8 @@ private String pollOperation(String projectId, String operationName) * Math.pow(POLL_EXPONENTIAL_BACKOFF_FACTOR, currentAttempt)); sleepOrThrow(projectId, delayMillis); OperationResponse operationResponseInstance = new OperationResponse(); - httpHelper.makeGetRequest(url, operationResponseInstance, projectId, "Project ID"); + IncomingHttpResponse response = httpHelper.makeGetRequest( + url, operationResponseInstance, projectId, "Project ID"); if (!operationResponseInstance.done) { continue; } @@ -338,19 +337,20 @@ private String pollOperation(String projectId, String operationName) // or 'error' is set. if (operationResponseInstance.response == null || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) { - throw HttpHelper.createFirebaseProjectManagementException( + String message = buildMessage( projectId, "Project ID", - "Unable to create App: internal server error.", - /* cause= */ null); + "Unable to create App: internal server error."); + throw new FirebaseProjectManagementException(ErrorCode.INTERNAL, message, response); } return operationResponseInstance.response.appId; } - throw HttpHelper.createFirebaseProjectManagementException( + + String message = buildMessage( projectId, "Project ID", - "Unable to create App: deadline exceeded.", - /* cause= */ null); + "Unable to create App: deadline exceeded."); + throw new FirebaseProjectManagementException(ErrorCode.DEADLINE_EXCEEDED, message, null); } /** @@ -409,19 +409,22 @@ private WaitOperationRunnable( public void run() { String url = String.format("%s/v1/%s", FIREBASE_PROJECT_MANAGEMENT_URL, operationName); OperationResponse operationResponseInstance = new OperationResponse(); + IncomingHttpResponse httpResponse; try { - httpHelper.makeGetRequest(url, operationResponseInstance, projectId, "Project ID"); + httpResponse = httpHelper.makeGetRequest( + url, operationResponseInstance, projectId, "Project ID"); } catch (FirebaseProjectManagementException e) { settableFuture.setException(e); return; } if (!operationResponseInstance.done) { if (numberOfPreviousPolls + 1 >= MAXIMUM_POLLING_ATTEMPTS) { - settableFuture.setException(HttpHelper.createFirebaseProjectManagementException( - projectId, + String message = buildMessage(projectId, "Project ID", - "Unable to create App: deadline exceeded.", - /* cause= */ null)); + "Unable to create App: deadline exceeded."); + FirebaseProjectManagementException exception = new FirebaseProjectManagementException( + ErrorCode.DEADLINE_EXCEEDED, message, httpResponse); + settableFuture.setException(exception); } else { long delayMillis = (long) ( POLL_BASE_WAIT_TIME_MILLIS @@ -440,11 +443,12 @@ public void run() { // or 'error' is set. if (operationResponseInstance.response == null || Strings.isNullOrEmpty(operationResponseInstance.response.appId)) { - settableFuture.setException(HttpHelper.createFirebaseProjectManagementException( - projectId, + String message = buildMessage(projectId, "Project ID", - "Unable to create App: internal server error.", - /* cause= */ null)); + "Unable to create App: internal server error."); + FirebaseProjectManagementException exception = new FirebaseProjectManagementException( + ErrorCode.INTERNAL, message, httpResponse); + settableFuture.setException(exception); } else { settableFuture.set(operationResponseInstance.response.appId); } @@ -754,14 +758,17 @@ private void sleepOrThrow(String projectId, long delayMillis) try { sleeper.sleep(delayMillis); } catch (InterruptedException e) { - throw HttpHelper.createFirebaseProjectManagementException( - projectId, + String message = buildMessage(projectId, "Project ID", - "Unable to create App: exponential backoff interrupted.", - /* cause= */ null); + "Unable to create App: exponential backoff interrupted."); + throw new FirebaseProjectManagementException(ErrorCode.ABORTED, message, null); } } + private String buildMessage(String resourceId, String resourceIdName, String description) { + return String.format("%s \"%s\": %s", resourceIdName, resourceId, description); + } + /* Helper types. */ private interface CreateAppFromAppIdFunction extends ApiFunction {} diff --git a/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java index 2143c1453..fb29f7b86 100644 --- a/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java +++ b/src/main/java/com/google/firebase/projectmanagement/HttpHelper.java @@ -16,84 +16,57 @@ package com.google.firebase.projectmanagement; -import com.google.api.client.http.GenericUrl; -import com.google.api.client.http.HttpRequest; +import com.google.api.client.http.HttpMethods; import com.google.api.client.http.HttpRequestFactory; -import com.google.api.client.http.HttpResponse; -import com.google.api.client.http.HttpResponseException; import com.google.api.client.http.HttpResponseInterceptor; -import com.google.api.client.http.json.JsonHttpContent; import com.google.api.client.json.JsonFactory; -import com.google.api.client.json.JsonObjectParser; -import com.google.common.annotations.VisibleForTesting; -import com.google.common.base.Charsets; -import com.google.common.collect.ImmutableMap; -import com.google.firebase.internal.Nullable; +import com.google.firebase.FirebaseException; +import com.google.firebase.IncomingHttpResponse; +import com.google.firebase.internal.AbstractPlatformErrorHandler; +import com.google.firebase.internal.ErrorHandlingHttpClient; +import com.google.firebase.internal.HttpRequestInfo; import com.google.firebase.internal.SdkUtils; -import java.io.IOException; - -class HttpHelper { - - @VisibleForTesting static final String PATCH_OVERRIDE_KEY = "X-HTTP-Method-Override"; - @VisibleForTesting static final String PATCH_OVERRIDE_VALUE = "PATCH"; - private static final ImmutableMap ERROR_CODES = - ImmutableMap.builder() - .put(401, "Request not authorized.") - .put(403, "Client does not have sufficient privileges.") - .put(404, "Failed to find the resource.") - .put(409, "The resource already exists.") - .put(429, "Request throttled by the backend server.") - .put(500, "Internal server error.") - .put(503, "Backend servers are over capacity. Try again later.") - .build(); + +final class HttpHelper { + private static final String CLIENT_VERSION_HEADER = "X-Client-Version"; - private final String clientVersion = "Java/Admin/" + SdkUtils.getVersion(); - private final JsonFactory jsonFactory; - private final HttpRequestFactory requestFactory; - private HttpResponseInterceptor interceptor; + private static final String CLIENT_VERSION = "Java/Admin/" + SdkUtils.getVersion(); + + private final ErrorHandlingHttpClient httpClient; HttpHelper(JsonFactory jsonFactory, HttpRequestFactory requestFactory) { - this.jsonFactory = jsonFactory; - this.requestFactory = requestFactory; + ProjectManagementErrorHandler errorHandler = new ProjectManagementErrorHandler(jsonFactory); + this.httpClient = new ErrorHandlingHttpClient<>(requestFactory, jsonFactory, errorHandler); } void setInterceptor(HttpResponseInterceptor interceptor) { - this.interceptor = interceptor; + httpClient.setInterceptor(interceptor); } - void makeGetRequest( + IncomingHttpResponse makeGetRequest( String url, T parsedResponseInstance, String requestIdentifier, String requestIdentifierDescription) throws FirebaseProjectManagementException { - try { - makeRequest( - requestFactory.buildGetRequest(new GenericUrl(url)), - parsedResponseInstance, - requestIdentifier, - requestIdentifierDescription); - } catch (IOException e) { - handleError(requestIdentifier, requestIdentifierDescription, e); - } + return makeRequest( + HttpRequestInfo.buildGetRequest(url), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); } - void makePostRequest( + IncomingHttpResponse makePostRequest( String url, Object payload, T parsedResponseInstance, String requestIdentifier, String requestIdentifierDescription) throws FirebaseProjectManagementException { - try { - makeRequest( - requestFactory.buildPostRequest( - new GenericUrl(url), new JsonHttpContent(jsonFactory, payload)), - parsedResponseInstance, - requestIdentifier, - requestIdentifierDescription); - } catch (IOException e) { - handleError(requestIdentifier, requestIdentifierDescription, e); - } + return makeRequest( + HttpRequestInfo.buildJsonPostRequest(url, payload), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); } void makePatchRequest( @@ -102,15 +75,11 @@ void makePatchRequest( T parsedResponseInstance, String requestIdentifier, String requestIdentifierDescription) throws FirebaseProjectManagementException { - try { - HttpRequest baseRequest = requestFactory.buildPostRequest( - new GenericUrl(url), new JsonHttpContent(jsonFactory, payload)); - baseRequest.getHeaders().set(PATCH_OVERRIDE_KEY, PATCH_OVERRIDE_VALUE); - makeRequest( - baseRequest, parsedResponseInstance, requestIdentifier, requestIdentifierDescription); - } catch (IOException e) { - handleError(requestIdentifier, requestIdentifierDescription, e); - } + makeRequest( + HttpRequestInfo.buildJsonRequest(HttpMethods.PATCH, url, payload), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); } void makeDeleteRequest( @@ -118,69 +87,40 @@ void makeDeleteRequest( T parsedResponseInstance, String requestIdentifier, String requestIdentifierDescription) throws FirebaseProjectManagementException { - try { - makeRequest( - requestFactory.buildDeleteRequest(new GenericUrl(url)), - parsedResponseInstance, - requestIdentifier, - requestIdentifierDescription); - } catch (IOException e) { - handleError(requestIdentifier, requestIdentifierDescription, e); - } + makeRequest( + HttpRequestInfo.buildDeleteRequest(url), + parsedResponseInstance, + requestIdentifier, + requestIdentifierDescription); } - void makeRequest( - HttpRequest baseRequest, + private IncomingHttpResponse makeRequest( + HttpRequestInfo baseRequest, T parsedResponseInstance, String requestIdentifier, String requestIdentifierDescription) throws FirebaseProjectManagementException { - HttpResponse response = null; try { - baseRequest.getHeaders().set(CLIENT_VERSION_HEADER, clientVersion); - baseRequest.setParser(new JsonObjectParser(jsonFactory)); - baseRequest.setResponseInterceptor(interceptor); - response = baseRequest.execute(); - jsonFactory.createJsonParser(response.getContent(), Charsets.UTF_8) - .parseAndClose(parsedResponseInstance); - } catch (Exception e) { - handleError(requestIdentifier, requestIdentifierDescription, e); - } finally { - disconnectQuietly(response); + baseRequest.addHeader(CLIENT_VERSION_HEADER, CLIENT_VERSION); + IncomingHttpResponse response = httpClient.send(baseRequest); + httpClient.parse(response, parsedResponseInstance); + return response; + } catch (FirebaseProjectManagementException e) { + String message = String.format( + "%s \"%s\": %s", requestIdentifierDescription, requestIdentifier, e.getMessage()); + throw new FirebaseProjectManagementException(e, message); } } - private static void disconnectQuietly(HttpResponse response) { - if (response != null) { - try { - response.disconnect(); - } catch (IOException ignored) { - // Ignored. - } - } - } + private static class ProjectManagementErrorHandler + extends AbstractPlatformErrorHandler { - private static void handleError( - String requestIdentifier, String requestIdentifierDescription, Exception e) - throws FirebaseProjectManagementException { - String messageBody = "Error while invoking Firebase Project Management service."; - if (e instanceof HttpResponseException) { - int statusCode = ((HttpResponseException) e).getStatusCode(); - if (ERROR_CODES.containsKey(statusCode)) { - messageBody = ERROR_CODES.get(statusCode); - } + ProjectManagementErrorHandler(JsonFactory jsonFactory) { + super(jsonFactory); } - throw createFirebaseProjectManagementException( - requestIdentifier, requestIdentifierDescription, messageBody, e); - } - static FirebaseProjectManagementException createFirebaseProjectManagementException( - String requestIdentifier, - String requestIdentifierDescription, - String messageBody, - @Nullable Exception cause) { - return new FirebaseProjectManagementException( - String.format( - "%s \"%s\": %s", requestIdentifierDescription, requestIdentifier, messageBody), - cause); + @Override + protected FirebaseProjectManagementException createException(FirebaseException base) { + return new FirebaseProjectManagementException(base); + } } } diff --git a/src/main/java/com/google/firebase/remoteconfig/AndCondition.java b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java new file mode 100644 index 000000000..ec118cef2 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/AndCondition.java @@ -0,0 +1,61 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.AndConditionResponse; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.OneOfConditionResponse; + +import java.util.List; +import java.util.stream.Collectors; + +final class AndCondition { + private final ImmutableList conditions; + + AndCondition(@NonNull List conditions) { + checkNotNull(conditions, "List of conditions for AND operation must not be null."); + checkArgument(!conditions.isEmpty(), + "List of conditions for AND operation must not be empty."); + this.conditions = ImmutableList.copyOf(conditions); + } + + AndCondition(AndConditionResponse andConditionResponse) { + List conditionList = andConditionResponse.getConditions(); + checkNotNull(conditionList, "List of conditions for AND operation must not be null."); + checkArgument(!conditionList.isEmpty(), + "List of conditions for AND operation must not be empty"); + this.conditions = conditionList.stream() + .map(OneOfCondition::new) + .collect(ImmutableList.toImmutableList()); + } + + @NonNull + ImmutableList getConditions() { + return conditions; + } + + AndConditionResponse toAndConditionResponse() { + return new AndConditionResponse() + .setConditions(this.conditions.stream() + .map(OneOfCondition::toOneOfConditionResponse) + .collect(Collectors.toList())); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/Condition.java b/src/main/java/com/google/firebase/remoteconfig/Condition.java new file mode 100644 index 000000000..6ccde59d9 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/Condition.java @@ -0,0 +1,174 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import com.google.firebase.remoteconfig.internal.TemplateResponse.ConditionResponse; + +import java.util.Objects; + +/** + * Represents a Remote Config condition that can be included in a {@link Template}. + * A condition targets a specific group of users. A list of these conditions make up + * part of a Remote Config template. + */ +public final class Condition { + + private String name; + private String expression; + private TagColor tagColor; + + /** + * Creates a new {@link Condition}. + * + * @param name A non-null, non-empty, and unique name of this condition. + * @param expression A non-null and non-empty expression of this condition. + */ + public Condition(@NonNull String name, @NonNull String expression) { + this(name, expression, null); + } + + /** + * Creates a new {@link Condition}. + * + * @param name A non-null, non-empty, and unique name of this condition. + * @param expression A non-null and non-empty expression of this condition. + * @param tagColor A color associated with this condition for display purposes in the + * Firebase Console. Not specifying this value results in the console picking an + * arbitrary color to associate with the condition. + */ + public Condition(@NonNull String name, @NonNull String expression, @Nullable TagColor tagColor) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + checkArgument(!Strings.isNullOrEmpty(expression), + "condition expression must not be null or empty"); + this.name = name; + this.expression = expression; + this.tagColor = tagColor; + } + + Condition(@NonNull ConditionResponse conditionResponse) { + checkNotNull(conditionResponse); + this.name = conditionResponse.getName(); + this.expression = conditionResponse.getExpression(); + if (!Strings.isNullOrEmpty(conditionResponse.getTagColor())) { + this.tagColor = TagColor.valueOf(conditionResponse.getTagColor()); + } + } + + /** + * Gets the name of the condition. + * + * @return The name of the condition. + */ + @NonNull + public String getName() { + return name; + } + + /** + * Gets the expression of the condition. + * + * @return The expression of the condition. + */ + @NonNull + public String getExpression() { + return expression; + } + + /** + * Gets the tag color of the condition used for display purposes in the Firebase Console. + * + * @return The tag color of the condition. + */ + @NonNull + public TagColor getTagColor() { + return tagColor; + } + + /** + * Sets the name of the condition. + * + * @param name A non-empty and unique name of this condition. + * @return This {@link Condition}. + */ + public Condition setName(@NonNull String name) { + checkArgument(!Strings.isNullOrEmpty(name), "condition name must not be null or empty"); + this.name = name; + return this; + } + + /** + * Sets the expression of the condition. + * + *

See + * condition expressions for the expected syntax of this field. + * + * @param expression The logic of this condition. + * @return This {@link Condition}. + */ + public Condition setExpression(@NonNull String expression) { + checkArgument(!Strings.isNullOrEmpty(expression), + "condition expression must not be null or empty"); + this.expression = expression; + return this; + } + + /** + * Sets the tag color of the condition. + * + *

The color associated with this condition for display purposes in the Firebase Console. + * Not specifying this value results in the console picking an arbitrary color to associate + * with the condition. + * + * @param tagColor The tag color of this condition. + * @return This {@link Condition}. + */ + public Condition setTagColor(@Nullable TagColor tagColor) { + this.tagColor = tagColor; + return this; + } + + ConditionResponse toConditionResponse() { + return new ConditionResponse() + .setName(this.name) + .setExpression(this.expression) + .setTagColor(this.tagColor == null ? null : this.tagColor.getColor()); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + Condition condition = (Condition) o; + return Objects.equals(name, condition.name) + && Objects.equals(expression, condition.expression) && tagColor == condition.tagColor; + } + + @Override + public int hashCode() { + return Objects.hash(name, expression, tagColor); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java new file mode 100644 index 000000000..63718b32b --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/ConditionEvaluator.java @@ -0,0 +1,346 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; +import static com.google.common.collect.ImmutableMap.toImmutableMap; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.firebase.internal.NonNull; +import com.google.firebase.internal.Nullable; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.function.BiPredicate; +import java.util.function.IntPredicate; +import java.util.regex.Pattern; +import java.util.regex.PatternSyntaxException; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +final class ConditionEvaluator { + private static final int MAX_CONDITION_RECURSION_DEPTH = 10; + private static final Logger logger = LoggerFactory.getLogger(ConditionEvaluator.class); + private static final BigInteger MICRO_PERCENT_MODULO = BigInteger.valueOf(100_000_000L); + private static final Pattern SEMVER_PATTERN = Pattern.compile("^[0-9]+(?:\\.[0-9]+){0,4}$"); + + /** + * Evaluates server conditions and assigns a boolean value to each condition. + * + * @param conditions List of conditions which are to be evaluated. + * @param context A map with additional metadata used during evaluation. + * @return A map of condition to evaluated value. + */ + @NonNull + Map evaluateConditions( + @NonNull List conditions, @Nullable KeysAndValues context) { + checkNotNull(conditions, "List of conditions must not be null."); + checkArgument(!conditions.isEmpty(), "List of conditions must not be empty."); + if (context == null || conditions.isEmpty()) { + return ImmutableMap.of(); + } + KeysAndValues evaluationContext = + context != null ? context : new KeysAndValues.Builder().build(); + + Map evaluatedConditions = + conditions.stream() + .collect( + toImmutableMap( + ServerCondition::getName, + condition -> + evaluateCondition( + condition.getCondition(), evaluationContext, /* nestingLevel= */ 0))); + + return evaluatedConditions; + } + + private boolean evaluateCondition( + OneOfCondition condition, KeysAndValues context, int nestingLevel) { + if (nestingLevel > MAX_CONDITION_RECURSION_DEPTH) { + logger.warn("Maximum condition recursion depth exceeded."); + return false; + } + + if (condition.getOrCondition() != null) { + return evaluateOrCondition(condition.getOrCondition(), context, nestingLevel + 1); + } else if (condition.getAndCondition() != null) { + return evaluateAndCondition(condition.getAndCondition(), context, nestingLevel + 1); + } else if (condition.isTrue() != null) { + return true; + } else if (condition.isFalse() != null) { + return false; + } else if (condition.getCustomSignal() != null) { + return evaluateCustomSignalCondition(condition.getCustomSignal(), context); + } else if (condition.getPercent() != null) { + return evaluatePercentCondition(condition.getPercent(), context); + } + logger.atWarn().log("Received invalid condition for evaluation."); + return false; + } + + private boolean evaluateOrCondition( + OrCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .anyMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateAndCondition( + AndCondition condition, KeysAndValues context, int nestingLevel) { + return condition.getConditions().stream() + .allMatch(subCondition -> evaluateCondition(subCondition, context, nestingLevel + 1)); + } + + private boolean evaluateCustomSignalCondition( + CustomSignalCondition condition, KeysAndValues context) { + CustomSignalOperator customSignalOperator = condition.getCustomSignalOperator(); + String customSignalKey = condition.getCustomSignalKey(); + ImmutableList targetCustomSignalValues = + ImmutableList.copyOf(condition.getTargetCustomSignalValues()); + + if (targetCustomSignalValues.isEmpty()) { + logger.warn( + String.format( + "Values must be assigned to all custom signal fields. Operator:%s, Key:%s, Values:%s", + customSignalOperator, customSignalKey, targetCustomSignalValues)); + return false; + } + + String customSignalValue = context.get(customSignalKey); + if (customSignalValue == null) { + return false; + } + + switch (customSignalOperator) { + // String operations. + case STRING_CONTAINS: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_DOES_NOT_CONTAIN: + return !compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.contains(targetSignal)); + case STRING_EXACTLY_MATCHES: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> customSignal.equals(targetSignal)); + case STRING_CONTAINS_REGEX: + return compareStrings( + targetCustomSignalValues, + customSignalValue, + (customSignal, targetSignal) -> compareStringRegex(customSignal, targetSignal)); + + // Numeric operations. + case NUMERIC_LESS_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case NUMERIC_LESS_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case NUMERIC_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case NUMERIC_NOT_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result != 0); + case NUMERIC_GREATER_THAN: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case NUMERIC_GREATER_EQUAL: + return compareNumbers(targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + + // Semantic operations. + case SEMANTIC_VERSION_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result == 0); + case SEMANTIC_VERSION_GREATER_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result >= 0); + case SEMANTIC_VERSION_GREATER_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result > 0); + case SEMANTIC_VERSION_LESS_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result <= 0); + case SEMANTIC_VERSION_LESS_THAN: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result < 0); + case SEMANTIC_VERSION_NOT_EQUAL: + return compareSemanticVersions( + targetCustomSignalValues, customSignalValue, (result) -> result != 0); + default: + return false; + } + } + + private boolean evaluatePercentCondition(PercentCondition condition, KeysAndValues context) { + if (!context.containsKey("randomizationId")) { + logger.warn("Percentage operation must not be performed without randomizationId"); + return false; + } + + PercentConditionOperator operator = condition.getPercentConditionOperator(); + + // The micro-percent interval to be used with the BETWEEN operator. + MicroPercentRange microPercentRange = condition.getMicroPercentRange(); + int microPercentUpperBound = + microPercentRange != null ? microPercentRange.getMicroPercentUpperBound() : 0; + int microPercentLowerBound = + microPercentRange != null ? microPercentRange.getMicroPercentLowerBound() : 0; + // The limit of percentiles to target in micro-percents when using the + // LESS_OR_EQUAL and GREATER_THAN operators. The value must be in the range [0 + // and 100000000]. + int microPercent = condition.getMicroPercent(); + BigInteger microPercentile = + getMicroPercentile(condition.getSeed(), context.get("randomizationId")); + switch (operator) { + case LESS_OR_EQUAL: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) <= 0; + case GREATER_THAN: + return microPercentile.compareTo(BigInteger.valueOf(microPercent)) > 0; + case BETWEEN: + return microPercentile.compareTo(BigInteger.valueOf(microPercentLowerBound)) > 0 + && microPercentile.compareTo(BigInteger.valueOf(microPercentUpperBound)) <= 0; + case UNSPECIFIED: + default: + return false; + } + } + + private BigInteger getMicroPercentile(String seed, String randomizationId) { + String seedPrefix = seed != null && !seed.isEmpty() ? seed + "." : ""; + String stringToHash = seedPrefix + randomizationId; + BigInteger hash = hashSeededRandomizationId(stringToHash); + BigInteger microPercentile = hash.mod(MICRO_PERCENT_MODULO); + + return microPercentile; + } + + private BigInteger hashSeededRandomizationId(String seededRandomizationId) { + try { + // Create a SHA-256 hash. + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashBytes = digest.digest(seededRandomizationId.getBytes(StandardCharsets.UTF_8)); + + // Convert the hash bytes to a BigInteger + return new BigInteger(1, hashBytes); + } catch (NoSuchAlgorithmException e) { + logger.error("SHA-256 algorithm not found", e); + throw new RuntimeException("SHA-256 algorithm not found", e); + } + } + + private boolean compareStrings( + ImmutableList targetValues, + String customSignal, + BiPredicate compareFunction) { + return targetValues.stream() + .anyMatch(targetValue -> compareFunction.test(customSignal, targetValue)); + } + + private boolean compareStringRegex(String customSignal, String targetSignal) { + try { + return Pattern.compile(targetSignal).matcher(customSignal).matches(); + } catch (PatternSyntaxException e) { + return false; + } + } + + private boolean compareNumbers( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn( + String.format( + "Target values must contain 1 element for numeric operations. Target Value: %s", + targetValues)); + return false; + } + + try { + double customSignalDouble = Double.parseDouble(customSignal); + double targetValue = Double.parseDouble(targetValues.get(0)); + int comparisonResult = Double.compare(customSignalDouble, targetValue); + return compareFunction.test(comparisonResult); + } catch (NumberFormatException e) { + logger.warn( + "Error parsing numeric values: customSignal=%s, targetValue=%s", + customSignal, targetValues.get(0), e); + return false; + } + } + + private boolean compareSemanticVersions( + ImmutableList targetValues, String customSignal, IntPredicate compareFunction) { + if (targetValues.size() != 1) { + logger.warn(String.format("Target values must contain 1 element for semantic operation.")); + return false; + } + + String targetValueString = targetValues.get(0); + if (!validateSemanticVersion(targetValueString) || !validateSemanticVersion(customSignal)) { + return false; + } + + List targetVersion = parseSemanticVersion(targetValueString); + List customSignalVersion = parseSemanticVersion(customSignal); + + int maxLength = 5; + if (targetVersion.size() > maxLength || customSignalVersion.size() > maxLength) { + logger.warn( + "Semantic version max length(%s) exceeded. Target: %s, Custom Signal: %s", + maxLength, targetValueString, customSignal); + return false; + } + + int comparison = compareSemanticVersions(customSignalVersion, targetVersion); + return compareFunction.test(comparison); + } + + private int compareSemanticVersions(List version1, List version2) { + int maxLength = Math.max(version1.size(), version2.size()); + int version1Size = version1.size(); + int version2Size = version2.size(); + + for (int i = 0; i < maxLength; i++) { + // Default to 0 if segment is missing + int v1 = i < version1Size ? version1.get(i) : 0; + int v2 = i < version2Size ? version2.get(i) : 0; + + int comparison = Integer.compare(v1, v2); + if (comparison != 0) { + return comparison; + } + } + // Versions are equal + return 0; + } + + private List parseSemanticVersion(String versionString) { + return Arrays.stream(versionString.split("\\.")) + .map(Integer::parseInt) + .collect(Collectors.toList()); + } + + private boolean validateSemanticVersion(String version) { + return SEMVER_PATTERN.matcher(version).matches(); + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java new file mode 100644 index 000000000..a8d96efdf --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalCondition.java @@ -0,0 +1,140 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.firebase.internal.NonNull; +import com.google.firebase.remoteconfig.internal.ServerTemplateResponse.CustomSignalConditionResponse; + +import java.util.ArrayList; +import java.util.List; + +final class CustomSignalCondition { + private final String customSignalKey; + private final CustomSignalOperator customSignalOperator; + private final ImmutableList targetCustomSignalValues; + + public CustomSignalCondition( + @NonNull String customSignalKey, + @NonNull CustomSignalOperator customSignalOperator, + @NonNull List targetCustomSignalValues) { + checkArgument( + !Strings.isNullOrEmpty(customSignalKey), "Custom signal key must not be null or empty."); + checkNotNull(customSignalOperator); + checkNotNull(targetCustomSignalValues); + checkArgument( + !targetCustomSignalValues.isEmpty(), "Target custom signal values must not be empty."); + this.customSignalKey = customSignalKey.trim(); + this.customSignalOperator = customSignalOperator; + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValues); + } + + CustomSignalCondition(CustomSignalConditionResponse customSignalCondition) { + checkArgument( + !Strings.isNullOrEmpty(customSignalCondition.getKey()), + "Custom signal key must not be null or empty."); + checkArgument( + !customSignalCondition.getTargetValues().isEmpty(), + "Target custom signal values must not be empty."); + this.customSignalKey = customSignalCondition.getKey().trim(); + List targetCustomSignalValuesList = customSignalCondition.getTargetValues(); + this.targetCustomSignalValues = ImmutableList.copyOf(targetCustomSignalValuesList); + switch (customSignalCondition.getOperator()) { + case "NUMERIC_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_EQUAL; + break; + case "NUMERIC_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_EQUAL; + break; + case "NUMERIC_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_GREATER_THAN; + break; + case "NUMERIC_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_EQUAL; + break; + case "NUMERIC_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.NUMERIC_LESS_THAN; + break; + case "NUMERIC_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.NUMERIC_NOT_EQUAL; + break; + case "SEMANTIC_VERSION_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_EQUAL; + break; + case "SEMANTIC_VERSION_GREATER_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_GREATER_THAN; + break; + case "SEMANTIC_VERSION_LESS_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_EQUAL; + break; + case "SEMANTIC_VERSION_LESS_THAN": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_LESS_THAN; + break; + case "SEMANTIC_VERSION_NOT_EQUAL": + this.customSignalOperator = CustomSignalOperator.SEMANTIC_VERSION_NOT_EQUAL; + break; + case "STRING_CONTAINS": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS; + break; + case "STRING_CONTAINS_REGEX": + this.customSignalOperator = CustomSignalOperator.STRING_CONTAINS_REGEX; + break; + case "STRING_DOES_NOT_CONTAIN": + this.customSignalOperator = CustomSignalOperator.STRING_DOES_NOT_CONTAIN; + break; + case "STRING_EXACTLY_MATCHES": + this.customSignalOperator = CustomSignalOperator.STRING_EXACTLY_MATCHES; + break; + default: + this.customSignalOperator = CustomSignalOperator.UNSPECIFIED; + } + checkArgument( + this.customSignalOperator != CustomSignalOperator.UNSPECIFIED, + "Custom signal operator passed is invalid"); + } + + @NonNull + String getCustomSignalKey() { + return customSignalKey; + } + + @NonNull + CustomSignalOperator getCustomSignalOperator() { + return customSignalOperator; + } + + @NonNull + List getTargetCustomSignalValues() { + return new ArrayList<>(targetCustomSignalValues); + } + + CustomSignalConditionResponse toCustomConditonResponse() { + CustomSignalConditionResponse customSignalConditionResponse = + new CustomSignalConditionResponse(); + customSignalConditionResponse.setKey(this.customSignalKey); + customSignalConditionResponse.setOperator(this.customSignalOperator.getOperator()); + customSignalConditionResponse.setTargetValues(this.targetCustomSignalValues); + return customSignalConditionResponse; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java new file mode 100644 index 000000000..0c0924d62 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/CustomSignalOperator.java @@ -0,0 +1,54 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkArgument; + +import com.google.common.base.Strings; +import com.google.firebase.internal.NonNull; + +enum CustomSignalOperator { + NUMERIC_EQUAL("NUMERIC_EQUAL"), + NUMERIC_GREATER_EQUAL("NUMERIC_GREATER_EQUAL"), + NUMERIC_GREATER_THAN("NUMERIC_GREATER_THAN"), + NUMERIC_LESS_EQUAL("NUMERIC_LESS_EQUAL"), + NUMERIC_LESS_THAN("NUMERIC_LESS_THAN"), + NUMERIC_NOT_EQUAL("NUMERIC_NOT_EQUAL"), + SEMANTIC_VERSION_EQUAL("SEMANTIC_VERSION_EQUAL"), + SEMANTIC_VERSION_GREATER_EQUAL("SEMANTIC_VERSION_GREATER_EQUAL"), + SEMANTIC_VERSION_GREATER_THAN("SEMANTIC_VERSION_GREATER_THAN"), + SEMANTIC_VERSION_LESS_EQUAL("SEMANTIC_VERSION_LESS_EQUAL"), + SEMANTIC_VERSION_LESS_THAN("SEMANTIC_VERSION_LESS_THAN"), + SEMANTIC_VERSION_NOT_EQUAL("SEMANTIC_VERSION_NOT_EQUAL"), + STRING_CONTAINS("STRING_CONTAINS"), + STRING_CONTAINS_REGEX("STRING_CONTAINS_REGEX"), + STRING_DOES_NOT_CONTAIN("STRING_DOES_NOT_CONTAIN"), + STRING_EXACTLY_MATCHES("STRING_EXACTLY_MATCHES"), + UNSPECIFIED("CUSTOM_SIGNAL_OPERATOR_UNSPECIFIED"); + + private final String operator; + + CustomSignalOperator(@NonNull String operator) { + checkArgument(!Strings.isNullOrEmpty(operator), "Operator must not be null or empty."); + this.operator = operator; + } + + @NonNull + String getOperator() { + return operator; + } +} diff --git a/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java new file mode 100644 index 000000000..e3edce4e4 --- /dev/null +++ b/src/main/java/com/google/firebase/remoteconfig/FirebaseRemoteConfig.java @@ -0,0 +1,483 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.firebase.remoteconfig; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.google.api.core.ApiFuture; +import com.google.common.annotations.VisibleForTesting; +import com.google.firebase.FirebaseApp; +import com.google.firebase.ImplFirebaseTrampolines; +import com.google.firebase.internal.CallableOperation; +import com.google.firebase.internal.FirebaseService; +import com.google.firebase.internal.NonNull; + +/** + * This class is the entry point for all server-side Firebase Remote Config actions. + * + *

You can get an instance of {@link FirebaseRemoteConfig} via {@link #getInstance(FirebaseApp)}, + * and then use it to manage Remote Config templates. + */ +public final class FirebaseRemoteConfig { + + private static final String SERVICE_ID = FirebaseRemoteConfig.class.getName(); + private final FirebaseApp app; + private final FirebaseRemoteConfigClient remoteConfigClient; + + @VisibleForTesting + FirebaseRemoteConfig(FirebaseApp app, FirebaseRemoteConfigClient client) { + this.app = checkNotNull(app); + this.remoteConfigClient = checkNotNull(client); + } + + private FirebaseRemoteConfig(FirebaseApp app) { + this(app, FirebaseRemoteConfigClientImpl.fromApp(app)); + } + + /** + * Gets the {@link FirebaseRemoteConfig} instance for the default {@link FirebaseApp}. + * + * @return The {@link FirebaseRemoteConfig} instance for the default {@link FirebaseApp}. + */ + public static FirebaseRemoteConfig getInstance() { + return getInstance(FirebaseApp.getInstance()); + } + + /** + * Gets the {@link FirebaseRemoteConfig} instance for the specified {@link FirebaseApp}. + * + * @return The {@link FirebaseRemoteConfig} instance for the specified {@link FirebaseApp}. + */ + public static synchronized FirebaseRemoteConfig getInstance(FirebaseApp app) { + FirebaseRemoteConfigService service = ImplFirebaseTrampolines.getService(app, SERVICE_ID, + FirebaseRemoteConfigService.class); + if (service == null) { + service = ImplFirebaseTrampolines.addService(app, new FirebaseRemoteConfigService(app)); + } + return service.getInstance(); + } + + /** + * Gets the current active version of the Remote Config template. + * + * @return A {@link Template}. + * @throws FirebaseRemoteConfigException If an error occurs while getting the template. + */ + public Template getTemplate() throws FirebaseRemoteConfigException { + return getTemplateOp().call(); + } + + /** + * Similar to {@link #getTemplate()} but performs the operation asynchronously. + * + * @return An {@code ApiFuture} that completes with a {@link Template} when + * the template is available. + */ + public ApiFuture