firebase-admin 7.3.0__tar.gz → 7.4.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/PKG-INFO +1 -1
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/__about__.py +1 -1
- firebase_admin-7.4.0/firebase_admin/phone_number_verification.py +250 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin.egg-info/PKG-INFO +1 -1
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin.egg-info/SOURCES.txt +2 -0
- firebase_admin-7.4.0/tests/test_phone_number_verification.py +311 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/LICENSE +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/README.md +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/__init__.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_auth_client.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_auth_providers.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_auth_utils.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_http_client.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_messaging_encoder.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_messaging_utils.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_retry.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_rfc3339.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_sseclient.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_token_gen.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_user_identifier.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_user_import.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_user_mgt.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/_utils.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/app_check.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/auth.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/credentials.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/db.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/exceptions.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/firestore.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/firestore_async.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/functions.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/instance_id.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/messaging.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/ml.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/project_management.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/remote_config.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/storage.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin/tenant_mgt.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin.egg-info/dependency_links.txt +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin.egg-info/requires.txt +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/firebase_admin.egg-info/top_level.txt +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/setup.cfg +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/setup.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_app.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_app_check.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_auth_providers.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_credentials.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_db.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_exceptions.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_firestore.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_firestore_async.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_functions.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_http_client.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_instance_id.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_messaging.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_ml.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_project_management.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_remote_config.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_retry.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_rfc3339.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_sseclient.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_storage.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_tenant_mgt.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_token_gen.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/test_user_mgt.py +0 -0
- {firebase_admin-7.3.0 → firebase_admin-7.4.0}/tests/testutils.py +0 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# Copyright 2026 Google Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Firebase Phone Number Verification module.
|
|
16
|
+
|
|
17
|
+
This module contains functions for verifying JWTs related to the Firebase
|
|
18
|
+
Phone Number Verification service.
|
|
19
|
+
"""
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
from typing import Any, Dict, Optional
|
|
22
|
+
|
|
23
|
+
import jwt
|
|
24
|
+
from jwt import (
|
|
25
|
+
PyJWKClient, InvalidSignatureError,
|
|
26
|
+
PyJWKClientError, InvalidAudienceError, InvalidIssuerError, ExpiredSignatureError
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
from firebase_admin import App, _utils, exceptions
|
|
30
|
+
|
|
31
|
+
_FPNV_ATTRIBUTE = '_phone_number_verification'
|
|
32
|
+
_FPNV_JWKS_URL = 'https://fpnv.googleapis.com/v1beta/jwks'
|
|
33
|
+
_FPNV_ISSUER = 'https://fpnv.googleapis.com/projects/'
|
|
34
|
+
_ALGORITHM_ES256 = 'ES256'
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _get_fpnv_service(app):
|
|
38
|
+
return _utils.get_app_service(app, _FPNV_ATTRIBUTE, _FpnvService)
|
|
39
|
+
|
|
40
|
+
def verify_token(token: str, app: Optional[App] = None) -> PhoneNumberVerificationToken:
|
|
41
|
+
"""Verifies a Firebase Phone Number Verification token.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
token: A string containing the Firebase Phone Number Verification JWT.
|
|
45
|
+
app: An App instance (optional).
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
PhoneNumberVerificationToken: The verified token claims.
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
ValueError: If the token is not a string or is empty.
|
|
52
|
+
InvalidTokenError: If the token is invalid or malformed.
|
|
53
|
+
ExpiredTokenError: If the token has expired.
|
|
54
|
+
"""
|
|
55
|
+
return _get_fpnv_service(app).verify_token(token)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PhoneNumberVerificationToken(dict):
|
|
59
|
+
"""Represents a verified Firebase Phone Number Verification token.
|
|
60
|
+
|
|
61
|
+
This class behaves like a dictionary, allowing access to the decoded claims.
|
|
62
|
+
It also provides convenience properties for common claims.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, claims):
|
|
66
|
+
super().__init__(claims)
|
|
67
|
+
self['phone_number'] = claims.get('sub')
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def phone_number(self) -> str:
|
|
71
|
+
"""Returns the phone number of the user.
|
|
72
|
+
This corresponds to the 'sub' claim in the JWT.
|
|
73
|
+
"""
|
|
74
|
+
return self.get('sub')
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def issuer(self) -> str:
|
|
78
|
+
"""Returns the issuer identifier for the issuer of the response."""
|
|
79
|
+
return self.get('iss')
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def audience(self) -> str:
|
|
83
|
+
"""Returns the audience for which this token is intended."""
|
|
84
|
+
return self.get('aud')
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def exp(self) -> int:
|
|
88
|
+
"""Returns the expiration time since the Unix epoch."""
|
|
89
|
+
return self.get('exp')
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def iat(self) -> int:
|
|
93
|
+
"""Returns the issued-at time since the Unix epoch."""
|
|
94
|
+
return self.get('iat')
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def sub(self) -> str:
|
|
98
|
+
"""Returns the sub (subject) of the token, which is the phone number."""
|
|
99
|
+
return self.get('sub')
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def claims(self):
|
|
103
|
+
"""Returns the entire map of claims."""
|
|
104
|
+
return self
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class _FpnvService:
|
|
108
|
+
"""Service class that implements Firebase Phone Number Verification functionality."""
|
|
109
|
+
_project_id = None
|
|
110
|
+
|
|
111
|
+
def __init__(self, app):
|
|
112
|
+
self._project_id = app.project_id
|
|
113
|
+
if not self._project_id:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
'Project ID is required for Firebase Phone Number Verification. Please ensure the '
|
|
116
|
+
'app is initialized with a credential that contains a project ID.'
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
self._verifier = _FpnvTokenVerifier(self._project_id)
|
|
120
|
+
|
|
121
|
+
def verify_token(self, token) -> PhoneNumberVerificationToken:
|
|
122
|
+
"""Verifies a Firebase Phone Number Verification token.
|
|
123
|
+
|
|
124
|
+
Verifies the signature, expiration, and claims of the token.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
token: A string containing the Firebase Phone Number Verification JWT.
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
PhoneNumberVerificationToken: The verified token claims.
|
|
131
|
+
|
|
132
|
+
Raises:
|
|
133
|
+
ValueError: If the token is not a string or is empty.
|
|
134
|
+
InvalidTokenError: If the token is invalid or malformed.
|
|
135
|
+
ExpiredTokenError: If the token has expired.
|
|
136
|
+
"""
|
|
137
|
+
return PhoneNumberVerificationToken(self._verifier.verify(token))
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class _FpnvTokenVerifier:
|
|
141
|
+
"""Internal class for verifying Firebase Phone Number Verification JWTs signed with ES256."""
|
|
142
|
+
_jwks_client = None
|
|
143
|
+
_project_id = None
|
|
144
|
+
|
|
145
|
+
def __init__(self, project_id):
|
|
146
|
+
self._project_id = project_id
|
|
147
|
+
self._jwks_client = PyJWKClient(_FPNV_JWKS_URL, lifespan=21600)
|
|
148
|
+
|
|
149
|
+
def verify(self, token) -> Dict[str, Any]:
|
|
150
|
+
"""Verifies the given Firebase Phone Number Verification token."""
|
|
151
|
+
_Validators.check_string("Firebase Phone Number Verification check token", token)
|
|
152
|
+
try:
|
|
153
|
+
self._validate_headers(jwt.get_unverified_header(token))
|
|
154
|
+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
|
|
155
|
+
claims = self._decode_and_verify(token, signing_key.key)
|
|
156
|
+
except (jwt.InvalidTokenError, PyJWKClientError) as exception:
|
|
157
|
+
raise InvalidTokenError(
|
|
158
|
+
'Verifying phone number verification token failed.',
|
|
159
|
+
cause=exception,
|
|
160
|
+
http_response=getattr(exception, 'http_response', None)
|
|
161
|
+
) from exception
|
|
162
|
+
|
|
163
|
+
return claims
|
|
164
|
+
|
|
165
|
+
def _validate_headers(self, headers: Any) -> None:
|
|
166
|
+
"""Validates the headers."""
|
|
167
|
+
if headers.get('kid') is None:
|
|
168
|
+
raise InvalidTokenError("Token has no 'kid' claim.")
|
|
169
|
+
|
|
170
|
+
if headers.get('typ') != 'JWT':
|
|
171
|
+
raise InvalidTokenError(
|
|
172
|
+
'The provided token has an incorrect type header. ' \
|
|
173
|
+
f"Expected 'JWT' but got {headers.get('typ')!r}."
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
algorithm = headers.get('alg')
|
|
177
|
+
if algorithm != _ALGORITHM_ES256:
|
|
178
|
+
raise InvalidTokenError(
|
|
179
|
+
'The provided token has an incorrect alg header. '
|
|
180
|
+
f'Expected {_ALGORITHM_ES256} but got {algorithm}.'
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
def _decode_and_verify(self, token, signing_key) -> Dict[str, Any]:
|
|
184
|
+
"""Decodes and verifies the token."""
|
|
185
|
+
expected_issuer = f'{_FPNV_ISSUER}{self._project_id}'
|
|
186
|
+
try:
|
|
187
|
+
payload = jwt.decode(
|
|
188
|
+
token,
|
|
189
|
+
signing_key,
|
|
190
|
+
algorithms=[_ALGORITHM_ES256],
|
|
191
|
+
audience=expected_issuer,
|
|
192
|
+
issuer=expected_issuer
|
|
193
|
+
)
|
|
194
|
+
except InvalidSignatureError as exception:
|
|
195
|
+
raise InvalidTokenError(
|
|
196
|
+
'The provided token has an invalid signature.'
|
|
197
|
+
) from exception
|
|
198
|
+
except InvalidAudienceError as exception:
|
|
199
|
+
raise InvalidTokenError(
|
|
200
|
+
'The provided token has an incorrect "aud" (audience) claim. '
|
|
201
|
+
f'Expected {expected_issuer}.'
|
|
202
|
+
) from exception
|
|
203
|
+
except InvalidIssuerError as exception:
|
|
204
|
+
raise InvalidTokenError(
|
|
205
|
+
'The provided token has an incorrect "iss" (issuer) claim. '
|
|
206
|
+
f'Expected {expected_issuer}.'
|
|
207
|
+
) from exception
|
|
208
|
+
except ExpiredSignatureError as exception:
|
|
209
|
+
raise ExpiredTokenError(
|
|
210
|
+
'The provided token has expired.'
|
|
211
|
+
) from exception
|
|
212
|
+
except jwt.InvalidTokenError as exception:
|
|
213
|
+
raise InvalidTokenError(
|
|
214
|
+
f'Decoding token failed. Error: {exception}'
|
|
215
|
+
) from exception
|
|
216
|
+
|
|
217
|
+
sub_claim = payload.get('sub')
|
|
218
|
+
if not isinstance(sub_claim, str) or not sub_claim:
|
|
219
|
+
raise InvalidTokenError(
|
|
220
|
+
'The provided token has an incorrect "sub" (subject) claim. '
|
|
221
|
+
'Expected a non-empty string.'
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
return payload
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class _Validators:
|
|
228
|
+
"""A collection of data validation utilities.
|
|
229
|
+
|
|
230
|
+
Methods provided in this class raise ``ValueErrors`` if any validations fail.
|
|
231
|
+
"""
|
|
232
|
+
|
|
233
|
+
@classmethod
|
|
234
|
+
def check_string(cls, label: str, value: Any):
|
|
235
|
+
"""Checks if the given value is a string."""
|
|
236
|
+
if not isinstance(value, str) or not value:
|
|
237
|
+
raise ValueError(f'{label} must be a non-empty string.')
|
|
238
|
+
|
|
239
|
+
# Firebase Phone Number Verification Errors
|
|
240
|
+
class InvalidTokenError(exceptions.InvalidArgumentError):
|
|
241
|
+
"""Raised when a Firebase Phone Number Verification token is invalid."""
|
|
242
|
+
|
|
243
|
+
def __init__(self, message, cause=None, http_response=None):
|
|
244
|
+
exceptions.InvalidArgumentError.__init__(self, message, cause, http_response)
|
|
245
|
+
|
|
246
|
+
class ExpiredTokenError(InvalidTokenError):
|
|
247
|
+
"""Raised when a Firebase Phone Number Verification token is expired."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, message, cause=None, http_response=None):
|
|
250
|
+
InvalidTokenError.__init__(self, message, cause, http_response)
|
|
@@ -29,6 +29,7 @@ firebase_admin/functions.py
|
|
|
29
29
|
firebase_admin/instance_id.py
|
|
30
30
|
firebase_admin/messaging.py
|
|
31
31
|
firebase_admin/ml.py
|
|
32
|
+
firebase_admin/phone_number_verification.py
|
|
32
33
|
firebase_admin/project_management.py
|
|
33
34
|
firebase_admin/remote_config.py
|
|
34
35
|
firebase_admin/storage.py
|
|
@@ -51,6 +52,7 @@ tests/test_http_client.py
|
|
|
51
52
|
tests/test_instance_id.py
|
|
52
53
|
tests/test_messaging.py
|
|
53
54
|
tests/test_ml.py
|
|
55
|
+
tests/test_phone_number_verification.py
|
|
54
56
|
tests/test_project_management.py
|
|
55
57
|
tests/test_remote_config.py
|
|
56
58
|
tests/test_retry.py
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
# Copyright 2026 Google Inc.
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Test cases for the firebase_admin.fpnv module."""
|
|
16
|
+
|
|
17
|
+
import base64
|
|
18
|
+
import time
|
|
19
|
+
from unittest import mock
|
|
20
|
+
from unittest.mock import patch
|
|
21
|
+
|
|
22
|
+
import jwt
|
|
23
|
+
import pytest
|
|
24
|
+
from cryptography.hazmat.primitives.asymmetric import ec
|
|
25
|
+
|
|
26
|
+
import firebase_admin
|
|
27
|
+
from firebase_admin import phone_number_verification as fpnv
|
|
28
|
+
from tests import testutils
|
|
29
|
+
|
|
30
|
+
# Mock Data
|
|
31
|
+
_PROJECT_ID = 'mock-project-id'
|
|
32
|
+
_EXP_TIMESTAMP = 2000000000
|
|
33
|
+
_ISSUER = f'https://fpnv.googleapis.com/projects/{_PROJECT_ID}'
|
|
34
|
+
_PHONE_NUMBER = '+1234567890'
|
|
35
|
+
_PUBLIC_KEY = 'test-public-key' # In real tests, use the corresponding public key
|
|
36
|
+
_ALGORITHM = 'ES256'
|
|
37
|
+
_KEY_ID = 'test-key-id'
|
|
38
|
+
_TYPE = 'JWT'
|
|
39
|
+
|
|
40
|
+
_MOCK_PAYLOAD = {
|
|
41
|
+
'iss': _ISSUER,
|
|
42
|
+
'sub': _PHONE_NUMBER,
|
|
43
|
+
'aud': [_ISSUER],
|
|
44
|
+
'exp': _EXP_TIMESTAMP,
|
|
45
|
+
'iat': _EXP_TIMESTAMP - 3600,
|
|
46
|
+
"other": 'other'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class TestCommon:
|
|
53
|
+
@classmethod
|
|
54
|
+
def setup_class(cls):
|
|
55
|
+
cred = testutils.MockCredential()
|
|
56
|
+
firebase_admin.initialize_app(cred, {'projectId': _PROJECT_ID})
|
|
57
|
+
|
|
58
|
+
@classmethod
|
|
59
|
+
def teardown_class(cls):
|
|
60
|
+
testutils.cleanup_apps()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestFpnvToken:
|
|
64
|
+
def test_properties(self):
|
|
65
|
+
token = fpnv.PhoneNumberVerificationToken(_MOCK_PAYLOAD)
|
|
66
|
+
|
|
67
|
+
assert token.phone_number == _PHONE_NUMBER
|
|
68
|
+
assert token.sub == _PHONE_NUMBER
|
|
69
|
+
assert token.issuer == _ISSUER
|
|
70
|
+
assert token.audience == [_ISSUER]
|
|
71
|
+
expected_claims = _MOCK_PAYLOAD.copy()
|
|
72
|
+
expected_claims['phone_number'] = _PHONE_NUMBER
|
|
73
|
+
assert token.claims == expected_claims
|
|
74
|
+
assert token['other'] == _MOCK_PAYLOAD['other']
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class TestVerifyToken(TestCommon):
|
|
78
|
+
|
|
79
|
+
def test_no_project_id(self):
|
|
80
|
+
app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id')
|
|
81
|
+
app.credential.get_credential().project_id = None
|
|
82
|
+
with pytest.raises(
|
|
83
|
+
ValueError,
|
|
84
|
+
match='Project ID is required for Firebase Phone Number Verification'
|
|
85
|
+
):
|
|
86
|
+
fpnv.verify_token('token', app=app)
|
|
87
|
+
|
|
88
|
+
def test_verify_token_with_real_crypto(self):
|
|
89
|
+
"""Verifies a token signed with a real ES256 key pair.
|
|
90
|
+
|
|
91
|
+
Mocking only the JWKS endpoint.
|
|
92
|
+
This ensures the cryptographic verification logic is functioning correctly.
|
|
93
|
+
"""
|
|
94
|
+
# Generate a real ES256 key pair
|
|
95
|
+
private_key = ec.generate_private_key(ec.SECP256R1())
|
|
96
|
+
public_key = private_key.public_key()
|
|
97
|
+
|
|
98
|
+
# Create the JWK representation of the public key (for the mock endpoint)
|
|
99
|
+
# Note: Retrieving numbers from the key involves cryptography primitives
|
|
100
|
+
public_numbers = public_key.public_numbers()
|
|
101
|
+
|
|
102
|
+
def to_b64url(b_data):
|
|
103
|
+
return base64.urlsafe_b64encode(b_data).rstrip(b'=').decode('utf-8')
|
|
104
|
+
|
|
105
|
+
jwk = {
|
|
106
|
+
"kty": "EC",
|
|
107
|
+
"use": "sig",
|
|
108
|
+
"alg": _ALGORITHM,
|
|
109
|
+
"kid": _KEY_ID,
|
|
110
|
+
"crv": "P-256",
|
|
111
|
+
"x": to_b64url(public_numbers.x.to_bytes(32, 'big')),
|
|
112
|
+
"y": to_b64url(public_numbers.y.to_bytes(32, 'big')),
|
|
113
|
+
}
|
|
114
|
+
now = int(time.time())
|
|
115
|
+
payload = {
|
|
116
|
+
'iss': _ISSUER,
|
|
117
|
+
'aud': [_ISSUER],
|
|
118
|
+
'iat': now,
|
|
119
|
+
'exp': now + 3600,
|
|
120
|
+
'sub': _PHONE_NUMBER
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
# Sign using the private key object directly (PyJWT supports this)
|
|
124
|
+
token = jwt.encode(
|
|
125
|
+
payload,
|
|
126
|
+
private_key,
|
|
127
|
+
algorithm=_ALGORITHM,
|
|
128
|
+
headers={'alg': _ALGORITHM, 'typ': _TYPE, 'kid': _KEY_ID},
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Mock PyJWKClient fetch_data
|
|
132
|
+
with patch('jwt.PyJWKClient.fetch_data') as mock_fetch:
|
|
133
|
+
mock_fetch.return_value = {'keys': [jwk]}
|
|
134
|
+
|
|
135
|
+
app = firebase_admin.get_app()
|
|
136
|
+
decoded_token = fpnv.verify_token(token, app)
|
|
137
|
+
|
|
138
|
+
assert decoded_token['sub'] == _PHONE_NUMBER
|
|
139
|
+
assert _ISSUER in decoded_token['aud']
|
|
140
|
+
assert decoded_token.phone_number == decoded_token['sub']
|
|
141
|
+
# Test convenience dictionary lookup
|
|
142
|
+
assert decoded_token['phone_number'] == _PHONE_NUMBER
|
|
143
|
+
|
|
144
|
+
def test_verify_token_module_level_delegation(self):
|
|
145
|
+
"""Verifies module-level verify_token delegates correctly."""
|
|
146
|
+
with patch(
|
|
147
|
+
'firebase_admin.phone_number_verification._FpnvService.verify_token'
|
|
148
|
+
) as mock_verify:
|
|
149
|
+
mock_verify.return_value = 'mock-result'
|
|
150
|
+
res = fpnv.verify_token('some-token')
|
|
151
|
+
assert res == 'mock-result'
|
|
152
|
+
mock_verify.assert_called_once_with('some-token')
|
|
153
|
+
|
|
154
|
+
@mock.patch('jwt.PyJWKClient')
|
|
155
|
+
@mock.patch('jwt.decode')
|
|
156
|
+
@mock.patch('jwt.get_unverified_header')
|
|
157
|
+
def test_verify_token_success(self, mock_header, mock_decode, mock_jwks_cls):
|
|
158
|
+
token_str = 'valid.token.string'
|
|
159
|
+
# Mock Header
|
|
160
|
+
mock_header.return_value = {'kid': 'key1', 'typ': 'JWT', 'alg': 'ES256'}
|
|
161
|
+
|
|
162
|
+
# Mock Signing Key
|
|
163
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
164
|
+
mock_signing_key = mock.Mock()
|
|
165
|
+
mock_signing_key.key = _PUBLIC_KEY
|
|
166
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value = mock_signing_key
|
|
167
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
168
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
169
|
+
|
|
170
|
+
mock_decode.return_value = _MOCK_PAYLOAD
|
|
171
|
+
|
|
172
|
+
# Execute
|
|
173
|
+
token = fpnv.verify_token(token_str)
|
|
174
|
+
|
|
175
|
+
# Verify
|
|
176
|
+
assert isinstance(token, fpnv.PhoneNumberVerificationToken)
|
|
177
|
+
assert token.phone_number == _PHONE_NUMBER
|
|
178
|
+
|
|
179
|
+
mock_header.assert_called_with(token_str)
|
|
180
|
+
mock_jwks_instance.get_signing_key_from_jwt.assert_called_with(token_str)
|
|
181
|
+
mock_decode.assert_called_with(
|
|
182
|
+
token_str,
|
|
183
|
+
_PUBLIC_KEY,
|
|
184
|
+
algorithms=['ES256'],
|
|
185
|
+
audience=_ISSUER,
|
|
186
|
+
issuer=_ISSUER
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
@mock.patch('jwt.get_unverified_header')
|
|
190
|
+
def test_verify_token_no_name(self, mock_header):
|
|
191
|
+
app = firebase_admin.get_app()
|
|
192
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
193
|
+
with pytest.raises(ValueError, match="must be a non-empty string"):
|
|
194
|
+
fpnv.verify_token('', app=app)
|
|
195
|
+
|
|
196
|
+
@mock.patch('jwt.get_unverified_header')
|
|
197
|
+
def test_verify_token_no_kid(self, mock_header):
|
|
198
|
+
app = firebase_admin.get_app()
|
|
199
|
+
mock_header.return_value = {'typ': 'JWT', 'alg': 'ES256'} # Missing kid
|
|
200
|
+
with pytest.raises(fpnv.InvalidTokenError, match="Token has no 'kid' claim."):
|
|
201
|
+
fpnv.verify_token('token', app=app)
|
|
202
|
+
|
|
203
|
+
@mock.patch('jwt.get_unverified_header')
|
|
204
|
+
def test_verify_token_wrong_alg(self, mock_header):
|
|
205
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'RS256'} # Wrong alg
|
|
206
|
+
with pytest.raises(fpnv.InvalidTokenError, match="incorrect alg"):
|
|
207
|
+
fpnv.verify_token('token')
|
|
208
|
+
|
|
209
|
+
@mock.patch('jwt.get_unverified_header')
|
|
210
|
+
def test_verify_token_wrong_typ(self, mock_header):
|
|
211
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'WRONG', 'alg': 'ES256'} # wrong typ
|
|
212
|
+
with pytest.raises(fpnv.InvalidTokenError, match="incorrect type header"):
|
|
213
|
+
fpnv.verify_token('token')
|
|
214
|
+
|
|
215
|
+
def test_verify_token_jwk_error(self):
|
|
216
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
217
|
+
jwks_client = service._verifier._jwks_client
|
|
218
|
+
|
|
219
|
+
# Mock the method on the existing instance
|
|
220
|
+
with mock.patch.object(jwks_client, 'get_signing_key_from_jwt') as mock_method:
|
|
221
|
+
mock_method.side_effect = jwt.PyJWKClientError("Key not found")
|
|
222
|
+
|
|
223
|
+
# Mock header is still needed if _get_signing_key calls it before the client
|
|
224
|
+
with mock.patch('jwt.get_unverified_header') as mock_header:
|
|
225
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
226
|
+
|
|
227
|
+
with pytest.raises(
|
|
228
|
+
fpnv.InvalidTokenError,
|
|
229
|
+
match="Verifying phone number verification token failed"
|
|
230
|
+
):
|
|
231
|
+
fpnv.verify_token('token')
|
|
232
|
+
|
|
233
|
+
@mock.patch('jwt.PyJWKClient')
|
|
234
|
+
@mock.patch('jwt.decode')
|
|
235
|
+
@mock.patch('jwt.get_unverified_header')
|
|
236
|
+
def test_verify_token_expired(self, mock_header, mock_decode, mock_jwks_cls):
|
|
237
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
238
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
239
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY
|
|
240
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
241
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
242
|
+
|
|
243
|
+
# Simulate ExpiredSignatureError
|
|
244
|
+
mock_decode.side_effect = jwt.ExpiredSignatureError("Expired")
|
|
245
|
+
|
|
246
|
+
with pytest.raises(fpnv.ExpiredTokenError, match="token has expired"):
|
|
247
|
+
fpnv.verify_token('token')
|
|
248
|
+
|
|
249
|
+
@mock.patch('jwt.PyJWKClient')
|
|
250
|
+
@mock.patch('jwt.decode')
|
|
251
|
+
@mock.patch('jwt.get_unverified_header')
|
|
252
|
+
def test_verify_token_invalid_signature(self, mock_header, mock_decode, mock_jwks_cls):
|
|
253
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
254
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
255
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY
|
|
256
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
257
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
258
|
+
|
|
259
|
+
# Simulate InvalidSignatureError
|
|
260
|
+
mock_decode.side_effect = jwt.InvalidSignatureError("Wrong Signature")
|
|
261
|
+
|
|
262
|
+
with pytest.raises(fpnv.InvalidTokenError, match="invalid signature"):
|
|
263
|
+
fpnv.verify_token('token')
|
|
264
|
+
|
|
265
|
+
@mock.patch('jwt.PyJWKClient')
|
|
266
|
+
@mock.patch('jwt.decode')
|
|
267
|
+
@mock.patch('jwt.get_unverified_header')
|
|
268
|
+
def test_verify_token_invalid_audience(self, mock_header, mock_decode, mock_jwks_cls):
|
|
269
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
270
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
271
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY
|
|
272
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
273
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
274
|
+
|
|
275
|
+
# Simulate InvalidAudienceError
|
|
276
|
+
mock_decode.side_effect = jwt.InvalidAudienceError("Wrong Aud")
|
|
277
|
+
|
|
278
|
+
with pytest.raises(fpnv.InvalidTokenError, match="incorrect \"aud\""):
|
|
279
|
+
fpnv.verify_token('token')
|
|
280
|
+
|
|
281
|
+
@mock.patch('jwt.PyJWKClient')
|
|
282
|
+
@mock.patch('jwt.decode')
|
|
283
|
+
@mock.patch('jwt.get_unverified_header')
|
|
284
|
+
def test_verify_token_invalid_issuer(self, mock_header, mock_decode, mock_jwks_cls):
|
|
285
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
286
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
287
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY
|
|
288
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
289
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
290
|
+
|
|
291
|
+
# Simulate InvalidIssuerError
|
|
292
|
+
mock_decode.side_effect = jwt.InvalidIssuerError("Wrong Iss")
|
|
293
|
+
|
|
294
|
+
with pytest.raises(fpnv.InvalidTokenError, match="incorrect \"iss\""):
|
|
295
|
+
fpnv.verify_token('token')
|
|
296
|
+
|
|
297
|
+
@mock.patch('jwt.PyJWKClient')
|
|
298
|
+
@mock.patch('jwt.decode')
|
|
299
|
+
@mock.patch('jwt.get_unverified_header')
|
|
300
|
+
def test_verify_token_invalid_token(self, mock_header, mock_decode, mock_jwks_cls):
|
|
301
|
+
mock_header.return_value = {'kid': 'k', 'typ': 'JWT', 'alg': 'ES256'}
|
|
302
|
+
mock_jwks_instance = mock_jwks_cls.return_value
|
|
303
|
+
mock_jwks_instance.get_signing_key_from_jwt.return_value.key = _PUBLIC_KEY
|
|
304
|
+
service = fpnv._get_fpnv_service(firebase_admin.get_app())
|
|
305
|
+
service._verifier._jwks_client = mock_jwks_instance
|
|
306
|
+
|
|
307
|
+
# Simulate InvalidTokenError
|
|
308
|
+
mock_decode.side_effect = jwt.InvalidTokenError("Decoding FPNV token failed")
|
|
309
|
+
|
|
310
|
+
with pytest.raises(fpnv.InvalidTokenError, match="Decoding FPNV token failed"):
|
|
311
|
+
fpnv.verify_token('token')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|