From 25d38426736666661c193a5c2e95767280823160 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Tue, 6 Sep 2022 18:22:45 -0400 Subject: [PATCH 01/28] Sketch out initial private methods and service --- .gitignore | 1 + firebase_admin/app_check.py | 151 ++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 firebase_admin/app_check.py diff --git a/.gitignore b/.gitignore index 79d2d5ff3..e5c1902d5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ apikey.txt htmlcov/ .pytest_cache/ .vscode/ +.venv/ diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py new file mode 100644 index 000000000..e13895f34 --- /dev/null +++ b/firebase_admin/app_check.py @@ -0,0 +1,151 @@ +# Copyright 2022 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. + +# See as an example from firebase_admin/messaging.py +# def _get_messaging_service(app): +# return _utils.get_app_service(app, _MESSAGING_ATTRIBUTE, _MessagingService) + +# Our goal in general is to take the design doc implentation and match it to the +# existing code in the SDK with tests +# Timeline is to be done by week of the 19th + +"""Firebase App Check module.""" + +# ASK(lahiru) Do I need to add these imports to the requirements file? +import jwt +from jwt import PyJWKClient +from typing import Any, Dict, List +from firebase_admin import _utils + +_APP_CHECK_ATTRIBUTE = '_app_check' +_APP_CHECK_API_URL = "https://firebaseappcheck.googleapis.com/" + +def _get_app_check_service(app) -> Any: + return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService) + +# we'll need a public method like def send(message, dry_run=False, app=None): +# that lives outside of the class. This method will refer to the class instance +# when calling + +# outside of the class we will need all the public methods that +# in this case just `verify_token` +# + +# should i accept an app or just always make it none +def verify_token(token: str, app=None) -> Dict[str, Any]: + return _get_app_check_service(app).verify_token(token) + +# this can be deleted, it was only for the example +def run_checks(): + app_id = verify_app_check(request.headers.get('X-Firebase-AppCheck')) + if app_id is None: + abort(401) + app.config['APP_ID'] = app_id + +class _AppCheckService: + """Service class that implements Firebase App Check functionality.""" + # Then we insert the code sample that uses flask but we do not need + # the actual flask stuff + # import all of verify_app_check from https://github.com/lahirumaramba/codecloud/blob/2f9ea1e206c740ed4c01c3277ee6745a39f8ee21/app-check-verify/python/server.py + def __init__(self, app): + # the verification method should go in the service + project_id = app.project_id + if not project_id: + raise ValueError( + 'Project ID is required to access App Check service. Either set the ' + 'projectId option, or use service account credentials. Alternatively, set the ' + 'GOOGLE_CLOUD_PROJECT environment variable.') + + @classmethod + def verify_token(self, token: str) -> Dict[str, Any]: + if token is None: + return None + + # Obtain the Firebase App Check Public Keys + # Note: It is not recommended to hard code these keys as they rotate, + # but you should cache them for up to 6 hours. + url = "https://firebaseappcheck.googleapis.com/v1beta/jwks" + + jwks_client = PyJWKClient(url) + signing_key = jwks_client.get_signing_key_from_jwt(token) + + header = jwt.get_unverified_header(token) + if not self._has_valid_verify_token_headers(header): + return None + + + # I don't see any method or property to just get key from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py + payload = self._decode_and_verify(token, signing_key.key, "project_number") + + # The token's subject will be the app ID, you may optionally filter against + # an allow list + return payload.get('sub') + + def _has_valid_verify_token_headers(header: Any) -> bool: + # Ensure the token's header uses the algorithm RS256 + if header.get('alg') != 'RS256': + return False + # Ensure the token's header has type JWT + if header.get('typ') != 'JWT': + return False + return True + + def _decode_token(token: str, signing_key: str, algorithms:List[str]=["RS256"]) -> Dict[str, Any]: + payload = {} + try: + # Verify the signature on the App Check token + # Ensure the token is not expired + payload = jwt.decode( + token, + signing_key, + algorithms + ) + except: + print(f'Unable to decode the token') + return payload + + # move inside service class + def _decode_and_verify(self, token: str, signing_key: str, project_number: str): + payload = {} + try: + # Verify the signature on the App Check token + # Ensure the token is not expired + payload = self._decode_token( + token, + signing_key, + algorithms=["RS256"] + ) + except: + print(f'Unable to verify the token') + + # TODO(jackdwyer) remove the aud, issuer, and project num + # instead after this call, we manually verify the audience (check if it is an array) + # one of the values must be project id, the issuer we just check that it starts with the url + # Ensure the token's audience matches your project + audience="projects/" + app.config["PROJECT_NUMBER"], # change this to project id + # Ensure the token is issued by App Check + issuer="https://firebaseappcheck.googleapis.com/" + \ + app.config["PROJECT_NUMBER"], #should it use project number or project id + + if len(payload.aud) <= 1: + raise ValueError('Project ID and Project Number are required to access App Check.') + if _APP_CHECK_API_URL not in payload.issuer: + raise ValueError('Token does not contain the correct Issuer.') + + # within the aud of the payload, there will be an array of project id & number + return payload + +# we need to make some code around fetching the project id + +# Instead of returning none, raise value errors exceptions see messaging \ No newline at end of file From cf60bb92dcb625d0c354e8ac04cbf24b2f1aa84b Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 7 Sep 2022 15:25:28 -0400 Subject: [PATCH 02/28] Remove unnecessary notes --- firebase_admin/app_check.py | 76 ++++++++++++------------------------- 1 file changed, 24 insertions(+), 52 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index e13895f34..545abde81 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -29,35 +29,21 @@ from firebase_admin import _utils _APP_CHECK_ATTRIBUTE = '_app_check' -_APP_CHECK_API_URL = "https://firebaseappcheck.googleapis.com/" def _get_app_check_service(app) -> Any: return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService) -# we'll need a public method like def send(message, dry_run=False, app=None): -# that lives outside of the class. This method will refer to the class instance -# when calling - -# outside of the class we will need all the public methods that -# in this case just `verify_token` -# - -# should i accept an app or just always make it none +# should i accept an app (design doc doesn't have one) or just always make it none def verify_token(token: str, app=None) -> Dict[str, Any]: return _get_app_check_service(app).verify_token(token) -# this can be deleted, it was only for the example -def run_checks(): - app_id = verify_app_check(request.headers.get('X-Firebase-AppCheck')) - if app_id is None: - abort(401) - app.config['APP_ID'] = app_id - class _AppCheckService: """Service class that implements Firebase App Check functionality.""" - # Then we insert the code sample that uses flask but we do not need - # the actual flask stuff - # import all of verify_app_check from https://github.com/lahirumaramba/codecloud/blob/2f9ea1e206c740ed4c01c3277ee6745a39f8ee21/app-check-verify/python/server.py + + _APP_CHECK_GCP_API_URL = "https://firebaseappcheck.googleapis.com" + _APP_CHECK_BETA_JWKS_RESOURCE = "/v1beta/jwks" + + def __init__(self, app): # the verification method should go in the service project_id = app.project_id @@ -66,6 +52,7 @@ def __init__(self, app): 'Project ID is required to access App Check service. Either set the ' 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') + # Unsure what I should include in this constructor, or even if I should include one @classmethod def verify_token(self, token: str) -> Dict[str, Any]: @@ -75,14 +62,13 @@ def verify_token(self, token: str) -> Dict[str, Any]: # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. - url = "https://firebaseappcheck.googleapis.com/v1beta/jwks" + url = f'{self._APP_CHECK_GCP_API_URL}{self._APP_CHECK_BETA_JWKS_RESOURCE}' jwks_client = PyJWKClient(url) signing_key = jwks_client.get_signing_key_from_jwt(token) header = jwt.get_unverified_header(token) - if not self._has_valid_verify_token_headers(header): - return None + self._has_valid_token_headers(header) # I don't see any method or property to just get key from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py @@ -92,14 +78,13 @@ def verify_token(self, token: str) -> Dict[str, Any]: # an allow list return payload.get('sub') - def _has_valid_verify_token_headers(header: Any) -> bool: - # Ensure the token's header uses the algorithm RS256 - if header.get('alg') != 'RS256': - return False + def _has_valid_token_headers(header: Any) -> None: # Ensure the token's header has type JWT if header.get('typ') != 'JWT': - return False - return True + raise ValueError("The token received is not a JWT") + # Ensure the token's header uses the algorithm RS256 + if header.get('alg') != 'RS256': + raise ValueError("JWT's algorithm does not have valid token headers") def _decode_token(token: str, signing_key: str, algorithms:List[str]=["RS256"]) -> Dict[str, Any]: payload = {} @@ -112,35 +97,22 @@ def _decode_token(token: str, signing_key: str, algorithms:List[str]=["RS256"]) algorithms ) except: - print(f'Unable to decode the token') + ValueError('Unable to decode the token') return payload # move inside service class def _decode_and_verify(self, token: str, signing_key: str, project_number: str): - payload = {} - try: - # Verify the signature on the App Check token - # Ensure the token is not expired - payload = self._decode_token( - token, - signing_key, - algorithms=["RS256"] - ) - except: - print(f'Unable to verify the token') - - # TODO(jackdwyer) remove the aud, issuer, and project num - # instead after this call, we manually verify the audience (check if it is an array) - # one of the values must be project id, the issuer we just check that it starts with the url - # Ensure the token's audience matches your project - audience="projects/" + app.config["PROJECT_NUMBER"], # change this to project id - # Ensure the token is issued by App Check - issuer="https://firebaseappcheck.googleapis.com/" + \ - app.config["PROJECT_NUMBER"], #should it use project number or project id - + payload = {} + # Verify the signature on the App Check token + # Ensure the token is not expired + payload = self._decode_token( + token, + signing_key, + algorithms=["RS256"] + ) if len(payload.aud) <= 1: raise ValueError('Project ID and Project Number are required to access App Check.') - if _APP_CHECK_API_URL not in payload.issuer: + if self._APP_CHECK_GCP_API_URL not in payload.issuer: raise ValueError('Token does not contain the correct Issuer.') # within the aud of the payload, there will be an array of project id & number From 3c4e191f3599af3630a2caa95f531363ec0d12c4 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 7 Sep 2022 15:52:35 -0400 Subject: [PATCH 03/28] Fix some lint issues --- firebase_admin/app_check.py | 41 +++++++++++++------------------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 545abde81..9ebd02198 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -16,16 +16,12 @@ # def _get_messaging_service(app): # return _utils.get_app_service(app, _MESSAGING_ATTRIBUTE, _MessagingService) -# Our goal in general is to take the design doc implentation and match it to the -# existing code in the SDK with tests -# Timeline is to be done by week of the 19th - """Firebase App Check module.""" # ASK(lahiru) Do I need to add these imports to the requirements file? +from typing import Any, Dict, List import jwt from jwt import PyJWKClient -from typing import Any, Dict, List from firebase_admin import _utils _APP_CHECK_ATTRIBUTE = '_app_check' @@ -39,10 +35,9 @@ def verify_token(token: str, app=None) -> Dict[str, Any]: class _AppCheckService: """Service class that implements Firebase App Check functionality.""" - + _APP_CHECK_GCP_API_URL = "https://firebaseappcheck.googleapis.com" - _APP_CHECK_BETA_JWKS_RESOURCE = "/v1beta/jwks" - + _APP_CHECK_BETA_JWKS_RESOURCE = "/v1beta/jwks" def __init__(self, app): # the verification method should go in the service @@ -68,17 +63,17 @@ def verify_token(self, token: str) -> Dict[str, Any]: signing_key = jwks_client.get_signing_key_from_jwt(token) header = jwt.get_unverified_header(token) - self._has_valid_token_headers(header) - + self._has_valid_token_headers(header) - # I don't see any method or property to just get key from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py + # I don't see any method or property to just get key + # from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py payload = self._decode_and_verify(token, signing_key.key, "project_number") # The token's subject will be the app ID, you may optionally filter against # an allow list return payload.get('sub') - - def _has_valid_token_headers(header: Any) -> None: + + def _has_valid_token_headers(self, header: Any) -> None: # Ensure the token's header has type JWT if header.get('typ') != 'JWT': raise ValueError("The token received is not a JWT") @@ -86,11 +81,9 @@ def _has_valid_token_headers(header: Any) -> None: if header.get('alg') != 'RS256': raise ValueError("JWT's algorithm does not have valid token headers") - def _decode_token(token: str, signing_key: str, algorithms:List[str]=["RS256"]) -> Dict[str, Any]: + def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> Dict[str, Any]: payload = {} try: - # Verify the signature on the App Check token - # Ensure the token is not expired payload = jwt.decode( token, signing_key, @@ -99,25 +92,19 @@ def _decode_token(token: str, signing_key: str, algorithms:List[str]=["RS256"]) except: ValueError('Unable to decode the token') return payload - - # move inside service class - def _decode_and_verify(self, token: str, signing_key: str, project_number: str): - payload = {} - # Verify the signature on the App Check token - # Ensure the token is not expired + + def _decode_and_verify(self, token: str, signing_key: str): + payload = {} payload = self._decode_token( token, signing_key, algorithms=["RS256"] ) + + # within the aud property, there will be an array of project id & number if len(payload.aud) <= 1: raise ValueError('Project ID and Project Number are required to access App Check.') if self._APP_CHECK_GCP_API_URL not in payload.issuer: raise ValueError('Token does not contain the correct Issuer.') - # within the aud of the payload, there will be an array of project id & number return payload - -# we need to make some code around fetching the project id - -# Instead of returning none, raise value errors exceptions see messaging \ No newline at end of file From 4e84ce31155c1cf3927c54c1edd2f416eb9d7f79 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 7 Sep 2022 17:31:57 -0400 Subject: [PATCH 04/28] Fix style guide issues --- firebase_admin/app_check.py | 43 ++++++++++++++++++++++--------------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 9ebd02198..54544dad3 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -12,16 +12,12 @@ # See the License for the specific language governing permissions and # limitations under the License. -# See as an example from firebase_admin/messaging.py -# def _get_messaging_service(app): -# return _utils.get_app_service(app, _MESSAGING_ATTRIBUTE, _MessagingService) - """Firebase App Check module.""" # ASK(lahiru) Do I need to add these imports to the requirements file? from typing import Any, Dict, List import jwt -from jwt import PyJWKClient +from jwt import PyJWKClient, DecodeError from firebase_admin import _utils _APP_CHECK_ATTRIBUTE = '_app_check' @@ -31,13 +27,23 @@ def _get_app_check_service(app) -> Any: # should i accept an app (design doc doesn't have one) or just always make it none def verify_token(token: str, app=None) -> Dict[str, Any]: + """Verifies a Firebase App Check token. + + Args: + token: A token from App Check. + app: An App instance (optional). + + Returns: + Dict[str, Any]: A token's decoded claims + if the App Check token is valid; otherwise, a rejected promise.. + """ return _get_app_check_service(app).verify_token(token) class _AppCheckService: """Service class that implements Firebase App Check functionality.""" - - _APP_CHECK_GCP_API_URL = "https://firebaseappcheck.googleapis.com" - _APP_CHECK_BETA_JWKS_RESOURCE = "/v1beta/jwks" + + _APP_CHECK_GCP_API_URL = 'https://firebaseappcheck.googleapis.com' + _APP_CHECK_BETA_JWKS_RESOURCE = '/v1beta/jwks' def __init__(self, app): # the verification method should go in the service @@ -48,32 +54,33 @@ def __init__(self, app): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') # Unsure what I should include in this constructor, or even if I should include one - + @classmethod - def verify_token(self, token: str) -> Dict[str, Any]: + def verify_token(cls, token: str) -> Dict[str, Any]: + """Verifies a Firebase App Check token.""" if token is None: return None # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. - url = f'{self._APP_CHECK_GCP_API_URL}{self._APP_CHECK_BETA_JWKS_RESOURCE}' + url = f'{cls._APP_CHECK_GCP_API_URL}{cls._APP_CHECK_BETA_JWKS_RESOURCE}' jwks_client = PyJWKClient(url) signing_key = jwks_client.get_signing_key_from_jwt(token) - header = jwt.get_unverified_header(token) - self._has_valid_token_headers(header) - + cls._has_valid_token_headers(jwt.get_unverified_header(token)) + # I don't see any method or property to just get key # from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py - payload = self._decode_and_verify(token, signing_key.key, "project_number") + payload = cls._decode_and_verify(token, signing_key.key, "project_number") # The token's subject will be the app ID, you may optionally filter against # an allow list return payload.get('sub') def _has_valid_token_headers(self, header: Any) -> None: + """Checks whether the token has valid headers for App Check.""" # Ensure the token's header has type JWT if header.get('typ') != 'JWT': raise ValueError("The token received is not a JWT") @@ -82,6 +89,7 @@ def _has_valid_token_headers(self, header: Any) -> None: raise ValueError("JWT's algorithm does not have valid token headers") def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> Dict[str, Any]: + """Decodes the JWT received from App Check.""" payload = {} try: payload = jwt.decode( @@ -89,11 +97,12 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> signing_key, algorithms ) - except: + except DecodeError: ValueError('Unable to decode the token') return payload def _decode_and_verify(self, token: str, signing_key: str): + """Decodes and verifies the token from App Check.""" payload = {} payload = self._decode_token( token, @@ -103,7 +112,7 @@ def _decode_and_verify(self, token: str, signing_key: str): # within the aud property, there will be an array of project id & number if len(payload.aud) <= 1: - raise ValueError('Project ID and Project Number are required to access App Check.') + raise ValueError('Project ID and Project Number are required to access App Check.') if self._APP_CHECK_GCP_API_URL not in payload.issuer: raise ValueError('Token does not contain the correct Issuer.') From dc9cbfd077e85440e21e0c90ba5e5f36c73ba888 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Thu, 8 Sep 2022 10:42:35 -0400 Subject: [PATCH 05/28] Update code structure --- firebase_admin/app_check.py | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 54544dad3..f9585f6aa 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -65,14 +65,12 @@ def verify_token(cls, token: str) -> Dict[str, Any]: # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. url = f'{cls._APP_CHECK_GCP_API_URL}{cls._APP_CHECK_BETA_JWKS_RESOURCE}' - jwks_client = PyJWKClient(url) signing_key = jwks_client.get_signing_key_from_jwt(token) + # Getting error "No value for argument 'header' in unbound + # method call (no-value-for-parameter)" cls._has_valid_token_headers(jwt.get_unverified_header(token)) - - # I don't see any method or property to just get key - # from signing_key /*/lib/python3.10/site-packages/jwt/api_jwk.py payload = cls._decode_and_verify(token, signing_key.key, "project_number") # The token's subject will be the app ID, you may optionally filter against @@ -103,7 +101,6 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> def _decode_and_verify(self, token: str, signing_key: str): """Decodes and verifies the token from App Check.""" - payload = {} payload = self._decode_token( token, signing_key, @@ -111,9 +108,9 @@ def _decode_and_verify(self, token: str, signing_key: str): ) # within the aud property, there will be an array of project id & number - if len(payload.aud) <= 1: + if len(payload.get('aud')) <= 1: raise ValueError('Project ID and Project Number are required to access App Check.') - if self._APP_CHECK_GCP_API_URL not in payload.issuer: + if self._APP_CHECK_GCP_API_URL not in payload.get('issuer'): raise ValueError('Token does not contain the correct Issuer.') return payload From eb1725d469d8a424fe6f74a37992a42c13344671 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 9 Sep 2022 15:47:59 -0400 Subject: [PATCH 06/28] Add pyjwt version to requirments & update code based on comments --- firebase_admin/app_check.py | 70 ++++++++++++++++++++++--------------- requirements.txt | 1 + 2 files changed, 43 insertions(+), 28 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index f9585f6aa..40348d66a 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -14,7 +14,6 @@ """Firebase App Check module.""" -# ASK(lahiru) Do I need to add these imports to the requirements file? from typing import Any, Dict, List import jwt from jwt import PyJWKClient, DecodeError @@ -25,7 +24,6 @@ def _get_app_check_service(app) -> Any: return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService) -# should i accept an app (design doc doesn't have one) or just always make it none def verify_token(token: str, app=None) -> Dict[str, Any]: """Verifies a Firebase App Check token. @@ -35,43 +33,37 @@ def verify_token(token: str, app=None) -> Dict[str, Any]: Returns: Dict[str, Any]: A token's decoded claims - if the App Check token is valid; otherwise, a rejected promise.. + if the App Check token is valid; otherwise, a rejected promise. """ return _get_app_check_service(app).verify_token(token) class _AppCheckService: """Service class that implements Firebase App Check functionality.""" - _APP_CHECK_GCP_API_URL = 'https://firebaseappcheck.googleapis.com' - _APP_CHECK_BETA_JWKS_RESOURCE = '/v1beta/jwks' + _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/' + _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks' + _project_id = None def __init__(self, app): - # the verification method should go in the service - project_id = app.project_id - if not project_id: + # Validate and store the project_id to validate the JWT claims + self._project_id = app.project_id + if not self._project_id: raise ValueError( 'Project ID is required to access App Check service. Either set the ' 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - # Unsure what I should include in this constructor, or even if I should include one - @classmethod - def verify_token(cls, token: str) -> Dict[str, Any]: + def verify_token(self, token: str) -> Dict[str, Any]: """Verifies a Firebase App Check token.""" - if token is None: - return None + _Validators.check_string("app check token", token) # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. - url = f'{cls._APP_CHECK_GCP_API_URL}{cls._APP_CHECK_BETA_JWKS_RESOURCE}' - jwks_client = PyJWKClient(url) + jwks_client = PyJWKClient(self._JWKS_URL) signing_key = jwks_client.get_signing_key_from_jwt(token) - - # Getting error "No value for argument 'header' in unbound - # method call (no-value-for-parameter)" - cls._has_valid_token_headers(jwt.get_unverified_header(token)) - payload = cls._decode_and_verify(token, signing_key.key, "project_number") + self._has_valid_token_headers(jwt.get_unverified_header(token)) + payload = self._decode_and_verify(token, signing_key.get('key')) # The token's subject will be the app ID, you may optionally filter against # an allow list @@ -81,10 +73,14 @@ def _has_valid_token_headers(self, header: Any) -> None: """Checks whether the token has valid headers for App Check.""" # Ensure the token's header has type JWT if header.get('typ') != 'JWT': - raise ValueError("The token received is not a JWT") + raise ValueError("The provided App Check token has an incorrect type header") # Ensure the token's header uses the algorithm RS256 - if header.get('alg') != 'RS256': - raise ValueError("JWT's algorithm does not have valid token headers") + algorithm = header.get('alg') + if algorithm != 'RS256': + raise ValueError( + f'The provided App Check token has an incorrect algorithm. ' + 'Expected RS256 but got {algorithm}.' + ) def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> Dict[str, Any]: """Decodes the JWT received from App Check.""" @@ -96,7 +92,10 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> algorithms ) except DecodeError: - ValueError('Unable to decode the token') + ValueError( + 'Decoding App Check token failed. Make sure you passed the entire string JWT ' + 'which represents the Firebase App Check token.' + ) return payload def _decode_and_verify(self, token: str, signing_key: str): @@ -107,10 +106,25 @@ def _decode_and_verify(self, token: str, signing_key: str): algorithms=["RS256"] ) - # within the aud property, there will be an array of project id & number - if len(payload.get('aud')) <= 1: - raise ValueError('Project ID and Project Number are required to access App Check.') - if self._APP_CHECK_GCP_API_URL not in payload.get('issuer'): + scoped_project_id = 'projects/' + self._project_id + audience = payload.get('aud') + if not isinstance(audience, list) and scoped_project_id not in audience: + raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') + if not payload.get('issuer').startswith(self._APP_CHECK_ISSUER): raise ValueError('Token does not contain the correct Issuer.') 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('{0} must be a non-empty string.'.format(label)) + if not isinstance(value, str): + raise ValueError('{0} must be a string.'.format(label)) diff --git a/requirements.txt b/requirements.txt index 87142fe93..74a5531fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != ' google-api-python-client >= 1.7.8 google-cloud-firestore >= 2.1.0; platform.python_implementation != 'PyPy' google-cloud-storage >= 1.37.1 +pyjwt[crypto] >= 2.4.0 \ No newline at end of file From c5a25c2ca35ca6156283e4bc3b3ed9cbca70d4d4 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 9 Sep 2022 16:53:19 -0400 Subject: [PATCH 07/28] Add app_id key for verified claims dict --- firebase_admin/app_check.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 40348d66a..02b928762 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -63,11 +63,12 @@ def verify_token(self, token: str) -> Dict[str, Any]: jwks_client = PyJWKClient(self._JWKS_URL) signing_key = jwks_client.get_signing_key_from_jwt(token) self._has_valid_token_headers(jwt.get_unverified_header(token)) - payload = self._decode_and_verify(token, signing_key.get('key')) + verified_claims = self._decode_and_verify(token, signing_key.get('key')) # The token's subject will be the app ID, you may optionally filter against # an allow list - return payload.get('sub') + verified_claims['app_id'] = verified_claims.get('sub') + return verified_claims def _has_valid_token_headers(self, header: Any) -> None: """Checks whether the token has valid headers for App Check.""" From aa986971b7be459c6b5f46f393be70af5ac7f2a5 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Mon, 12 Sep 2022 19:53:15 -0400 Subject: [PATCH 08/28] Add initial test --- firebase_admin/app_check.py | 4 +-- tests/test_app_check.py | 51 +++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100644 tests/test_app_check.py diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 02b928762..5aa8f4907 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -126,6 +126,6 @@ class _Validators: def check_string(cls, label: str, value: Any): """Checks if the given value is a string.""" if value is None: - raise ValueError('{0} must be a non-empty string.'.format(label)) + raise ValueError('{0} "{1}" must be a non-empty string.'.format(label, value)) if not isinstance(value, str): - raise ValueError('{0} must be a string.'.format(label)) + raise ValueError('{0} "{1}" must be a string.'.format(label, value)) diff --git a/tests/test_app_check.py b/tests/test_app_check.py new file mode 100644 index 000000000..0c0dc49ed --- /dev/null +++ b/tests/test_app_check.py @@ -0,0 +1,51 @@ +# Copyright 2022 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.app_check module.""" + +import pytest + +import firebase_admin +from firebase_admin import app_check +from tests import testutils + +NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] + +class TestBatch: + + @classmethod + def setup_class(cls): + cred = testutils.MockCredential() + firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + + @classmethod + def teardown_class(cls): + testutils.cleanup_apps() + +class TestVerifyToken(TestBatch): + + def test_no_project_id(self): + def evaluate(): + app = firebase_admin.initialize_app(testutils.MockCredential(), name='no_project_id') + with pytest.raises(ValueError): + app_check.verify_token(token="app_check_token", app=app) + testutils.run_without_project_id(evaluate) + + @pytest.mark.parametrize('token', NON_STRING_ARGS) + def test_non_string_verify_token(self, token): + with pytest.raises(ValueError) as excinfo: + app_check.verify_token(token) + expected = 'app check token "{0}" must be a string.'.format(token) + assert str(excinfo.value) == expected + \ No newline at end of file From 7e2259cf27ee19aff368a13743780a94d5caefa3 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 14 Sep 2022 14:43:02 -0400 Subject: [PATCH 09/28] Add tests for token headers --- firebase_admin/app_check.py | 11 ++++++----- tests/test_app_check.py | 33 +++++++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 5aa8f4907..315ef1887 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -60,6 +60,7 @@ def verify_token(self, token: str) -> Dict[str, Any]: # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. + # TODO(dwyfrequency): update cache lifespan jwks_client = PyJWKClient(self._JWKS_URL) signing_key = jwks_client.get_signing_key_from_jwt(token) self._has_valid_token_headers(jwt.get_unverified_header(token)) @@ -70,17 +71,17 @@ def verify_token(self, token: str) -> Dict[str, Any]: verified_claims['app_id'] = verified_claims.get('sub') return verified_claims - def _has_valid_token_headers(self, header: Any) -> None: + def _has_valid_token_headers(self, headers: Any) -> None: """Checks whether the token has valid headers for App Check.""" # Ensure the token's header has type JWT - if header.get('typ') != 'JWT': + if headers.get('typ') != 'JWT': raise ValueError("The provided App Check token has an incorrect type header") # Ensure the token's header uses the algorithm RS256 - algorithm = header.get('alg') + algorithm = headers.get('alg') if algorithm != 'RS256': raise ValueError( - f'The provided App Check token has an incorrect algorithm. ' - 'Expected RS256 but got {algorithm}.' + 'The provided App Check token has an incorrect algorithm. ' + f'Expected RS256 but got {algorithm}.' ) def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> Dict[str, Any]: diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 0c0dc49ed..421fb24f9 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -19,6 +19,8 @@ import firebase_admin from firebase_admin import app_check from tests import testutils +import jwt +from jwt import PyJWK NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] @@ -43,9 +45,36 @@ def evaluate(): testutils.run_without_project_id(evaluate) @pytest.mark.parametrize('token', NON_STRING_ARGS) - def test_non_string_verify_token(self, token): + def test_verify_token_with_non_string_raises_error(self, token): with pytest.raises(ValueError) as excinfo: app_check.verify_token(token) expected = 'app check token "{0}" must be a string.'.format(token) assert str(excinfo.value) == expected - \ No newline at end of file + + def test_has_valid_token_headers(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + + headers = {"alg": "RS256", 'typ': "JWT"} + assert None == app_check_service._has_valid_token_headers(headers=headers) + + def test_has_valid_token_headers_with_incorrect_type_raises_error(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + headers = {"alg": "RS256", 'typ': "WRONG"} + with pytest.raises(ValueError) as excinfo: + app_check_service._has_valid_token_headers(headers=headers) + + expected = 'The provided App Check token has an incorrect type header' + assert str(excinfo.value) == expected + + def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + headers = {"alg": "HS256", 'typ': "JWT"} + with pytest.raises(ValueError) as excinfo: + app_check_service._has_valid_token_headers(headers=headers) + + expected = 'The provided App Check token has an incorrect algorithm. Expected RS256 but got HS256.' + assert str(excinfo.value) == expected + \ No newline at end of file From 0978778bf3d5e19afca6c79d97a8a03e0a76bb63 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 14 Sep 2022 16:03:35 -0400 Subject: [PATCH 10/28] Add decode token test and notes --- tests/test_app_check.py | 44 ++++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 421fb24f9..cba809f89 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -16,11 +16,14 @@ import pytest +# import jwt +# from jwt import PyJWK, DecodeError +# from mock import Mock, sentinel import firebase_admin from firebase_admin import app_check from tests import testutils -import jwt -from jwt import PyJWK + + NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] @@ -55,9 +58,9 @@ def test_has_valid_token_headers(self): app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) - headers = {"alg": "RS256", 'typ': "JWT"} - assert None == app_check_service._has_valid_token_headers(headers=headers) - + headers = {"alg": "RS256", 'typ': "JWT"} + assert app_check_service._has_valid_token_headers(headers=headers) is None + def test_has_valid_token_headers_with_incorrect_type_raises_error(self): app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) @@ -67,7 +70,7 @@ def test_has_valid_token_headers_with_incorrect_type_raises_error(self): expected = 'The provided App Check token has an incorrect type header' assert str(excinfo.value) == expected - + def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) @@ -75,6 +78,33 @@ def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): with pytest.raises(ValueError) as excinfo: app_check_service._has_valid_token_headers(headers=headers) - expected = 'The provided App Check token has an incorrect algorithm. Expected RS256 but got HS256.' + expected = ('The provided App Check token has an incorrect algorithm. ' + 'Expected RS256 but got HS256.') assert str(excinfo.value) == expected + + def test_decode_token(self, mocker): + decoded_token = {"aud": "projects/1234"} + jwt_decode_mock = mocker.patch("jwt.decode", return_value=decoded_token) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + payload = app_check_service._decode_token( + token=None, + signing_key="1234", + algorithms=["RS256"] + ) + + jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) + assert payload == decoded_token + + # def test_decode_token_with_invalid_token_raises_error(self, mocker): + # jwt_decode_mock = mocker.patch("jwt.decode", side_effect=DecodeError()) + # app = firebase_admin.get_app() + # app_check_service = app_check._get_app_check_service(app) + # with pytest.raises(ValueError) as excinfo: + # app_check_service._decode_token(token="2213213", signing_key=None, algorithms=["RS256"]) + + # expected = ('Decoding App Check token failed. Make sure you passed the ' + # 'entire string JWT which represents the Firebase App Check token.') + # jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) + # assert str(excinfo.value) == expected \ No newline at end of file From 85145e1336e217768a79511d0f7c3f1cd3f4799b Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 14 Sep 2022 17:37:01 -0400 Subject: [PATCH 11/28] Updating requirements for mocks and note in test --- requirements.txt | 1 + tests/test_app_check.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 74a5531fb..308cb3899 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ pytest >= 6.2.0 pytest-cov >= 2.4.0 pytest-localserver >= 0.4.1 pytest-asyncio >= 0.16.0 +pytest-mock >= 3.8.2 cachecontrol >= 0.12.6 google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != 'PyPy' diff --git a/tests/test_app_check.py b/tests/test_app_check.py index cba809f89..c980372a5 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -96,12 +96,13 @@ def test_decode_token(self, mocker): jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) assert payload == decoded_token + # it's been difficult to get the error to pop up, should I also try and have a catch all error # def test_decode_token_with_invalid_token_raises_error(self, mocker): # jwt_decode_mock = mocker.patch("jwt.decode", side_effect=DecodeError()) # app = firebase_admin.get_app() # app_check_service = app_check._get_app_check_service(app) # with pytest.raises(ValueError) as excinfo: - # app_check_service._decode_token(token="2213213", signing_key=None, algorithms=["RS256"]) + # app_check_service._decode_token(token="2213213", signing_key=None, algorithms=["RS256"]) # expected = ('Decoding App Check token failed. Make sure you passed the ' # 'entire string JWT which represents the Firebase App Check token.') From 41f93eae0dbd8e348537d1ff11277ca6ae569250 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 16 Sep 2022 14:10:44 -0400 Subject: [PATCH 12/28] Add verify token test and decode test --- firebase_admin/app_check.py | 6 ++--- tests/test_app_check.py | 47 ++++++++++++++++++++----------------- 2 files changed, 28 insertions(+), 25 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 315ef1887..b1fabfe93 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -16,7 +16,7 @@ from typing import Any, Dict, List import jwt -from jwt import PyJWKClient, DecodeError +from jwt import PyJWKClient, DecodeError, InvalidKeyError from firebase_admin import _utils _APP_CHECK_ATTRIBUTE = '_app_check' @@ -93,7 +93,7 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> signing_key, algorithms ) - except DecodeError: + except (DecodeError, InvalidKeyError): ValueError( 'Decoding App Check token failed. Make sure you passed the entire string JWT ' 'which represents the Firebase App Check token.' @@ -112,7 +112,7 @@ def _decode_and_verify(self, token: str, signing_key: str): audience = payload.get('aud') if not isinstance(audience, list) and scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') - if not payload.get('issuer').startswith(self._APP_CHECK_ISSUER): + if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): raise ValueError('Token does not contain the correct Issuer.') return payload diff --git a/tests/test_app_check.py b/tests/test_app_check.py index c980372a5..9baa4e7c0 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -16,17 +16,24 @@ import pytest -# import jwt -# from jwt import PyJWK, DecodeError -# from mock import Mock, sentinel import firebase_admin from firebase_admin import app_check from tests import testutils - - NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] +APP_ID = "1234567890" +JWT_PAYLOAD_SAMPLE = { + "headers": { + "alg": "RS256", + "typ": "JWT" + }, + "sub": APP_ID, + "name": "John Doe", + "iss": "https://firebaseappcheck.googleapis.com/", + "aud": ["projects/1334"] +} + class TestBatch: @classmethod @@ -83,8 +90,7 @@ def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): assert str(excinfo.value) == expected def test_decode_token(self, mocker): - decoded_token = {"aud": "projects/1234"} - jwt_decode_mock = mocker.patch("jwt.decode", return_value=decoded_token) + jwt_decode_mock = mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) payload = app_check_service._decode_token( @@ -94,18 +100,15 @@ def test_decode_token(self, mocker): ) jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) - assert payload == decoded_token - - # it's been difficult to get the error to pop up, should I also try and have a catch all error - # def test_decode_token_with_invalid_token_raises_error(self, mocker): - # jwt_decode_mock = mocker.patch("jwt.decode", side_effect=DecodeError()) - # app = firebase_admin.get_app() - # app_check_service = app_check._get_app_check_service(app) - # with pytest.raises(ValueError) as excinfo: - # app_check_service._decode_token(token="2213213", signing_key=None, algorithms=["RS256"]) - - # expected = ('Decoding App Check token failed. Make sure you passed the ' - # 'entire string JWT which represents the Firebase App Check token.') - # jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) - # assert str(excinfo.value) == expected - \ No newline at end of file + assert payload == JWT_PAYLOAD_SAMPLE.copy() + + def test_verify_token(self, mocker): + mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + payload = app_check.verify_token("encoded", app) + expected = JWT_PAYLOAD_SAMPLE.copy() + expected['app_id'] = APP_ID + assert payload == expected From 5436d12b10334edd571db446b7ec841c3afcba7d Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 16 Sep 2022 14:19:48 -0400 Subject: [PATCH 13/28] Update pytest-mock requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 308cb3899..acede6ebc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ pytest >= 6.2.0 pytest-cov >= 2.4.0 pytest-localserver >= 0.4.1 pytest-asyncio >= 0.16.0 -pytest-mock >= 3.8.2 +pytest-mock >= 3.6.1 cachecontrol >= 0.12.6 google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != 'PyPy' From 6a4815aa4befafaa7d2097ed7a33fbeca1999a74 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Mon, 19 Sep 2022 17:28:14 -0400 Subject: [PATCH 14/28] Add tests for error messages --- tests/test_app_check.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 9baa4e7c0..469c3d490 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -112,3 +112,31 @@ def test_verify_token(self, mocker): expected = JWT_PAYLOAD_SAMPLE.copy() expected['app_id'] = APP_ID assert payload == expected + + def test_verify_token_with_non_list_audience_raises_error(self, mocker): + jwt_with_non_list_audience = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_non_list_audience["aud"] = '1234' + mocker.patch("jwt.decode", return_value=jwt_with_non_list_audience) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Firebase App Check token has incorrect "aud" (audience) claim.' + assert str(excinfo.value) == expected + + def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): + jwt_with_non_incorrect_issuer = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_non_incorrect_issuer["iss"] = "https://dwyfrequency.googleapis.com/" + mocker.patch("jwt.decode", return_value=jwt_with_non_incorrect_issuer) + mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + mocker.patch("jwt.get_unverified_header", return_value=JWT_PAYLOAD_SAMPLE.get("headers")) + app = firebase_admin.get_app() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Token does not contain the correct Issuer.' + assert str(excinfo.value) == expected From a592256243fef7f1bb368f6098e389692e2c42eb Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Tue, 20 Sep 2022 13:07:04 -0400 Subject: [PATCH 15/28] Update requirements for lifespan cache --- firebase_admin/app_check.py | 4 ++-- requirements.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index b1fabfe93..b9418bb0e 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -60,8 +60,8 @@ def verify_token(self, token: str) -> Dict[str, Any]: # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. - # TODO(dwyfrequency): update cache lifespan - jwks_client = PyJWKClient(self._JWKS_URL) + # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours). + jwks_client = PyJWKClient(self._JWKS_URL, lifespan=21600) signing_key = jwks_client.get_signing_key_from_jwt(token) self._has_valid_token_headers(jwt.get_unverified_header(token)) verified_claims = self._decode_and_verify(token, signing_key.get('key')) diff --git a/requirements.txt b/requirements.txt index acede6ebc..c66212673 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,4 +11,4 @@ google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != ' google-api-python-client >= 1.7.8 google-cloud-firestore >= 2.1.0; platform.python_implementation != 'PyPy' google-cloud-storage >= 1.37.1 -pyjwt[crypto] >= 2.4.0 \ No newline at end of file +pyjwt[crypto] >= 2.5.0 \ No newline at end of file From 5b94963b58d57e6280f25ce81c2e8b0d16e9888a Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Tue, 20 Sep 2022 13:24:41 -0400 Subject: [PATCH 16/28] update error message and test --- firebase_admin/app_check.py | 2 +- tests/test_app_check.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index b9418bb0e..6d58afbcb 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -113,7 +113,7 @@ def _decode_and_verify(self, token: str, signing_key: str): if not isinstance(audience, list) and scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): - raise ValueError('Token does not contain the correct Issuer.') + raise ValueError('Token does not contain the correct "iss" (issuer).') return payload diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 469c3d490..a67b5c131 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -138,5 +138,5 @@ def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): with pytest.raises(ValueError) as excinfo: app_check.verify_token("encoded", app) - expected = 'Token does not contain the correct Issuer.' + expected = 'Token does not contain the correct "iss" (issuer).' assert str(excinfo.value) == expected From 89f29d30901b3c35f0a166c0560eeae892a8b302 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Thu, 22 Sep 2022 12:58:02 -0400 Subject: [PATCH 17/28] Explicitly pass audience to jwt.decode and update key retrieval --- firebase_admin/app_check.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 6d58afbcb..176632724 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -43,6 +43,7 @@ class _AppCheckService: _APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/' _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks' _project_id = None + _scoped_project_id = None def __init__(self, app): # Validate and store the project_id to validate the JWT claims @@ -52,6 +53,8 @@ def __init__(self, app): 'Project ID is required to access App Check service. Either set the ' 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') + self._scoped_project_id = 'projects/' + app.project_id + def verify_token(self, token: str) -> Dict[str, Any]: """Verifies a Firebase App Check token.""" @@ -64,7 +67,7 @@ def verify_token(self, token: str) -> Dict[str, Any]: jwks_client = PyJWKClient(self._JWKS_URL, lifespan=21600) signing_key = jwks_client.get_signing_key_from_jwt(token) self._has_valid_token_headers(jwt.get_unverified_header(token)) - verified_claims = self._decode_and_verify(token, signing_key.get('key')) + verified_claims = self._decode_and_verify(token, signing_key.key) # The token's subject will be the app ID, you may optionally filter against # an allow list @@ -91,7 +94,8 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> payload = jwt.decode( token, signing_key, - algorithms + algorithms, + audience=self._scoped_project_id ) except (DecodeError, InvalidKeyError): ValueError( @@ -108,9 +112,9 @@ def _decode_and_verify(self, token: str, signing_key: str): algorithms=["RS256"] ) - scoped_project_id = 'projects/' + self._project_id + audience = payload.get('aud') - if not isinstance(audience, list) and scoped_project_id not in audience: + if not isinstance(audience, list) and self._scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): raise ValueError('Token does not contain the correct "iss" (issuer).') From a5290b540bbc5e2762c1adfb1bfb7502455e05e0 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 23 Sep 2022 12:18:32 -0400 Subject: [PATCH 18/28] Mock signing key --- firebase_admin/app_check.py | 3 +-- tests/test_app_check.py | 23 +++++++++++++++++------ 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 176632724..fcad04f55 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -53,7 +53,7 @@ def __init__(self, app): 'Project ID is required to access App Check service. Either set the ' 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - self._scoped_project_id = 'projects/' + app.project_id + self._scoped_project_id = 'projects/' + app.project_id def verify_token(self, token: str) -> Dict[str, Any]: @@ -112,7 +112,6 @@ def _decode_and_verify(self, token: str, signing_key: str): algorithms=["RS256"] ) - audience = payload.get('aud') if not isinstance(audience, list) and self._scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') diff --git a/tests/test_app_check.py b/tests/test_app_check.py index a67b5c131..f48fa5d17 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -13,9 +13,10 @@ # limitations under the License. """Test cases for the firebase_admin.app_check module.""" - +import base64 import pytest +from jwt import PyJWK import firebase_admin from firebase_admin import app_check from tests import testutils @@ -23,6 +24,7 @@ NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] APP_ID = "1234567890" +SCOPED_PROJECT_ID = "projects/1334" JWT_PAYLOAD_SAMPLE = { "headers": { "alg": "RS256", @@ -34,6 +36,14 @@ "aud": ["projects/1334"] } +secret_key = "secret" +signing_key = { + "kty": "oct", + # Using HS256 for simplicity, production key will use RS256 + "alg": "HS256", + "k": base64.urlsafe_b64encode(secret_key.encode()) +} + class TestBatch: @classmethod @@ -96,15 +106,16 @@ def test_decode_token(self, mocker): payload = app_check_service._decode_token( token=None, signing_key="1234", - algorithms=["RS256"] + algorithms=["RS256"], ) - jwt_decode_mock.assert_called_once_with(None, "1234", ["RS256"]) + jwt_decode_mock.assert_called_once_with( + None, "1234", ["RS256"], audience="projects/explicit-project-id") assert payload == JWT_PAYLOAD_SAMPLE.copy() def test_verify_token(self, mocker): mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) - mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + 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() @@ -117,7 +128,7 @@ def test_verify_token_with_non_list_audience_raises_error(self, mocker): jwt_with_non_list_audience = JWT_PAYLOAD_SAMPLE.copy() jwt_with_non_list_audience["aud"] = '1234' mocker.patch("jwt.decode", return_value=jwt_with_non_list_audience) - mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + 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() @@ -131,7 +142,7 @@ def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): jwt_with_non_incorrect_issuer = JWT_PAYLOAD_SAMPLE.copy() jwt_with_non_incorrect_issuer["iss"] = "https://dwyfrequency.googleapis.com/" mocker.patch("jwt.decode", return_value=jwt_with_non_incorrect_issuer) - mocker.patch("jwt.PyJWKClient.get_signing_key_from_jwt", return_value={"key": "secret"}) + 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() From c46b60b7a858f41ffc5c21e5d975194efffc410a Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 23 Sep 2022 13:15:03 -0400 Subject: [PATCH 19/28] Update aud check logic and tests --- firebase_admin/app_check.py | 6 +++++- tests/test_app_check.py | 23 +++++++++++++++++++---- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index fcad04f55..01e9d1216 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -34,6 +34,9 @@ def verify_token(token: str, app=None) -> Dict[str, Any]: Returns: Dict[str, Any]: A token's decoded claims if the App Check token is valid; otherwise, a rejected promise. + + Raises: + ValueError: If ``project_id``, headers, or the decoded token payload is invalid. """ return _get_app_check_service(app).verify_token(token) @@ -113,7 +116,8 @@ def _decode_and_verify(self, token: str, signing_key: str): ) audience = payload.get('aud') - if not isinstance(audience, list) and self._scoped_project_id not in audience: + print(audience, self._scoped_project_id) + if not isinstance(audience, list) or self._scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): raise ValueError('Token does not contain the correct "iss" (issuer).') diff --git a/tests/test_app_check.py b/tests/test_app_check.py index f48fa5d17..18d562d05 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -24,7 +24,8 @@ NON_STRING_ARGS = [list(), tuple(), dict(), True, False, 1, 0] APP_ID = "1234567890" -SCOPED_PROJECT_ID = "projects/1334" +PROJECT_ID = "1334" +SCOPED_PROJECT_ID = f"projects/{PROJECT_ID}" JWT_PAYLOAD_SAMPLE = { "headers": { "alg": "RS256", @@ -33,7 +34,7 @@ "sub": APP_ID, "name": "John Doe", "iss": "https://firebaseappcheck.googleapis.com/", - "aud": ["projects/1334"] + "aud": [SCOPED_PROJECT_ID] } secret_key = "secret" @@ -49,7 +50,7 @@ class TestBatch: @classmethod def setup_class(cls): cred = testutils.MockCredential() - firebase_admin.initialize_app(cred, {'projectId': 'explicit-project-id'}) + firebase_admin.initialize_app(cred, {'projectId': PROJECT_ID}) @classmethod def teardown_class(cls): @@ -110,7 +111,7 @@ def test_decode_token(self, mocker): ) jwt_decode_mock.assert_called_once_with( - None, "1234", ["RS256"], audience="projects/explicit-project-id") + None, "1234", ["RS256"], audience=SCOPED_PROJECT_ID) assert payload == JWT_PAYLOAD_SAMPLE.copy() def test_verify_token(self, mocker): @@ -138,6 +139,20 @@ def test_verify_token_with_non_list_audience_raises_error(self, mocker): expected = 'Firebase App Check token has incorrect "aud" (audience) claim.' assert str(excinfo.value) == expected + def test_verify_token_with_empty_list_audience_raises_error(self, mocker): + jwt_with_empty_list_audience = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_empty_list_audience["aud"] = [] + mocker.patch("jwt.decode", return_value=jwt_with_empty_list_audience) + 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() + + with pytest.raises(ValueError) as excinfo: + app_check.verify_token("encoded", app) + + expected = 'Firebase App Check token has incorrect "aud" (audience) claim.' + assert str(excinfo.value) == expected + def test_verify_token_with_incorrect_issuer_raises_error(self, mocker): jwt_with_non_incorrect_issuer = JWT_PAYLOAD_SAMPLE.copy() jwt_with_non_incorrect_issuer["iss"] = "https://dwyfrequency.googleapis.com/" From e9148b7e8f021e3125f4b8e9f4978b727798a5e7 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 23 Sep 2022 13:18:18 -0400 Subject: [PATCH 20/28] Remove print statement --- firebase_admin/app_check.py | 1 - 1 file changed, 1 deletion(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 01e9d1216..2a174360f 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -116,7 +116,6 @@ def _decode_and_verify(self, token: str, signing_key: str): ) audience = payload.get('aud') - print(audience, self._scoped_project_id) if not isinstance(audience, list) or self._scoped_project_id not in audience: raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): From b732aa6c6bb48448634e41c1e1600ba8808016a4 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 23 Sep 2022 13:36:13 -0400 Subject: [PATCH 21/28] Update method doc string --- firebase_admin/app_check.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 2a174360f..02ebe35d0 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -32,8 +32,7 @@ def verify_token(token: str, app=None) -> Dict[str, Any]: app: An App instance (optional). Returns: - Dict[str, Any]: A token's decoded claims - if the App Check token is valid; otherwise, a rejected promise. + Dict[str, Any]: A token's decoded claims if the App Check token Raises: ValueError: If ``project_id``, headers, or the decoded token payload is invalid. From 46f22f63dc452e1e5b113ec72a5fc4c8676dae20 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Fri, 23 Sep 2022 13:49:52 -0400 Subject: [PATCH 22/28] Add test for decode_token error --- firebase_admin/app_check.py | 2 +- tests/test_app_check.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 02ebe35d0..d8de0da31 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -100,7 +100,7 @@ def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> audience=self._scoped_project_id ) except (DecodeError, InvalidKeyError): - ValueError( + raise ValueError( 'Decoding App Check token failed. Make sure you passed the entire string JWT ' 'which represents the Firebase App Check token.' ) diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 18d562d05..84299da8b 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -114,6 +114,21 @@ def test_decode_token(self, mocker): None, "1234", ["RS256"], audience=SCOPED_PROJECT_ID) assert payload == JWT_PAYLOAD_SAMPLE.copy() + def test_decode_token_with_incorrect_token_and_key(self): + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_token( + token="1232132", + signing_key=signing_key, + algorithms=["RS256"], + ) + + expected = ( + 'Decoding App Check token failed. Make sure you passed the entire string JWT ' + 'which represents the Firebase App Check token.') + assert str(excinfo.value) == expected + def test_verify_token(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)) From 2b6c7e7f0f2ea685bb23c6f36dcb74df721fc1d2 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Tue, 27 Sep 2022 13:32:19 -0400 Subject: [PATCH 23/28] Catch additional errors and add custom error messages for them --- firebase_admin/app_check.py | 44 +++++++++++++++++++++++-------------- tests/test_app_check.py | 39 ++++++++++++++++++++++++-------- 2 files changed, 57 insertions(+), 26 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index d8de0da31..230e75437 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -14,9 +14,10 @@ """Firebase App Check module.""" -from typing import Any, Dict, List +from typing import Any, Dict import jwt -from jwt import PyJWKClient, DecodeError, InvalidKeyError +from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError +from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError from firebase_admin import _utils _APP_CHECK_ATTRIBUTE = '_app_check' @@ -89,30 +90,39 @@ def _has_valid_token_headers(self, headers: Any) -> None: f'Expected RS256 but got {algorithm}.' ) - def _decode_token(self, token: str, signing_key: str, algorithms: List[str]) -> Dict[str, Any]: - """Decodes the JWT received from App Check.""" + def _decode_and_verify(self, token: str, signing_key: str): + """Decodes and verifies the token from App Check.""" payload = {} try: payload = jwt.decode( token, signing_key, - algorithms, + algorithms=["RS256"], audience=self._scoped_project_id ) - except (DecodeError, InvalidKeyError): + except InvalidSignatureError: raise ValueError( - 'Decoding App Check token failed. Make sure you passed the entire string JWT ' - 'which represents the Firebase App Check token.' + 'The provided App Check token signature cannot be verified.' + ) + except InvalidAudienceError: + raise ValueError( + 'The provided App Check token has incorrect "aud" (audience) claim.' + f'Expected payload to include {self._scoped_project_id} but got ' + f'{payload.get("aud")}. ' + ) + except InvalidIssuerError: + raise ValueError( + 'The provided App Check token has incorrect "iss" (issuer) claim.' + f'Expected claim to include {self._APP_CHECK_ISSUER}' + ) + except ExpiredSignatureError: + raise ValueError( + 'The provided App Check token signature has expired.' + ) + except InvalidTokenError as exception: + raise ValueError( + f'Decoding App Check token failed. Error: {exception}' ) - return payload - - def _decode_and_verify(self, token: str, signing_key: str): - """Decodes and verifies the token from App Check.""" - payload = self._decode_token( - token, - signing_key, - algorithms=["RS256"] - ) audience = payload.get('aud') if not isinstance(audience, list) or self._scoped_project_id not in audience: diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 84299da8b..598d92381 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -45,6 +45,10 @@ "k": base64.urlsafe_b64encode(secret_key.encode()) } +EXPIRED_TOKEN = "eyJraWQiOiJsWUJXVmciLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxOjM4ODE4ODY2Njk2MzphbmRyb2lkOjYyZWNhZWEzOTYzMWIxZGM3NDFhYTYiLCJhdWQiOlsicHJvamVjdHNcLzM4ODE4ODY2Njk2MyIsInByb2plY3RzXC9hZG1pbi1qYXZhLWludGVncmF0aW9uIl0sImlzcyI6Imh0dHBzOlwvXC9maXJlYmFzZWFwcGNoZWNrLmdvb2dsZWFwaXMuY29tXC8zODgxODg2NjY5NjMiLCJleHAiOjE2NjM3OTUxNDcsImlhdCI6MTY2Mzc5MTU0N30.oQWIQFwUlWp1wXhZ-rQvrw7ud2fmPj7kagWWPlqvXrRKASjtMka09Anm25mRaOymm7jeu7r0JMOYTSJJM6Iz89qCndO92nC6Wuvlug1zVYSJDgUWAv6msGOK_qANMMbYYXjx912nCHT0A7CyeTSCKK3xxq8lD0YI6c2E9g6U1E23mbHn-ekI8K_fV3DjZ9staCYmymlhbdZwf6FMeBZzSgjfXaHzNwe37Ndj9C_HxdZwYS4Yt7JS_SWNXtgGM6kj-Ie5MWLGuzR-qkMglaS7KqTK3K-iYG1pMzKst4akDbhsr7CO3K4Z1q-iT-yBkTuwMvE40ztVXBm_v5zQQqE7IGWu79Fr-3yjmyf7MrvgP-WgAGc4MLozuXvasgRUnaf2XiXlMlmAk3BfiOB4maUhVktjexlzF1lD7MnDQ0mxpVz_Q2gzAus8ugbySGS0XvvDDTX3qlPIeVFsXRehwzwvUKc6li2hIdzG3nvOMWNBBGQzSs99-EbfGVm4caGmVoc3" + +INVALID_SIGNATURE_TOKEN = "eyJraWQiOiJsWUJXVmciLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxOjM4ODE4ODY2Njk2MzphbmRyb2lkOjYyZWNhZWEzOTYzMWIxZGM3NDFhYTYiLCJhdWQiOlsicHJvamVjdHNcLzM4ODE4ODY2Njk2MyIsInByb2plY3RzXC9hZG1pbi1qYXZhLWludGVncmF0aW9uIl0sImlzcyI6Imh0dHBzOlwvXC9maXJlYmFzZWFwcGNoZWNrLmdvb2dsZWFwaXMuY29tXC8zODgxODg2NjY5NjMiLCJleHAiOjE2NjM3OTUxNDcsImlhdCI6MTY2Mzc5MTU0N30.oQWIQFwUlWp1wXhZ-rQvrw7ud2fmPj7kagWWPlqvXrRKASjtMka09Anm25mRaOymm7jeuOYTSJJM6Iz89qCndO92nC6Wuvlug1zVYSJDgUWAv6msGOK_qANMMbYYXjx912nCHT0A7CyeTSCKK3xxq8lD0YI6c2E9g6U1E23mbHn-ekI8K_fV3DjZ9staCYmymlhbdZwf6FMeBZzSgjfXaHzNwe37Ndj9C_HxdZwYS4Yt7JS_SWNXtgGM6kj-Ie5MWLGuzR-qkMglaS7KqTK3K-iYG1pMzKst4akDbhsr7CO3K4Z1q-iT-yBkTuwMvE40ztVXBm_v5zQQqE7IGWu79Fr-3yjmyf7MrvgP-WgAGc4MLozuXvasgRUnaf2XiXlMlmAk3BfiOB4maUhVktjexlzF1lD7MnDQ0mxpVz_Q2gzAus8ugbySGS0XvvDDTX3qlPIeVFsXRehwzwvUKc6li2hIdzG3nvOMWNBBGQzSs99-EbfGVm4caGmVoc3" + class TestBatch: @classmethod @@ -100,33 +104,50 @@ def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): 'Expected RS256 but got HS256.') assert str(excinfo.value) == expected - def test_decode_token(self, mocker): + def test_decode_and_verify(self, mocker): jwt_decode_mock = mocker.patch("jwt.decode", return_value=JWT_PAYLOAD_SAMPLE) app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) - payload = app_check_service._decode_token( + payload = app_check_service._decode_and_verify( token=None, signing_key="1234", - algorithms=["RS256"], ) jwt_decode_mock.assert_called_once_with( - None, "1234", ["RS256"], audience=SCOPED_PROJECT_ID) + None, "1234", algorithms=["RS256"], audience=SCOPED_PROJECT_ID) assert payload == JWT_PAYLOAD_SAMPLE.copy() - def test_decode_token_with_incorrect_token_and_key(self): + def test_decode_and_verify_with_incorrect_token_and_key(self): app = firebase_admin.get_app() app_check_service = app_check._get_app_check_service(app) with pytest.raises(ValueError) as excinfo: - app_check_service._decode_token( + app_check_service._decode_and_verify( token="1232132", signing_key=signing_key, - algorithms=["RS256"], ) expected = ( - 'Decoding App Check token failed. Make sure you passed the entire string JWT ' - 'which represents the Firebase App Check token.') + 'Decoding App Check token failed. Error: Not enough segments') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_expired_token(self): + app = firebase_admin.get_app() + app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check.verify_token(EXPIRED_TOKEN, app) + + expected = ( + 'The provided App Check token signature has expired.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_invalid_signature(self): + app = firebase_admin.get_app() + app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check.verify_token(INVALID_SIGNATURE_TOKEN, app) + + expected = ( + 'The provided App Check token signature cannot be verified.') assert str(excinfo.value) == expected def test_verify_token(self, mocker): From e08f35599a7da697992986bd8319fdf3626ab620 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Tue, 27 Sep 2022 15:00:30 -0400 Subject: [PATCH 24/28] Mock out all the common errors --- firebase_admin/app_check.py | 3 +- tests/test_app_check.py | 60 +++++++++++++++++++++++++++++-------- 2 files changed, 49 insertions(+), 14 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 230e75437..0cf536d6b 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -107,8 +107,7 @@ def _decode_and_verify(self, token: str, signing_key: str): except InvalidAudienceError: raise ValueError( 'The provided App Check token has incorrect "aud" (audience) claim.' - f'Expected payload to include {self._scoped_project_id} but got ' - f'{payload.get("aud")}. ' + f'Expected payload to include {self._scoped_project_id}.' ) except InvalidIssuerError: raise ValueError( diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 598d92381..e5f80f645 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -16,7 +16,8 @@ import base64 import pytest -from jwt import PyJWK +from jwt import PyJWK, InvalidAudienceError, InvalidIssuerError +from jwt import ExpiredSignatureError, InvalidSignatureError import firebase_admin from firebase_admin import app_check from tests import testutils @@ -26,6 +27,7 @@ APP_ID = "1234567890" PROJECT_ID = "1334" SCOPED_PROJECT_ID = f"projects/{PROJECT_ID}" +ISSUER = "https://firebaseappcheck.googleapis.com/" JWT_PAYLOAD_SAMPLE = { "headers": { "alg": "RS256", @@ -33,7 +35,7 @@ }, "sub": APP_ID, "name": "John Doe", - "iss": "https://firebaseappcheck.googleapis.com/", + "iss": ISSUER, "aud": [SCOPED_PROJECT_ID] } @@ -45,10 +47,6 @@ "k": base64.urlsafe_b64encode(secret_key.encode()) } -EXPIRED_TOKEN = "eyJraWQiOiJsWUJXVmciLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxOjM4ODE4ODY2Njk2MzphbmRyb2lkOjYyZWNhZWEzOTYzMWIxZGM3NDFhYTYiLCJhdWQiOlsicHJvamVjdHNcLzM4ODE4ODY2Njk2MyIsInByb2plY3RzXC9hZG1pbi1qYXZhLWludGVncmF0aW9uIl0sImlzcyI6Imh0dHBzOlwvXC9maXJlYmFzZWFwcGNoZWNrLmdvb2dsZWFwaXMuY29tXC8zODgxODg2NjY5NjMiLCJleHAiOjE2NjM3OTUxNDcsImlhdCI6MTY2Mzc5MTU0N30.oQWIQFwUlWp1wXhZ-rQvrw7ud2fmPj7kagWWPlqvXrRKASjtMka09Anm25mRaOymm7jeu7r0JMOYTSJJM6Iz89qCndO92nC6Wuvlug1zVYSJDgUWAv6msGOK_qANMMbYYXjx912nCHT0A7CyeTSCKK3xxq8lD0YI6c2E9g6U1E23mbHn-ekI8K_fV3DjZ9staCYmymlhbdZwf6FMeBZzSgjfXaHzNwe37Ndj9C_HxdZwYS4Yt7JS_SWNXtgGM6kj-Ie5MWLGuzR-qkMglaS7KqTK3K-iYG1pMzKst4akDbhsr7CO3K4Z1q-iT-yBkTuwMvE40ztVXBm_v5zQQqE7IGWu79Fr-3yjmyf7MrvgP-WgAGc4MLozuXvasgRUnaf2XiXlMlmAk3BfiOB4maUhVktjexlzF1lD7MnDQ0mxpVz_Q2gzAus8ugbySGS0XvvDDTX3qlPIeVFsXRehwzwvUKc6li2hIdzG3nvOMWNBBGQzSs99-EbfGVm4caGmVoc3" - -INVALID_SIGNATURE_TOKEN = "eyJraWQiOiJsWUJXVmciLCJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIxOjM4ODE4ODY2Njk2MzphbmRyb2lkOjYyZWNhZWEzOTYzMWIxZGM3NDFhYTYiLCJhdWQiOlsicHJvamVjdHNcLzM4ODE4ODY2Njk2MyIsInByb2plY3RzXC9hZG1pbi1qYXZhLWludGVncmF0aW9uIl0sImlzcyI6Imh0dHBzOlwvXC9maXJlYmFzZWFwcGNoZWNrLmdvb2dsZWFwaXMuY29tXC8zODgxODg2NjY5NjMiLCJleHAiOjE2NjM3OTUxNDcsImlhdCI6MTY2Mzc5MTU0N30.oQWIQFwUlWp1wXhZ-rQvrw7ud2fmPj7kagWWPlqvXrRKASjtMka09Anm25mRaOymm7jeuOYTSJJM6Iz89qCndO92nC6Wuvlug1zVYSJDgUWAv6msGOK_qANMMbYYXjx912nCHT0A7CyeTSCKK3xxq8lD0YI6c2E9g6U1E23mbHn-ekI8K_fV3DjZ9staCYmymlhbdZwf6FMeBZzSgjfXaHzNwe37Ndj9C_HxdZwYS4Yt7JS_SWNXtgGM6kj-Ie5MWLGuzR-qkMglaS7KqTK3K-iYG1pMzKst4akDbhsr7CO3K4Z1q-iT-yBkTuwMvE40ztVXBm_v5zQQqE7IGWu79Fr-3yjmyf7MrvgP-WgAGc4MLozuXvasgRUnaf2XiXlMlmAk3BfiOB4maUhVktjexlzF1lD7MnDQ0mxpVz_Q2gzAus8ugbySGS0XvvDDTX3qlPIeVFsXRehwzwvUKc6li2hIdzG3nvOMWNBBGQzSs99-EbfGVm4caGmVoc3" - class TestBatch: @classmethod @@ -130,26 +128,64 @@ def test_decode_and_verify_with_incorrect_token_and_key(self): 'Decoding App Check token failed. Error: Not enough segments') assert str(excinfo.value) == expected - def test_decode_and_verify_with_expired_token(self): + def test_decode_and_verify_with_expired_token_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=ExpiredSignatureError) app = firebase_admin.get_app() - app_check._get_app_check_service(app) + app_check_service = app_check._get_app_check_service(app) with pytest.raises(ValueError) as excinfo: - app_check.verify_token(EXPIRED_TOKEN, app) + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) expected = ( 'The provided App Check token signature has expired.') assert str(excinfo.value) == expected - def test_decode_and_verify_with_invalid_signature(self): + def test_decode_and_verify_with_invalid_signature_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidSignatureError) app = firebase_admin.get_app() - app_check._get_app_check_service(app) + app_check_service = app_check._get_app_check_service(app) with pytest.raises(ValueError) as excinfo: - app_check.verify_token(INVALID_SIGNATURE_TOKEN, app) + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) expected = ( 'The provided App Check token signature cannot be verified.') assert str(excinfo.value) == expected + def test_decode_and_verify_with_invalid_aud_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidAudienceError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has incorrect "aud" (audience) claim.' + f'Expected payload to include {SCOPED_PROJECT_ID}.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_invalid_iss_raises_error(self, mocker): + mocker.patch("jwt.decode", side_effect=InvalidIssuerError) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token has incorrect "iss" (issuer) claim.' + f'Expected claim to include {ISSUER}') + assert str(excinfo.value) == expected + def test_verify_token(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)) From 73edeb3d920a3654e6e3ec6d21ad6e3523394594 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 28 Sep 2022 15:36:01 -0400 Subject: [PATCH 25/28] Updating error messages and tests per comments --- firebase_admin/app_check.py | 6 ++---- tests/test_app_check.py | 4 ++-- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 0cf536d6b..2625a622f 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -72,8 +72,6 @@ def verify_token(self, token: str) -> Dict[str, Any]: self._has_valid_token_headers(jwt.get_unverified_header(token)) verified_claims = self._decode_and_verify(token, signing_key.key) - # The token's subject will be the app ID, you may optionally filter against - # an allow list verified_claims['app_id'] = verified_claims.get('sub') return verified_claims @@ -102,7 +100,7 @@ def _decode_and_verify(self, token: str, signing_key: str): ) except InvalidSignatureError: raise ValueError( - 'The provided App Check token signature cannot be verified.' + 'The provided App Check token has invalid signature..' ) except InvalidAudienceError: raise ValueError( @@ -116,7 +114,7 @@ def _decode_and_verify(self, token: str, signing_key: str): ) except ExpiredSignatureError: raise ValueError( - 'The provided App Check token signature has expired.' + 'The provided App Check token has expired.' ) except InvalidTokenError as exception: raise ValueError( diff --git a/tests/test_app_check.py b/tests/test_app_check.py index e5f80f645..0de62e79e 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -139,7 +139,7 @@ def test_decode_and_verify_with_expired_token_raises_error(self, mocker): ) expected = ( - 'The provided App Check token signature has expired.') + 'The provided App Check token has expired.') assert str(excinfo.value) == expected def test_decode_and_verify_with_invalid_signature_raises_error(self, mocker): @@ -153,7 +153,7 @@ def test_decode_and_verify_with_invalid_signature_raises_error(self, mocker): ) expected = ( - 'The provided App Check token signature cannot be verified.') + 'The provided App Check token has invalid signature..') assert str(excinfo.value) == expected def test_decode_and_verify_with_invalid_aud_raises_error(self, mocker): From 33f93e5ad71e135ba23c41afade3d7db71dd6d32 Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 28 Sep 2022 15:42:14 -0400 Subject: [PATCH 26/28] Make jwks_client a class property --- firebase_admin/app_check.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 2625a622f..48ad619e0 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -47,6 +47,7 @@ class _AppCheckService: _JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks' _project_id = None _scoped_project_id = None + _jwks_client = None def __init__(self, app): # Validate and store the project_id to validate the JWT claims @@ -57,6 +58,8 @@ def __init__(self, app): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') self._scoped_project_id = 'projects/' + app.project_id + # 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) def verify_token(self, token: str) -> Dict[str, Any]: @@ -66,9 +69,7 @@ def verify_token(self, token: str) -> Dict[str, Any]: # Obtain the Firebase App Check Public Keys # Note: It is not recommended to hard code these keys as they rotate, # but you should cache them for up to 6 hours. - # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours). - jwks_client = PyJWKClient(self._JWKS_URL, lifespan=21600) - signing_key = jwks_client.get_signing_key_from_jwt(token) + signing_key = self._jwks_client.get_signing_key_from_jwt(token) self._has_valid_token_headers(jwt.get_unverified_header(token)) verified_claims = self._decode_and_verify(token, signing_key.key) From 532120361b5045cbbb3e1ef24df6f5ac33d654eb Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Wed, 28 Sep 2022 18:10:39 -0400 Subject: [PATCH 27/28] Add validation for the subject in the JWT payload --- firebase_admin/app_check.py | 3 +++ tests/test_app_check.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index 48ad619e0..cb9286d80 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -127,6 +127,9 @@ def _decode_and_verify(self, token: str, signing_key: str): raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.') if not payload.get('iss').startswith(self._APP_CHECK_ISSUER): raise ValueError('Token does not contain the correct "iss" (issuer).') + _Validators.check_string( + 'The provided App Check token "sub" (subject) claim', + payload.get('sub')) return payload diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 0de62e79e..017845388 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -186,6 +186,41 @@ def test_decode_and_verify_with_invalid_iss_raises_error(self, mocker): f'Expected claim to include {ISSUER}') assert str(excinfo.value) == expected + def test_decode_and_verify_with_none_sub_raises_error(self, mocker): + jwt_with_none_sub = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_none_sub['sub'] = None + mocker.patch("jwt.decode", return_value=jwt_with_none_sub) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token "sub" (subject) claim ' + f'"{None}" must be a non-empty string.') + assert str(excinfo.value) == expected + + def test_decode_and_verify_with_non_string_sub_raises_error(self, mocker): + sub_number = 1234 + jwt_with_none_sub = JWT_PAYLOAD_SAMPLE.copy() + jwt_with_none_sub['sub'] = sub_number + mocker.patch("jwt.decode", return_value=jwt_with_none_sub) + app = firebase_admin.get_app() + app_check_service = app_check._get_app_check_service(app) + with pytest.raises(ValueError) as excinfo: + app_check_service._decode_and_verify( + token="1232132", + signing_key=signing_key, + ) + + expected = ( + 'The provided App Check token "sub" (subject) claim ' + f'"{sub_number}" must be a string.') + assert str(excinfo.value) == expected + def test_verify_token(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)) From 77eb730e5ebd9659e22bddf8c7e32d75b001a92f Mon Sep 17 00:00:00 2001 From: dwyfrequency Date: Thu, 29 Sep 2022 12:59:38 -0400 Subject: [PATCH 28/28] Update docs and error message strings --- firebase_admin/app_check.py | 18 ++++++++++-------- tests/test_app_check.py | 8 ++++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/firebase_admin/app_check.py b/firebase_admin/app_check.py index cb9286d80..91b0c4c31 100644 --- a/firebase_admin/app_check.py +++ b/firebase_admin/app_check.py @@ -33,10 +33,11 @@ def verify_token(token: str, app=None) -> Dict[str, Any]: app: An App instance (optional). Returns: - Dict[str, Any]: A token's decoded claims if the App Check token + Dict[str, Any]: The token's decoded claims. Raises: - ValueError: If ``project_id``, headers, or the decoded token payload is invalid. + ValueError: If the app's ``project_id`` is invalid or unspecified, + or if the token's headers or payload are invalid. """ return _get_app_check_service(app).verify_token(token) @@ -54,8 +55,9 @@ def __init__(self, app): self._project_id = app.project_id if not self._project_id: raise ValueError( - 'Project ID is required to access App Check service. Either set the ' - 'projectId option, or use service account credentials. Alternatively, set the ' + 'A project ID must be specified to access the App Check ' + 'service. Either set the projectId option, use service ' + 'account credentials, or set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') self._scoped_project_id = 'projects/' + app.project_id # Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours). @@ -85,7 +87,7 @@ def _has_valid_token_headers(self, headers: Any) -> None: algorithm = headers.get('alg') if algorithm != 'RS256': raise ValueError( - 'The provided App Check token has an incorrect algorithm. ' + 'The provided App Check token has an incorrect alg header. ' f'Expected RS256 but got {algorithm}.' ) @@ -101,16 +103,16 @@ def _decode_and_verify(self, token: str, signing_key: str): ) except InvalidSignatureError: raise ValueError( - 'The provided App Check token has invalid signature..' + 'The provided App Check token has an invalid signature.' ) except InvalidAudienceError: raise ValueError( - 'The provided App Check token has incorrect "aud" (audience) claim.' + 'The provided App Check token has an incorrect "aud" (audience) claim. ' f'Expected payload to include {self._scoped_project_id}.' ) except InvalidIssuerError: raise ValueError( - 'The provided App Check token has incorrect "iss" (issuer) claim.' + 'The provided App Check token has an incorrect "iss" (issuer) claim. ' f'Expected claim to include {self._APP_CHECK_ISSUER}' ) except ExpiredSignatureError: diff --git a/tests/test_app_check.py b/tests/test_app_check.py index 017845388..168d0a972 100644 --- a/tests/test_app_check.py +++ b/tests/test_app_check.py @@ -98,7 +98,7 @@ def test_has_valid_token_headers_with_incorrect_algorithm_raises_error(self): with pytest.raises(ValueError) as excinfo: app_check_service._has_valid_token_headers(headers=headers) - expected = ('The provided App Check token has an incorrect algorithm. ' + expected = ('The provided App Check token has an incorrect alg header. ' 'Expected RS256 but got HS256.') assert str(excinfo.value) == expected @@ -153,7 +153,7 @@ def test_decode_and_verify_with_invalid_signature_raises_error(self, mocker): ) expected = ( - 'The provided App Check token has invalid signature..') + 'The provided App Check token has an invalid signature.') assert str(excinfo.value) == expected def test_decode_and_verify_with_invalid_aud_raises_error(self, mocker): @@ -167,7 +167,7 @@ def test_decode_and_verify_with_invalid_aud_raises_error(self, mocker): ) expected = ( - 'The provided App Check token has incorrect "aud" (audience) claim.' + 'The provided App Check token has an incorrect "aud" (audience) claim. ' f'Expected payload to include {SCOPED_PROJECT_ID}.') assert str(excinfo.value) == expected @@ -182,7 +182,7 @@ def test_decode_and_verify_with_invalid_iss_raises_error(self, mocker): ) expected = ( - 'The provided App Check token has incorrect "iss" (issuer) claim.' + 'The provided App Check token has an incorrect "iss" (issuer) claim. ' f'Expected claim to include {ISSUER}') assert str(excinfo.value) == expected