Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 33 additions & 8 deletions firebase_admin/app_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,38 +15,47 @@
"""Firebase App Check module."""

from typing import Any, Dict
import requests
import jwt
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError, DecodeError
from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError
from firebase_admin import _utils
from firebase_admin import _http_client, _utils

_APP_CHECK_ATTRIBUTE = '_app_check'

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

def verify_token(token: str, app=None) -> Dict[str, Any]:
"""Verifies a Firebase App Check token.
def verify_token(token: str, app=None, consume: bool = False) -> Dict[str, Any]:
"""Verifies a Firebase App Check token, optionally consuming limited-use tokens.

Args:
token: A token from App Check.
app: An App instance (optional).
consume: Set to ``True`` only if the token is a limited-use (one-time) token
that should be consumed upon verification (optional, defaults to ``False``).

Returns:
Dict[str, Any]: The token's decoded claims.
Dict[str, Any]: The token's decoded claims. If ``consume`` is ``True``, the dictionary
also includes an ``already_consumed`` boolean key indicating whether the token was
previously consumed.

Raises:
ValueError: If the app's ``project_id`` is invalid or unspecified,
or if the token's headers or payload are invalid.
or if the token's headers or payload are invalid.
FirebaseError: If an error occurs while communicating with the App Check service.
PyJWKClientError: If PyJWKClient fails to fetch a valid signing key.
"""
return _get_app_check_service(app).verify_token(token)
return _get_app_check_service(app).verify_token(token, consume=consume)

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

_APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
_JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
_VERIFY_URL_FORMAT = (
'https://firebaseappcheck.googleapis.com/v1/projects/{project_id}:verifyAppCheckToken'
)
_project_id = None
_scoped_project_id = None
_jwks_client = None
Expand All @@ -68,10 +77,13 @@ def __init__(self, app):
# Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours).
self._jwks_client = PyJWKClient(
self._JWKS_URL, lifespan=21600, headers=self._APP_CHECK_HEADERS)
timeout = app.options.get('httpTimeout', _http_client.DEFAULT_TIMEOUT_SECONDS)
self._http_client = _http_client.JsonHttpClient(
credential=app.credential.get_credential(), timeout=timeout)


def verify_token(self, token: str) -> Dict[str, Any]:
"""Verifies a Firebase App Check token."""
def verify_token(self, token: str, consume: bool = False) -> Dict[str, Any]:
"""Verifies a Firebase App Check token, optionally consuming limited-use tokens."""
_Validators.check_string("app check token", token)

# Obtain the Firebase App Check Public Keys
Expand All @@ -87,6 +99,19 @@ def verify_token(self, token: str) -> Dict[str, Any]:
) from exception

verified_claims['app_id'] = verified_claims.get('sub')

if consume:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's validate consume is a bool here.

url = self._VERIFY_URL_FORMAT.format(project_id=self._project_id)
try:
body = self._http_client.body('post', url, json={'app_check_token': token})
except requests.exceptions.RequestException as error:
raise _utils.handle_platform_error_from_requests(error)

already_consumed = False
if isinstance(body, dict):
already_consumed = body.get('alreadyConsumed', False)
verified_claims['already_consumed'] = bool(already_consumed)

return verified_claims

def _has_valid_token_headers(self, headers: Any) -> None:
Expand Down
62 changes: 61 additions & 1 deletion tests/test_app_check.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
"""Test cases for the firebase_admin.app_check module."""
import base64
import pytest
import requests

from jwt import PyJWK, InvalidAudienceError, InvalidIssuerError
from jwt import ExpiredSignatureError, InvalidSignatureError
import firebase_admin
from firebase_admin import app_check
from firebase_admin import app_check, exceptions
from tests import testutils

NON_STRING_ARGS = [[], tuple(), {}, True, False, 1, 0]
Expand Down Expand Up @@ -273,3 +274,62 @@ def test_verify_token_with_incorrect_issuer_raises_error(self, mocker):

expected = 'Token does not contain the correct "iss" (issuer).'
assert str(excinfo.value) == expected

def test_verify_token_with_consume_true_not_consumed(self, mocker):
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))
app = firebase_admin.get_app()
app_check_service = app_check._get_app_check_service(app)
mock_body = mocker.patch.object(
app_check_service._http_client, "body", return_value={"alreadyConsumed": False}
)

payload = app_check.verify_token("encoded", app=app, consume=True)
expected = JWT_PAYLOAD_SAMPLE.copy()
expected["app_id"] = APP_ID
expected["already_consumed"] = False
assert payload == expected

expected_url = (
f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
)
mock_body.assert_called_once_with(
"post", expected_url, json={"app_check_token": "encoded"}
)

def test_verify_token_with_consume_true_already_consumed(self, mocker):
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))
app = firebase_admin.get_app()
app_check_service = app_check._get_app_check_service(app)
mock_body = mocker.patch.object(
app_check_service._http_client, "body", return_value={"alreadyConsumed": True}
)

payload = app_check.verify_token("encoded", app=app, consume=True)
expected = JWT_PAYLOAD_SAMPLE.copy()
expected["app_id"] = APP_ID
expected["already_consumed"] = True
assert payload == expected

expected_url = (
f"https://firebaseappcheck.googleapis.com/v1/projects/{PROJECT_ID}:verifyAppCheckToken"
)
mock_body.assert_called_once_with(
"post", expected_url, json={"app_check_token": "encoded"}
)

def test_verify_token_with_consume_true_backend_error(self, mocker):
mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE)
mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value=PyJWK(signing_key))
mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers"))
app = firebase_admin.get_app()
app_check_service = app_check._get_app_check_service(app)

req_exc = requests.exceptions.RequestException("Backend error")
mocker.patch.object(app_check_service._http_client, "body", side_effect=req_exc)

with pytest.raises(exceptions.FirebaseError):
app_check.verify_token("encoded", app=app, consume=True)
Loading