diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..b894ee771 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +*.pyc +.python-version +.cache/ +.tox/ +*.egg-info/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 000000000..89ad58fd1 --- /dev/null +++ b/.pylintrc @@ -0,0 +1,410 @@ +[MASTER] + +# Specify a configuration file. +#rcfile= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=1 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + +# Allow optimization of some AST trees. This will activate a peephole AST +# optimizer, which will apply various small optimizations. For instance, it can +# be used to obtain the result of joining multiple strings with the addition +# operator. Joining a lot of strings can lead to a maximum recursion error in +# Pylint and this flag can prevent that. It has one side effect, the resulting +# AST will be different than the one from reality. This option is deprecated +# and it will be removed in Pylint 2.0. +optimize-ast=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=indexing-exception,old-raise-syntax + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=design,similarities,no-self-use,attribute-defined-outside-init,locally-disabled,star-args,pointless-except,bad-option-value,global-statement,fixme,suppressed-message,useless-suppression,locally-enabled,file-ignored + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,future.builtins + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[TYPECHECK] + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +bad-functions=input,apply,reduce + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty + +# Regular expression matching correct function names +function-rgx=[a-z_][a-z0-9_]*$ + +# Naming hint for function names +function-name-hint=[a-z_][a-z0-9_]*$ + +# Regular expression matching correct variable names +variable-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for variable names +variable-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + + +# Naming hint for constant names +const-name-hint=(([A-Z_][A-Z0-9_]*)|(__.*__))$ + +# Regular expression matching correct attribute names +attr-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for attribute names +attr-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct argument names +argument-rgx=[a-z_][a-z0-9_]{2,30}$ + +# Naming hint for argument names +argument-name-hint=[a-z_][a-z0-9_]{2,30}$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Naming hint for class attribute names +class-attribute-name-hint=([A-Za-z_][A-Za-z0-9_]{2,30}|(__.*__))$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ + +# Naming hint for inline iteration names +inlinevar-name-hint=[A-Za-z_][A-Za-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=[A-Z_][a-zA-Z0-9]+$ + +# Naming hint for class names +class-name-hint=[A-Z_][a-zA-Z0-9]+$ + +# Regular expression matching correct module names +module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Naming hint for module names +module-name-hint=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ + +# Regular expression matching correct method names +method-rgx=[a-z_][a-z0-9_]*$ + +# Naming hint for method names +method-name-hint=[a-z_][a-z0-9_]*$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main) + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=10 + + +[ELIF] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=100 + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma,dict-separator + +# Maximum number of lines in a module +max-module-lines=1000 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME,XXX,TODO + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub,TERMIOS,Bastion,rexec + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[DESIGN] + +# Maximum number of arguments for function / method +max-args=5 + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore +ignored-argument-names=_.* + +# Maximum number of locals for function / method body +max-locals=15 + +# Maximum number of return / yield for function / method body +max-returns=6 + +# Maximum number of branch for function / method body +max-branches=12 + +# Maximum number of statements in function / method body +max-statements=50 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of boolean expressions in a if statement +max-bool-expr=5 + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__,__new__,setUp + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict,_fields,_replace,_source,_make + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=Exception diff --git a/README.md b/README.md index d31ee544f..aa57b3060 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,84 @@ # Firebase Admin Python SDK +The Firebase Admin Python SDK enables server-side (backend) Python developers +to integrate [Firebase](https://firebase.google.com) into their services +and applications. Currently this SDK provides Firebase custom authentication +support. + + +## Unit Testing +We use [pytest](http://doc.pytest.org/en/latest/) for writing and executing +unit tests. Download pytest 3.0.6 or higher using pip: + +``` +pip install -U pytest +``` + +All source files containing test code is located in the `tests/` +directory. Simply launch pytest from the root of the Git repository, or from +within the `tests/` directory to execute all test cases. + +``` +pytest +``` + +Refer to the pytest [usage and invocations guide](http://doc.pytest.org/en/latest/usage.html) +to learn how to run a subset of all test cases. + + +## Running Linters +We use [pylint](https://pylint.org/) for verifying source code format, and +enforcing other Python programming best practices. Install pylint 1.6.4 or +higher using pip: + +``` +pip install -U pylint +``` + +Specify a pylint version explicitly if the above command installs an older +version: + +``` +pip install pylint==1.6.4 +``` + +Once installed, you can check the version of the installed binary by running +the following command: + +``` +pylint --version +``` + +There is a pylint configuration file (`.pylintrc`) at the root of this Git +repository. This enables you to invoke pylint directly from the command line: + +``` +pylint firebase +``` + +However, it is recommended that you use the `lint.sh` bash script to invoke +pylint. This script will run the linter on both `firebase` 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, +pass `all` as an argument. + +``` +./lint.sh # Lint locally modified source files +./lint.sh all # Lint all source files +``` + +Ideally you should not see any pylint errors or warnings when you run the +linter. This means source files are properly formatted, and the linter has +not found any issues. If you do observe any errors, fix them before +committing or sending a pull request. Details on how to interpret pylint +errors are available +[here](https://pylint.readthedocs.io/en/latest/user_guide/output.html). + +Our configuration files suppress the verbose reports usually generated +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 +``` diff --git a/firebase/__init__.py b/firebase/__init__.py new file mode 100644 index 000000000..3c1d6fd0f --- /dev/null +++ b/firebase/__init__.py @@ -0,0 +1,157 @@ +"""Firebase Admin SDK for Python.""" +import threading + + +_apps = {} +_apps_lock = threading.RLock() + +_DEFAULT_APP_NAME = '[DEFAULT]' + + +def initialize_app(options, name=_DEFAULT_APP_NAME): + """Initializes and returns a new App instance. + + Creates a new App intance using the specified options + and the app name. If an instance already exists by the same + app name a ValueError is raised. Use this function whenever + a new App instance is required. Do not directly invoke the + App constructor. + + Args: + options: A dictionary of configuration options. + name: Name of the app (optional). + + Returns: + A newly initialized instance of App. + + Raises: + ValueError: If the app name is already in use, or any of the + provided arguments are invalid. + """ + app = App(name, options) + with _apps_lock: + if app.name not in _apps: + _apps[app.name] = app + return app + + if name == _DEFAULT_APP_NAME: + raise ValueError(( + 'The default Firebase app already exists. This means you called ' + 'initialize_app() more than once without providing an app name as ' + 'the second argument. In most cases you only need to call ' + 'initialize_app() once. But if you do want to initialize multiple ' + 'apps, pass a second argument to initialize_app() to give each app ' + 'a unique name.')) + else: + raise ValueError(( + 'Firebase app named "{0}" already exists. This means you called ' + 'initialize_app() more than once with the same app name as the ' + 'second argument. Make sure you provide a unique name every time ' + 'you call initialize_app().').format(name)) + + +def delete_app(name): + """Gracefully deletes an App instance. + + Args: + name: Name of the app instance to be deleted. + + Raises: + ValueError: If the name is not a string. + """ + if not isinstance(name, basestring): + raise ValueError('Illegal app name argument type: "{}". App name ' + 'must be a string.'.format(type(name))) + with _apps_lock: + if name in _apps: + del _apps[name] + return + if name == _DEFAULT_APP_NAME: + raise ValueError( + 'The default Firebase app does not exist. Make sure to initialize ' + 'the SDK by calling initialize_app().') + else: + raise ValueError( + ('Firebase app named "{0}" does not exist. Make sure to initialize ' + 'the SDK by calling initialize_app() with your app name as the ' + 'second argument.').format(name)) + + +def get_app(name=_DEFAULT_APP_NAME): + """Retrieves an App instance by name. + + Args: + name: Name of the App instance to retrieve (optional). + + Returns: + An App instance. + + Raises: + ValueError: If the specified name is not a string, or if the specified + app does not exist. + """ + if not isinstance(name, basestring): + raise ValueError('Illegal app name argument type: "{}". App name ' + 'must be a string.'.format(type(name))) + with _apps_lock: + if name in _apps: + return _apps[name] + + if name == _DEFAULT_APP_NAME: + raise ValueError( + 'The default Firebase app does not exist. Make sure to initialize ' + 'the SDK by calling initialize_app().') + else: + raise ValueError( + ('Firebase app named "{0}" does not exist. Make sure to initialize ' + 'the SDK by calling initialize_app() with your app name as the ' + 'second argument.').format(name)) + + +class _AppOptions(object): + """A collection of configuration options for an App.""" + + def __init__(self, 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 + + +class App(object): + """The entry point for Firebase Python SDK. + + Represents a Firebase app, while holding the configuration and state + common to all Firebase APIs. + """ + + def __init__(self, name, options): + """Constructs a new App using the provided name and options. + + Args: + name: Name of the application. + 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)) + self._name = name + self._options = _AppOptions(options) + + @property + def name(self): + return self._name + + @property + def options(self): + return self._options diff --git a/firebase/auth.py b/firebase/auth.py new file mode 100644 index 000000000..d8ac86a39 --- /dev/null +++ b/firebase/auth.py @@ -0,0 +1,356 @@ +"""Firebase Authentication Library. + +This library 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 + +_auth_lock = threading.Lock() + +"""Provided for overriding during tests. (OAuth2 client uses a caching-enabled + HTTP client internally if none provided) +""" +_http = None + +_AUTH_ATTRIBUTE = '_auth' +GCLOUD_PROJECT_ENV_VAR = 'GCLOUD_PROJECT' + + +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) + 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))) + + +def _get_token_generator(app): + """Returns a _TokenGenerator instance for an App. + + If the App already has a _TokenGenerator associated with it, simply returns + it. Otherwise creates a new _TokenGenerator, and adds it to the App before + returning it. + + Args: + app: A Firebase App instance (or None to use the default App). + + Returns: + A _TokenGenerator instance. + + Raises: + ValueError: If the app argument is invalid. + """ + app = _get_initialized_app(app) + with _auth_lock: + if not hasattr(app, _AUTH_ATTRIBUTE): + setattr(app, _AUTH_ATTRIBUTE, _TokenGenerator(app)) + return getattr(app, _AUTH_ATTRIBUTE) + + +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). + + Returns: + A token string minted from the input parameters. + + Raises: + ValueError: If input parameters are invalid. + """ + token_generator = _get_token_generator(app) + return token_generator.create_custom_token(uid, developer_claims) + + +def verify_id_token(id_token, app=None): + """Verifies the signature and data for the provided JWT. + + Accepts a signed token string, verifies that it is current, and issued + 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). + + Returns: + A dict consisting of the key-value pairs parsed from the decoded JWT. + + Raises: + ValueError: If the input parameters are invalid, or if the App was not + initialized with a CertificateCredential. + AppIdenityError: The JWT was found to be invalid, the message will contain + details. + """ + token_generator = _get_token_generator(app) + return token_generator.verify_id_token(id_token) + + +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: + A token string minted from the input parameters. + + Raises: + ValueError: If input parameters are invalid. + """ + credential = self._app.options.credential + if not isinstance(credential, CertificateCredential): + 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, basestring) or len(uid) > 128: + raise ValueError( + 'uid must be a string between 1 and 128 characters.') + + now = int(time.time()) + payload = { + 'iss': credential.service_account_email, + 'sub': 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(payload, credential.signer) + + 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: + A dict consisting of the key-value pairs parsed from the decoded JWT. + + Raises: + ValueError: The app was not initialized with a CertificateCredential + AppIdenityError: The JWT was found to be invalid, the message will + contain details. + """ + if not id_token or not isinstance(id_token, basestring): + 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 + except AttributeError: + project_id = os.environ.get(GCLOUD_PROJECT_ENV_VAR) + + if not project_id: + raise ValueError('Must initialize app with a CertificateCredential ' + 'or set your Firebase project ID as the ' + 'GCLOUD_PROJECT environment variable to call ' + 'verify_id_token().') + + header, payload = jwt.decode(id_token) + 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, basestring): + 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 crypt.AppIdentityError(error_message) + + return jwt.verify_id_token( + id_token, + self.FIREBASE_CERT_URI, + 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/jwt.py b/firebase/jwt.py new file mode 100644 index 000000000..d0d45bcbd --- /dev/null +++ b/firebase/jwt.py @@ -0,0 +1,149 @@ +"""Utility functions for encoding/decoding JWT tokens. + +This module implements the basic JWT token encoding and +decoding functionality. Most function implementations +were inspired by the oauth2client library. It also uses the +crypto capabilities of the oauth2client library for +signing and verifying JWTs. However, unlike oauth2client +this implementation provides more control over JWT headers. +""" +import base64 +import json + +import httplib2 +import six + +from oauth2client import client +from oauth2client import crypt + +try: + # Newer versions of oauth2client (> v1.4) + # pylint: disable=g-import-not-at-top + from oauth2client import transport + _cached_http = httplib2.Http(transport.MemoryCache()) +except ImportError: + # Older versions of oauth2client (<= v1.4) + _cached_http = httplib2.Http(client.MemoryCache()) + + +def _to_bytes(value, encoding='ascii'): + result = (value.encode(encoding) + if isinstance(value, six.text_type) else value) + if isinstance(result, six.binary_type): + return result + else: + raise ValueError('{0!r} could not be converted to bytes'.format(value)) + + +def _urlsafe_b64encode(raw_bytes): + raw_bytes = _to_bytes(raw_bytes, encoding='utf-8') + return base64.urlsafe_b64encode(raw_bytes).rstrip(b'=') + + +def _urlsafe_b64decode(b64string): + b64string = _to_bytes(b64string) + padded = b64string + b'=' * (4 - len(b64string) % 4) + return base64.urlsafe_b64decode(padded) + + +def encode(payload, signer, headers=None): + """Encodes the provided payload into a signed JWT. + + Creates a signed JWT from the given dictionary payload of claims. + By default this function only adds the 'typ' and 'alg' headers to + the encoded JWT. The 'headers' argument can be used to set additional + JWT headers, and override the defaults. This function provides the + bare minimal token encoding and signing functionality. Any validations + on individual claims should be performed by the caller. + + Args: + payload: A dictionary of claims. + signer: An oauth2client.crypt.Signer instance for signing tokens. + headers: An dictionary of headers (optional). + + Returns: + A signed JWT token as a string + """ + header = {'typ': 'JWT', 'alg': 'RS256'} + if headers: + header.update(headers) + segments = [ + _urlsafe_b64encode(json.dumps(header, separators=(',', ':'))), + _urlsafe_b64encode(json.dumps(payload, separators=(',', ':'))), + ] + signing_input = b'.'.join(segments) + signature = signer.sign(signing_input) + segments.append(_urlsafe_b64encode(signature)) + return b'.'.join(segments) + + +def decode(token): + """Decodes the provided JWT into dictionaries. + + Parses the provided token and extracts its header values and claims. + Note that this function does not perform any verification on the + token content. Nor does it attempt to verify the token signature. + Th only validation it performs is for the proper formatting/encoding + of the JWT token, which is necessary to parse it. Simply use this + function to unpack, and inspect the contents of a JWT. + + Args: + token: A signed JWT token as a string. + + Returns: + A 2-tuple where the first element is a dictionary of JWT headers, + and the second element is a dictionary of payload claims. + + Raises: + AppIdentityError: If the token is malformed or badly formatted + """ + if token.count(b'.') != 2: + raise crypt.AppIdentityError(('Wrong number of segments' + ' in token: {0}').format(token)) + header, payload, _ = token.split(b'.') + header_dict = json.loads(_urlsafe_b64decode(header).decode('utf-8')) + payload_dict = json.loads(_urlsafe_b64decode(payload).decode('utf-8')) + return (header_dict, payload_dict) + + +def verify_id_token(id_token, cert_uri, audience=None, kid=None, http=None): + """Verifies the provided ID token. + + Checks for token integrity by verifying its signature against + a set of public key certificates. Certificates are downloaded + from cert_uri, and cached according to the HTTP cache control + requirements. If provided, the audience and kid fields of the + ID token are also validated. + + Args: + id_token: JWT ID token to be validated. + cert_uri: A URI string pointing to public key certificates. + audience: Audience string that should be present in the token. + kid: JWT key ID header to locate the public key certificate. + http: An httplib2 HTTP client instance. + + Returns: + A dictionary of claims extracted from the ID token. + + Raises: + ValueError: Certificate URI is None or empty. + AppIdentityError: Token integrity check failed. + VerifyJwtTokenError: Failed to load public keys or invalid kid header. + """ + if not cert_uri: + raise ValueError('Certificate URI is required') + if not http: + http = _cached_http + resp, content = http.request(cert_uri) + if resp.status != 200: + raise client.VerifyJwtTokenError( + ('Failed to load public key certificates from URL "{0}". Received ' + 'HTTP status code {1}.').format(cert_uri, resp.status)) + certs = json.loads(content.decode('utf-8')) + if kid and not certs.has_key(kid): + raise client.VerifyJwtTokenError( + 'Firebase ID token has "kid" claim which does' + ' not correspond to a known public key. Most' + ' likely the ID token is expired, so get a' + ' fresh token from your client app and try again.') + return crypt.verify_signed_jwt_with_certs(id_token, certs, audience) diff --git a/lint.sh b/lint.sh new file mode 100755 index 000000000..2982caacd --- /dev/null +++ b/lint.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +function lintAllFiles () { + echo "Running linter on module $1" + pylint --disable=$2 $1 +} + +function lintChangedFiles () { + files=`git status -s $1 | grep -v "^D" | awk '{print $NF}' | grep .py$` + for f in $files + do + echo "Running linter on $f" + pylint --disable=$2 $f + done +} + +SKIP_FOR_TESTS="redefined-outer-name,protected-access,missing-docstring" + +if [[ $1 = "all" ]] +then + lintAllFiles firebase + lintAllFiles tests $SKIP_FOR_TESTS +else + lintChangedFiles firebase + lintChangedFiles tests $SKIP_FOR_TESTS +fi diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 000000000..bd8a7091e --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Enables exclusion of the tests module from the distribution. diff --git a/tests/data/private_key.pem b/tests/data/private_key.pem new file mode 100644 index 000000000..636af7a3e --- /dev/null +++ b/tests/data/private_key.pem @@ -0,0 +1,25 @@ +-----BEGIN RSA PRIVATE KEY----- +MIIEpAIBAAKCAQEAwJENcRev+eXZKvhhWLiV3Lz2MvO+naQRHo59g3vaNQnbgyduN/L4krlr +J5c6FiikXdtJNb/QrsAHSyJWCu8j3T9CruiwbidGAk2W0RuViTVspjHUTsIHExx9euWM0Uom +GvYkoqXahdhPL/zViVSJt+Rt8bHLsMvpb8RquTIb9iKY3SMV2tCofNmyCSgVbghq/y7lKORt +V/IRguWs6R22fbkb0r2MCYoNAbZ9dqnbRIFNZBC7itYtUoTEresRWcyFMh0zfAIJycWOJlVL +DLqkY2SmIx8u7fuysCg1wcoSZoStuDq02nZEMw1dx8HGzE0hynpHlloRLByuIuOAfMCCYwID +AQABAoIBADFtihu7TspAO0wSUTpqttzgC/nsIsNn95T2UjVLtyjiDNxPZLUrwq42tdCFur0x +VW9Z+CK5x6DzXWvltlw8IeKKeF1ZEOBVaFzy+YFXKTz835SROcO1fgdjyrme7lRSShGlmKW/ +GKY+baUNquoDLw5qreXaE0SgMp0jt5ktyYuVxvhLDeV4omw2u6waoGkifsGm8lYivg5l3VR7 +w2IVOvYZTt4BuSYVwOM+qjwaS1vtL7gv0SUjrj85Ja6zERRdFiITDhZw6nsvacr9/+/aut9E +aL/koSSb62g5fntQMEwoT4hRnjPnAedmorM9Rhddh2TB3ZKTBbMN1tUk3fJxOuECgYEA+z6l +eSaAcZ3qvwpntcXSpwwJ0SSmzLTH2RJNf+Ld3eBHiSvLTG53dWB7lJtF4R1KcIwf+KGcOFJv +snepzcZBylRvT8RrAAkV0s9OiVm1lXZyaepbLg4GGFJBPi8A6VIAj7zYknToRApdW0s1x/XX +ChewfJDckqsevTMovdbg8YkCgYEAxDYX+3mfvv/opo6HNNY3SfVunM+4vVJL+n8gWZ2w9kz3 +Q9Ub9YbRmI7iQaiVkO5xNuoG1n9bM+3Mnm84aQ1YeNT01YqeyQsipP5Wi+um0PzYTaBw9RO+ +8Gh6992OwlJiRtFk5WjalNWOxY4MU0ImnJwIfKQlUODvLmcixm68NYsCgYEAuAqI3jkk55Vd +KvotREsX5wP7gPePM+7NYiZ1HNQL4Ab1f/bTojZdTV8Sx6YCR0fUiqMqnE+OBvfkGGBtw22S +Lesx6sWf99Ov58+x4Q0U5dpxL0Lb7d2Z+2Dtp+Z4jXFjNeeI4ae/qG/LOR/b0pE0J5F415ap +7Mpq5v89vepUtrkCgYAjMXytu4v+q1Ikhc4UmRPDrUUQ1WVSd+9u19yKlnFGTFnRjej86hiw +H3jPxBhHra0a53EgiilmsBGSnWpl1WH4EmJz5vBCKUAmjgQiBrueIqv9iHiaTNdjsanUyaWw +jyxXfXl2eI80QPXh02+8g1H/pzESgjK7Rg1AqnkfVH9nrwKBgQDJVxKBPTw9pigYMVt9iHrR +iCl9zQVjRMbWiPOc0J56+/5FZYm/AOGl9rfhQ9vGxXZYZiOP5FsNkwt05Y1UoAAH4B4VQwbL +qod71qOcI0ywgZiIR87CYw40gzRfjWnN+YEEW1qfyoNLilEwJB8iB/T+ZePHGmJ4MmQ/cTn9 +xpdLXA== +-----END RSA PRIVATE KEY----- diff --git a/tests/data/public_certs.json b/tests/data/public_certs.json new file mode 100644 index 000000000..d23f458fc --- /dev/null +++ b/tests/data/public_certs.json @@ -0,0 +1,5 @@ +{ + "mock-key-id-1": "-----BEGIN CERTIFICATE-----\nMIIEFTCCAv2gAwIBAgIJALLYfi2oN8cPMA0GCSqGSIb3DQEBCwUAMIGgMQswCQYD\nVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcxDzAN\nBgNVBAoMBkdvb2dsZTERMA8GA1UECwwIRmlyZWJhc2UxHDAaBgNVBAMME2ZpcmVi\nYXNlLmdvb2dsZS5jb20xKjAoBgkqhkiG9w0BCQEWG3N1cHBvcnRAZmlyZWJhc2Uu\nZ29vZ2xlLmNvbTAeFw0xNzAzMjIwMDM4MzRaFw0yNzAzMjAwMDM4MzRaMIGgMQsw\nCQYDVQQGEwJVUzELMAkGA1UECAwCQ0ExFjAUBgNVBAcMDU1vdW50YWluIFZpZXcx\nDzANBgNVBAoMBkdvb2dsZTERMA8GA1UECwwIRmlyZWJhc2UxHDAaBgNVBAMME2Zp\ncmViYXNlLmdvb2dsZS5jb20xKjAoBgkqhkiG9w0BCQEWG3N1cHBvcnRAZmlyZWJh\nc2UuZ29vZ2xlLmNvbTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMCR\nDXEXr/nl2Sr4YVi4ldy89jLzvp2kER6OfYN72jUJ24Mnbjfy+JK5ayeXOhYopF3b\nSTW/0K7AB0siVgrvI90/Qq7osG4nRgJNltEblYk1bKYx1E7CBxMcfXrljNFKJhr2\nJKKl2oXYTy/81YlUibfkbfGxy7DL6W/EarkyG/YimN0jFdrQqHzZsgkoFW4Iav8u\n5SjkbVfyEYLlrOkdtn25G9K9jAmKDQG2fXap20SBTWQQu4rWLVKExK3rEVnMhTId\nM3wCCcnFjiZVSwy6pGNkpiMfLu37srAoNcHKEmaErbg6tNp2RDMNXcfBxsxNIcp6\nR5ZaESwcriLjgHzAgmMCAwEAAaNQME4wHQYDVR0OBBYEFGmG5dc2YEEDbFA2+SBS\nA13S5l4VMB8GA1UdIwQYMBaAFGmG5dc2YEEDbFA2+SBSA13S5l4VMAwGA1UdEwQF\nMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAAEmICKB6kq/Y++JKHZg88JS4nlWzIFh\nNBrfyCnMQiL9mmllEXQIhK25xleQwQGsBF2odDj+8H9CG/lwWLmyC5+TryFjWrhn\nHlt8QJb8E4dIZkYAxDL/ii6tXfFTjvrXsTcY2moD6ZoOoxahVOjVfwkHup0ONn2v\nsCL/11FneR0jhgruXKoqrKspgNVuYp+t4IKnnePpeGJb/I3SyS9GUXlScV/uWyRw\nLdIoR2teEWcWeNrMLmth0NSa3AF3gd9+HTaGpESsusG4qPamqiSM7+INAeTo4k8b\nlbqLwo3Ju6cNGGlDSsDXIUahpCdKnqxBALytITmIcHwsR4vYaDP4iOE=\n-----END CERTIFICATE-----", + "mock-key-id-2": "-----BEGIN CERTIFICATE-----\nMIIDKjCCAhKgAwIBAgIIBIUnv7pTIx8wDQYJKoZIhvcNAQEFBQAwODE2MDQGA1UE\nAxMtdGVzdC00ODQubWctdGVzdC0xMjEwLmlhbS5nc2VydmljZWFjY291bnQuY29t\nMB4XDTE2MDMxOTE3NTE1NFoXDTE2MDMyMTA2NTE1NFowODE2MDQGA1UEAxMtdGVz\ndC00ODQubWctdGVzdC0xMjEwLmlhbS5nc2VydmljZWFjY291bnQuY29tMIIBIjAN\nBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA7beJFmTrA/T4AeMWk/IjxUlGpaxH\n6D1CYbfxEBJUqzuIe7ujaxh76ik/FPQV5WxlL1GOjW0/f5CsmrNaFmTmQbsK4BY3\n3cCd3gM8LcEtmF1I9NxxpXxrZihlfuwbEpb5NpjGPkCC+fG3gTY7qtjuO6e8pGb2\nVQQguOGXKw/YZLZRZXZ41xkQRYrs+tFw48+4YkjMsYJIxyBMiL5Q/HNAQ2IUyZwr\nuc+CMcWyPLNcnsRNXgnPXQD/GKZQnjjJ5KzQAU1vnDcufL9V5KRhb0kRxTTUjE7D\nJl3x4+J6+hbAheZFu9Fntrxie9TvQuQbEBm/437QFYZphfQli0fDjlPHSwIDAQAB\nozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK\nBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEAQzlUGQiWiHgeBZyUsetuoTiQ\nsxzU7B1qw3la/FQrG+jRFr9GE3yjOOxi9JvX16U/ebwSHLUip8UFf/Ir6AJlt/tt\nIjBA6TOd8DysAtr4PCZrAP/m43H9w4lBWdWl1XJE2YfYQgZnorveAMUZqTo0P0pd\nFo3IsYBSTMflKv2Vqz91PPiHgyu2fk+8TYwJT57rnnkS6VzdORTIf+9ZB+J1ye9i\nQN5IgdZ/eqFiJPD8qT5jOcXelWSWqHHdGrNjQNp+z8jgMusY5/ZAlZUe55eo3I0m\nuDSPImLNkDwqY0+bBW6Fp5xi/4O+gJg3cQ+/PeIHzoFqKAlSpxQZSCziPpGfAA==\n-----END CERTIFICATE-----\n", + "mock-key-id-3": "-----BEGIN CERTIFICATE-----\nMIIC+jCCAeKgAwIBAgIIRKlYUHIlbRkwDQYJKoZIhvcNAQEFBQAwIDEeMBwGA1UE\nAxMVMTAwNzcyNzQyMjQ5MTUwNTc4MjYyMB4XDTE2MDIxMDAxMzI1N1oXDTI2MDIw\nNzAxMzI1N1owIDEeMBwGA1UEAxMVMTAwNzcyNzQyMjQ5MTUwNTc4MjYyMIIBIjAN\nBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAkIqM1JR3h61LDUN+FaTDFnwQkrVD\nG9qDy54QNSxElxgEPRyGhc4KAz7cRTMSWfoCWYkYaW/nZkqTfWlhsawZQU1oK8pj\nxJhUQHYpTISA4DTLmz5R4ng9mVGMqZWaCs4oiPvdizyrxus6RIdQ5bRbZyhl4pzn\n23E8tszCxnFX4KYneyvLtbcXoEvSezhm6n6yT4bNzSZguKxOSZU3XNFPmcBVjYPN\njA2aWzE54uu0ve+JrBVkRq/3XB/OvvJyjIovdxnzF91YJ5KukHJMhZqnQRIc3VcN\njx5+WwSQKE2klR7wB0AsAIs0lxA4wxH/+EUCHM2S5lfrMidz7cOXmMmeJwIDAQAB\nozgwNjAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDAWBgNVHSUBAf8EDDAK\nBggrBgEFBQcDAjANBgkqhkiG9w0BAQUFAAOCAQEAF+V8kmeJQnvpPKlFT74BROi0\n1Eple2mSsyQbtm1kL7FJpl1AXZ4sLXXTVj3ql0LsqVawDCVtUSvDXBSHtejnh0bi\nZ0WUyEEJ38XPfXRilIaTrYP408ezowDaXxrfLhho1EjoMOPgXjksu1FyhBFoHmif\ndLJoxyA4f+8DZ8jj7ew6ZIVEmvONYgctpU72uUh36Vyl84oc9D2GByq/zYDXvVvl\nSKWYZ5+86/eGocO4sosB5QrsEdVGT2Im6mz2DUIewSyIvrDgZ5r3XyL4RXpdi8+8\n9re/meIh5pnhimU4pX9weQia8bqSPf0oZhh0uAWxO5ES7k1GwblnJfxeCZ0xDQ==\n-----END CERTIFICATE-----\n" +} diff --git a/tests/data/service_account.json b/tests/data/service_account.json new file mode 100644 index 000000000..ee8357f86 --- /dev/null +++ b/tests/data/service_account.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "mock-project-id", + "private_key_id": "mock-key-id-1", + "private_key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEAwJENcRev+eXZKvhhWLiV3Lz2MvO+naQRHo59g3vaNQnbgyduN/L4krlr\nJ5c6FiikXdtJNb/QrsAHSyJWCu8j3T9CruiwbidGAk2W0RuViTVspjHUTsIHExx9euWM0Uom\nGvYkoqXahdhPL/zViVSJt+Rt8bHLsMvpb8RquTIb9iKY3SMV2tCofNmyCSgVbghq/y7lKORt\nV/IRguWs6R22fbkb0r2MCYoNAbZ9dqnbRIFNZBC7itYtUoTEresRWcyFMh0zfAIJycWOJlVL\nDLqkY2SmIx8u7fuysCg1wcoSZoStuDq02nZEMw1dx8HGzE0hynpHlloRLByuIuOAfMCCYwID\nAQABAoIBADFtihu7TspAO0wSUTpqttzgC/nsIsNn95T2UjVLtyjiDNxPZLUrwq42tdCFur0x\nVW9Z+CK5x6DzXWvltlw8IeKKeF1ZEOBVaFzy+YFXKTz835SROcO1fgdjyrme7lRSShGlmKW/\nGKY+baUNquoDLw5qreXaE0SgMp0jt5ktyYuVxvhLDeV4omw2u6waoGkifsGm8lYivg5l3VR7\nw2IVOvYZTt4BuSYVwOM+qjwaS1vtL7gv0SUjrj85Ja6zERRdFiITDhZw6nsvacr9/+/aut9E\naL/koSSb62g5fntQMEwoT4hRnjPnAedmorM9Rhddh2TB3ZKTBbMN1tUk3fJxOuECgYEA+z6l\neSaAcZ3qvwpntcXSpwwJ0SSmzLTH2RJNf+Ld3eBHiSvLTG53dWB7lJtF4R1KcIwf+KGcOFJv\nsnepzcZBylRvT8RrAAkV0s9OiVm1lXZyaepbLg4GGFJBPi8A6VIAj7zYknToRApdW0s1x/XX\nChewfJDckqsevTMovdbg8YkCgYEAxDYX+3mfvv/opo6HNNY3SfVunM+4vVJL+n8gWZ2w9kz3\nQ9Ub9YbRmI7iQaiVkO5xNuoG1n9bM+3Mnm84aQ1YeNT01YqeyQsipP5Wi+um0PzYTaBw9RO+\n8Gh6992OwlJiRtFk5WjalNWOxY4MU0ImnJwIfKQlUODvLmcixm68NYsCgYEAuAqI3jkk55Vd\nKvotREsX5wP7gPePM+7NYiZ1HNQL4Ab1f/bTojZdTV8Sx6YCR0fUiqMqnE+OBvfkGGBtw22S\nLesx6sWf99Ov58+x4Q0U5dpxL0Lb7d2Z+2Dtp+Z4jXFjNeeI4ae/qG/LOR/b0pE0J5F415ap\n7Mpq5v89vepUtrkCgYAjMXytu4v+q1Ikhc4UmRPDrUUQ1WVSd+9u19yKlnFGTFnRjej86hiw\nH3jPxBhHra0a53EgiilmsBGSnWpl1WH4EmJz5vBCKUAmjgQiBrueIqv9iHiaTNdjsanUyaWw\njyxXfXl2eI80QPXh02+8g1H/pzESgjK7Rg1AqnkfVH9nrwKBgQDJVxKBPTw9pigYMVt9iHrR\niCl9zQVjRMbWiPOc0J56+/5FZYm/AOGl9rfhQ9vGxXZYZiOP5FsNkwt05Y1UoAAH4B4VQwbL\nqod71qOcI0ywgZiIR87CYw40gzRfjWnN+YEEW1qfyoNLilEwJB8iB/T+ZePHGmJ4MmQ/cTn9\nxpdLXA==\n-----END RSA PRIVATE KEY-----", + "client_email": "mock-email@mock-project.iam.gserviceaccount.com", + "client_id": "1234567890", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/mock-project-id.iam.gserviceaccount.com" +} diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..3c11c0845 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,72 @@ +"""Tests for firebase.App.""" +import pytest + +import firebase +from firebase import auth +from tests import testutils + + +CREDENTIAL = auth.CertificateCredential( + 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_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 + with pytest.raises(ValueError): + firebase.initialize_app(OPTIONS) + + def test_non_default_app_init(self): + app = firebase.initialize_app(OPTIONS, 'myApp') + assert app.name == 'myApp' + assert CREDENTIAL is app.options.credential + with pytest.raises(ValueError): + firebase.initialize_app(OPTIONS, 'myApp') + + @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('name', invalid_names) + def test_app_init_with_invalid_name(self, name): + with pytest.raises(ValueError): + firebase.initialize_app(OPTIONS, name) + + def test_default_app_get(self): + app = firebase.initialize_app(OPTIONS) + assert app is firebase.get_app() + + def test_non_default_app_get(self): + app = firebase.initialize_app(OPTIONS, 'myApp') + assert app is firebase.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) + + @pytest.mark.parametrize('name', invalid_names) + def test_app_get_with_invalid_name(self, name): + with pytest.raises(ValueError): + firebase.get_app(name) diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 000000000..3bdf26f61 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,245 @@ +"""Test cases for firebase.auth module.""" +import os +import time + +from oauth2client import client +from oauth2client import crypt +import pytest + +import firebase +from firebase import auth +from firebase import jwt +from tests import testutils + + +FIREBASE_AUDIENCE = ('https://identitytoolkit.googleapis.com/' + 'google.identity.identitytoolkit.v1.IdentityToolkit') + +MOCK_UID = 'user1' +MOCK_CREDENTIAL = auth.CertificateCredential( + testutils.resource_filename('service_account.json')) +MOCK_PUBLIC_CERTS = testutils.resource('public_certs.json') +MOCK_PRIVATE_KEY = testutils.resource('private_key.pem') +MOCK_SERVICE_ACCOUNT_EMAIL = MOCK_CREDENTIAL.service_account_email + + +class AuthFixture(object): + def __init__(self, name=None): + if name: + self.app = firebase.get_app(name) + else: + self.app = None + + def create_custom_token(self, *args): + if self.app: + return auth.create_custom_token(*args, app=self.app) + else: + return auth.create_custom_token(*args) + + def verify_id_token(self, *args): + if self.app: + return auth.verify_id_token(*args, app=self.app) + else: + return auth.verify_id_token(*args) + +def setup_module(): + firebase.initialize_app({'credential': MOCK_CREDENTIAL}) + firebase.initialize_app({'credential': MOCK_CREDENTIAL}, 'testApp') + +def teardown_module(): + firebase.delete_app('[DEFAULT]') + firebase.delete_app('testApp') + +@pytest.fixture(params=[None, 'testApp'], ids=['DefaultApp', 'CustomApp']) +def authtest(request): + """Returns an AuthFixture instance. + + Instances returned by this fixture are parameterized to use either the defult App instance, + or a custom App instance named 'testApp'. Due to this parameterization, each test case that + depends on this fixture will get executed twice (as two test cases); once with the default + App, and once with the custom App. + """ + return AuthFixture(request.param) + +@pytest.fixture +def non_cert_app(): + """Returns an App instance initialized with a mock non-cert credential. + + The lines of code following the yield statement are guaranteed to run after each test case + 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') + yield app + firebase.delete_app(app.name) + +def verify_custom_token(custom_token, expected_claims): + assert isinstance(custom_token, basestring) + token = client.verify_id_token( + custom_token, + FIREBASE_AUDIENCE, + http=testutils.HttpMock(200, MOCK_PUBLIC_CERTS)) + assert token['uid'] == MOCK_UID + assert token['iss'] == MOCK_SERVICE_ACCOUNT_EMAIL + assert token['sub'] == MOCK_SERVICE_ACCOUNT_EMAIL + header, _ = jwt.decode(custom_token) + assert header.get('typ') == 'JWT' + assert header.get('alg') == 'RS256' + if expected_claims: + for key, value in expected_claims.items(): + assert value == token['claims'][key] + +def _merge_jwt_claims(defaults, overrides): + defaults.update(overrides) + for key, value in overrides.items(): + if value is None: + del defaults[key] + return defaults + +def get_id_token(payload_overrides=None, header_overrides=None): + signer = crypt.Signer.from_string(MOCK_PRIVATE_KEY) + headers = { + 'kid': 'mock-key-id-1' + } + payload = { + 'aud': MOCK_CREDENTIAL.project_id, + 'iss': 'https://securetoken.google.com/' + MOCK_CREDENTIAL.project_id, + 'iat': int(time.time()) - 100, + 'exp': int(time.time()) + 3600, + 'sub': '1234567890', + 'admin': True, + } + if header_overrides: + headers = _merge_jwt_claims(headers, header_overrides) + if payload_overrides: + payload = _merge_jwt_claims(payload, payload_overrides) + return jwt.encode(payload, signer, headers=headers) + + +class TestCreateCustomToken(object): + + valid_args = { + 'Basic': (MOCK_UID, {'one': 2, 'three': 'four'}), + 'NoDevClaims': (MOCK_UID, None), + 'EmptyDevClaims': (MOCK_UID, {}), + } + + invalid_args = { + 'NoUid': (None, None, ValueError), + 'EmptyUid': ('', None, ValueError), + 'LongUid': ('x'*129, None, ValueError), + 'BoolUid': (True, None, ValueError), + 'IntUid': (1, None, ValueError), + 'ListUid': ([], None, ValueError), + 'EmptyDictUid': ({}, None, ValueError), + 'NonEmptyDictUid': ({'a':1}, None, ValueError), + 'BoolClaims': (MOCK_UID, True, ValueError), + 'IntClaims': (MOCK_UID, 1, ValueError), + 'StrClaims': (MOCK_UID, 'foo', ValueError), + 'ListClaims': (MOCK_UID, [], ValueError), + 'TupleClaims': (MOCK_UID, (1, 2), ValueError), + 'ReservedClaims': (MOCK_UID, {'sub':'1234'}, ValueError), + } + + @pytest.mark.parametrize('user,claims', valid_args.values(), + ids=valid_args.keys()) + def test_valid_params(self, authtest, user, claims): + verify_custom_token(authtest.create_custom_token(user, claims), claims) + + @pytest.mark.parametrize('user,claims,error', invalid_args.values(), + ids=invalid_args.keys()) + def test_invalid_params(self, authtest, user, claims, error): + with pytest.raises(error): + authtest.create_custom_token(user, claims) + + def test_noncert_credential(self, non_cert_app): + with pytest.raises(ValueError): + auth.create_custom_token(MOCK_UID, app=non_cert_app) + + +class TestVerifyIdToken(object): + + invalid_tokens = { + 'NoKid': (get_id_token(header_overrides={'kid': None}), + crypt.AppIdentityError), + 'WrongKid': (get_id_token(header_overrides={'kid': 'foo'}), + client.VerifyJwtTokenError), + 'WrongAlg': (get_id_token(header_overrides={'alg': 'HS256'}), + crypt.AppIdentityError), + 'BadAudience': (get_id_token({'aud': 'bad-audience'}), + crypt.AppIdentityError), + 'BadIssuer': (get_id_token({ + 'iss': 'https://securetoken.google.com/wrong-issuer' + }), crypt.AppIdentityError), + 'EmptySubject': (get_id_token({'sub': ''}), + crypt.AppIdentityError), + 'IntSubject': (get_id_token({'sub': 10}), + crypt.AppIdentityError), + 'LongStrSubject': (get_id_token({'sub': 'a' * 129}), + crypt.AppIdentityError), + 'FutureToken': (get_id_token({'iat': int(time.time()) + 1000}), + crypt.AppIdentityError), + 'ExpiredToken': (get_id_token({ + 'iat': int(time.time()) - 10000, + 'exp': int(time.time()) - 3600 + }), crypt.AppIdentityError), + 'NoneToken': (None, ValueError), + 'EmptyToken': ('', ValueError), + 'BoolToken': (True, ValueError), + 'IntToken': (1, ValueError), + 'ListToken': ([], ValueError), + 'EmptyDictToken': ({}, ValueError), + 'NonEmptyDictToken': ({'a': 1}, ValueError), + 'BadFormatToken': ('foobar', crypt.AppIdentityError) + } + + def setup_method(self): + auth._http = testutils.HttpMock(200, MOCK_PUBLIC_CERTS) + + def test_valid_token(self, authtest): + id_token = get_id_token() + claims = authtest.verify_id_token(id_token) + assert claims['admin'] is True + + @pytest.mark.parametrize('id_token,error', invalid_tokens.values(), + ids=invalid_tokens.keys()) + def test_invalid_token(self, authtest, id_token, error): + with pytest.raises(error): + authtest.verify_id_token(id_token) + + def test_project_id_env_var(self, non_cert_app): + id_token = get_id_token() + gcloud_project = os.environ.get(auth.GCLOUD_PROJECT_ENV_VAR) + try: + os.environ[auth.GCLOUD_PROJECT_ENV_VAR] = MOCK_CREDENTIAL.project_id + claims = auth.verify_id_token(id_token, non_cert_app) + assert claims['admin'] is True + finally: + if gcloud_project: + os.environ[auth.GCLOUD_PROJECT_ENV_VAR] = gcloud_project + else: + del os.environ[auth.GCLOUD_PROJECT_ENV_VAR] + + def test_no_project_id(self, non_cert_app): + id_token = get_id_token() + gcloud_project = os.environ.get(auth.GCLOUD_PROJECT_ENV_VAR) + if gcloud_project: + del os.environ[auth.GCLOUD_PROJECT_ENV_VAR] + try: + with pytest.raises(ValueError): + auth.verify_id_token(id_token, non_cert_app) + finally: + if gcloud_project: + os.environ[auth.GCLOUD_PROJECT_ENV_VAR] = gcloud_project + + def test_custom_token(self, authtest): + id_token = authtest.create_custom_token(MOCK_UID) + with pytest.raises(crypt.AppIdentityError): + authtest.verify_id_token(id_token) + + def test_certificate_request_failure(self, authtest): + id_token = get_id_token() + auth._http = testutils.HttpMock(404, 'not found') + with pytest.raises(client.VerifyJwtTokenError): + authtest.verify_id_token(id_token) diff --git a/tests/testutils.py b/tests/testutils.py new file mode 100644 index 000000000..06ad5f096 --- /dev/null +++ b/tests/testutils.py @@ -0,0 +1,43 @@ +"""Common utility classes and functions for testing.""" +import os + +import httplib2 + +import firebase + + +def resource_filename(filename): + """Returns the absolute path to a test resource.""" + return os.path.join(os.path.dirname(__file__), 'data', filename) + + +def resource(filename): + """Returns the contents of a test resource.""" + with open(resource_filename(filename), 'r') as file_obj: + return file_obj.read() + + +def cleanup_apps(): + with firebase._apps_lock: + app_names = list(firebase._apps.keys()) + for name in app_names: + firebase.delete_app(name) + + +class HttpMock(object): + """A mock HTTP client implementation. + + This can be used whenever an HTTP interaction needs to be mocked + for testing purposes. For example HTTP calls to fetch public key + certificates, and HTTP calls to retrieve access tokens can be + mocked using this class. + """ + + def __init__(self, status, response): + self.status = status + self.response = response + + def request(self, *args, **kwargs): + del args + del kwargs + return httplib2.Response({'status': self.status}), self.response