-
Notifications
You must be signed in to change notification settings - Fork 338
feat: Add function to verify an App Check token #642
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
Merged
Merged
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 cf60bb9
Remove unnecessary notes
dwyfrequency 3c4e191
Fix some lint issues
dwyfrequency 4e84ce3
Fix style guide issues
dwyfrequency dc9cbfd
Update code structure
dwyfrequency eb1725d
Add pyjwt version to requirments & update code based on comments
dwyfrequency c5a25c2
Add app_id key for verified claims dict
dwyfrequency aa98697
Add initial test
dwyfrequency 7e2259c
Add tests for token headers
dwyfrequency 0978778
Add decode token test and notes
dwyfrequency 85145e1
Updating requirements for mocks and note in test
dwyfrequency 41f93ea
Add verify token test and decode test
dwyfrequency 5436d12
Update pytest-mock requirements
dwyfrequency 6a4815a
Add tests for error messages
dwyfrequency a592256
Update requirements for lifespan cache
dwyfrequency 5b94963
update error message and test
dwyfrequency 89f29d3
Explicitly pass audience to jwt.decode and update key retrieval
dwyfrequency a5290b5
Mock signing key
dwyfrequency c46b60b
Update aud check logic and tests
dwyfrequency e9148b7
Remove print statement
dwyfrequency b732aa6
Update method doc string
dwyfrequency 46f22f6
Add test for decode_token error
dwyfrequency 2b6c7e7
Catch additional errors and add custom error messages for them
dwyfrequency e08f355
Mock out all the common errors
dwyfrequency 73edeb3
Updating error messages and tests per comments
dwyfrequency 33f93e5
Make jwks_client a class property
dwyfrequency 5321203
Add validation for the subject in the JWT payload
dwyfrequency 77eb730
Update docs and error message strings
dwyfrequency fe30abb
Merge branch 'master' into jd-verifyToken
dwyfrequency File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -12,3 +12,4 @@ apikey.txt | |
htmlcov/ | ||
.pytest_cache/ | ||
.vscode/ | ||
.venv/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]: | ||
"""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).') | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can we also validate the example from Node.js:
|
||
_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)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.