From 81067960c19b8e79fcfbe458436175aa6eff9d1e Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 20 Jan 2026 07:45:40 +0100 Subject: [PATCH 1/8] feat: add FPNV --- firebase_admin/fpnv.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 firebase_admin/fpnv.py diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py new file mode 100644 index 00000000..21fd49cb --- /dev/null +++ b/firebase_admin/fpnv.py @@ -0,0 +1,19 @@ +# 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 (FPNV) service. + +This module contains functions for verifying JWTs used for +authenticating against Firebase services. +""" From b6d06899b728dc5ec2bac1507f48b708ac1ac357 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Tue, 20 Jan 2026 16:11:27 +0100 Subject: [PATCH 2/8] feat: add basic logic --- firebase_admin/fpnv.py | 216 ++++++++++++++++++++++++++++++++++++++++- tests/test_fpnv.py | 18 ++++ 2 files changed, 230 insertions(+), 4 deletions(-) create mode 100644 tests/test_fpnv.py diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py index 21fd49cb..4a212288 100644 --- a/firebase_admin/fpnv.py +++ b/firebase_admin/fpnv.py @@ -12,8 +12,216 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Firebase Phone Number Verification (FPNV) service. +"""Firebase Phone Number Verification (FPNV) module.""" +from typing import Any, Dict -This module contains functions for verifying JWTs used for -authenticating against Firebase services. -""" +import jwt +from jwt import PyJWKClient, InvalidTokenError, DecodeError, InvalidSignatureError, \ + InvalidAudienceError, InvalidIssuerError, ExpiredSignatureError + +from firebase_admin import _utils +from firebase_admin.exceptions import InvalidArgumentError + +_FPNV_ATTRIBUTE = '_fpnv' +_FPNV_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks' +_FPNV_ISSUER = 'https://fpnv.googleapis.com/projects/' +_ALGORITHM_ES256 = 'ES256' + + +def client(app=None): + """Returns an instance of the FPNV service for the specified app. + + Args: + app: An App instance (optional). + + Returns: + FpnvClient: A FpnvClient instance. + + Raises: + ValueError: If the app is not a valid App instance. + """ + return _utils.get_app_service(app, _FPNV_ATTRIBUTE, FpnvClient) + + +class FpnvToken(dict): + """Represents a verified FPNV 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(FpnvToken, self).__init__(claims) + + @property + def phone_number(self): + """Returns the phone number associated with the token.""" + return self.get('sub') + + @property + def issuer(self): + """Returns the issuer of the token.""" + return self.get('iss') + + @property + def audience(self): + """Returns the audience of the token.""" + return self.get('aud') + + @property + def sub(self): + """Returns the sub (subject) of the token, which is the phone number.""" + return self.get('sub') + + # TODO: ADD ALL + + +class FpnvClient: + """The client for the Firebase Phone Number Verification service.""" + _project_id = None + + def __init__(self, app): + """Initializes the FpnvClient. + + Args: + app: A firebase_admin.App instance. + + Raises: + ValueError: If the app is invalid or lacks a project ID. + """ + self._project_id = app.project_id + + if not self._project_id: + cred = app.credential.get_credential() + if hasattr(cred, 'project_id'): + self._project_id = cred.project_id + + if not self._project_id: + raise ValueError( + 'Project ID is required for FPNV. 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) -> FpnvToken: + """Verifies the given FPNV token. + + Verifies the signature, expiration, and claims of the token. + + Args: + token: A string containing the FPNV JWT. + + Returns: + FpnvToken: The verified token claims. + + Raises: + ValueError: If the token is invalid or malformed. + firebase_admin.exceptions.InvalidArgumentError: If verification fails. + """ + try: + claims = self._verifier.verify(token) + return FpnvToken(claims) + except Exception as error: + raise InvalidArgumentError( + 'Failed to verify token: {0}'.format(error) + ) + + +class _FpnvTokenVerifier: + """Internal class for verifying FPNV 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]: + _Validators.check_string("FPNV 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._validate_payload(token, signing_key.key) + except (InvalidTokenError, DecodeError) as exception: + raise ValueError( + f'Verifying FPNV token failed. Error: {exception}' + ) from exception + + return claims + + def _validate_headers(self, headers: Any) -> None: + if headers.get('kid') is None: + raise ValueError("FPNV has no 'kid' claim.") + + if headers.get('typ') != 'JWT': + raise ValueError("The provided FPNV token has an incorrect type header") + + algorithm = headers.get('alg') + if algorithm != _ALGORITHM_ES256: + raise ValueError( + 'The provided FPNV token has an incorrect alg header. ' + f'Expected {_ALGORITHM_ES256} but got {algorithm}.' + ) + + def _validate_payload(self, token: str, signing_key: str) -> Dict[str, Any]: + """Decodes and verifies the token.""" + _issuer = None + payload = {} + try: + unsafe_payload = jwt.decode(token, options={"verify_signature": False}) + _issuer = unsafe_payload.get('iss') + + if _issuer is None: + raise ValueError('The provided FPNV token has no issuer.') + payload = jwt.decode( + token, + signing_key, + algorithms=[_ALGORITHM_ES256], + audience=_issuer + ) + except InvalidSignatureError as exception: + raise ValueError( + 'The provided FPNV token has an invalid signature.' + ) from exception + except InvalidAudienceError as exception: + raise ValueError( + 'The provided FPNV token has an incorrect "aud" (audience) claim. ' + f'Expected payload to include {_issuer}.' + ) from exception + except InvalidIssuerError as exception: + raise ValueError( + 'The provided FPNV token has an incorrect "iss" (issuer) claim. ' + f'Expected claim to include {_issuer}' + ) from exception + except ExpiredSignatureError as exception: + raise ValueError( + 'The provided FPNV token has expired.' + ) from exception + except InvalidTokenError as exception: + raise ValueError( + f'Decoding FPNV token failed. Error: {exception}' + ) from exception + + if not payload.get('iss').startswith(_FPNV_ISSUER): + raise ValueError('Token does not contain the correct "iss" (issuer).') + _Validators.check_string( + 'The provided FPNV token "sub" (subject) claim', + payload.get('sub')) + + 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 value is None: + raise ValueError(f'{label} "{value}" must be a non-empty string.') + if not isinstance(value, str): + raise ValueError(f'{label} "{value}" must be a string.') diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py new file mode 100644 index 00000000..818238a4 --- /dev/null +++ b/tests/test_fpnv.py @@ -0,0 +1,18 @@ +# 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.""" + +class TestVerifyToken: + pass \ No newline at end of file From c22cb8c0ff36dfe8eadf40e2a6d51f6b2f158d28 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 12:53:26 +0100 Subject: [PATCH 3/8] chore: resolve robot comments --- firebase_admin/fpnv.py | 53 ++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py index 4a212288..a2ca7c02 100644 --- a/firebase_admin/fpnv.py +++ b/firebase_admin/fpnv.py @@ -12,7 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Firebase Phone Number Verification (FPNV) module.""" +"""Firebase Phone Number Verification (FPNV) module. + +This module contains functions for verifying JWTs related to the Firebase +Phone Number Verification (FPNV) service. +""" from typing import Any, Dict import jwt @@ -55,25 +59,40 @@ def __init__(self, claims): @property def phone_number(self): - """Returns the phone number associated with the token.""" + """Returns the phone number of the user. + This corresponds to the 'sub' claim in the JWT. + """ return self.get('sub') @property def issuer(self): - """Returns the issuer of the token.""" + """Returns the issuer identifier for the issuer of the response.""" return self.get('iss') @property def audience(self): - """Returns the audience of the token.""" + """Returns the audience for which this token is intended.""" return self.get('aud') + @property + def exp(self): + """Returns the expiration time since the Unix epoch.""" + return self.get('exp') + + @property + def iat(self): + """Returns the issued-at time since the Unix epoch.""" + return self.get('iat') + @property def sub(self): """Returns the sub (subject) of the token, which is the phone number.""" return self.get('sub') - # TODO: ADD ALL + @property + def claims(self): + """Returns the entire map of claims.""" + return self class FpnvClient: @@ -122,9 +141,9 @@ def verify_token(self, token) -> FpnvToken: try: claims = self._verifier.verify(token) return FpnvToken(claims) - except Exception as error: + except ValueError as error: raise InvalidArgumentError( - 'Failed to verify token: {0}'.format(error) + 'Failed to verify token: {0}'.format(error), cause=error ) @@ -166,19 +185,15 @@ def _validate_headers(self, headers: Any) -> None: def _validate_payload(self, token: str, signing_key: str) -> Dict[str, Any]: """Decodes and verifies the token.""" - _issuer = None + expected_issuer = f'{_FPNV_ISSUER}{self._project_id}' payload = {} try: - unsafe_payload = jwt.decode(token, options={"verify_signature": False}) - _issuer = unsafe_payload.get('iss') - - if _issuer is None: - raise ValueError('The provided FPNV token has no issuer.') payload = jwt.decode( token, signing_key, algorithms=[_ALGORITHM_ES256], - audience=_issuer + audience=expected_issuer, + issuer=expected_issuer ) except InvalidSignatureError as exception: raise ValueError( @@ -187,12 +202,12 @@ def _validate_payload(self, token: str, signing_key: str) -> Dict[str, Any]: except InvalidAudienceError as exception: raise ValueError( 'The provided FPNV token has an incorrect "aud" (audience) claim. ' - f'Expected payload to include {_issuer}.' + f'Expected payload to include {expected_issuer}.' ) from exception except InvalidIssuerError as exception: raise ValueError( 'The provided FPNV token has an incorrect "iss" (issuer) claim. ' - f'Expected claim to include {_issuer}' + f'Expected claim to include {expected_issuer}' ) from exception except ExpiredSignatureError as exception: raise ValueError( @@ -221,7 +236,5 @@ class _Validators: @classmethod def check_string(cls, label: str, value: Any): """Checks if the given value is a string.""" - if value is None: - raise ValueError(f'{label} "{value}" must be a non-empty string.') - if not isinstance(value, str): - raise ValueError(f'{label} "{value}" must be a string.') + if not isinstance(value, str) or not value: + raise ValueError(f'{label} must be a non-empty string.') From 7ebc65273e7fd10fefd3f8b612caf778940bd0e6 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 14:09:59 +0100 Subject: [PATCH 4/8] chore: add tests --- tests/test_fpnv.py | 197 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 196 insertions(+), 1 deletion(-) diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py index 818238a4..3dc2bebc 100644 --- a/tests/test_fpnv.py +++ b/tests/test_fpnv.py @@ -14,5 +14,200 @@ """Test cases for the firebase_admin.fpnv module.""" +import json +from datetime import time + +import jwt +import pytest +from unittest import mock + +import firebase_admin +from firebase_admin import fpnv +from firebase_admin import _utils +from tests import testutils + +# Mock Data +_PROJECT_ID = 'mock-project-id' +_FPNV_TOKEN = 'fpnv_token_string' +_EXP_TIMESTAMP = 2000000000 +_ISSUER = f'https://fpnv.googleapis.com/projects/{_PROJECT_ID}' +_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks' +_PHONE_NUMBER = '+1234567890' +_ISSUER_PREFIX = 'https://fpnv.googleapis.com/projects/' +_PRIVATE_KEY = 'test-private-key' # In real tests, use a real RSA/EC private key +_PUBLIC_KEY = 'test-public-key' # In real tests, use the corresponding public key + +_MOCK_PAYLOAD = { + 'iss': _ISSUER, + 'sub': '+1234567890', + 'aud': [_ISSUER], + 'exp': _EXP_TIMESTAMP, + 'iat': _EXP_TIMESTAMP - 3600, + "other": 'other' +} + + +@pytest.fixture +def app(): + cred = testutils.MockCredential() + return firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID}) + + +@pytest.fixture +def client(app): + return fpnv.client(app) + + +class TestCommon: + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + + +class TestFpnvToken(TestCommon): + def test_properties(self): + token = fpnv.FpnvToken(_MOCK_PAYLOAD) + + assert token.phone_number == _PHONE_NUMBER + assert token.sub == _PHONE_NUMBER + assert token.issuer == _ISSUER + assert token.audience == [_ISSUER] + assert token.exp == _MOCK_PAYLOAD['exp'] + assert token.iat == _MOCK_PAYLOAD['iat'] + assert token.claims == _MOCK_PAYLOAD + assert token['other'] == _MOCK_PAYLOAD['other'] + + +class TestFpnvClient(TestCommon): + + def test_client_no_app(self): + with mock.patch('firebase_admin._utils.get_app_service') as mock_get_service: + fpnv.client() + mock_get_service.assert_called_once() + with pytest.raises(ValueError): + fpnv.client() + + def test_client(self, app): + client = fpnv.client(app) + assert isinstance(client, fpnv.FpnvClient) + assert client._project_id == _PROJECT_ID + + def test_requires_project_id(self): + cred = testutils.MockCredential() + # Create app without project ID + app = firebase_admin.initialize_app(cred, name='no_project_id') + # Mock credential to not have project_id + app.credential.get_credential().project_id = None + + with pytest.raises(ValueError, match='Project ID is required'): + fpnv.client(app) + + def test_client_default_app(self): + client = fpnv.client() + assert isinstance(client, fpnv.FpnvClient) + + def test_client_explicit_app(self): + cred = testutils.MockCredential() + app = firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID}, name='custom') + client = fpnv.client(app) + assert isinstance(client, fpnv.FpnvClient) + + class TestVerifyToken: - pass \ No newline at end of file + @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, client): + 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 + + mock_decode.return_value = _MOCK_PAYLOAD + + # Execute + token = client.verify_token(token_str) + + # Verify + assert isinstance(token, fpnv.FpnvToken) + 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_kid(self, mock_header, client): + mock_header.return_value = {'typ': 'JWT', 'alg': 'ES256'} # Missing kid + with pytest.raises(ValueError, match="no 'kid' claim"): + client.verify_token('token') + + @mock.patch('jwt.get_unverified_header') + def test_verify_token_wrong_alg(self, mock_header, client): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'RS256'} # Wrong alg + with pytest.raises(ValueError, match="incorrect alg"): + client.verify_token('token') + + @mock.patch('jwt.PyJWKClient') + @mock.patch('jwt.get_unverified_header') + def test_verify_token_jwk_error(self, mock_header, mock_jwks_cls, client): + mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} + mock_jwks_instance = mock_jwks_cls.return_value + # Simulate Key not found or other PyJWKClient error + mock_jwks_instance.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError("Key not found") + + with pytest.raises(ValueError, match="Verifying FPNV token failed"): + client.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, client): + 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 + + # Simulate ExpiredSignatureError + mock_decode.side_effect = jwt.ExpiredSignatureError("Expired") + + with pytest.raises(ValueError, match="token has expired"): + client.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, client): + 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 + + # Simulate InvalidAudienceError + mock_decode.side_effect = jwt.InvalidAudienceError("Wrong Aud") + + with pytest.raises(ValueError, match="incorrect \"aud\""): + client.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, client): + 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 + + # Simulate InvalidIssuerError + mock_decode.side_effect = jwt.InvalidIssuerError("Wrong Iss") + + with pytest.raises(ValueError, match="incorrect \"iss\""): + client.verify_token('token') From 03353e9828c1a8f2e150287c9a5a961fbdb673eb Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 15:57:20 +0100 Subject: [PATCH 5/8] chore: update tests --- tests/test_fpnv.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py index 3dc2bebc..ea8c358c 100644 --- a/tests/test_fpnv.py +++ b/tests/test_fpnv.py @@ -14,16 +14,13 @@ """Test cases for the firebase_admin.fpnv module.""" -import json -from datetime import time +from unittest import mock import jwt import pytest -from unittest import mock import firebase_admin from firebase_admin import fpnv -from firebase_admin import _utils from tests import testutils # Mock Data @@ -114,6 +111,7 @@ def test_client_explicit_app(self): class TestVerifyToken: + @mock.patch('jwt.PyJWKClient') @mock.patch('jwt.decode') @mock.patch('jwt.get_unverified_header') @@ -165,7 +163,8 @@ def test_verify_token_jwk_error(self, mock_header, mock_jwks_cls, client): mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} mock_jwks_instance = mock_jwks_cls.return_value # Simulate Key not found or other PyJWKClient error - mock_jwks_instance.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError("Key not found") + mock_jwks_instance.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError( + "Key not found") with pytest.raises(ValueError, match="Verifying FPNV token failed"): client.verify_token('token') From 839a4971036596ff00a4ad0b557824fb73f0da80 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 17:13:17 +0100 Subject: [PATCH 6/8] chore: update tests --- firebase_admin/fpnv.py | 11 ++--------- tests/test_fpnv.py | 44 ++++++++++++++++++++++-------------------- 2 files changed, 25 insertions(+), 30 deletions(-) diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py index a2ca7c02..7be5547c 100644 --- a/firebase_admin/fpnv.py +++ b/firebase_admin/fpnv.py @@ -136,15 +136,8 @@ def verify_token(self, token) -> FpnvToken: Raises: ValueError: If the token is invalid or malformed. - firebase_admin.exceptions.InvalidArgumentError: If verification fails. """ - try: - claims = self._verifier.verify(token) - return FpnvToken(claims) - except ValueError as error: - raise InvalidArgumentError( - 'Failed to verify token: {0}'.format(error), cause=error - ) + return FpnvToken(self._verifier.verify(token)) class _FpnvTokenVerifier: @@ -237,4 +230,4 @@ class _Validators: 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.') + raise ValueError(f'{label} must be a non-empty string.') \ No newline at end of file diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py index ea8c358c..24eb78a4 100644 --- a/tests/test_fpnv.py +++ b/tests/test_fpnv.py @@ -14,6 +14,7 @@ """Test cases for the firebase_admin.fpnv module.""" +import base64 from unittest import mock import jwt @@ -43,25 +44,25 @@ "other": 'other' } - -@pytest.fixture -def app(): - cred = testutils.MockCredential() - return firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID}) - - @pytest.fixture -def client(app): +def client(): + app = firebase_admin.get_app() return fpnv.client(app) 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(TestCommon): +class TestFpnvToken: def test_properties(self): token = fpnv.FpnvToken(_MOCK_PAYLOAD) @@ -77,14 +78,8 @@ def test_properties(self): class TestFpnvClient(TestCommon): - def test_client_no_app(self): - with mock.patch('firebase_admin._utils.get_app_service') as mock_get_service: - fpnv.client() - mock_get_service.assert_called_once() - with pytest.raises(ValueError): - fpnv.client() - - def test_client(self, app): + def test_client(self): + app = firebase_admin.get_app() client = fpnv.client(app) assert isinstance(client, fpnv.FpnvClient) assert client._project_id == _PROJECT_ID @@ -110,7 +105,7 @@ def test_client_explicit_app(self): assert isinstance(client, fpnv.FpnvClient) -class TestVerifyToken: +class TestVerifyToken(TestCommon): @mock.patch('jwt.PyJWKClient') @mock.patch('jwt.decode') @@ -125,6 +120,7 @@ def test_verify_token_success(self, mock_header, mock_decode, mock_jwks_cls, cli mock_signing_key = mock.Mock() mock_signing_key.key = _PUBLIC_KEY mock_jwks_instance.get_signing_key_from_jwt.return_value = mock_signing_key + client._verifier._jwks_client = mock_jwks_instance mock_decode.return_value = _MOCK_PAYLOAD @@ -146,9 +142,11 @@ def test_verify_token_success(self, mock_header, mock_decode, mock_jwks_cls, cli ) @mock.patch('jwt.get_unverified_header') - def test_verify_token_no_kid(self, mock_header, client): + def test_verify_token_no_kid(self, mock_header): + app = firebase_admin.get_app() + client = fpnv.client(app) mock_header.return_value = {'typ': 'JWT', 'alg': 'ES256'} # Missing kid - with pytest.raises(ValueError, match="no 'kid' claim"): + with pytest.raises(ValueError, match="FPNV has no 'kid' claim."): client.verify_token('token') @mock.patch('jwt.get_unverified_header') @@ -176,6 +174,8 @@ def test_verify_token_expired(self, mock_header, mock_decode, mock_jwks_cls, cli 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 + client._verifier._jwks_client = mock_jwks_instance + # Simulate ExpiredSignatureError mock_decode.side_effect = jwt.ExpiredSignatureError("Expired") @@ -190,6 +190,7 @@ def test_verify_token_invalid_audience(self, mock_header, mock_decode, mock_jwks 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 + client._verifier._jwks_client = mock_jwks_instance # Simulate InvalidAudienceError mock_decode.side_effect = jwt.InvalidAudienceError("Wrong Aud") @@ -204,9 +205,10 @@ def test_verify_token_invalid_issuer(self, mock_header, mock_decode, mock_jwks_c 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 + client._verifier._jwks_client = mock_jwks_instance # Simulate InvalidIssuerError mock_decode.side_effect = jwt.InvalidIssuerError("Wrong Iss") with pytest.raises(ValueError, match="incorrect \"iss\""): - client.verify_token('token') + client.verify_token('token') \ No newline at end of file From 966b31354c49752e42c1dd4b041d5f7234043e16 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 17:55:49 +0100 Subject: [PATCH 7/8] chore: update tests --- firebase_admin/fpnv.py | 7 +++---- tests/test_fpnv.py | 30 ++++++++++++++++-------------- 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py index 7be5547c..2bfc7df9 100644 --- a/firebase_admin/fpnv.py +++ b/firebase_admin/fpnv.py @@ -21,10 +21,9 @@ import jwt from jwt import PyJWKClient, InvalidTokenError, DecodeError, InvalidSignatureError, \ - InvalidAudienceError, InvalidIssuerError, ExpiredSignatureError + PyJWKClientError, InvalidAudienceError, InvalidIssuerError, ExpiredSignatureError from firebase_admin import _utils -from firebase_admin.exceptions import InvalidArgumentError _FPNV_ATTRIBUTE = '_fpnv' _FPNV_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks' @@ -155,7 +154,7 @@ def verify(self, token) -> Dict[str, Any]: self._validate_headers(jwt.get_unverified_header(token)) signing_key = self._jwks_client.get_signing_key_from_jwt(token) claims = self._validate_payload(token, signing_key.key) - except (InvalidTokenError, DecodeError) as exception: + except (InvalidTokenError, DecodeError, PyJWKClientError) as exception: raise ValueError( f'Verifying FPNV token failed. Error: {exception}' ) from exception @@ -230,4 +229,4 @@ class _Validators: 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.') \ No newline at end of file + raise ValueError(f'{label} must be a non-empty string.') diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py index 24eb78a4..4dbd57cc 100644 --- a/tests/test_fpnv.py +++ b/tests/test_fpnv.py @@ -14,7 +14,6 @@ """Test cases for the firebase_admin.fpnv module.""" -import base64 from unittest import mock import jwt @@ -44,6 +43,7 @@ "other": 'other' } + @pytest.fixture def client(): app = firebase_admin.get_app() @@ -56,7 +56,6 @@ def setup_class(cls): cred = testutils.MockCredential() firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID}) - @classmethod def teardown_class(cls): testutils.cleanup_apps() @@ -155,17 +154,21 @@ def test_verify_token_wrong_alg(self, mock_header, client): with pytest.raises(ValueError, match="incorrect alg"): client.verify_token('token') - @mock.patch('jwt.PyJWKClient') - @mock.patch('jwt.get_unverified_header') - def test_verify_token_jwk_error(self, mock_header, mock_jwks_cls, client): - mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'} - mock_jwks_instance = mock_jwks_cls.return_value - # Simulate Key not found or other PyJWKClient error - mock_jwks_instance.get_signing_key_from_jwt.side_effect = jwt.PyJWKClientError( - "Key not found") + def test_verify_token_jwk_error(self, client): + # Access the ACTUAL client instance used by the verifier + # (Assuming internal structure: client -> _verifier -> _jwks_client) + jwks_client = client._verifier._jwks_client - with pytest.raises(ValueError, match="Verifying FPNV token failed"): - client.verify_token('token') + # 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(ValueError, match="Verifying FPNV token failed"): + client.verify_token('token') @mock.patch('jwt.PyJWKClient') @mock.patch('jwt.decode') @@ -176,7 +179,6 @@ def test_verify_token_expired(self, mock_header, mock_decode, mock_jwks_cls, cli mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY client._verifier._jwks_client = mock_jwks_instance - # Simulate ExpiredSignatureError mock_decode.side_effect = jwt.ExpiredSignatureError("Expired") @@ -211,4 +213,4 @@ def test_verify_token_invalid_issuer(self, mock_header, mock_decode, mock_jwks_c mock_decode.side_effect = jwt.InvalidIssuerError("Wrong Iss") with pytest.raises(ValueError, match="incorrect \"iss\""): - client.verify_token('token') \ No newline at end of file + client.verify_token('token') From b7240995b2f2ada80cc40f2fbaa884d9ad354eb3 Mon Sep 17 00:00:00 2001 From: Andrii Boiko Date: Wed, 21 Jan 2026 18:23:29 +0100 Subject: [PATCH 8/8] chore: resolve comments --- firebase_admin/fpnv.py | 3 --- tests/test_fpnv.py | 4 ---- 2 files changed, 7 deletions(-) diff --git a/firebase_admin/fpnv.py b/firebase_admin/fpnv.py index 2bfc7df9..d0374859 100644 --- a/firebase_admin/fpnv.py +++ b/firebase_admin/fpnv.py @@ -178,7 +178,6 @@ def _validate_headers(self, headers: Any) -> None: def _validate_payload(self, token: str, signing_key: str) -> Dict[str, Any]: """Decodes and verifies the token.""" expected_issuer = f'{_FPNV_ISSUER}{self._project_id}' - payload = {} try: payload = jwt.decode( token, @@ -210,8 +209,6 @@ def _validate_payload(self, token: str, signing_key: str) -> Dict[str, Any]: f'Decoding FPNV token failed. Error: {exception}' ) from exception - if not payload.get('iss').startswith(_FPNV_ISSUER): - raise ValueError('Token does not contain the correct "iss" (issuer).') _Validators.check_string( 'The provided FPNV token "sub" (subject) claim', payload.get('sub')) diff --git a/tests/test_fpnv.py b/tests/test_fpnv.py index 4dbd57cc..21e85c23 100644 --- a/tests/test_fpnv.py +++ b/tests/test_fpnv.py @@ -25,13 +25,9 @@ # Mock Data _PROJECT_ID = 'mock-project-id' -_FPNV_TOKEN = 'fpnv_token_string' _EXP_TIMESTAMP = 2000000000 _ISSUER = f'https://fpnv.googleapis.com/projects/{_PROJECT_ID}' -_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks' _PHONE_NUMBER = '+1234567890' -_ISSUER_PREFIX = 'https://fpnv.googleapis.com/projects/' -_PRIVATE_KEY = 'test-private-key' # In real tests, use a real RSA/EC private key _PUBLIC_KEY = 'test-public-key' # In real tests, use the corresponding public key _MOCK_PAYLOAD = {