From 096138cd056d70a2b2dcc9bcf69a8ece97e3b107 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 29 Mar 2018 17:04:11 -0700 Subject: [PATCH 01/14] Moved token generation/validation code to new helper module --- firebase_admin/_token_gen.py | 188 +++++++++++++++++++++++++++++++++++ firebase_admin/auth.py | 188 +---------------------------------- tests/test_auth.py | 5 +- 3 files changed, 193 insertions(+), 188 deletions(-) create mode 100644 firebase_admin/_token_gen.py diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py new file mode 100644 index 000000000..d28fcd78d --- /dev/null +++ b/firebase_admin/_token_gen.py @@ -0,0 +1,188 @@ +"""Firebase token minting and validation sub module.""" +import time + +import six +from google.auth import jwt +from google.auth import transport +import google.oauth2.id_token + +from firebase_admin import credentials + +# Provided for overriding during tests. +_request = transport.requests.Request() + + +class TokenGenerator(object): + """Generates custom tokens, and validates ID tokens.""" + + FIREBASE_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' + 'securetoken@system.gserviceaccount.com') + + ISSUER_PREFIX = 'https://securetoken.google.com/' + + MAX_TOKEN_LIFETIME_SECONDS = 3600 # One Hour, in Seconds + FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.' + 'identity.identitytoolkit.v1.IdentityToolkit') + + # Key names we don't allow to appear in the developer_claims. + _RESERVED_CLAIMS_ = set([ + 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', + 'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub' + ]) + + + def __init__(self, app): + """Initializes FirebaseAuth from a FirebaseApp instance. + + Args: + app: A FirebaseApp instance. + """ + self._app = app + + def create_custom_token(self, uid, developer_claims=None): + """Builds and signs a FirebaseCustomAuthToken. + + Args: + uid: ID of the user for whom the token is created. + developer_claims: A dictionary of claims to be included in the token. + + Returns: + string: A token minted from the input parameters as a byte string. + + Raises: + ValueError: If input parameters are invalid. + """ + if not isinstance(self._app.credential, credentials.Certificate): + raise ValueError( + 'Must initialize Firebase App with a certificate credential ' + 'to call create_custom_token().') + + if developer_claims is not None: + if not isinstance(developer_claims, dict): + raise ValueError('developer_claims must be a dictionary') + + disallowed_keys = set(developer_claims.keys() + ) & self._RESERVED_CLAIMS_ + if disallowed_keys: + if len(disallowed_keys) > 1: + error_message = ('Developer claims {0} are reserved and ' + 'cannot be specified.'.format( + ', '.join(disallowed_keys))) + else: + error_message = ('Developer claim {0} is reserved and ' + 'cannot be specified.'.format( + ', '.join(disallowed_keys))) + raise ValueError(error_message) + + if not uid or not isinstance(uid, six.string_types) or len(uid) > 128: + raise ValueError('uid must be a string between 1 and 128 characters.') + + now = int(time.time()) + payload = { + 'iss': self._app.credential.service_account_email, + 'sub': self._app.credential.service_account_email, + 'aud': self.FIREBASE_AUDIENCE, + 'uid': uid, + 'iat': now, + 'exp': now + self.MAX_TOKEN_LIFETIME_SECONDS, + } + + if developer_claims is not None: + payload['claims'] = developer_claims + + return jwt.encode(self._app.credential.signer, payload) + + def verify_id_token(self, id_token): + """Verifies the signature and data for the provided JWT. + + Accepts a signed token string, verifies that is the current, and issued + to this project, and that it was correctly signed by Google. + + Args: + id_token: A string of the encoded JWT. + + Returns: + dict: A dictionary of key-value pairs parsed from the decoded JWT. + + Raises: + ValueError: The JWT was found to be invalid, or the app was not initialized with a + credentials.Certificate instance. + """ + if not id_token: + raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' + 'string.'.format(id_token)) + + if isinstance(id_token, six.text_type): + id_token = id_token.encode('ascii') + if not isinstance(id_token, six.binary_type): + raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' + 'string.'.format(id_token)) + + project_id = self._app.project_id + if not project_id: + raise ValueError('Failed to ascertain project ID from the credential or the ' + 'environment. Project ID is required to call verify_id_token(). ' + 'Initialize the app with a credentials.Certificate or set ' + 'your Firebase project ID as an app option. Alternatively ' + 'set the GCLOUD_PROJECT environment variable.') + + header = jwt.decode_header(id_token) + payload = jwt.decode(id_token, verify=False) + issuer = payload.get('iss') + audience = payload.get('aud') + subject = payload.get('sub') + expected_issuer = self.ISSUER_PREFIX + project_id + + project_id_match_msg = ('Make sure the ID token comes from the same' + ' Firebase project as the service account used' + ' to authenticate this SDK.') + verify_id_token_msg = ( + 'See https://firebase.google.com/docs/auth/admin/verify-id-tokens' + ' for details on how to retrieve an ID token.') + error_message = None + if not header.get('kid'): + if audience == self.FIREBASE_AUDIENCE: + error_message = ('verify_id_token() expects an ID token, but ' + 'was given a custom token.') + elif header.get('alg') == 'HS256' and payload.get( + 'v') is 0 and 'uid' in payload.get('d', {}): + error_message = ('verify_id_token() expects an ID token, but ' + 'was given a legacy custom token.') + else: + error_message = 'Firebase ID token has no "kid" claim.' + elif header.get('alg') != 'RS256': + error_message = ('Firebase ID token has incorrect algorithm. ' + 'Expected "RS256" but got "{0}". {1}'.format( + header.get('alg'), verify_id_token_msg)) + elif audience != project_id: + error_message = ( + 'Firebase ID token has incorrect "aud" (audience) claim. ' + 'Expected "{0}" but got "{1}". {2} {3}'.format( + project_id, audience, project_id_match_msg, + verify_id_token_msg)) + elif issuer != expected_issuer: + error_message = ('Firebase ID token has incorrect "iss" (issuer) ' + 'claim. Expected "{0}" but got "{1}". {2} {3}' + .format(expected_issuer, issuer, + project_id_match_msg, + verify_id_token_msg)) + elif subject is None or not isinstance(subject, six.string_types): + error_message = ('Firebase ID token has no "sub" (subject) ' + 'claim. ') + verify_id_token_msg + elif not subject: + error_message = ('Firebase ID token has an empty string "sub" ' + '(subject) claim. ') + verify_id_token_msg + elif len(subject) > 128: + error_message = ('Firebase ID token has a "sub" (subject) ' + 'claim longer than 128 ' + 'characters. ') + verify_id_token_msg + + if error_message: + raise ValueError(error_message) + + verified_claims = google.oauth2.id_token.verify_firebase_token( + id_token, + request=_request, + audience=project_id) + verified_claims['uid'] = verified_claims['sub'] + return verified_claims \ No newline at end of file diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index 9da0f10c4..b53463a6c 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -22,19 +22,11 @@ import json import time -from google.auth import jwt -from google.auth import transport -import google.oauth2.id_token -import six - -from firebase_admin import credentials +from firebase_admin import _token_gen from firebase_admin import _user_mgt from firebase_admin import _utils -# Provided for overriding during tests. -_request = transport.requests.Request() - _AUTH_ATTRIBUTE = '_auth' _ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED' @@ -664,186 +656,10 @@ def __init__(self, code, message, error=None): self.detail = error -class _TokenGenerator(object): - """Generates custom tokens, and validates ID tokens.""" - - FIREBASE_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' - 'securetoken@system.gserviceaccount.com') - - ISSUER_PREFIX = 'https://securetoken.google.com/' - - MAX_TOKEN_LIFETIME_SECONDS = 3600 # One Hour, in Seconds - FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.' - 'identity.identitytoolkit.v1.IdentityToolkit') - - # Key names we don't allow to appear in the developer_claims. - _RESERVED_CLAIMS_ = set([ - 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', - 'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub' - ]) - - - def __init__(self, app): - """Initializes FirebaseAuth from a FirebaseApp instance. - - Args: - app: A FirebaseApp instance. - """ - self._app = app - - def create_custom_token(self, uid, developer_claims=None): - """Builds and signs a FirebaseCustomAuthToken. - - Args: - uid: ID of the user for whom the token is created. - developer_claims: A dictionary of claims to be included in the token. - - Returns: - string: A token minted from the input parameters as a byte string. - - Raises: - ValueError: If input parameters are invalid. - """ - if not isinstance(self._app.credential, credentials.Certificate): - raise ValueError( - 'Must initialize Firebase App with a certificate credential ' - 'to call create_custom_token().') - - if developer_claims is not None: - if not isinstance(developer_claims, dict): - raise ValueError('developer_claims must be a dictionary') - - disallowed_keys = set(developer_claims.keys() - ) & self._RESERVED_CLAIMS_ - if disallowed_keys: - if len(disallowed_keys) > 1: - error_message = ('Developer claims {0} are reserved and ' - 'cannot be specified.'.format( - ', '.join(disallowed_keys))) - else: - error_message = ('Developer claim {0} is reserved and ' - 'cannot be specified.'.format( - ', '.join(disallowed_keys))) - raise ValueError(error_message) - - if not uid or not isinstance(uid, six.string_types) or len(uid) > 128: - raise ValueError('uid must be a string between 1 and 128 characters.') - - now = int(time.time()) - payload = { - 'iss': self._app.credential.service_account_email, - 'sub': self._app.credential.service_account_email, - 'aud': self.FIREBASE_AUDIENCE, - 'uid': uid, - 'iat': now, - 'exp': now + self.MAX_TOKEN_LIFETIME_SECONDS, - } - - if developer_claims is not None: - payload['claims'] = developer_claims - - return jwt.encode(self._app.credential.signer, payload) - - def verify_id_token(self, id_token): - """Verifies the signature and data for the provided JWT. - - Accepts a signed token string, verifies that is the current, and issued - to this project, and that it was correctly signed by Google. - - Args: - id_token: A string of the encoded JWT. - - Returns: - dict: A dictionary of key-value pairs parsed from the decoded JWT. - - Raises: - ValueError: The JWT was found to be invalid, or the app was not initialized with a - credentials.Certificate instance. - """ - if not id_token: - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) - - if isinstance(id_token, six.text_type): - id_token = id_token.encode('ascii') - if not isinstance(id_token, six.binary_type): - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) - - project_id = self._app.project_id - if not project_id: - raise ValueError('Failed to ascertain project ID from the credential or the ' - 'environment. Project ID is required to call verify_id_token(). ' - 'Initialize the app with a credentials.Certificate or set ' - 'your Firebase project ID as an app option. Alternatively ' - 'set the GCLOUD_PROJECT environment variable.') - - header = jwt.decode_header(id_token) - payload = jwt.decode(id_token, verify=False) - issuer = payload.get('iss') - audience = payload.get('aud') - subject = payload.get('sub') - expected_issuer = self.ISSUER_PREFIX + project_id - - project_id_match_msg = ('Make sure the ID token comes from the same' - ' Firebase project as the service account used' - ' to authenticate this SDK.') - verify_id_token_msg = ( - 'See https://firebase.google.com/docs/auth/admin/verify-id-tokens' - ' for details on how to retrieve an ID token.') - error_message = None - if not header.get('kid'): - if audience == self.FIREBASE_AUDIENCE: - error_message = ('verify_id_token() expects an ID token, but ' - 'was given a custom token.') - elif header.get('alg') == 'HS256' and payload.get( - 'v') is 0 and 'uid' in payload.get('d', {}): - error_message = ('verify_id_token() expects an ID token, but ' - 'was given a legacy custom token.') - else: - error_message = 'Firebase ID token has no "kid" claim.' - elif header.get('alg') != 'RS256': - error_message = ('Firebase ID token has incorrect algorithm. ' - 'Expected "RS256" but got "{0}". {1}'.format( - header.get('alg'), verify_id_token_msg)) - elif audience != project_id: - error_message = ( - 'Firebase ID token has incorrect "aud" (audience) claim. ' - 'Expected "{0}" but got "{1}". {2} {3}'.format( - project_id, audience, project_id_match_msg, - verify_id_token_msg)) - elif issuer != expected_issuer: - error_message = ('Firebase ID token has incorrect "iss" (issuer) ' - 'claim. Expected "{0}" but got "{1}". {2} {3}' - .format(expected_issuer, issuer, - project_id_match_msg, - verify_id_token_msg)) - elif subject is None or not isinstance(subject, six.string_types): - error_message = ('Firebase ID token has no "sub" (subject) ' - 'claim. ') + verify_id_token_msg - elif not subject: - error_message = ('Firebase ID token has an empty string "sub" ' - '(subject) claim. ') + verify_id_token_msg - elif len(subject) > 128: - error_message = ('Firebase ID token has a "sub" (subject) ' - 'claim longer than 128 ' - 'characters. ') + verify_id_token_msg - - if error_message: - raise ValueError(error_message) - - verified_claims = google.oauth2.id_token.verify_firebase_token( - id_token, - request=_request, - audience=project_id) - verified_claims['uid'] = verified_claims['sub'] - return verified_claims - - class _AuthService(object): def __init__(self, app): - self._token_generator = _TokenGenerator(app) + self._token_generator = _token_gen.TokenGenerator(app) self._user_manager = _user_mgt.UserManager(app) @property diff --git a/tests/test_auth.py b/tests/test_auth.py index 3f2fef1a8..4666907d3 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -27,6 +27,7 @@ import firebase_admin from firebase_admin import auth from firebase_admin import credentials +from firebase_admin import _token_gen from firebase_admin import _user_mgt from tests import testutils @@ -242,7 +243,7 @@ class TestVerifyIdToken(object): } def setup_method(self): - auth._request = testutils.MockRequest(200, MOCK_PUBLIC_CERTS) + _token_gen._request = testutils.MockRequest(200, MOCK_PUBLIC_CERTS) @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) def test_valid_token(self, authtest, id_token): @@ -321,7 +322,7 @@ def test_custom_token(self, authtest): authtest.verify_id_token(id_token) def test_certificate_request_failure(self, authtest): - auth._request = testutils.MockRequest(404, 'not found') + _token_gen._request = testutils.MockRequest(404, 'not found') with pytest.raises(exceptions.TransportError): authtest.verify_id_token(TEST_ID_TOKEN) From b1c4dba566ea324213915ac4bbcf97d927523486 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 29 Mar 2018 19:06:13 -0700 Subject: [PATCH 02/14] Basic session cookie support (without tests) --- firebase_admin/_token_gen.py | 236 +++++++++++++++++++++++------------ firebase_admin/_user_mgt.py | 38 ++---- firebase_admin/auth.py | 94 +++++++++++--- tests/test_auth.py | 2 +- 4 files changed, 242 insertions(+), 128 deletions(-) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index d28fcd78d..2875e4228 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -1,6 +1,8 @@ """Firebase token minting and validation sub module.""" +import datetime import time +import requests import six from google.auth import jwt from google.auth import transport @@ -12,32 +14,53 @@ _request = transport.requests.Request() -class TokenGenerator(object): - """Generates custom tokens, and validates ID tokens.""" +ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/' +FIREBASE_ID_TOKEN_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' + 'securetoken@system.gserviceaccount.com') +FIREBASE_COOKIE_CERT_URI = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys' +ISSUER_PREFIX = 'https://securetoken.google.com/' - FIREBASE_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' - 'securetoken@system.gserviceaccount.com') +MAX_TOKEN_LIFETIME_SECONDS = datetime.timedelta(hours=1).total_seconds() +FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.' + 'identity.identitytoolkit.v1.IdentityToolkit') - ISSUER_PREFIX = 'https://securetoken.google.com/' +# Key names we don't allow to appear in the developer_claims. +RESERVED_CLAIMS = set([ + 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', + 'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub' +]) - MAX_TOKEN_LIFETIME_SECONDS = 3600 # One Hour, in Seconds - FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.' - 'identity.identitytoolkit.v1.IdentityToolkit') +MIN_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(minutes=5).total_seconds() +MAX_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(days=14).total_seconds() - # Key names we don't allow to appear in the developer_claims. - _RESERVED_CLAIMS_ = set([ - 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', - 'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub' - ]) +COOKIE_CREATE_ERROR = 'COOKIE_CREATE_ERROR' - def __init__(self, app): - """Initializes FirebaseAuth from a FirebaseApp instance. +class ApiCallError(Exception): + """Represents an Exception encountered while invoking the ID toolkit API.""" - Args: - app: A FirebaseApp instance. - """ + def __init__(self, code, message, error=None): + Exception.__init__(self, message) + self.code = code + self.detail = error + + +class TokenGenerator(object): + """Generates custom tokens, and validates ID tokens.""" + + def __init__(self, app, client): self._app = app + self._client = client + self._id_token_verifier = JWTVerifier( + project_id=app.project_id, short_name='ID token', + operation='verify_id_token()', + doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', + cert_url=FIREBASE_ID_TOKEN_CERT_URI) + self._cookie_verifier = JWTVerifier( + project_id=app.project_id, short_name='session cookie', + operation='verify_session_cookie()', + doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', + cert_url=FIREBASE_COOKIE_CERT_URI) def create_custom_token(self, uid, developer_claims=None): """Builds and signs a FirebaseCustomAuthToken. @@ -61,8 +84,7 @@ def create_custom_token(self, uid, developer_claims=None): if not isinstance(developer_claims, dict): raise ValueError('developer_claims must be a dictionary') - disallowed_keys = set(developer_claims.keys() - ) & self._RESERVED_CLAIMS_ + disallowed_keys = set(developer_claims.keys()) & RESERVED_CLAIMS if disallowed_keys: if len(disallowed_keys) > 1: error_message = ('Developer claims {0} are reserved and ' @@ -81,25 +103,81 @@ def create_custom_token(self, uid, developer_claims=None): payload = { 'iss': self._app.credential.service_account_email, 'sub': self._app.credential.service_account_email, - 'aud': self.FIREBASE_AUDIENCE, + 'aud': FIREBASE_AUDIENCE, 'uid': uid, 'iat': now, - 'exp': now + self.MAX_TOKEN_LIFETIME_SECONDS, + 'exp': now + MAX_TOKEN_LIFETIME_SECONDS, } if developer_claims is not None: payload['claims'] = developer_claims - return jwt.encode(self._app.credential.signer, payload) def verify_id_token(self, id_token): + return self._id_token_verifier.verify(id_token) + + def verify_session_cookie(self, cookie): + return self._cookie_verifier.verify(cookie) + + def create_session_cookie(self, id_token, expires_in): + if not id_token: + raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' + 'string.'.format(id_token)) + if isinstance(id_token, six.text_type): + id_token = id_token.encode('ascii') + if not isinstance(id_token, six.binary_type): + raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' + 'string.'.format(id_token)) + + if isinstance(expires_in, datetime.timedelta): + expires_in = expires_in.total_seconds() + if isinstance(expires_in, bool) or not isinstance(expires_in, int): + raise ValueError('Illegal expiry duration: {0}.'.format(expires_in)) + if expires_in < MIN_SESSION_COOKIE_DURATION_SECONDS: + raise ValueError('Illegal expiry duration: {0}. Duration must be at least {1} ' + 'seconds.'.format(expires_in, MIN_SESSION_COOKIE_DURATION_SECONDS)) + if expires_in > MAX_SESSION_COOKIE_DURATION_SECONDS: + raise ValueError('Illegal expiry duration: {0}. Duration must be at most {1} ' + 'seconds.'.format(expires_in, MAX_SESSION_COOKIE_DURATION_SECONDS)) + + payload = { + 'idToken': id_token, + 'validDuration': expires_in, + } + try: + response = self._client.request('post', 'createSessionCookie', json=payload) + except requests.exceptions.RequestException as error: + self._handle_http_error(COOKIE_CREATE_ERROR, 'Failed to create session cookie', error) + else: + if not response or not response.get('sessionCookie'): + raise ApiCallError(COOKIE_CREATE_ERROR, 'Failed to create session cookie.') + return response.get('sessionCookie') + + def _handle_http_error(self, code, msg, error): + if error.response is not None: + msg += '\nServer response: {0}'.format(error.response.content.decode()) + else: + msg += '\nReason: {0}'.format(error) + raise ApiCallError(code, msg, error) + + +class JWTVerifier(object): + + def __init__(self, **kwargs): + self.project_id = kwargs.pop('project_id') + self.short_name = kwargs.pop('short_name') + self.operation = kwargs.pop('operation') + self.url = kwargs.pop('doc_url') + self.cert_url = kwargs.pop('cert_url') + + def verify(self, token): """Verifies the signature and data for the provided JWT. Accepts a signed token string, verifies that is the current, and issued to this project, and that it was correctly signed by Google. Args: - id_token: A string of the encoded JWT. + token: A string of the encoded JWT. Returns: dict: A dictionary of key-value pairs parsed from the decoded JWT. @@ -108,81 +186,83 @@ def verify_id_token(self, id_token): ValueError: The JWT was found to be invalid, or the app was not initialized with a credentials.Certificate instance. """ - if not id_token: - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) - - if isinstance(id_token, six.text_type): - id_token = id_token.encode('ascii') - if not isinstance(id_token, six.binary_type): - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) + if not token: + raise ValueError( + 'Illegal {0} provided: {1}. {0} must be a non-empty ' + 'string.'.format(self.short_name, token)) + if isinstance(token, six.text_type): + token = token.encode('ascii') + if not isinstance(token, six.binary_type): + raise ValueError( + 'Illegal {0} provided: {1}. {0} must be a non-empty ' + 'string.'.format(self.short_name, token)) - project_id = self._app.project_id - if not project_id: - raise ValueError('Failed to ascertain project ID from the credential or the ' - 'environment. Project ID is required to call verify_id_token(). ' - 'Initialize the app with a credentials.Certificate or set ' - 'your Firebase project ID as an app option. Alternatively ' - 'set the GCLOUD_PROJECT environment variable.') + if not self.project_id: + raise ValueError( + 'Failed to ascertain project ID from the credential or the environment. Project ' + 'ID is required to call {0}. Initialize the app with a credentials.Certificate ' + 'or set your Firebase project ID as an app option. Alternatively set the ' + 'GCLOUD_PROJECT environment variable.'.format(self.operation)) - header = jwt.decode_header(id_token) - payload = jwt.decode(id_token, verify=False) + header = jwt.decode_header(token) + payload = jwt.decode(token, verify=False) issuer = payload.get('iss') audience = payload.get('aud') subject = payload.get('sub') - expected_issuer = self.ISSUER_PREFIX + project_id + expected_issuer = ISSUER_PREFIX + self.project_id - project_id_match_msg = ('Make sure the ID token comes from the same' - ' Firebase project as the service account used' - ' to authenticate this SDK.') + project_id_match_msg = ( + 'Make sure the {0} comes from the same Firebase project as the service account used ' + 'to authenticate this SDK.'.format(self.short_name)) verify_id_token_msg = ( - 'See https://firebase.google.com/docs/auth/admin/verify-id-tokens' - ' for details on how to retrieve an ID token.') + 'See {0} for details on how to retrieve an {1}.'.format(self.url, self.short_name)) + error_message = None if not header.get('kid'): - if audience == self.FIREBASE_AUDIENCE: - error_message = ('verify_id_token() expects an ID token, but ' - 'was given a custom token.') + if audience == FIREBASE_AUDIENCE: + error_message = ( + '{0} expects an ID token, but was given a custom token.'.format(self.operation)) elif header.get('alg') == 'HS256' and payload.get( 'v') is 0 and 'uid' in payload.get('d', {}): - error_message = ('verify_id_token() expects an ID token, but ' - 'was given a legacy custom token.') + error_message = ( + '{0} expects an ID token, but was given a legacy custom ' + 'token.'.format(self.operation)) else: - error_message = 'Firebase ID token has no "kid" claim.' + error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name) elif header.get('alg') != 'RS256': - error_message = ('Firebase ID token has incorrect algorithm. ' - 'Expected "RS256" but got "{0}". {1}'.format( - header.get('alg'), verify_id_token_msg)) - elif audience != project_id: error_message = ( - 'Firebase ID token has incorrect "aud" (audience) claim. ' - 'Expected "{0}" but got "{1}". {2} {3}'.format( - project_id, audience, project_id_match_msg, - verify_id_token_msg)) + 'Firebase {0} has incorrect algorithm. Expected "RS256" but got ' + '"{1}". {2}'.format(self.short_name, header.get('alg'), verify_id_token_msg)) + elif audience != self.project_id: + error_message = ( + 'Firebase {0} has incorrect "aud" (audience) claim. Expected "{1}" but ' + 'got "{2}". {3} {4}'.format(self.short_name, self.project_id, audience, + project_id_match_msg, verify_id_token_msg)) elif issuer != expected_issuer: - error_message = ('Firebase ID token has incorrect "iss" (issuer) ' - 'claim. Expected "{0}" but got "{1}". {2} {3}' - .format(expected_issuer, issuer, - project_id_match_msg, - verify_id_token_msg)) + error_message = ( + 'Firebase {0} has incorrect "iss" (issuer) claim. Expected "{1}" but ' + 'got "{2}". {3} {4}'.format(self.short_name, expected_issuer, issuer, + project_id_match_msg, verify_id_token_msg)) elif subject is None or not isinstance(subject, six.string_types): - error_message = ('Firebase ID token has no "sub" (subject) ' - 'claim. ') + verify_id_token_msg + error_message = ( + 'Firebase {0} has no "sub" (subject) claim. ' + '{1}'.format(self.short_name, verify_id_token_msg)) elif not subject: - error_message = ('Firebase ID token has an empty string "sub" ' - '(subject) claim. ') + verify_id_token_msg + error_message = ( + 'Firebase {0} has an empty string "sub" (subject) claim. ' + '{1}'.format(self.short_name, verify_id_token_msg)) elif len(subject) > 128: - error_message = ('Firebase ID token has a "sub" (subject) ' - 'claim longer than 128 ' - 'characters. ') + verify_id_token_msg + error_message = ( + 'Firebase {0} has a "sub" (subject) claim longer than 128 characters. ' + '{1}'.format(self.short_name, verify_id_token_msg)) if error_message: raise ValueError(error_message) - verified_claims = google.oauth2.id_token.verify_firebase_token( - id_token, + verified_claims = google.oauth2.id_token.verify_token( + token, request=_request, - audience=project_id) + audience=self.project_id, + certs_url=self.cert_url) verified_claims['uid'] = verified_claims['sub'] - return verified_claims \ No newline at end of file + return verified_claims diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 2d4752637..430f53561 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -17,7 +17,6 @@ import json import re -from google.auth import transport import requests import six from six.moves import urllib @@ -225,12 +224,8 @@ class UserManager(object): 'photoUrl' : 'PHOTO_URL' } - def __init__(self, app): - g_credential = app.credential.get_credential() - session = transport.requests.AuthorizedSession(g_credential) - version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) - session.headers.update({'X-Client-Version': version_header}) - self._session = session + def __init__(self, client): + self._client = client def get_user(self, **kwargs): """Gets the user data corresponding to the provided key.""" @@ -250,7 +245,7 @@ def get_user(self, **kwargs): raise ValueError('Unsupported keyword arguments: {0}.'.format(kwargs)) try: - response = self._request('post', 'getAccountInfo', json=payload) + response = self._client.request('post', 'getAccountInfo', json=payload) except requests.exceptions.RequestException as error: msg = 'Failed to get user by {0}: {1}.'.format(key_type, key) self._handle_http_error(INTERNAL_ERROR, msg, error) @@ -277,7 +272,7 @@ def list_users(self, page_token=None, max_results=MAX_LIST_USERS_RESULTS): if page_token: payload['nextPageToken'] = page_token try: - return self._request('post', 'downloadAccount', json=payload) + return self._client.request('post', 'downloadAccount', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_DOWNLOAD_ERROR, 'Failed to download user accounts.', error) @@ -286,7 +281,7 @@ def create_user(self, **kwargs): payload = self._init_payload('create_user', UserManager._CREATE_USER_FIELDS, **kwargs) self._validate(payload, self._VALIDATORS, 'create user') try: - response = self._request('post', 'signupNewUser', json=payload) + response = self._client.request('post', 'signupNewUser', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error(USER_CREATE_ERROR, 'Failed to create new user.', error) else: @@ -319,7 +314,7 @@ def update_user(self, uid, **kwargs): self._validate(payload, self._VALIDATORS, 'update user') try: - response = self._request('post', 'setAccountInfo', json=payload) + response = self._client.request('post', 'setAccountInfo', json=payload) except requests.exceptions.RequestException as error: self._handle_http_error( USER_UPDATE_ERROR, 'Failed to update user: {0}.'.format(uid), error) @@ -332,7 +327,7 @@ def delete_user(self, uid): """Deletes the user identified by the specified user ID.""" _Validator.validate_uid(uid) try: - response = self._request('post', 'deleteAccount', json={'localId' : uid}) + response = self._client.request('post', 'deleteAccount', json={'localId' : uid}) except requests.exceptions.RequestException as error: self._handle_http_error( USER_DELETE_ERROR, 'Failed to delete user: {0}.'.format(uid), error) @@ -365,25 +360,6 @@ def _validate(self, properties, validators, operation): raise ValueError('Unsupported property: "{0}" in {1} call.'.format(key, operation)) validator(value) - def _request(self, method, urlpath, **kwargs): - """Makes an HTTP call using the Python requests library. - - Refer to http://docs.python-requests.org/en/master/api/ for more information on supported - options and features. - - Args: - method: HTTP method name as a string (e.g. get, post). - urlpath: URL path of the remote endpoint. This will be appended to the server's base URL. - kwargs: An additional set of keyword arguments to be passed into requests API - (e.g. json, params). - - Returns: - dict: The parsed JSON response. - """ - resp = self._session.request(method, ID_TOOLKIT_URL + urlpath, **kwargs) - resp.raise_for_status() - return resp.json() - class UserIterator(object): """An iterator that allows iterating over user accounts, one at a time. diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index b53463a6c..a4b5d28c8 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -22,6 +22,9 @@ import json import time +from google.auth import transport + +import firebase_admin from firebase_admin import _token_gen from firebase_admin import _user_mgt from firebase_admin import _utils @@ -39,13 +42,13 @@ def _get_auth_service(app): returning it. Args: - app: A Firebase App instance (or None to use the default App). + app: A Firebase App instance (or None to use the default App). Returns: - _AuthService: An _AuthService for the specified App instance. + _AuthService: An _AuthService for the specified App instance. Raises: - ValueError: If the app argument is invalid. + ValueError: If the app argument is invalid. """ return _utils.get_app_service(app, _AUTH_ATTRIBUTE, _AuthService) @@ -54,16 +57,16 @@ def create_custom_token(uid, developer_claims=None, app=None): """Builds and signs a Firebase custom auth token. Args: - uid: ID of the user for whom the token is created. - developer_claims: A dictionary of claims to be included in the token - (optional). - app: An App instance (optional). + uid: ID of the user for whom the token is created. + developer_claims: A dictionary of claims to be included in the token + (optional). + app: An App instance (optional). Returns: - bytes: A token minted from the input parameters. + bytes: A token minted from the input parameters. Raises: - ValueError: If input parameters are invalid. + ValueError: If input parameters are invalid. """ token_generator = _get_auth_service(app).token_generator return token_generator.create_custom_token(uid, developer_claims) @@ -76,17 +79,17 @@ def verify_id_token(id_token, app=None, check_revoked=False): to this project, and that it was correctly signed by Google. Args: - id_token: A string of the encoded JWT. - app: An App instance (optional). - check_revoked: Boolean, If true, checks whether the token has been revoked (optional). + id_token: A string of the encoded JWT. + app: An App instance (optional). + check_revoked: Boolean, If true, checks whether the token has been revoked (optional). Returns: - dict: A dictionary of key-value pairs parsed from the decoded JWT. + dict: A dictionary of key-value pairs parsed from the decoded JWT. Raises: - ValueError: If the JWT was found to be invalid, or if the App was not - initialized with a credentials.Certificate. - AuthError: If check_revoked is requested and the token was revoked. + ValueError: If the JWT was found to be invalid, or if the App was not + initialized with a credentials.Certificate. + AuthError: If check_revoked is requested and the token was revoked. """ if not isinstance(check_revoked, bool): # guard against accidental wrong assignment. @@ -100,6 +103,30 @@ def verify_id_token(id_token, app=None, check_revoked=False): raise AuthError(_ID_TOKEN_REVOKED, 'The Firebase ID token has been revoked.') return verified_claims +def create_session_cookie(id_token, expires_in, app=None): + """Creates a new Firebase session cookie from the given ID token and options. + + The returned JWT can be set as a server-side session cookie with a custom cookie policy. + + Args: + id_token: The Firebase ID token to exchange for a session cookie. + expires_in: Duration until the cookie is expired. This can be specified + as a numeric seconds value or a ``datetime.timedelta`` instance. + app: An App instance (optional). + + Returns: + bytes: A session cookie generated from the input parameters. + + Raises: + ValueError: If input parameters are invalid. + AuthError: If an error occurs while creating the cookie. + """ + token_generator = _get_auth_service(app).token_generator + try: + return token_generator.create_session_cookie(id_token, expires_in) + except _token_gen.ApiCallError as error: + raise AuthError(error.code, str(error), error.detail) + def revoke_refresh_tokens(uid, app=None): """Revokes all refresh tokens for an existing user. @@ -657,10 +684,12 @@ def __init__(self, code, message, error=None): class _AuthService(object): + """Firebase Authentication service.""" def __init__(self, app): - self._token_generator = _token_gen.TokenGenerator(app) - self._user_manager = _user_mgt.UserManager(app) + client = _AuthHTTPClient(app) + self._token_generator = _token_gen.TokenGenerator(app, client) + self._user_manager = _user_mgt.UserManager(client) @property def token_generator(self): @@ -669,3 +698,32 @@ def token_generator(self): @property def user_manager(self): return self._user_manager + + +class _AuthHTTPClient(object): + """An HTTP client for making REST calls to the identity toolkit service.""" + + ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/' + + def __init__(self, app): + g_credential = app.credential.get_credential() + session = transport.requests.AuthorizedSession(g_credential) + version_header = 'Python/Admin/{0}'.format(firebase_admin.__version__) + session.headers.update({'X-Client-Version': version_header}) + self.session = session + + def request(self, method, urlpath, **kwargs): + """Makes an HTTP call using the Python requests library. + + Args: + method: HTTP method name as a string (e.g. get, post). + urlpath: URL path of the endpoint. This will be appended to the server's base URL. + kwargs: An additional set of keyword arguments to be passed into requests API + (e.g. json, params). + + Returns: + dict: The parsed JSON response. + """ + resp = self.session.request(method, self.ID_TOOLKIT_URL + urlpath, **kwargs) + resp.raise_for_status() + return resp.json() diff --git a/tests/test_auth.py b/tests/test_auth.py index 4666907d3..348a218c4 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -338,7 +338,7 @@ def _instrument_user_manager(app, status, payload): auth_service = auth._get_auth_service(app) user_manager = auth_service.user_manager recorder = [] - user_manager._session.mount( + user_manager._client.session.mount( _user_mgt.ID_TOOLKIT_URL, testutils.MockAdapter(payload, status, recorder)) return user_manager, recorder From e8183429ec652da26153bc9a0478667fc2f6b187 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 29 Mar 2018 22:06:57 -0700 Subject: [PATCH 03/14] Separated token generation and verification into two classes --- firebase_admin/_token_gen.py | 144 +++++++++++++++++------------------ firebase_admin/_user_mgt.py | 2 - firebase_admin/auth.py | 9 ++- 3 files changed, 75 insertions(+), 80 deletions(-) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index 2875e4228..ec1dd2e65 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -1,4 +1,19 @@ +# Copyright 2018 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 token minting and validation sub module.""" + import datetime import time @@ -13,26 +28,27 @@ # Provided for overriding during tests. _request = transport.requests.Request() +# ID token constants +ID_TOKEN_ISSUER_PREFIX = 'https://securetoken.google.com/' +ID_TOKEN_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' + 'securetoken@system.gserviceaccount.com') -ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/' -FIREBASE_ID_TOKEN_CERT_URI = ('https://www.googleapis.com/robot/v1/metadata/x509/' - 'securetoken@system.gserviceaccount.com') -FIREBASE_COOKIE_CERT_URI = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys' -ISSUER_PREFIX = 'https://securetoken.google.com/' +# Session cookie constants +COOKIE_ISSUER_PREFIX = 'https://session.firebase.google.com/' +COOKIE_CERT_URI = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/publicKeys' +MIN_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(minutes=5).total_seconds() +MAX_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(days=14).total_seconds() +# Custom token constants MAX_TOKEN_LIFETIME_SECONDS = datetime.timedelta(hours=1).total_seconds() FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/google.' 'identity.identitytoolkit.v1.IdentityToolkit') - -# Key names we don't allow to appear in the developer_claims. RESERVED_CLAIMS = set([ 'acr', 'amr', 'at_hash', 'aud', 'auth_time', 'azp', 'cnf', 'c_hash', 'exp', 'firebase', 'iat', 'iss', 'jti', 'nbf', 'nonce', 'sub' ]) -MIN_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(minutes=5).total_seconds() -MAX_SESSION_COOKIE_DURATION_SECONDS = datetime.timedelta(days=14).total_seconds() - +# Error codes COOKIE_CREATE_ERROR = 'COOKIE_CREATE_ERROR' @@ -46,35 +62,14 @@ def __init__(self, code, message, error=None): class TokenGenerator(object): - """Generates custom tokens, and validates ID tokens.""" + """Generates custom tokens and session cookies.""" def __init__(self, app, client): self._app = app self._client = client - self._id_token_verifier = JWTVerifier( - project_id=app.project_id, short_name='ID token', - operation='verify_id_token()', - doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', - cert_url=FIREBASE_ID_TOKEN_CERT_URI) - self._cookie_verifier = JWTVerifier( - project_id=app.project_id, short_name='session cookie', - operation='verify_session_cookie()', - doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', - cert_url=FIREBASE_COOKIE_CERT_URI) def create_custom_token(self, uid, developer_claims=None): - """Builds and signs a FirebaseCustomAuthToken. - - Args: - uid: ID of the user for whom the token is created. - developer_claims: A dictionary of claims to be included in the token. - - Returns: - string: A token minted from the input parameters as a byte string. - - Raises: - ValueError: If input parameters are invalid. - """ + """Builds and signs a Firebase custom auth token.""" if not isinstance(self._app.credential, credentials.Certificate): raise ValueError( 'Must initialize Firebase App with a certificate credential ' @@ -113,24 +108,16 @@ def create_custom_token(self, uid, developer_claims=None): payload['claims'] = developer_claims return jwt.encode(self._app.credential.signer, payload) - def verify_id_token(self, id_token): - return self._id_token_verifier.verify(id_token) - - def verify_session_cookie(self, cookie): - return self._cookie_verifier.verify(cookie) - def create_session_cookie(self, id_token, expires_in): - if not id_token: - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) - if isinstance(id_token, six.text_type): - id_token = id_token.encode('ascii') - if not isinstance(id_token, six.binary_type): - raise ValueError('Illegal ID token provided: {0}. ID token must be a non-empty ' - 'string.'.format(id_token)) + """Creates a session cookie from the provided ID token.""" + id_token = id_token.decode('utf-8') if isinstance(id_token, six.binary_type) else id_token + if not isinstance(id_token, six.text_type) or not id_token: + raise ValueError( + 'Illegal ID token provided: {0}. ID token must be a non-empty ' + 'string.'.format(id_token)) if isinstance(expires_in, datetime.timedelta): - expires_in = expires_in.total_seconds() + expires_in = int(expires_in.total_seconds()) if isinstance(expires_in, bool) or not isinstance(expires_in, int): raise ValueError('Illegal expiry duration: {0}.'.format(expires_in)) if expires_in < MIN_SESSION_COOKIE_DURATION_SECONDS: @@ -161,7 +148,29 @@ def _handle_http_error(self, code, msg, error): raise ApiCallError(code, msg, error) -class JWTVerifier(object): +class TokenVerifier(object): + """Verifies ID tokens and session cookies.""" + + def __init__(self, app): + self._id_token_verifier = _JWTVerifier( + project_id=app.project_id, short_name='ID token', + operation='verify_id_token()', + doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', + cert_url=ID_TOKEN_CERT_URI, issuer=ID_TOKEN_ISSUER_PREFIX) + self._cookie_verifier = _JWTVerifier( + project_id=app.project_id, short_name='session cookie', + operation='verify_session_cookie()', + doc_url='https://firebase.google.com/docs/auth/admin/verify-id-tokens', + cert_url=COOKIE_CERT_URI, issuer=COOKIE_ISSUER_PREFIX) + + def verify_id_token(self, id_token): + return self._id_token_verifier.verify(id_token) + + def verify_session_cookie(self, cookie): + return self._cookie_verifier.verify(cookie) + + +class _JWTVerifier(object): def __init__(self, **kwargs): self.project_id = kwargs.pop('project_id') @@ -169,30 +178,12 @@ def __init__(self, **kwargs): self.operation = kwargs.pop('operation') self.url = kwargs.pop('doc_url') self.cert_url = kwargs.pop('cert_url') + self.issuer = kwargs.pop('issuer') def verify(self, token): - """Verifies the signature and data for the provided JWT. - - Accepts a signed token string, verifies that is the current, and issued - to this project, and that it was correctly signed by Google. - - Args: - token: A string of the encoded JWT. - - Returns: - dict: A dictionary of key-value pairs parsed from the decoded JWT. - - Raises: - ValueError: The JWT was found to be invalid, or the app was not initialized with a - credentials.Certificate instance. - """ - if not token: - raise ValueError( - 'Illegal {0} provided: {1}. {0} must be a non-empty ' - 'string.'.format(self.short_name, token)) - if isinstance(token, six.text_type): - token = token.encode('ascii') - if not isinstance(token, six.binary_type): + """Verifies the signature and data for the provided JWT.""" + token = token.encode('utf-8') if isinstance(token, six.text_type) else token + if not isinstance(token, six.binary_type) or not token: raise ValueError( 'Illegal {0} provided: {1}. {0} must be a non-empty ' 'string.'.format(self.short_name, token)) @@ -209,24 +200,25 @@ def verify(self, token): issuer = payload.get('iss') audience = payload.get('aud') subject = payload.get('sub') - expected_issuer = ISSUER_PREFIX + self.project_id + expected_issuer = self.issuer + self.project_id project_id_match_msg = ( 'Make sure the {0} comes from the same Firebase project as the service account used ' 'to authenticate this SDK.'.format(self.short_name)) verify_id_token_msg = ( - 'See {0} for details on how to retrieve an {1}.'.format(self.url, self.short_name)) + 'See {0} for details on how to retrieve {1}.'.format(self.url, self.short_name)) error_message = None if not header.get('kid'): if audience == FIREBASE_AUDIENCE: error_message = ( - '{0} expects an ID token, but was given a custom token.'.format(self.operation)) + '{0} expects {1}, but was given a custom ' + 'token.'.format(self.operation, self.short_name)) elif header.get('alg') == 'HS256' and payload.get( 'v') is 0 and 'uid' in payload.get('d', {}): error_message = ( - '{0} expects an ID token, but was given a legacy custom ' - 'token.'.format(self.operation)) + '{0} expects {1}, but was given a legacy custom ' + 'token.'.format(self.operation, self.short_name)) else: error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name) elif header.get('alg') != 'RS256': diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 430f53561..159a24f90 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -21,8 +21,6 @@ import six from six.moves import urllib -import firebase_admin - INTERNAL_ERROR = 'INTERNAL_ERROR' USER_NOT_FOUND_ERROR = 'USER_NOT_FOUND_ERROR' diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index a4b5d28c8..596397c76 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -95,8 +95,8 @@ def verify_id_token(id_token, app=None, check_revoked=False): # guard against accidental wrong assignment. raise ValueError('Illegal check_revoked argument. Argument must be of type ' ' bool, but given "{0}".'.format(type(app))) - token_generator = _get_auth_service(app).token_generator - verified_claims = token_generator.verify_id_token(id_token) + token_verifier = _get_auth_service(app).token_verifier + verified_claims = token_verifier.verify_id_token(id_token) if check_revoked: user = get_user(verified_claims.get('uid'), app) if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: @@ -689,12 +689,17 @@ class _AuthService(object): def __init__(self, app): client = _AuthHTTPClient(app) self._token_generator = _token_gen.TokenGenerator(app, client) + self._token_verifier = _token_gen.TokenVerifier(app) self._user_manager = _user_mgt.UserManager(client) @property def token_generator(self): return self._token_generator + @property + def token_verifier(self): + return self._token_verifier + @property def user_manager(self): return self._user_manager From c2812f97d33a88014fcdf0170d82645bed6a3ac2 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 30 Mar 2018 11:45:55 -0700 Subject: [PATCH 04/14] Added unit tests for session management --- firebase_admin/_user_mgt.py | 2 - firebase_admin/auth.py | 39 +++++++-- tests/test_auth.py | 160 +++++++++++++++++++++++++++++++++++- 3 files changed, 192 insertions(+), 9 deletions(-) diff --git a/firebase_admin/_user_mgt.py b/firebase_admin/_user_mgt.py index 159a24f90..8621ec922 100644 --- a/firebase_admin/_user_mgt.py +++ b/firebase_admin/_user_mgt.py @@ -29,8 +29,6 @@ USER_DELETE_ERROR = 'USER_DELETE_ERROR' USER_DOWNLOAD_ERROR = 'LIST_USERS_ERROR' -ID_TOOLKIT_URL = 'https://www.googleapis.com/identitytoolkit/v3/relyingparty/' - MAX_LIST_USERS_RESULTS = 1000 MAX_CLAIMS_PAYLOAD_SIZE = 1000 RESERVED_CLAIMS = set([ diff --git a/firebase_admin/auth.py b/firebase_admin/auth.py index 596397c76..c159d9bea 100644 --- a/firebase_admin/auth.py +++ b/firebase_admin/auth.py @@ -32,6 +32,7 @@ _AUTH_ATTRIBUTE = '_auth' _ID_TOKEN_REVOKED = 'ID_TOKEN_REVOKED' +_SESSION_COOKIE_REVOKED = 'SESSION_COOKIE_REVOKED' def _get_auth_service(app): @@ -87,8 +88,8 @@ def verify_id_token(id_token, app=None, check_revoked=False): dict: A dictionary of key-value pairs parsed from the decoded JWT. Raises: - ValueError: If the JWT was found to be invalid, or if the App was not - initialized with a credentials.Certificate. + ValueError: If the JWT was found to be invalid, or if the App's project ID cannot + be determined. AuthError: If check_revoked is requested and the token was revoked. """ if not isinstance(check_revoked, bool): @@ -98,9 +99,7 @@ def verify_id_token(id_token, app=None, check_revoked=False): token_verifier = _get_auth_service(app).token_verifier verified_claims = token_verifier.verify_id_token(id_token) if check_revoked: - user = get_user(verified_claims.get('uid'), app) - if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: - raise AuthError(_ID_TOKEN_REVOKED, 'The Firebase ID token has been revoked.') + _check_jwt_revoked(verified_claims, _ID_TOKEN_REVOKED, 'ID token', app) return verified_claims def create_session_cookie(id_token, expires_in, app=None): @@ -127,6 +126,31 @@ def create_session_cookie(id_token, expires_in, app=None): except _token_gen.ApiCallError as error: raise AuthError(error.code, str(error), error.detail) +def verify_session_cookie(session_cookie, check_revoked=False, app=None): + """Verifies a Firebase session cookie. + + Accepts a session cookie string, verifies that it is current, and issued + to this project, and that it was correctly signed by Google. + + Args: + session_cookie: A session cookie string to verify. + check_revoked: Boolean, if true, checks whether the cookie has been revoked (optional). + app: An App instance (optional). + + Returns: + dict: A dictionary of key-value pairs parsed from the decoded JWT. + + Raises: + ValueError: If the cookie was found to be invalid, or if the App's project ID cannot + be determined. + AuthError: If check_revoked is requested and the cookie was revoked. + """ + token_verifier = _get_auth_service(app).token_verifier + verified_claims = token_verifier.verify_session_cookie(session_cookie) + if check_revoked: + _check_jwt_revoked(verified_claims, _SESSION_COOKIE_REVOKED, 'session cookie', app) + return verified_claims + def revoke_refresh_tokens(uid, app=None): """Revokes all refresh tokens for an existing user. @@ -352,6 +376,11 @@ def delete_user(uid, app=None): except _user_mgt.ApiCallError as error: raise AuthError(error.code, str(error), error.detail) +def _check_jwt_revoked(verified_claims, error_code, label, app): + user = get_user(verified_claims.get('uid'), app=app) + if verified_claims.get('iat') * 1000 < user.tokens_valid_after_timestamp: + raise AuthError(error_code, 'The Firebase {0} has been revoked.'.format(label)) + class UserInfo(object): """A collection of standard profile information for a user. diff --git a/tests/test_auth.py b/tests/test_auth.py index 348a218c4..cd86107e7 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -13,6 +13,7 @@ # limitations under the License. """Test cases for the firebase_admin.auth module.""" +import datetime import json import os import time @@ -77,6 +78,11 @@ def verify_id_token(self, *args, **kwargs): return auth.verify_id_token(*args, app=self.app, **kwargs) return auth.verify_id_token(*args, **kwargs) + def verify_session_cookie(self, *args, **kwargs): + if self.app: + return auth.verify_session_cookie(*args, app=self.app, **kwargs) + return auth.verify_session_cookie(*args, **kwargs) + def setup_module(): firebase_admin.initialize_app(MOCK_CREDENTIAL) firebase_admin.initialize_app(MOCK_CREDENTIAL, name='testApp') @@ -165,8 +171,16 @@ def get_id_token(payload_overrides=None, header_overrides=None): payload = _merge_jwt_claims(payload, payload_overrides) return jwt.encode(signer, payload, header=headers) +def get_session_cookie(payload_overrides=None, header_overrides=None): + payload_overrides = payload_overrides or {} + if 'iss' not in payload_overrides: + payload_overrides['iss'] = 'https://session.firebase.google.com/{0}'.format( + MOCK_CREDENTIAL.project_id) + return get_id_token(payload_overrides, header_overrides) + TEST_ID_TOKEN = get_id_token() +TEST_SESSION_COOKIE = get_session_cookie() class TestCreateCustomToken(object): @@ -210,6 +224,47 @@ def test_noncert_credential(self, non_cert_app): auth.create_custom_token(MOCK_UID, app=non_cert_app) +class TestCreateSessionCookie(object): + + @pytest.mark.parametrize('id_token', [None, '', 0, 1, True, False, list(), dict(), tuple()]) + def test_invalid_id_token(self, user_mgt_app, id_token): + with pytest.raises(ValueError): + auth.create_session_cookie(id_token, expires_in=3600) + + @pytest.mark.parametrize('expires_in', [ + None, '', True, False, list(), dict(), tuple(), + _token_gen.MIN_SESSION_COOKIE_DURATION_SECONDS - 1, + _token_gen.MAX_SESSION_COOKIE_DURATION_SECONDS + 1, + ]) + def test_invalid_expires_in(self, user_mgt_app, expires_in): + with pytest.raises(ValueError): + auth.create_session_cookie('id_token', expires_in=expires_in) + + @pytest.mark.parametrize('expires_in', [ + 3600, datetime.timedelta(hours=1), datetime.timedelta(milliseconds=3600500) + ]) + def test_valid_args(self, user_mgt_app, expires_in): + _, recorder = _instrument_user_manager(user_mgt_app, 200, '{"sessionCookie": "cookie"}') + cookie = auth.create_session_cookie('id_token', expires_in=expires_in, app=user_mgt_app) + assert cookie == 'cookie' + request = json.loads(recorder[0].body.decode()) + assert request == {'idToken' : 'id_token', 'validDuration': 3600} + + def test_error(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 500, '{"error":"test"}') + with pytest.raises(auth.AuthError) as excinfo: + auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) + assert excinfo.value.code == _token_gen.COOKIE_CREATE_ERROR + assert '{"error":"test"}' in str(excinfo.value) + + def test_unexpected_response(self, user_mgt_app): + _instrument_user_manager(user_mgt_app, 200, '{}') + with pytest.raises(auth.AuthError) as excinfo: + auth.create_session_cookie('id_token', expires_in=3600, app=user_mgt_app) + assert excinfo.value.code == _token_gen.COOKIE_CREATE_ERROR + assert 'Failed to create session cookie' in str(excinfo.value) + + class TestVerifyIdToken(object): valid_tokens = { @@ -271,7 +326,7 @@ def test_revoked_token_check_revoked(self, user_mgt_app, id_token): @pytest.mark.parametrize('arg', INVALID_BOOLS) def test_invalid_check_revoked(self, arg): with pytest.raises(ValueError): - auth.verify_id_token("id_token", check_revoked=arg) + auth.verify_id_token('id_token', check_revoked=arg) @pytest.mark.parametrize('id_token', valid_tokens.values(), ids=list(valid_tokens)) def test_revoked_token_do_not_check_revoked(self, user_mgt_app, id_token): @@ -327,6 +382,107 @@ def test_certificate_request_failure(self, authtest): authtest.verify_id_token(TEST_ID_TOKEN) +class TestVerifySessionCookie(object): + + valid_cookies = { + 'BinaryCookie': TEST_SESSION_COOKIE, + 'TextCookie': TEST_SESSION_COOKIE.decode('utf-8'), + } + + invalid_cookies = { + 'NoKid': get_session_cookie(header_overrides={'kid': None}), + 'WrongKid': get_session_cookie(header_overrides={'kid': 'foo'}), + 'BadAudience': get_session_cookie({'aud': 'bad-audience'}), + 'BadIssuer': get_session_cookie({ + 'iss': 'https://session.firebase.google.com/wrong-issuer' + }), + 'EmptySubject': get_session_cookie({'sub': ''}), + 'IntSubject': get_session_cookie({'sub': 10}), + 'LongStrSubject': get_session_cookie({'sub': 'a' * 129}), + 'FutureCookie': get_session_cookie({'iat': int(time.time()) + 1000}), + 'ExpiredCookie': get_session_cookie({ + 'iat': int(time.time()) - 10000, + 'exp': int(time.time()) - 3600 + }), + 'NoneCookie': None, + 'EmptyCookie': '', + 'BoolCookie': True, + 'IntCookie': 1, + 'ListCookie': [], + 'EmptyDictCookie': {}, + 'NonEmptyDictCookie': {'a': 1}, + 'BadFormatCookie': 'foobar' + } + + def setup_method(self): + _token_gen._request = testutils.MockRequest(200, MOCK_PUBLIC_CERTS) + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_valid_cookie(self, authtest, cookie): + claims = authtest.verify_session_cookie(cookie) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_valid_cookie_check_revoked(self, user_mgt_app, cookie): + _instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_RESPONSE) + claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_revoked_cookie_check_revoked(self, user_mgt_app, cookie): + _instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE) + + with pytest.raises(auth.AuthError) as excinfo: + auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=True) + + assert excinfo.value.code == 'SESSION_COOKIE_REVOKED' + assert str(excinfo.value) == 'The Firebase session cookie has been revoked.' + + @pytest.mark.parametrize('cookie', valid_cookies.values(), ids=list(valid_cookies)) + def test_revoked_cookie_does_not_check_revoked(self, user_mgt_app, cookie): + _instrument_user_manager(user_mgt_app, 200, MOCK_GET_USER_REVOKED_TOKENS_RESPONSE) + claims = auth.verify_session_cookie(cookie, app=user_mgt_app, check_revoked=False) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + + @pytest.mark.parametrize('cookie', invalid_cookies.values(), ids=list(invalid_cookies)) + def test_invalid_cookie(self, authtest, cookie): + with pytest.raises(ValueError): + authtest.verify_session_cookie(cookie) + + def test_project_id_option(self): + app = firebase_admin.initialize_app( + testutils.MockCredential(), options={'projectId': 'mock-project-id'}, name='myApp') + try: + claims = auth.verify_session_cookie(TEST_SESSION_COOKIE, app=app) + assert claims['admin'] is True + assert claims['uid'] == claims['sub'] + finally: + firebase_admin.delete_app(app) + + @pytest.mark.parametrize('env_var_app', [{'GCLOUD_PROJECT': 'mock-project-id'}], indirect=True) + def test_project_id_env_var(self, env_var_app): + claims = auth.verify_session_cookie(TEST_SESSION_COOKIE, app=env_var_app) + assert claims['admin'] is True + + @pytest.mark.parametrize('env_var_app', [{}], indirect=True) + def test_no_project_id(self, env_var_app): + with pytest.raises(ValueError): + auth.verify_session_cookie(TEST_SESSION_COOKIE, app=env_var_app) + + def test_custom_token(self, authtest): + custom_token = authtest.create_custom_token(MOCK_UID) + with pytest.raises(ValueError): + authtest.verify_session_cookie(custom_token) + + def test_certificate_request_failure(self, authtest): + _token_gen._request = testutils.MockRequest(404, 'not found') + with pytest.raises(exceptions.TransportError): + authtest.verify_session_cookie(TEST_SESSION_COOKIE) + + @pytest.fixture(scope='module') def user_mgt_app(): app = firebase_admin.initialize_app(testutils.MockCredential(), name='userMgt', @@ -339,7 +495,7 @@ def _instrument_user_manager(app, status, payload): user_manager = auth_service.user_manager recorder = [] user_manager._client.session.mount( - _user_mgt.ID_TOOLKIT_URL, + auth._AuthHTTPClient.ID_TOOLKIT_URL, testutils.MockAdapter(payload, status, recorder)) return user_manager, recorder From 8e6701ea2821ab3457524b742b106f798939f024 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 30 Mar 2018 12:00:41 -0700 Subject: [PATCH 05/14] Fixing a lint error --- tests/test_auth.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_auth.py b/tests/test_auth.py index cd86107e7..8f0a4d2b3 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -228,6 +228,7 @@ class TestCreateSessionCookie(object): @pytest.mark.parametrize('id_token', [None, '', 0, 1, True, False, list(), dict(), tuple()]) def test_invalid_id_token(self, user_mgt_app, id_token): + del user_mgt_app with pytest.raises(ValueError): auth.create_session_cookie(id_token, expires_in=3600) @@ -237,6 +238,7 @@ def test_invalid_id_token(self, user_mgt_app, id_token): _token_gen.MAX_SESSION_COOKIE_DURATION_SECONDS + 1, ]) def test_invalid_expires_in(self, user_mgt_app, expires_in): + del user_mgt_app with pytest.raises(ValueError): auth.create_session_cookie('id_token', expires_in=expires_in) From 00086050a46eb36f1190a25219ffc39cdceae6c2 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 30 Mar 2018 13:28:53 -0700 Subject: [PATCH 06/14] Added integration tests --- integration/test_auth.py | 38 ++++++++++++++++++++++++++++++++++++++ tests/test_auth.py | 3 ++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/integration/test_auth.py b/integration/test_auth.py index d744c0f08..d5b6eeb90 100644 --- a/integration/test_auth.py +++ b/integration/test_auth.py @@ -13,6 +13,7 @@ # limitations under the License. """Integration tests for firebase_admin.auth module.""" +import datetime import random import time import uuid @@ -56,6 +57,20 @@ def test_custom_token_with_claims(api_key): assert claims['premium'] is True assert claims['subscription'] == 'silver' +def test_session_cookies(api_key): + dev_claims = {'premium' : True, 'subscription' : 'silver'} + custom_token = auth.create_custom_token('user3', dev_claims) + id_token = _sign_in(custom_token, api_key) + expires_in = datetime.timedelta(days=1) + session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in) + claims = auth.verify_session_cookie(session_cookie) + assert claims['uid'] == 'user3' + assert claims['premium'] is True + assert claims['subscription'] == 'silver' + assert claims['iss'].startswith('https://session.firebase.google.com') + estimated_exp = int(time.time() + expires_in.total_seconds()) + assert abs(claims['exp'] - estimated_exp) < 5 + def test_get_non_existing_user(): with pytest.raises(auth.AuthError) as excinfo: auth.get_user('non.existing') @@ -271,3 +286,26 @@ def test_verify_id_token_revoked(new_user, api_key): id_token = _sign_in(custom_token, api_key) claims = auth.verify_id_token(id_token, check_revoked=True) assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp + +def test_verify_session_cookie_revoked(new_user, api_key): + custom_token = auth.create_custom_token(new_user.uid) + id_token = _sign_in(custom_token, api_key) + session_cookie = auth.create_session_cookie(id_token, expires_in=datetime.timedelta(days=1)) + + time.sleep(1) + auth.revoke_refresh_tokens(new_user.uid) + claims = auth.verify_session_cookie(session_cookie, check_revoked=False) + user = auth.get_user(new_user.uid) + # verify_session_cookie succeeded because it didn't check revoked. + assert claims['iat'] * 1000 < user.tokens_valid_after_timestamp + + with pytest.raises(auth.AuthError) as excinfo: + claims = auth.verify_session_cookie(session_cookie, check_revoked=True) + assert excinfo.value.code == auth._SESSION_COOKIE_REVOKED + assert str(excinfo.value) == 'The Firebase session cookie has been revoked.' + + # Sign in again, verify works. + id_token = _sign_in(custom_token, api_key) + session_cookie = auth.create_session_cookie(id_token, expires_in=datetime.timedelta(days=1)) + claims = auth.verify_session_cookie(session_cookie, check_revoked=True) + assert claims['iat'] * 1000 >= user.tokens_valid_after_timestamp diff --git a/tests/test_auth.py b/tests/test_auth.py index 8f0a4d2b3..55522d46e 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -413,7 +413,8 @@ class TestVerifySessionCookie(object): 'ListCookie': [], 'EmptyDictCookie': {}, 'NonEmptyDictCookie': {'a': 1}, - 'BadFormatCookie': 'foobar' + 'BadFormatCookie': 'foobar', + 'IDToken': TEST_ID_TOKEN, } def setup_method(self): From 0ae133dc408cc52a18fa61bdfdf0266125a4a044 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 30 Mar 2018 15:50:33 -0700 Subject: [PATCH 07/14] Handling article in error messages --- firebase_admin/_token_gen.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index ec1dd2e65..aefc6b707 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -179,6 +179,10 @@ def __init__(self, **kwargs): self.url = kwargs.pop('doc_url') self.cert_url = kwargs.pop('cert_url') self.issuer = kwargs.pop('issuer') + if self.short_name[0].lower() in 'aeiou': + self.articled_short_name = 'an {0}'.format(self.short_name) + else: + self.articled_short_name = 'a {0}'.format(self.short_name) def verify(self, token): """Verifies the signature and data for the provided JWT.""" @@ -213,12 +217,12 @@ def verify(self, token): if audience == FIREBASE_AUDIENCE: error_message = ( '{0} expects {1}, but was given a custom ' - 'token.'.format(self.operation, self.short_name)) + 'token.'.format(self.operation, self.articled_short_name)) elif header.get('alg') == 'HS256' and payload.get( 'v') is 0 and 'uid' in payload.get('d', {}): error_message = ( '{0} expects {1}, but was given a legacy custom ' - 'token.'.format(self.operation, self.short_name)) + 'token.'.format(self.operation, self.articled_short_name)) else: error_message = 'Firebase {0} has no "kid" claim.'.format(self.short_name) elif header.get('alg') != 'RS256': From ce1256d743282b2372984974999870040c092283 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Fri, 30 Mar 2018 16:00:07 -0700 Subject: [PATCH 08/14] Fixed a lint error --- firebase_admin/_token_gen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/firebase_admin/_token_gen.py b/firebase_admin/_token_gen.py index aefc6b707..a62590c19 100644 --- a/firebase_admin/_token_gen.py +++ b/firebase_admin/_token_gen.py @@ -171,6 +171,7 @@ def verify_session_cookie(self, cookie): class _JWTVerifier(object): + """Verifies Firebase JWTs (ID tokens or session cookies).""" def __init__(self, **kwargs): self.project_id = kwargs.pop('project_id') From 98a4884fa96690c7189b7fc81f6eccd770b2302a Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 2 Apr 2018 11:11:41 -0700 Subject: [PATCH 09/14] Updated changelog --- CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a3d392994..fe36ef2ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased -- +- [added] A new `create_session_cookie()` method for creating a long-lived + session cookie given a valid ID token. +- [added] A new `verify_session_cookie()` method for verifying a given + cookie string is valid. # v2.9.1 From b6f5263199c012b2be60c5e9be76a52b4080c4c6 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 2 Apr 2018 17:19:39 -0700 Subject: [PATCH 10/14] Added snippets for auth session management --- snippets/auth/index.py | 105 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 46c661c43..c80d061d9 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -12,7 +12,10 @@ # See the License for the specific language governing permissions and # limitations under the License. +import datetime import sys +import time + # [START import_sdk] import firebase_admin # [END import_sdk] @@ -289,6 +292,108 @@ def list_all_users(): print 'User: ' + user.uid # [END list_all_users] +def create_session_cookie(flask, app): + # [START session_login] + @app.route('/sessionLogin', methods=['POST']) + def session_login(): + # Set session expiration to 5 days. + expires_in = datetime.timedelta(days=5) + expires = datetime.datetime.now() + expires_in + id_token = flask.request.json['idToken'] + try: + # Create the session cookie. This will also verify the ID token in the process. + # The session cookie will have the same claims as the ID token. + session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in) + response = flask.jsonify({'status': 'success'}) + response.set_cookie( + 'session', session_cookie, expires=expires, httponly=True, secure=True) + return response + except auth.AuthError: + return flask.abort(401, 'Failed to create a session cookie') + # [END session_login] + +def check_auth_time(id_token, flask): + # [START check_auth_time] + # To ensure that cookies are set only on recently signed in users, check auth_time in + # ID token before creating a cookie. + try: + decoded_claims = auth.verify_id_token(id_token) + if time.time() - decoded_claims['auth_time'] < 5 * 60: + expires_in = datetime.timedelta(days=5) + expires = datetime.datetime.now() + expires_in + session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in) + response = flask.jsonify({'status': 'success'}) + response.set_cookie( + 'session', session_cookie, expires=expires, httponly=True, secure=True) + return response + else: + # User did not sign in recently. To guard against ID token theft, require + # re-authentication. + return flask.abort(401, 'Recent sign in required') + except ValueError: + return flask.abort(401, 'Invalid ID token') + except auth.AuthError: + return flask.abort(401, 'Failed to create a session cookie') + # [END check_auth_time] + +def verfy_session_cookie(app, flask): + def serve_content_for_user(decoded_claims): + print 'Serving content with claims:', decoded_claims + return flask.jsonify({'status': 'success'}) + + # [START session_verify] + @app.route('/profile', methods=['POST']) + def access_restricted_content(): + session_cookie = flask.request.cookies.get('session') + try: + decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) + return serve_content_for_user(decoded_claims) + except ValueError: + return flask.redirect('/login') + except auth.AuthError: + return flask.redirect('/login') + # [END session_verify] + +def check_permissions(id_token, flask): + # [START session_verify_with_permission_check] + try: + decoded_claims = auth.verify_session_cookie(id_token, check_revoked=True) + if decoded_claims.get('admin') is True: + print 'Logged in as admin' + # Serve content for user + else: + return flask.abort(401, 'Insufficient permissions') + except ValueError: + return flask.abort(401, 'Invalid session cookie') + except auth.AuthError: + return flask.abort(401, 'Session revoked') + # [END session_verify_with_permission_check] + +def clear_session_cookie(app, flask): + # [START session_clear] + @app.route('/sessionLogout', methods=['POST']) + def session_logout(): + response = flask.make_response(flask.redirect('/login')) + response.set_cookie('session', expires=0) + return response + # [END session_clear] + +def clear_session_cookie_and_revoke(app, flask): + # [START session_clear_and_revoke] + @app.route('/sessionLogout', methods=['POST']) + def session_logout(): + session_cookie = flask.request.cookies.get('session') + try: + decoded_claims = auth.verify_session_cookie(session_cookie) + auth.revoke_refresh_tokens(decoded_claims['sub']) + response = flask.make_response(flask.redirect('/login')) + response.set_cookie('session', expires=0) + return response + except ValueError: + return flask.redirect('/login') + # [END session_clear_and_revoke] + + initialize_sdk_with_service_account() initialize_sdk_with_application_default() #initialize_sdk_with_refresh_token() From d17bd64aad2f2a4da0359387a40f0a61b0a0c247 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 2 Apr 2018 17:30:52 -0700 Subject: [PATCH 11/14] Updated snippets --- snippets/auth/index.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/snippets/auth/index.py b/snippets/auth/index.py index c80d061d9..7573b5499 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -326,10 +326,9 @@ def check_auth_time(id_token, flask): response.set_cookie( 'session', session_cookie, expires=expires, httponly=True, secure=True) return response - else: - # User did not sign in recently. To guard against ID token theft, require - # re-authentication. - return flask.abort(401, 'Recent sign in required') + # User did not sign in recently. To guard against ID token theft, require + # re-authentication. + return flask.abort(401, 'Recent sign in required') except ValueError: return flask.abort(401, 'Invalid ID token') except auth.AuthError: @@ -354,10 +353,10 @@ def access_restricted_content(): return flask.redirect('/login') # [END session_verify] -def check_permissions(id_token, flask): +def check_permissions(session_cookie, flask): # [START session_verify_with_permission_check] try: - decoded_claims = auth.verify_session_cookie(id_token, check_revoked=True) + decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) if decoded_claims.get('admin') is True: print 'Logged in as admin' # Serve content for user From 72d208ebe163a9dd46a7a06cee7afc71cb51c4d5 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Wed, 4 Apr 2018 13:14:21 -0700 Subject: [PATCH 12/14] Added some comments --- snippets/auth/index.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 7573b5499..27f33bcbf 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -296,15 +296,17 @@ def create_session_cookie(flask, app): # [START session_login] @app.route('/sessionLogin', methods=['POST']) def session_login(): + # Get the ID token sent by the client + id_token = flask.request.json['idToken'] # Set session expiration to 5 days. expires_in = datetime.timedelta(days=5) expires = datetime.datetime.now() + expires_in - id_token = flask.request.json['idToken'] try: # Create the session cookie. This will also verify the ID token in the process. # The session cookie will have the same claims as the ID token. session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in) response = flask.jsonify({'status': 'success'}) + # Set cookie policy for session cookie. response.set_cookie( 'session', session_cookie, expires=expires, httponly=True, secure=True) return response @@ -344,12 +346,16 @@ def serve_content_for_user(decoded_claims): @app.route('/profile', methods=['POST']) def access_restricted_content(): session_cookie = flask.request.cookies.get('session') + # Verify the session cookie. In this case an additional check is added to detect + # if the user's Firebase session was revoked, user deleted/disabled, etc. try: decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) return serve_content_for_user(decoded_claims) except ValueError: + # Session cookie is unavailable or invalid. Force user to login. return flask.redirect('/login') except auth.AuthError: + # Session revoked. Force user to login. return flask.redirect('/login') # [END session_verify] @@ -357,15 +363,18 @@ def check_permissions(session_cookie, flask): # [START session_verify_with_permission_check] try: decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) + # Check custom claims to confirm user is an admin. if decoded_claims.get('admin') is True: print 'Logged in as admin' # Serve content for user else: return flask.abort(401, 'Insufficient permissions') except ValueError: - return flask.abort(401, 'Invalid session cookie') + # Session cookie is unavailable or invalid. Force user to login. + return flask.redirect('/login') except auth.AuthError: - return flask.abort(401, 'Session revoked') + # Session revoked. Force user to login. + return flask.redirect('/login') # [END session_verify_with_permission_check] def clear_session_cookie(app, flask): From 1868cef08f1cb0006e29c9f7a8b4a9354fb5ec77 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Thu, 5 Apr 2018 14:27:26 -0700 Subject: [PATCH 13/14] Merged with master; Updated CHANGELOG for #150 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83e6c4c6f..595da7b40 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ session cookie given a valid ID token. - [added] A new `verify_session_cookie()` method for verifying a given cookie string is valid. +- [added] `auth` module now caches the public key certificates used to + verify ID tokens and sessions cookies. This enables the SDK to avoid + making a network call everytime a credential needs to be verified. - [added] Added the `mutable_content` optional field to the `messaging.Aps` type. - [added] Added support for specifying arbitrary custom key-value From a314e25904ed78503614a57b417ae55a293d4ed4 Mon Sep 17 00:00:00 2001 From: hiranya911 Date: Mon, 9 Apr 2018 15:14:15 -0700 Subject: [PATCH 14/14] Minor improvements to samples --- snippets/auth/index.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/snippets/auth/index.py b/snippets/auth/index.py index 27f33bcbf..54271da8b 100644 --- a/snippets/auth/index.py +++ b/snippets/auth/index.py @@ -300,13 +300,13 @@ def session_login(): id_token = flask.request.json['idToken'] # Set session expiration to 5 days. expires_in = datetime.timedelta(days=5) - expires = datetime.datetime.now() + expires_in try: # Create the session cookie. This will also verify the ID token in the process. # The session cookie will have the same claims as the ID token. session_cookie = auth.create_session_cookie(id_token, expires_in=expires_in) response = flask.jsonify({'status': 'success'}) # Set cookie policy for session cookie. + expires = datetime.datetime.now() + expires_in response.set_cookie( 'session', session_cookie, expires=expires, httponly=True, secure=True) return response @@ -320,6 +320,7 @@ def check_auth_time(id_token, flask): # ID token before creating a cookie. try: decoded_claims = auth.verify_id_token(id_token) + # Only process if the user signed in within the last 5 minutes. if time.time() - decoded_claims['auth_time'] < 5 * 60: expires_in = datetime.timedelta(days=5) expires = datetime.datetime.now() + expires_in @@ -360,13 +361,16 @@ def access_restricted_content(): # [END session_verify] def check_permissions(session_cookie, flask): + def serve_content_for_admin(decoded_claims): + print 'Serving content with claims:', decoded_claims + return flask.jsonify({'status': 'success'}) + # [START session_verify_with_permission_check] try: decoded_claims = auth.verify_session_cookie(session_cookie, check_revoked=True) # Check custom claims to confirm user is an admin. if decoded_claims.get('admin') is True: - print 'Logged in as admin' - # Serve content for user + return serve_content_for_admin(decoded_claims) else: return flask.abort(401, 'Insufficient permissions') except ValueError: