diff --git a/.github/resources/integ-service-account.json.gpg b/.github/resources/integ-service-account.json.gpg index 7740dccd8..5a52805c9 100644 Binary files a/.github/resources/integ-service-account.json.gpg and b/.github/resources/integ-service-account.json.gpg differ diff --git a/.github/scripts/publish_preflight_check.sh b/.github/scripts/publish_preflight_check.sh index 1d001c3b9..38fe49a88 100755 --- a/.github/scripts/publish_preflight_check.sh +++ b/.github/scripts/publish_preflight_check.sh @@ -159,8 +159,8 @@ echo_info "Generating changelog" echo_info "--------------------------------------------" echo_info "" -echo_info "---< git fetch origin master --prune --unshallow >---" -git fetch origin master --prune --unshallow +echo_info "---< git fetch origin main --prune --unshallow >---" +git fetch origin main --prune --unshallow echo "" echo_info "Generating changelog from history..." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfd29e2cc..5bf78a56b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,20 @@ jobs: python: ['3.9', '3.10', '3.11', '3.12', '3.13', 'pypy3.9'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 + + - name: Set up Python 3.13 for emulator + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 + with: + python-version: '3.13' + - name: Setup functions emulator environment + run: | + python -m venv integration/emulators/functions/venv + source integration/emulators/functions/venv/bin/activate + pip install -r integration/emulators/functions/requirements.txt + deactivate - name: Set up Python ${{ matrix.python }} - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: ${{ matrix.python }} - name: Install dependencies @@ -23,20 +34,27 @@ jobs: - name: Test with pytest run: pytest - name: Set up Node.js 20 - uses: actions/setup-node@v4 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # 4.4.0 with: node-version: 20 - - name: Run integration tests against emulator - run: | - npm install -g firebase-tools - firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - + - name: Set up Java 21 + uses: actions/setup-java@f2beeb24e141e01a676f977032f5a29d81c9e27e # 5.1.0 + with: + distribution: 'temurin' + java-version: '21' + check-latest: true + - name: Install firebase-tools + run: npm install -g firebase-tools + - name: Run Database emulator tests + run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' + - name: Run Functions emulator tests + run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 - name: Set up Python 3.9 - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 - name: Install dependencies diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 3d5420537..d60b3cd0b 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -29,12 +29,12 @@ jobs: steps: - name: Checkout source for staging - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 with: ref: ${{ github.event.client_payload.ref || github.ref }} - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 @@ -63,14 +63,14 @@ jobs: # 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 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist - name: Send email on failure if: failure() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} @@ -85,7 +85,7 @@ jobs: - name: Send email on cancelled if: cancelled() - uses: firebase/firebase-admin-node/.github/actions/send-email@master + uses: firebase/firebase-admin-node/.github/actions/send-email@2e2b36a84ba28679bcb7aecdacabfec0bded2d48 # Admin Node SDK v13.6.0 with: api-key: ${{ secrets.OSS_BOT_MAILGUN_KEY }} domain: ${{ secrets.OSS_BOT_MAILGUN_DOMAIN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6cd1d3f07..6bbf19aab 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,10 +15,18 @@ 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. + # Run the workflow when: + # 1. A PR is created or updated (staging checks). + # 2. A commit is pushed to main (release publication). + # 3. A developer explicitly requests a build via 'firebase_build' event. pull_request: - types: [opened, synchronize, closed] + types: [opened, synchronize] + + push: + branches: + - main + paths: + - 'firebase_admin/__about__.py' repository_dispatch: types: @@ -26,26 +34,22 @@ on: jobs: stage_release: - # To publish a release, merge the release PR with the label 'release:publish'. + # To publish a release, merge a PR with the title prefix '[chore] Release ' to main + # and ensure the squashed commit message also has the prefix. # 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')) + (github.event_name == 'push' && startsWith(github.event.head_commit.message, '[chore] Release ')) 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 }} + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 - name: Set up Python - uses: actions/setup-python@v5 + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # 5.6.0 with: python-version: 3.9 @@ -74,7 +78,7 @@ jobs: # 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 + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: dist path: dist @@ -82,17 +86,16 @@ jobs: 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 ') + # Check whether the release should be published. We publish only when the trigger is + # 1. a push (merge) + # 2. to the main branch + # 3. and the commit message has the title prefix '[chore] Release '. + if: github.event_name == 'push' && + github.ref == 'refs/heads/main' && + startsWith(github.event.head_commit.message, '[chore] Release ') runs-on: ubuntu-latest + environment: Release permissions: # Used to create a short-lived OIDC token which is given to PyPi to identify this workflow job # See: https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#adding-permissions-settings @@ -102,11 +105,11 @@ jobs: steps: - name: Checkout source for publish - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # 4.3.1 # Download the artifacts created by the stage_release job. - name: Download release candidates - uses: actions/download-artifact@v4.1.7 + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: name: dist path: dist @@ -119,24 +122,12 @@ jobs: - name: Create release tag env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: gh release create ${{ steps.preflight.outputs.version }} - --title "Firebase Admin Python SDK ${{ steps.preflight.outputs.version }}" - --notes '${{ steps.preflight.outputs.changelog }}' + RELEASE_VER: ${{ steps.preflight.outputs.version }} + RELEASE_BODY: ${{ steps.preflight.outputs.changelog }} + run: | + gh release create "$RELEASE_VER" \ + --title "Firebase Admin Python SDK $RELEASE_VER" \ + --notes "$RELEASE_BODY" - name: Publish to Pypi - uses: pypa/gh-action-pypi-publish@release/v1 - - # 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 Python SDK is available. - https://github.com/firebase/firebase-admin-python/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 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72933a24f..139e7f96c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -47,7 +47,7 @@ Great, we love hearing how we can improve our products! Share you idea through o ## Want to submit a pull request? Sweet, we'd love to accept your contribution! -[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new/master) and fill +[Open a new pull request](https://github.com/firebase/firebase-admin-python/pull/new) and fill out the provided template. **If you want to implement a new feature, please open an issue with a proposal first so that we can @@ -252,6 +252,17 @@ to ensure that exported user records contain the password hashes of the user acc 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. 4. Click **SAVE**. +9. Enable Cloud Tasks: + 1. Search for and enable **Cloud Run**. + 2. Search for and enable **Cloud Tasks**. + 3. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin) + and make sure your Firebase project is selected. + 4. Ensure your service account has the following required roles: + * **Cloud Tasks Enqueuer** - `cloudtasks.taskEnqueuer` + * **Cloud Tasks Task Deleter** - `cloudtasks.taskDeleter` + * **Cloud Run Invoker** - `run.invoker` + * **Service Account User** - `iam.serviceAccountUser` + Now you can invoke the integration test suite as follows: diff --git a/firebase_admin/__about__.py b/firebase_admin/__about__.py index 9fb40b11c..e8ae3bb46 100644 --- a/firebase_admin/__about__.py +++ b/firebase_admin/__about__.py @@ -14,7 +14,7 @@ """About information (version, etc) for Firebase Admin SDK.""" -__version__ = '7.1.0' +__version__ = '7.4.0' __title__ = 'firebase_admin' __author__ = 'Firebase' __license__ = 'Apache License 2.0' diff --git a/firebase_admin/_auth_utils.py b/firebase_admin/_auth_utils.py index a514442c4..8f3c419a7 100644 --- a/firebase_admin/_auth_utils.py +++ b/firebase_admin/_auth_utils.py @@ -479,7 +479,7 @@ def _parse_error_body(response): separator = code.find(':') if separator != -1: custom_message = code[separator + 1:].strip() - code = code[:separator] + code = code[:separator].strip() return code, custom_message diff --git a/firebase_admin/_messaging_encoder.py b/firebase_admin/_messaging_encoder.py index 960a6d742..4c0c6daa4 100644 --- a/firebase_admin/_messaging_encoder.py +++ b/firebase_admin/_messaging_encoder.py @@ -207,6 +207,10 @@ def encode_android(cls, android): 'fcm_options': cls.encode_android_fcm_options(android.fcm_options), 'direct_boot_ok': _Validators.check_boolean( 'AndroidConfig.direct_boot_ok', android.direct_boot_ok), + 'bandwidth_constrained_ok': _Validators.check_boolean( + 'AndroidConfig.bandwidth_constrained_ok', android.bandwidth_constrained_ok), + 'restricted_satellite_ok': _Validators.check_boolean( + 'AndroidConfig.restricted_satellite_ok', android.restricted_satellite_ok), } result = cls.remove_null_values(result) priority = result.get('priority') diff --git a/firebase_admin/_messaging_utils.py b/firebase_admin/_messaging_utils.py index 8fd720701..773ed6057 100644 --- a/firebase_admin/_messaging_utils.py +++ b/firebase_admin/_messaging_utils.py @@ -13,6 +13,9 @@ # limitations under the License. """Types and utilities used by the messaging (FCM) module.""" +from __future__ import annotations +import datetime +from typing import Dict, Optional, Union from firebase_admin import exceptions @@ -51,10 +54,25 @@ class AndroidConfig: fcm_options: A ``messaging.AndroidFCMOptions`` to be included in the message (optional). direct_boot_ok: A boolean indicating whether messages will be allowed to be delivered to the app while the device is in direct boot mode (optional). + bandwidth_constrained_ok: A boolean indicating whether messages will be allowed to be + delivered to the app while the device is on a bandwidth constrained network (optional). + restricted_satellite_ok: A boolean indicating whether messages will be allowed to be + delivered to the app while the device is on a restricted satellite network (optional). """ - def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_package_name=None, - data=None, notification=None, fcm_options=None, direct_boot_ok=None): + def __init__( + self, + collapse_key: Optional[str] = None, + priority: Optional[str] = None, + ttl: Optional[Union[int, float, datetime.timedelta]] = None, + restricted_package_name: Optional[str] = None, + data: Optional[Dict[str, str]] = None, + notification: Optional[AndroidNotification] = None, + fcm_options: Optional[AndroidFCMOptions] = None, + direct_boot_ok: Optional[bool] = None, + bandwidth_constrained_ok: Optional[bool] = None, + restricted_satellite_ok: Optional[bool] = None + ): self.collapse_key = collapse_key self.priority = priority self.ttl = ttl @@ -63,6 +81,8 @@ def __init__(self, collapse_key=None, priority=None, ttl=None, restricted_packag self.notification = notification self.fcm_options = fcm_options self.direct_boot_ok = direct_boot_ok + self.bandwidth_constrained_ok = bandwidth_constrained_ok + self.restricted_satellite_ok = restricted_satellite_ok class AndroidNotification: diff --git a/firebase_admin/_utils.py b/firebase_admin/_utils.py index d0aca884b..0277b9e5f 100644 --- a/firebase_admin/_utils.py +++ b/firebase_admin/_utils.py @@ -279,7 +279,6 @@ def handle_httpx_error(error: httpx.HTTPError, message=None, code=None) -> excep message=f'Failed to establish a connection: {error}', cause=error) if isinstance(error, httpx.HTTPStatusError): - print("printing status error", error) if not code: code = _http_status_to_error_code(error.response.status_code) if not message: diff --git a/firebase_admin/credentials.py b/firebase_admin/credentials.py index 7117b71a9..0edbecaae 100644 --- a/firebase_admin/credentials.py +++ b/firebase_admin/credentials.py @@ -37,7 +37,7 @@ AccessTokenInfo = collections.namedtuple('AccessTokenInfo', ['access_token', 'expiry']) """Data included in an OAuth2 access token. -Contains the access token string and the expiry time. The expirty time is exposed as a +Contains the access token string and the expiry time. The expiry time is exposed as a ``datetime`` value. """ diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 6db0fbb42..66ba700b3 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -18,11 +18,16 @@ from datetime import datetime, timedelta, timezone from urllib import parse import re +import os import json from base64 import b64encode from typing import Any, Optional, Dict from dataclasses import dataclass + from google.auth.compute_engine import Credentials as ComputeEngineCredentials +from google.auth.credentials import TokenState +from google.auth.exceptions import RefreshError +from google.auth.transport import requests as google_auth_requests import requests import firebase_admin @@ -45,6 +50,8 @@ 'https://cloudtasks.googleapis.com/v2/' + _CLOUD_TASKS_API_RESOURCE_PATH _FIREBASE_FUNCTION_URL_FORMAT = \ 'https://{location_id}-{project_id}.cloudfunctions.net/{resource_id}' +_EMULATOR_HOST_ENV_VAR = 'CLOUD_TASKS_EMULATOR_HOST' +_EMULATED_SERVICE_ACCOUNT_DEFAULT = 'emulated-service-acct@email.com' _FUNCTIONS_HEADERS = { 'X-GOOG-API-FORMAT-VERSION': '2', @@ -54,6 +61,17 @@ # Default canonical location ID of the task queue. _DEFAULT_LOCATION = 'us-central1' +def _get_emulator_host() -> Optional[str]: + emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR) + if emulator_host: + if '//' in emulator_host: + raise ValueError( + f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' + '"host:port".') + return emulator_host + return None + + def _get_functions_service(app) -> _FunctionsService: return _utils.get_app_service(app, _FUNCTIONS_ATTRIBUTE, _FunctionsService) @@ -99,13 +117,19 @@ def __init__(self, app: App): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - self._credential = app.credential.get_credential() + self._emulator_host = _get_emulator_host() + if self._emulator_host: + self._credential = _utils.EmulatorAdminCredentials() + else: + self._credential = app.credential.get_credential() + self._http_client = _http_client.JsonHttpClient(credential=self._credential) def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue: """Creates a TaskQueue instance.""" return TaskQueue( - function_name, extension_id, self._project_id, self._credential, self._http_client) + function_name, extension_id, self._project_id, self._credential, self._http_client, + self._emulator_host) @classmethod def handle_functions_error(cls, error: Any): @@ -121,7 +145,8 @@ def __init__( extension_id: Optional[str], project_id, credential, - http_client + http_client, + emulator_host: Optional[str] = None ) -> None: # Validate function_name @@ -130,6 +155,7 @@ def __init__( self._project_id = project_id self._credential = credential self._http_client = http_client + self._emulator_host = emulator_host self._function_name = function_name self._extension_id = extension_id # Parse resources from function_name @@ -163,16 +189,26 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: str: The ID of the task relative to this queue. """ task = self._validate_task_options(task_data, self._resource, opts) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) + emulator_url = self._get_emulator_url(self._resource) + service_url = emulator_url or self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) task_payload = self._update_task_payload(task, self._resource, self._extension_id) try: resp = self._http_client.body( 'post', url=service_url, headers=_FUNCTIONS_HEADERS, - json={'task': task_payload.__dict__} + json={'task': task_payload.to_api_dict()} ) - task_name = resp.get('name', None) + if self._is_emulated(): + # Emulator returns a response with format {task: {name: }} + # The task name also has an extra '/' at the start compared to prod + task_info = resp.get('task') or {} + task_name = task_info.get('name') + if task_name: + task_name = task_name[1:] + else: + # Production returns a response with format {name: } + task_name = resp.get('name') task_resource = \ self._parse_resource_name(task_name, f'queues/{self._resource.resource_id}/tasks') return task_resource.resource_id @@ -193,7 +229,11 @@ def delete(self, task_id: str) -> None: ValueError: If the input arguments are invalid. """ _Validators.check_non_empty_string('task_id', task_id) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') + emulator_url = self._get_emulator_url(self._resource) + if emulator_url: + service_url = emulator_url + f'/{task_id}' + else: + service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') try: self._http_client.body( 'delete', @@ -231,8 +271,8 @@ def _validate_task_options( """Validate and create a Task from optional ``TaskOptions``.""" task_http_request = { 'url': '', - 'oidc_token': { - 'service_account_email': '' + 'oidcToken': { + 'serviceAccountEmail': '' }, 'body': b64encode(json.dumps(data).encode()).decode(), 'headers': { @@ -246,7 +286,7 @@ def _validate_task_options( task.http_request['headers'] = {**task.http_request['headers'], **opts.headers} if opts.schedule_time is not None and opts.schedule_delay_seconds is not None: raise ValueError( - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.') + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.') if opts.schedule_time is not None and opts.schedule_delay_seconds is None: if not isinstance(opts.schedule_time, datetime): raise ValueError('schedule_time should be UTC datetime.') @@ -284,22 +324,53 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str """Prepares task to be sent with credentials.""" # Get function url from task or generate from resources if not _Validators.is_non_empty_string(task.http_request['url']): - task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) - # If extension id is provided, it emplies that it is being run from a deployed extension. + if self._is_emulated(): + task.http_request['url'] = '' + else: + task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) + + # Refresh the credential to ensure all attributes (e.g. service_account_email, id_token) + # are populated, preventing cold start errors. + if self._credential.token_state != TokenState.FRESH: + try: + self._credential.refresh(google_auth_requests.Request()) + except RefreshError as err: + raise ValueError(f'Initial task payload credential refresh failed: {err}') from err + + # If extension id is provided, it implies that it is being run from a deployed extension. # Meaning that it's credential should be a Compute Engine Credential. if _Validators.is_non_empty_string(extension_id) and \ isinstance(self._credential, ComputeEngineCredentials): - id_token = self._credential.token task.http_request['headers'] = \ - {**task.http_request['headers'], 'Authorization': f'Bearer ${id_token}'} + {**task.http_request['headers'], 'Authorization': f'Bearer {id_token}'} # Delete oidc token - del task.http_request['oidc_token'] + del task.http_request['oidcToken'] else: - task.http_request['oidc_token'] = \ - {'service_account_email': self._credential.service_account_email} + try: + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': self._credential.service_account_email} + except AttributeError as error: + if self._is_emulated(): + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': _EMULATED_SERVICE_ACCOUNT_DEFAULT} + else: + raise ValueError( + 'Failed to determine service account. Initialize the SDK with service ' + 'account credentials or set service account ID as an app option.' + ) from error return task + def _get_emulator_url(self, resource: Resource): + if self._emulator_host: + emulator_url_format = f'http://{self._emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH + url = self._get_url(resource, emulator_url_format) + return url + return None + + def _is_emulated(self): + return self._emulator_host is not None + class _Validators: """A collection of data validation utilities.""" @@ -424,6 +495,14 @@ class Task: schedule_time: Optional[str] = None dispatch_deadline: Optional[str] = None + def to_api_dict(self) -> dict: + """Converts the Task object to a dictionary suitable for the Cloud Tasks API.""" + return { + 'httpRequest': self.http_request, + 'name': self.name, + 'scheduleTime': self.schedule_time, + 'dispatchDeadline': self.dispatch_deadline, + } @dataclass class Resource: diff --git a/firebase_admin/phone_number_verification.py b/firebase_admin/phone_number_verification.py new file mode 100644 index 000000000..ea8f0cdab --- /dev/null +++ b/firebase_admin/phone_number_verification.py @@ -0,0 +1,250 @@ +# Copyright 2026 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. + +"""Firebase Phone Number Verification module. + +This module contains functions for verifying JWTs related to the Firebase +Phone Number Verification service. +""" +from __future__ import annotations +from typing import Any, Dict, Optional + +import jwt +from jwt import ( + PyJWKClient, InvalidSignatureError, + PyJWKClientError, InvalidAudienceError, InvalidIssuerError, ExpiredSignatureError +) + +from firebase_admin import App, _utils, exceptions + +_FPNV_ATTRIBUTE = '_phone_number_verification' +_FPNV_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks' +_FPNV_ISSUER = 'https://fpnv.googleapis.com/projects/' +_ALGORITHM_ES256 = 'ES256' + + +def _get_fpnv_service(app): + return _utils.get_app_service(app, _FPNV_ATTRIBUTE, _FpnvService) + +def verify_token(token: str, app: Optional[App] = None) -> PhoneNumberVerificationToken: + """Verifies a Firebase Phone Number Verification token. + + Args: + token: A string containing the Firebase Phone Number Verification JWT. + app: An App instance (optional). + + Returns: + PhoneNumberVerificationToken: The verified token claims. + + Raises: + ValueError: If the token is not a string or is empty. + InvalidTokenError: If the token is invalid or malformed. + ExpiredTokenError: If the token has expired. + """ + return _get_fpnv_service(app).verify_token(token) + + +class PhoneNumberVerificationToken(dict): + """Represents a verified Firebase Phone Number Verification token. + + This class behaves like a dictionary, allowing access to the decoded claims. + It also provides convenience properties for common claims. + """ + + def __init__(self, claims): + super().__init__(claims) + self['phone_number'] = claims.get('sub') + + @property + def phone_number(self) -> str: + """Returns the phone number of the user. + This corresponds to the 'sub' claim in the JWT. + """ + return self.get('sub') + + @property + def issuer(self) -> str: + """Returns the issuer identifier for the issuer of the response.""" + return self.get('iss') + + @property + def audience(self) -> str: + """Returns the audience for which this token is intended.""" + return self.get('aud') + + @property + def exp(self) -> int: + """Returns the expiration time since the Unix epoch.""" + return self.get('exp') + + @property + def iat(self) -> int: + """Returns the issued-at time since the Unix epoch.""" + return self.get('iat') + + @property + def sub(self) -> str: + """Returns the sub (subject) of the token, which is the phone number.""" + return self.get('sub') + + @property + def claims(self): + """Returns the entire map of claims.""" + return self + + +class _FpnvService: + """Service class that implements Firebase Phone Number Verification functionality.""" + _project_id = None + + def __init__(self, app): + self._project_id = app.project_id + if not self._project_id: + raise ValueError( + 'Project ID is required for Firebase Phone Number Verification. Please ensure the ' + 'app is initialized with a credential that contains a project ID.' + ) + + self._verifier = _FpnvTokenVerifier(self._project_id) + + def verify_token(self, token) -> PhoneNumberVerificationToken: + """Verifies a Firebase Phone Number Verification token. + + Verifies the signature, expiration, and claims of the token. + + Args: + token: A string containing the Firebase Phone Number Verification JWT. + + Returns: + PhoneNumberVerificationToken: The verified token claims. + + Raises: + ValueError: If the token is not a string or is empty. + InvalidTokenError: If the token is invalid or malformed. + ExpiredTokenError: If the token has expired. + """ + return PhoneNumberVerificationToken(self._verifier.verify(token)) + + +class _FpnvTokenVerifier: + """Internal class for verifying Firebase Phone Number Verification JWTs signed with ES256.""" + _jwks_client = None + _project_id = None + + def __init__(self, project_id): + self._project_id = project_id + self._jwks_client = PyJWKClient(_FPNV_JWKS_URL, lifespan=21600) + + def verify(self, token) -> Dict[str, Any]: + """Verifies the given Firebase Phone Number Verification token.""" + _Validators.check_string("Firebase Phone Number Verification check token", token) + try: + self._validate_headers(jwt.get_unverified_header(token)) + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + claims = self._decode_and_verify(token, signing_key.key) + except (jwt.InvalidTokenError, PyJWKClientError) as exception: + raise InvalidTokenError( + 'Verifying phone number verification token failed.', + cause=exception, + http_response=getattr(exception, 'http_response', None) + ) from exception + + return claims + + def _validate_headers(self, headers: Any) -> None: + """Validates the headers.""" + if headers.get('kid') is None: + raise InvalidTokenError("Token has no 'kid' claim.") + + if headers.get('typ') != 'JWT': + raise InvalidTokenError( + 'The provided token has an incorrect type header. ' \ + f"Expected 'JWT' but got {headers.get('typ')!r}." + ) + + algorithm = headers.get('alg') + if algorithm != _ALGORITHM_ES256: + raise InvalidTokenError( + 'The provided token has an incorrect alg header. ' + f'Expected {_ALGORITHM_ES256} but got {algorithm}.' + ) + + def _decode_and_verify(self, token, signing_key) -> Dict[str, Any]: + """Decodes and verifies the token.""" + expected_issuer = f'{_FPNV_ISSUER}{self._project_id}' + try: + payload = jwt.decode( + token, + signing_key, + algorithms=[_ALGORITHM_ES256], + audience=expected_issuer, + issuer=expected_issuer + ) + except InvalidSignatureError as exception: + raise InvalidTokenError( + 'The provided token has an invalid signature.' + ) from exception + except InvalidAudienceError as exception: + raise InvalidTokenError( + 'The provided token has an incorrect "aud" (audience) claim. ' + f'Expected {expected_issuer}.' + ) from exception + except InvalidIssuerError as exception: + raise InvalidTokenError( + 'The provided token has an incorrect "iss" (issuer) claim. ' + f'Expected {expected_issuer}.' + ) from exception + except ExpiredSignatureError as exception: + raise ExpiredTokenError( + 'The provided token has expired.' + ) from exception + except jwt.InvalidTokenError as exception: + raise InvalidTokenError( + f'Decoding token failed. Error: {exception}' + ) from exception + + sub_claim = payload.get('sub') + if not isinstance(sub_claim, str) or not sub_claim: + raise InvalidTokenError( + 'The provided token has an incorrect "sub" (subject) claim. ' + 'Expected a non-empty string.' + ) + + return payload + + +class _Validators: + """A collection of data validation utilities. + + Methods provided in this class raise ``ValueErrors`` if any validations fail. + """ + + @classmethod + def check_string(cls, label: str, value: Any): + """Checks if the given value is a string.""" + if not isinstance(value, str) or not value: + raise ValueError(f'{label} must be a non-empty string.') + +# Firebase Phone Number Verification Errors +class InvalidTokenError(exceptions.InvalidArgumentError): + """Raised when a Firebase Phone Number Verification token is invalid.""" + + def __init__(self, message, cause=None, http_response=None): + exceptions.InvalidArgumentError.__init__(self, message, cause, http_response) + +class ExpiredTokenError(InvalidTokenError): + """Raised when a Firebase Phone Number Verification token is expired.""" + + def __init__(self, message, cause=None, http_response=None): + InvalidTokenError.__init__(self, message, cause, http_response) diff --git a/integration/emulators/.gitignore b/integration/emulators/.gitignore new file mode 100644 index 000000000..b17f63107 --- /dev/null +++ b/integration/emulators/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/integration/emulators/firebase.json b/integration/emulators/firebase.json new file mode 100644 index 000000000..a7b727c4d --- /dev/null +++ b/integration/emulators/firebase.json @@ -0,0 +1,29 @@ +{ + "emulators": { + "tasks": { + "port": 9499 + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true, + "functions": { + "port": 5001 + } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "venv", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "runtime": "python313" + } + ] +} diff --git a/integration/emulators/functions/.gitignore b/integration/emulators/functions/.gitignore new file mode 100644 index 000000000..1609bab70 --- /dev/null +++ b/integration/emulators/functions/.gitignore @@ -0,0 +1,6 @@ +# Python bytecode +__pycache__/ + +# Python virtual environment +venv/ +*.local diff --git a/integration/emulators/functions/main.py b/integration/emulators/functions/main.py new file mode 100644 index 000000000..6cd2c5766 --- /dev/null +++ b/integration/emulators/functions/main.py @@ -0,0 +1,7 @@ +from firebase_functions import tasks_fn + +@tasks_fn.on_task_dispatched() +def testTaskQueue(req: tasks_fn.CallableRequest) -> None: + """Handles tasks from the task queue.""" + print(f"Received task with data: {req.data}") + return diff --git a/integration/emulators/functions/requirements.txt b/integration/emulators/functions/requirements.txt new file mode 100644 index 000000000..6bbab42f8 --- /dev/null +++ b/integration/emulators/functions/requirements.txt @@ -0,0 +1 @@ +firebase_functions~=0.4.1 diff --git a/integration/test_auth.py b/integration/test_auth.py index 7f4725dfe..b36063d19 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -724,6 +724,19 @@ def test_email_sign_in_with_settings(new_user_email_unverified, api_key): assert id_token is not None and len(id_token) > 0 assert auth.get_user(new_user_email_unverified.uid).email_verified +def test_auth_error_parse(new_user_email_unverified): + action_code_settings = auth.ActionCodeSettings( + ACTION_LINK_CONTINUE_URL, handle_code_in_app=True, link_domain="cool.link") + with pytest.raises(auth.InvalidHostingLinkDomainError) as excinfo: + auth.generate_sign_in_with_email_link(new_user_email_unverified.email, + action_code_settings=action_code_settings) + assert str(excinfo.value) == ('The provided hosting link domain is not configured in Firebase ' + 'Hosting or is not owned by the current project ' + '(INVALID_HOSTING_LINK_DOMAIN). The provided hosting link ' + 'domain is not configured in Firebase Hosting or is not owned ' + 'by the current project. This cannot be a default hosting domain ' + '(web.app or firebaseapp.com).') + @pytest.fixture(scope='module') def oidc_provider(): diff --git a/integration/test_functions.py b/integration/test_functions.py index 606798436..fc972f9e5 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -14,17 +14,34 @@ """Integration tests for firebase_admin.functions module.""" +import os import pytest import firebase_admin from firebase_admin import functions +from firebase_admin import _utils from integration import conftest +_DEFAULT_DATA = {'data': {'city': 'Seattle'}} +def integration_conf(request): + host_override = os.environ.get('CLOUD_TASKS_EMULATOR_HOST') + if host_override: + return _utils.EmulatorAdminCredentials(), 'fake-project-id' + + return conftest.integration_conf(request) + @pytest.fixture(scope='module') def app(request): - cred, _ = conftest.integration_conf(request) - return firebase_admin.initialize_app(cred, name='integration-functions') + cred, project_id = integration_conf(request) + return firebase_admin.initialize_app( + cred, options={'projectId': project_id}, name='integration-functions') + +@pytest.fixture(scope='module', autouse=True) +def default_app(): + # Overwrites the default_app fixture in conftest.py. + # This test suite should not use the default app. Use the app fixture instead. + pass class TestFunctions: @@ -41,16 +58,31 @@ class TestFunctions: ] @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue(self, task_queue_params): - queue = functions.task_queue(**task_queue_params) - assert queue is not None - assert callable(queue.enqueue) - assert callable(queue.delete) - - @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue_app(self, task_queue_params, app): + def test_task_queue(self, task_queue_params, app): assert app.name == 'integration-functions' queue = functions.task_queue(**task_queue_params, app=app) assert queue is not None assert callable(queue.enqueue) assert callable(queue.delete) + + def test_task_enqueue(self, app): + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert task_id is not None + + @pytest.mark.skipif( + os.environ.get('CLOUD_TASKS_EMULATOR_HOST') is not None, + reason="Skipping test_task_delete against emulator due to bug in firebase-tools" + ) + def test_task_delete(self, app): + # Skip this test against the emulator since tasks can't be delayed there to verify deletion + # See: https://github.com/firebase/firebase-tools/issues/8254 + task_options = functions.TaskOptions(schedule_delay_seconds=60) + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA, task_options) + assert task_id is not None + queue.delete(task_id) + # We don't have a way to check the contents of the queue so we check that the deleted + # task is not found using the delete method again. + with pytest.raises(firebase_admin.exceptions.NotFoundError): + queue.delete(task_id) diff --git a/requirements.txt b/requirements.txt index c68d71a0f..0bbfbe183 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,11 @@ astroid == 3.3.11 -pylint == 3.3.7 +pylint == 3.3.9 pytest >= 8.2.2 pytest-cov >= 2.4.0 pytest-localserver >= 0.4.1 pytest-asyncio >= 0.26.0 pytest-mock >= 3.6.1 -respx == 0.22.0 +respx == 0.23.1 cachecontrol >= 0.14.3 google-api-core[grpc] >= 2.25.1, < 3.0.0dev; platform.python_implementation != 'PyPy' diff --git a/setup.cfg b/setup.cfg index 32e00676b..4c6cf8d8f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,3 @@ [tool:pytest] testpaths = tests asyncio_default_test_loop_scope = class -asyncio_default_fixture_loop_scope = None diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 6a509b8f5..656137dba 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -770,7 +770,7 @@ def get_tenant(tenant_id): # [START get_tenant] tenant = tenant_mgt.get_tenant(tenant_id) - print('Retreieved tenant:', tenant.tenant_id) + print('Retrieved tenant:', tenant.tenant_id) # [END get_tenant] def create_tenant(): diff --git a/tests/test_functions.py b/tests/test_functions.py index 52e92c1b2..0f766767a 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -44,13 +44,14 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE): + def _instrument_functions_service( + self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): if not app: app = firebase_admin.get_app() functions_service = functions._get_functions_service(app) recorder = [] functions_service._http_client.session.mount( - _CLOUD_TASKS_URL, + mounted_url, testutils.MockAdapter(payload, status, recorder)) return functions_service, recorder @@ -124,6 +125,10 @@ def test_task_enqueue(self): assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header assert task_id == 'test-task-id' + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + def test_task_enqueue_with_extension(self): resource_name = ( 'projects/test-project/locations/us-central1/queues/' @@ -142,6 +147,59 @@ def test_task_enqueue_with_extension(self): assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header assert task_id == 'test-task-id' + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_compute_engine(self): + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce') + _, recorder = self._instrument_functions_service(app) + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _DEFAULT_REQUEST_URL + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-gce-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} + + def test_task_enqueue_with_extension_compute_engine(self): + resource_name = ( + 'projects/test-project/locations/us-central1/queues/' + 'ext-test-extension-id-test-function-name/tasks' + ) + extension_response = json.dumps({'name': resource_name + '/test-task-id'}) + app = firebase_admin.initialize_app( + testutils.MockComputeEngineCredential(), + options={'projectId': 'test-project'}, + name='test-project-gce-extensions') + _, recorder = self._instrument_functions_service(app, payload=extension_response) + queue = functions.task_queue('test-function-name', 'test-extension-id', app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == _CLOUD_TASKS_URL + resource_name + assert recorder[0].headers['Content-Type'] == 'application/json' + assert recorder[0].headers['Authorization'] == 'Bearer mock-compute-engine-token' + expected_metrics_header = _utils.get_metrics_header() + ' mock-gce-cred-metric-tag' + assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + assert task_id == 'test-task-id' + + task = json.loads(recorder[0].body.decode())['task'] + assert 'oidcToken' not in task['httpRequest'] + assert task['httpRequest']['headers'] == { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer mock-compute-engine-token'} + def test_task_delete(self): _, recorder = self._instrument_functions_service() queue = functions.task_queue('test-function-name') @@ -152,6 +210,58 @@ def test_task_delete(self): expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + def test_task_enqueue_with_emulator_host(self, monkeypatch): + emulator_host = 'localhost:8124' + emulator_url = f'http://{emulator_host}/' + request_url = emulator_url + _DEFAULT_TASK_PATH.replace('/tasks/test-task-id', '/tasks') + + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', emulator_host) + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') + + expected_task_name = ( + '/projects/test-project/locations/us-central1' + '/queues/test-function-name/tasks/test-task-id' + ) + expected_response = json.dumps({'task': {'name': expected_task_name}}) + _, recorder = self._instrument_functions_service( + app, payload=expected_response, mounted_url=emulator_url) + + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == request_url + assert recorder[0].headers['Content-Type'] == 'application/json' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == { + 'serviceAccountEmail': 'emulated-service-acct@email.com' + } + assert task_id == 'test-task-id' + + def test_task_enqueue_without_emulator_host_error(self, monkeypatch): + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), + {'projectId': 'test-project'}, name='no-emulator-app') + + _, recorder = self._instrument_functions_service(app) + monkeypatch.delenv('CLOUD_TASKS_EMULATOR_HOST', raising=False) + queue = functions.task_queue('test-function-name', app=app) + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA) + assert "Failed to determine service account" in str(excinfo.value) + assert len(recorder) == 0 + + def test_get_emulator_url_invalid_format(self, monkeypatch): + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', 'http://localhost:8124') + app = firebase_admin.initialize_app( + testutils.MockCredential(), {'projectId': 'test-project'}, name='invalid-host-app') + with pytest.raises(ValueError) as excinfo: + functions.task_queue('test-function-name', app=app) + assert 'Invalid CLOUD_TASKS_EMULATOR_HOST' in str(excinfo.value) + class TestTaskQueueOptions: _DEFAULT_TASK_OPTS = {'schedule_delay_seconds': None, 'schedule_time': None, \ @@ -202,13 +312,13 @@ def test_task_options_delay_seconds(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) delta = abs(task_schedule_time - expected_schedule_time) assert delta <= timedelta(seconds=1) - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_task_options_utc_time(self): @@ -230,12 +340,12 @@ def test_task_options_utc_time(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) assert task_schedule_time == expected_schedule_time - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_schedule_set_twice_error(self): @@ -247,7 +357,7 @@ def test_schedule_set_twice_error(self): queue.enqueue(_DEFAULT_DATA, opts) assert len(recorder) == 0 assert str(excinfo.value) == \ - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.' + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.' @pytest.mark.parametrize('schedule_time', [ diff --git a/tests/test_messaging.py b/tests/test_messaging.py index 9fa30fef9..b30790f14 100644 --- a/tests/test_messaging.py +++ b/tests/test_messaging.py @@ -335,6 +335,18 @@ def test_invalid_direct_boot_ok(self, data): check_encoding(messaging.Message( topic='topic', android=messaging.AndroidConfig(direct_boot_ok=data))) + @pytest.mark.parametrize('data', NON_BOOL_ARGS) + def test_invalid_bandwidth_constrained_ok(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig(bandwidth_constrained_ok=data))) + + @pytest.mark.parametrize('data', NON_BOOL_ARGS) + def test_invalid_restricted_satellite_ok(self, data): + with pytest.raises(ValueError): + check_encoding(messaging.Message( + topic='topic', android=messaging.AndroidConfig(restricted_satellite_ok=data))) + def test_android_config(self): msg = messaging.Message( @@ -347,6 +359,8 @@ def test_android_config(self): data={'k1': 'v1', 'k2': 'v2'}, fcm_options=messaging.AndroidFCMOptions('analytics_label_v1'), direct_boot_ok=True, + bandwidth_constrained_ok=True, + restricted_satellite_ok=True, ) ) expected = { @@ -364,6 +378,8 @@ def test_android_config(self): 'analytics_label': 'analytics_label_v1', }, 'direct_boot_ok': True, + 'bandwidth_constrained_ok': True, + 'restricted_satellite_ok': True, }, } check_encoding(msg, expected) diff --git a/tests/test_phone_number_verification.py b/tests/test_phone_number_verification.py new file mode 100644 index 000000000..71f8c2ded --- /dev/null +++ b/tests/test_phone_number_verification.py @@ -0,0 +1,311 @@ +# Copyright 2026 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. + +"""Test cases for the firebase_admin.fpnv module.""" + +import base64 +import time +from unittest import mock +from unittest.mock import patch + +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import ec + +import firebase_admin +from firebase_admin import phone_number_verification as fpnv +from tests import testutils + +# Mock Data +_PROJECT_ID = 'mock-project-id' +_EXP_TIMESTAMP = 2000000000 +_ISSUER = f'https://fpnv.googleapis.com/projects/{_PROJECT_ID}' +_PHONE_NUMBER = '+1234567890' +_PUBLIC_KEY = 'test-public-key' # In real tests, use the corresponding public key +_ALGORITHM = 'ES256' +_KEY_ID = 'test-key-id' +_TYPE = 'JWT' + +_MOCK_PAYLOAD = { + 'iss': _ISSUER, + 'sub': _PHONE_NUMBER, + 'aud': [_ISSUER], + 'exp': _EXP_TIMESTAMP, + 'iat': _EXP_TIMESTAMP - 3600, + "other": 'other' +} + + + + +class TestCommon: + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + +class TestFpnvToken: + def test_properties(self): + token = fpnv.PhoneNumberVerificationToken(_MOCK_PAYLOAD) + + assert token.phone_number == _PHONE_NUMBER + assert token.sub == _PHONE_NUMBER + assert token.issuer == _ISSUER + assert token.audience == [_ISSUER] + expected_claims = _MOCK_PAYLOAD.copy() + expected_claims['phone_number'] = _PHONE_NUMBER + assert token.claims == expected_claims + assert token['other'] == _MOCK_PAYLOAD['other'] + + +class TestVerifyToken(TestCommon): + + def test_no_project_id(self): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + app.credential.get_credential().project_id = None + with pytest.raises( + ValueError, + match='Project ID is required for Firebase Phone Number Verification' + ): + fpnv.verify_token('token', app=app) + + def test_verify_token_with_real_crypto(self): + """Verifies a token signed with a real ES256 key pair. + + Mocking only the JWKS endpoint. + This ensures the cryptographic verification logic is functioning correctly. + """ + # Generate a real ES256 key pair + private_key = ec.generate_private_key(ec.SECP256R1()) + public_key = private_key.public_key() + + # Create the JWK representation of the public key (for the mock endpoint) + # Note: Retrieving numbers from the key involves cryptography primitives + public_numbers = public_key.public_numbers() + + def to_b64url(b_data): + return base64.urlsafe_b64encode(b_data).rstrip(b'=').decode('utf-8') + + jwk = { + "kty": "EC", + "use": "sig", + "alg": _ALGORITHM, + "kid": _KEY_ID, + "crv": "P-256", + "x": to_b64url(public_numbers.x.to_bytes(32, 'big')), + "y": to_b64url(public_numbers.y.to_bytes(32, 'big')), + } + now = int(time.time()) + payload = { + 'iss': _ISSUER, + 'aud': [_ISSUER], + 'iat': now, + 'exp': now + 3600, + 'sub': _PHONE_NUMBER + } + + # Sign using the private key object directly (PyJWT supports this) + token = jwt.encode( + payload, + private_key, + algorithm=_ALGORITHM, + headers={'alg': _ALGORITHM, 'typ': _TYPE, 'kid': _KEY_ID}, + ) + + # Mock PyJWKClient fetch_data + with patch('jwt.PyJWKClient.fetch_data') as mock_fetch: + mock_fetch.return_value = {'keys': [jwk]} + + app = firebase_admin.get_app() + decoded_token = fpnv.verify_token(token, app) + + assert decoded_token['sub'] == _PHONE_NUMBER + assert _ISSUER in decoded_token['aud'] + assert decoded_token.phone_number == decoded_token['sub'] + # Test convenience dictionary lookup + assert decoded_token['phone_number'] == _PHONE_NUMBER + + def test_verify_token_module_level_delegation(self): + """Verifies module-level verify_token delegates correctly.""" + with patch( + 'firebase_admin.phone_number_verification._FpnvService.verify_token' + ) as mock_verify: + mock_verify.return_value = 'mock-result' + res = fpnv.verify_token('some-token') + assert res == 'mock-result' + mock_verify.assert_called_once_with('some-token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_success(self, mock_header, mock_decode, mock_jwks_cls): + token_str = 'valid.token.string' + # Mock Header + mock_header.return_value = {'kid': 'key1', 'typ': 'JWT', 'alg': 'ES256'} + + # Mock Signing Key + mock_jwks_instance = mock_jwks_cls.return_value + mock_signing_key = mock.Mock() + mock_signing_key.key = _PUBLIC_KEY + mock_jwks_instance.get_signing_key_from_jwt.return_value = mock_signing_key + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + mock_decode.return_value = _MOCK_PAYLOAD + + # Execute + token = fpnv.verify_token(token_str) + + # Verify + assert isinstance(token, fpnv.PhoneNumberVerificationToken) + assert token.phone_number == _PHONE_NUMBER + + mock_header.assert_called_with(token_str) + mock_jwks_instance.get_signing_key_from_jwt.assert_called_with(token_str) + mock_decode.assert_called_with( + token_str, + _PUBLIC_KEY, + algorithms=['ES256'], + audience=_ISSUER, + issuer=_ISSUER + ) + + @mock.patch('jwt.get_unverified_header') + def test_verify_token_no_name(self, mock_header): + app = firebase_admin.get_app() + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + with pytest.raises(ValueError, match="must be a non-empty string"): + fpnv.verify_token('', app=app) + + @mock.patch('jwt.get_unverified_header') + def test_verify_token_no_kid(self, mock_header): + app = firebase_admin.get_app() + mock_header.return_value = {'typ': 'JWT', 'alg': 'ES256'} # Missing kid + with pytest.raises(fpnv.InvalidTokenError, match="Token has no 'kid' claim."): + fpnv.verify_token('token', app=app) + + @mock.patch('jwt.get_unverified_header') + def test_verify_token_wrong_alg(self, mock_header): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'RS256'} # Wrong alg + with pytest.raises(fpnv.InvalidTokenError, match="incorrect alg"): + fpnv.verify_token('token') + + @mock.patch('jwt.get_unverified_header') + def test_verify_token_wrong_typ(self, mock_header): + mock_header.return_value = {'kid': 'k', 'typ': 'WRONG', 'alg': 'ES256'} # wrong typ + with pytest.raises(fpnv.InvalidTokenError, match="incorrect type header"): + fpnv.verify_token('token') + + def test_verify_token_jwk_error(self): + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + jwks_client = service._verifier._jwks_client + + # Mock the method on the existing instance + with mock.patch.object(jwks_client, 'get_signing_key_from_jwt') as mock_method: + mock_method.side_effect = jwt.PyJWKClientError("Key not found") + + # Mock header is still needed if _get_signing_key calls it before the client + with mock.patch('jwt.get_unverified_header') as mock_header: + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + + with pytest.raises( + fpnv.InvalidTokenError, + match="Verifying phone number verification token failed" + ): + fpnv.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_expired(self, mock_header, mock_decode, mock_jwks_cls): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + # Simulate ExpiredSignatureError + mock_decode.side_effect = jwt.ExpiredSignatureError("Expired") + + with pytest.raises(fpnv.ExpiredTokenError, match="token has expired"): + fpnv.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_invalid_signature(self, mock_header, mock_decode, mock_jwks_cls): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + # Simulate InvalidSignatureError + mock_decode.side_effect = jwt.InvalidSignatureError("Wrong Signature") + + with pytest.raises(fpnv.InvalidTokenError, match="invalid signature"): + fpnv.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_invalid_audience(self, mock_header, mock_decode, mock_jwks_cls): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + # Simulate InvalidAudienceError + mock_decode.side_effect = jwt.InvalidAudienceError("Wrong Aud") + + with pytest.raises(fpnv.InvalidTokenError, match="incorrect \"aud\""): + fpnv.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_invalid_issuer(self, mock_header, mock_decode, mock_jwks_cls): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + # Simulate InvalidIssuerError + mock_decode.side_effect = jwt.InvalidIssuerError("Wrong Iss") + + with pytest.raises(fpnv.InvalidTokenError, match="incorrect \"iss\""): + fpnv.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.decode') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_invalid_token(self, mock_header, mock_decode, mock_jwks_cls): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY + service = fpnv._get_fpnv_service(firebase_admin.get_app()) + service._verifier._jwks_client = mock_jwks_instance + + # Simulate InvalidTokenError + mock_decode.side_effect = jwt.InvalidTokenError("Decoding FPNV token failed") + + with pytest.raises(fpnv.InvalidTokenError, match="Decoding FPNV token failed"): + fpnv.verify_token('token') diff --git a/tests/testutils.py b/tests/testutils.py index 598a929b4..7546595af 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -116,12 +116,25 @@ def __call__(self, *args, **kwargs): # pylint: disable=arguments-differ # pylint: disable=abstract-method class MockGoogleCredential(credentials.Credentials): """A mock Google authentication credential.""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + def refresh(self, request): self.token = 'mock-token' + self._service_account_email = 'mock-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state @property def service_account_email(self): - return 'mock-email' + return self._service_account_email # Simulate x-goog-api-client modification in credential refresh def _metric_header_for_usage(self): @@ -139,8 +152,24 @@ def get_credential(self): class MockGoogleComputeEngineCredential(compute_engine.Credentials): """A mock Compute Engine credential""" + + def __init__(self): + super().__init__() + self.token = None + self._service_account_email = None + self._token_state = credentials.TokenState.INVALID + def refresh(self, request): self.token = 'mock-compute-engine-token' + self._service_account_email = 'mock-gce-email' + self._token_state = credentials.TokenState.FRESH + + @property + def token_state(self): + return self._token_state + + def _metric_header_for_usage(self): + return 'mock-gce-cred-metric-tag' class MockComputeEngineCredential(firebase_admin.credentials.Base): """A mock Firebase credential implementation."""