diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 75fa15088..182235ba1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -90,11 +90,11 @@ There is a pylint configuration file ([`.pylintrc`](../.pylintrc)) at the root o repository. This enables you to invoke pylint directly from the command line: ``` -pylint firebase +pylint firebase_admin ``` However, it is recommended that you use the [`lint.sh`](../lint.sh) bash script to invoke -pylint. This script will run the linter on both `firebase` and the corresponding +pylint. This script will run the linter on both `firebase_admin` and the corresponding `tests` module. It suprresses some of the noisy warnings that get generated when running pylint on test code. Note that by default `lint.sh` will only validate the locally modified source files. To validate all source files, @@ -117,7 +117,7 @@ by pylint, and only output the detected issues. If you wish to obtain the comprehensive reports, run pylint from command-line with the `-r` flag. ``` -pylint -r yes firebase +pylint -r yes firebase_admin ``` ### Unit Testing @@ -153,7 +153,7 @@ file in the Git repository, and execute test cases in each of those environments Here are some highlights of the directory structure and notable source files -* `firebase/` - Source directory for the `firebase` module. +* `firebase_admin/` - Source directory for the `firebase_admin` module. * `tests/` - Unit tests. * `data/` - Provides mocks for several variables as well as mock service account keys. * `.github/` - Contribution instructions as well as issue and pull request templates. diff --git a/.gitignore b/.gitignore index b894ee771..ad787505d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .cache/ .tox/ *.egg-info/ +*~ diff --git a/firebase/__init__.py b/firebase_admin/__init__.py similarity index 83% rename from firebase/__init__.py rename to firebase_admin/__init__.py index 3c1d6fd0f..093365e8c 100644 --- a/firebase/__init__.py +++ b/firebase_admin/__init__.py @@ -1,6 +1,8 @@ """Firebase Admin SDK for Python.""" import threading +from firebase_admin import credentials + _apps = {} _apps_lock = threading.RLock() @@ -8,7 +10,7 @@ _DEFAULT_APP_NAME = '[DEFAULT]' -def initialize_app(options, name=_DEFAULT_APP_NAME): +def initialize_app(credential=None, options=None, name=_DEFAULT_APP_NAME): """Initializes and returns a new App instance. Creates a new App intance using the specified options @@ -18,7 +20,9 @@ def initialize_app(options, name=_DEFAULT_APP_NAME): App constructor. Args: - options: A dictionary of configuration options. + credential: A credential object used to initialize the SDK (optional). If none is provided, + Google Application Default Credentials are used. + options: A dictionary of configuration options (optional). name: Name of the app (optional). Returns: @@ -28,7 +32,7 @@ def initialize_app(options, name=_DEFAULT_APP_NAME): ValueError: If the app name is already in use, or any of the provided arguments are invalid. """ - app = App(name, options) + app = App(name, credential, options) with _apps_lock: if app.name not in _apps: _apps[app.name] = app @@ -112,17 +116,12 @@ class _AppOptions(object): """A collection of configuration options for an App.""" def __init__(self, options): + if options is None: + options = {} if not isinstance(options, dict): raise ValueError('Illegal Firebase app options type: {0}. Options ' 'must be a dictionary.'.format(type(options))) - self._credential = options.get('credential', None) - if not self._credential: - raise ValueError('Options must be a dict containing at least a' - ' "credential" key.') - - @property - def credential(self): - return self._credential + self._options = options class App(object): @@ -132,26 +131,36 @@ class App(object): common to all Firebase APIs. """ - def __init__(self, name, options): + def __init__(self, name, credential, options): """Constructs a new App using the provided name and options. Args: name: Name of the application. + credential: A credential object. options: A dictionary of configuration options. Raises: ValueError: If an argument is None or invalid. """ if not name or not isinstance(name, basestring): - raise ValueError('Illegal Firebase app name "{0}" provided. App ' - 'name must be a non-empty string.'.format(name)) + raise ValueError('Illegal Firebase app name "{0}" provided. App name must be a ' + 'non-empty string.'.format(name)) self._name = name + + if not isinstance(credential, credentials.Base): + raise ValueError('Illegal Firebase credential provided. App must be initialized ' + 'with a valid credential instance.') + self._credential = credential self._options = _AppOptions(options) @property def name(self): return self._name + @property + def credential(self): + return self._credential + @property def options(self): return self._options diff --git a/firebase/auth.py b/firebase_admin/auth.py similarity index 73% rename from firebase/auth.py rename to firebase_admin/auth.py index d8ac86a39..a399cb844 100644 --- a/firebase/auth.py +++ b/firebase_admin/auth.py @@ -1,21 +1,18 @@ -"""Firebase Authentication Library. +"""Firebase Authentication module. -This library contains helper methods and utilities for minting and verifying +This module contains helper methods and utilities for minting and verifying JWTs used for authenticating against Firebase services. """ -import json import os -import sys import threading import time -import httplib2 -from oauth2client import client from oauth2client import crypt -import firebase -from firebase import jwt +import firebase_admin +from firebase_admin import credentials +from firebase_admin import jwt _auth_lock = threading.Lock() @@ -30,16 +27,16 @@ def _get_initialized_app(app): if app is None: - return firebase.get_app() - elif isinstance(app, firebase.App): - initialized_app = firebase.get_app(app.name) + return firebase_admin.get_app() + elif isinstance(app, firebase_admin.App): + initialized_app = firebase_admin.get_app(app.name) if app is not initialized_app: raise ValueError('Illegal app argument. App instance not ' 'initialized via the firebase module.') return app else: raise ValueError('Illegal app argument. Argument must be of type ' - ' firebase.App, but given "{0}".'.format(type(app))) + ' firebase_admin.App, but given "{0}".'.format(type(app))) def _get_token_generator(app): @@ -99,7 +96,7 @@ def verify_id_token(id_token, app=None): Raises: ValueError: If the input parameters are invalid, or if the App was not - initialized with a CertificateCredential. + initialized with a credentials.Certificate. AppIdenityError: The JWT was found to be invalid, the message will contain details. """ @@ -147,10 +144,9 @@ def create_custom_token(self, uid, developer_claims=None): Raises: ValueError: If input parameters are invalid. """ - credential = self._app.options.credential - if not isinstance(credential, CertificateCredential): + if not isinstance(self._app.credential, credentials.Certificate): raise ValueError( - 'Must initialize Firebase App with a certificate credential' + 'Must initialize Firebase App with a certificate credential ' 'to call create_custom_token().') if developer_claims is not None: @@ -176,8 +172,8 @@ def create_custom_token(self, uid, developer_claims=None): now = int(time.time()) payload = { - 'iss': credential.service_account_email, - 'sub': credential.service_account_email, + 'iss': self._app.credential.service_account_email, + 'sub': self._app.credential.service_account_email, 'aud': self.FIREBASE_AUDIENCE, 'uid': uid, 'iat': now, @@ -187,7 +183,7 @@ def create_custom_token(self, uid, developer_claims=None): if developer_claims is not None: payload['claims'] = developer_claims - return jwt.encode(payload, credential.signer) + return jwt.encode(payload, self._app.credential.signer) def verify_id_token(self, id_token): """Verifies the signature and data for the provided JWT. @@ -202,7 +198,7 @@ def verify_id_token(self, id_token): A dict consisting of the key-value pairs parsed from the decoded JWT. Raises: - ValueError: The app was not initialized with a CertificateCredential + ValueError: The app was not initialized with a credentials.Certificate instance. AppIdenityError: The JWT was found to be invalid, the message will contain details. """ @@ -210,14 +206,13 @@ def verify_id_token(self, id_token): raise ValueError('Illegal ID token provided: {0}. ID token ' 'must be a non-empty string.'.format(id_token)) - credential = self._app.options.credential try: - project_id = credential.project_id + project_id = self._app.credential.project_id except AttributeError: project_id = os.environ.get(GCLOUD_PROJECT_ENV_VAR) if not project_id: - raise ValueError('Must initialize app with a CertificateCredential ' + raise ValueError('Must initialize app with a credentials.Certificate ' 'or set your Firebase project ID as the ' 'GCLOUD_PROJECT environment variable to call ' 'verify_id_token().') @@ -281,76 +276,3 @@ def verify_id_token(self, id_token): audience=project_id, kid=header.get('kid'), http=_http) - - -class Credential(object): - """Provides OAuth2 access tokens for accessing Firebase services. - """ - - def get_access_token(self, force_refresh=False): - """Fetches a Google OAuth2 access token using this credential instance. - - Args: - force_refresh: A boolean value indicating whether to fetch a new token - or use a cached one if available. - """ - raise NotImplementedError - - def get_credential(self): - """Returns the credential instance used for authentication.""" - raise NotImplementedError - - -class CertificateCredential(Credential): - """A Credential initialized from a JSON keyfile.""" - - def __init__(self, file_path): - """Initializes a credential from a certificate file. - - Parses the specified certificate file (service account file), and - creates a credential instance from it. - - Args: - file_path: Path to a service account certificate file. - - Raises: - IOError: If the specified file doesn't exist or cannot be read. - ValueError: If an error occurs while parsing the file content. - """ - super(CertificateCredential, self).__init__() - # TODO(hkj): Clean this up once we are able to take a dependency - # TODO(hkj): on latest oauth2client. - with open(file_path) as json_keyfile: - json_data = json.load(json_keyfile) - self._project_id = json_data.get('project_id') - try: - self._signer = crypt.Signer.from_string( - json_data.get('private_key')) - except Exception as error: - err_type, err_value, err_traceback = sys.exc_info() - err_message = 'Failed to parse the private key string: {0}'.format( - error) - raise ValueError, (err_message, err_type, err_value), err_traceback - self._service_account_email = json_data.get('client_email') - self._g_credential = client.GoogleCredentials.from_stream(file_path) - - @property - def project_id(self): - return self._project_id - - @property - def signer(self): - return self._signer - - @property - def service_account_email(self): - return self._service_account_email - - def get_access_token(self, force_refresh=False): - if force_refresh: - self._g_credential.refresh(httplib2.Http()) - token_info = self._g_credential.get_access_token() - return token_info.access_token - - def get_credential(self): - return self._g_credential diff --git a/firebase_admin/credentials.py b/firebase_admin/credentials.py new file mode 100644 index 000000000..295042f3d --- /dev/null +++ b/firebase_admin/credentials.py @@ -0,0 +1,79 @@ +"""Firebase credentials module.""" +import json +import sys + +import httplib2 + +from oauth2client import client +from oauth2client import crypt + + +_http = httplib2.Http() + + +class Base(object): + """Provides OAuth2 access tokens for accessing Firebase services.""" + + def get_access_token(self): + """Fetches a Google OAuth2 access token using this credential instance. + + Returns: + An oauth2client.client.AccessTokenInfo instance + """ + raise NotImplementedError + + def get_credential(self): + """Returns the credential instance used for authentication.""" + raise NotImplementedError + + +class Certificate(Base): + """A credential initialized from a JSON certificate keyfile.""" + + def __init__(self, file_path): + """Initializes a credential from a certificate file. + + Parses the specified certificate file (service account file), and + creates a credential instance from it. + + Args: + file_path: Path to a service account certificate file. + + Raises: + IOError: If the specified file doesn't exist or cannot be read. + ValueError: If an error occurs while parsing the file content. + """ + super(Certificate, self).__init__() + # TODO(hkj): Clean this up once we are able to take a dependency + # TODO(hkj): on latest oauth2client. + with open(file_path) as json_keyfile: + json_data = json.load(json_keyfile) + self._project_id = json_data.get('project_id') + try: + self._signer = crypt.Signer.from_string( + json_data.get('private_key')) + except Exception as error: + err_type, err_value, err_traceback = sys.exc_info() + err_message = 'Failed to parse the private key string: {0}'.format( + error) + raise ValueError, (err_message, err_type, err_value), err_traceback + self._service_account_email = json_data.get('client_email') + self._g_credential = client.GoogleCredentials.from_stream(file_path) + + @property + def project_id(self): + return self._project_id + + @property + def signer(self): + return self._signer + + @property + def service_account_email(self): + return self._service_account_email + + def get_access_token(self): + return self._g_credential.get_access_token(_http) + + def get_credential(self): + return self._g_credential diff --git a/firebase/jwt.py b/firebase_admin/jwt.py similarity index 100% rename from firebase/jwt.py rename to firebase_admin/jwt.py diff --git a/lint.sh b/lint.sh index 2982caacd..2f02c3645 100755 --- a/lint.sh +++ b/lint.sh @@ -18,9 +18,9 @@ SKIP_FOR_TESTS="redefined-outer-name,protected-access,missing-docstring" if [[ $1 = "all" ]] then - lintAllFiles firebase + lintAllFiles firebase_admin lintAllFiles tests $SKIP_FOR_TESTS else - lintChangedFiles firebase + lintChangedFiles firebase_admin lintChangedFiles tests $SKIP_FOR_TESTS fi diff --git a/tests/test_app.py b/tests/test_app.py index 3c11c0845..0e861ccc6 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1,72 +1,69 @@ -"""Tests for firebase.App.""" +"""Tests for firebase_admin.App.""" import pytest -import firebase -from firebase import auth +import firebase_admin +from firebase_admin import credentials from tests import testutils -CREDENTIAL = auth.CertificateCredential( +CREDENTIAL = credentials.Certificate( testutils.resource_filename('service_account.json')) -OPTIONS = {'credential': CREDENTIAL} class TestFirebaseApp(object): """Test cases for App initialization and life cycle.""" - invalid_options = { - 'EmptyOptions': ({}, ValueError), - 'NoCredential': ({'k':'v'}, ValueError), - 'NoneOptions': (None, ValueError), - 'IntOptions': (1, ValueError), - 'StringOptions': ('foo', ValueError), - } - + invalid_credentials = ['', 'foo', 0, 1, dict(), list(), tuple(), True, False] + invalid_options = ['', 0, 1, list(), tuple(), True, False] invalid_names = [None, '', 0, 1, dict(), list(), tuple(), True, False] def teardown_method(self): testutils.cleanup_apps() def test_default_app_init(self): - app = firebase.initialize_app(OPTIONS) - assert firebase._DEFAULT_APP_NAME == app.name - assert CREDENTIAL is app.options.credential + app = firebase_admin.initialize_app(CREDENTIAL) + assert firebase_admin._DEFAULT_APP_NAME == app.name + assert CREDENTIAL is app.credential with pytest.raises(ValueError): - firebase.initialize_app(OPTIONS) + firebase_admin.initialize_app(CREDENTIAL) def test_non_default_app_init(self): - app = firebase.initialize_app(OPTIONS, 'myApp') + app = firebase_admin.initialize_app(CREDENTIAL, name='myApp') assert app.name == 'myApp' - assert CREDENTIAL is app.options.credential + assert CREDENTIAL is app.credential + with pytest.raises(ValueError): + firebase_admin.initialize_app(CREDENTIAL, name='myApp') + + @pytest.mark.parametrize('cred', invalid_credentials) + def test_app_init_with_invalid_credential(self, cred): with pytest.raises(ValueError): - firebase.initialize_app(OPTIONS, 'myApp') + firebase_admin.initialize_app(cred) - @pytest.mark.parametrize('options,error', invalid_options.values(), - ids=invalid_options.keys()) - def test_app_init_with_invalid_options(self, options, error): - with pytest.raises(error): - firebase.initialize_app(options) + @pytest.mark.parametrize('options', invalid_options) + def test_app_init_with_invalid_options(self, options): + with pytest.raises(ValueError): + firebase_admin.initialize_app(CREDENTIAL, options=options) @pytest.mark.parametrize('name', invalid_names) def test_app_init_with_invalid_name(self, name): with pytest.raises(ValueError): - firebase.initialize_app(OPTIONS, name) + firebase_admin.initialize_app(CREDENTIAL, name=name) def test_default_app_get(self): - app = firebase.initialize_app(OPTIONS) - assert app is firebase.get_app() + app = firebase_admin.initialize_app(CREDENTIAL) + assert app is firebase_admin.get_app() def test_non_default_app_get(self): - app = firebase.initialize_app(OPTIONS, 'myApp') - assert app is firebase.get_app('myApp') + app = firebase_admin.initialize_app(CREDENTIAL, name='myApp') + assert app is firebase_admin.get_app('myApp') @pytest.mark.parametrize('args', [(), ('myApp',)], ids=['DefaultApp', 'CustomApp']) def test_non_existing_app_get(self, args): with pytest.raises(ValueError): - firebase.get_app(*args) + firebase_admin.get_app(*args) @pytest.mark.parametrize('name', invalid_names) def test_app_get_with_invalid_name(self, name): with pytest.raises(ValueError): - firebase.get_app(name) + firebase_admin.get_app(name) diff --git a/tests/test_auth.py b/tests/test_auth.py index 3bdf26f61..aeb21c290 100644 --- a/tests/test_auth.py +++ b/tests/test_auth.py @@ -1,4 +1,4 @@ -"""Test cases for firebase.auth module.""" +"""Test cases for firebase_admin.auth module.""" import os import time @@ -6,9 +6,10 @@ from oauth2client import crypt import pytest -import firebase -from firebase import auth -from firebase import jwt +import firebase_admin +from firebase_admin import auth +from firebase_admin import credentials +from firebase_admin import jwt from tests import testutils @@ -16,7 +17,7 @@ 'google.identity.identitytoolkit.v1.IdentityToolkit') MOCK_UID = 'user1' -MOCK_CREDENTIAL = auth.CertificateCredential( +MOCK_CREDENTIAL = credentials.Certificate( testutils.resource_filename('service_account.json')) MOCK_PUBLIC_CERTS = testutils.resource('public_certs.json') MOCK_PRIVATE_KEY = testutils.resource('private_key.pem') @@ -26,7 +27,7 @@ class AuthFixture(object): def __init__(self, name=None): if name: - self.app = firebase.get_app(name) + self.app = firebase_admin.get_app(name) else: self.app = None @@ -43,12 +44,12 @@ def verify_id_token(self, *args): return auth.verify_id_token(*args) def setup_module(): - firebase.initialize_app({'credential': MOCK_CREDENTIAL}) - firebase.initialize_app({'credential': MOCK_CREDENTIAL}, 'testApp') + firebase_admin.initialize_app(MOCK_CREDENTIAL) + firebase_admin.initialize_app(MOCK_CREDENTIAL, name='testApp') def teardown_module(): - firebase.delete_app('[DEFAULT]') - firebase.delete_app('testApp') + firebase_admin.delete_app('[DEFAULT]') + firebase_admin.delete_app('testApp') @pytest.fixture(params=[None, 'testApp'], ids=['DefaultApp', 'CustomApp']) def authtest(request): @@ -69,10 +70,9 @@ def non_cert_app(): that depends on this fixture. This ensures the proper cleanup of the App instance after tests. """ - app = firebase.initialize_app( - {'credential': auth.Credential()}, 'non-cert-app') + app = firebase_admin.initialize_app(credentials.Base(), name='non-cert-app') yield app - firebase.delete_app(app.name) + firebase_admin.delete_app(app.name) def verify_custom_token(custom_token, expected_claims): assert isinstance(custom_token, basestring) diff --git a/tests/test_credentials.py b/tests/test_credentials.py new file mode 100644 index 000000000..437758a79 --- /dev/null +++ b/tests/test_credentials.py @@ -0,0 +1,33 @@ +"""Tests for firebase_admin.credentials module.""" +from firebase_admin import credentials +from oauth2client import client +from oauth2client import crypt +import pytest + +from tests import testutils + + +class TestCertificate(object): + + def test_init_from_file(self): + credential = credentials.Certificate( + testutils.resource_filename('service_account.json')) + assert credential.project_id == 'mock-project-id' + assert credential.service_account_email == 'mock-email@mock-project.iam.gserviceaccount.com' + assert isinstance(credential.signer, crypt.Signer) + + g_credential = credential.get_credential() + assert isinstance(g_credential, client.GoogleCredentials) + assert g_credential.access_token is None + + # The HTTP client should not be used. + credential._http = None + access_token = credential.get_access_token() + assert isinstance(access_token.access_token, basestring) + assert isinstance(access_token.expires_in, int) + + + def test_init_from_nonexisting_file(self): + with pytest.raises(IOError): + credentials.Certificate( + testutils.resource_filename('non_existing.json')) diff --git a/tests/testutils.py b/tests/testutils.py index 06ad5f096..98a917886 100644 --- a/tests/testutils.py +++ b/tests/testutils.py @@ -3,7 +3,7 @@ import httplib2 -import firebase +import firebase_admin def resource_filename(filename): @@ -18,10 +18,10 @@ def resource(filename): def cleanup_apps(): - with firebase._apps_lock: - app_names = list(firebase._apps.keys()) + with firebase_admin._apps_lock: + app_names = list(firebase_admin._apps.keys()) for name in app_names: - firebase.delete_app(name) + firebase_admin.delete_app(name) class HttpMock(object):