Skip to content

Commit 18eb9aa

Browse files
authored
Session Management API (#147)
* Moved token generation/validation code to new helper module * Basic session cookie support (without tests) * Separated token generation and verification into two classes * Added unit tests for session management * Fixing a lint error * Added integration tests * Handling article in error messages * Fixed a lint error * Updated changelog
1 parent a9300ef commit 18eb9aa

File tree

6 files changed

+589
-242
lines changed

6 files changed

+589
-242
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# Unreleased
22

3+
- [added] A new `create_session_cookie()` method for creating a long-lived
4+
session cookie given a valid ID token.
5+
- [added] A new `verify_session_cookie()` method for verifying a given
6+
cookie string is valid.
37
- [added] Added the `mutable_content` optional field to the `messaging.Aps`
48
type.
59
- [added] Added support for specifying arbitrary custom key-value

firebase_admin/_token_gen.py

Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
# Copyright 2018 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 token minting and validation sub module."""
16+
17+
import datetime
18+
import time
19+
20+
import requests
21+
import six
22+
from google.auth import jwt
23+
from google.auth import transport
24+
import google.oauth2.id_token
25+
26+
from firebase_admin import credentials
27+
28+
# Provided for overriding during tests.
29+
_request = transport.requests.Request()
30+
31+
# ID token constants
32+
ID_TOKEN_ISSUER_PREFIX = 'https://securetoken.google.com/'
33+
ID_TOKEN_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/'
34+
35+
36+
# Session cookie constants
37+
COOKIE_ISSUER_PREFIX = 'https://session.firebase.google.com/'
38+
COOKIE_CERT_URI = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys'
39+
MIN_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(minutes=5).total_seconds()
40+
MAX_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(days=14).total_seconds()
41+
42+
# Custom token constants
43+
MAX_TOKEN_LIFETIME_SECONDS = datetime.timedelta(hours=1).total_seconds()
44+
FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.'
45+
'identity.identitytoolkit.v1.IdentityToolkit')
46+
RESERVED_CLAIMS = set([
47+
'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash',
48+
'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub'
49+
])
50+
51+
# Error codes
52+
COOKIE_CREATE_ERROR = 'COOKIE_CREATE_ERROR'
53+
54+
55+
class ApiCallError(Exception):
56+
"""Represents an Exception encountered while invoking the ID toolkit API."""
57+
58+
def __init__(self, code, message, error=None):
59+
Exception.__init__(self, message)
60+
self.code = code
61+
self.detail = error
62+
63+
64+
class TokenGenerator(object):
65+
"""Generates custom tokens and session cookies."""
66+
67+
def __init__(self, app, client):
68+
self._app = app
69+
self._client = client
70+
71+
def create_custom_token(self, uid, developer_claims=None):
72+
"""Builds and signs a Firebase custom auth token."""
73+
if not isinstance(self._app.credential, credentials.Certificate):
74+
raise ValueError(
75+
'Must initialize Firebase App with a certificate credential '
76+
'to call create_custom_token().')
77+
78+
if developer_claims is not None:
79+
if not isinstance(developer_claims, dict):
80+
raise ValueError('developer_claims must be a dictionary')
81+
82+
disallowed_keys = set(developer_claims.keys()) & RESERVED_CLAIMS
83+
if disallowed_keys:
84+
if len(disallowed_keys) > 1:
85+
error_message = ('Developer claims {0} are reserved and '
86+
'cannot be specified.'.format(
87+
', '.join(disallowed_keys)))
88+
else:
89+
error_message = ('Developer claim {0} is reserved and '
90+
'cannot be specified.'.format(
91+
', '.join(disallowed_keys)))
92+
raise ValueError(error_message)
93+
94+
if not uid or not isinstance(uid, six.string_types) or len(uid) > 128:
95+
raise ValueError('uid must be a string between 1 and 128 characters.')
96+
97+
now = int(time.time())
98+
payload = {
99+
'iss': self._app.credential.service_account_email,
100+
'sub': self._app.credential.service_account_email,
101+
'aud': FIREBASE_AUDIENCE,
102+
'uid': uid,
103+
'iat': now,
104+
'exp': now + MAX_TOKEN_LIFETIME_SECONDS,
105+
}
106+
107+
if developer_claims is not None:
108+
payload['claims'] = developer_claims
109+
return jwt.encode(self._app.credential.signer, payload)
110+
111+
def create_session_cookie(self, id_token, expires_in):
112+
"""Creates a session cookie from the provided ID token."""
113+
id_token = id_token.decode('utf-8') if isinstance(id_token, six.binary_type) else id_token
114+
if not isinstance(id_token, six.text_type) or not id_token:
115+
raise ValueError(
116+
'Illegal ID token provided: {0}. ID token must be a non-empty '
117+
'string.'.format(id_token))
118+
119+
if isinstance(expires_in, datetime.timedelta):
120+
expires_in = int(expires_in.total_seconds())
121+
if isinstance(expires_in, bool) or not isinstance(expires_in, int):
122+
raise ValueError('Illegal expiry duration: {0}.'.format(expires_in))
123+
if expires_in < MIN_SESSION_COOKIE_DURATION_SECONDS:
124+
raise ValueError('Illegal expiry duration: {0}. Duration must be at least {1} '
125+
'seconds.'.format(expires_in, MIN_SESSION_COOKIE_DURATION_SECONDS))
126+
if expires_in > MAX_SESSION_COOKIE_DURATION_SECONDS:
127+
raise ValueError('Illegal expiry duration: {0}. Duration must be at most {1} '
128+
'seconds.'.format(expires_in, MAX_SESSION_COOKIE_DURATION_SECONDS))
129+
130+
payload = {
131+
'idToken': id_token,
132+
'validDuration': expires_in,
133+
}
134+
try:
135+
response = self._client.request('post', 'createSessionCookie', json=payload)
136+
except requests.exceptions.RequestException as error:
137+
self._handle_http_error(COOKIE_CREATE_ERROR, 'Failed to create session cookie', error)
138+
else:
139+
if not response or not response.get('sessionCookie'):
140+
raise ApiCallError(COOKIE_CREATE_ERROR, 'Failed to create session cookie.')
141+
return response.get('sessionCookie')
142+
143+
def _handle_http_error(self, code, msg, error):
144+
if error.response is not None:
145+
msg += '\nServer response: {0}'.format(error.response.content.decode())
146+
else:
147+
msg += '\nReason: {0}'.format(error)
148+
raise ApiCallError(code, msg, error)
149+
150+
151+
class TokenVerifier(object):
152+
"""Verifies ID tokens and session cookies."""
153+
154+
def __init__(self, app):
155+
self._id_token_verifier = _JWTVerifier(
156+
project_id=app.project_id, short_name='ID token',
157+
operation='verify_id_token()',
158+
doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens',
159+
cert_url=ID_TOKEN_CERT_URI, issuer=ID_TOKEN_ISSUER_PREFIX)
160+
self._cookie_verifier = _JWTVerifier(
161+
project_id=app.project_id, short_name='session cookie',
162+
operation='verify_session_cookie()',
163+
doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens',
164+
cert_url=COOKIE_CERT_URI, issuer=COOKIE_ISSUER_PREFIX)
165+
166+
def verify_id_token(self, id_token):
167+
return self._id_token_verifier.verify(id_token)
168+
169+
def verify_session_cookie(self, cookie):
170+
return self._cookie_verifier.verify(cookie)
171+
172+
173+
class _JWTVerifier(object):
174+
"""Verifies Firebase JWTs (ID tokens or session cookies)."""
175+
176+
def __init__(self, **kwargs):
177+
self.project_id = kwargs.pop('project_id')
178+
self.short_name = kwargs.pop('short_name')
179+
self.operation = kwargs.pop('operation')
180+
self.url = kwargs.pop('doc_url')
181+
self.cert_url = kwargs.pop('cert_url')
182+
self.issuer = kwargs.pop('issuer')
183+
if self.short_name[0].lower() in 'aeiou':
184+
self.articled_short_name = 'an {0}'.format(self.short_name)
185+
else:
186+
self.articled_short_name = 'a {0}'.format(self.short_name)
187+
188+
def verify(self, token):
189+
"""Verifies the signature and data for the provided JWT."""
190+
token = token.encode('utf-8') if isinstance(token, six.text_type) else token
191+
if not isinstance(token, six.binary_type) or not token:
192+
raise ValueError(
193+
'Illegal {0} provided: {1}. {0} must be a non-empty '
194+
'string.'.format(self.short_name, token))
195+
196+
if not self.project_id:
197+
raise ValueError(
198+
'Failed to ascertain project ID from the credential or the environment. Project '
199+
'ID is required to call {0}. Initialize the app with a credentials.Certificate '
200+
'or set your Firebase project ID as an app option. Alternatively set the '
201+
'GCLOUD_PROJECT environment variable.'.format(self.operation))
202+
203+
header = jwt.decode_header(token)
204+
payload = jwt.decode(token, verify=False)
205+
issuer = payload.get('iss')
206+
audience = payload.get('aud')
207+
subject = payload.get('sub')
208+
expected_issuer = self.issuer + self.project_id
209+
210+
project_id_match_msg = (
211+
'Make sure the {0} comes from the same Firebase project as the service account used '
212+
'to authenticate this SDK.'.format(self.short_name))
213+
verify_id_token_msg = (
214+
'See {0} for details on how to retrieve {1}.'.format(self.url, self.short_name))
215+
216+
error_message = None
217+
if not header.get('kid'):
218+
if audience == FIREBASE_AUDIENCE:
219+
error_message = (
220+
'{0} expects {1}, but was given a custom '
221+
'token.'.format(self.operation, self.articled_short_name))
222+
elif header.get('alg') == 'HS256' and payload.get(
223+
'v') is 0 and 'uid' in payload.get('d', {}):
224+
error_message = (
225+
'{0} expects {1}, but was given a legacy custom '
226+
'token.'.format(self.operation, self.articled_short_name))
227+
else:
228+
error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name)
229+
elif header.get('alg') != 'RS256':
230+
error_message = (
231+
'Firebase {0} has incorrect algorithm. Expected "RS256" but got '
232+
'"{1}". {2}'.format(self.short_name, header.get('alg'), verify_id_token_msg))
233+
elif audience != self.project_id:
234+
error_message = (
235+
'Firebase {0} has incorrect "aud" (audience) claim. Expected "{1}" but '
236+
'got "{2}". {3} {4}'.format(self.short_name, self.project_id, audience,
237+
project_id_match_msg, verify_id_token_msg))
238+
elif issuer != expected_issuer:
239+
error_message = (
240+
'Firebase {0} has incorrect "iss" (issuer) claim. Expected "{1}" but '
241+
'got "{2}". {3} {4}'.format(self.short_name, expected_issuer, issuer,
242+
project_id_match_msg, verify_id_token_msg))
243+
elif subject is None or not isinstance(subject, six.string_types):
244+
error_message = (
245+
'Firebase {0} has no "sub" (subject) claim. '
246+
'{1}'.format(self.short_name, verify_id_token_msg))
247+
elif not subject:
248+
error_message = (
249+
'Firebase {0} has an empty string "sub" (subject) claim. '
250+
'{1}'.format(self.short_name, verify_id_token_msg))
251+
elif len(subject) > 128:
252+
error_message = (
253+
'Firebase {0} has a "sub" (subject) claim longer than 128 characters. '
254+
'{1}'.format(self.short_name, verify_id_token_msg))
255+
256+
if error_message:
257+
raise ValueError(error_message)
258+
259+
verified_claims = google.oauth2.id_token.verify_token(
260+
token,
261+
request=_request,
262+
audience=self.project_id,
263+
certs_url=self.cert_url)
264+
verified_claims['uid'] = verified_claims['sub']
265+
return verified_claims

firebase_admin/_user_mgt.py

Lines changed: 7 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,10 @@
1717
import json
1818
import re
1919

20-
from google.auth import transport
2120
import requests
2221
import six
2322
from six.moves import urllib
2423

25-
import firebase_admin
26-
2724

2825
INTERNAL_ERROR = 'INTERNAL_ERROR'
2926
USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND_ERROR'
@@ -32,8 +29,6 @@
3229
USER_DELETE_ERROR = 'USER_DELETE_ERROR'
3330
USER_DOWNLOAD_ERROR = 'LIST_USERS_ERROR'
3431

35-
ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/'
36-
3732
MAX_LIST_USERS_RESULTS = 1000
3833
MAX_CLAIMS_PAYLOAD_SIZE = 1000
3934
RESERVED_CLAIMS = set([
@@ -225,12 +220,8 @@ class UserManager(object):
225220
'photoUrl' : 'PHOTO_URL'
226221
}
227222

228-
def __init__(self, app):
229-
g_credential = app.credential.get_credential()
230-
session = transport.requests.AuthorizedSession(g_credential)
231-
version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__)
232-
session.headers.update({'X-Client-Version': version_header})
233-
self._session = session
223+
def __init__(self, client):
224+
self._client = client
234225

235226
def get_user(self, **kwargs):
236227
"""Gets the user data corresponding to the provided key."""
@@ -250,7 +241,7 @@ def get_user(self, **kwargs):
250241
raise ValueError('Unsupported keyword arguments: {0}.'.format(kwargs))
251242

252243
try:
253-
response = self._request('post', 'getAccountInfo', json=payload)
244+
response = self._client.request('post', 'getAccountInfo', json=payload)
254245
except requests.exceptions.RequestException as error:
255246
msg = 'Failed to get user by {0}: {1}.'.format(key_type, key)
256247
self._handle_http_error(INTERNAL_ERROR, msg, error)
@@ -277,7 +268,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS):
277268
if page_token:
278269
payload['nextPageToken'] = page_token
279270
try:
280-
return self._request('post', 'downloadAccount', json=payload)
271+
return self._client.request('post', 'downloadAccount', json=payload)
281272
except requests.exceptions.RequestException as error:
282273
self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error)
283274

@@ -286,7 +277,7 @@ def create_user(self, **kwargs):
286277
payload = self._init_payload('create_user', UserManager._CREATE_USER_FIELDS, **kwargs)
287278
self._validate(payload, self._VALIDATORS, 'create user')
288279
try:
289-
response = self._request('post', 'signupNewUser', json=payload)
280+
response = self._client.request('post', 'signupNewUser', json=payload)
290281
except requests.exceptions.RequestException as error:
291282
self._handle_http_error(USER_CREATE_ERROR, 'Failed to create new user.', error)
292283
else:
@@ -319,7 +310,7 @@ def update_user(self, uid, **kwargs):
319310

320311
self._validate(payload, self._VALIDATORS, 'update user')
321312
try:
322-
response = self._request('post', 'setAccountInfo', json=payload)
313+
response = self._client.request('post', 'setAccountInfo', json=payload)
323314
except requests.exceptions.RequestException as error:
324315
self._handle_http_error(
325316
USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid), error)
@@ -332,7 +323,7 @@ def delete_user(self, uid):
332323
"""Deletes the user identified by the specified user ID."""
333324
_Validator.validate_uid(uid)
334325
try:
335-
response = self._request('post', 'deleteAccount', json={'localId' : uid})
326+
response = self._client.request('post', 'deleteAccount', json={'localId' : uid})
336327
except requests.exceptions.RequestException as error:
337328
self._handle_http_error(
338329
USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid), error)
@@ -365,25 +356,6 @@ def _validate(self, properties, validators, operation):
365356
raise ValueError('Unsupported property: "{0}" in {1} call.'.format(key, operation))
366357
validator(value)
367358

368-
def _request(self, method, urlpath, **kwargs):
369-
"""Makes an HTTP call using the Python requests library.
370-
371-
Refer to http://docs.python-requests.org/en/master/api/ for more information on supported
372-
options and features.
373-
374-
Args:
375-
method: HTTP method name as a string (e.g. get, post).
376-
urlpath: URL path of the remote endpoint. This will be appended to the server's base URL.
377-
kwargs: An additional set of keyword arguments to be passed into requests API
378-
(e.g. json, params).
379-
380-
Returns:
381-
dict: The parsed JSON response.
382-
"""
383-
resp = self._session.request(method, ID_TOOLKIT_URL + urlpath, **kwargs)
384-
resp.raise_for_status()
385-
return resp.json()
386-
387359

388360
class UserIterator(object):
389361
"""An iterator that allows iterating over user accounts, one at a time.

0 commit comments

Comments
 (0)