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 5 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/
116 changes: 116 additions & 0 deletions firebase_admin/app_check.py
@@ -0,0 +1,116 @@
# 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."""

# ASK(lahiru) Do I need to add these imports to the requirements file?
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
from typing import Any, Dict, List
import jwt
from jwt import PyJWKClient, DecodeError
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)

# 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]:
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]: 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'
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved

def __init__(self, app):
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
# 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.')
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
# 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]:
"""Verifies a Firebase App Check token."""
if token is None:
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
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.
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
url = f'{cls._APP_CHECK_GCP_API_URL}{cls._APP_CHECK_BETA_JWKS_RESOURCE}'
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
jwks_client = PyJWKClient(url)
signing_key = jwks_client.get_signing_key_from_jwt(token)

# Getting error "No value for argument 'header' in unbound
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
# 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")

# The token's subject will be the app ID, you may optionally filter against
# an allow list
Copy link
Member

Choose a reason for hiding this comment

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

I think we can remove you may optionally filter against an allow list part. It was a comment in the code sample to help developers, but we don't do the filtering in the SDK.

return payload.get('sub')
Copy link
Member

Choose a reason for hiding this comment

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

We need to return the full decoded token here (a dictionary with all the claims). Additionally we need to add app_id to the payload.

verified_claims['app_id'] = verified_claims['sub']
return verified_claims


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")
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
# 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")
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved

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(
token,
signing_key,
algorithms
)
except DecodeError:
ValueError('Unable to decode the token')
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
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"]
)

# within the aud property, there will be an array of project id & number
if len(payload.get('aud')) <= 1:
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
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'):
dwyfrequency marked this conversation as resolved.
Show resolved Hide resolved
raise ValueError('Token does not contain the correct Issuer.')

return payload