Skip to content

Commit 5b7ac05

Browse files
authored
feat: Add function to verify an App Check token (#642)
* Sketch out initial private methods and service * Remove unnecessary notes * Fix some lint issues * Fix style guide issues * Update code structure * Add pyjwt version to requirments & update code based on comments * Add app_id key for verified claims dict * Add initial test * Add tests for token headers * Add decode token test and notes * Updating requirements for mocks and note in test * Add verify token test and decode test * Update pytest-mock requirements * Add tests for error messages * Update requirements for lifespan cache * update error message and test * Explicitly pass audience to jwt.decode and update key retrieval * Mock signing key * Update aud check logic and tests * Remove print statement * Update method doc string * Add test for decode_token error * Catch additional errors and add custom error messages for them * Mock out all the common errors * Updating error messages and tests per comments * Make jwks_client a class property * Add validation for the subject in the JWT payload * Update docs and error message strings
1 parent 0dd6303 commit 5b7ac05

File tree

4 files changed

+428
-0
lines changed

4 files changed

+428
-0
lines changed

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ apikey.txt
1212
htmlcov/
1313
.pytest_cache/
1414
.vscode/
15+
.venv/

firebase_admin/app_check.py

+150
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Copyright 2022 Google Inc.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Firebase App Check module."""
16+
17+
from typing import Any, Dict
18+
import jwt
19+
from jwt import PyJWKClient, ExpiredSignatureError, InvalidTokenError
20+
from jwt import InvalidAudienceError, InvalidIssuerError, InvalidSignatureError
21+
from firebase_admin import _utils
22+
23+
_APP_CHECK_ATTRIBUTE = '_app_check'
24+
25+
def _get_app_check_service(app) -> Any:
26+
return _utils.get_app_service(app, _APP_CHECK_ATTRIBUTE, _AppCheckService)
27+
28+
def verify_token(token: str, app=None) -> Dict[str, Any]:
29+
"""Verifies a Firebase App Check token.
30+
31+
Args:
32+
token: A token from App Check.
33+
app: An App instance (optional).
34+
35+
Returns:
36+
Dict[str, Any]: The token's decoded claims.
37+
38+
Raises:
39+
ValueError: If the app's ``project_id`` is invalid or unspecified,
40+
or if the token's headers or payload are invalid.
41+
"""
42+
return _get_app_check_service(app).verify_token(token)
43+
44+
class _AppCheckService:
45+
"""Service class that implements Firebase App Check functionality."""
46+
47+
_APP_CHECK_ISSUER = 'https://firebaseappcheck.googleapis.com/'
48+
_JWKS_URL = 'https://firebaseappcheck.googleapis.com/v1/jwks'
49+
_project_id = None
50+
_scoped_project_id = None
51+
_jwks_client = None
52+
53+
def __init__(self, app):
54+
# Validate and store the project_id to validate the JWT claims
55+
self._project_id = app.project_id
56+
if not self._project_id:
57+
raise ValueError(
58+
'A project ID must be specified to access the App Check '
59+
'service. Either set the projectId option, use service '
60+
'account credentials, or set the '
61+
'GOOGLE_CLOUD_PROJECT environment variable.')
62+
self._scoped_project_id = 'projects/' + app.project_id
63+
# Default lifespan is 300 seconds (5 minutes) so we change it to 21600 seconds (6 hours).
64+
self._jwks_client = PyJWKClient(self._JWKS_URL, lifespan=21600)
65+
66+
67+
def verify_token(self, token: str) -> Dict[str, Any]:
68+
"""Verifies a Firebase App Check token."""
69+
_Validators.check_string("app check token", token)
70+
71+
# Obtain the Firebase App Check Public Keys
72+
# Note: It is not recommended to hard code these keys as they rotate,
73+
# but you should cache them for up to 6 hours.
74+
signing_key = self._jwks_client.get_signing_key_from_jwt(token)
75+
self._has_valid_token_headers(jwt.get_unverified_header(token))
76+
verified_claims = self._decode_and_verify(token, signing_key.key)
77+
78+
verified_claims['app_id'] = verified_claims.get('sub')
79+
return verified_claims
80+
81+
def _has_valid_token_headers(self, headers: Any) -> None:
82+
"""Checks whether the token has valid headers for App Check."""
83+
# Ensure the token's header has type JWT
84+
if headers.get('typ') != 'JWT':
85+
raise ValueError("The provided App Check token has an incorrect type header")
86+
# Ensure the token's header uses the algorithm RS256
87+
algorithm = headers.get('alg')
88+
if algorithm != 'RS256':
89+
raise ValueError(
90+
'The provided App Check token has an incorrect alg header. '
91+
f'Expected RS256 but got {algorithm}.'
92+
)
93+
94+
def _decode_and_verify(self, token: str, signing_key: str):
95+
"""Decodes and verifies the token from App Check."""
96+
payload = {}
97+
try:
98+
payload = jwt.decode(
99+
token,
100+
signing_key,
101+
algorithms=["RS256"],
102+
audience=self._scoped_project_id
103+
)
104+
except InvalidSignatureError:
105+
raise ValueError(
106+
'The provided App Check token has an invalid signature.'
107+
)
108+
except InvalidAudienceError:
109+
raise ValueError(
110+
'The provided App Check token has an incorrect "aud" (audience) claim. '
111+
f'Expected payload to include {self._scoped_project_id}.'
112+
)
113+
except InvalidIssuerError:
114+
raise ValueError(
115+
'The provided App Check token has an incorrect "iss" (issuer) claim. '
116+
f'Expected claim to include {self._APP_CHECK_ISSUER}'
117+
)
118+
except ExpiredSignatureError:
119+
raise ValueError(
120+
'The provided App Check token has expired.'
121+
)
122+
except InvalidTokenError as exception:
123+
raise ValueError(
124+
f'Decoding App Check token failed. Error: {exception}'
125+
)
126+
127+
audience = payload.get('aud')
128+
if not isinstance(audience, list) or self._scoped_project_id not in audience:
129+
raise ValueError('Firebase App Check token has incorrect "aud" (audience) claim.')
130+
if not payload.get('iss').startswith(self._APP_CHECK_ISSUER):
131+
raise ValueError('Token does not contain the correct "iss" (issuer).')
132+
_Validators.check_string(
133+
'The provided App Check token "sub" (subject) claim',
134+
payload.get('sub'))
135+
136+
return payload
137+
138+
class _Validators:
139+
"""A collection of data validation utilities.
140+
141+
Methods provided in this class raise ``ValueErrors`` if any validations fail.
142+
"""
143+
144+
@classmethod
145+
def check_string(cls, label: str, value: Any):
146+
"""Checks if the given value is a string."""
147+
if value is None:
148+
raise ValueError('{0} "{1}" must be a non-empty string.'.format(label, value))
149+
if not isinstance(value, str):
150+
raise ValueError('{0} "{1}" must be a string.'.format(label, value))

requirements.txt

+2
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ pytest >= 6.2.0
44
pytest-cov >= 2.4.0
55
pytest-localserver >= 0.4.1
66
pytest-asyncio >= 0.16.0
7+
pytest-mock >= 3.6.1
78

89
cachecontrol >= 0.12.6
910
google-api-core[grpc] >= 1.22.1, < 3.0.0dev; platform.python_implementation != 'PyPy'
1011
google-api-python-client >= 1.7.8
1112
google-cloud-firestore >= 2.1.0; platform.python_implementation != 'PyPy'
1213
google-cloud-storage >= 1.37.1
14+
pyjwt[crypto] >= 2.5.0

0 commit comments

Comments
 (0)