Skip to content

Commit 2ef3414

Browse files
authored
feat(appcheck): Verify one-time tokens for replay protection (#976)
* add one time token verification support * add unit tests * fix lint error * change to use _utils.handle_platform_error_from_requests * change the function description * change docstring to state limited-use tokens * add validation for consume variable * add check_boolean to _Validators and add unit tests to check non-boolean consume * raise `exceptions.UnknownError` if body is not a dict * move the replay verification to a separate helper method and add unit tests * add test_verify_token_with_consume_false_makes_no_http_call unit test * replace the broad exceptions.FirebaseError assertion with more specific error types * Refactor token verification tests to use mock_jwt fixture * Document ValueError when consume is not a boolean in verify_token docstring
1 parent a7aafa2 commit 2ef3414

2 files changed

Lines changed: 189 additions & 14 deletions

File tree

‎firebase_admin/app_check.py‎

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,38 +15,47 @@
1515
"""Firebase App Check module."""
1616

1717
from typing import Any, Dict
18+
import requests
1819
import jwt
1920
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError, DecodeError
2021
from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError
21-
from firebase_admin import _utils
22+
from firebase_admin import _http_client, _utils, exceptions
2223

2324
_APP_CHECK_ATTRIBUTE = '_app_check'
2425

2526
def _get_app_check_service(app) -> Any:
2627
return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService)
2728

28-
def verify_token(token: str, app=None) -> Dict[str, Any]:
29-
"""Verifies a Firebase App Check token.
29+
def verify_token(token: str, app=None, consume: bool = False) -> Dict[str, Any]:
30+
"""Verifies a Firebase App Check token, optionally consuming limited-use tokens.
3031
3132
Args:
3233
token: A token from App Check.
3334
app: An App instance (optional).
35+
consume: Set to ``True`` only if the token is a limited-use (one-time) token
36+
that should be consumed upon verification (optional, defaults to ``False``).
3437
3538
Returns:
36-
Dict[str, Any]: The token's decoded claims.
39+
Dict[str, Any]: The token's decoded claims. If ``consume`` is ``True``, the dictionary
40+
also includes an ``already_consumed`` boolean key indicating whether the token was
41+
previously consumed.
3742
3843
Raises:
39-
ValueError: If the app's ``project_id`` is invalid or unspecified,
40-
or if the token's headers or payload are invalid.
44+
ValueError: If ``consume`` is not a boolean, or if the app's ``project_id``
45+
is invalid or unspecified, or if the token's headers or payload are invalid.
46+
FirebaseError: If an error occurs while communicating with the App Check service.
4147
PyJWKClientError: If PyJWKClient fails to fetch a valid signing key.
4248
"""
43-
return _get_app_check_service(app).verify_token(token)
49+
return _get_app_check_service(app).verify_token(token, consume=consume)
4450

4551
class _AppCheckService:
4652
"""Service class that implements Firebase App Check functionality."""
4753

4854
_APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
4955
_JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
56+
_VERIFY_URL_FORMAT = (
57+
'https://firebaseappcheck.googleapis.com/v1/projects/{project_id}:verifyAppCheckToken'
58+
)
5059
_project_id = None
5160
_scoped_project_id = None
5261
_jwks_client = None
@@ -68,11 +77,15 @@ def __init__(self, app):
6877
# Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours).
6978
self._jwks_client = PyJWKClient(
7079
self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS)
80+
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
81+
self._http_client = _http_client.JsonHttpClient(
82+
credential=app.credential.get_credential(), timeout=timeout)
7183

7284

73-
def verify_token(self, token: str) -> Dict[str, Any]:
74-
"""Verifies a Firebase App Check token."""
85+
def verify_token(self, token: str, consume: bool = False) -> Dict[str, Any]:
86+
"""Verifies a Firebase App Check token, optionally consuming limited-use tokens."""
7587
_Validators.check_string("app check token", token)
88+
_Validators.check_boolean("consume", consume)
7689

7790
# Obtain the Firebase App Check Public Keys
7891
# Note: It is not recommended to hard code these keys as they rotate,
@@ -87,8 +100,32 @@ def verify_token(self, token: str) -> Dict[str, Any]:
87100
) from exception
88101

89102
verified_claims['app_id'] = verified_claims.get('sub')
103+
104+
if consume:
105+
verified_claims['already_consumed'] = self._verify_replay_protection(token)
106+
90107
return verified_claims
91108

109+
def _verify_replay_protection(self, token: str) -> bool:
110+
"""Verifies replay protection with the backend and returns the alreadyConsumed status."""
111+
url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)
112+
try:
113+
body = self._http_client.body('post', url, json={'app_check_token': token})
114+
except requests.exceptions.RequestException as error:
115+
raise _utils.handle_platform_error_from_requests(error)
116+
except ValueError as error:
117+
raise exceptions.UnknownError(
118+
f'Unexpected response from App Check service: {error}'
119+
) from error
120+
121+
if not isinstance(body, dict):
122+
raise exceptions.UnknownError(
123+
'Unexpected response from App Check service. '
124+
f'Expected a JSON object, but got {type(body).__name__}.'
125+
)
126+
127+
return bool(body.get('alreadyConsumed', False))
128+
92129
def _has_valid_token_headers(self, headers: Any) -> None:
93130
"""Checks whether the token has valid headers for App Check."""
94131
# Ensure the token's header has type JWT
@@ -159,3 +196,10 @@ def check_string(cls, label: str, value: Any):
159196
raise ValueError(f'{label} "{value}" must be a non-empty string.')
160197
if not isinstance(value, str):
161198
raise ValueError(f'{label} "{value}" must be a string.')
199+
200+
@classmethod
201+
def check_boolean(cls, label: str, value: Any):
202+
"""Checks if the given value is a boolean."""
203+
if not isinstance(value, bool):
204+
raise ValueError(f'{label} must be a boolean.')
205+
return value

‎tests/test_app_check.py‎

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@
1515
"""Test cases for the firebase_admin.app_check module."""
1616
import base64
1717
import pytest
18+
import requests
1819

1920
from jwt import PyJWK, InvalidAudienceError, InvalidIssuerError
2021
from jwt import ExpiredSignatureError, InvalidSignatureError
2122
import firebase_admin
22-
from firebase_admin import app_check
23+
from firebase_admin import app_check, exceptions
2324
from tests import testutils
2425

2526
NON_STRING_ARGS = [[], tuple(), {}, True, False, 1, 0]
@@ -58,6 +59,15 @@ def setup_class(cls):
5859
def teardown_class(cls):
5960
testutils.cleanup_apps()
6061

62+
63+
@pytest.fixture
64+
def mock_jwt(mocker):
65+
"""Mocks JWT decoding and JWKS key retrieval for a valid App Check token."""
66+
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
67+
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
68+
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))
69+
70+
6171
class TestVerifyToken(TestBatch):
6272

6373
def test_no_project_id(self):
@@ -74,6 +84,12 @@ def test_verify_token_with_non_string_raises_error(self, token):
7484
expected = f'app check token "{token}" must be a string.'
7585
assert str(excinfo.value) == expected
7686

87+
@pytest.mark.parametrize('consume', [[], tuple(), {}, 1, 0, 'true', 'false', None])
88+
def test_verify_token_with_non_boolean_consume_raises_error(self, consume):
89+
with pytest.raises(ValueError) as excinfo:
90+
app_check.verify_token('app_check_token', consume=consume)
91+
assert str(excinfo.value) == 'consume must be a boolean.'
92+
7793
def test_has_valid_token_headers(self):
7894
app = firebase_admin.get_app()
7995
app_check_service = app_check._get_app_check_service(app)
@@ -221,10 +237,8 @@ def test_decode_and_verify_with_non_string_sub_raises_error(self, mocker):
221237
f'"{sub_number}" must be a string.')
222238
assert str(excinfo.value) == expected
223239

224-
def test_verify_token(self, mocker):
225-
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
226-
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
227-
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))
240+
@pytest.mark.usefixtures("mock_jwt")
241+
def test_verify_token(self):
228242
app = firebase_admin.get_app()
229243

230244
payload = app_check.verify_token("encoded", app)
@@ -273,3 +287,120 @@ def test_verify_token_with_incorrect_issuer_raises_error(self, mocker):
273287

274288
expected = 'Token does not contain the correct "iss" (issuer).'
275289
assert str(excinfo.value) == expected
290+
291+
@pytest.mark.usefixtures("mock_jwt")
292+
@pytest.mark.parametrize('consume_kwargs', [{}, {'consume': False}])
293+
def test_verify_token_with_consume_false_makes_no_http_call(
294+
self, mocker, consume_kwargs
295+
):
296+
app = firebase_admin.get_app()
297+
app_check_service = app_check._get_app_check_service(app)
298+
mock_body = mocker.patch.object(app_check_service._http_client, "body")
299+
300+
payload = app_check.verify_token("encoded", app=app, **consume_kwargs)
301+
expected = JWT_PAYLOAD_SAMPLE.copy()
302+
expected["app_id"] = APP_ID
303+
assert payload == expected
304+
assert 'already_consumed' not in payload
305+
mock_body.assert_not_called()
306+
307+
@pytest.mark.usefixtures("mock_jwt")
308+
def test_verify_token_with_consume_true_not_consumed(self, mocker):
309+
app = firebase_admin.get_app()
310+
app_check_service = app_check._get_app_check_service(app)
311+
mock_body = mocker.patch.object(
312+
app_check_service._http_client, "body", return_value={"alreadyConsumed": False}
313+
)
314+
315+
payload = app_check.verify_token("encoded", app=app, consume=True)
316+
expected = JWT_PAYLOAD_SAMPLE.copy()
317+
expected["app_id"] = APP_ID
318+
expected["already_consumed"] = False
319+
assert payload == expected
320+
321+
expected_url = (
322+
f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
323+
)
324+
mock_body.assert_called_once_with(
325+
"post", expected_url, json={"app_check_token": "encoded"}
326+
)
327+
328+
@pytest.mark.usefixtures("mock_jwt")
329+
def test_verify_token_with_consume_true_already_consumed(self, mocker):
330+
app = firebase_admin.get_app()
331+
app_check_service = app_check._get_app_check_service(app)
332+
mock_body = mocker.patch.object(
333+
app_check_service._http_client, "body", return_value={"alreadyConsumed": True}
334+
)
335+
336+
payload = app_check.verify_token("encoded", app=app, consume=True)
337+
expected = JWT_PAYLOAD_SAMPLE.copy()
338+
expected["app_id"] = APP_ID
339+
expected["already_consumed"] = True
340+
assert payload == expected
341+
342+
expected_url = (
343+
f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
344+
)
345+
mock_body.assert_called_once_with(
346+
"post", expected_url, json={"app_check_token": "encoded"}
347+
)
348+
349+
@pytest.mark.usefixtures("mock_jwt")
350+
def test_verify_token_with_consume_true_backend_error(self, mocker):
351+
app = firebase_admin.get_app()
352+
app_check_service = app_check._get_app_check_service(app)
353+
354+
req_exc = requests.exceptions.RequestException("Backend error")
355+
mocker.patch.object(app_check_service._http_client, "body", side_effect=req_exc)
356+
357+
with pytest.raises(exceptions.UnknownError) as excinfo:
358+
app_check.verify_token("encoded", app=app, consume=True)
359+
assert "Unknown error while making a remote service call" in str(excinfo.value)
360+
361+
@pytest.mark.usefixtures("mock_jwt")
362+
def test_verify_token_with_consume_true_http_error(self, mocker):
363+
app = firebase_admin.get_app()
364+
app_check_service = app_check._get_app_check_service(app)
365+
366+
response = requests.Response()
367+
response.status_code = 403
368+
response._content = (
369+
b'{"error": {"status": "PERMISSION_DENIED", "message": "Permission denied."}}'
370+
)
371+
http_exc = requests.exceptions.HTTPError(response=response)
372+
mocker.patch.object(app_check_service._http_client, "body", side_effect=http_exc)
373+
374+
with pytest.raises(exceptions.PermissionDeniedError) as excinfo:
375+
app_check.verify_token("encoded", app=app, consume=True)
376+
assert "Permission denied." in str(excinfo.value)
377+
378+
@pytest.mark.usefixtures("mock_jwt")
379+
@pytest.mark.parametrize('malformed_body', ['string_response', [1, 2], 123, None])
380+
def test_verify_token_with_consume_true_malformed_response_raises_error(
381+
self, mocker, malformed_body
382+
):
383+
app = firebase_admin.get_app()
384+
app_check_service = app_check._get_app_check_service(app)
385+
mocker.patch.object(app_check_service._http_client, "body", return_value=malformed_body)
386+
387+
with pytest.raises(exceptions.UnknownError) as excinfo:
388+
app_check.verify_token("encoded", app=app, consume=True)
389+
assert 'Unexpected response from App Check service' in str(excinfo.value)
390+
391+
@pytest.mark.usefixtures("mock_jwt")
392+
def test_verify_token_with_consume_true_json_decode_error(self, mocker):
393+
app = firebase_admin.get_app()
394+
app_check_service = app_check._get_app_check_service(app)
395+
mocker.patch.object(
396+
app_check_service._http_client,
397+
"body",
398+
side_effect=ValueError("Expecting value: line 1 column 1 (char 0)"),
399+
)
400+
401+
with pytest.raises(exceptions.UnknownError) as excinfo:
402+
app_check.verify_token("encoded", app=app, consume=True)
403+
expected_msg = (
404+
"Unexpected response from App Check service: Expecting value: line 1 column 1 (char 0)"
405+
)
406+
assert expected_msg in str(excinfo.value)

0 commit comments

Comments
 (0)