Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add function to verify an App Check token #642

Merged
merged 29 commits into from Sep 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
25d3842
Sketch out initial private methods and service
dwyfrequency Sep 6, 2022
cf60bb9
Remove unnecessary notes
dwyfrequency Sep 7, 2022
3c4e191
Fix some lint issues
dwyfrequency Sep 7, 2022
4e84ce3
Fix style guide issues
dwyfrequency Sep 7, 2022
dc9cbfd
Update code structure
dwyfrequency Sep 8, 2022
eb1725d
Add pyjwt version to requirments & update code based on comments
dwyfrequency Sep 9, 2022
c5a25c2
Add app_id key for verified claims dict
dwyfrequency Sep 9, 2022
aa98697
Add initial test
dwyfrequency Sep 12, 2022
7e2259c
Add tests for token headers
dwyfrequency Sep 14, 2022
0978778
Add decode token test and notes
dwyfrequency Sep 14, 2022
85145e1
Updating requirements for mocks and note in test
dwyfrequency Sep 14, 2022
41f93ea
Add verify token test and decode test
dwyfrequency Sep 16, 2022
5436d12
Update pytest-mock requirements
dwyfrequency Sep 16, 2022
6a4815a
Add tests for error messages
dwyfrequency Sep 19, 2022
a592256
Update requirements for lifespan cache
dwyfrequency Sep 20, 2022
5b94963
update error message and test
dwyfrequency Sep 20, 2022
89f29d3
Explicitly pass audience to jwt.decode and update key retrieval
dwyfrequency Sep 22, 2022
a5290b5
Mock signing key
dwyfrequency Sep 23, 2022
c46b60b
Update aud check logic and tests
dwyfrequency Sep 23, 2022
e9148b7
Remove print statement
dwyfrequency Sep 23, 2022
b732aa6
Update method doc string
dwyfrequency Sep 23, 2022
46f22f6
Add test for decode_token error
dwyfrequency Sep 23, 2022
2b6c7e7
Catch additional errors and add custom error messages for them
dwyfrequency Sep 27, 2022
e08f355
Mock out all the common errors
dwyfrequency Sep 27, 2022
73edeb3
Updating error messages and tests per comments
dwyfrequency Sep 28, 2022
33f93e5
Make jwks_client a class property
dwyfrequency Sep 28, 2022
5321203
Add validation for the subject in the JWT payload
dwyfrequency Sep 28, 2022
77eb730
Update docs and error message strings
dwyfrequency Sep 29, 2022
fe30abb
Merge branch 'master' into jd-verifyToken
dwyfrequency Sep 29, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Expand Up @@ -12,3 +12,4 @@ apikey.txt
htmlcov/
.pytest_cache/
.vscode/
.venv/
150 changes: 150 additions & 0 deletions firebase_admin/app_check.py
@@ -0,0 +1,150 @@
# 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.

"""Firebase App Check module."""

from typing import Any, Dict
import jwt
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError
from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError
from firebase_admin import _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]:
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
"""Verifies a Firebase App Check token.

Args:
token: A token from App Check.
app: An App instance (optional).

Returns:
Dict[str, Any]: The token's decoded claims.

Raises:
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)

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'
_project_id = None
_scoped_project_id = None
_jwks_client = None

def __init__(self, app):
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
# Validate and store the project_id to validate the JWT claims
self._project_id = app.project_id
if not self._project_id:
raise ValueError(
'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).
self._jwks_client = PyJWKClient(self._JWKS_URL, lifespan=21600)


def verify_token(self, token: str) -> Dict[str, Any]:
"""Verifies a Firebase App Check token."""
_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.
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
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)

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

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 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 = headers.get('alg')
if algorithm != 'RS256':
raise ValueError(
'The provided App Check token has an incorrect alg header. '
f'Expected RS256 but got {algorithm}.'
)

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=["RS256"],
audience=self._scoped_project_id
)
except InvalidSignatureError:
raise ValueError(
'The provided App Check token has an invalid signature.'
)
except InvalidAudienceError:
raise ValueError(
'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 an incorrect "iss" (issuer) claim. '
f'Expected claim to include {self._APP_CHECK_ISSUER}'
)
except ExpiredSignatureError:
raise ValueError(
'The provided App Check token has expired.'
)
except InvalidTokenError as exception:
raise ValueError(
f'Decoding App Check token failed. Error: {exception}'
)

audience = payload.get('aud')
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).')
Copy link
Member

Choose a reason for hiding this comment

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

Can we also validate the sub claim?

example from Node.js:

} else if (typeof payload.sub !== 'string') {
      errorMessage = 'The provided App Check token has no "sub" (subject) claim.';
    } else if (payload.sub === '') {
      errorMessage = 'The provided App Check token has an empty string "sub" (subject) claim.';
    }
    ```

_Validators.check_string(
'The provided App Check token "sub" (subject) claim',
payload.get('sub'))

return payload

class _Validators:
"""A collection of data validation utilities.

Methods provided in this class raise ``ValueErrors`` if any validations fail.
"""

@classmethod
def check_string(cls, label: str, value: Any):
"""Checks if the given value is a string."""
if value is None:
raise ValueError('{0} "{1}" must be a non-empty string.'.format(label, value))
if not isinstance(value, str):
raise ValueError('{0} "{1}" must be a string.'.format(label, value))
2 changes: 2 additions & 0 deletions requirements.txt
Expand Up @@ -4,9 +4,11 @@ pytest >= 6.2.0
pytest-cov >= 2.4.0
pytest-localserver >= 0.4.1
pytest-asyncio >= 0.16.0
pytest-mock >= 3.6.1

cachecontrol >= 0.12.6
google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != 'PyPy'
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.5.0