Merge commit 'ae733129' into oauth2client v3.0.0 Initial commitcd of oauth2client v3.0.0 with history.
am: 1af3acd3c3
Change-Id: Ie05565822188daa03fbda84f568328e7fb090694
diff --git a/.coveragerc b/.coveragerc
new file mode 100644
index 0000000..0151e07
--- /dev/null
+++ b/.coveragerc
@@ -0,0 +1,12 @@
+[report]
+omit =
+ */samples/*
+ # Don't report coverage over platform-specific modules.
+ oauth2client/contrib/_fcntl_opener.py
+ oauth2client/contrib/_win32_opener.py
+ oauth2client/contrib/django_util/apps.py
+exclude_lines =
+ # Re-enable the standard pragma
+ pragma: NO COVER
+ # Ignore debug-only repr
+ def __repr__
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..89c1121
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,30 @@
+# Build artifacts
+*.py[cod]
+oauth2client.egg-info/
+build/
+dist/
+
+# Documentation-related
+docs/_build
+/google_appengine/
+
+# Test files
+.tox/
+
+# Django test database
+db.sqlite3
+
+# Coverage files
+.coverage
+coverage.xml
+nosetests.xml
+htmlcov/
+
+# Files with private / local data
+scripts/local_test_setup
+tests/data/key.json
+tests/data/key.p12
+tests/data/user-key.json
+
+# PyCharm configuration:
+.idea
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 0000000..a8c01fa
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,43 @@
+language: python
+python: 2.7
+sudo: false
+# TODO(issue 532): Fix syntax when 3.5 is natively available upstream
+matrix:
+ include:
+ - python: 3.5
+ env:
+ - TOX_ENV=py35
+env:
+ matrix:
+ - TOX_ENV=py26
+ - TOX_ENV=py27
+ - TOX_ENV=py33
+ - TOX_ENV=py34
+ - TOX_ENV=pypy
+ - TOX_ENV=docs
+ - TOX_ENV=system-tests
+ - TOX_ENV=system-tests3
+ - TOX_ENV=gae
+ - TOX_ENV=flake8
+ global:
+ - GAE_PYTHONPATH=${HOME}/.cache/google_appengine
+cache:
+ directories:
+ - ${HOME}/.cache
+install:
+- ./scripts/install.sh
+script:
+- ./scripts/run.sh
+after_success:
+- if [[ "${TOX_ENV}" == "gae" ]]; then tox -e coveralls; fi
+notifications:
+ email: false
+
+deploy:
+ provider: pypi
+ user: gcloudpypi
+ password:
+ secure: "C9ImNa5kbdnrQNfX9ww4PUtQIr3tN+nfxl7eDkP1B8Qr0QNYjrjov7x+DLImkKvmoJd3dxYtYIpLE9esObUHu0gKHYxqymNHtuAAyoBOUfPtmp0vIEse9brNKMtaey5Ngk7ZWz9EHKBBqRHxqgN+Giby+K9Ta3K3urJIq6urYhE="
+ on:
+ tags: true
+ repo: google/oauth2client
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..bf33dea
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,425 @@
+# CHANGELOG
+
+## v3.0.0
+
+* Populate `token_expiry` for GCE credentials. (#473)
+* Move GCE metadata interface to a separate module. (#520)
+* Populate `scopes` for GCE credentials. (#524)
+* Fix Python 3.5 compatibility. (#531)
+* Add `oauth2client.contrib.sqlalchemy`, a SQLAlchemy-based credential store. (#527)
+* Improve error when an invalid client secret is provided. (#530)
+* Add `oauth2client.contrib.multiprocess_storage`. This supersedes the functionality in `oauth2client.contrib.multistore_file`. (#504)
+* Pull httplib2 usage into a separate transport module. (#559, #561)
+* Refactor all django-related code into `oauth2client.contrib.django_util`. Add `DjangoORMStorage`, remove `FlowField`. (#546)
+* Fix application default credentials resolution order. (#570)
+* Add configurable timeout for GCE metadata server check. (#571)
+* Add warnings when using deprecated `approval_prompt='force'`. (#572)
+* Add deprecation warning to `oauth2client.contrib.multistore_file`. (#574)
+* (Hygiene) PEP8 compliance and various style fixes (#537, #540, #552, #562)
+* (Hygiene) Remove duplicated exception classes in `oauth2client.contrib.appengine`. (#533)
+
+NOTE: The next major release of oauth2client (v4.0.0) will remove the `oauth2client.contrib.multistore_file` module.
+
+## v2.2.0
+
+* Added support to override `token_uri` and `revoke_uri` in `oauth2client.service_account.ServiceAccountCredentials`. (#510)
+* `oauth2client.contrib.multistore_file` now handles `OSError` in addition to `IOError` because Windows may raise `OSError` where other platforms will raise `IOError`.
+* `oauth2client.contrib.django_util` and `oauth2client.contrib.django_orm` have been updated to support Django 1.8 - 1.10. Versions of Django below 1.8 will not work with these modules.
+
+## v2.1.0
+
+* Add basic support for JWT access credentials. (#503)
+* Fix `oauth2client.client.DeviceFlowInfo` to use UTC instead of the system timezone when calculating code expiration.
+
+## v2.0.2
+
+* Fix issue where `flask_util.UserOAuth2.required` would accept expired credentials (#452).
+* Fix issue where `flask_util` would fill the session with `Flow` objects (#498).
+* Fix issue with Python 3 binary strings in `Flow.step2_exchange` (#446).
+* Improve test coverage to 100%.
+
+## v2.0.1
+
+* Making scopes optional on Google Compute Engine `AppAssertionCredentials`
+ and adding a warning that GCE won't honor scopes (#419)
+* Adding common `sign_blob()` to service account types and a
+ `service_account_email` property. (#421)
+* Improving error message in P12 factory
+ `ServiceAccountCredentials.from_p12_keyfile` when pyOpenSSL is
+ missing. (#424)
+* Allowing default flags in `oauth2client.tools.run_flow()`
+ rather than forcing users to create a dummy argparser (#426)
+* Removing `oauth2client.util.dict_to_tuple_key()` from public
+ interface (#429)
+* Adding `oauth2client.contrib._appengine_ndb` helper module
+ for `oauth2client.contrib.appengine` and moving most code that
+ uses the `ndb` library into the helper (#434)
+* Fix error in `django_util` sample code (#438)
+
+## v2.0.0-post1
+
+* Fix Google Compute Engine breakage (#411, breakage introduced in #387) that
+ made it impossible to obtain access tokens
+* Implement `ServiceAccountCredentials.from_p12_keyfile_buffer()`
+ to allow passing a file-like object in addition to the factory
+ constructor that uses a filename directly (#413)
+* Implement `ServiceAccountCredentials.create_delegated()`
+ to allow upgrading a credential to one that acts on behalf
+ of a given subject (#420)
+
+## v2.0.0
+
+* Add django_util (#332)
+* Avoid OAuth2Credentials `id_token` going out of sync after a token
+ refresh (#337)
+* Move to a `contrib` sub-package code not considered a core part of
+ the library (#346, #353, #370, #375, #376, #382)
+* Add `token_expiry` to `devshell` credentials (#372)
+* Move `Storage` locking into a base class (#379)
+* Added dictionary storage (#380)
+* Added `to_json` and `from_json` methods to all `Credentials`
+ classes (#385)
+* Fall back to read-only credentials on EACCES errors (#389)
+* Coalesced the two `ServiceAccountCredentials`
+ classes (#395, #396, #397, #398, #400)
+
+### Special Note About `ServiceAccountCredentials`:
+-------------------------------------------------
+
+For JSON keys, you can create a credential via
+
+```py
+from oauth2client.service_account import ServiceAccountCredentials
+credentials = ServiceAccountCredentials.from_json_keyfile_name(
+ key_file_name, scopes=[...])
+```
+
+You can still rely on
+
+```py
+from oauth2client.client import GoogleCredentials
+credentials = GoogleCredentials.get_application_default()
+```
+
+returning these credentials when you set the `GOOGLE_APPLICATION_CREDENTIALS`
+environment variable.
+
+For `.p12` keys, construct via
+
+```py
+credentials = ServiceAccountCredentials.from_p12_keyfile(
+ service_account_email, key_file_name, scopes=[...])
+```
+
+though we urge you to use JSON keys (rather than `.p12` keys) if you can.
+
+This is equivalent to the previous method
+
+```py
+# PRE-oauth2client 2.0.0 EXAMPLE CODE!
+from oauth2client.client import SignedJwtAssertionCredentials
+
+with open(key_file_name, 'rb') as key_file:
+ private_key = key_file.read()
+
+credentials = SignedJwtAssertionCredentials(
+ service_account_email, private_key, scope=[...])
+```
+
+## v1.5.2
+
+* Add access token refresh error class that includes HTTP status (#310)
+* Python3 compatibility fixes for Django (#316, #318)
+* Fix incremental auth in flask_util (#322)
+* Fall back to credential refresh on EDEADLK in multistore_file (#336)
+
+## v1.5.1
+
+* Fix bad indent in `tools.run_flow()` (#301, bug was
+ introduced when switching from 2 space indents to 4)
+
+## v1.5.0
+
+* Fix (more like clarify) `bytes` / `str` handling in crypto
+ methods. (#203, #250, #272)
+* Replacing `webapp` with `webapp2` in `oauth2client.appengine` (#217)
+* Added optional `state` parameter to
+ `step1_get_authorize_url`. (#219 and #222)
+* Added `flask_util` module that provides a Flask extension to aid
+ with using OAuth2 web server flow. This provides the same functionality
+ as the `appengine.webapp2` OAuth2Decorator, but will work with any Flask
+ application regardless of hosting environment. (#226, #273)
+* Track scopes used on credentials objects (#230)
+* Moving docs to [readthedocs.org][1] (#237, #238, #244)
+* Removing `old_run` module. Was deprecated July 2, 2013. (#285)
+* Avoid proxies when querying for GCE metadata (to check if
+ running on GCE) (#114, #293)
+
+[1]: https://readthedocs.org/
+
+## v1.4.12
+
+* Fix OS X flaky test failure (#189).
+* Fix broken OpenSSL import (#191).
+* Remove `@util.positional` from wrapped request in `Credentials.authorize()`
+ (#196, #197).
+* Changing pinned dependencies to `>=` (#200, #204).
+* Support client authentication using `Authorization` header (#206).
+* Clarify environment check in case where GAE imports succeed but GAE services
+ aren't available (#208).
+
+## v1.4.11
+
+* Better environment detection with Managed VMs.
+* Better OpenSSL detection in exotic environments.
+
+## v1.4.10
+
+* Update the `OpenSSL` check to be less strict about finding `crypto.py` in
+ the `OpenSSL` directory.
+* `tox` updates for new environment handling in `tox`.
+
+## v1.4.9
+
+* Ensure that the ADC fails if we try to *write* the well-known file to a
+ directory that doesn't exist, but not if we try to *read* from one.
+
+## v1.4.8
+
+* Better handling of `body` during token refresh when `body` is a stream.
+* Better handling of expired tokens in storage.
+* Cleanup around `openSSL` import.
+* Allow custom directory for the `well_known_file`.
+* Integration tests for python2 and python3. (!!!)
+* Stricter file permissions when saving the `well_known_file`.
+* Test cleanup around config file locations.
+
+## v1.4.7
+
+* Add support for Google Developer Shell credentials.
+* Better handling of filesystem errors in credential refresh.
+* python3 fixes
+* Add `NO_GCE_CHECK` for skipping GCE detection.
+* Better error messages on `InvalidClientSecretsError`.
+* Comment cleanup on `run_flow`.
+
+## v1.4.6
+
+* Add utility function to convert PKCS12 key to PEM. (#115)
+* Change GCE detection logic. (#93)
+* Add a tox env for doc generation.
+
+## v1.4.5
+
+* Set a shorter timeout for an Application Default Credentials issue on some
+ networks. (#93, #101)
+* Test cleanup, switch from mox to mock. (#103)
+* Switch docs to sphinx from epydoc.
+
+## v1.4.4
+
+* Fix a bug in bytes/string encoding of headers.
+
+## v1.4.3
+
+* Big thanks to @dhermes for spotting and fixing a mess in our test setup.
+
+* Fix a serious issue with tests not being run. (#86, #87, #89)
+* Start credentials cleanup for single 2LO/3LO call. (#83, #84)
+* Clean up stack traces when re-raising in some places. (#79)
+* Clean up doc building. (#81, #82)
+* Fixed minimum version for `six` dependency. (#75)
+
+## v1.4.2
+
+* Several small bugfixes related to `six`/py3 support.
+
+## v1.4.1
+
+* Fix a critical bug on import in `oauth2client.tools`.
+
+## v1.4
+
+* Merge python3 branch! Massive thanks due to @pferate and @methane for doing
+ the heavy lifting.
+
+* Make `oauth2client.tools` import gracefully if `argparse` isn't present.
+
+* Change `flow.step2_exchange` to preserve the raw `id_token` in the
+ `token_response` field.
+
+## v1.3.2
+
+* Quick bugfix for an issue with dict-like arguments to `flow.step2_exchange`,
+ which is common in some environments (such as GAE).
+
+## v1.3.1
+
+* Quick bugfix for bad error handling in from_json.
+
+## v1.3
+
+* Added support for the
+ [Google Application Default Credentials](https://developers.google.com/accounts/docs/application-default-credentials)
+ for more information (thanks @orestica).
+* Added support for OAuth2 for devices (#3, thanks @sde-melo).
+* The minimum required Python version is now 2.6.
+* The `anyjson` submodule has been removed.
+
+* Better exception handling around missing crypto libraries (#56).
+* Improve error messages in `AccessTokenRefreshError` (#53, thanks
+ @erickoledadevrel).
+* Drop `uritemplate` as a dependency.
+* Handle X509 certs with PyCrypto (#51, thanks @liujin-google).
+* Handle additional failure types on OSX (#32, thanks @simoncadman).
+* Better unicode handling with PKCS12 passwords (#31, thanks @jterrace).
+* Better retry handling with bad server replies on refresh (#29, thanks
+ @kaste).
+* Better logging for missing `refresh_token` in server replies (#21).
+* Support `login_hint` (#18, thanks @jay0lee).
+* Better overwrite options in `django_orm.Storage`. (#2, thanks @lraccomando).
+
+
+## v1.2
+
+* The use of the `gflags` library is now deprecated, and is no longer a
+ dependency. If you are still using the `oauth2client.tools.run()` function
+ then include `python-gflags` as a dependency of your application or switch to
+ `oauth2client.tools.run_flow`.
+* Samples have been updated to use the new `apiclient.sample_tools`, and no
+ longer use `gflags`.
+* Added support for the experimental Object Change Notification, as found in
+ the Cloud Storage API.
+* The oauth2client App Engine decorators are now threadsafe.
+
+* Use the following redirects feature of httplib2 where it returns the
+ ultimate URL after a series of redirects to avoid multiple hops for every
+ resumable media upload request.
+* Updated AdSense Management API samples to V1.3
+* Add option to automatically retry requests.
+* Ability to list registered keys in `multistore_file`.
+* User-agent must contain `(gzip)`.
+* The `method` parameter for `httplib2` is not positional. This would cause
+ spurious warnings in the logging.
+* Making OAuth2Decorator more extensible. Fixes Issue 256.
+* Update AdExchange Buyer API examples to version v1.2.
+
+
+## v1.1
+
+* Add PEM support to `SignedJWTAssertionCredentials` (used to only support
+ PKCS12 formatted keys). Note that if you use PEM formatted keys you can use
+ PyCrypto 2.6 or later instead of OpenSSL.
+
+* Allow deserialized discovery docs to be passed to `build_from_document()`.
+
+* Make `ResumableUploadError` derive from `HttpError`.
+* Many changes to move all the closures in `apiclient.discovery` into real
+ classes and objects.
+* Make `from_json` behavior inheritable.
+* Expose the full token response in `OAuth2Client` and `OAuth2Decorator`.
+* Handle reasons that are None.
+* Added support for NDB based storing of oauth2client objects.
+* Update `grant_type` for `AssertionCredentials`.
+* Adding a `.revoke()` to Credentials. Closes issue 98.
+* Modify `oauth2client.multistore_file` to store and retrieve credentials
+ using an arbitrary key.
+* Don't accept `403` challenges by default for auth challenges.
+* Set `httplib2.RETRIES` to 1.
+* Consolidate handling of scopes.
+* Upgrade to httplib2 version 0.8.
+* Allow setting the `response_type` in `OAuth2WebServerFlow`.
+* Ensure that `dataWrapper` feature is checked before using the `data` value.
+* HMAC verification does not use a constant time algorithm.
+
+## v1.0
+
+* Changes to the code for running tests and building releases.
+
+## v1.0c3
+
+* In samples and oauth2 decorator, escape untrusted content before displaying it.
+* Do not allow credentials files to be symlinks.
+* Add XSRF protection to oauth2decorator callback state.
+* Handle uploading chunked media by stream.
+* Handle passing streams directly to httplib2.
+* Add support for Google Compute Engine service accounts.
+* Flows no longer need to be saved between uses.
+* Change GET to POST if URI is too long. Fixes issue 96.
+* Add a `keyring`-based `Storage`.
+* More robust picking up JSON error responses.
+* Make batch errors align with normal errors.
+* Add a Google Compute sample.
+* Token refresh to work with old GData API.
+* Loading of `client_secrets` JSON file backed by a cache.
+* Switch to new discovery path parameters.
+* Add support for `additionalProperties` when printing schema'd objects.
+* [Fix media upload parameter names.](http://codereview.appspot.com/6374062/)
+* oauth2client support for URL-encoded format of exchange token response (e.g.
+ Facebook)
+* Build cleaner and easier to read docs for dynamic surfaces.
+
+## v1.0c2
+
+* Parameter values of None should be treated as missing. Fixes issue 144.
+* Distribute the samples separately from the library source. Fixes issue 155.
+* Move all remaining samples over to `client_secrets.json`. Fixes issue 156.
+* Make `locked_file.py` understand win32file primitives for better
+ awesomeness.
+
+## v1.0c1
+
+* Documentation for the library has
+ [switched to epydoc](http://google-api-python-client.googlecode.com/hg/docs/epy/index.html)
+* Many improvements for media support:
+ + Added media download support, including resumable downloads.
+ + Better handling of streams that report their size as 0.
+ + Update `MediaUpload` to include `io.Base` and also fix some bugs.
+* OAuth bug fixes and improvements.
+ + Remove OAuth 1.0 support.
+ + Added `credentials_from_code` and `credentials_from_clientsecrets_and_code`.
+ + Make oauth2client support Windows-friendly locking.
+ + Fix bug in `StorageByKeyName`.
+ + Fix `None` handling in Django fields.
+ [Fixes issue 128](http://codereview.appspot.com/6298084/).
+* [Add epydoc generated docs.](http://codereview.appspot.com/6305043/)
+* Move to PEP386 compliant version numbers.
+* New and updated samples
+ + Ad Exchange Buyer API v1 code samples.
+ + Automatically generate Samples wiki page from `README` files.
+ + Update Google Prediction samples.
+ + Add a Tasks sample that demonstrates Service accounts.
+ + [new analytics api samples.](http://codereview.appspot.com/5494058/)
+* Convert all inline samples to the Farm API for consistency.
+
+## v1.0beta8
+
+* Updated media upload support.
+* Many fixes for batch requests.
+* Better handling for requests that don't require a body.
+* Fix issues with Google App Engine Python 2.7 runtime.
+* Better support for proxies.
+* All Storages now have a `.delete()` method.
+* Important changes which might break your code:
+ + `apiclient.anyjson` has moved to `oauth2client.anyjson`.
+ + Some calls, for example, `taskqueue().lease()` used to require a parameter
+ named body. In this new release only methods that really need to send a
+ body require a body parameter, and so you may get errors about an unknown
+ `body` parameter in your call. The solution is to remove the unneeded
+ `body={}` parameter.
+
+## v1.0beta7
+
+* Support for
+ [batch requests](http://code.google.com/p/google-api-python-client/wiki/Batch).
+* Support for
+ [media upload](http://code.google.com/p/google-api-python-client/wiki/MediaUpload).
+* Better handling for APIs that return something other than JSON.
+* Major cleanup and consolidation of the samples.
+* Bug fixes and other enhancements:
+ 72 Defect Appengine OAuth2Decorator: Convert redirect address to string
+ 22 Defect Better error handling for unknown service name or version
+ 48 Defect StorageByKeyName().get() has side effects
+ 50 Defect Need sample client code for Admin Audit API
+ 28 Defect better comments for app engine sample Nov 9
+ 63 Enhancement Let OAuth2Decorator take a list of scope
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..15b9455
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,208 @@
+Contributing
+============
+
+1. **Please sign one of the contributor license agreements [below][6].**
+1. [File an issue][9] to notify the maintainers about what you're working on.
+1. [Fork the repo][10], develop and [test][11] your code changes, add docs.
+1. Make sure that your commit messages clearly describe the changes.
+1. [Send][12] a pull request.
+
+Here are some guidelines for hacking on `oauth2client`.
+
+Before writing code, file an issue
+----------------------------------
+
+Use the [issue tracker][7] to start the discussion. It is possible that someone
+else is already working on your idea, your approach is not quite right, or that
+the functionality exists already. The ticket you file in the issue tracker will
+be used to hash that all out.
+
+Fork `oauth2client`
+-------------------
+
+We will use GitHub's mechanism for [forking][8] repositories and making pull
+requests. Fork the repository, and make your changes in the forked repository.
+
+Include tests
+-------------
+
+Be sure to add the relevant tests before making the pull request. Docs will be
+updated automatically when we merge to `master`, but you should also build
+the docs yourself via `tox -e docs` and make sure they're readable.
+
+Make the pull request
+---------------------
+
+Once you have made all your changes, tests, and updated the documentation,
+make a pull request to move everything back into the main `oauth2client`
+repository. Be sure to reference the original issue in the pull request.
+Expect some back-and-forth with regards to style and compliance of these
+rules. In particular:
+* `oauth2client` follows the [Google Python Style Guide][GooglePythonStyle].
+* Follow [these guidelines][GitCommitRules] when authoring your commit message.
+
+Using a Development Checkout
+----------------------------
+
+You’ll have to create a development environment to hack on
+`oauth2client`, using a Git checkout:
+
+- While logged into your GitHub account, navigate to the `oauth2client`
+ [repo][1] on GitHub.
+- Fork and clone the `oauth2client` repository to your GitHub account
+ by clicking the "Fork" button.
+- Clone your fork of `oauth2client` from your GitHub account to your
+ local computer, substituting your account username and specifying
+ the destination as `hack-on-oauth2client`. For example:
+
+ ```bash
+ $ cd ${HOME}
+ $ git clone git@github.com:USERNAME/oauth2client.git hack-on-oauth2client
+ $ cd hack-on-oauth2client
+ $ # Configure remotes such that you can pull changes from the oauth2client
+ $ # repository into your local repository.
+ $ git remote add upstream https://github.com:google/oauth2client
+ $ # fetch and merge changes from upstream into master
+ $ git fetch upstream
+ $ git merge upstream/master
+ ```
+
+Now your local repo is set up such that you will push changes to your
+GitHub repo, from which you can submit a pull request.
+
+- Create a virtualenv in which to install `oauth2client`:
+
+ ```bash
+ $ cd ~/hack-on-oauth2client
+ $ virtualenv -ppython2.7 env
+ ```
+
+ Note that very old versions of virtualenv (virtualenv versions
+ below, say, 1.10 or thereabouts) require you to pass a
+ `--no-site-packages` flag to get a completely isolated environment.
+
+ You can choose which Python version you want to use by passing a
+ `-p` flag to `virtualenv`. For example, `virtualenv -ppython2.7`
+ chooses the Python 2.7 interpreter to be installed.
+
+ From here on in within these instructions, the
+ `~/hack-on-oauth2client/env` virtual environment you created above will be
+ referred to as `$VENV`. To use the instructions in the steps that
+ follow literally, use the `export VENV=~/hack-on-oauth2client/env`
+ command.
+
+- Install `oauth2client` from the checkout into the virtualenv using
+ `setup.py develop`. Running `setup.py develop` **must** be done while
+ the current working directory is the `oauth2client` checkout
+ directory:
+
+ ```bash
+ $ cd ~/hack-on-oauth2client
+ $ $VENV/bin/python setup.py develop
+ ```
+
+Running Tests
+--------------
+
+- To run all tests for `oauth2client` on a single Python version, run
+ `nosetests` from your development virtualenv (See
+ **Using a Development Checkout** above).
+
+- To run the full set of `oauth2client` tests on all platforms, install
+ [`tox`][2] into a system Python. The `tox` console script will be
+ installed into the scripts location for that Python. While in the
+ `oauth2client` checkout root directory (it contains `tox.ini`),
+ invoke the `tox` console script. This will read the `tox.ini` file and
+ execute the tests on multiple Python versions and platforms; while it runs,
+ it creates a virtualenv for each version/platform combination. For
+ example:
+
+ ```bash
+ $ sudo pip install tox
+ $ cd ~/hack-on-oauth2client
+ $ tox
+ ```
+
+- In order to run the `pypy` environment (in `tox`) you'll need at
+ least version 2.6 of `pypy` installed. See the [docs][13] for
+ more information.
+
+- **Note** that `django` related tests are turned off for Python 2.6
+ and 3.3. This is because `django` dropped support for
+ [2.6 in `django==1.7`][14] and for [3.3 in `django==1.9`][15].
+
+Running System Tests
+--------------------
+
+- To run system tests you can execute:
+
+ ```bash
+ $ tox -e system-tests
+ $ tox -e system-tests3
+ ```
+
+ This alone will not run the tests. You'll need to change some local
+ auth settings and download some service account configuration files
+ from your project to run all the tests.
+
+- System tests will be run against an actual project and so you'll need to
+ provide some environment variables to facilitate this.
+
+ - `OAUTH2CLIENT_TEST_JSON_KEY_PATH`: The path to a service account JSON
+ key file; see `tests/data/gcloud/application_default_credentials.json`
+ as an example. Such a file can be downloaded directly from the
+ developer's console by clicking "Generate new JSON key". See private
+ key [docs][3] for more details.
+ - `OAUTH2CLIENT_TEST_P12_KEY_PATH`: The path to a service account
+ P12/PKCS12 key file. You can download this in the same way as a JSON
+ key, just select "P12 Key" as your "Key type" when downloading.
+ - `OAUTH2CLIENT_TEST_P12_KEY_EMAIL`: The service account email
+ corresponding to the P12/PKCS12 key file.
+ - `OAUTH2CLIENT_TEST_USER_KEY_PATH`: The path to a JSON key file for a
+ user. If this is not set, the file created by running
+ `gcloud auth login` will be used. See
+ `tests/data/gcloud/application_default_credentials_authorized_user.json`
+ for an example.
+ - `OAUTH2CLIENT_TEST_USER_KEY_EMAIL`: The user account email
+ corresponding to the user JSON key file.
+
+- Examples of these can be found in `scripts/local_test_setup.sample`. We
+ recommend copying this to `scripts/local_test_setup`, editing the values
+ and sourcing them into your environment:
+
+ ```bash
+ $ source scripts/local_test_setup
+ ```
+
+Contributor License Agreements
+------------------------------
+
+Before we can accept your pull requests you'll need to sign a Contributor
+License Agreement (CLA):
+
+- **If you are an individual writing original source code** and **you own
+ the intellectual property**, then you'll need to sign an
+ [individual CLA][4].
+- **If you work for a company that wants to allow you to contribute your
+ work**, then you'll need to sign a [corporate CLA][5].
+
+You can sign these electronically (just scroll to the bottom). After that,
+we'll be able to accept your pull requests.
+
+[1]: https://github.com/google/oauth2client
+[2]: https://tox.readthedocs.io/en/latest/
+[3]: https://cloud.google.com/storage/docs/authentication#generating-a-private-key
+[4]: https://developers.google.com/open-source/cla/individual
+[5]: https://developers.google.com/open-source/cla/corporate
+[6]: #contributor-license-agreements
+[7]: https://github.com/google/oauth2client/issues
+[8]: https://help.github.com/articles/fork-a-repo/
+[9]: #before-writing-code-file-an-issue
+[10]: #fork-oauth2client
+[11]: #include-tests
+[12]: #make-the-pull-request
+[13]: https://oauth2client.readthedocs.io/en/latest/#using-pypy
+[14]: https://docs.djangoproject.com/en/1.7/faq/install/#what-python-version-can-i-use-with-django
+[15]: https://docs.djangoproject.com/en/1.9/faq/install/#what-python-version-can-i-use-with-django
+[GooglePythonStyle]: https://google.github.io/styleguide/pyguide.html
+[GitCommitRules]: http://chris.beams.io/posts/git-commit/#seven-rules
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..b506d50
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,22 @@
+ Copyright 2014 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.
+
+Dependent Modules
+=================
+
+This code has the following dependencies
+above and beyond the Python standard library:
+
+uritemplates - Apache License 2.0
+httplib2 - MIT License
diff --git a/MANIFEST.in b/MANIFEST.in
new file mode 100644
index 0000000..39f5637
--- /dev/null
+++ b/MANIFEST.in
@@ -0,0 +1,2 @@
+include README.md
+recursive-exclude tests *
diff --git a/METADATA b/METADATA
new file mode 100644
index 0000000..6a258aa
--- /dev/null
+++ b/METADATA
@@ -0,0 +1,16 @@
+name: "oauth2client"
+description:
+ "This is a client library for accessing resources protected by OAuth 2.0."
+
+third_party {
+ url {
+ type: HOMEPAGE
+ value: "https://pypi.org/project/oauth2client"
+ }
+ url {
+ type: GIT
+ value: "https://github.com/google/oauth2client"
+ }
+ version: "v3.0.0"
+ last_upgrade_date { year: 2018 month: 6 day: 6 }
+}
diff --git a/MODULE_LICENSE_APACHE2 b/MODULE_LICENSE_APACHE2
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/MODULE_LICENSE_APACHE2
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..d0141e3
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,5 @@
+test:
+ tox
+
+docs:
+ scripts/doc-build
diff --git a/NOTICE b/NOTICE
new file mode 120000
index 0000000..7a694c9
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1 @@
+LICENSE
\ No newline at end of file
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..17e69fc
--- /dev/null
+++ b/README.md
@@ -0,0 +1,29 @@
+[![Build Status](https://travis-ci.org/google/oauth2client.svg?branch=master)](https://travis-ci.org/google/oauth2client)
+[![Coverage Status](https://coveralls.io/repos/google/oauth2client/badge.svg?branch=master&service=github)](https://coveralls.io/github/google/oauth2client?branch=master)
+[![Documentation Status](https://readthedocs.org/projects/oauth2client/badge/?version=latest)](https://oauth2client.readthedocs.io/)
+
+This is a client library for accessing resources protected by OAuth 2.0.
+
+Installation
+============
+
+To install, simply run the following command in your terminal:
+
+```bash
+$ pip install --upgrade oauth2client
+```
+
+Contributing
+============
+
+Please see the [CONTRIBUTING page][1] for more information. In particular, we
+love pull requests -- but please make sure to sign the contributor license
+agreement.
+
+Supported Python Versions
+=========================
+
+We support Python 2.6, 2.7, 3.3+. More information [in the docs][2].
+
+[1]: https://github.com/google/oauth2client/blob/master/CONTRIBUTING.md
+[2]: https://oauth2client.readthedocs.io/#supported-python-versions
diff --git a/docs/Makefile b/docs/Makefile
new file mode 100644
index 0000000..6ed721d
--- /dev/null
+++ b/docs/Makefile
@@ -0,0 +1,177 @@
+# Makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS = -W
+SPHINXBUILD = sphinx-build
+PAPER =
+BUILDDIR = _build
+
+# User-friendly check for sphinx-build
+ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1)
+$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/)
+endif
+
+# Internal variables.
+PAPEROPT_a4 = -D latex_paper_size=a4
+PAPEROPT_letter = -D latex_paper_size=letter
+ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+# the i18n builder cannot share the environment and doctrees with the others
+I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) .
+
+.PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext
+
+help:
+ @echo "Please use \`make <target>' where <target> is one of"
+ @echo " html to make standalone HTML files"
+ @echo " dirhtml to make HTML files named index.html in directories"
+ @echo " singlehtml to make a single large HTML file"
+ @echo " pickle to make pickle files"
+ @echo " json to make JSON files"
+ @echo " htmlhelp to make HTML files and a HTML help project"
+ @echo " qthelp to make HTML files and a qthelp project"
+ @echo " devhelp to make HTML files and a Devhelp project"
+ @echo " epub to make an epub"
+ @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter"
+ @echo " latexpdf to make LaTeX files and run them through pdflatex"
+ @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx"
+ @echo " text to make text files"
+ @echo " man to make manual pages"
+ @echo " texinfo to make Texinfo files"
+ @echo " info to make Texinfo files and run them through makeinfo"
+ @echo " gettext to make PO message catalogs"
+ @echo " changes to make an overview of all changed/added/deprecated items"
+ @echo " xml to make Docutils-native XML files"
+ @echo " pseudoxml to make pseudoxml-XML files for display purposes"
+ @echo " linkcheck to check all external links for integrity"
+ @echo " doctest to run all doctests embedded in the documentation (if enabled)"
+
+clean:
+ rm -rf $(BUILDDIR)/*
+
+html:
+ $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+
+dirhtml:
+ $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
+ @echo
+ @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml."
+
+singlehtml:
+ $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml
+ @echo
+ @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml."
+
+pickle:
+ $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle
+ @echo
+ @echo "Build finished; now you can process the pickle files."
+
+json:
+ $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json
+ @echo
+ @echo "Build finished; now you can process the JSON files."
+
+htmlhelp:
+ $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp
+ @echo
+ @echo "Build finished; now you can run HTML Help Workshop with the" \
+ ".hhp project file in $(BUILDDIR)/htmlhelp."
+
+qthelp:
+ $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp
+ @echo
+ @echo "Build finished; now you can run "qcollectiongenerator" with the" \
+ ".qhcp project file in $(BUILDDIR)/qthelp, like this:"
+ @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/oauth2client.qhcp"
+ @echo "To view the help file:"
+ @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/oauth2client.qhc"
+
+devhelp:
+ $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp
+ @echo
+ @echo "Build finished."
+ @echo "To view the help file:"
+ @echo "# mkdir -p $$HOME/.local/share/devhelp/oauth2client"
+ @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/oauth2client"
+ @echo "# devhelp"
+
+epub:
+ $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub
+ @echo
+ @echo "Build finished. The epub file is in $(BUILDDIR)/epub."
+
+latex:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo
+ @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex."
+ @echo "Run \`make' in that directory to run these through (pdf)latex" \
+ "(use \`make latexpdf' here to do that automatically)."
+
+latexpdf:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through pdflatex..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+latexpdfja:
+ $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex
+ @echo "Running LaTeX files through platex and dvipdfmx..."
+ $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja
+ @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex."
+
+text:
+ $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text
+ @echo
+ @echo "Build finished. The text files are in $(BUILDDIR)/text."
+
+man:
+ $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man
+ @echo
+ @echo "Build finished. The manual pages are in $(BUILDDIR)/man."
+
+texinfo:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo
+ @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo."
+ @echo "Run \`make' in that directory to run these through makeinfo" \
+ "(use \`make info' here to do that automatically)."
+
+info:
+ $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo
+ @echo "Running Texinfo files through makeinfo..."
+ make -C $(BUILDDIR)/texinfo info
+ @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo."
+
+gettext:
+ $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale
+ @echo
+ @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale."
+
+changes:
+ $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes
+ @echo
+ @echo "The overview file is in $(BUILDDIR)/changes."
+
+linkcheck:
+ $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck
+ @echo
+ @echo "Link check complete; look for any errors in the above output " \
+ "or in $(BUILDDIR)/linkcheck/output.txt."
+
+doctest:
+ $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest
+ @echo "Testing of doctests in the sources finished, look at the " \
+ "results in $(BUILDDIR)/doctest/output.txt."
+
+xml:
+ $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml
+ @echo
+ @echo "Build finished. The XML files are in $(BUILDDIR)/xml."
+
+pseudoxml:
+ $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml
+ @echo
+ @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml."
diff --git a/docs/_static/favicon.ico b/docs/_static/favicon.ico
new file mode 100644
index 0000000..c040e7f
--- /dev/null
+++ b/docs/_static/favicon.ico
Binary files differ
diff --git a/docs/_static/google_logo.png b/docs/_static/google_logo.png
new file mode 100644
index 0000000..2cf8fe6
--- /dev/null
+++ b/docs/_static/google_logo.png
Binary files differ
diff --git a/docs/conf.py b/docs/conf.py
new file mode 100644
index 0000000..1b4663b
--- /dev/null
+++ b/docs/conf.py
@@ -0,0 +1,114 @@
+# -*- coding: utf-8 -*-
+#
+# oauth2client documentation build configuration file, created by
+# sphinx-quickstart on Wed Dec 17 23:13:19 2014.
+#
+
+import os
+import sys
+
+
+# In order to load django before 1.7, we need to create a faux
+# settings module and load it. This assumes django has been installed
+# (but it must be for the docs to build), so if it has not already
+# been installed run `pip install -r docs/requirements.txt`.
+os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.contrib.django_util.settings'
+import django
+import mock
+from pkg_resources import get_distribution
+if django.VERSION[1] < 7:
+ sys.path.insert(0, '.')
+
+# See https://read-the-docs.readthedocs.io/en/latest/faq.html#i-get-import-errors-on-libraries-that-depend-on-c-modules
+
+
+class Mock(mock.Mock):
+
+ @classmethod
+ def __getattr__(cls, name):
+ return Mock()
+
+
+MOCK_MODULES = (
+ 'google',
+ 'google.appengine',
+ 'google.appengine.api',
+ 'google.appengine.api.app_identiy',
+ 'google.appengine.api.urlfetch',
+ 'google.appengine.ext',
+ 'google.appengine.ext.webapp',
+ 'google.appengine.ext.webapp.util',
+ 'werkzeug.local',
+)
+
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+sys.path.insert(0, os.path.abspath('..'))
+
+# -- General configuration ------------------------------------------------
+
+extensions = [
+ 'sphinx.ext.autodoc',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.napoleon',
+ 'sphinx.ext.viewcode',
+]
+templates_path = ['_templates']
+source_suffix = '.rst'
+master_doc = 'index'
+
+# General information about the project.
+project = u'oauth2client'
+copyright = u'2014, Google, Inc'
+
+# Version info
+distro = get_distribution('oauth2client')
+version = distro.version
+release = distro.version
+
+exclude_patterns = ['_build']
+
+# -- Options for HTML output ----------------------------------------------
+
+# We fake our more expensive imports when building the docs.
+sys.modules.update((mod_name, Mock()) for mod_name in MOCK_MODULES)
+
+# We want to set the RTD theme, but not if we're on RTD.
+if os.environ.get('READTHEDOCS', None) != 'True':
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+
+# The name of an image file (within the static path) to use as favicon of the
+# docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32
+# pixels large.
+html_favicon = '_static/favicon.ico'
+
+html_static_path = ['_static']
+html_logo = '_static/google_logo.png'
+htmlhelp_basename = 'oauth2clientdoc'
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {}
+latex_documents = [
+ ('index', 'oauth2client.tex', u'oauth2client Documentation',
+ u'Google, Inc.', 'manual'),
+]
+
+# -- Options for manual page output ---------------------------------------
+
+man_pages = [
+ ('index', 'oauth2client', u'oauth2client Documentation',
+ [u'Google, Inc.'], 1)
+]
+
+# -- Options for Texinfo output -------------------------------------------
+
+texinfo_documents = [
+ ('index', 'oauth2client', u'oauth2client Documentation',
+ u'Google, Inc.', 'oauth2client', 'One line description of project.',
+ 'Miscellaneous'),
+]
diff --git a/docs/index.rst b/docs/index.rst
new file mode 100644
index 0000000..0543e1a
--- /dev/null
+++ b/docs/index.rst
@@ -0,0 +1,124 @@
+oauth2client
+============
+
+*making OAuth2 just a little less painful*
+
+``oauth2client`` makes it easy to interact with OAuth2-protected resources,
+especially those related to Google APIs. You can also start with `general
+information about using OAuth2 with Google APIs
+<https://developers.google.com/accounts/docs/OAuth2>`_.
+
+Getting started
+---------------
+
+We recommend installing via ``pip``:
+
+.. code-block:: bash
+
+ $ pip install --upgrade oauth2client
+
+You can also install from source:
+
+.. code-block:: bash
+
+ $ git clone https://github.com/google/oauth2client
+ $ cd oauth2client
+ $ python setup.py install
+
+Using ``pypy``
+--------------
+
+- In order to use crypto libraries (e.g. for service accounts) you will
+ need to install one of ``pycrypto`` or ``pyOpenSSL``.
+- Using ``pycrypto`` with ``pypy`` will be in general problematic. If
+ ``libgmp`` is installed on your machine, the ``pycrypto`` install will
+ attempt to build ``_fastmath.c``. However, this file uses CPython
+ implementation details and hence can't be built in ``pypy`` (as of
+ ``pypy`` 2.6 and ``pycrypto`` 2.6.1). In order to install
+
+ .. code-block:: bash
+
+ with_gmp=no pip install --upgrade pycrypto
+
+ See discussions on the `pypy issue tracker`_ and the
+ `pycrypto issue tracker`_.
+
+- Using ``pyOpenSSL`` with versions of ``pypy`` before 2.6 may be in general
+ problematic since ``pyOpenSSL`` depends on the ``cryptography`` library.
+ For versions of ``cryptography`` before 1.0, importing ``pyOpenSSL``
+ with it caused `massive startup costs`_. In order to address this
+ slow startup, ``cryptography`` 1.0 made some `changes`_ in how it used
+ ``cffi`` when means it can't be used on versions of ``pypy`` before 2.6.
+
+ The default version of ``pypy`` you get when installed
+
+ .. code-block:: bash
+
+ apt-get install pypy pypy-dev
+
+ on `Ubuntu 14.04`_ is 2.2.1. In order to upgrade, you'll need to use
+ the `pypy/ppa PPA`_:
+
+ .. code-block:: bash
+
+ apt-get purge pypy pypy-dev
+ add-apt-repository ppa:pypy/ppa
+ apt-get update
+ apt-get install pypy pypy-dev
+
+.. _pypy issue tracker: https://bitbucket.org/pypy/pypy/issues/997
+.. _pycrypto issue tracker: https://github.com/dlitz/pycrypto/pull/59
+.. _massive startup costs: https://github.com/pyca/pyopenssl/issues/137
+.. _changes: https://github.com/pyca/cryptography/issues/2275#issuecomment-130751514
+.. _Ubuntu 14.04: http://packages.ubuntu.com/trusty/pypy
+.. _pypy/ppa PPA: https://launchpad.net/~pypy/+archive/ubuntu/ppa
+
+Downloads
+^^^^^^^^^
+
+* `Most recent release tarball
+ <https://github.com/google/oauth2client/tarball/master>`_
+* `Most recent release zipfile
+ <https://github.com/google/oauth2client/zipball/master>`_
+* `Complete release list <https://github.com/google/oauth2client/releases>`_
+
+Library Documentation
+---------------------
+
+* Complete library index: :ref:`genindex`
+* Index of all modules: :ref:`modindex`
+* Search all documentation: :ref:`search`
+
+Contributing
+------------
+
+Please see the `contributing page`_ for more information.
+In particular, we love pull requests -- but please make sure to sign the
+contributor license agreement.
+
+.. _contributing page: https://github.com/google/oauth2client/blob/master/CONTRIBUTING.md
+
+.. toctree::
+ :maxdepth: 1
+ :hidden:
+
+ source/oauth2client
+
+Supported Python Versions
+-------------------------
+
+We support Python 2.6, 2.7, 3.3+. (Whatever this file says, the truth is
+always represented by our `tox.ini`_).
+
+.. _tox.ini: https://github.com/google/oauth2client/blob/master/tox.ini
+
+We explicitly decided to support Python 3 beginning with version
+3.3. Reasons for this include:
+
+* Encouraging use of newest versions of Python 3
+* Following the lead of prominent `open-source projects`_
+* Unicode literal support which
+ allows for a cleaner codebase that works in both Python 2 and Python 3
+
+.. _open-source projects: http://docs.python-requests.org/en/latest/
+.. _Unicode literal support: https://www.python.org/dev/peps/pep-0414/
diff --git a/docs/requirements.txt b/docs/requirements.txt
new file mode 100644
index 0000000..433a833
--- /dev/null
+++ b/docs/requirements.txt
@@ -0,0 +1,10 @@
+django
+flask
+keyring
+mock
+pycrypto>=2.6
+pyopenssl>=0.14
+python-gflags
+pyyaml
+webapp2
+WebOb
diff --git a/docs/source/oauth2client.client.rst b/docs/source/oauth2client.client.rst
new file mode 100644
index 0000000..f3b1832
--- /dev/null
+++ b/docs/source/oauth2client.client.rst
@@ -0,0 +1,7 @@
+oauth2client.client module
+==========================
+
+.. automodule:: oauth2client.client
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.clientsecrets.rst b/docs/source/oauth2client.clientsecrets.rst
new file mode 100644
index 0000000..d666564
--- /dev/null
+++ b/docs/source/oauth2client.clientsecrets.rst
@@ -0,0 +1,7 @@
+oauth2client.clientsecrets module
+=================================
+
+.. automodule:: oauth2client.clientsecrets
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.appengine.rst b/docs/source/oauth2client.contrib.appengine.rst
new file mode 100644
index 0000000..7f3d5e2
--- /dev/null
+++ b/docs/source/oauth2client.contrib.appengine.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.appengine module
+=====================================
+
+.. automodule:: oauth2client.contrib.appengine
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.devshell.rst b/docs/source/oauth2client.contrib.devshell.rst
new file mode 100644
index 0000000..20d5c41
--- /dev/null
+++ b/docs/source/oauth2client.contrib.devshell.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.devshell module
+====================================
+
+.. automodule:: oauth2client.contrib.devshell
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.dictionary_storage.rst b/docs/source/oauth2client.contrib.dictionary_storage.rst
new file mode 100644
index 0000000..1b59a2c
--- /dev/null
+++ b/docs/source/oauth2client.contrib.dictionary_storage.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.dictionary_storage module
+==============================================
+
+.. automodule:: oauth2client.contrib.dictionary_storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.apps.rst b/docs/source/oauth2client.contrib.django_util.apps.rst
new file mode 100644
index 0000000..b7c91ae
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.apps.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.apps module
+============================================
+
+.. automodule:: oauth2client.contrib.django_util.apps
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.decorators.rst b/docs/source/oauth2client.contrib.django_util.decorators.rst
new file mode 100644
index 0000000..07350bc
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.decorators.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.decorators module
+==================================================
+
+.. automodule:: oauth2client.contrib.django_util.decorators
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.models.rst b/docs/source/oauth2client.contrib.django_util.models.rst
new file mode 100644
index 0000000..4be59d3
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.models.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.models module
+==============================================
+
+.. automodule:: oauth2client.contrib.django_util.models
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.rst b/docs/source/oauth2client.contrib.django_util.rst
new file mode 100644
index 0000000..f60195a
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.rst
@@ -0,0 +1,23 @@
+oauth2client.contrib.django_util package
+========================================
+
+Submodules
+----------
+
+.. toctree::
+
+ oauth2client.contrib.django_util.apps
+ oauth2client.contrib.django_util.decorators
+ oauth2client.contrib.django_util.models
+ oauth2client.contrib.django_util.signals
+ oauth2client.contrib.django_util.site
+ oauth2client.contrib.django_util.storage
+ oauth2client.contrib.django_util.views
+
+Module contents
+---------------
+
+.. automodule:: oauth2client.contrib.django_util
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.signals.rst b/docs/source/oauth2client.contrib.django_util.signals.rst
new file mode 100644
index 0000000..70b5d2d
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.signals.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.signals module
+===============================================
+
+.. automodule:: oauth2client.contrib.django_util.signals
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.site.rst b/docs/source/oauth2client.contrib.django_util.site.rst
new file mode 100644
index 0000000..a271b98
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.site.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.site module
+============================================
+
+.. automodule:: oauth2client.contrib.django_util.site
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.storage.rst b/docs/source/oauth2client.contrib.django_util.storage.rst
new file mode 100644
index 0000000..393e738
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.storage.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.storage module
+===============================================
+
+.. automodule:: oauth2client.contrib.django_util.storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.django_util.views.rst b/docs/source/oauth2client.contrib.django_util.views.rst
new file mode 100644
index 0000000..4cbbea0
--- /dev/null
+++ b/docs/source/oauth2client.contrib.django_util.views.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.django_util.views module
+=============================================
+
+.. automodule:: oauth2client.contrib.django_util.views
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.flask_util.rst b/docs/source/oauth2client.contrib.flask_util.rst
new file mode 100644
index 0000000..8ff2355
--- /dev/null
+++ b/docs/source/oauth2client.contrib.flask_util.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.flask_util module
+======================================
+
+.. automodule:: oauth2client.contrib.flask_util
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.gce.rst b/docs/source/oauth2client.contrib.gce.rst
new file mode 100644
index 0000000..a3748b6
--- /dev/null
+++ b/docs/source/oauth2client.contrib.gce.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.gce module
+===============================
+
+.. automodule:: oauth2client.contrib.gce
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.keyring_storage.rst b/docs/source/oauth2client.contrib.keyring_storage.rst
new file mode 100644
index 0000000..0fd7476
--- /dev/null
+++ b/docs/source/oauth2client.contrib.keyring_storage.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.keyring_storage module
+===========================================
+
+.. automodule:: oauth2client.contrib.keyring_storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.locked_file.rst b/docs/source/oauth2client.contrib.locked_file.rst
new file mode 100644
index 0000000..1076e29
--- /dev/null
+++ b/docs/source/oauth2client.contrib.locked_file.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.locked_file module
+=======================================
+
+.. automodule:: oauth2client.contrib.locked_file
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.multiprocess_file_storage.rst b/docs/source/oauth2client.contrib.multiprocess_file_storage.rst
new file mode 100644
index 0000000..6f683a0
--- /dev/null
+++ b/docs/source/oauth2client.contrib.multiprocess_file_storage.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.multiprocess_file_storage module
+=====================================================
+
+.. automodule:: oauth2client.contrib.multiprocess_file_storage
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.multistore_file.rst b/docs/source/oauth2client.contrib.multistore_file.rst
new file mode 100644
index 0000000..2787b10
--- /dev/null
+++ b/docs/source/oauth2client.contrib.multistore_file.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.multistore_file module
+===========================================
+
+.. automodule:: oauth2client.contrib.multistore_file
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.rst b/docs/source/oauth2client.contrib.rst
new file mode 100644
index 0000000..44be6f9
--- /dev/null
+++ b/docs/source/oauth2client.contrib.rst
@@ -0,0 +1,34 @@
+oauth2client.contrib package
+============================
+
+Subpackages
+-----------
+
+.. toctree::
+
+ oauth2client.contrib.django_util
+
+Submodules
+----------
+
+.. toctree::
+
+ oauth2client.contrib.appengine
+ oauth2client.contrib.devshell
+ oauth2client.contrib.dictionary_storage
+ oauth2client.contrib.flask_util
+ oauth2client.contrib.gce
+ oauth2client.contrib.keyring_storage
+ oauth2client.contrib.locked_file
+ oauth2client.contrib.multiprocess_file_storage
+ oauth2client.contrib.multistore_file
+ oauth2client.contrib.sqlalchemy
+ oauth2client.contrib.xsrfutil
+
+Module contents
+---------------
+
+.. automodule:: oauth2client.contrib
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.sqlalchemy.rst b/docs/source/oauth2client.contrib.sqlalchemy.rst
new file mode 100644
index 0000000..94eeeec
--- /dev/null
+++ b/docs/source/oauth2client.contrib.sqlalchemy.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.sqlalchemy module
+======================================
+
+.. automodule:: oauth2client.contrib.sqlalchemy
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.contrib.xsrfutil.rst b/docs/source/oauth2client.contrib.xsrfutil.rst
new file mode 100644
index 0000000..dd5e8d6
--- /dev/null
+++ b/docs/source/oauth2client.contrib.xsrfutil.rst
@@ -0,0 +1,7 @@
+oauth2client.contrib.xsrfutil module
+====================================
+
+.. automodule:: oauth2client.contrib.xsrfutil
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.crypt.rst b/docs/source/oauth2client.crypt.rst
new file mode 100644
index 0000000..c3b6acc
--- /dev/null
+++ b/docs/source/oauth2client.crypt.rst
@@ -0,0 +1,7 @@
+oauth2client.crypt module
+=========================
+
+.. automodule:: oauth2client.crypt
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.file.rst b/docs/source/oauth2client.file.rst
new file mode 100644
index 0000000..52a9e94
--- /dev/null
+++ b/docs/source/oauth2client.file.rst
@@ -0,0 +1,7 @@
+oauth2client.file module
+========================
+
+.. automodule:: oauth2client.file
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.rst b/docs/source/oauth2client.rst
new file mode 100644
index 0000000..65de8ac
--- /dev/null
+++ b/docs/source/oauth2client.rst
@@ -0,0 +1,31 @@
+oauth2client package
+====================
+
+Subpackages
+-----------
+
+.. toctree::
+
+ oauth2client.contrib
+
+Submodules
+----------
+
+.. toctree::
+
+ oauth2client.client
+ oauth2client.clientsecrets
+ oauth2client.crypt
+ oauth2client.file
+ oauth2client.service_account
+ oauth2client.tools
+ oauth2client.transport
+ oauth2client.util
+
+Module contents
+---------------
+
+.. automodule:: oauth2client
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.service_account.rst b/docs/source/oauth2client.service_account.rst
new file mode 100644
index 0000000..0d3b382
--- /dev/null
+++ b/docs/source/oauth2client.service_account.rst
@@ -0,0 +1,7 @@
+oauth2client.service_account module
+===================================
+
+.. automodule:: oauth2client.service_account
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.tools.rst b/docs/source/oauth2client.tools.rst
new file mode 100644
index 0000000..240ad52
--- /dev/null
+++ b/docs/source/oauth2client.tools.rst
@@ -0,0 +1,7 @@
+oauth2client.tools module
+=========================
+
+.. automodule:: oauth2client.tools
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.transport.rst b/docs/source/oauth2client.transport.rst
new file mode 100644
index 0000000..1c6dbb0
--- /dev/null
+++ b/docs/source/oauth2client.transport.rst
@@ -0,0 +1,7 @@
+oauth2client.transport module
+=============================
+
+.. automodule:: oauth2client.transport
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/docs/source/oauth2client.util.rst b/docs/source/oauth2client.util.rst
new file mode 100644
index 0000000..21dc8c8
--- /dev/null
+++ b/docs/source/oauth2client.util.rst
@@ -0,0 +1,7 @@
+oauth2client.util module
+========================
+
+.. automodule:: oauth2client.util
+ :members:
+ :undoc-members:
+ :show-inheritance:
diff --git a/oauth2client/Android.bp b/oauth2client/Android.bp
new file mode 100644
index 0000000..7818920
--- /dev/null
+++ b/oauth2client/Android.bp
@@ -0,0 +1,38 @@
+// Copyright 2018 Google Inc. All rights reserved.
+//
+// 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.
+python_library {
+ name: "py-oauth2client",
+ host_supported: true,
+ srcs: [
+ "*.py",
+ "contrib/*.py",
+ "contrib/django_util/*.py",
+ ],
+ version: {
+ py2: {
+ enabled: true,
+ },
+ py3: {
+ enabled: true,
+ },
+ },
+ libs: [
+ "py-httplib2",
+ "py-pyasn1",
+ "py-pyasn1-modules",
+ "py-rsa",
+ "py-six",
+ ],
+ pkg_path: "oauth2client",
+}
diff --git a/oauth2client/__init__.py b/oauth2client/__init__.py
new file mode 100644
index 0000000..28384bb
--- /dev/null
+++ b/oauth2client/__init__.py
@@ -0,0 +1,23 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Client library for using OAuth2, especially with Google APIs."""
+
+__version__ = '3.0.0'
+
+GOOGLE_AUTH_URI = 'https://accounts.google.com/o/oauth2/v2/auth'
+GOOGLE_DEVICE_URI = 'https://accounts.google.com/o/oauth2/device/code'
+GOOGLE_REVOKE_URI = 'https://accounts.google.com/o/oauth2/revoke'
+GOOGLE_TOKEN_URI = 'https://www.googleapis.com/oauth2/v4/token'
+GOOGLE_TOKEN_INFO_URI = 'https://www.googleapis.com/oauth2/v3/tokeninfo'
diff --git a/oauth2client/_helpers.py b/oauth2client/_helpers.py
new file mode 100644
index 0000000..cb959c5
--- /dev/null
+++ b/oauth2client/_helpers.py
@@ -0,0 +1,105 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+"""Helper functions for commonly used utilities."""
+
+import base64
+import json
+
+import six
+
+
+def _parse_pem_key(raw_key_input):
+ """Identify and extract PEM keys.
+
+ Determines whether the given key is in the format of PEM key, and extracts
+ the relevant part of the key if it is.
+
+ Args:
+ raw_key_input: The contents of a private key file (either PEM or
+ PKCS12).
+
+ Returns:
+ string, The actual key if the contents are from a PEM file, or
+ else None.
+ """
+ offset = raw_key_input.find(b'-----BEGIN ')
+ if offset != -1:
+ return raw_key_input[offset:]
+
+
+def _json_encode(data):
+ return json.dumps(data, separators=(',', ':'))
+
+
+def _to_bytes(value, encoding='ascii'):
+ """Converts a string value to bytes, if necessary.
+
+ Unfortunately, ``six.b`` is insufficient for this task since in
+ Python2 it does not modify ``unicode`` objects.
+
+ Args:
+ value: The string/bytes value to be converted.
+ encoding: The encoding to use to convert unicode to bytes. Defaults
+ to "ascii", which will not allow any characters from ordinals
+ larger than 127. Other useful values are "latin-1", which
+ which will only allows byte ordinals (up to 255) and "utf-8",
+ which will encode any unicode that needs to be.
+
+ Returns:
+ The original value converted to bytes (if unicode) or as passed in
+ if it started out as bytes.
+
+ Raises:
+ ValueError if the value could not be converted to bytes.
+ """
+ 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 _from_bytes(value):
+ """Converts bytes to a string value, if necessary.
+
+ Args:
+ value: The string/bytes value to be converted.
+
+ Returns:
+ The original value converted to unicode (if bytes) or as passed in
+ if it started out as unicode.
+
+ Raises:
+ ValueError if the value could not be converted to unicode.
+ """
+ result = (value.decode('utf-8')
+ if isinstance(value, six.binary_type) else value)
+ if isinstance(result, six.text_type):
+ return result
+ else:
+ raise ValueError(
+ '{0!r} could not be converted to unicode'.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):
+ # Guard against unicode strings, which base64 can't handle.
+ b64string = _to_bytes(b64string)
+ padded = b64string + b'=' * (4 - len(b64string) % 4)
+ return base64.urlsafe_b64decode(padded)
diff --git a/oauth2client/_openssl_crypt.py b/oauth2client/_openssl_crypt.py
new file mode 100644
index 0000000..77fac74
--- /dev/null
+++ b/oauth2client/_openssl_crypt.py
@@ -0,0 +1,136 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+"""OpenSSL Crypto-related routines for oauth2client."""
+
+from OpenSSL import crypto
+
+from oauth2client import _helpers
+
+
+class OpenSSLVerifier(object):
+ """Verifies the signature on a message."""
+
+ def __init__(self, pubkey):
+ """Constructor.
+
+ Args:
+ pubkey: OpenSSL.crypto.PKey, The public key to verify with.
+ """
+ self._pubkey = pubkey
+
+ def verify(self, message, signature):
+ """Verifies a message against a signature.
+
+ Args:
+ message: string or bytes, The message to verify. If string, will be
+ encoded to bytes as utf-8.
+ signature: string or bytes, The signature on the message. If string,
+ will be encoded to bytes as utf-8.
+
+ Returns:
+ True if message was signed by the private key associated with the
+ public key that this object was constructed with.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ signature = _helpers._to_bytes(signature, encoding='utf-8')
+ try:
+ crypto.verify(self._pubkey, signature, message, 'sha256')
+ return True
+ except crypto.Error:
+ return False
+
+ @staticmethod
+ def from_string(key_pem, is_x509_cert):
+ """Construct a Verified instance from a string.
+
+ Args:
+ key_pem: string, public key in PEM format.
+ is_x509_cert: bool, True if key_pem is an X509 cert, otherwise it
+ is expected to be an RSA key in PEM format.
+
+ Returns:
+ Verifier instance.
+
+ Raises:
+ OpenSSL.crypto.Error: if the key_pem can't be parsed.
+ """
+ key_pem = _helpers._to_bytes(key_pem)
+ if is_x509_cert:
+ pubkey = crypto.load_certificate(crypto.FILETYPE_PEM, key_pem)
+ else:
+ pubkey = crypto.load_privatekey(crypto.FILETYPE_PEM, key_pem)
+ return OpenSSLVerifier(pubkey)
+
+
+class OpenSSLSigner(object):
+ """Signs messages with a private key."""
+
+ def __init__(self, pkey):
+ """Constructor.
+
+ Args:
+ pkey: OpenSSL.crypto.PKey (or equiv), The private key to sign with.
+ """
+ self._key = pkey
+
+ def sign(self, message):
+ """Signs a message.
+
+ Args:
+ message: bytes, Message to be signed.
+
+ Returns:
+ string, The signature of the message for the given key.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ return crypto.sign(self._key, message, 'sha256')
+
+ @staticmethod
+ def from_string(key, password=b'notasecret'):
+ """Construct a Signer instance from a string.
+
+ Args:
+ key: string, private key in PKCS12 or PEM format.
+ password: string, password for the private key file.
+
+ Returns:
+ Signer instance.
+
+ Raises:
+ OpenSSL.crypto.Error if the key can't be parsed.
+ """
+ key = _helpers._to_bytes(key)
+ parsed_pem_key = _helpers._parse_pem_key(key)
+ if parsed_pem_key:
+ pkey = crypto.load_privatekey(crypto.FILETYPE_PEM, parsed_pem_key)
+ else:
+ password = _helpers._to_bytes(password, encoding='utf-8')
+ pkey = crypto.load_pkcs12(key, password).get_privatekey()
+ return OpenSSLSigner(pkey)
+
+
+def pkcs12_key_as_pem(private_key_bytes, private_key_password):
+ """Convert the contents of a PKCS#12 key to PEM using pyOpenSSL.
+
+ Args:
+ private_key_bytes: Bytes. PKCS#12 key in DER format.
+ private_key_password: String. Password for PKCS#12 key.
+
+ Returns:
+ String. PEM contents of ``private_key_bytes``.
+ """
+ private_key_password = _helpers._to_bytes(private_key_password)
+ pkcs12 = crypto.load_pkcs12(private_key_bytes, private_key_password)
+ return crypto.dump_privatekey(crypto.FILETYPE_PEM,
+ pkcs12.get_privatekey())
diff --git a/oauth2client/_pure_python_crypt.py b/oauth2client/_pure_python_crypt.py
new file mode 100644
index 0000000..2c5d43a
--- /dev/null
+++ b/oauth2client/_pure_python_crypt.py
@@ -0,0 +1,184 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Pure Python crypto-related routines for oauth2client.
+
+Uses the ``rsa``, ``pyasn1`` and ``pyasn1_modules`` packages
+to parse PEM files storing PKCS#1 or PKCS#8 keys as well as
+certificates.
+"""
+
+from pyasn1.codec.der import decoder
+from pyasn1_modules import pem
+from pyasn1_modules.rfc2459 import Certificate
+from pyasn1_modules.rfc5208 import PrivateKeyInfo
+import rsa
+import six
+
+from oauth2client import _helpers
+
+
+_PKCS12_ERROR = r"""\
+PKCS12 format is not supported by the RSA library.
+Either install PyOpenSSL, or please convert .p12 format
+to .pem format:
+ $ cat key.p12 | \
+ > openssl pkcs12 -nodes -nocerts -passin pass:notasecret | \
+ > openssl rsa > key.pem
+"""
+
+_POW2 = (128, 64, 32, 16, 8, 4, 2, 1)
+_PKCS1_MARKER = ('-----BEGIN RSA PRIVATE KEY-----',
+ '-----END RSA PRIVATE KEY-----')
+_PKCS8_MARKER = ('-----BEGIN PRIVATE KEY-----',
+ '-----END PRIVATE KEY-----')
+_PKCS8_SPEC = PrivateKeyInfo()
+
+
+def _bit_list_to_bytes(bit_list):
+ """Converts an iterable of 1's and 0's to bytes.
+
+ Combines the list 8 at a time, treating each group of 8 bits
+ as a single byte.
+ """
+ num_bits = len(bit_list)
+ byte_vals = bytearray()
+ for start in six.moves.xrange(0, num_bits, 8):
+ curr_bits = bit_list[start:start + 8]
+ char_val = sum(val * digit
+ for val, digit in zip(_POW2, curr_bits))
+ byte_vals.append(char_val)
+ return bytes(byte_vals)
+
+
+class RsaVerifier(object):
+ """Verifies the signature on a message.
+
+ Args:
+ pubkey: rsa.key.PublicKey (or equiv), The public key to verify with.
+ """
+
+ def __init__(self, pubkey):
+ self._pubkey = pubkey
+
+ def verify(self, message, signature):
+ """Verifies a message against a signature.
+
+ Args:
+ message: string or bytes, The message to verify. If string, will be
+ encoded to bytes as utf-8.
+ signature: string or bytes, The signature on the message. If
+ string, will be encoded to bytes as utf-8.
+
+ Returns:
+ True if message was signed by the private key associated with the
+ public key that this object was constructed with.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ try:
+ return rsa.pkcs1.verify(message, signature, self._pubkey)
+ except (ValueError, rsa.pkcs1.VerificationError):
+ return False
+
+ @classmethod
+ def from_string(cls, key_pem, is_x509_cert):
+ """Construct an RsaVerifier instance from a string.
+
+ Args:
+ key_pem: string, public key in PEM format.
+ is_x509_cert: bool, True if key_pem is an X509 cert, otherwise it
+ is expected to be an RSA key in PEM format.
+
+ Returns:
+ RsaVerifier instance.
+
+ Raises:
+ ValueError: if the key_pem can't be parsed. In either case, error
+ will begin with 'No PEM start marker'. If
+ ``is_x509_cert`` is True, will fail to find the
+ "-----BEGIN CERTIFICATE-----" error, otherwise fails
+ to find "-----BEGIN RSA PUBLIC KEY-----".
+ """
+ key_pem = _helpers._to_bytes(key_pem)
+ if is_x509_cert:
+ der = rsa.pem.load_pem(key_pem, 'CERTIFICATE')
+ asn1_cert, remaining = decoder.decode(der, asn1Spec=Certificate())
+ if remaining != b'':
+ raise ValueError('Unused bytes', remaining)
+
+ cert_info = asn1_cert['tbsCertificate']['subjectPublicKeyInfo']
+ key_bytes = _bit_list_to_bytes(cert_info['subjectPublicKey'])
+ pubkey = rsa.PublicKey.load_pkcs1(key_bytes, 'DER')
+ else:
+ pubkey = rsa.PublicKey.load_pkcs1(key_pem, 'PEM')
+ return cls(pubkey)
+
+
+class RsaSigner(object):
+ """Signs messages with a private key.
+
+ Args:
+ pkey: rsa.key.PrivateKey (or equiv), The private key to sign with.
+ """
+
+ def __init__(self, pkey):
+ self._key = pkey
+
+ def sign(self, message):
+ """Signs a message.
+
+ Args:
+ message: bytes, Message to be signed.
+
+ Returns:
+ string, The signature of the message for the given key.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ return rsa.pkcs1.sign(message, self._key, 'SHA-256')
+
+ @classmethod
+ def from_string(cls, key, password='notasecret'):
+ """Construct an RsaSigner instance from a string.
+
+ Args:
+ key: string, private key in PEM format.
+ password: string, password for private key file. Unused for PEM
+ files.
+
+ Returns:
+ RsaSigner instance.
+
+ Raises:
+ ValueError if the key cannot be parsed as PKCS#1 or PKCS#8 in
+ PEM format.
+ """
+ key = _helpers._from_bytes(key) # pem expects str in Py3
+ marker_id, key_bytes = pem.readPemBlocksFromFile(
+ six.StringIO(key), _PKCS1_MARKER, _PKCS8_MARKER)
+
+ if marker_id == 0:
+ pkey = rsa.key.PrivateKey.load_pkcs1(key_bytes,
+ format='DER')
+ elif marker_id == 1:
+ key_info, remaining = decoder.decode(
+ key_bytes, asn1Spec=_PKCS8_SPEC)
+ if remaining != b'':
+ raise ValueError('Unused bytes', remaining)
+ pkey_info = key_info.getComponentByName('privateKey')
+ pkey = rsa.key.PrivateKey.load_pkcs1(pkey_info.asOctets(),
+ format='DER')
+ else:
+ raise ValueError('No key could be detected.')
+
+ return cls(pkey)
diff --git a/oauth2client/_pycrypto_crypt.py b/oauth2client/_pycrypto_crypt.py
new file mode 100644
index 0000000..fd2ce0c
--- /dev/null
+++ b/oauth2client/_pycrypto_crypt.py
@@ -0,0 +1,124 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+"""pyCrypto Crypto-related routines for oauth2client."""
+
+from Crypto.Hash import SHA256
+from Crypto.PublicKey import RSA
+from Crypto.Signature import PKCS1_v1_5
+from Crypto.Util.asn1 import DerSequence
+
+from oauth2client import _helpers
+
+
+class PyCryptoVerifier(object):
+ """Verifies the signature on a message."""
+
+ def __init__(self, pubkey):
+ """Constructor.
+
+ Args:
+ pubkey: OpenSSL.crypto.PKey (or equiv), The public key to verify
+ with.
+ """
+ self._pubkey = pubkey
+
+ def verify(self, message, signature):
+ """Verifies a message against a signature.
+
+ Args:
+ message: string or bytes, The message to verify. If string, will be
+ encoded to bytes as utf-8.
+ signature: string or bytes, The signature on the message.
+
+ Returns:
+ True if message was signed by the private key associated with the
+ public key that this object was constructed with.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ return PKCS1_v1_5.new(self._pubkey).verify(
+ SHA256.new(message), signature)
+
+ @staticmethod
+ def from_string(key_pem, is_x509_cert):
+ """Construct a Verified instance from a string.
+
+ Args:
+ key_pem: string, public key in PEM format.
+ is_x509_cert: bool, True if key_pem is an X509 cert, otherwise it
+ is expected to be an RSA key in PEM format.
+
+ Returns:
+ Verifier instance.
+ """
+ if is_x509_cert:
+ key_pem = _helpers._to_bytes(key_pem)
+ pemLines = key_pem.replace(b' ', b'').split()
+ certDer = _helpers._urlsafe_b64decode(b''.join(pemLines[1:-1]))
+ certSeq = DerSequence()
+ certSeq.decode(certDer)
+ tbsSeq = DerSequence()
+ tbsSeq.decode(certSeq[0])
+ pubkey = RSA.importKey(tbsSeq[6])
+ else:
+ pubkey = RSA.importKey(key_pem)
+ return PyCryptoVerifier(pubkey)
+
+
+class PyCryptoSigner(object):
+ """Signs messages with a private key."""
+
+ def __init__(self, pkey):
+ """Constructor.
+
+ Args:
+ pkey, OpenSSL.crypto.PKey (or equiv), The private key to sign with.
+ """
+ self._key = pkey
+
+ def sign(self, message):
+ """Signs a message.
+
+ Args:
+ message: string, Message to be signed.
+
+ Returns:
+ string, The signature of the message for the given key.
+ """
+ message = _helpers._to_bytes(message, encoding='utf-8')
+ return PKCS1_v1_5.new(self._key).sign(SHA256.new(message))
+
+ @staticmethod
+ def from_string(key, password='notasecret'):
+ """Construct a Signer instance from a string.
+
+ Args:
+ key: string, private key in PEM format.
+ password: string, password for private key file. Unused for PEM
+ files.
+
+ Returns:
+ Signer instance.
+
+ Raises:
+ NotImplementedError if the key isn't in PEM format.
+ """
+ parsed_pem_key = _helpers._parse_pem_key(_helpers._to_bytes(key))
+ if parsed_pem_key:
+ pkey = RSA.importKey(parsed_pem_key)
+ else:
+ raise NotImplementedError(
+ 'No key in PEM format was detected. This implementation '
+ 'can only use the PyCrypto library for keys in PEM '
+ 'format.')
+ return PyCryptoSigner(pkey)
diff --git a/oauth2client/client.py b/oauth2client/client.py
new file mode 100644
index 0000000..8956443
--- /dev/null
+++ b/oauth2client/client.py
@@ -0,0 +1,2133 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""An OAuth 2.0 client.
+
+Tools for interacting with OAuth 2.0 protected resources.
+"""
+
+import collections
+import copy
+import datetime
+import json
+import logging
+import os
+import shutil
+import socket
+import sys
+import tempfile
+
+import six
+from six.moves import http_client
+from six.moves import urllib
+
+import oauth2client
+from oauth2client import _helpers
+from oauth2client import clientsecrets
+from oauth2client import transport
+from oauth2client import util
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+HAS_OPENSSL = False
+HAS_CRYPTO = False
+try:
+ from oauth2client import crypt
+ HAS_CRYPTO = True
+ HAS_OPENSSL = crypt.OpenSSLVerifier is not None
+except ImportError: # pragma: NO COVER
+ pass
+
+
+logger = logging.getLogger(__name__)
+
+# Expiry is stored in RFC3339 UTC format
+EXPIRY_FORMAT = '%Y-%m-%dT%H:%M:%SZ'
+
+# Which certs to use to validate id_tokens received.
+ID_TOKEN_VERIFICATION_CERTS = 'https://www.googleapis.com/oauth2/v1/certs'
+# This symbol previously had a typo in the name; we keep the old name
+# around for now, but will remove it in the future.
+ID_TOKEN_VERIFICATON_CERTS = ID_TOKEN_VERIFICATION_CERTS
+
+# Constant to use for the out of band OAuth 2.0 flow.
+OOB_CALLBACK_URN = 'urn:ietf:wg:oauth:2.0:oob'
+
+# The value representing user credentials.
+AUTHORIZED_USER = 'authorized_user'
+
+# The value representing service account credentials.
+SERVICE_ACCOUNT = 'service_account'
+
+# The environment variable pointing the file with local
+# Application Default Credentials.
+GOOGLE_APPLICATION_CREDENTIALS = 'GOOGLE_APPLICATION_CREDENTIALS'
+# The ~/.config subdirectory containing gcloud credentials. Intended
+# to be swapped out in tests.
+_CLOUDSDK_CONFIG_DIRECTORY = 'gcloud'
+# The environment variable name which can replace ~/.config if set.
+_CLOUDSDK_CONFIG_ENV_VAR = 'CLOUDSDK_CONFIG'
+
+# The error message we show users when we can't find the Application
+# Default Credentials.
+ADC_HELP_MSG = (
+ 'The Application Default Credentials are not available. They are '
+ 'available if running in Google Compute Engine. Otherwise, the '
+ 'environment variable ' +
+ GOOGLE_APPLICATION_CREDENTIALS +
+ ' must be defined pointing to a file defining the credentials. See '
+ 'https://developers.google.com/accounts/docs/'
+ 'application-default-credentials for more information.')
+
+_WELL_KNOWN_CREDENTIALS_FILE = 'application_default_credentials.json'
+
+# The access token along with the seconds in which it expires.
+AccessTokenInfo = collections.namedtuple(
+ 'AccessTokenInfo', ['access_token', 'expires_in'])
+
+DEFAULT_ENV_NAME = 'UNKNOWN'
+
+# If set to True _get_environment avoid GCE check (_detect_gce_environment)
+NO_GCE_CHECK = os.environ.setdefault('NO_GCE_CHECK', 'False')
+
+# Timeout in seconds to wait for the GCE metadata server when detecting the
+# GCE environment.
+try:
+ GCE_METADATA_TIMEOUT = int(
+ os.environ.setdefault('GCE_METADATA_TIMEOUT', '3'))
+except ValueError: # pragma: NO COVER
+ GCE_METADATA_TIMEOUT = 3
+
+_SERVER_SOFTWARE = 'SERVER_SOFTWARE'
+_GCE_METADATA_HOST = '169.254.169.254'
+_METADATA_FLAVOR_HEADER = 'Metadata-Flavor'
+_DESIRED_METADATA_FLAVOR = 'Google'
+
+# Expose utcnow() at module level to allow for
+# easier testing (by replacing with a stub).
+_UTCNOW = datetime.datetime.utcnow
+
+# NOTE: These names were previously defined in this module but have been
+# moved into `oauth2client.transport`,
+clean_headers = transport.clean_headers
+MemoryCache = transport.MemoryCache
+REFRESH_STATUS_CODES = transport.REFRESH_STATUS_CODES
+
+
+class SETTINGS(object):
+ """Settings namespace for globally defined values."""
+ env_name = None
+
+
+class Error(Exception):
+ """Base error for this module."""
+
+
+class FlowExchangeError(Error):
+ """Error trying to exchange an authorization grant for an access token."""
+
+
+class AccessTokenRefreshError(Error):
+ """Error trying to refresh an expired access token."""
+
+
+class HttpAccessTokenRefreshError(AccessTokenRefreshError):
+ """Error (with HTTP status) trying to refresh an expired access token."""
+ def __init__(self, *args, **kwargs):
+ super(HttpAccessTokenRefreshError, self).__init__(*args)
+ self.status = kwargs.get('status')
+
+
+class TokenRevokeError(Error):
+ """Error trying to revoke a token."""
+
+
+class UnknownClientSecretsFlowError(Error):
+ """The client secrets file called for an unknown type of OAuth 2.0 flow."""
+
+
+class AccessTokenCredentialsError(Error):
+ """Having only the access_token means no refresh is possible."""
+
+
+class VerifyJwtTokenError(Error):
+ """Could not retrieve certificates for validation."""
+
+
+class NonAsciiHeaderError(Error):
+ """Header names and values must be ASCII strings."""
+
+
+class ApplicationDefaultCredentialsError(Error):
+ """Error retrieving the Application Default Credentials."""
+
+
+class OAuth2DeviceCodeError(Error):
+ """Error trying to retrieve a device code."""
+
+
+class CryptoUnavailableError(Error, NotImplementedError):
+ """Raised when a crypto library is required, but none is available."""
+
+
+def _parse_expiry(expiry):
+ if expiry and isinstance(expiry, datetime.datetime):
+ return expiry.strftime(EXPIRY_FORMAT)
+ else:
+ return None
+
+
+class Credentials(object):
+ """Base class for all Credentials objects.
+
+ Subclasses must define an authorize() method that applies the credentials
+ to an HTTP transport.
+
+ Subclasses must also specify a classmethod named 'from_json' that takes a
+ JSON string as input and returns an instantiated Credentials object.
+ """
+
+ NON_SERIALIZED_MEMBERS = frozenset(['store'])
+
+ def authorize(self, http):
+ """Take an httplib2.Http instance (or equivalent) and authorizes it.
+
+ Authorizes it for the set of credentials, usually by replacing
+ http.request() with a method that adds in the appropriate headers and
+ then delegates to the original Http.request() method.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the refresh
+ request.
+ """
+ raise NotImplementedError
+
+ def refresh(self, http):
+ """Forces a refresh of the access_token.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the refresh
+ request.
+ """
+ raise NotImplementedError
+
+ def revoke(self, http):
+ """Revokes a refresh_token and makes the credentials void.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the revoke
+ request.
+ """
+ raise NotImplementedError
+
+ def apply(self, headers):
+ """Add the authorization to the headers.
+
+ Args:
+ headers: dict, the headers to add the Authorization header to.
+ """
+ raise NotImplementedError
+
+ def _to_json(self, strip, to_serialize=None):
+ """Utility function that creates JSON repr. of a Credentials object.
+
+ Args:
+ strip: array, An array of names of members to exclude from the
+ JSON.
+ to_serialize: dict, (Optional) The properties for this object
+ that will be serialized. This allows callers to
+ modify before serializing.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ curr_type = self.__class__
+ if to_serialize is None:
+ to_serialize = copy.copy(self.__dict__)
+ else:
+ # Assumes it is a str->str dictionary, so we don't deep copy.
+ to_serialize = copy.copy(to_serialize)
+ for member in strip:
+ if member in to_serialize:
+ del to_serialize[member]
+ to_serialize['token_expiry'] = _parse_expiry(
+ to_serialize.get('token_expiry'))
+ # Add in information we will need later to reconstitute this instance.
+ to_serialize['_class'] = curr_type.__name__
+ to_serialize['_module'] = curr_type.__module__
+ for key, val in to_serialize.items():
+ if isinstance(val, bytes):
+ to_serialize[key] = val.decode('utf-8')
+ if isinstance(val, set):
+ to_serialize[key] = list(val)
+ return json.dumps(to_serialize)
+
+ def to_json(self):
+ """Creating a JSON representation of an instance of Credentials.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ return self._to_json(self.NON_SERIALIZED_MEMBERS)
+
+ @classmethod
+ def new_from_json(cls, json_data):
+ """Utility class method to instantiate a Credentials subclass from JSON.
+
+ Expects the JSON string to have been produced by to_json().
+
+ Args:
+ json_data: string or bytes, JSON from to_json().
+
+ Returns:
+ An instance of the subclass of Credentials that was serialized with
+ to_json().
+ """
+ json_data_as_unicode = _helpers._from_bytes(json_data)
+ data = json.loads(json_data_as_unicode)
+ # Find and call the right classmethod from_json() to restore
+ # the object.
+ module_name = data['_module']
+ try:
+ module_obj = __import__(module_name)
+ except ImportError:
+ # In case there's an object from the old package structure,
+ # update it
+ module_name = module_name.replace('.googleapiclient', '')
+ module_obj = __import__(module_name)
+
+ module_obj = __import__(module_name,
+ fromlist=module_name.split('.')[:-1])
+ kls = getattr(module_obj, data['_class'])
+ return kls.from_json(json_data_as_unicode)
+
+ @classmethod
+ def from_json(cls, unused_data):
+ """Instantiate a Credentials object from a JSON description of it.
+
+ The JSON should have been produced by calling .to_json() on the object.
+
+ Args:
+ unused_data: dict, A deserialized JSON object.
+
+ Returns:
+ An instance of a Credentials subclass.
+ """
+ return Credentials()
+
+
+class Flow(object):
+ """Base class for all Flow objects."""
+ pass
+
+
+class Storage(object):
+ """Base class for all Storage objects.
+
+ Store and retrieve a single credential. This class supports locking
+ such that multiple processes and threads can operate on a single
+ store.
+ """
+ def __init__(self, lock=None):
+ """Create a Storage instance.
+
+ Args:
+ lock: An optional threading.Lock-like object. Must implement at
+ least acquire() and release(). Does not need to be
+ re-entrant.
+ """
+ self._lock = lock
+
+ def acquire_lock(self):
+ """Acquires any lock necessary to access this Storage.
+
+ This lock is not reentrant.
+ """
+ if self._lock is not None:
+ self._lock.acquire()
+
+ def release_lock(self):
+ """Release the Storage lock.
+
+ Trying to release a lock that isn't held will result in a
+ RuntimeError in the case of a threading.Lock or multiprocessing.Lock.
+ """
+ if self._lock is not None:
+ self._lock.release()
+
+ def locked_get(self):
+ """Retrieve credential.
+
+ The Storage lock must be held when this is called.
+
+ Returns:
+ oauth2client.client.Credentials
+ """
+ raise NotImplementedError
+
+ def locked_put(self, credentials):
+ """Write a credential.
+
+ The Storage lock must be held when this is called.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ raise NotImplementedError
+
+ def locked_delete(self):
+ """Delete a credential.
+
+ The Storage lock must be held when this is called.
+ """
+ raise NotImplementedError
+
+ def get(self):
+ """Retrieve credential.
+
+ The Storage lock must *not* be held when this is called.
+
+ Returns:
+ oauth2client.client.Credentials
+ """
+ self.acquire_lock()
+ try:
+ return self.locked_get()
+ finally:
+ self.release_lock()
+
+ def put(self, credentials):
+ """Write a credential.
+
+ The Storage lock must be held when this is called.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ self.acquire_lock()
+ try:
+ self.locked_put(credentials)
+ finally:
+ self.release_lock()
+
+ def delete(self):
+ """Delete credential.
+
+ Frees any resources associated with storing the credential.
+ The Storage lock must *not* be held when this is called.
+
+ Returns:
+ None
+ """
+ self.acquire_lock()
+ try:
+ return self.locked_delete()
+ finally:
+ self.release_lock()
+
+
+def _update_query_params(uri, params):
+ """Updates a URI with new query parameters.
+
+ Args:
+ uri: string, A valid URI, with potential existing query parameters.
+ params: dict, A dictionary of query parameters.
+
+ Returns:
+ The same URI but with the new query parameters added.
+ """
+ parts = urllib.parse.urlparse(uri)
+ query_params = dict(urllib.parse.parse_qsl(parts.query))
+ query_params.update(params)
+ new_parts = parts._replace(query=urllib.parse.urlencode(query_params))
+ return urllib.parse.urlunparse(new_parts)
+
+
+class OAuth2Credentials(Credentials):
+ """Credentials object for OAuth 2.0.
+
+ Credentials can be applied to an httplib2.Http object using the authorize()
+ method, which then adds the OAuth 2.0 access token to each request.
+
+ OAuth2Credentials objects may be safely pickled and unpickled.
+ """
+
+ @util.positional(8)
+ def __init__(self, access_token, client_id, client_secret, refresh_token,
+ token_expiry, token_uri, user_agent, revoke_uri=None,
+ id_token=None, token_response=None, scopes=None,
+ token_info_uri=None):
+ """Create an instance of OAuth2Credentials.
+
+ This constructor is not usually called by the user, instead
+ OAuth2Credentials objects are instantiated by the OAuth2WebServerFlow.
+
+ Args:
+ access_token: string, access token.
+ client_id: string, client identifier.
+ client_secret: string, client secret.
+ refresh_token: string, refresh token.
+ token_expiry: datetime, when the access_token expires.
+ token_uri: string, URI of token endpoint.
+ user_agent: string, The HTTP User-Agent to provide for this
+ application.
+ revoke_uri: string, URI for revoke endpoint. Defaults to None; a
+ token can't be revoked if this is None.
+ id_token: object, The identity of the resource owner.
+ token_response: dict, the decoded response to the token request.
+ None if a token hasn't been requested yet. Stored
+ because some providers (e.g. wordpress.com) include
+ extra fields that clients may want.
+ scopes: list, authorized scopes for these credentials.
+ token_info_uri: string, the URI for the token info endpoint. Defaults
+ to None; scopes can not be refreshed if this is None.
+
+ Notes:
+ store: callable, A callable that when passed a Credential
+ will store the credential back to where it came from.
+ This is needed to store the latest access_token if it
+ has expired and been refreshed.
+ """
+ self.access_token = access_token
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.refresh_token = refresh_token
+ self.store = None
+ self.token_expiry = token_expiry
+ self.token_uri = token_uri
+ self.user_agent = user_agent
+ self.revoke_uri = revoke_uri
+ self.id_token = id_token
+ self.token_response = token_response
+ self.scopes = set(util.string_to_scopes(scopes or []))
+ self.token_info_uri = token_info_uri
+
+ # True if the credentials have been revoked or expired and can't be
+ # refreshed.
+ self.invalid = False
+
+ def authorize(self, http):
+ """Authorize an httplib2.Http instance with these credentials.
+
+ The modified http.request method will add authentication headers to
+ each request and will refresh access_tokens when a 401 is received on a
+ request. In addition the http.request method has a credentials
+ property, http.request.credentials, which is the Credentials object
+ that authorized it.
+
+ Args:
+ http: An instance of ``httplib2.Http`` or something that acts
+ like it.
+
+ Returns:
+ A modified instance of http that was passed in.
+
+ Example::
+
+ h = httplib2.Http()
+ h = credentials.authorize(h)
+
+ You can't create a new OAuth subclass of httplib2.Authentication
+ because it never gets passed the absolute URI, which is needed for
+ signing. So instead we have to overload 'request' with a closure
+ that adds in the Authorization header and then calls the original
+ version of 'request()'.
+ """
+ transport.wrap_http_for_auth(self, http)
+ return http
+
+ def refresh(self, http):
+ """Forces a refresh of the access_token.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the refresh
+ request.
+ """
+ self._refresh(http.request)
+
+ def revoke(self, http):
+ """Revokes a refresh_token and makes the credentials void.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the revoke
+ request.
+ """
+ self._revoke(http.request)
+
+ def apply(self, headers):
+ """Add the authorization to the headers.
+
+ Args:
+ headers: dict, the headers to add the Authorization header to.
+ """
+ headers['Authorization'] = 'Bearer ' + self.access_token
+
+ def has_scopes(self, scopes):
+ """Verify that the credentials are authorized for the given scopes.
+
+ Returns True if the credentials authorized scopes contain all of the
+ scopes given.
+
+ Args:
+ scopes: list or string, the scopes to check.
+
+ Notes:
+ There are cases where the credentials are unaware of which scopes
+ are authorized. Notably, credentials obtained and stored before
+ this code was added will not have scopes, AccessTokenCredentials do
+ not have scopes. In both cases, you can use refresh_scopes() to
+ obtain the canonical set of scopes.
+ """
+ scopes = util.string_to_scopes(scopes)
+ return set(scopes).issubset(self.scopes)
+
+ def retrieve_scopes(self, http):
+ """Retrieves the canonical list of scopes for this access token.
+
+ Gets the scopes from the OAuth2 provider.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the refresh
+ request.
+
+ Returns:
+ A set of strings containing the canonical list of scopes.
+ """
+ self._retrieve_scopes(http.request)
+ return self.scopes
+
+ @classmethod
+ def from_json(cls, json_data):
+ """Instantiate a Credentials object from a JSON description of it.
+
+ The JSON should have been produced by calling .to_json() on the object.
+
+ Args:
+ json_data: string or bytes, JSON to deserialize.
+
+ Returns:
+ An instance of a Credentials subclass.
+ """
+ data = json.loads(_helpers._from_bytes(json_data))
+ if (data.get('token_expiry') and
+ not isinstance(data['token_expiry'], datetime.datetime)):
+ try:
+ data['token_expiry'] = datetime.datetime.strptime(
+ data['token_expiry'], EXPIRY_FORMAT)
+ except ValueError:
+ data['token_expiry'] = None
+ retval = cls(
+ data['access_token'],
+ data['client_id'],
+ data['client_secret'],
+ data['refresh_token'],
+ data['token_expiry'],
+ data['token_uri'],
+ data['user_agent'],
+ revoke_uri=data.get('revoke_uri', None),
+ id_token=data.get('id_token', None),
+ token_response=data.get('token_response', None),
+ scopes=data.get('scopes', None),
+ token_info_uri=data.get('token_info_uri', None))
+ retval.invalid = data['invalid']
+ return retval
+
+ @property
+ def access_token_expired(self):
+ """True if the credential is expired or invalid.
+
+ If the token_expiry isn't set, we assume the token doesn't expire.
+ """
+ if self.invalid:
+ return True
+
+ if not self.token_expiry:
+ return False
+
+ now = _UTCNOW()
+ if now >= self.token_expiry:
+ logger.info('access_token is expired. Now: %s, token_expiry: %s',
+ now, self.token_expiry)
+ return True
+ return False
+
+ def get_access_token(self, http=None):
+ """Return the access token and its expiration information.
+
+ If the token does not exist, get one.
+ If the token expired, refresh it.
+ """
+ if not self.access_token or self.access_token_expired:
+ if not http:
+ http = transport.get_http_object()
+ self.refresh(http)
+ return AccessTokenInfo(access_token=self.access_token,
+ expires_in=self._expires_in())
+
+ def set_store(self, store):
+ """Set the Storage for the credential.
+
+ Args:
+ store: Storage, an implementation of Storage object.
+ This is needed to store the latest access_token if it
+ has expired and been refreshed. This implementation uses
+ locking to check for updates before updating the
+ access_token.
+ """
+ self.store = store
+
+ def _expires_in(self):
+ """Return the number of seconds until this token expires.
+
+ If token_expiry is in the past, this method will return 0, meaning the
+ token has already expired.
+
+ If token_expiry is None, this method will return None. Note that
+ returning 0 in such a case would not be fair: the token may still be
+ valid; we just don't know anything about it.
+ """
+ if self.token_expiry:
+ now = _UTCNOW()
+ if self.token_expiry > now:
+ time_delta = self.token_expiry - now
+ # TODO(orestica): return time_delta.total_seconds()
+ # once dropping support for Python 2.6
+ return time_delta.days * 86400 + time_delta.seconds
+ else:
+ return 0
+
+ def _updateFromCredential(self, other):
+ """Update this Credential from another instance."""
+ self.__dict__.update(other.__getstate__())
+
+ def __getstate__(self):
+ """Trim the state down to something that can be pickled."""
+ d = copy.copy(self.__dict__)
+ del d['store']
+ return d
+
+ def __setstate__(self, state):
+ """Reconstitute the state of the object from being pickled."""
+ self.__dict__.update(state)
+ self.store = None
+
+ def _generate_refresh_request_body(self):
+ """Generate the body that will be used in the refresh request."""
+ body = urllib.parse.urlencode({
+ 'grant_type': 'refresh_token',
+ 'client_id': self.client_id,
+ 'client_secret': self.client_secret,
+ 'refresh_token': self.refresh_token,
+ })
+ return body
+
+ def _generate_refresh_request_headers(self):
+ """Generate the headers that will be used in the refresh request."""
+ headers = {
+ 'content-type': 'application/x-www-form-urlencoded',
+ }
+
+ if self.user_agent is not None:
+ headers['user-agent'] = self.user_agent
+
+ return headers
+
+ def _refresh(self, http_request):
+ """Refreshes the access_token.
+
+ This method first checks by reading the Storage object if available.
+ If a refresh is still needed, it holds the Storage lock until the
+ refresh is completed.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ refresh request.
+
+ Raises:
+ HttpAccessTokenRefreshError: When the refresh fails.
+ """
+ if not self.store:
+ self._do_refresh_request(http_request)
+ else:
+ self.store.acquire_lock()
+ try:
+ new_cred = self.store.locked_get()
+
+ if (new_cred and not new_cred.invalid and
+ new_cred.access_token != self.access_token and
+ not new_cred.access_token_expired):
+ logger.info('Updated access_token read from Storage')
+ self._updateFromCredential(new_cred)
+ else:
+ self._do_refresh_request(http_request)
+ finally:
+ self.store.release_lock()
+
+ def _do_refresh_request(self, http_request):
+ """Refresh the access_token using the refresh_token.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ refresh request.
+
+ Raises:
+ HttpAccessTokenRefreshError: When the refresh fails.
+ """
+ body = self._generate_refresh_request_body()
+ headers = self._generate_refresh_request_headers()
+
+ logger.info('Refreshing access_token')
+ resp, content = http_request(
+ self.token_uri, method='POST', body=body, headers=headers)
+ content = _helpers._from_bytes(content)
+ if resp.status == http_client.OK:
+ d = json.loads(content)
+ self.token_response = d
+ self.access_token = d['access_token']
+ self.refresh_token = d.get('refresh_token', self.refresh_token)
+ if 'expires_in' in d:
+ delta = datetime.timedelta(seconds=int(d['expires_in']))
+ self.token_expiry = delta + _UTCNOW()
+ else:
+ self.token_expiry = None
+ if 'id_token' in d:
+ self.id_token = _extract_id_token(d['id_token'])
+ else:
+ self.id_token = None
+ # On temporary refresh errors, the user does not actually have to
+ # re-authorize, so we unflag here.
+ self.invalid = False
+ if self.store:
+ self.store.locked_put(self)
+ else:
+ # An {'error':...} response body means the token is expired or
+ # revoked, so we flag the credentials as such.
+ logger.info('Failed to retrieve access token: %s', content)
+ error_msg = 'Invalid response {0}.'.format(resp['status'])
+ try:
+ d = json.loads(content)
+ if 'error' in d:
+ error_msg = d['error']
+ if 'error_description' in d:
+ error_msg += ': ' + d['error_description']
+ self.invalid = True
+ if self.store is not None:
+ self.store.locked_put(self)
+ except (TypeError, ValueError):
+ pass
+ raise HttpAccessTokenRefreshError(error_msg, status=resp.status)
+
+ def _revoke(self, http_request):
+ """Revokes this credential and deletes the stored copy (if it exists).
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ revoke request.
+ """
+ self._do_revoke(http_request, self.refresh_token or self.access_token)
+
+ def _do_revoke(self, http_request, token):
+ """Revokes this credential and deletes the stored copy (if it exists).
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ refresh request.
+ token: A string used as the token to be revoked. Can be either an
+ access_token or refresh_token.
+
+ Raises:
+ TokenRevokeError: If the revoke request does not return with a
+ 200 OK.
+ """
+ logger.info('Revoking token')
+ query_params = {'token': token}
+ token_revoke_uri = _update_query_params(self.revoke_uri, query_params)
+ resp, content = http_request(token_revoke_uri)
+ if resp.status == http_client.OK:
+ self.invalid = True
+ else:
+ error_msg = 'Invalid response {0}.'.format(resp.status)
+ try:
+ d = json.loads(_helpers._from_bytes(content))
+ if 'error' in d:
+ error_msg = d['error']
+ except (TypeError, ValueError):
+ pass
+ raise TokenRevokeError(error_msg)
+
+ if self.store:
+ self.store.delete()
+
+ def _retrieve_scopes(self, http_request):
+ """Retrieves the list of authorized scopes from the OAuth2 provider.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ revoke request.
+ """
+ self._do_retrieve_scopes(http_request, self.access_token)
+
+ def _do_retrieve_scopes(self, http_request, token):
+ """Retrieves the list of authorized scopes from the OAuth2 provider.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ refresh request.
+ token: A string used as the token to identify the credentials to
+ the provider.
+
+ Raises:
+ Error: When refresh fails, indicating the the access token is
+ invalid.
+ """
+ logger.info('Refreshing scopes')
+ query_params = {'access_token': token, 'fields': 'scope'}
+ token_info_uri = _update_query_params(self.token_info_uri,
+ query_params)
+ resp, content = http_request(token_info_uri)
+ content = _helpers._from_bytes(content)
+ if resp.status == http_client.OK:
+ d = json.loads(content)
+ self.scopes = set(util.string_to_scopes(d.get('scope', '')))
+ else:
+ error_msg = 'Invalid response {0}.'.format(resp.status)
+ try:
+ d = json.loads(content)
+ if 'error_description' in d:
+ error_msg = d['error_description']
+ except (TypeError, ValueError):
+ pass
+ raise Error(error_msg)
+
+
+class AccessTokenCredentials(OAuth2Credentials):
+ """Credentials object for OAuth 2.0.
+
+ Credentials can be applied to an httplib2.Http object using the
+ authorize() method, which then signs each request from that object
+ with the OAuth 2.0 access token. This set of credentials is for the
+ use case where you have acquired an OAuth 2.0 access_token from
+ another place such as a JavaScript client or another web
+ application, and wish to use it from Python. Because only the
+ access_token is present it can not be refreshed and will in time
+ expire.
+
+ AccessTokenCredentials objects may be safely pickled and unpickled.
+
+ Usage::
+
+ credentials = AccessTokenCredentials('<an access token>',
+ 'my-user-agent/1.0')
+ http = httplib2.Http()
+ http = credentials.authorize(http)
+
+ Raises:
+ AccessTokenCredentialsExpired: raised when the access_token expires or
+ is revoked.
+ """
+
+ def __init__(self, access_token, user_agent, revoke_uri=None):
+ """Create an instance of OAuth2Credentials
+
+ This is one of the few types if Credentials that you should contrust,
+ Credentials objects are usually instantiated by a Flow.
+
+ Args:
+ access_token: string, access token.
+ user_agent: string, The HTTP User-Agent to provide for this
+ application.
+ revoke_uri: string, URI for revoke endpoint. Defaults to None; a
+ token can't be revoked if this is None.
+ """
+ super(AccessTokenCredentials, self).__init__(
+ access_token,
+ None,
+ None,
+ None,
+ None,
+ None,
+ user_agent,
+ revoke_uri=revoke_uri)
+
+ @classmethod
+ def from_json(cls, json_data):
+ data = json.loads(_helpers._from_bytes(json_data))
+ retval = AccessTokenCredentials(
+ data['access_token'],
+ data['user_agent'])
+ return retval
+
+ def _refresh(self, http_request):
+ raise AccessTokenCredentialsError(
+ 'The access_token is expired or invalid and can\'t be refreshed.')
+
+ def _revoke(self, http_request):
+ """Revokes the access_token and deletes the store if available.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ revoke request.
+ """
+ self._do_revoke(http_request, self.access_token)
+
+
+def _detect_gce_environment():
+ """Determine if the current environment is Compute Engine.
+
+ Returns:
+ Boolean indicating whether or not the current environment is Google
+ Compute Engine.
+ """
+ # NOTE: The explicit ``timeout`` is a workaround. The underlying
+ # issue is that resolving an unknown host on some networks will take
+ # 20-30 seconds; making this timeout short fixes the issue, but
+ # could lead to false negatives in the event that we are on GCE, but
+ # the metadata resolution was particularly slow. The latter case is
+ # "unlikely".
+ connection = six.moves.http_client.HTTPConnection(
+ _GCE_METADATA_HOST, timeout=GCE_METADATA_TIMEOUT)
+
+ try:
+ headers = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR}
+ connection.request('GET', '/', headers=headers)
+ response = connection.getresponse()
+ if response.status == http_client.OK:
+ return (response.getheader(_METADATA_FLAVOR_HEADER) ==
+ _DESIRED_METADATA_FLAVOR)
+ except socket.error: # socket.timeout or socket.error(64, 'Host is down')
+ logger.info('Timeout attempting to reach GCE metadata service.')
+ return False
+ finally:
+ connection.close()
+
+
+def _in_gae_environment():
+ """Detects if the code is running in the App Engine environment.
+
+ Returns:
+ True if running in the GAE environment, False otherwise.
+ """
+ if SETTINGS.env_name is not None:
+ return SETTINGS.env_name in ('GAE_PRODUCTION', 'GAE_LOCAL')
+
+ try:
+ import google.appengine # noqa: unused import
+ except ImportError:
+ pass
+ else:
+ server_software = os.environ.get(_SERVER_SOFTWARE, '')
+ if server_software.startswith('Google App Engine/'):
+ SETTINGS.env_name = 'GAE_PRODUCTION'
+ return True
+ elif server_software.startswith('Development/'):
+ SETTINGS.env_name = 'GAE_LOCAL'
+ return True
+
+ return False
+
+
+def _in_gce_environment():
+ """Detect if the code is running in the Compute Engine environment.
+
+ Returns:
+ True if running in the GCE environment, False otherwise.
+ """
+ if SETTINGS.env_name is not None:
+ return SETTINGS.env_name == 'GCE_PRODUCTION'
+
+ if NO_GCE_CHECK != 'True' and _detect_gce_environment():
+ SETTINGS.env_name = 'GCE_PRODUCTION'
+ return True
+ return False
+
+
+class GoogleCredentials(OAuth2Credentials):
+ """Application Default Credentials for use in calling Google APIs.
+
+ The Application Default Credentials are being constructed as a function of
+ the environment where the code is being run.
+ More details can be found on this page:
+ https://developers.google.com/accounts/docs/application-default-credentials
+
+ Here is an example of how to use the Application Default Credentials for a
+ service that requires authentication::
+
+ from googleapiclient.discovery import build
+ from oauth2client.client import GoogleCredentials
+
+ credentials = GoogleCredentials.get_application_default()
+ service = build('compute', 'v1', credentials=credentials)
+
+ PROJECT = 'bamboo-machine-422'
+ ZONE = 'us-central1-a'
+ request = service.instances().list(project=PROJECT, zone=ZONE)
+ response = request.execute()
+
+ print(response)
+ """
+
+ NON_SERIALIZED_MEMBERS = (
+ frozenset(['_private_key']) |
+ OAuth2Credentials.NON_SERIALIZED_MEMBERS)
+ """Members that aren't serialized when object is converted to JSON."""
+
+ def __init__(self, access_token, client_id, client_secret, refresh_token,
+ token_expiry, token_uri, user_agent,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI):
+ """Create an instance of GoogleCredentials.
+
+ This constructor is not usually called by the user, instead
+ GoogleCredentials objects are instantiated by
+ GoogleCredentials.from_stream() or
+ GoogleCredentials.get_application_default().
+
+ Args:
+ access_token: string, access token.
+ client_id: string, client identifier.
+ client_secret: string, client secret.
+ refresh_token: string, refresh token.
+ token_expiry: datetime, when the access_token expires.
+ token_uri: string, URI of token endpoint.
+ user_agent: string, The HTTP User-Agent to provide for this
+ application.
+ revoke_uri: string, URI for revoke endpoint. Defaults to
+ oauth2client.GOOGLE_REVOKE_URI; a token can't be
+ revoked if this is None.
+ """
+ super(GoogleCredentials, self).__init__(
+ access_token, client_id, client_secret, refresh_token,
+ token_expiry, token_uri, user_agent, revoke_uri=revoke_uri)
+
+ def create_scoped_required(self):
+ """Whether this Credentials object is scopeless.
+
+ create_scoped(scopes) method needs to be called in order to create
+ a Credentials object for API calls.
+ """
+ return False
+
+ def create_scoped(self, scopes):
+ """Create a Credentials object for the given scopes.
+
+ The Credentials type is preserved.
+ """
+ return self
+
+ @classmethod
+ def from_json(cls, json_data):
+ # TODO(issue 388): eliminate the circularity that is the reason for
+ # this non-top-level import.
+ from oauth2client import service_account
+ data = json.loads(_helpers._from_bytes(json_data))
+
+ # We handle service_account.ServiceAccountCredentials since it is a
+ # possible return type of GoogleCredentials.get_application_default()
+ if (data['_module'] == 'oauth2client.service_account' and
+ data['_class'] == 'ServiceAccountCredentials'):
+ return service_account.ServiceAccountCredentials.from_json(data)
+ elif (data['_module'] == 'oauth2client.service_account' and
+ data['_class'] == '_JWTAccessCredentials'):
+ return service_account._JWTAccessCredentials.from_json(data)
+
+ token_expiry = _parse_expiry(data.get('token_expiry'))
+ google_credentials = cls(
+ data['access_token'],
+ data['client_id'],
+ data['client_secret'],
+ data['refresh_token'],
+ token_expiry,
+ data['token_uri'],
+ data['user_agent'],
+ revoke_uri=data.get('revoke_uri', None))
+ google_credentials.invalid = data['invalid']
+ return google_credentials
+
+ @property
+ def serialization_data(self):
+ """Get the fields and values identifying the current credentials."""
+ return {
+ 'type': 'authorized_user',
+ 'client_id': self.client_id,
+ 'client_secret': self.client_secret,
+ 'refresh_token': self.refresh_token
+ }
+
+ @staticmethod
+ def _implicit_credentials_from_gae():
+ """Attempts to get implicit credentials in Google App Engine env.
+
+ If the current environment is not detected as App Engine, returns None,
+ indicating no Google App Engine credentials can be detected from the
+ current environment.
+
+ Returns:
+ None, if not in GAE, else an appengine.AppAssertionCredentials
+ object.
+ """
+ if not _in_gae_environment():
+ return None
+
+ return _get_application_default_credential_GAE()
+
+ @staticmethod
+ def _implicit_credentials_from_gce():
+ """Attempts to get implicit credentials in Google Compute Engine env.
+
+ If the current environment is not detected as Compute Engine, returns
+ None, indicating no Google Compute Engine credentials can be detected
+ from the current environment.
+
+ Returns:
+ None, if not in GCE, else a gce.AppAssertionCredentials object.
+ """
+ if not _in_gce_environment():
+ return None
+
+ return _get_application_default_credential_GCE()
+
+ @staticmethod
+ def _implicit_credentials_from_files():
+ """Attempts to get implicit credentials from local credential files.
+
+ First checks if the environment variable GOOGLE_APPLICATION_CREDENTIALS
+ is set with a filename and then falls back to a configuration file (the
+ "well known" file) associated with the 'gcloud' command line tool.
+
+ Returns:
+ Credentials object associated with the
+ GOOGLE_APPLICATION_CREDENTIALS file or the "well known" file if
+ either exist. If neither file is define, returns None, indicating
+ no credentials from a file can detected from the current
+ environment.
+ """
+ credentials_filename = _get_environment_variable_file()
+ if not credentials_filename:
+ credentials_filename = _get_well_known_file()
+ if os.path.isfile(credentials_filename):
+ extra_help = (' (produced automatically when running'
+ ' "gcloud auth login" command)')
+ else:
+ credentials_filename = None
+ else:
+ extra_help = (' (pointed to by ' + GOOGLE_APPLICATION_CREDENTIALS +
+ ' environment variable)')
+
+ if not credentials_filename:
+ return
+
+ # If we can read the credentials from a file, we don't need to know
+ # what environment we are in.
+ SETTINGS.env_name = DEFAULT_ENV_NAME
+
+ try:
+ return _get_application_default_credential_from_file(
+ credentials_filename)
+ except (ApplicationDefaultCredentialsError, ValueError) as error:
+ _raise_exception_for_reading_json(credentials_filename,
+ extra_help, error)
+
+ @classmethod
+ def _get_implicit_credentials(cls):
+ """Gets credentials implicitly from the environment.
+
+ Checks environment in order of precedence:
+ - Environment variable GOOGLE_APPLICATION_CREDENTIALS pointing to
+ a file with stored credentials information.
+ - Stored "well known" file associated with `gcloud` command line tool.
+ - Google App Engine (production and testing)
+ - Google Compute Engine production environment.
+
+ Raises:
+ ApplicationDefaultCredentialsError: raised when the credentials
+ fail to be retrieved.
+ """
+ # Environ checks (in order).
+ environ_checkers = [
+ cls._implicit_credentials_from_files,
+ cls._implicit_credentials_from_gae,
+ cls._implicit_credentials_from_gce,
+ ]
+
+ for checker in environ_checkers:
+ credentials = checker()
+ if credentials is not None:
+ return credentials
+
+ # If no credentials, fail.
+ raise ApplicationDefaultCredentialsError(ADC_HELP_MSG)
+
+ @staticmethod
+ def get_application_default():
+ """Get the Application Default Credentials for the current environment.
+
+ Raises:
+ ApplicationDefaultCredentialsError: raised when the credentials
+ fail to be retrieved.
+ """
+ return GoogleCredentials._get_implicit_credentials()
+
+ @staticmethod
+ def from_stream(credential_filename):
+ """Create a Credentials object by reading information from a file.
+
+ It returns an object of type GoogleCredentials.
+
+ Args:
+ credential_filename: the path to the file from where the
+ credentials are to be read
+
+ Raises:
+ ApplicationDefaultCredentialsError: raised when the credentials
+ fail to be retrieved.
+ """
+ if credential_filename and os.path.isfile(credential_filename):
+ try:
+ return _get_application_default_credential_from_file(
+ credential_filename)
+ except (ApplicationDefaultCredentialsError, ValueError) as error:
+ extra_help = (' (provided as parameter to the '
+ 'from_stream() method)')
+ _raise_exception_for_reading_json(credential_filename,
+ extra_help,
+ error)
+ else:
+ raise ApplicationDefaultCredentialsError(
+ 'The parameter passed to the from_stream() '
+ 'method should point to a file.')
+
+
+def _save_private_file(filename, json_contents):
+ """Saves a file with read-write permissions on for the owner.
+
+ Args:
+ filename: String. Absolute path to file.
+ json_contents: JSON serializable object to be saved.
+ """
+ temp_filename = tempfile.mktemp()
+ file_desc = os.open(temp_filename, os.O_WRONLY | os.O_CREAT, 0o600)
+ with os.fdopen(file_desc, 'w') as file_handle:
+ json.dump(json_contents, file_handle, sort_keys=True,
+ indent=2, separators=(',', ': '))
+ shutil.move(temp_filename, filename)
+
+
+def save_to_well_known_file(credentials, well_known_file=None):
+ """Save the provided GoogleCredentials to the well known file.
+
+ Args:
+ credentials: the credentials to be saved to the well known file;
+ it should be an instance of GoogleCredentials
+ well_known_file: the name of the file where the credentials are to be
+ saved; this parameter is supposed to be used for
+ testing only
+ """
+ # TODO(orestica): move this method to tools.py
+ # once the argparse import gets fixed (it is not present in Python 2.6)
+
+ if well_known_file is None:
+ well_known_file = _get_well_known_file()
+
+ config_dir = os.path.dirname(well_known_file)
+ if not os.path.isdir(config_dir):
+ raise OSError(
+ 'Config directory does not exist: {0}'.format(config_dir))
+
+ credentials_data = credentials.serialization_data
+ _save_private_file(well_known_file, credentials_data)
+
+
+def _get_environment_variable_file():
+ application_default_credential_filename = (
+ os.environ.get(GOOGLE_APPLICATION_CREDENTIALS, None))
+
+ if application_default_credential_filename:
+ if os.path.isfile(application_default_credential_filename):
+ return application_default_credential_filename
+ else:
+ raise ApplicationDefaultCredentialsError(
+ 'File ' + application_default_credential_filename +
+ ' (pointed by ' +
+ GOOGLE_APPLICATION_CREDENTIALS +
+ ' environment variable) does not exist!')
+
+
+def _get_well_known_file():
+ """Get the well known file produced by command 'gcloud auth login'."""
+ # TODO(orestica): Revisit this method once gcloud provides a better way
+ # of pinpointing the exact location of the file.
+ default_config_dir = os.getenv(_CLOUDSDK_CONFIG_ENV_VAR)
+ if default_config_dir is None:
+ if os.name == 'nt':
+ try:
+ default_config_dir = os.path.join(os.environ['APPDATA'],
+ _CLOUDSDK_CONFIG_DIRECTORY)
+ except KeyError:
+ # This should never happen unless someone is really
+ # messing with things.
+ drive = os.environ.get('SystemDrive', 'C:')
+ default_config_dir = os.path.join(drive, '\\',
+ _CLOUDSDK_CONFIG_DIRECTORY)
+ else:
+ default_config_dir = os.path.join(os.path.expanduser('~'),
+ '.config',
+ _CLOUDSDK_CONFIG_DIRECTORY)
+
+ return os.path.join(default_config_dir, _WELL_KNOWN_CREDENTIALS_FILE)
+
+
+def _get_application_default_credential_from_file(filename):
+ """Build the Application Default Credentials from file."""
+ # read the credentials from the file
+ with open(filename) as file_obj:
+ client_credentials = json.load(file_obj)
+
+ credentials_type = client_credentials.get('type')
+ if credentials_type == AUTHORIZED_USER:
+ required_fields = set(['client_id', 'client_secret', 'refresh_token'])
+ elif credentials_type == SERVICE_ACCOUNT:
+ required_fields = set(['client_id', 'client_email', 'private_key_id',
+ 'private_key'])
+ else:
+ raise ApplicationDefaultCredentialsError(
+ "'type' field should be defined (and have one of the '" +
+ AUTHORIZED_USER + "' or '" + SERVICE_ACCOUNT + "' values)")
+
+ missing_fields = required_fields.difference(client_credentials.keys())
+
+ if missing_fields:
+ _raise_exception_for_missing_fields(missing_fields)
+
+ if client_credentials['type'] == AUTHORIZED_USER:
+ return GoogleCredentials(
+ access_token=None,
+ client_id=client_credentials['client_id'],
+ client_secret=client_credentials['client_secret'],
+ refresh_token=client_credentials['refresh_token'],
+ token_expiry=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ user_agent='Python client library')
+ else: # client_credentials['type'] == SERVICE_ACCOUNT
+ from oauth2client import service_account
+ return service_account._JWTAccessCredentials.from_json_keyfile_dict(
+ client_credentials)
+
+
+def _raise_exception_for_missing_fields(missing_fields):
+ raise ApplicationDefaultCredentialsError(
+ 'The following field(s) must be defined: ' + ', '.join(missing_fields))
+
+
+def _raise_exception_for_reading_json(credential_file,
+ extra_help,
+ error):
+ raise ApplicationDefaultCredentialsError(
+ 'An error was encountered while reading json file: ' +
+ credential_file + extra_help + ': ' + str(error))
+
+
+def _get_application_default_credential_GAE():
+ from oauth2client.contrib.appengine import AppAssertionCredentials
+
+ return AppAssertionCredentials([])
+
+
+def _get_application_default_credential_GCE():
+ from oauth2client.contrib.gce import AppAssertionCredentials
+
+ return AppAssertionCredentials()
+
+
+class AssertionCredentials(GoogleCredentials):
+ """Abstract Credentials object used for OAuth 2.0 assertion grants.
+
+ This credential does not require a flow to instantiate because it
+ represents a two legged flow, and therefore has all of the required
+ information to generate and refresh its own access tokens. It must
+ be subclassed to generate the appropriate assertion string.
+
+ AssertionCredentials objects may be safely pickled and unpickled.
+ """
+
+ @util.positional(2)
+ def __init__(self, assertion_type, user_agent=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ **unused_kwargs):
+ """Constructor for AssertionFlowCredentials.
+
+ Args:
+ assertion_type: string, assertion type that will be declared to the
+ auth server
+ user_agent: string, The HTTP User-Agent to provide for this
+ application.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint.
+ """
+ super(AssertionCredentials, self).__init__(
+ None,
+ None,
+ None,
+ None,
+ None,
+ token_uri,
+ user_agent,
+ revoke_uri=revoke_uri)
+ self.assertion_type = assertion_type
+
+ def _generate_refresh_request_body(self):
+ assertion = self._generate_assertion()
+
+ body = urllib.parse.urlencode({
+ 'assertion': assertion,
+ 'grant_type': 'urn:ietf:params:oauth:grant-type:jwt-bearer',
+ })
+
+ return body
+
+ def _generate_assertion(self):
+ """Generate assertion string to be used in the access token request."""
+ raise NotImplementedError
+
+ def _revoke(self, http_request):
+ """Revokes the access_token and deletes the store if available.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ revoke request.
+ """
+ self._do_revoke(http_request, self.access_token)
+
+ def sign_blob(self, blob):
+ """Cryptographically sign a blob (of bytes).
+
+ Args:
+ blob: bytes, Message to be signed.
+
+ Returns:
+ tuple, A pair of the private key ID used to sign the blob and
+ the signed contents.
+ """
+ raise NotImplementedError('This method is abstract.')
+
+
+def _require_crypto_or_die():
+ """Ensure we have a crypto library, or throw CryptoUnavailableError.
+
+ The oauth2client.crypt module requires either PyCrypto or PyOpenSSL
+ to be available in order to function, but these are optional
+ dependencies.
+ """
+ if not HAS_CRYPTO:
+ raise CryptoUnavailableError('No crypto library available')
+
+
+@util.positional(2)
+def verify_id_token(id_token, audience, http=None,
+ cert_uri=ID_TOKEN_VERIFICATION_CERTS):
+ """Verifies a signed JWT id_token.
+
+ This function requires PyOpenSSL and because of that it does not work on
+ App Engine.
+
+ Args:
+ id_token: string, A Signed JWT.
+ audience: string, The audience 'aud' that the token should be for.
+ http: httplib2.Http, instance to use to make the HTTP request. Callers
+ should supply an instance that has caching enabled.
+ cert_uri: string, URI of the certificates in JSON format to
+ verify the JWT against.
+
+ Returns:
+ The deserialized JSON in the JWT.
+
+ Raises:
+ oauth2client.crypt.AppIdentityError: if the JWT fails to verify.
+ CryptoUnavailableError: if no crypto library is available.
+ """
+ _require_crypto_or_die()
+ if http is None:
+ http = transport.get_cached_http()
+
+ resp, content = http.request(cert_uri)
+ if resp.status == http_client.OK:
+ certs = json.loads(_helpers._from_bytes(content))
+ return crypt.verify_signed_jwt_with_certs(id_token, certs, audience)
+ else:
+ raise VerifyJwtTokenError('Status code: {0}'.format(resp.status))
+
+
+def _extract_id_token(id_token):
+ """Extract the JSON payload from a JWT.
+
+ Does the extraction w/o checking the signature.
+
+ Args:
+ id_token: string or bytestring, OAuth 2.0 id_token.
+
+ Returns:
+ object, The deserialized JSON payload.
+ """
+ if type(id_token) == bytes:
+ segments = id_token.split(b'.')
+ else:
+ segments = id_token.split(u'.')
+
+ if len(segments) != 3:
+ raise VerifyJwtTokenError(
+ 'Wrong number of segments in token: {0}'.format(id_token))
+
+ return json.loads(
+ _helpers._from_bytes(_helpers._urlsafe_b64decode(segments[1])))
+
+
+def _parse_exchange_token_response(content):
+ """Parses response of an exchange token request.
+
+ Most providers return JSON but some (e.g. Facebook) return a
+ url-encoded string.
+
+ Args:
+ content: The body of a response
+
+ Returns:
+ Content as a dictionary object. Note that the dict could be empty,
+ i.e. {}. That basically indicates a failure.
+ """
+ resp = {}
+ content = _helpers._from_bytes(content)
+ try:
+ resp = json.loads(content)
+ except Exception:
+ # different JSON libs raise different exceptions,
+ # so we just do a catch-all here
+ resp = dict(urllib.parse.parse_qsl(content))
+
+ # some providers respond with 'expires', others with 'expires_in'
+ if resp and 'expires' in resp:
+ resp['expires_in'] = resp.pop('expires')
+
+ return resp
+
+
+@util.positional(4)
+def credentials_from_code(client_id, client_secret, scope, code,
+ redirect_uri='postmessage', http=None,
+ user_agent=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ auth_uri=oauth2client.GOOGLE_AUTH_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ device_uri=oauth2client.GOOGLE_DEVICE_URI,
+ token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI):
+ """Exchanges an authorization code for an OAuth2Credentials object.
+
+ Args:
+ client_id: string, client identifier.
+ client_secret: string, client secret.
+ scope: string or iterable of strings, scope(s) to request.
+ code: string, An authorization code, most likely passed down from
+ the client
+ redirect_uri: string, this is generally set to 'postmessage' to match
+ the redirect_uri that the client specified
+ http: httplib2.Http, optional http instance to use to do the fetch
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ auth_uri: string, URI for authorization endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider
+ can be used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider
+ can be used.
+ device_uri: string, URI for device authorization endpoint. For
+ convenience defaults to Google's endpoints but any OAuth
+ 2.0 provider can be used.
+
+ Returns:
+ An OAuth2Credentials object.
+
+ Raises:
+ FlowExchangeError if the authorization code cannot be exchanged for an
+ access token
+ """
+ flow = OAuth2WebServerFlow(client_id, client_secret, scope,
+ redirect_uri=redirect_uri,
+ user_agent=user_agent, auth_uri=auth_uri,
+ token_uri=token_uri, revoke_uri=revoke_uri,
+ device_uri=device_uri,
+ token_info_uri=token_info_uri)
+
+ credentials = flow.step2_exchange(code, http=http)
+ return credentials
+
+
+@util.positional(3)
+def credentials_from_clientsecrets_and_code(filename, scope, code,
+ message=None,
+ redirect_uri='postmessage',
+ http=None,
+ cache=None,
+ device_uri=None):
+ """Returns OAuth2Credentials from a clientsecrets file and an auth code.
+
+ Will create the right kind of Flow based on the contents of the
+ clientsecrets file or will raise InvalidClientSecretsError for unknown
+ types of Flows.
+
+ Args:
+ filename: string, File name of clientsecrets.
+ scope: string or iterable of strings, scope(s) to request.
+ code: string, An authorization code, most likely passed down from
+ the client
+ message: string, A friendly string to display to the user if the
+ clientsecrets file is missing or invalid. If message is
+ provided then sys.exit will be called in the case of an error.
+ If message in not provided then
+ clientsecrets.InvalidClientSecretsError will be raised.
+ redirect_uri: string, this is generally set to 'postmessage' to match
+ the redirect_uri that the client specified
+ http: httplib2.Http, optional http instance to use to do the fetch
+ cache: An optional cache service client that implements get() and set()
+ methods. See clientsecrets.loadfile() for details.
+ device_uri: string, OAuth 2.0 device authorization endpoint
+
+ Returns:
+ An OAuth2Credentials object.
+
+ Raises:
+ FlowExchangeError: if the authorization code cannot be exchanged for an
+ access token
+ UnknownClientSecretsFlowError: if the file describes an unknown kind
+ of Flow.
+ clientsecrets.InvalidClientSecretsError: if the clientsecrets file is
+ invalid.
+ """
+ flow = flow_from_clientsecrets(filename, scope, message=message,
+ cache=cache, redirect_uri=redirect_uri,
+ device_uri=device_uri)
+ credentials = flow.step2_exchange(code, http=http)
+ return credentials
+
+
+class DeviceFlowInfo(collections.namedtuple('DeviceFlowInfo', (
+ 'device_code', 'user_code', 'interval', 'verification_url',
+ 'user_code_expiry'))):
+ """Intermediate information the OAuth2 for devices flow."""
+
+ @classmethod
+ def FromResponse(cls, response):
+ """Create a DeviceFlowInfo from a server response.
+
+ The response should be a dict containing entries as described here:
+
+ http://tools.ietf.org/html/draft-ietf-oauth-v2-05#section-3.7.1
+ """
+ # device_code, user_code, and verification_url are required.
+ kwargs = {
+ 'device_code': response['device_code'],
+ 'user_code': response['user_code'],
+ }
+ # The response may list the verification address as either
+ # verification_url or verification_uri, so we check for both.
+ verification_url = response.get(
+ 'verification_url', response.get('verification_uri'))
+ if verification_url is None:
+ raise OAuth2DeviceCodeError(
+ 'No verification_url provided in server response')
+ kwargs['verification_url'] = verification_url
+ # expires_in and interval are optional.
+ kwargs.update({
+ 'interval': response.get('interval'),
+ 'user_code_expiry': None,
+ })
+ if 'expires_in' in response:
+ kwargs['user_code_expiry'] = (
+ _UTCNOW() +
+ datetime.timedelta(seconds=int(response['expires_in'])))
+ return cls(**kwargs)
+
+
+def _oauth2_web_server_flow_params(kwargs):
+ """Configures redirect URI parameters for OAuth2WebServerFlow."""
+ params = {
+ 'access_type': 'offline',
+ 'response_type': 'code',
+ }
+
+ params.update(kwargs)
+
+ # Check for the presence of the deprecated approval_prompt param and
+ # warn appropriately.
+ approval_prompt = params.get('approval_prompt')
+ if approval_prompt is not None:
+ logger.warning(
+ 'The approval_prompt parameter for OAuth2WebServerFlow is '
+ 'deprecated. Please use the prompt parameter instead.')
+
+ if approval_prompt == 'force':
+ logger.warning(
+ 'approval_prompt="force" has been adjusted to '
+ 'prompt="consent"')
+ params['prompt'] = 'consent'
+ del params['approval_prompt']
+
+ return params
+
+
+class OAuth2WebServerFlow(Flow):
+ """Does the Web Server Flow for OAuth 2.0.
+
+ OAuth2WebServerFlow objects may be safely pickled and unpickled.
+ """
+
+ @util.positional(4)
+ def __init__(self, client_id,
+ client_secret=None,
+ scope=None,
+ redirect_uri=None,
+ user_agent=None,
+ auth_uri=oauth2client.GOOGLE_AUTH_URI,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ login_hint=None,
+ device_uri=oauth2client.GOOGLE_DEVICE_URI,
+ token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI,
+ authorization_header=None,
+ **kwargs):
+ """Constructor for OAuth2WebServerFlow.
+
+ The kwargs argument is used to set extra query parameters on the
+ auth_uri. For example, the access_type and prompt
+ query parameters can be set via kwargs.
+
+ Args:
+ client_id: string, client identifier.
+ client_secret: string client secret.
+ scope: string or iterable of strings, scope(s) of the credentials
+ being requested.
+ redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob'
+ for a non-web-based application, or a URI that
+ handles the callback from the authorization server.
+ user_agent: string, HTTP User-Agent to provide for this
+ application.
+ auth_uri: string, URI for authorization endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider
+ can be used.
+ token_uri: string, URI for token endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+ login_hint: string, Either an email address or domain. Passing this
+ hint will either pre-fill the email box on the sign-in
+ form or select the proper multi-login session, thereby
+ simplifying the login flow.
+ device_uri: string, URI for device authorization endpoint. For
+ convenience defaults to Google's endpoints but any
+ OAuth 2.0 provider can be used.
+ authorization_header: string, For use with OAuth 2.0 providers that
+ require a client to authenticate using a
+ header value instead of passing client_secret
+ in the POST body.
+ **kwargs: dict, The keyword arguments are all optional and required
+ parameters for the OAuth calls.
+ """
+ # scope is a required argument, but to preserve backwards-compatibility
+ # we don't want to rearrange the positional arguments
+ if scope is None:
+ raise TypeError("The value of scope must not be None")
+ self.client_id = client_id
+ self.client_secret = client_secret
+ self.scope = util.scopes_to_string(scope)
+ self.redirect_uri = redirect_uri
+ self.login_hint = login_hint
+ self.user_agent = user_agent
+ self.auth_uri = auth_uri
+ self.token_uri = token_uri
+ self.revoke_uri = revoke_uri
+ self.device_uri = device_uri
+ self.token_info_uri = token_info_uri
+ self.authorization_header = authorization_header
+ self.params = _oauth2_web_server_flow_params(kwargs)
+
+ @util.positional(1)
+ def step1_get_authorize_url(self, redirect_uri=None, state=None):
+ """Returns a URI to redirect to the provider.
+
+ Args:
+ redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob'
+ for a non-web-based application, or a URI that
+ handles the callback from the authorization server.
+ This parameter is deprecated, please move to passing
+ the redirect_uri in via the constructor.
+ state: string, Opaque state string which is passed through the
+ OAuth2 flow and returned to the client as a query parameter
+ in the callback.
+
+ Returns:
+ A URI as a string to redirect the user to begin the authorization
+ flow.
+ """
+ if redirect_uri is not None:
+ logger.warning((
+ 'The redirect_uri parameter for '
+ 'OAuth2WebServerFlow.step1_get_authorize_url is deprecated. '
+ 'Please move to passing the redirect_uri in via the '
+ 'constructor.'))
+ self.redirect_uri = redirect_uri
+
+ if self.redirect_uri is None:
+ raise ValueError('The value of redirect_uri must not be None.')
+
+ query_params = {
+ 'client_id': self.client_id,
+ 'redirect_uri': self.redirect_uri,
+ 'scope': self.scope,
+ }
+ if state is not None:
+ query_params['state'] = state
+ if self.login_hint is not None:
+ query_params['login_hint'] = self.login_hint
+ query_params.update(self.params)
+ return _update_query_params(self.auth_uri, query_params)
+
+ @util.positional(1)
+ def step1_get_device_and_user_codes(self, http=None):
+ """Returns a user code and the verification URL where to enter it
+
+ Returns:
+ A user code as a string for the user to authorize the application
+ An URL as a string where the user has to enter the code
+ """
+ if self.device_uri is None:
+ raise ValueError('The value of device_uri must not be None.')
+
+ body = urllib.parse.urlencode({
+ 'client_id': self.client_id,
+ 'scope': self.scope,
+ })
+ headers = {
+ 'content-type': 'application/x-www-form-urlencoded',
+ }
+
+ if self.user_agent is not None:
+ headers['user-agent'] = self.user_agent
+
+ if http is None:
+ http = transport.get_http_object()
+
+ resp, content = http.request(self.device_uri, method='POST', body=body,
+ headers=headers)
+ content = _helpers._from_bytes(content)
+ if resp.status == http_client.OK:
+ try:
+ flow_info = json.loads(content)
+ except ValueError as exc:
+ raise OAuth2DeviceCodeError(
+ 'Could not parse server response as JSON: "{0}", '
+ 'error: "{1}"'.format(content, exc))
+ return DeviceFlowInfo.FromResponse(flow_info)
+ else:
+ error_msg = 'Invalid response {0}.'.format(resp.status)
+ try:
+ error_dict = json.loads(content)
+ if 'error' in error_dict:
+ error_msg += ' Error: {0}'.format(error_dict['error'])
+ except ValueError:
+ # Couldn't decode a JSON response, stick with the
+ # default message.
+ pass
+ raise OAuth2DeviceCodeError(error_msg)
+
+ @util.positional(2)
+ def step2_exchange(self, code=None, http=None, device_flow_info=None):
+ """Exchanges a code for OAuth2Credentials.
+
+ Args:
+ code: string, a dict-like object, or None. For a non-device
+ flow, this is either the response code as a string, or a
+ dictionary of query parameters to the redirect_uri. For a
+ device flow, this should be None.
+ http: httplib2.Http, optional http instance to use when fetching
+ credentials.
+ device_flow_info: DeviceFlowInfo, return value from step1 in the
+ case of a device flow.
+
+ Returns:
+ An OAuth2Credentials object that can be used to authorize requests.
+
+ Raises:
+ FlowExchangeError: if a problem occurred exchanging the code for a
+ refresh_token.
+ ValueError: if code and device_flow_info are both provided or both
+ missing.
+ """
+ if code is None and device_flow_info is None:
+ raise ValueError('No code or device_flow_info provided.')
+ if code is not None and device_flow_info is not None:
+ raise ValueError('Cannot provide both code and device_flow_info.')
+
+ if code is None:
+ code = device_flow_info.device_code
+ elif not isinstance(code, (six.string_types, six.binary_type)):
+ if 'code' not in code:
+ raise FlowExchangeError(code.get(
+ 'error', 'No code was supplied in the query parameters.'))
+ code = code['code']
+
+ post_data = {
+ 'client_id': self.client_id,
+ 'code': code,
+ 'scope': self.scope,
+ }
+ if self.client_secret is not None:
+ post_data['client_secret'] = self.client_secret
+ if device_flow_info is not None:
+ post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0'
+ else:
+ post_data['grant_type'] = 'authorization_code'
+ post_data['redirect_uri'] = self.redirect_uri
+ body = urllib.parse.urlencode(post_data)
+ headers = {
+ 'content-type': 'application/x-www-form-urlencoded',
+ }
+ if self.authorization_header is not None:
+ headers['Authorization'] = self.authorization_header
+ if self.user_agent is not None:
+ headers['user-agent'] = self.user_agent
+
+ if http is None:
+ http = transport.get_http_object()
+
+ resp, content = http.request(self.token_uri, method='POST', body=body,
+ headers=headers)
+ d = _parse_exchange_token_response(content)
+ if resp.status == http_client.OK and 'access_token' in d:
+ access_token = d['access_token']
+ refresh_token = d.get('refresh_token', None)
+ if not refresh_token:
+ logger.info(
+ 'Received token response with no refresh_token. Consider '
+ "reauthenticating with prompt='consent'.")
+ token_expiry = None
+ if 'expires_in' in d:
+ delta = datetime.timedelta(seconds=int(d['expires_in']))
+ token_expiry = delta + _UTCNOW()
+
+ extracted_id_token = None
+ if 'id_token' in d:
+ extracted_id_token = _extract_id_token(d['id_token'])
+
+ logger.info('Successfully retrieved access token')
+ return OAuth2Credentials(
+ access_token, self.client_id, self.client_secret,
+ refresh_token, token_expiry, self.token_uri, self.user_agent,
+ revoke_uri=self.revoke_uri, id_token=extracted_id_token,
+ token_response=d, scopes=self.scope,
+ token_info_uri=self.token_info_uri)
+ else:
+ logger.info('Failed to retrieve access token: %s', content)
+ if 'error' in d:
+ # you never know what those providers got to say
+ error_msg = (str(d['error']) +
+ str(d.get('error_description', '')))
+ else:
+ error_msg = 'Invalid response: {0}.'.format(str(resp.status))
+ raise FlowExchangeError(error_msg)
+
+
+@util.positional(2)
+def flow_from_clientsecrets(filename, scope, redirect_uri=None,
+ message=None, cache=None, login_hint=None,
+ device_uri=None):
+ """Create a Flow from a clientsecrets file.
+
+ Will create the right kind of Flow based on the contents of the
+ clientsecrets file or will raise InvalidClientSecretsError for unknown
+ types of Flows.
+
+ Args:
+ filename: string, File name of client secrets.
+ scope: string or iterable of strings, scope(s) to request.
+ redirect_uri: string, Either the string 'urn:ietf:wg:oauth:2.0:oob' for
+ a non-web-based application, or a URI that handles the
+ callback from the authorization server.
+ message: string, A friendly string to display to the user if the
+ clientsecrets file is missing or invalid. If message is
+ provided then sys.exit will be called in the case of an error.
+ If message in not provided then
+ clientsecrets.InvalidClientSecretsError will be raised.
+ cache: An optional cache service client that implements get() and set()
+ methods. See clientsecrets.loadfile() for details.
+ login_hint: string, Either an email address or domain. Passing this
+ hint will either pre-fill the email box on the sign-in form
+ or select the proper multi-login session, thereby
+ simplifying the login flow.
+ device_uri: string, URI for device authorization endpoint. For
+ convenience defaults to Google's endpoints but any
+ OAuth 2.0 provider can be used.
+
+ Returns:
+ A Flow object.
+
+ Raises:
+ UnknownClientSecretsFlowError: if the file describes an unknown kind of
+ Flow.
+ clientsecrets.InvalidClientSecretsError: if the clientsecrets file is
+ invalid.
+ """
+ try:
+ client_type, client_info = clientsecrets.loadfile(filename,
+ cache=cache)
+ if client_type in (clientsecrets.TYPE_WEB,
+ clientsecrets.TYPE_INSTALLED):
+ constructor_kwargs = {
+ 'redirect_uri': redirect_uri,
+ 'auth_uri': client_info['auth_uri'],
+ 'token_uri': client_info['token_uri'],
+ 'login_hint': login_hint,
+ }
+ revoke_uri = client_info.get('revoke_uri')
+ if revoke_uri is not None:
+ constructor_kwargs['revoke_uri'] = revoke_uri
+ if device_uri is not None:
+ constructor_kwargs['device_uri'] = device_uri
+ return OAuth2WebServerFlow(
+ client_info['client_id'], client_info['client_secret'],
+ scope, **constructor_kwargs)
+
+ except clientsecrets.InvalidClientSecretsError as e:
+ if message is not None:
+ if e.args:
+ message = ('The client secrets were invalid: '
+ '\n{0}\n{1}'.format(e, message))
+ sys.exit(message)
+ else:
+ raise
+ else:
+ raise UnknownClientSecretsFlowError(
+ 'This OAuth 2.0 flow is unsupported: {0!r}'.format(client_type))
diff --git a/oauth2client/clientsecrets.py b/oauth2client/clientsecrets.py
new file mode 100644
index 0000000..4b43e66
--- /dev/null
+++ b/oauth2client/clientsecrets.py
@@ -0,0 +1,174 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for reading OAuth 2.0 client secret files.
+
+A client_secrets.json file contains all the information needed to interact with
+an OAuth 2.0 protected service.
+"""
+
+import json
+
+import six
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+# Properties that make a client_secrets.json file valid.
+TYPE_WEB = 'web'
+TYPE_INSTALLED = 'installed'
+
+VALID_CLIENT = {
+ TYPE_WEB: {
+ 'required': [
+ 'client_id',
+ 'client_secret',
+ 'redirect_uris',
+ 'auth_uri',
+ 'token_uri',
+ ],
+ 'string': [
+ 'client_id',
+ 'client_secret',
+ ],
+ },
+ TYPE_INSTALLED: {
+ 'required': [
+ 'client_id',
+ 'client_secret',
+ 'redirect_uris',
+ 'auth_uri',
+ 'token_uri',
+ ],
+ 'string': [
+ 'client_id',
+ 'client_secret',
+ ],
+ },
+}
+
+
+class Error(Exception):
+ """Base error for this module."""
+
+
+class InvalidClientSecretsError(Error):
+ """Format of ClientSecrets file is invalid."""
+
+
+def _validate_clientsecrets(clientsecrets_dict):
+ """Validate parsed client secrets from a file.
+
+ Args:
+ clientsecrets_dict: dict, a dictionary holding the client secrets.
+
+ Returns:
+ tuple, a string of the client type and the information parsed
+ from the file.
+ """
+ _INVALID_FILE_FORMAT_MSG = (
+ 'Invalid file format. See '
+ 'https://developers.google.com/api-client-library/'
+ 'python/guide/aaa_client_secrets')
+
+ if clientsecrets_dict is None:
+ raise InvalidClientSecretsError(_INVALID_FILE_FORMAT_MSG)
+ try:
+ (client_type, client_info), = clientsecrets_dict.items()
+ except (ValueError, AttributeError):
+ raise InvalidClientSecretsError(
+ _INVALID_FILE_FORMAT_MSG + ' '
+ 'Expected a JSON object with a single property for a "web" or '
+ '"installed" application')
+
+ if client_type not in VALID_CLIENT:
+ raise InvalidClientSecretsError(
+ 'Unknown client type: {0}.'.format(client_type))
+
+ for prop_name in VALID_CLIENT[client_type]['required']:
+ if prop_name not in client_info:
+ raise InvalidClientSecretsError(
+ 'Missing property "{0}" in a client type of "{1}".'.format(
+ prop_name, client_type))
+ for prop_name in VALID_CLIENT[client_type]['string']:
+ if client_info[prop_name].startswith('[['):
+ raise InvalidClientSecretsError(
+ 'Property "{0}" is not configured.'.format(prop_name))
+ return client_type, client_info
+
+
+def load(fp):
+ obj = json.load(fp)
+ return _validate_clientsecrets(obj)
+
+
+def loads(s):
+ obj = json.loads(s)
+ return _validate_clientsecrets(obj)
+
+
+def _loadfile(filename):
+ try:
+ with open(filename, 'r') as fp:
+ obj = json.load(fp)
+ except IOError as exc:
+ raise InvalidClientSecretsError('Error opening file', exc.filename,
+ exc.strerror, exc.errno)
+ return _validate_clientsecrets(obj)
+
+
+def loadfile(filename, cache=None):
+ """Loading of client_secrets JSON file, optionally backed by a cache.
+
+ Typical cache storage would be App Engine memcache service,
+ but you can pass in any other cache client that implements
+ these methods:
+
+ * ``get(key, namespace=ns)``
+ * ``set(key, value, namespace=ns)``
+
+ Usage::
+
+ # without caching
+ client_type, client_info = loadfile('secrets.json')
+ # using App Engine memcache service
+ from google.appengine.api import memcache
+ client_type, client_info = loadfile('secrets.json', cache=memcache)
+
+ Args:
+ filename: string, Path to a client_secrets.json file on a filesystem.
+ cache: An optional cache service client that implements get() and set()
+ methods. If not specified, the file is always being loaded from
+ a filesystem.
+
+ Raises:
+ InvalidClientSecretsError: In case of a validation error or some
+ I/O failure. Can happen only on cache miss.
+
+ Returns:
+ (client_type, client_info) tuple, as _loadfile() normally would.
+ JSON contents is validated only during first load. Cache hits are not
+ validated.
+ """
+ _SECRET_NAMESPACE = 'oauth2client:secrets#ns'
+
+ if not cache:
+ return _loadfile(filename)
+
+ obj = cache.get(filename, namespace=_SECRET_NAMESPACE)
+ if obj is None:
+ client_type, client_info = _loadfile(filename)
+ obj = {client_type: client_info}
+ cache.set(filename, obj, namespace=_SECRET_NAMESPACE)
+
+ return next(six.iteritems(obj))
diff --git a/oauth2client/contrib/__init__.py b/oauth2client/contrib/__init__.py
new file mode 100644
index 0000000..ecfd06c
--- /dev/null
+++ b/oauth2client/contrib/__init__.py
@@ -0,0 +1,6 @@
+"""Contributed modules.
+
+Contrib contains modules that are not considered part of the core oauth2client
+library but provide additional functionality. These modules are intended to
+make it easier to use oauth2client.
+"""
diff --git a/oauth2client/contrib/_appengine_ndb.py b/oauth2client/contrib/_appengine_ndb.py
new file mode 100644
index 0000000..c863e8f
--- /dev/null
+++ b/oauth2client/contrib/_appengine_ndb.py
@@ -0,0 +1,163 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Google App Engine utilities helper.
+
+Classes that directly require App Engine's ndb library. Provided
+as a separate module in case of failure to import ndb while
+other App Engine libraries are present.
+"""
+
+import logging
+
+from google.appengine.ext import ndb
+
+from oauth2client import client
+
+
+NDB_KEY = ndb.Key
+"""Key constant used by :mod:`oauth2client.contrib.appengine`."""
+
+NDB_MODEL = ndb.Model
+"""Model constant used by :mod:`oauth2client.contrib.appengine`."""
+
+_LOGGER = logging.getLogger(__name__)
+
+
+class SiteXsrfSecretKeyNDB(ndb.Model):
+ """NDB Model for storage for the sites XSRF secret key.
+
+ Since this model uses the same kind as SiteXsrfSecretKey, it can be
+ used interchangeably. This simply provides an NDB model for interacting
+ with the same data the DB model interacts with.
+
+ There should only be one instance stored of this model, the one used
+ for the site.
+ """
+ secret = ndb.StringProperty()
+
+ @classmethod
+ def _get_kind(cls):
+ """Return the kind name for this class."""
+ return 'SiteXsrfSecretKey'
+
+
+class FlowNDBProperty(ndb.PickleProperty):
+ """App Engine NDB datastore Property for Flow.
+
+ Serves the same purpose as the DB FlowProperty, but for NDB models.
+ Since PickleProperty inherits from BlobProperty, the underlying
+ representation of the data in the datastore will be the same as in the
+ DB case.
+
+ Utility property that allows easy storage and retrieval of an
+ oauth2client.Flow
+ """
+
+ def _validate(self, value):
+ """Validates a value as a proper Flow object.
+
+ Args:
+ value: A value to be set on the property.
+
+ Raises:
+ TypeError if the value is not an instance of Flow.
+ """
+ _LOGGER.info('validate: Got type %s', type(value))
+ if value is not None and not isinstance(value, client.Flow):
+ raise TypeError(
+ 'Property {0} must be convertible to a flow '
+ 'instance; received: {1}.'.format(self._name, value))
+
+
+class CredentialsNDBProperty(ndb.BlobProperty):
+ """App Engine NDB datastore Property for Credentials.
+
+ Serves the same purpose as the DB CredentialsProperty, but for NDB
+ models. Since CredentialsProperty stores data as a blob and this
+ inherits from BlobProperty, the data in the datastore will be the same
+ as in the DB case.
+
+ Utility property that allows easy storage and retrieval of Credentials
+ and subclasses.
+ """
+
+ def _validate(self, value):
+ """Validates a value as a proper credentials object.
+
+ Args:
+ value: A value to be set on the property.
+
+ Raises:
+ TypeError if the value is not an instance of Credentials.
+ """
+ _LOGGER.info('validate: Got type %s', type(value))
+ if value is not None and not isinstance(value, client.Credentials):
+ raise TypeError(
+ 'Property {0} must be convertible to a credentials '
+ 'instance; received: {1}.'.format(self._name, value))
+
+ def _to_base_type(self, value):
+ """Converts our validated value to a JSON serialized string.
+
+ Args:
+ value: A value to be set in the datastore.
+
+ Returns:
+ A JSON serialized version of the credential, else '' if value
+ is None.
+ """
+ if value is None:
+ return ''
+ else:
+ return value.to_json()
+
+ def _from_base_type(self, value):
+ """Converts our stored JSON string back to the desired type.
+
+ Args:
+ value: A value from the datastore to be converted to the
+ desired type.
+
+ Returns:
+ A deserialized Credentials (or subclass) object, else None if
+ the value can't be parsed.
+ """
+ if not value:
+ return None
+ try:
+ # Uses the from_json method of the implied class of value
+ credentials = client.Credentials.new_from_json(value)
+ except ValueError:
+ credentials = None
+ return credentials
+
+
+class CredentialsNDBModel(ndb.Model):
+ """NDB Model for storage of OAuth 2.0 Credentials
+
+ Since this model uses the same kind as CredentialsModel and has a
+ property which can serialize and deserialize Credentials correctly, it
+ can be used interchangeably with a CredentialsModel to access, insert
+ and delete the same entities. This simply provides an NDB model for
+ interacting with the same data the DB model interacts with.
+
+ Storage of the model is keyed by the user.user_id().
+ """
+ credentials = CredentialsNDBProperty()
+
+ @classmethod
+ def _get_kind(cls):
+ """Return the kind name for this class."""
+ return 'CredentialsModel'
diff --git a/oauth2client/contrib/_fcntl_opener.py b/oauth2client/contrib/_fcntl_opener.py
new file mode 100644
index 0000000..ae6c85b
--- /dev/null
+++ b/oauth2client/contrib/_fcntl_opener.py
@@ -0,0 +1,81 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import errno
+import fcntl
+import time
+
+from oauth2client.contrib import locked_file
+
+
+class _FcntlOpener(locked_file._Opener):
+ """Open, lock, and unlock a file using fcntl.lockf."""
+
+ def open_and_lock(self, timeout, delay):
+ """Open the file and lock it.
+
+ Args:
+ timeout: float, How long to try to lock for.
+ delay: float, How long to wait between retries
+
+ Raises:
+ AlreadyLockedException: if the lock is already acquired.
+ IOError: if the open fails.
+ CredentialsFileSymbolicLinkError: if the file is a symbolic
+ link.
+ """
+ if self._locked:
+ raise locked_file.AlreadyLockedException(
+ 'File {0} is already locked'.format(self._filename))
+ start_time = time.time()
+
+ locked_file.validate_file(self._filename)
+ try:
+ self._fh = open(self._filename, self._mode)
+ except IOError as e:
+ # If we can't access with _mode, try _fallback_mode and
+ # don't lock.
+ if e.errno in (errno.EPERM, errno.EACCES):
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+
+ # We opened in _mode, try to lock the file.
+ while True:
+ try:
+ fcntl.lockf(self._fh.fileno(), fcntl.LOCK_EX)
+ self._locked = True
+ return
+ except IOError as e:
+ # If not retrying, then just pass on the error.
+ if timeout == 0:
+ raise
+ if e.errno != errno.EACCES:
+ raise
+ # We could not acquire the lock. Try again.
+ if (time.time() - start_time) >= timeout:
+ locked_file.logger.warn('Could not lock %s in %s seconds',
+ self._filename, timeout)
+ if self._fh:
+ self._fh.close()
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+ time.sleep(delay)
+
+ def unlock_and_close(self):
+ """Close and unlock the file using the fcntl.lockf primitive."""
+ if self._locked:
+ fcntl.lockf(self._fh.fileno(), fcntl.LOCK_UN)
+ self._locked = False
+ if self._fh:
+ self._fh.close()
diff --git a/oauth2client/contrib/_metadata.py b/oauth2client/contrib/_metadata.py
new file mode 100644
index 0000000..10e6a69
--- /dev/null
+++ b/oauth2client/contrib/_metadata.py
@@ -0,0 +1,123 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Provides helper methods for talking to the Compute Engine metadata server.
+
+See https://cloud.google.com/compute/docs/metadata
+"""
+
+import datetime
+import json
+
+import httplib2
+from six.moves import http_client
+from six.moves.urllib import parse as urlparse
+
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client import util
+
+
+METADATA_ROOT = 'http://metadata.google.internal/computeMetadata/v1/'
+METADATA_HEADERS = {'Metadata-Flavor': 'Google'}
+
+
+def get(http_request, path, root=METADATA_ROOT, recursive=None):
+ """Fetch a resource from the metadata server.
+
+ Args:
+ path: A string indicating the resource to retrieve. For example,
+ 'instance/service-accounts/defualt'
+ http_request: A callable that matches the method
+ signature of httplib2.Http.request. Used to make the request to the
+ metadataserver.
+ root: A string indicating the full path to the metadata server root.
+ recursive: A boolean indicating whether to do a recursive query of
+ metadata. See
+ https://cloud.google.com/compute/docs/metadata#aggcontents
+
+ Returns:
+ A dictionary if the metadata server returns JSON, otherwise a string.
+
+ Raises:
+ httplib2.Httplib2Error if an error corrured while retrieving metadata.
+ """
+ url = urlparse.urljoin(root, path)
+ url = util._add_query_parameter(url, 'recursive', recursive)
+
+ response, content = http_request(
+ url,
+ headers=METADATA_HEADERS
+ )
+
+ if response.status == http_client.OK:
+ decoded = _helpers._from_bytes(content)
+ if response['content-type'] == 'application/json':
+ return json.loads(decoded)
+ else:
+ return decoded
+ else:
+ raise httplib2.HttpLib2Error(
+ 'Failed to retrieve {0} from the Google Compute Engine'
+ 'metadata service. Response:\n{1}'.format(url, response))
+
+
+def get_service_account_info(http_request, service_account='default'):
+ """Get information about a service account from the metadata server.
+
+ Args:
+ service_account: An email specifying the service account for which to
+ look up information. Default will be information for the "default"
+ service account of the current compute engine instance.
+ http_request: A callable that matches the method
+ signature of httplib2.Http.request. Used to make the request to the
+ metadata server.
+ Returns:
+ A dictionary with information about the specified service account,
+ for example:
+
+ {
+ 'email': '...',
+ 'scopes': ['scope', ...],
+ 'aliases': ['default', '...']
+ }
+ """
+ return get(
+ http_request,
+ 'instance/service-accounts/{0}/'.format(service_account),
+ recursive=True)
+
+
+def get_token(http_request, service_account='default'):
+ """Fetch an oauth token for the
+
+ Args:
+ service_account: An email specifying the service account this token
+ should represent. Default will be a token for the "default" service
+ account of the current compute engine instance.
+ http_request: A callable that matches the method
+ signature of httplib2.Http.request. Used to make the request to the
+ metadataserver.
+
+ Returns:
+ A tuple of (access token, token expiration), where access token is the
+ access token as a string and token expiration is a datetime object
+ that indicates when the access token will expire.
+ """
+ token_json = get(
+ http_request,
+ 'instance/service-accounts/{0}/token'.format(service_account))
+ token_expiry = client._UTCNOW() + datetime.timedelta(
+ seconds=token_json['expires_in'])
+ return token_json['access_token'], token_expiry
diff --git a/oauth2client/contrib/_win32_opener.py b/oauth2client/contrib/_win32_opener.py
new file mode 100644
index 0000000..34b4f48
--- /dev/null
+++ b/oauth2client/contrib/_win32_opener.py
@@ -0,0 +1,106 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import errno
+import time
+
+import pywintypes
+import win32con
+import win32file
+
+from oauth2client.contrib import locked_file
+
+
+class _Win32Opener(locked_file._Opener):
+ """Open, lock, and unlock a file using windows primitives."""
+
+ # Error #33:
+ # 'The process cannot access the file because another process'
+ FILE_IN_USE_ERROR = 33
+
+ # Error #158:
+ # 'The segment is already unlocked.'
+ FILE_ALREADY_UNLOCKED_ERROR = 158
+
+ def open_and_lock(self, timeout, delay):
+ """Open the file and lock it.
+
+ Args:
+ timeout: float, How long to try to lock for.
+ delay: float, How long to wait between retries
+
+ Raises:
+ AlreadyLockedException: if the lock is already acquired.
+ IOError: if the open fails.
+ CredentialsFileSymbolicLinkError: if the file is a symbolic
+ link.
+ """
+ if self._locked:
+ raise locked_file.AlreadyLockedException(
+ 'File {0} is already locked'.format(self._filename))
+ start_time = time.time()
+
+ locked_file.validate_file(self._filename)
+ try:
+ self._fh = open(self._filename, self._mode)
+ except IOError as e:
+ # If we can't access with _mode, try _fallback_mode
+ # and don't lock.
+ if e.errno == errno.EACCES:
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+
+ # We opened in _mode, try to lock the file.
+ while True:
+ try:
+ hfile = win32file._get_osfhandle(self._fh.fileno())
+ win32file.LockFileEx(
+ hfile,
+ (win32con.LOCKFILE_FAIL_IMMEDIATELY |
+ win32con.LOCKFILE_EXCLUSIVE_LOCK), 0, -0x10000,
+ pywintypes.OVERLAPPED())
+ self._locked = True
+ return
+ except pywintypes.error as e:
+ if timeout == 0:
+ raise
+
+ # If the error is not that the file is already
+ # in use, raise.
+ if e[0] != _Win32Opener.FILE_IN_USE_ERROR:
+ raise
+
+ # We could not acquire the lock. Try again.
+ if (time.time() - start_time) >= timeout:
+ locked_file.logger.warn('Could not lock %s in %s seconds',
+ self._filename, timeout)
+ if self._fh:
+ self._fh.close()
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+ time.sleep(delay)
+
+ def unlock_and_close(self):
+ """Close and unlock the file using the win32 primitive."""
+ if self._locked:
+ try:
+ hfile = win32file._get_osfhandle(self._fh.fileno())
+ win32file.UnlockFileEx(hfile, 0, -0x10000,
+ pywintypes.OVERLAPPED())
+ except pywintypes.error as e:
+ if e[0] != _Win32Opener.FILE_ALREADY_UNLOCKED_ERROR:
+ raise
+ self._locked = False
+ if self._fh:
+ self._fh.close()
diff --git a/oauth2client/contrib/appengine.py b/oauth2client/contrib/appengine.py
new file mode 100644
index 0000000..661105e
--- /dev/null
+++ b/oauth2client/contrib/appengine.py
@@ -0,0 +1,913 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for Google App Engine
+
+Utilities for making it easier to use OAuth 2.0 on Google App Engine.
+"""
+
+import cgi
+import json
+import logging
+import os
+import pickle
+import threading
+
+from google.appengine.api import app_identity
+from google.appengine.api import memcache
+from google.appengine.api import users
+from google.appengine.ext import db
+from google.appengine.ext.webapp.util import login_required
+import httplib2
+import webapp2 as webapp
+
+import oauth2client
+from oauth2client import client
+from oauth2client import clientsecrets
+from oauth2client import util
+from oauth2client.contrib import xsrfutil
+
+# This is a temporary fix for a Google internal issue.
+try:
+ from oauth2client.contrib import _appengine_ndb
+except ImportError: # pragma: NO COVER
+ _appengine_ndb = None
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+logger = logging.getLogger(__name__)
+
+OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns'
+
+XSRF_MEMCACHE_ID = 'xsrf_secret_key'
+
+if _appengine_ndb is None: # pragma: NO COVER
+ CredentialsNDBModel = None
+ CredentialsNDBProperty = None
+ FlowNDBProperty = None
+ _NDB_KEY = None
+ _NDB_MODEL = None
+ SiteXsrfSecretKeyNDB = None
+else:
+ CredentialsNDBModel = _appengine_ndb.CredentialsNDBModel
+ CredentialsNDBProperty = _appengine_ndb.CredentialsNDBProperty
+ FlowNDBProperty = _appengine_ndb.FlowNDBProperty
+ _NDB_KEY = _appengine_ndb.NDB_KEY
+ _NDB_MODEL = _appengine_ndb.NDB_MODEL
+ SiteXsrfSecretKeyNDB = _appengine_ndb.SiteXsrfSecretKeyNDB
+
+
+def _safe_html(s):
+ """Escape text to make it safe to display.
+
+ Args:
+ s: string, The text to escape.
+
+ Returns:
+ The escaped text as a string.
+ """
+ return cgi.escape(s, quote=1).replace("'", ''')
+
+
+class SiteXsrfSecretKey(db.Model):
+ """Storage for the sites XSRF secret key.
+
+ There will only be one instance stored of this model, the one used for the
+ site.
+ """
+ secret = db.StringProperty()
+
+
+def _generate_new_xsrf_secret_key():
+ """Returns a random XSRF secret key."""
+ return os.urandom(16).encode("hex")
+
+
+def xsrf_secret_key():
+ """Return the secret key for use for XSRF protection.
+
+ If the Site entity does not have a secret key, this method will also create
+ one and persist it.
+
+ Returns:
+ The secret key.
+ """
+ secret = memcache.get(XSRF_MEMCACHE_ID, namespace=OAUTH2CLIENT_NAMESPACE)
+ if not secret:
+ # Load the one and only instance of SiteXsrfSecretKey.
+ model = SiteXsrfSecretKey.get_or_insert(key_name='site')
+ if not model.secret:
+ model.secret = _generate_new_xsrf_secret_key()
+ model.put()
+ secret = model.secret
+ memcache.add(XSRF_MEMCACHE_ID, secret,
+ namespace=OAUTH2CLIENT_NAMESPACE)
+
+ return str(secret)
+
+
+class AppAssertionCredentials(client.AssertionCredentials):
+ """Credentials object for App Engine Assertion Grants
+
+ This object will allow an App Engine application to identify itself to
+ Google and other OAuth 2.0 servers that can verify assertions. It can be
+ used for the purpose of accessing data stored under an account assigned to
+ the App Engine application itself.
+
+ This credential does not require a flow to instantiate because it
+ represents a two legged flow, and therefore has all of the required
+ information to generate and refresh its own access tokens.
+ """
+
+ @util.positional(2)
+ def __init__(self, scope, **kwargs):
+ """Constructor for AppAssertionCredentials
+
+ Args:
+ scope: string or iterable of strings, scope(s) of the credentials
+ being requested.
+ **kwargs: optional keyword args, including:
+ service_account_id: service account id of the application. If None
+ or unspecified, the default service account for
+ the app is used.
+ """
+ self.scope = util.scopes_to_string(scope)
+ self._kwargs = kwargs
+ self.service_account_id = kwargs.get('service_account_id', None)
+ self._service_account_email = None
+
+ # Assertion type is no longer used, but still in the
+ # parent class signature.
+ super(AppAssertionCredentials, self).__init__(None)
+
+ @classmethod
+ def from_json(cls, json_data):
+ data = json.loads(json_data)
+ return AppAssertionCredentials(data['scope'])
+
+ def _refresh(self, http_request):
+ """Refreshes the access_token.
+
+ Since the underlying App Engine app_identity implementation does its
+ own caching we can skip all the storage hoops and just to a refresh
+ using the API.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ refresh request.
+
+ Raises:
+ AccessTokenRefreshError: When the refresh fails.
+ """
+ try:
+ scopes = self.scope.split()
+ (token, _) = app_identity.get_access_token(
+ scopes, service_account_id=self.service_account_id)
+ except app_identity.Error as e:
+ raise client.AccessTokenRefreshError(str(e))
+ self.access_token = token
+
+ @property
+ def serialization_data(self):
+ raise NotImplementedError('Cannot serialize credentials '
+ 'for Google App Engine.')
+
+ def create_scoped_required(self):
+ return not self.scope
+
+ def create_scoped(self, scopes):
+ return AppAssertionCredentials(scopes, **self._kwargs)
+
+ def sign_blob(self, blob):
+ """Cryptographically sign a blob (of bytes).
+
+ Implements abstract method
+ :meth:`oauth2client.client.AssertionCredentials.sign_blob`.
+
+ Args:
+ blob: bytes, Message to be signed.
+
+ Returns:
+ tuple, A pair of the private key ID used to sign the blob and
+ the signed contents.
+ """
+ return app_identity.sign_blob(blob)
+
+ @property
+ def service_account_email(self):
+ """Get the email for the current service account.
+
+ Returns:
+ string, The email associated with the Google App Engine
+ service account.
+ """
+ if self._service_account_email is None:
+ self._service_account_email = (
+ app_identity.get_service_account_name())
+ return self._service_account_email
+
+
+class FlowProperty(db.Property):
+ """App Engine datastore Property for Flow.
+
+ Utility property that allows easy storage and retrieval of an
+ oauth2client.Flow
+ """
+
+ # Tell what the user type is.
+ data_type = client.Flow
+
+ # For writing to datastore.
+ def get_value_for_datastore(self, model_instance):
+ flow = super(FlowProperty, self).get_value_for_datastore(
+ model_instance)
+ return db.Blob(pickle.dumps(flow))
+
+ # For reading from datastore.
+ def make_value_from_datastore(self, value):
+ if value is None:
+ return None
+ return pickle.loads(value)
+
+ def validate(self, value):
+ if value is not None and not isinstance(value, client.Flow):
+ raise db.BadValueError(
+ 'Property {0} must be convertible '
+ 'to a FlowThreeLegged instance ({1})'.format(self.name, value))
+ return super(FlowProperty, self).validate(value)
+
+ def empty(self, value):
+ return not value
+
+
+class CredentialsProperty(db.Property):
+ """App Engine datastore Property for Credentials.
+
+ Utility property that allows easy storage and retrieval of
+ oauth2client.Credentials
+ """
+
+ # Tell what the user type is.
+ data_type = client.Credentials
+
+ # For writing to datastore.
+ def get_value_for_datastore(self, model_instance):
+ logger.info("get: Got type " + str(type(model_instance)))
+ cred = super(CredentialsProperty, self).get_value_for_datastore(
+ model_instance)
+ if cred is None:
+ cred = ''
+ else:
+ cred = cred.to_json()
+ return db.Blob(cred)
+
+ # For reading from datastore.
+ def make_value_from_datastore(self, value):
+ logger.info("make: Got type " + str(type(value)))
+ if value is None:
+ return None
+ if len(value) == 0:
+ return None
+ try:
+ credentials = client.Credentials.new_from_json(value)
+ except ValueError:
+ credentials = None
+ return credentials
+
+ def validate(self, value):
+ value = super(CredentialsProperty, self).validate(value)
+ logger.info("validate: Got type " + str(type(value)))
+ if value is not None and not isinstance(value, client.Credentials):
+ raise db.BadValueError(
+ 'Property {0} must be convertible '
+ 'to a Credentials instance ({1})'.format(self.name, value))
+ return value
+
+
+class StorageByKeyName(client.Storage):
+ """Store and retrieve a credential to and from the App Engine datastore.
+
+ This Storage helper presumes the Credentials have been stored as a
+ CredentialsProperty or CredentialsNDBProperty on a datastore model class,
+ and that entities are stored by key_name.
+ """
+
+ @util.positional(4)
+ def __init__(self, model, key_name, property_name, cache=None, user=None):
+ """Constructor for Storage.
+
+ Args:
+ model: db.Model or ndb.Model, model class
+ key_name: string, key name for the entity that has the credentials
+ property_name: string, name of the property that is a
+ CredentialsProperty or CredentialsNDBProperty.
+ cache: memcache, a write-through cache to put in front of the
+ datastore. If the model you are using is an NDB model, using
+ a cache will be redundant since the model uses an instance
+ cache and memcache for you.
+ user: users.User object, optional. Can be used to grab user ID as a
+ key_name if no key name is specified.
+ """
+ super(StorageByKeyName, self).__init__()
+
+ if key_name is None:
+ if user is None:
+ raise ValueError('StorageByKeyName called with no '
+ 'key name or user.')
+ key_name = user.user_id()
+
+ self._model = model
+ self._key_name = key_name
+ self._property_name = property_name
+ self._cache = cache
+
+ def _is_ndb(self):
+ """Determine whether the model of the instance is an NDB model.
+
+ Returns:
+ Boolean indicating whether or not the model is an NDB or DB model.
+ """
+ # issubclass will fail if one of the arguments is not a class, only
+ # need worry about new-style classes since ndb and db models are
+ # new-style
+ if isinstance(self._model, type):
+ if _NDB_MODEL is not None and issubclass(self._model, _NDB_MODEL):
+ return True
+ elif issubclass(self._model, db.Model):
+ return False
+
+ raise TypeError(
+ 'Model class not an NDB or DB model: {0}.'.format(self._model))
+
+ def _get_entity(self):
+ """Retrieve entity from datastore.
+
+ Uses a different model method for db or ndb models.
+
+ Returns:
+ Instance of the model corresponding to the current storage object
+ and stored using the key name of the storage object.
+ """
+ if self._is_ndb():
+ return self._model.get_by_id(self._key_name)
+ else:
+ return self._model.get_by_key_name(self._key_name)
+
+ def _delete_entity(self):
+ """Delete entity from datastore.
+
+ Attempts to delete using the key_name stored on the object, whether or
+ not the given key is in the datastore.
+ """
+ if self._is_ndb():
+ _NDB_KEY(self._model, self._key_name).delete()
+ else:
+ entity_key = db.Key.from_path(self._model.kind(), self._key_name)
+ db.delete(entity_key)
+
+ @db.non_transactional(allow_existing=True)
+ def locked_get(self):
+ """Retrieve Credential from datastore.
+
+ Returns:
+ oauth2client.Credentials
+ """
+ credentials = None
+ if self._cache:
+ json = self._cache.get(self._key_name)
+ if json:
+ credentials = client.Credentials.new_from_json(json)
+ if credentials is None:
+ entity = self._get_entity()
+ if entity is not None:
+ credentials = getattr(entity, self._property_name)
+ if self._cache:
+ self._cache.set(self._key_name, credentials.to_json())
+
+ if credentials and hasattr(credentials, 'set_store'):
+ credentials.set_store(self)
+ return credentials
+
+ @db.non_transactional(allow_existing=True)
+ def locked_put(self, credentials):
+ """Write a Credentials to the datastore.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ entity = self._model.get_or_insert(self._key_name)
+ setattr(entity, self._property_name, credentials)
+ entity.put()
+ if self._cache:
+ self._cache.set(self._key_name, credentials.to_json())
+
+ @db.non_transactional(allow_existing=True)
+ def locked_delete(self):
+ """Delete Credential from datastore."""
+
+ if self._cache:
+ self._cache.delete(self._key_name)
+
+ self._delete_entity()
+
+
+class CredentialsModel(db.Model):
+ """Storage for OAuth 2.0 Credentials
+
+ Storage of the model is keyed by the user.user_id().
+ """
+ credentials = CredentialsProperty()
+
+
+def _build_state_value(request_handler, user):
+ """Composes the value for the 'state' parameter.
+
+ Packs the current request URI and an XSRF token into an opaque string that
+ can be passed to the authentication server via the 'state' parameter.
+
+ Args:
+ request_handler: webapp.RequestHandler, The request.
+ user: google.appengine.api.users.User, The current user.
+
+ Returns:
+ The state value as a string.
+ """
+ uri = request_handler.request.url
+ token = xsrfutil.generate_token(xsrf_secret_key(), user.user_id(),
+ action_id=str(uri))
+ return uri + ':' + token
+
+
+def _parse_state_value(state, user):
+ """Parse the value of the 'state' parameter.
+
+ Parses the value and validates the XSRF token in the state parameter.
+
+ Args:
+ state: string, The value of the state parameter.
+ user: google.appengine.api.users.User, The current user.
+
+ Returns:
+ The redirect URI, or None if XSRF token is not valid.
+ """
+ uri, token = state.rsplit(':', 1)
+ if xsrfutil.validate_token(xsrf_secret_key(), token, user.user_id(),
+ action_id=uri):
+ return uri
+ else:
+ return None
+
+
+class OAuth2Decorator(object):
+ """Utility for making OAuth 2.0 easier.
+
+ Instantiate and then use with oauth_required or oauth_aware
+ as decorators on webapp.RequestHandler methods.
+
+ ::
+
+ decorator = OAuth2Decorator(
+ client_id='837...ent.com',
+ client_secret='Qh...wwI',
+ scope='https://www.googleapis.com/auth/plus')
+
+ class MainHandler(webapp.RequestHandler):
+ @decorator.oauth_required
+ def get(self):
+ http = decorator.http()
+ # http is authorized with the user's Credentials and can be
+ # used in API calls
+
+ """
+
+ def set_credentials(self, credentials):
+ self._tls.credentials = credentials
+
+ def get_credentials(self):
+ """A thread local Credentials object.
+
+ Returns:
+ A client.Credentials object, or None if credentials hasn't been set
+ in this thread yet, which may happen when calling has_credentials
+ inside oauth_aware.
+ """
+ return getattr(self._tls, 'credentials', None)
+
+ credentials = property(get_credentials, set_credentials)
+
+ def set_flow(self, flow):
+ self._tls.flow = flow
+
+ def get_flow(self):
+ """A thread local Flow object.
+
+ Returns:
+ A credentials.Flow object, or None if the flow hasn't been set in
+ this thread yet, which happens in _create_flow() since Flows are
+ created lazily.
+ """
+ return getattr(self._tls, 'flow', None)
+
+ flow = property(get_flow, set_flow)
+
+ @util.positional(4)
+ def __init__(self, client_id, client_secret, scope,
+ auth_uri=oauth2client.GOOGLE_AUTH_URI,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ user_agent=None,
+ message=None,
+ callback_path='/oauth2callback',
+ token_response_param=None,
+ _storage_class=StorageByKeyName,
+ _credentials_class=CredentialsModel,
+ _credentials_property_name='credentials',
+ **kwargs):
+ """Constructor for OAuth2Decorator
+
+ Args:
+ client_id: string, client identifier.
+ client_secret: string client secret.
+ scope: string or iterable of strings, scope(s) of the credentials
+ being requested.
+ auth_uri: string, URI for authorization endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0 provider
+ can be used.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+ user_agent: string, User agent of your application, default to
+ None.
+ message: Message to display if there are problems with the
+ OAuth 2.0 configuration. The message may contain HTML and
+ will be presented on the web interface for any method that
+ uses the decorator.
+ callback_path: string, The absolute path to use as the callback
+ URI. Note that this must match up with the URI given
+ when registering the application in the APIs
+ Console.
+ token_response_param: string. If provided, the full JSON response
+ to the access token request will be encoded
+ and included in this query parameter in the
+ callback URI. This is useful with providers
+ (e.g. wordpress.com) that include extra
+ fields that the client may want.
+ _storage_class: "Protected" keyword argument not typically provided
+ to this constructor. A storage class to aid in
+ storing a Credentials object for a user in the
+ datastore. Defaults to StorageByKeyName.
+ _credentials_class: "Protected" keyword argument not typically
+ provided to this constructor. A db or ndb Model
+ class to hold credentials. Defaults to
+ CredentialsModel.
+ _credentials_property_name: "Protected" keyword argument not
+ typically provided to this constructor.
+ A string indicating the name of the
+ field on the _credentials_class where a
+ Credentials object will be stored.
+ Defaults to 'credentials'.
+ **kwargs: dict, Keyword arguments are passed along as kwargs to
+ the OAuth2WebServerFlow constructor.
+ """
+ self._tls = threading.local()
+ self.flow = None
+ self.credentials = None
+ self._client_id = client_id
+ self._client_secret = client_secret
+ self._scope = util.scopes_to_string(scope)
+ self._auth_uri = auth_uri
+ self._token_uri = token_uri
+ self._revoke_uri = revoke_uri
+ self._user_agent = user_agent
+ self._kwargs = kwargs
+ self._message = message
+ self._in_error = False
+ self._callback_path = callback_path
+ self._token_response_param = token_response_param
+ self._storage_class = _storage_class
+ self._credentials_class = _credentials_class
+ self._credentials_property_name = _credentials_property_name
+
+ def _display_error_message(self, request_handler):
+ request_handler.response.out.write('<html><body>')
+ request_handler.response.out.write(_safe_html(self._message))
+ request_handler.response.out.write('</body></html>')
+
+ def oauth_required(self, method):
+ """Decorator that starts the OAuth 2.0 dance.
+
+ Starts the OAuth dance for the logged in user if they haven't already
+ granted access for this application.
+
+ Args:
+ method: callable, to be decorated method of a webapp.RequestHandler
+ instance.
+ """
+
+ def check_oauth(request_handler, *args, **kwargs):
+ if self._in_error:
+ self._display_error_message(request_handler)
+ return
+
+ user = users.get_current_user()
+ # Don't use @login_decorator as this could be used in a
+ # POST request.
+ if not user:
+ request_handler.redirect(users.create_login_url(
+ request_handler.request.uri))
+ return
+
+ self._create_flow(request_handler)
+
+ # Store the request URI in 'state' so we can use it later
+ self.flow.params['state'] = _build_state_value(
+ request_handler, user)
+ self.credentials = self._storage_class(
+ self._credentials_class, None,
+ self._credentials_property_name, user=user).get()
+
+ if not self.has_credentials():
+ return request_handler.redirect(self.authorize_url())
+ try:
+ resp = method(request_handler, *args, **kwargs)
+ except client.AccessTokenRefreshError:
+ return request_handler.redirect(self.authorize_url())
+ finally:
+ self.credentials = None
+ return resp
+
+ return check_oauth
+
+ def _create_flow(self, request_handler):
+ """Create the Flow object.
+
+ The Flow is calculated lazily since we don't know where this app is
+ running until it receives a request, at which point redirect_uri can be
+ calculated and then the Flow object can be constructed.
+
+ Args:
+ request_handler: webapp.RequestHandler, the request handler.
+ """
+ if self.flow is None:
+ redirect_uri = request_handler.request.relative_url(
+ self._callback_path) # Usually /oauth2callback
+ self.flow = client.OAuth2WebServerFlow(
+ self._client_id, self._client_secret, self._scope,
+ redirect_uri=redirect_uri, user_agent=self._user_agent,
+ auth_uri=self._auth_uri, token_uri=self._token_uri,
+ revoke_uri=self._revoke_uri, **self._kwargs)
+
+ def oauth_aware(self, method):
+ """Decorator that sets up for OAuth 2.0 dance, but doesn't do it.
+
+ Does all the setup for the OAuth dance, but doesn't initiate it.
+ This decorator is useful if you want to create a page that knows
+ whether or not the user has granted access to this application.
+ From within a method decorated with @oauth_aware the has_credentials()
+ and authorize_url() methods can be called.
+
+ Args:
+ method: callable, to be decorated method of a webapp.RequestHandler
+ instance.
+ """
+
+ def setup_oauth(request_handler, *args, **kwargs):
+ if self._in_error:
+ self._display_error_message(request_handler)
+ return
+
+ user = users.get_current_user()
+ # Don't use @login_decorator as this could be used in a
+ # POST request.
+ if not user:
+ request_handler.redirect(users.create_login_url(
+ request_handler.request.uri))
+ return
+
+ self._create_flow(request_handler)
+
+ self.flow.params['state'] = _build_state_value(request_handler,
+ user)
+ self.credentials = self._storage_class(
+ self._credentials_class, None,
+ self._credentials_property_name, user=user).get()
+ try:
+ resp = method(request_handler, *args, **kwargs)
+ finally:
+ self.credentials = None
+ return resp
+ return setup_oauth
+
+ def has_credentials(self):
+ """True if for the logged in user there are valid access Credentials.
+
+ Must only be called from with a webapp.RequestHandler subclassed method
+ that had been decorated with either @oauth_required or @oauth_aware.
+ """
+ return self.credentials is not None and not self.credentials.invalid
+
+ def authorize_url(self):
+ """Returns the URL to start the OAuth dance.
+
+ Must only be called from with a webapp.RequestHandler subclassed method
+ that had been decorated with either @oauth_required or @oauth_aware.
+ """
+ url = self.flow.step1_get_authorize_url()
+ return str(url)
+
+ def http(self, *args, **kwargs):
+ """Returns an authorized http instance.
+
+ Must only be called from within an @oauth_required decorated method, or
+ from within an @oauth_aware decorated method where has_credentials()
+ returns True.
+
+ Args:
+ *args: Positional arguments passed to httplib2.Http constructor.
+ **kwargs: Positional arguments passed to httplib2.Http constructor.
+ """
+ return self.credentials.authorize(httplib2.Http(*args, **kwargs))
+
+ @property
+ def callback_path(self):
+ """The absolute path where the callback will occur.
+
+ Note this is the absolute path, not the absolute URI, that will be
+ calculated by the decorator at runtime. See callback_handler() for how
+ this should be used.
+
+ Returns:
+ The callback path as a string.
+ """
+ return self._callback_path
+
+ def callback_handler(self):
+ """RequestHandler for the OAuth 2.0 redirect callback.
+
+ Usage::
+
+ app = webapp.WSGIApplication([
+ ('/index', MyIndexHandler),
+ ...,
+ (decorator.callback_path, decorator.callback_handler())
+ ])
+
+ Returns:
+ A webapp.RequestHandler that handles the redirect back from the
+ server during the OAuth 2.0 dance.
+ """
+ decorator = self
+
+ class OAuth2Handler(webapp.RequestHandler):
+ """Handler for the redirect_uri of the OAuth 2.0 dance."""
+
+ @login_required
+ def get(self):
+ error = self.request.get('error')
+ if error:
+ errormsg = self.request.get('error_description', error)
+ self.response.out.write(
+ 'The authorization request failed: {0}'.format(
+ _safe_html(errormsg)))
+ else:
+ user = users.get_current_user()
+ decorator._create_flow(self)
+ credentials = decorator.flow.step2_exchange(
+ self.request.params)
+ decorator._storage_class(
+ decorator._credentials_class, None,
+ decorator._credentials_property_name,
+ user=user).put(credentials)
+ redirect_uri = _parse_state_value(
+ str(self.request.get('state')), user)
+ if redirect_uri is None:
+ self.response.out.write(
+ 'The authorization request failed')
+ return
+
+ if (decorator._token_response_param and
+ credentials.token_response):
+ resp_json = json.dumps(credentials.token_response)
+ redirect_uri = util._add_query_parameter(
+ redirect_uri, decorator._token_response_param,
+ resp_json)
+
+ self.redirect(redirect_uri)
+
+ return OAuth2Handler
+
+ def callback_application(self):
+ """WSGI application for handling the OAuth 2.0 redirect callback.
+
+ If you need finer grained control use `callback_handler` which returns
+ just the webapp.RequestHandler.
+
+ Returns:
+ A webapp.WSGIApplication that handles the redirect back from the
+ server during the OAuth 2.0 dance.
+ """
+ return webapp.WSGIApplication([
+ (self.callback_path, self.callback_handler())
+ ])
+
+
+class OAuth2DecoratorFromClientSecrets(OAuth2Decorator):
+ """An OAuth2Decorator that builds from a clientsecrets file.
+
+ Uses a clientsecrets file as the source for all the information when
+ constructing an OAuth2Decorator.
+
+ ::
+
+ decorator = OAuth2DecoratorFromClientSecrets(
+ os.path.join(os.path.dirname(__file__), 'client_secrets.json')
+ scope='https://www.googleapis.com/auth/plus')
+
+ class MainHandler(webapp.RequestHandler):
+ @decorator.oauth_required
+ def get(self):
+ http = decorator.http()
+ # http is authorized with the user's Credentials and can be
+ # used in API calls
+
+ """
+
+ @util.positional(3)
+ def __init__(self, filename, scope, message=None, cache=None, **kwargs):
+ """Constructor
+
+ Args:
+ filename: string, File name of client secrets.
+ scope: string or iterable of strings, scope(s) of the credentials
+ being requested.
+ message: string, A friendly string to display to the user if the
+ clientsecrets file is missing or invalid. The message may
+ contain HTML and will be presented on the web interface
+ for any method that uses the decorator.
+ cache: An optional cache service client that implements get() and
+ set()
+ methods. See clientsecrets.loadfile() for details.
+ **kwargs: dict, Keyword arguments are passed along as kwargs to
+ the OAuth2WebServerFlow constructor.
+ """
+ client_type, client_info = clientsecrets.loadfile(filename,
+ cache=cache)
+ if client_type not in (clientsecrets.TYPE_WEB,
+ clientsecrets.TYPE_INSTALLED):
+ raise clientsecrets.InvalidClientSecretsError(
+ "OAuth2Decorator doesn't support this OAuth 2.0 flow.")
+
+ constructor_kwargs = dict(kwargs)
+ constructor_kwargs.update({
+ 'auth_uri': client_info['auth_uri'],
+ 'token_uri': client_info['token_uri'],
+ 'message': message,
+ })
+ revoke_uri = client_info.get('revoke_uri')
+ if revoke_uri is not None:
+ constructor_kwargs['revoke_uri'] = revoke_uri
+ super(OAuth2DecoratorFromClientSecrets, self).__init__(
+ client_info['client_id'], client_info['client_secret'],
+ scope, **constructor_kwargs)
+ if message is not None:
+ self._message = message
+ else:
+ self._message = 'Please configure your application for OAuth 2.0.'
+
+
+@util.positional(2)
+def oauth2decorator_from_clientsecrets(filename, scope,
+ message=None, cache=None):
+ """Creates an OAuth2Decorator populated from a clientsecrets file.
+
+ Args:
+ filename: string, File name of client secrets.
+ scope: string or list of strings, scope(s) of the credentials being
+ requested.
+ message: string, A friendly string to display to the user if the
+ clientsecrets file is missing or invalid. The message may
+ contain HTML and will be presented on the web interface for
+ any method that uses the decorator.
+ cache: An optional cache service client that implements get() and set()
+ methods. See clientsecrets.loadfile() for details.
+
+ Returns: An OAuth2Decorator
+ """
+ return OAuth2DecoratorFromClientSecrets(filename, scope,
+ message=message, cache=cache)
diff --git a/oauth2client/contrib/devshell.py b/oauth2client/contrib/devshell.py
new file mode 100644
index 0000000..b8bb978
--- /dev/null
+++ b/oauth2client/contrib/devshell.py
@@ -0,0 +1,146 @@
+# Copyright 2015 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""OAuth 2.0 utitilies for Google Developer Shell environment."""
+
+import datetime
+import json
+import os
+import socket
+
+from oauth2client import _helpers
+from oauth2client import client
+
+DEVSHELL_ENV = 'DEVSHELL_CLIENT_PORT'
+
+
+class Error(Exception):
+ """Errors for this module."""
+ pass
+
+
+class CommunicationError(Error):
+ """Errors for communication with the Developer Shell server."""
+
+
+class NoDevshellServer(Error):
+ """Error when no Developer Shell server can be contacted."""
+
+# The request for credential information to the Developer Shell client socket
+# is always an empty PBLite-formatted JSON object, so just define it as a
+# constant.
+CREDENTIAL_INFO_REQUEST_JSON = '[]'
+
+
+class CredentialInfoResponse(object):
+ """Credential information response from Developer Shell server.
+
+ The credential information response from Developer Shell socket is a
+ PBLite-formatted JSON array with fields encoded by their index in the
+ array:
+
+ * Index 0 - user email
+ * Index 1 - default project ID. None if the project context is not known.
+ * Index 2 - OAuth2 access token. None if there is no valid auth context.
+ * Index 3 - Seconds until the access token expires. None if not present.
+ """
+
+ def __init__(self, json_string):
+ """Initialize the response data from JSON PBLite array."""
+ pbl = json.loads(json_string)
+ if not isinstance(pbl, list):
+ raise ValueError('Not a list: ' + str(pbl))
+ pbl_len = len(pbl)
+ self.user_email = pbl[0] if pbl_len > 0 else None
+ self.project_id = pbl[1] if pbl_len > 1 else None
+ self.access_token = pbl[2] if pbl_len > 2 else None
+ self.expires_in = pbl[3] if pbl_len > 3 else None
+
+
+def _SendRecv():
+ """Communicate with the Developer Shell server socket."""
+
+ port = int(os.getenv(DEVSHELL_ENV, 0))
+ if port == 0:
+ raise NoDevshellServer()
+
+ sock = socket.socket()
+ sock.connect(('localhost', port))
+
+ data = CREDENTIAL_INFO_REQUEST_JSON
+ msg = '{0}\n{1}'.format(len(data), data)
+ sock.sendall(_helpers._to_bytes(msg, encoding='utf-8'))
+
+ header = sock.recv(6).decode()
+ if '\n' not in header:
+ raise CommunicationError('saw no newline in the first 6 bytes')
+ len_str, json_str = header.split('\n', 1)
+ to_read = int(len_str) - len(json_str)
+ if to_read > 0:
+ json_str += sock.recv(to_read, socket.MSG_WAITALL).decode()
+
+ return CredentialInfoResponse(json_str)
+
+
+class DevshellCredentials(client.GoogleCredentials):
+ """Credentials object for Google Developer Shell environment.
+
+ This object will allow a Google Developer Shell session to identify its
+ user to Google and other OAuth 2.0 servers that can verify assertions. It
+ can be used for the purpose of accessing data stored under the user
+ account.
+
+ This credential does not require a flow to instantiate because it
+ represents a two legged flow, and therefore has all of the required
+ information to generate and refresh its own access tokens.
+ """
+
+ def __init__(self, user_agent=None):
+ super(DevshellCredentials, self).__init__(
+ None, # access_token, initialized below
+ None, # client_id
+ None, # client_secret
+ None, # refresh_token
+ None, # token_expiry
+ None, # token_uri
+ user_agent)
+ self._refresh(None)
+
+ def _refresh(self, http_request):
+ self.devshell_response = _SendRecv()
+ self.access_token = self.devshell_response.access_token
+ expires_in = self.devshell_response.expires_in
+ if expires_in is not None:
+ delta = datetime.timedelta(seconds=expires_in)
+ self.token_expiry = client._UTCNOW() + delta
+ else:
+ self.token_expiry = None
+
+ @property
+ def user_email(self):
+ return self.devshell_response.user_email
+
+ @property
+ def project_id(self):
+ return self.devshell_response.project_id
+
+ @classmethod
+ def from_json(cls, json_data):
+ raise NotImplementedError(
+ 'Cannot load Developer Shell credentials from JSON.')
+
+ @property
+ def serialization_data(self):
+ raise NotImplementedError(
+ 'Cannot serialize Developer Shell credentials.')
diff --git a/oauth2client/contrib/dictionary_storage.py b/oauth2client/contrib/dictionary_storage.py
new file mode 100644
index 0000000..6ee333f
--- /dev/null
+++ b/oauth2client/contrib/dictionary_storage.py
@@ -0,0 +1,65 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Dictionary storage for OAuth2 Credentials."""
+
+from oauth2client import client
+
+
+class DictionaryStorage(client.Storage):
+ """Store and retrieve credentials to and from a dictionary-like object.
+
+ Args:
+ dictionary: A dictionary or dictionary-like object.
+ key: A string or other hashable. The credentials will be stored in
+ ``dictionary[key]``.
+ lock: An optional threading.Lock-like object. The lock will be
+ acquired before anything is written or read from the
+ dictionary.
+ """
+
+ def __init__(self, dictionary, key, lock=None):
+ """Construct a DictionaryStorage instance."""
+ super(DictionaryStorage, self).__init__(lock=lock)
+ self._dictionary = dictionary
+ self._key = key
+
+ def locked_get(self):
+ """Retrieve the credentials from the dictionary, if they exist.
+
+ Returns: A :class:`oauth2client.client.OAuth2Credentials` instance.
+ """
+ serialized = self._dictionary.get(self._key)
+
+ if serialized is None:
+ return None
+
+ credentials = client.OAuth2Credentials.from_json(serialized)
+ credentials.set_store(self)
+
+ return credentials
+
+ def locked_put(self, credentials):
+ """Save the credentials to the dictionary.
+
+ Args:
+ credentials: A :class:`oauth2client.client.OAuth2Credentials`
+ instance.
+ """
+ serialized = credentials.to_json()
+ self._dictionary[self._key] = serialized
+
+ def locked_delete(self):
+ """Remove the credentials from the dictionary, if they exist."""
+ self._dictionary.pop(self._key, None)
diff --git a/oauth2client/contrib/django_util/__init__.py b/oauth2client/contrib/django_util/__init__.py
new file mode 100644
index 0000000..5449e32
--- /dev/null
+++ b/oauth2client/contrib/django_util/__init__.py
@@ -0,0 +1,477 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for the Django web framework.
+
+Provides Django views and helpers the make using the OAuth2 web server
+flow easier. It includes an ``oauth_required`` decorator to automatically
+ensure that user credentials are available, and an ``oauth_enabled`` decorator
+to check if the user has authorized, and helper shortcuts to create the
+authorization URL otherwise.
+
+There are two basic use cases supported. The first is using Google OAuth as the
+primary form of authentication, which is the simpler approach recommended
+for applications without their own user system.
+
+The second use case is adding Google OAuth credentials to an
+existing Django model containing a Django user field. Most of the
+configuration is the same, except for `GOOGLE_OAUTH_MODEL_STORAGE` in
+settings.py. See "Adding Credentials To An Existing Django User System" for
+usage differences.
+
+Only Django versions 1.8+ are supported.
+
+Configuration
+===============
+
+To configure, you'll need a set of OAuth2 web application credentials from
+`Google Developer's Console <https://console.developers.google.com/project/_/apiui/credential>`.
+
+Add the helper to your INSTALLED_APPS:
+
+.. code-block:: python
+ :caption: settings.py
+ :name: installed_apps
+
+ INSTALLED_APPS = (
+ # other apps
+ "django.contrib.sessions.middleware"
+ "oauth2client.contrib.django_util"
+ )
+
+This helper also requires the Django Session Middleware, so
+``django.contrib.sessions.middleware`` should be in INSTALLED_APPS as well.
+
+Add the client secrets created earlier to the settings. You can either
+specify the path to the credentials file in JSON format
+
+.. code-block:: python
+ :caption: settings.py
+ :name: secrets_file
+
+ GOOGLE_OAUTH2_CLIENT_SECRETS_JSON=/path/to/client-secret.json
+
+Or, directly configure the client Id and client secret.
+
+
+.. code-block:: python
+ :caption: settings.py
+ :name: secrets_config
+
+ GOOGLE_OAUTH2_CLIENT_ID=client-id-field
+ GOOGLE_OAUTH2_CLIENT_SECRET=client-secret-field
+
+By default, the default scopes for the required decorator only contains the
+``email`` scopes. You can change that default in the settings.
+
+.. code-block:: python
+ :caption: settings.py
+ :name: scopes
+
+ GOOGLE_OAUTH2_SCOPES = ('email', 'https://www.googleapis.com/auth/calendar',)
+
+By default, the decorators will add an `oauth` object to the Django request
+object, and include all of its state and helpers inside that object. If the
+`oauth` name conflicts with another usage, it can be changed
+
+.. code-block:: python
+ :caption: settings.py
+ :name: request_prefix
+
+ # changes request.oauth to request.google_oauth
+ GOOGLE_OAUTH2_REQUEST_ATTRIBUTE = 'google_oauth'
+
+Add the oauth2 routes to your application's urls.py urlpatterns.
+
+.. code-block:: python
+ :caption: urls.py
+ :name: urls
+
+ from oauth2client.contrib.django_util.site import urls as oauth2_urls
+
+ urlpatterns += [url(r'^oauth2/', include(oauth2_urls))]
+
+To require OAuth2 credentials for a view, use the `oauth2_required` decorator.
+This creates a credentials object with an id_token, and allows you to create
+an `http` object to build service clients with. These are all attached to the
+request.oauth
+
+.. code-block:: python
+ :caption: views.py
+ :name: views_required
+
+ from oauth2client.contrib.django_util.decorators import oauth_required
+
+ @oauth_required
+ def requires_default_scopes(request):
+ email = request.oauth.credentials.id_token['email']
+ service = build(serviceName='calendar', version='v3',
+ http=request.oauth.http,
+ developerKey=API_KEY)
+ events = service.events().list(calendarId='primary').execute()['items']
+ return HttpResponse("email: {0} , calendar: {1}".format(
+ email,str(events)))
+ return HttpResponse(
+ "email: {0} , calendar: {1}".format(email, str(events)))
+
+To make OAuth2 optional and provide an authorization link in your own views.
+
+.. code-block:: python
+ :caption: views.py
+ :name: views_enabled2
+
+ from oauth2client.contrib.django_util.decorators import oauth_enabled
+
+ @oauth_enabled
+ def optional_oauth2(request):
+ if request.oauth.has_credentials():
+ # this could be passed into a view
+ # request.oauth.http is also initialized
+ return HttpResponse("User email: {0}".format(
+ request.oauth.credentials.id_token['email']))
+ else:
+ return HttpResponse(
+ 'Here is an OAuth Authorize link: <a href="{0}">Authorize'
+ '</a>'.format(request.oauth.get_authorize_redirect()))
+
+If a view needs a scope not included in the default scopes specified in
+the settings, you can use [incremental auth](https://developers.google.com/identity/sign-in/web/incremental-auth)
+and specify additional scopes in the decorator arguments.
+
+.. code-block:: python
+ :caption: views.py
+ :name: views_required_additional_scopes
+
+ @oauth_enabled(scopes=['https://www.googleapis.com/auth/drive'])
+ def drive_required(request):
+ if request.oauth.has_credentials():
+ service = build(serviceName='drive', version='v2',
+ http=request.oauth.http,
+ developerKey=API_KEY)
+ events = service.files().list().execute()['items']
+ return HttpResponse(str(events))
+ else:
+ return HttpResponse(
+ 'Here is an OAuth Authorize link: <a href="{0}">Authorize'
+ '</a>'.format(request.oauth.get_authorize_redirect()))
+
+
+To provide a callback on authorization being completed, use the
+oauth2_authorized signal:
+
+.. code-block:: python
+ :caption: views.py
+ :name: signals
+
+ from oauth2client.contrib.django_util.signals import oauth2_authorized
+
+ def test_callback(sender, request, credentials, **kwargs):
+ print("Authorization Signal Received {0}".format(
+ credentials.id_token['email']))
+
+ oauth2_authorized.connect(test_callback)
+
+Adding Credentials To An Existing Django User System
+=====================================================
+
+As an alternative to storing the credentials in the session, the helper
+can be configured to store the fields on a Django model. This might be useful
+if you need to use the credentials outside the context of a user request. It
+also prevents the need for a logged in user to repeat the OAuth flow when
+starting a new session.
+
+To use, change ``settings.py``
+
+.. code-block:: python
+ :caption: settings.py
+ :name: storage_model_config
+
+ GOOGLE_OAUTH2_STORAGE_MODEL = {
+ 'model': 'path.to.model.MyModel',
+ 'user_property': 'user_id',
+ 'credentials_property': 'credential'
+ }
+
+Where ``path.to.model`` class is the fully qualified name of a
+``django.db.model`` class containing a ``django.contrib.auth.models.User``
+field with the name specified by `user_property` and a
+:class:`oauth2client.contrib.django_util.models.CredentialsField` with the name
+specified by `credentials_property`. For the sample configuration given,
+our model would look like
+
+.. code-block:: python
+ :caption: models.py
+ :name: storage_model_model
+
+ from django.contrib.auth.models import User
+ from oauth2client.contrib.django_util.models import CredentialsField
+
+ class MyModel(models.Model):
+ # ... other fields here ...
+ user = models.OneToOneField(User)
+ credential = CredentialsField()
+"""
+
+import importlib
+
+import django.conf
+from django.core import exceptions
+from django.core import urlresolvers
+import httplib2
+from six.moves.urllib import parse
+
+from oauth2client import clientsecrets
+from oauth2client.contrib import dictionary_storage
+from oauth2client.contrib.django_util import storage
+
+GOOGLE_OAUTH2_DEFAULT_SCOPES = ('email',)
+GOOGLE_OAUTH2_REQUEST_ATTRIBUTE = 'oauth'
+
+
+def _load_client_secrets(filename):
+ """Loads client secrets from the given filename.
+
+ Args:
+ filename: The name of the file containing the JSON secret key.
+
+ Returns:
+ A 2-tuple, the first item containing the client id, and the second
+ item containing a client secret.
+ """
+ client_type, client_info = clientsecrets.loadfile(filename)
+
+ if client_type != clientsecrets.TYPE_WEB:
+ raise ValueError(
+ 'The flow specified in {} is not supported, only the WEB flow '
+ 'type is supported.'.format(client_type))
+ return client_info['client_id'], client_info['client_secret']
+
+
+def _get_oauth2_client_id_and_secret(settings_instance):
+ """Initializes client id and client secret based on the settings.
+
+ Args:
+ settings_instance: An instance of ``django.conf.settings``.
+
+ Returns:
+ A 2-tuple, the first item is the client id and the second
+ item is the client secret.
+ """
+ secret_json = getattr(settings_instance,
+ 'GOOGLE_OAUTH2_CLIENT_SECRETS_JSON', None)
+ if secret_json is not None:
+ return _load_client_secrets(secret_json)
+ else:
+ client_id = getattr(settings_instance, "GOOGLE_OAUTH2_CLIENT_ID",
+ None)
+ client_secret = getattr(settings_instance,
+ "GOOGLE_OAUTH2_CLIENT_SECRET", None)
+ if client_id is not None and client_secret is not None:
+ return client_id, client_secret
+ else:
+ raise exceptions.ImproperlyConfigured(
+ "Must specify either GOOGLE_OAUTH2_CLIENT_SECRETS_JSON, or "
+ "both GOOGLE_OAUTH2_CLIENT_ID and "
+ "GOOGLE_OAUTH2_CLIENT_SECRET in settings.py")
+
+
+def _get_storage_model():
+ """This configures whether the credentials will be stored in the session
+ or the Django ORM based on the settings. By default, the credentials
+ will be stored in the session, unless `GOOGLE_OAUTH2_STORAGE_MODEL`
+ is found in the settings. Usually, the ORM storage is used to integrate
+ credentials into an existing Django user system.
+
+ Returns:
+ A tuple containing three strings, or None. If
+ ``GOOGLE_OAUTH2_STORAGE_MODEL`` is configured, the tuple
+ will contain the fully qualifed path of the `django.db.model`,
+ the name of the ``django.contrib.auth.models.User`` field on the
+ model, and the name of the
+ :class:`oauth2client.contrib.django_util.models.CredentialsField`
+ field on the model. If Django ORM storage is not configured,
+ this function returns None.
+ """
+ storage_model_settings = getattr(django.conf.settings,
+ 'GOOGLE_OAUTH2_STORAGE_MODEL', None)
+ if storage_model_settings is not None:
+ return (storage_model_settings['model'],
+ storage_model_settings['user_property'],
+ storage_model_settings['credentials_property'])
+ else:
+ return None, None, None
+
+
+class OAuth2Settings(object):
+ """Initializes Django OAuth2 Helper Settings
+
+ This class loads the OAuth2 Settings from the Django settings, and then
+ provides those settings as attributes to the rest of the views and
+ decorators in the module.
+
+ Attributes:
+ scopes: A list of OAuth2 scopes that the decorators and views will use
+ as defaults.
+ request_prefix: The name of the attribute that the decorators use to
+ attach the UserOAuth2 object to the Django request object.
+ client_id: The OAuth2 Client ID.
+ client_secret: The OAuth2 Client Secret.
+ """
+
+ def __init__(self, settings_instance):
+ self.scopes = getattr(settings_instance, 'GOOGLE_OAUTH2_SCOPES',
+ GOOGLE_OAUTH2_DEFAULT_SCOPES)
+ self.request_prefix = getattr(settings_instance,
+ 'GOOGLE_OAUTH2_REQUEST_ATTRIBUTE',
+ GOOGLE_OAUTH2_REQUEST_ATTRIBUTE)
+ self.client_id, self.client_secret = \
+ _get_oauth2_client_id_and_secret(settings_instance)
+
+ if ('django.contrib.sessions.middleware.SessionMiddleware'
+ not in settings_instance.MIDDLEWARE_CLASSES):
+ raise exceptions.ImproperlyConfigured(
+ 'The Google OAuth2 Helper requires session middleware to '
+ 'be installed. Edit your MIDDLEWARE_CLASSES setting'
+ ' to include \'django.contrib.sessions.middleware.'
+ 'SessionMiddleware\'.')
+ (self.storage_model, self.storage_model_user_property,
+ self.storage_model_credentials_property) = _get_storage_model()
+
+
+oauth2_settings = OAuth2Settings(django.conf.settings)
+
+_CREDENTIALS_KEY = 'google_oauth2_credentials'
+
+
+def get_storage(request):
+ """ Gets a Credentials storage object provided by the Django OAuth2 Helper
+ object.
+
+ Args:
+ request: Reference to the current request object.
+
+ Returns:
+ An :class:`oauth2.client.Storage` object.
+ """
+ storage_model = oauth2_settings.storage_model
+ user_property = oauth2_settings.storage_model_user_property
+ credentials_property = oauth2_settings.storage_model_credentials_property
+
+ if storage_model:
+ module_name, class_name = storage_model.rsplit('.', 1)
+ module = importlib.import_module(module_name)
+ storage_model_class = getattr(module, class_name)
+ return storage.DjangoORMStorage(storage_model_class,
+ user_property,
+ request.user,
+ credentials_property)
+ else:
+ # use session
+ return dictionary_storage.DictionaryStorage(
+ request.session, key=_CREDENTIALS_KEY)
+
+
+def _redirect_with_params(url_name, *args, **kwargs):
+ """Helper method to create a redirect response with URL params.
+
+ This builds a redirect string that converts kwargs into a
+ query string.
+
+ Args:
+ url_name: The name of the url to redirect to.
+ kwargs: the query string param and their values to build.
+
+ Returns:
+ A properly formatted redirect string.
+ """
+ url = urlresolvers.reverse(url_name, args=args)
+ params = parse.urlencode(kwargs, True)
+ return "{0}?{1}".format(url, params)
+
+
+def _credentials_from_request(request):
+ """Gets the authorized credentials for this flow, if they exist."""
+ # ORM storage requires a logged in user
+ if (oauth2_settings.storage_model is None or
+ request.user.is_authenticated()):
+ return get_storage(request).get()
+ else:
+ return None
+
+
+class UserOAuth2(object):
+ """Class to create oauth2 objects on Django request objects containing
+ credentials and helper methods.
+ """
+
+ def __init__(self, request, scopes=None, return_url=None):
+ """Initialize the Oauth2 Object.
+
+ Args:
+ request: Django request object.
+ scopes: Scopes desired for this OAuth2 flow.
+ return_url: The url to return to after the OAuth flow is complete,
+ defaults to the request's current URL path.
+ """
+ self.request = request
+ self.return_url = return_url or request.get_full_path()
+ if scopes:
+ self._scopes = set(oauth2_settings.scopes) | set(scopes)
+ else:
+ self._scopes = set(oauth2_settings.scopes)
+
+ def get_authorize_redirect(self):
+ """Creates a URl to start the OAuth2 authorization flow."""
+ get_params = {
+ 'return_url': self.return_url,
+ 'scopes': self._get_scopes()
+ }
+
+ return _redirect_with_params('google_oauth:authorize', **get_params)
+
+ def has_credentials(self):
+ """Returns True if there are valid credentials for the current user
+ and required scopes."""
+ credentials = _credentials_from_request(self.request)
+ return (credentials and not credentials.invalid and
+ credentials.has_scopes(self._get_scopes()))
+
+ def _get_scopes(self):
+ """Returns the scopes associated with this object, kept up to
+ date for incremental auth."""
+ if _credentials_from_request(self.request):
+ return (self._scopes |
+ _credentials_from_request(self.request).scopes)
+ else:
+ return self._scopes
+
+ @property
+ def scopes(self):
+ """Returns the scopes associated with this OAuth2 object."""
+ # make sure previously requested custom scopes are maintained
+ # in future authorizations
+ return self._get_scopes()
+
+ @property
+ def credentials(self):
+ """Gets the authorized credentials for this flow, if they exist."""
+ return _credentials_from_request(self.request)
+
+ @property
+ def http(self):
+ """Helper method to create an HTTP client authorized with OAuth2
+ credentials."""
+ if self.has_credentials():
+ return self.credentials.authorize(httplib2.Http())
+ return None
diff --git a/oauth2client/contrib/django_util/apps.py b/oauth2client/contrib/django_util/apps.py
new file mode 100644
index 0000000..86676b9
--- /dev/null
+++ b/oauth2client/contrib/django_util/apps.py
@@ -0,0 +1,32 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Application Config For Django OAuth2 Helper.
+
+Django 1.7+ provides an
+[applications](https://docs.djangoproject.com/en/1.8/ref/applications/)
+API so that Django projects can introspect on installed applications using a
+stable API. This module exists to follow that convention.
+"""
+
+import sys
+
+# Django 1.7+ only supports Python 2.7+
+if sys.hexversion >= 0x02070000: # pragma: NO COVER
+ from django.apps import AppConfig
+
+ class GoogleOAuth2HelperConfig(AppConfig):
+ """ App Config for Django Helper"""
+ name = 'oauth2client.django_util'
+ verbose_name = "Google OAuth2 Django Helper"
diff --git a/oauth2client/contrib/django_util/decorators.py b/oauth2client/contrib/django_util/decorators.py
new file mode 100644
index 0000000..e62e171
--- /dev/null
+++ b/oauth2client/contrib/django_util/decorators.py
@@ -0,0 +1,145 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Decorators for Django OAuth2 Flow.
+
+Contains two decorators, ``oauth_required`` and ``oauth_enabled``.
+
+``oauth_required`` will ensure that a user has an oauth object containing
+credentials associated with the request, and if not, redirect to the
+authorization flow.
+
+``oauth_enabled`` will attach the oauth2 object containing credentials if it
+exists. If it doesn't, the view will still render, but helper methods will be
+attached to start the oauth2 flow.
+"""
+
+from django import shortcuts
+import django.conf
+from six import wraps
+from six.moves.urllib import parse
+
+from oauth2client.contrib import django_util
+
+
+def oauth_required(decorated_function=None, scopes=None, **decorator_kwargs):
+ """ Decorator to require OAuth2 credentials for a view.
+
+
+ .. code-block:: python
+ :caption: views.py
+ :name: views_required_2
+
+
+ from oauth2client.django_util.decorators import oauth_required
+
+ @oauth_required
+ def requires_default_scopes(request):
+ email = request.credentials.id_token['email']
+ service = build(serviceName='calendar', version='v3',
+ http=request.oauth.http,
+ developerKey=API_KEY)
+ events = service.events().list(
+ calendarId='primary').execute()['items']
+ return HttpResponse(
+ "email: {0}, calendar: {1}".format(email, str(events)))
+
+ Args:
+ decorated_function: View function to decorate, must have the Django
+ request object as the first argument.
+ scopes: Scopes to require, will default.
+ decorator_kwargs: Can include ``return_url`` to specify the URL to
+ return to after OAuth2 authorization is complete.
+
+ Returns:
+ An OAuth2 Authorize view if credentials are not found or if the
+ credentials are missing the required scopes. Otherwise,
+ the decorated view.
+ """
+ def curry_wrapper(wrapped_function):
+ @wraps(wrapped_function)
+ def required_wrapper(request, *args, **kwargs):
+ if not (django_util.oauth2_settings.storage_model is None or
+ request.user.is_authenticated()):
+ redirect_str = '{0}?next={1}'.format(
+ django.conf.settings.LOGIN_URL,
+ parse.quote(request.path))
+ return shortcuts.redirect(redirect_str)
+
+ return_url = decorator_kwargs.pop('return_url',
+ request.get_full_path())
+ user_oauth = django_util.UserOAuth2(request, scopes, return_url)
+ if not user_oauth.has_credentials():
+ return shortcuts.redirect(user_oauth.get_authorize_redirect())
+ setattr(request, django_util.oauth2_settings.request_prefix,
+ user_oauth)
+ return wrapped_function(request, *args, **kwargs)
+
+ return required_wrapper
+
+ if decorated_function:
+ return curry_wrapper(decorated_function)
+ else:
+ return curry_wrapper
+
+
+def oauth_enabled(decorated_function=None, scopes=None, **decorator_kwargs):
+ """ Decorator to enable OAuth Credentials if authorized, and setup
+ the oauth object on the request object to provide helper functions
+ to start the flow otherwise.
+
+ .. code-block:: python
+ :caption: views.py
+ :name: views_enabled3
+
+ from oauth2client.django_util.decorators import oauth_enabled
+
+ @oauth_enabled
+ def optional_oauth2(request):
+ if request.oauth.has_credentials():
+ # this could be passed into a view
+ # request.oauth.http is also initialized
+ return HttpResponse("User email: {0}".format(
+ request.oauth.credentials.id_token['email'])
+ else:
+ return HttpResponse('Here is an OAuth Authorize link:
+ <a href="{0}">Authorize</a>'.format(
+ request.oauth.get_authorize_redirect()))
+
+
+ Args:
+ decorated_function: View function to decorate.
+ scopes: Scopes to require, will default.
+ decorator_kwargs: Can include ``return_url`` to specify the URL to
+ return to after OAuth2 authorization is complete.
+
+ Returns:
+ The decorated view function.
+ """
+ def curry_wrapper(wrapped_function):
+ @wraps(wrapped_function)
+ def enabled_wrapper(request, *args, **kwargs):
+ return_url = decorator_kwargs.pop('return_url',
+ request.get_full_path())
+ user_oauth = django_util.UserOAuth2(request, scopes, return_url)
+ setattr(request, django_util.oauth2_settings.request_prefix,
+ user_oauth)
+ return wrapped_function(request, *args, **kwargs)
+
+ return enabled_wrapper
+
+ if decorated_function:
+ return curry_wrapper(decorated_function)
+ else:
+ return curry_wrapper
diff --git a/oauth2client/contrib/django_util/models.py b/oauth2client/contrib/django_util/models.py
new file mode 100644
index 0000000..87e1da7
--- /dev/null
+++ b/oauth2client/contrib/django_util/models.py
@@ -0,0 +1,75 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Contains classes used for the Django ORM storage."""
+
+import base64
+import pickle
+
+from django.db import models
+from django.utils import encoding
+
+import oauth2client
+
+
+class CredentialsField(models.Field):
+ """Django ORM field for storing OAuth2 Credentials."""
+
+ def __init__(self, *args, **kwargs):
+ if 'null' not in kwargs:
+ kwargs['null'] = True
+ super(CredentialsField, self).__init__(*args, **kwargs)
+
+ def get_internal_type(self):
+ return 'BinaryField'
+
+ def from_db_value(self, value, expression, connection, context):
+ """Overrides ``models.Field`` method. This converts the value
+ returned from the database to an instance of this class.
+ """
+ return self.to_python(value)
+
+ def to_python(self, value):
+ """Overrides ``models.Field`` method. This is used to convert
+ bytes (from serialization etc) to an instance of this class"""
+ if value is None:
+ return None
+ elif isinstance(value, oauth2client.client.Credentials):
+ return value
+ else:
+ return pickle.loads(base64.b64decode(encoding.smart_bytes(value)))
+
+ def get_prep_value(self, value):
+ """Overrides ``models.Field`` method. This is used to convert
+ the value from an instances of this class to bytes that can be
+ inserted into the database.
+ """
+ if value is None:
+ return None
+ else:
+ return encoding.smart_text(base64.b64encode(pickle.dumps(value)))
+
+ def value_to_string(self, obj):
+ """Convert the field value from the provided model to a string.
+
+ Used during model serialization.
+
+ Args:
+ obj: db.Model, model object
+
+ Returns:
+ string, the serialized field value
+ """
+ value = self._get_val_from_obj(obj)
+ return self.get_prep_value(value)
diff --git a/oauth2client/contrib/django_util/signals.py b/oauth2client/contrib/django_util/signals.py
new file mode 100644
index 0000000..e9356b4
--- /dev/null
+++ b/oauth2client/contrib/django_util/signals.py
@@ -0,0 +1,28 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Signals for Google OAuth2 Helper.
+
+This module contains signals for Google OAuth2 Helper. Currently it only
+contains one, which fires when an OAuth2 authorization flow has completed.
+"""
+
+import django.dispatch
+
+"""Signal that fires when OAuth2 Flow has completed.
+It passes the Django request object and the OAuth2 credentials object to the
+ receiver.
+"""
+oauth2_authorized = django.dispatch.Signal(
+ providing_args=["request", "credentials"])
diff --git a/oauth2client/contrib/django_util/site.py b/oauth2client/contrib/django_util/site.py
new file mode 100644
index 0000000..631f79b
--- /dev/null
+++ b/oauth2client/contrib/django_util/site.py
@@ -0,0 +1,26 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Contains Django URL patterns used for OAuth2 flow."""
+
+from django.conf import urls
+
+from oauth2client.contrib.django_util import views
+
+urlpatterns = [
+ urls.url(r'oauth2callback/', views.oauth2_callback, name="callback"),
+ urls.url(r'oauth2authorize/', views.oauth2_authorize, name="authorize")
+]
+
+urls = (urlpatterns, "google_oauth", "google_oauth")
diff --git a/oauth2client/contrib/django_util/storage.py b/oauth2client/contrib/django_util/storage.py
new file mode 100644
index 0000000..5682919
--- /dev/null
+++ b/oauth2client/contrib/django_util/storage.py
@@ -0,0 +1,81 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Contains a storage module that stores credentials using the Django ORM."""
+
+from oauth2client import client
+
+
+class DjangoORMStorage(client.Storage):
+ """Store and retrieve a single credential to and from the Django datastore.
+
+ This Storage helper presumes the Credentials
+ have been stored as a CredentialsField
+ on a db model class.
+ """
+
+ def __init__(self, model_class, key_name, key_value, property_name):
+ """Constructor for Storage.
+
+ Args:
+ model: string, fully qualified name of db.Model model class.
+ key_name: string, key name for the entity that has the credentials
+ key_value: string, key value for the entity that has the
+ credentials.
+ property_name: string, name of the property that is an
+ CredentialsProperty.
+ """
+ super(DjangoORMStorage, self).__init__()
+ self.model_class = model_class
+ self.key_name = key_name
+ self.key_value = key_value
+ self.property_name = property_name
+
+ def locked_get(self):
+ """Retrieve stored credential from the Django ORM.
+
+ Returns:
+ oauth2client.Credentials retrieved from the Django ORM, associated
+ with the ``model``, ``key_value``->``key_name`` pair used to query
+ for the model, and ``property_name`` identifying the
+ ``CredentialsProperty`` field, all of which are defined in the
+ constructor for this Storage object.
+
+ """
+ query = {self.key_name: self.key_value}
+ entities = self.model_class.objects.filter(**query)
+ if len(entities) > 0:
+ credential = getattr(entities[0], self.property_name)
+ if getattr(credential, 'set_store', None) is not None:
+ credential.set_store(self)
+ return credential
+ else:
+ return None
+
+ def locked_put(self, credentials):
+ """Write a Credentials to the Django datastore.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ entity, _ = self.model_class.objects.get_or_create(
+ **{self.key_name: self.key_value})
+
+ setattr(entity, self.property_name, credentials)
+ entity.save()
+
+ def locked_delete(self):
+ """Delete Credentials from the datastore."""
+ query = {self.key_name: self.key_value}
+ self.model_class.objects.filter(**query).delete()
diff --git a/oauth2client/contrib/django_util/views.py b/oauth2client/contrib/django_util/views.py
new file mode 100644
index 0000000..4d8ae03
--- /dev/null
+++ b/oauth2client/contrib/django_util/views.py
@@ -0,0 +1,190 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""This module contains the views used by the OAuth2 flows.
+
+Their are two views used by the OAuth2 flow, the authorize and the callback
+view. The authorize view kicks off the three-legged OAuth flow, and the
+callback view validates the flow and if successful stores the credentials
+in the configured storage."""
+
+import hashlib
+import json
+import os
+import pickle
+
+from django import http
+from django import shortcuts
+from django.conf import settings
+from django.core import urlresolvers
+from django.shortcuts import redirect
+from six.moves.urllib import parse
+
+from oauth2client import client
+from oauth2client.contrib import django_util
+from oauth2client.contrib.django_util import get_storage
+from oauth2client.contrib.django_util import signals
+
+_CSRF_KEY = 'google_oauth2_csrf_token'
+_FLOW_KEY = 'google_oauth2_flow_{0}'
+
+
+def _make_flow(request, scopes, return_url=None):
+ """Creates a Web Server Flow
+
+ Args:
+ request: A Django request object.
+ scopes: the request oauth2 scopes.
+ return_url: The URL to return to after the flow is complete. Defaults
+ to the path of the current request.
+
+ Returns:
+ An OAuth2 flow object that has been stored in the session.
+ """
+ # Generate a CSRF token to prevent malicious requests.
+ csrf_token = hashlib.sha256(os.urandom(1024)).hexdigest()
+
+ request.session[_CSRF_KEY] = csrf_token
+
+ state = json.dumps({
+ 'csrf_token': csrf_token,
+ 'return_url': return_url,
+ })
+
+ flow = client.OAuth2WebServerFlow(
+ client_id=django_util.oauth2_settings.client_id,
+ client_secret=django_util.oauth2_settings.client_secret,
+ scope=scopes,
+ state=state,
+ redirect_uri=request.build_absolute_uri(
+ urlresolvers.reverse("google_oauth:callback")))
+
+ flow_key = _FLOW_KEY.format(csrf_token)
+ request.session[flow_key] = pickle.dumps(flow)
+ return flow
+
+
+def _get_flow_for_token(csrf_token, request):
+ """ Looks up the flow in session to recover information about requested
+ scopes.
+
+ Args:
+ csrf_token: The token passed in the callback request that should
+ match the one previously generated and stored in the request on the
+ initial authorization view.
+
+ Returns:
+ The OAuth2 Flow object associated with this flow based on the
+ CSRF token.
+ """
+ flow_pickle = request.session.get(_FLOW_KEY.format(csrf_token), None)
+ return None if flow_pickle is None else pickle.loads(flow_pickle)
+
+
+def oauth2_callback(request):
+ """ View that handles the user's return from OAuth2 provider.
+
+ This view verifies the CSRF state and OAuth authorization code, and on
+ success stores the credentials obtained in the storage provider,
+ and redirects to the return_url specified in the authorize view and
+ stored in the session.
+
+ Args:
+ request: Django request.
+
+ Returns:
+ A redirect response back to the return_url.
+ """
+ if 'error' in request.GET:
+ reason = request.GET.get(
+ 'error_description', request.GET.get('error', ''))
+ return http.HttpResponseBadRequest(
+ 'Authorization failed {0}'.format(reason))
+
+ try:
+ encoded_state = request.GET['state']
+ code = request.GET['code']
+ except KeyError:
+ return http.HttpResponseBadRequest(
+ 'Request missing state or authorization code')
+
+ try:
+ server_csrf = request.session[_CSRF_KEY]
+ except KeyError:
+ return http.HttpResponseBadRequest(
+ 'No existing session for this flow.')
+
+ try:
+ state = json.loads(encoded_state)
+ client_csrf = state['csrf_token']
+ return_url = state['return_url']
+ except (ValueError, KeyError):
+ return http.HttpResponseBadRequest('Invalid state parameter.')
+
+ if client_csrf != server_csrf:
+ return http.HttpResponseBadRequest('Invalid CSRF token.')
+
+ flow = _get_flow_for_token(client_csrf, request)
+
+ if not flow:
+ return http.HttpResponseBadRequest('Missing Oauth2 flow.')
+
+ try:
+ credentials = flow.step2_exchange(code)
+ except client.FlowExchangeError as exchange_error:
+ return http.HttpResponseBadRequest(
+ 'An error has occurred: {0}'.format(exchange_error))
+
+ get_storage(request).put(credentials)
+
+ signals.oauth2_authorized.send(sender=signals.oauth2_authorized,
+ request=request, credentials=credentials)
+
+ return shortcuts.redirect(return_url)
+
+
+def oauth2_authorize(request):
+ """ View to start the OAuth2 Authorization flow.
+
+ This view starts the OAuth2 authorization flow. If scopes is passed in
+ as a GET URL parameter, it will authorize those scopes, otherwise the
+ default scopes specified in settings. The return_url can also be
+ specified as a GET parameter, otherwise the referer header will be
+ checked, and if that isn't found it will return to the root path.
+
+ Args:
+ request: The Django request object.
+
+ Returns:
+ A redirect to Google OAuth2 Authorization.
+ """
+ return_url = request.GET.get('return_url', None)
+
+ # Model storage (but not session storage) requires a logged in user
+ if django_util.oauth2_settings.storage_model:
+ if not request.user.is_authenticated():
+ return redirect('{0}?next={1}'.format(
+ settings.LOGIN_URL, parse.quote(request.get_full_path())))
+ # This checks for the case where we ended up here because of a logged
+ # out user but we had credentials for it in the first place
+ elif get_storage(request).get() is not None:
+ return redirect(return_url)
+
+ scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes)
+
+ if not return_url:
+ return_url = request.META.get('HTTP_REFERER', '/')
+ flow = _make_flow(request=request, scopes=scopes, return_url=return_url)
+ auth_url = flow.step1_get_authorize_url()
+ return shortcuts.redirect(auth_url)
diff --git a/oauth2client/contrib/flask_util.py b/oauth2client/contrib/flask_util.py
new file mode 100644
index 0000000..47c3df1
--- /dev/null
+++ b/oauth2client/contrib/flask_util.py
@@ -0,0 +1,556 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for the Flask web framework
+
+Provides a Flask extension that makes using OAuth2 web server flow easier.
+The extension includes views that handle the entire auth flow and a
+``@required`` decorator to automatically ensure that user credentials are
+available.
+
+
+Configuration
+=============
+
+To configure, you'll need a set of OAuth2 web application credentials from the
+`Google Developer's Console <https://console.developers.google.com/project/_/\
+apiui/credential>`__.
+
+.. code-block:: python
+
+ from oauth2client.contrib.flask_util import UserOAuth2
+
+ app = Flask(__name__)
+
+ app.config['SECRET_KEY'] = 'your-secret-key'
+
+ app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE'] = 'client_secrets.json'
+
+ # or, specify the client id and secret separately
+ app.config['GOOGLE_OAUTH2_CLIENT_ID'] = 'your-client-id'
+ app.config['GOOGLE_OAUTH2_CLIENT_SECRET'] = 'your-client-secret'
+
+ oauth2 = UserOAuth2(app)
+
+
+Usage
+=====
+
+Once configured, you can use the :meth:`UserOAuth2.required` decorator to
+ensure that credentials are available within a view.
+
+.. code-block:: python
+ :emphasize-lines: 3,7,10
+
+ # Note that app.route should be the outermost decorator.
+ @app.route('/needs_credentials')
+ @oauth2.required
+ def example():
+ # http is authorized with the user's credentials and can be used
+ # to make http calls.
+ http = oauth2.http()
+
+ # Or, you can access the credentials directly
+ credentials = oauth2.credentials
+
+If you want credentials to be optional for a view, you can leave the decorator
+off and use :meth:`UserOAuth2.has_credentials` to check.
+
+.. code-block:: python
+ :emphasize-lines: 3
+
+ @app.route('/optional')
+ def optional():
+ if oauth2.has_credentials():
+ return 'Credentials found!'
+ else:
+ return 'No credentials!'
+
+
+When credentials are available, you can use :attr:`UserOAuth2.email` and
+:attr:`UserOAuth2.user_id` to access information from the `ID Token
+<https://developers.google.com/identity/protocols/OpenIDConnect?hl=en>`__, if
+available.
+
+.. code-block:: python
+ :emphasize-lines: 4
+
+ @app.route('/info')
+ @oauth2.required
+ def info():
+ return "Hello, {} ({})".format(oauth2.email, oauth2.user_id)
+
+
+URLs & Trigging Authorization
+=============================
+
+The extension will add two new routes to your application:
+
+ * ``"oauth2.authorize"`` -> ``/oauth2authorize``
+ * ``"oauth2.callback"`` -> ``/oauth2callback``
+
+When configuring your OAuth2 credentials on the Google Developer's Console, be
+sure to add ``http[s]://[your-app-url]/oauth2callback`` as an authorized
+callback url.
+
+Typically you don't not need to use these routes directly, just be sure to
+decorate any views that require credentials with ``@oauth2.required``. If
+needed, you can trigger authorization at any time by redirecting the user
+to the URL returned by :meth:`UserOAuth2.authorize_url`.
+
+.. code-block:: python
+ :emphasize-lines: 3
+
+ @app.route('/login')
+ def login():
+ return oauth2.authorize_url("/")
+
+
+Incremental Auth
+================
+
+This extension also supports `Incremental Auth <https://developers.google.com\
+/identity/protocols/OAuth2WebServer?hl=en#incrementalAuth>`__. To enable it,
+configure the extension with ``include_granted_scopes``.
+
+.. code-block:: python
+
+ oauth2 = UserOAuth2(app, include_granted_scopes=True)
+
+Then specify any additional scopes needed on the decorator, for example:
+
+.. code-block:: python
+ :emphasize-lines: 2,7
+
+ @app.route('/drive')
+ @oauth2.required(scopes=["https://www.googleapis.com/auth/drive"])
+ def requires_drive():
+ ...
+
+ @app.route('/calendar')
+ @oauth2.required(scopes=["https://www.googleapis.com/auth/calendar"])
+ def requires_calendar():
+ ...
+
+The decorator will ensure that the the user has authorized all specified scopes
+before allowing them to access the view, and will also ensure that credentials
+do not lose any previously authorized scopes.
+
+
+Storage
+=======
+
+By default, the extension uses a Flask session-based storage solution. This
+means that credentials are only available for the duration of a session. It
+also means that with Flask's default configuration, the credentials will be
+visible in the session cookie. It's highly recommended to use database-backed
+session and to use https whenever handling user credentials.
+
+If you need the credentials to be available longer than a user session or
+available outside of a request context, you will need to implement your own
+:class:`oauth2client.Storage`.
+"""
+
+from functools import wraps
+import hashlib
+import json
+import os
+import pickle
+
+try:
+ from flask import Blueprint
+ from flask import _app_ctx_stack
+ from flask import current_app
+ from flask import redirect
+ from flask import request
+ from flask import session
+ from flask import url_for
+except ImportError: # pragma: NO COVER
+ raise ImportError('The flask utilities require flask 0.9 or newer.')
+
+import httplib2
+import six.moves.http_client as httplib
+
+from oauth2client import client
+from oauth2client import clientsecrets
+from oauth2client.contrib import dictionary_storage
+
+
+__author__ = 'jonwayne@google.com (Jon Wayne Parrott)'
+
+_DEFAULT_SCOPES = ('email',)
+_CREDENTIALS_KEY = 'google_oauth2_credentials'
+_FLOW_KEY = 'google_oauth2_flow_{0}'
+_CSRF_KEY = 'google_oauth2_csrf_token'
+
+
+def _get_flow_for_token(csrf_token):
+ """Retrieves the flow instance associated with a given CSRF token from
+ the Flask session."""
+ flow_pickle = session.pop(
+ _FLOW_KEY.format(csrf_token), None)
+
+ if flow_pickle is None:
+ return None
+ else:
+ return pickle.loads(flow_pickle)
+
+
+class UserOAuth2(object):
+ """Flask extension for making OAuth 2.0 easier.
+
+ Configuration values:
+
+ * ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` path to a client secrets json
+ file, obtained from the credentials screen in the Google Developers
+ console.
+ * ``GOOGLE_OAUTH2_CLIENT_ID`` the oauth2 credentials' client ID. This
+ is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE`` is not
+ specified.
+ * ``GOOGLE_OAUTH2_CLIENT_SECRET`` the oauth2 credentials' client
+ secret. This is only needed if ``GOOGLE_OAUTH2_CLIENT_SECRETS_FILE``
+ is not specified.
+
+ If app is specified, all arguments will be passed along to init_app.
+
+ If no app is specified, then you should call init_app in your application
+ factory to finish initialization.
+ """
+
+ def __init__(self, app=None, *args, **kwargs):
+ self.app = app
+ if app is not None:
+ self.init_app(app, *args, **kwargs)
+
+ def init_app(self, app, scopes=None, client_secrets_file=None,
+ client_id=None, client_secret=None, authorize_callback=None,
+ storage=None, **kwargs):
+ """Initialize this extension for the given app.
+
+ Arguments:
+ app: A Flask application.
+ scopes: Optional list of scopes to authorize.
+ client_secrets_file: Path to a file containing client secrets. You
+ can also specify the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE config
+ value.
+ client_id: If not specifying a client secrets file, specify the
+ OAuth2 client id. You can also specify the
+ GOOGLE_OAUTH2_CLIENT_ID config value. You must also provide a
+ client secret.
+ client_secret: The OAuth2 client secret. You can also specify the
+ GOOGLE_OAUTH2_CLIENT_SECRET config value.
+ authorize_callback: A function that is executed after successful
+ user authorization.
+ storage: A oauth2client.client.Storage subclass for storing the
+ credentials. By default, this is a Flask session based storage.
+ kwargs: Any additional args are passed along to the Flow
+ constructor.
+ """
+ self.app = app
+ self.authorize_callback = authorize_callback
+ self.flow_kwargs = kwargs
+
+ if storage is None:
+ storage = dictionary_storage.DictionaryStorage(
+ session, key=_CREDENTIALS_KEY)
+ self.storage = storage
+
+ if scopes is None:
+ scopes = app.config.get('GOOGLE_OAUTH2_SCOPES', _DEFAULT_SCOPES)
+ self.scopes = scopes
+
+ self._load_config(client_secrets_file, client_id, client_secret)
+
+ app.register_blueprint(self._create_blueprint())
+
+ def _load_config(self, client_secrets_file, client_id, client_secret):
+ """Loads oauth2 configuration in order of priority.
+
+ Priority:
+ 1. Config passed to the constructor or init_app.
+ 2. Config passed via the GOOGLE_OAUTH2_CLIENT_SECRETS_FILE app
+ config.
+ 3. Config passed via the GOOGLE_OAUTH2_CLIENT_ID and
+ GOOGLE_OAUTH2_CLIENT_SECRET app config.
+
+ Raises:
+ ValueError if no config could be found.
+ """
+ if client_id and client_secret:
+ self.client_id, self.client_secret = client_id, client_secret
+ return
+
+ if client_secrets_file:
+ self._load_client_secrets(client_secrets_file)
+ return
+
+ if 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE' in self.app.config:
+ self._load_client_secrets(
+ self.app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE'])
+ return
+
+ try:
+ self.client_id, self.client_secret = (
+ self.app.config['GOOGLE_OAUTH2_CLIENT_ID'],
+ self.app.config['GOOGLE_OAUTH2_CLIENT_SECRET'])
+ except KeyError:
+ raise ValueError(
+ 'OAuth2 configuration could not be found. Either specify the '
+ 'client_secrets_file or client_id and client_secret or set '
+ 'the app configuration variables '
+ 'GOOGLE_OAUTH2_CLIENT_SECRETS_FILE or '
+ 'GOOGLE_OAUTH2_CLIENT_ID and GOOGLE_OAUTH2_CLIENT_SECRET.')
+
+ def _load_client_secrets(self, filename):
+ """Loads client secrets from the given filename."""
+ client_type, client_info = clientsecrets.loadfile(filename)
+ if client_type != clientsecrets.TYPE_WEB:
+ raise ValueError(
+ 'The flow specified in {0} is not supported.'.format(
+ client_type))
+
+ self.client_id = client_info['client_id']
+ self.client_secret = client_info['client_secret']
+
+ def _make_flow(self, return_url=None, **kwargs):
+ """Creates a Web Server Flow"""
+ # Generate a CSRF token to prevent malicious requests.
+ csrf_token = hashlib.sha256(os.urandom(1024)).hexdigest()
+
+ session[_CSRF_KEY] = csrf_token
+
+ state = json.dumps({
+ 'csrf_token': csrf_token,
+ 'return_url': return_url
+ })
+
+ kw = self.flow_kwargs.copy()
+ kw.update(kwargs)
+
+ extra_scopes = kw.pop('scopes', [])
+ scopes = set(self.scopes).union(set(extra_scopes))
+
+ flow = client.OAuth2WebServerFlow(
+ client_id=self.client_id,
+ client_secret=self.client_secret,
+ scope=scopes,
+ state=state,
+ redirect_uri=url_for('oauth2.callback', _external=True),
+ **kw)
+
+ flow_key = _FLOW_KEY.format(csrf_token)
+ session[flow_key] = pickle.dumps(flow)
+
+ return flow
+
+ def _create_blueprint(self):
+ bp = Blueprint('oauth2', __name__)
+ bp.add_url_rule('/oauth2authorize', 'authorize', self.authorize_view)
+ bp.add_url_rule('/oauth2callback', 'callback', self.callback_view)
+
+ return bp
+
+ def authorize_view(self):
+ """Flask view that starts the authorization flow.
+
+ Starts flow by redirecting the user to the OAuth2 provider.
+ """
+ args = request.args.to_dict()
+
+ # Scopes will be passed as mutliple args, and to_dict() will only
+ # return one. So, we use getlist() to get all of the scopes.
+ args['scopes'] = request.args.getlist('scopes')
+
+ return_url = args.pop('return_url', None)
+ if return_url is None:
+ return_url = request.referrer or '/'
+
+ flow = self._make_flow(return_url=return_url, **args)
+ auth_url = flow.step1_get_authorize_url()
+
+ return redirect(auth_url)
+
+ def callback_view(self):
+ """Flask view that handles the user's return from OAuth2 provider.
+
+ On return, exchanges the authorization code for credentials and stores
+ the credentials.
+ """
+ if 'error' in request.args:
+ reason = request.args.get(
+ 'error_description', request.args.get('error', ''))
+ return ('Authorization failed: {0}'.format(reason),
+ httplib.BAD_REQUEST)
+
+ try:
+ encoded_state = request.args['state']
+ server_csrf = session[_CSRF_KEY]
+ code = request.args['code']
+ except KeyError:
+ return 'Invalid request', httplib.BAD_REQUEST
+
+ try:
+ state = json.loads(encoded_state)
+ client_csrf = state['csrf_token']
+ return_url = state['return_url']
+ except (ValueError, KeyError):
+ return 'Invalid request state', httplib.BAD_REQUEST
+
+ if client_csrf != server_csrf:
+ return 'Invalid request state', httplib.BAD_REQUEST
+
+ flow = _get_flow_for_token(server_csrf)
+
+ if flow is None:
+ return 'Invalid request state', httplib.BAD_REQUEST
+
+ # Exchange the auth code for credentials.
+ try:
+ credentials = flow.step2_exchange(code)
+ except client.FlowExchangeError as exchange_error:
+ current_app.logger.exception(exchange_error)
+ content = 'An error occurred: {0}'.format(exchange_error)
+ return content, httplib.BAD_REQUEST
+
+ # Save the credentials to the storage.
+ self.storage.put(credentials)
+
+ if self.authorize_callback:
+ self.authorize_callback(credentials)
+
+ return redirect(return_url)
+
+ @property
+ def credentials(self):
+ """The credentials for the current user or None if unavailable."""
+ ctx = _app_ctx_stack.top
+
+ if not hasattr(ctx, _CREDENTIALS_KEY):
+ ctx.google_oauth2_credentials = self.storage.get()
+
+ return ctx.google_oauth2_credentials
+
+ def has_credentials(self):
+ """Returns True if there are valid credentials for the current user."""
+ if not self.credentials:
+ return False
+ # Is the access token expired? If so, do we have an refresh token?
+ elif (self.credentials.access_token_expired and
+ not self.credentials.refresh_token):
+ return False
+ else:
+ return True
+
+ @property
+ def email(self):
+ """Returns the user's email address or None if there are no credentials.
+
+ The email address is provided by the current credentials' id_token.
+ This should not be used as unique identifier as the user can change
+ their email. If you need a unique identifier, use user_id.
+ """
+ if not self.credentials:
+ return None
+ try:
+ return self.credentials.id_token['email']
+ except KeyError:
+ current_app.logger.error(
+ 'Invalid id_token {0}'.format(self.credentials.id_token))
+
+ @property
+ def user_id(self):
+ """Returns the a unique identifier for the user
+
+ Returns None if there are no credentials.
+
+ The id is provided by the current credentials' id_token.
+ """
+ if not self.credentials:
+ return None
+ try:
+ return self.credentials.id_token['sub']
+ except KeyError:
+ current_app.logger.error(
+ 'Invalid id_token {0}'.format(self.credentials.id_token))
+
+ def authorize_url(self, return_url, **kwargs):
+ """Creates a URL that can be used to start the authorization flow.
+
+ When the user is directed to the URL, the authorization flow will
+ begin. Once complete, the user will be redirected to the specified
+ return URL.
+
+ Any kwargs are passed into the flow constructor.
+ """
+ return url_for('oauth2.authorize', return_url=return_url, **kwargs)
+
+ def required(self, decorated_function=None, scopes=None,
+ **decorator_kwargs):
+ """Decorator to require OAuth2 credentials for a view.
+
+ If credentials are not available for the current user, then they will
+ be redirected to the authorization flow. Once complete, the user will
+ be redirected back to the original page.
+ """
+
+ def curry_wrapper(wrapped_function):
+ @wraps(wrapped_function)
+ def required_wrapper(*args, **kwargs):
+ return_url = decorator_kwargs.pop('return_url', request.url)
+
+ requested_scopes = set(self.scopes)
+ if scopes is not None:
+ requested_scopes |= set(scopes)
+ if self.has_credentials():
+ requested_scopes |= self.credentials.scopes
+
+ requested_scopes = list(requested_scopes)
+
+ # Does the user have credentials and does the credentials have
+ # all of the needed scopes?
+ if (self.has_credentials() and
+ self.credentials.has_scopes(requested_scopes)):
+ return wrapped_function(*args, **kwargs)
+ # Otherwise, redirect to authorization
+ else:
+ auth_url = self.authorize_url(
+ return_url,
+ scopes=requested_scopes,
+ **decorator_kwargs)
+
+ return redirect(auth_url)
+
+ return required_wrapper
+
+ if decorated_function:
+ return curry_wrapper(decorated_function)
+ else:
+ return curry_wrapper
+
+ def http(self, *args, **kwargs):
+ """Returns an authorized http instance.
+
+ Can only be called if there are valid credentials for the user, such
+ as inside of a view that is decorated with @required.
+
+ Args:
+ *args: Positional arguments passed to httplib2.Http constructor.
+ **kwargs: Positional arguments passed to httplib2.Http constructor.
+
+ Raises:
+ ValueError if no credentials are available.
+ """
+ if not self.credentials:
+ raise ValueError('No credentials available.')
+ return self.credentials.authorize(httplib2.Http(*args, **kwargs))
diff --git a/oauth2client/contrib/gce.py b/oauth2client/contrib/gce.py
new file mode 100644
index 0000000..f3a6ca1
--- /dev/null
+++ b/oauth2client/contrib/gce.py
@@ -0,0 +1,162 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for Google Compute Engine
+
+Utilities for making it easier to use OAuth 2.0 on Google Compute Engine.
+"""
+
+import logging
+import warnings
+
+import httplib2
+
+from oauth2client import client
+from oauth2client.contrib import _metadata
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+logger = logging.getLogger(__name__)
+
+_SCOPES_WARNING = """\
+You have requested explicit scopes to be used with a GCE service account.
+Using this argument will have no effect on the actual scopes for tokens
+requested. These scopes are set at VM instance creation time and
+can't be overridden in the request.
+"""
+
+
+class AppAssertionCredentials(client.AssertionCredentials):
+ """Credentials object for Compute Engine Assertion Grants
+
+ This object will allow a Compute Engine instance to identify itself to
+ Google and other OAuth 2.0 servers that can verify assertions. It can be
+ used for the purpose of accessing data stored under an account assigned to
+ the Compute Engine instance itself.
+
+ This credential does not require a flow to instantiate because it
+ represents a two legged flow, and therefore has all of the required
+ information to generate and refresh its own access tokens.
+
+ Note that :attr:`service_account_email` and :attr:`scopes`
+ will both return None until the credentials have been refreshed.
+ To check whether credentials have previously been refreshed use
+ :attr:`invalid`.
+ """
+
+ def __init__(self, email=None, *args, **kwargs):
+ """Constructor for AppAssertionCredentials
+
+ Args:
+ email: an email that specifies the service account to use.
+ Only necessary if using custom service accounts
+ (see https://cloud.google.com/compute/docs/access/create-enable-service-accounts-for-instances#createdefaultserviceaccount).
+ """
+ if 'scopes' in kwargs:
+ warnings.warn(_SCOPES_WARNING)
+ kwargs['scopes'] = None
+
+ # Assertion type is no longer used, but still in the
+ # parent class signature.
+ super(AppAssertionCredentials, self).__init__(None, *args, **kwargs)
+
+ self.service_account_email = email
+ self.scopes = None
+ self.invalid = True
+
+ @classmethod
+ def from_json(cls, json_data):
+ raise NotImplementedError(
+ 'Cannot serialize credentials for GCE service accounts.')
+
+ def to_json(self):
+ raise NotImplementedError(
+ 'Cannot serialize credentials for GCE service accounts.')
+
+ def retrieve_scopes(self, http):
+ """Retrieves the canonical list of scopes for this access token.
+
+ Overrides client.Credentials.retrieve_scopes. Fetches scopes info
+ from the metadata server.
+
+ Args:
+ http: httplib2.Http, an http object to be used to make the refresh
+ request.
+
+ Returns:
+ A set of strings containing the canonical list of scopes.
+ """
+ self._retrieve_info(http.request)
+ return self.scopes
+
+ def _retrieve_info(self, http_request):
+ """Validates invalid service accounts by retrieving service account info.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make the
+ request to the metadata server
+ """
+ if self.invalid:
+ info = _metadata.get_service_account_info(
+ http_request,
+ service_account=self.service_account_email or 'default')
+ self.invalid = False
+ self.service_account_email = info['email']
+ self.scopes = info['scopes']
+
+ def _refresh(self, http_request):
+ """Refreshes the access_token.
+
+ Skip all the storage hoops and just refresh using the API.
+
+ Args:
+ http_request: callable, a callable that matches the method
+ signature of httplib2.Http.request, used to make
+ the refresh request.
+
+ Raises:
+ HttpAccessTokenRefreshError: When the refresh fails.
+ """
+ try:
+ self._retrieve_info(http_request)
+ self.access_token, self.token_expiry = _metadata.get_token(
+ http_request, service_account=self.service_account_email)
+ except httplib2.HttpLib2Error as e:
+ raise client.HttpAccessTokenRefreshError(str(e))
+
+ @property
+ def serialization_data(self):
+ raise NotImplementedError(
+ 'Cannot serialize credentials for GCE service accounts.')
+
+ def create_scoped_required(self):
+ return False
+
+ def sign_blob(self, blob):
+ """Cryptographically sign a blob (of bytes).
+
+ This method is provided to support a common interface, but
+ the actual key used for a Google Compute Engine service account
+ is not available, so it can't be used to sign content.
+
+ Args:
+ blob: bytes, Message to be signed.
+
+ Raises:
+ NotImplementedError, always.
+ """
+ raise NotImplementedError(
+ 'Compute Engine service accounts cannot sign blobs')
diff --git a/oauth2client/contrib/keyring_storage.py b/oauth2client/contrib/keyring_storage.py
new file mode 100644
index 0000000..f4f2e30
--- /dev/null
+++ b/oauth2client/contrib/keyring_storage.py
@@ -0,0 +1,98 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""A keyring based Storage.
+
+A Storage for Credentials that uses the keyring module.
+"""
+
+import threading
+
+import keyring
+
+from oauth2client import client
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+class Storage(client.Storage):
+ """Store and retrieve a single credential to and from the keyring.
+
+ To use this module you must have the keyring module installed. See
+ <http://pypi.python.org/pypi/keyring/>. This is an optional module and is
+ not installed with oauth2client by default because it does not work on all
+ the platforms that oauth2client supports, such as Google App Engine.
+
+ The keyring module <http://pypi.python.org/pypi/keyring/> is a
+ cross-platform library for access the keyring capabilities of the local
+ system. The user will be prompted for their keyring password when this
+ module is used, and the manner in which the user is prompted will vary per
+ platform.
+
+ Usage::
+
+ from oauth2client import keyring_storage
+
+ s = keyring_storage.Storage('name_of_application', 'user1')
+ credentials = s.get()
+
+ """
+
+ def __init__(self, service_name, user_name):
+ """Constructor.
+
+ Args:
+ service_name: string, The name of the service under which the
+ credentials are stored.
+ user_name: string, The name of the user to store credentials for.
+ """
+ super(Storage, self).__init__(lock=threading.Lock())
+ self._service_name = service_name
+ self._user_name = user_name
+
+ def locked_get(self):
+ """Retrieve Credential from file.
+
+ Returns:
+ oauth2client.client.Credentials
+ """
+ credentials = None
+ content = keyring.get_password(self._service_name, self._user_name)
+
+ if content is not None:
+ try:
+ credentials = client.Credentials.new_from_json(content)
+ credentials.set_store(self)
+ except ValueError:
+ pass
+
+ return credentials
+
+ def locked_put(self, credentials):
+ """Write Credentials to file.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ keyring.set_password(self._service_name, self._user_name,
+ credentials.to_json())
+
+ def locked_delete(self):
+ """Delete Credentials file.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ keyring.set_password(self._service_name, self._user_name, '')
diff --git a/oauth2client/contrib/locked_file.py b/oauth2client/contrib/locked_file.py
new file mode 100644
index 0000000..0d28ebb
--- /dev/null
+++ b/oauth2client/contrib/locked_file.py
@@ -0,0 +1,234 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Locked file interface that should work on Unix and Windows pythons.
+
+This module first tries to use fcntl locking to ensure serialized access
+to a file, then falls back on a lock file if that is unavialable.
+
+Usage::
+
+ f = LockedFile('filename', 'r+b', 'rb')
+ f.open_and_lock()
+ if f.is_locked():
+ print('Acquired filename with r+b mode')
+ f.file_handle().write('locked data')
+ else:
+ print('Acquired filename with rb mode')
+ f.unlock_and_close()
+
+"""
+
+from __future__ import print_function
+
+import errno
+import logging
+import os
+import time
+
+from oauth2client import util
+
+
+__author__ = 'cache@google.com (David T McWherter)'
+
+logger = logging.getLogger(__name__)
+
+
+class CredentialsFileSymbolicLinkError(Exception):
+ """Credentials files must not be symbolic links."""
+
+
+class AlreadyLockedException(Exception):
+ """Trying to lock a file that has already been locked by the LockedFile."""
+ pass
+
+
+def validate_file(filename):
+ if os.path.islink(filename):
+ raise CredentialsFileSymbolicLinkError(
+ 'File: {0} is a symbolic link.'.format(filename))
+
+
+class _Opener(object):
+ """Base class for different locking primitives."""
+
+ def __init__(self, filename, mode, fallback_mode):
+ """Create an Opener.
+
+ Args:
+ filename: string, The pathname of the file.
+ mode: string, The preferred mode to access the file with.
+ fallback_mode: string, The mode to use if locking fails.
+ """
+ self._locked = False
+ self._filename = filename
+ self._mode = mode
+ self._fallback_mode = fallback_mode
+ self._fh = None
+ self._lock_fd = None
+
+ def is_locked(self):
+ """Was the file locked."""
+ return self._locked
+
+ def file_handle(self):
+ """The file handle to the file. Valid only after opened."""
+ return self._fh
+
+ def filename(self):
+ """The filename that is being locked."""
+ return self._filename
+
+ def open_and_lock(self, timeout, delay):
+ """Open the file and lock it.
+
+ Args:
+ timeout: float, How long to try to lock for.
+ delay: float, How long to wait between retries.
+ """
+ pass
+
+ def unlock_and_close(self):
+ """Unlock and close the file."""
+ pass
+
+
+class _PosixOpener(_Opener):
+ """Lock files using Posix advisory lock files."""
+
+ def open_and_lock(self, timeout, delay):
+ """Open the file and lock it.
+
+ Tries to create a .lock file next to the file we're trying to open.
+
+ Args:
+ timeout: float, How long to try to lock for.
+ delay: float, How long to wait between retries.
+
+ Raises:
+ AlreadyLockedException: if the lock is already acquired.
+ IOError: if the open fails.
+ CredentialsFileSymbolicLinkError if the file is a symbolic link.
+ """
+ if self._locked:
+ raise AlreadyLockedException(
+ 'File {0} is already locked'.format(self._filename))
+ self._locked = False
+
+ validate_file(self._filename)
+ try:
+ self._fh = open(self._filename, self._mode)
+ except IOError as e:
+ # If we can't access with _mode, try _fallback_mode and don't lock.
+ if e.errno == errno.EACCES:
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+
+ lock_filename = self._posix_lockfile(self._filename)
+ start_time = time.time()
+ while True:
+ try:
+ self._lock_fd = os.open(lock_filename,
+ os.O_CREAT | os.O_EXCL | os.O_RDWR)
+ self._locked = True
+ break
+
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ raise
+ if (time.time() - start_time) >= timeout:
+ logger.warn('Could not acquire lock %s in %s seconds',
+ lock_filename, timeout)
+ # Close the file and open in fallback_mode.
+ if self._fh:
+ self._fh.close()
+ self._fh = open(self._filename, self._fallback_mode)
+ return
+ time.sleep(delay)
+
+ def unlock_and_close(self):
+ """Unlock a file by removing the .lock file, and close the handle."""
+ if self._locked:
+ lock_filename = self._posix_lockfile(self._filename)
+ os.close(self._lock_fd)
+ os.unlink(lock_filename)
+ self._locked = False
+ self._lock_fd = None
+ if self._fh:
+ self._fh.close()
+
+ def _posix_lockfile(self, filename):
+ """The name of the lock file to use for posix locking."""
+ return '{0}.lock'.format(filename)
+
+
+class LockedFile(object):
+ """Represent a file that has exclusive access."""
+
+ @util.positional(4)
+ def __init__(self, filename, mode, fallback_mode, use_native_locking=True):
+ """Construct a LockedFile.
+
+ Args:
+ filename: string, The path of the file to open.
+ mode: string, The mode to try to open the file with.
+ fallback_mode: string, The mode to use if locking fails.
+ use_native_locking: bool, Whether or not fcntl/win32 locking is
+ used.
+ """
+ opener = None
+ if not opener and use_native_locking:
+ try:
+ from oauth2client.contrib._win32_opener import _Win32Opener
+ opener = _Win32Opener(filename, mode, fallback_mode)
+ except ImportError:
+ try:
+ from oauth2client.contrib._fcntl_opener import _FcntlOpener
+ opener = _FcntlOpener(filename, mode, fallback_mode)
+ except ImportError:
+ pass
+
+ if not opener:
+ opener = _PosixOpener(filename, mode, fallback_mode)
+
+ self._opener = opener
+
+ def filename(self):
+ """Return the filename we were constructed with."""
+ return self._opener._filename
+
+ def file_handle(self):
+ """Return the file_handle to the opened file."""
+ return self._opener.file_handle()
+
+ def is_locked(self):
+ """Return whether we successfully locked the file."""
+ return self._opener.is_locked()
+
+ def open_and_lock(self, timeout=0, delay=0.05):
+ """Open the file, trying to lock it.
+
+ Args:
+ timeout: float, The number of seconds to try to acquire the lock.
+ delay: float, The number of seconds to wait between retry attempts.
+
+ Raises:
+ AlreadyLockedException: if the lock is already acquired.
+ IOError: if the open fails.
+ """
+ self._opener.open_and_lock(timeout, delay)
+
+ def unlock_and_close(self):
+ """Unlock and close a file."""
+ self._opener.unlock_and_close()
diff --git a/oauth2client/contrib/multiprocess_file_storage.py b/oauth2client/contrib/multiprocess_file_storage.py
new file mode 100644
index 0000000..e9e8c8c
--- /dev/null
+++ b/oauth2client/contrib/multiprocess_file_storage.py
@@ -0,0 +1,355 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Multiprocess file credential storage.
+
+This module provides file-based storage that supports multiple credentials and
+cross-thread and process access.
+
+This module supersedes the functionality previously found in `multistore_file`.
+
+This module provides :class:`MultiprocessFileStorage` which:
+ * Is tied to a single credential via a user-specified key. This key can be
+ used to distinguish between multiple users, client ids, and/or scopes.
+ * Can be safely accessed and refreshed across threads and processes.
+
+Process & thread safety guarantees the following behavior:
+ * If one thread or process refreshes a credential, subsequent refreshes
+ from other processes will re-fetch the credentials from the file instead
+ of performing an http request.
+ * If two processes or threads attempt to refresh concurrently, only one
+ will be able to acquire the lock and refresh, with the deadlock caveat
+ below.
+ * The interprocess lock will not deadlock, instead, the if a process can
+ not acquire the interprocess lock within ``INTERPROCESS_LOCK_DEADLINE``
+ it will allow refreshing the credential but will not write the updated
+ credential to disk, This logic happens during every lock cycle - if the
+ credentials are refreshed again it will retry locking and writing as
+ normal.
+
+Usage
+=====
+
+Before using the storage, you need to decide how you want to key the
+credentials. A few common strategies include:
+
+ * If you're storing credentials for multiple users in a single file, use
+ a unique identifier for each user as the key.
+ * If you're storing credentials for multiple client IDs in a single file,
+ use the client ID as the key.
+ * If you're storing multiple credentials for one user, use the scopes as
+ the key.
+ * If you have a complicated setup, use a compound key. For example, you
+ can use a combination of the client ID and scopes as the key.
+
+Create an instance of :class:`MultiprocessFileStorage` for each credential you
+want to store, for example::
+
+ filename = 'credentials'
+ key = '{}-{}'.format(client_id, user_id)
+ storage = MultiprocessFileStorage(filename, key)
+
+To store the credentials::
+
+ storage.put(credentials)
+
+If you're going to continue to use the credentials after storing them, be sure
+to call :func:`set_store`::
+
+ credentials.set_store(storage)
+
+To retrieve the credentials::
+
+ storage.get(credentials)
+
+"""
+
+import base64
+import json
+import logging
+import os
+import threading
+
+import fasteners
+from six import iteritems
+
+from oauth2client import _helpers
+from oauth2client import client
+
+
+#: The maximum amount of time, in seconds, to wait when acquire the
+#: interprocess lock before falling back to read-only mode.
+INTERPROCESS_LOCK_DEADLINE = 1
+
+logger = logging.getLogger(__name__)
+_backends = {}
+_backends_lock = threading.Lock()
+
+
+def _create_file_if_needed(filename):
+ """Creates the an empty file if it does not already exist.
+
+ Returns:
+ True if the file was created, False otherwise.
+ """
+ if os.path.exists(filename):
+ return False
+ else:
+ # Equivalent to "touch".
+ open(filename, 'a+b').close()
+ logger.info('Credential file {0} created'.format(filename))
+ return True
+
+
+def _load_credentials_file(credentials_file):
+ """Load credentials from the given file handle.
+
+ The file is expected to be in this format:
+
+ {
+ "file_version": 2,
+ "credentials": {
+ "key": "base64 encoded json representation of credentials."
+ }
+ }
+
+ This function will warn and return empty credentials instead of raising
+ exceptions.
+
+ Args:
+ credentials_file: An open file handle.
+
+ Returns:
+ A dictionary mapping user-defined keys to an instance of
+ :class:`oauth2client.client.Credentials`.
+ """
+ try:
+ credentials_file.seek(0)
+ data = json.load(credentials_file)
+ except Exception:
+ logger.warning(
+ 'Credentials file could not be loaded, will ignore and '
+ 'overwrite.')
+ return {}
+
+ if data.get('file_version') != 2:
+ logger.warning(
+ 'Credentials file is not version 2, will ignore and '
+ 'overwrite.')
+ return {}
+
+ credentials = {}
+
+ for key, encoded_credential in iteritems(data.get('credentials', {})):
+ try:
+ credential_json = base64.b64decode(encoded_credential)
+ credential = client.Credentials.new_from_json(credential_json)
+ credentials[key] = credential
+ except:
+ logger.warning(
+ 'Invalid credential {0} in file, ignoring.'.format(key))
+
+ return credentials
+
+
+def _write_credentials_file(credentials_file, credentials):
+ """Writes credentials to a file.
+
+ Refer to :func:`_load_credentials_file` for the format.
+
+ Args:
+ credentials_file: An open file handle, must be read/write.
+ credentials: A dictionary mapping user-defined keys to an instance of
+ :class:`oauth2client.client.Credentials`.
+ """
+ data = {'file_version': 2, 'credentials': {}}
+
+ for key, credential in iteritems(credentials):
+ credential_json = credential.to_json()
+ encoded_credential = _helpers._from_bytes(base64.b64encode(
+ _helpers._to_bytes(credential_json)))
+ data['credentials'][key] = encoded_credential
+
+ credentials_file.seek(0)
+ json.dump(data, credentials_file)
+ credentials_file.truncate()
+
+
+class _MultiprocessStorageBackend(object):
+ """Thread-local backend for multiprocess storage.
+
+ Each process has only one instance of this backend per file. All threads
+ share a single instance of this backend. This ensures that all threads
+ use the same thread lock and process lock when accessing the file.
+ """
+
+ def __init__(self, filename):
+ self._file = None
+ self._filename = filename
+ self._process_lock = fasteners.InterProcessLock(
+ '{0}.lock'.format(filename))
+ self._thread_lock = threading.Lock()
+ self._read_only = False
+ self._credentials = {}
+
+ def _load_credentials(self):
+ """(Re-)loads the credentials from the file."""
+ if not self._file:
+ return
+
+ loaded_credentials = _load_credentials_file(self._file)
+ self._credentials.update(loaded_credentials)
+
+ logger.debug('Read credential file')
+
+ def _write_credentials(self):
+ if self._read_only:
+ logger.debug('In read-only mode, not writing credentials.')
+ return
+
+ _write_credentials_file(self._file, self._credentials)
+ logger.debug('Wrote credential file {0}.'.format(self._filename))
+
+ def acquire_lock(self):
+ self._thread_lock.acquire()
+ locked = self._process_lock.acquire(timeout=INTERPROCESS_LOCK_DEADLINE)
+
+ if locked:
+ _create_file_if_needed(self._filename)
+ self._file = open(self._filename, 'r+')
+ self._read_only = False
+
+ else:
+ logger.warn(
+ 'Failed to obtain interprocess lock for credentials. '
+ 'If a credential is being refreshed, other processes may '
+ 'not see the updated access token and refresh as well.')
+ if os.path.exists(self._filename):
+ self._file = open(self._filename, 'r')
+ else:
+ self._file = None
+ self._read_only = True
+
+ self._load_credentials()
+
+ def release_lock(self):
+ if self._file is not None:
+ self._file.close()
+ self._file = None
+
+ if not self._read_only:
+ self._process_lock.release()
+
+ self._thread_lock.release()
+
+ def _refresh_predicate(self, credentials):
+ if credentials is None:
+ return True
+ elif credentials.invalid:
+ return True
+ elif credentials.access_token_expired:
+ return True
+ else:
+ return False
+
+ def locked_get(self, key):
+ # Check if the credential is already in memory.
+ credentials = self._credentials.get(key, None)
+
+ # Use the refresh predicate to determine if the entire store should be
+ # reloaded. This basically checks if the credentials are invalid
+ # or expired. This covers the situation where another process has
+ # refreshed the credentials and this process doesn't know about it yet.
+ # In that case, this process won't needlessly refresh the credentials.
+ if self._refresh_predicate(credentials):
+ self._load_credentials()
+ credentials = self._credentials.get(key, None)
+
+ return credentials
+
+ def locked_put(self, key, credentials):
+ self._load_credentials()
+ self._credentials[key] = credentials
+ self._write_credentials()
+
+ def locked_delete(self, key):
+ self._load_credentials()
+ self._credentials.pop(key, None)
+ self._write_credentials()
+
+
+def _get_backend(filename):
+ """A helper method to get or create a backend with thread locking.
+
+ This ensures that only one backend is used per-file per-process, so that
+ thread and process locks are appropriately shared.
+
+ Args:
+ filename: The full path to the credential storage file.
+
+ Returns:
+ An instance of :class:`_MultiprocessStorageBackend`.
+ """
+ filename = os.path.abspath(filename)
+
+ with _backends_lock:
+ if filename not in _backends:
+ _backends[filename] = _MultiprocessStorageBackend(filename)
+ return _backends[filename]
+
+
+class MultiprocessFileStorage(client.Storage):
+ """Multiprocess file credential storage.
+
+ Args:
+ filename: The path to the file where credentials will be stored.
+ key: An arbitrary string used to uniquely identify this set of
+ credentials. For example, you may use the user's ID as the key or
+ a combination of the client ID and user ID.
+ """
+ def __init__(self, filename, key):
+ self._key = key
+ self._backend = _get_backend(filename)
+
+ def acquire_lock(self):
+ self._backend.acquire_lock()
+
+ def release_lock(self):
+ self._backend.release_lock()
+
+ def locked_get(self):
+ """Retrieves the current credentials from the store.
+
+ Returns:
+ An instance of :class:`oauth2client.client.Credentials` or `None`.
+ """
+ credential = self._backend.locked_get(self._key)
+
+ if credential is not None:
+ credential.set_store(self)
+
+ return credential
+
+ def locked_put(self, credentials):
+ """Writes the given credentials to the store.
+
+ Args:
+ credentials: an instance of
+ :class:`oauth2client.client.Credentials`.
+ """
+ return self._backend.locked_put(self._key, credentials)
+
+ def locked_delete(self):
+ """Deletes the current credentials from the store."""
+ return self._backend.locked_delete(self._key)
diff --git a/oauth2client/contrib/multistore_file.py b/oauth2client/contrib/multistore_file.py
new file mode 100644
index 0000000..10f4cb4
--- /dev/null
+++ b/oauth2client/contrib/multistore_file.py
@@ -0,0 +1,505 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Multi-credential file store with lock support.
+
+This module implements a JSON credential store where multiple
+credentials can be stored in one file. That file supports locking
+both in a single process and across processes.
+
+The credential themselves are keyed off of:
+
+* client_id
+* user_agent
+* scope
+
+The format of the stored data is like so::
+
+ {
+ 'file_version': 1,
+ 'data': [
+ {
+ 'key': {
+ 'clientId': '<client id>',
+ 'userAgent': '<user agent>',
+ 'scope': '<scope>'
+ },
+ 'credential': {
+ # JSON serialized Credentials.
+ }
+ }
+ ]
+ }
+
+"""
+
+import errno
+import json
+import logging
+import os
+import threading
+
+from oauth2client import client
+from oauth2client import util
+from oauth2client.contrib import locked_file
+
+__author__ = 'jbeda@google.com (Joe Beda)'
+
+logger = logging.getLogger(__name__)
+
+logger.warning(
+ 'The oauth2client.contrib.multistore_file module has been deprecated and '
+ 'will be removed in the next release of oauth2client. Please migrate to '
+ 'multiprocess_file_storage.')
+
+# A dict from 'filename'->_MultiStore instances
+_multistores = {}
+_multistores_lock = threading.Lock()
+
+
+class Error(Exception):
+ """Base error for this module."""
+
+
+class NewerCredentialStoreError(Error):
+ """The credential store is a newer version than supported."""
+
+
+def _dict_to_tuple_key(dictionary):
+ """Converts a dictionary to a tuple that can be used as an immutable key.
+
+ The resulting key is always sorted so that logically equivalent
+ dictionaries always produce an identical tuple for a key.
+
+ Args:
+ dictionary: the dictionary to use as the key.
+
+ Returns:
+ A tuple representing the dictionary in it's naturally sorted ordering.
+ """
+ return tuple(sorted(dictionary.items()))
+
+
+@util.positional(4)
+def get_credential_storage(filename, client_id, user_agent, scope,
+ warn_on_readonly=True):
+ """Get a Storage instance for a credential.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ client_id: The client_id for the credential
+ user_agent: The user agent for the credential
+ scope: string or iterable of strings, Scope(s) being requested
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ An object derived from client.Storage for getting/setting the
+ credential.
+ """
+ # Recreate the legacy key with these specific parameters
+ key = {'clientId': client_id, 'userAgent': user_agent,
+ 'scope': util.scopes_to_string(scope)}
+ return get_credential_storage_custom_key(
+ filename, key, warn_on_readonly=warn_on_readonly)
+
+
+@util.positional(2)
+def get_credential_storage_custom_string_key(filename, key_string,
+ warn_on_readonly=True):
+ """Get a Storage instance for a credential using a single string as a key.
+
+ Allows you to provide a string as a custom key that will be used for
+ credential storage and retrieval.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ key_string: A string to use as the key for storing this credential.
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ An object derived from client.Storage for getting/setting the
+ credential.
+ """
+ # Create a key dictionary that can be used
+ key_dict = {'key': key_string}
+ return get_credential_storage_custom_key(
+ filename, key_dict, warn_on_readonly=warn_on_readonly)
+
+
+@util.positional(2)
+def get_credential_storage_custom_key(filename, key_dict,
+ warn_on_readonly=True):
+ """Get a Storage instance for a credential using a dictionary as a key.
+
+ Allows you to provide a dictionary as a custom key that will be used for
+ credential storage and retrieval.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ key_dict: A dictionary to use as the key for storing this credential.
+ There is no ordering of the keys in the dictionary. Logically
+ equivalent dictionaries will produce equivalent storage keys.
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ An object derived from client.Storage for getting/setting the
+ credential.
+ """
+ multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
+ key = _dict_to_tuple_key(key_dict)
+ return multistore._get_storage(key)
+
+
+@util.positional(1)
+def get_all_credential_keys(filename, warn_on_readonly=True):
+ """Gets all the registered credential keys in the given Multistore.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ A list of the credential keys present in the file. They are returned
+ as dictionaries that can be passed into
+ get_credential_storage_custom_key to get the actual credentials.
+ """
+ multistore = _get_multistore(filename, warn_on_readonly=warn_on_readonly)
+ multistore._lock()
+ try:
+ return multistore._get_all_credential_keys()
+ finally:
+ multistore._unlock()
+
+
+@util.positional(1)
+def _get_multistore(filename, warn_on_readonly=True):
+ """A helper method to initialize the multistore with proper locking.
+
+ Args:
+ filename: The JSON file storing a set of credentials
+ warn_on_readonly: if True, log a warning if the store is readonly
+
+ Returns:
+ A multistore object
+ """
+ filename = os.path.expanduser(filename)
+ _multistores_lock.acquire()
+ try:
+ multistore = _multistores.setdefault(
+ filename, _MultiStore(filename, warn_on_readonly=warn_on_readonly))
+ finally:
+ _multistores_lock.release()
+ return multistore
+
+
+class _MultiStore(object):
+ """A file backed store for multiple credentials."""
+
+ @util.positional(2)
+ def __init__(self, filename, warn_on_readonly=True):
+ """Initialize the class.
+
+ This will create the file if necessary.
+ """
+ self._file = locked_file.LockedFile(filename, 'r+', 'r')
+ self._thread_lock = threading.Lock()
+ self._read_only = False
+ self._warn_on_readonly = warn_on_readonly
+
+ self._create_file_if_needed()
+
+ # Cache of deserialized store. This is only valid after the
+ # _MultiStore is locked or _refresh_data_cache is called. This is
+ # of the form of:
+ #
+ # ((key, value), (key, value)...) -> OAuth2Credential
+ #
+ # If this is None, then the store hasn't been read yet.
+ self._data = None
+
+ class _Storage(client.Storage):
+ """A Storage object that can read/write a single credential."""
+
+ def __init__(self, multistore, key):
+ self._multistore = multistore
+ self._key = key
+
+ def acquire_lock(self):
+ """Acquires any lock necessary to access this Storage.
+
+ This lock is not reentrant.
+ """
+ self._multistore._lock()
+
+ def release_lock(self):
+ """Release the Storage lock.
+
+ Trying to release a lock that isn't held will result in a
+ RuntimeError.
+ """
+ self._multistore._unlock()
+
+ def locked_get(self):
+ """Retrieve credential.
+
+ The Storage lock must be held when this is called.
+
+ Returns:
+ oauth2client.client.Credentials
+ """
+ credential = self._multistore._get_credential(self._key)
+ if credential:
+ credential.set_store(self)
+ return credential
+
+ def locked_put(self, credentials):
+ """Write a credential.
+
+ The Storage lock must be held when this is called.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ self._multistore._update_credential(self._key, credentials)
+
+ def locked_delete(self):
+ """Delete a credential.
+
+ The Storage lock must be held when this is called.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ self._multistore._delete_credential(self._key)
+
+ def _create_file_if_needed(self):
+ """Create an empty file if necessary.
+
+ This method will not initialize the file. Instead it implements a
+ simple version of "touch" to ensure the file has been created.
+ """
+ if not os.path.exists(self._file.filename()):
+ old_umask = os.umask(0o177)
+ try:
+ open(self._file.filename(), 'a+b').close()
+ finally:
+ os.umask(old_umask)
+
+ def _lock(self):
+ """Lock the entire multistore."""
+ self._thread_lock.acquire()
+ try:
+ self._file.open_and_lock()
+ except (IOError, OSError) as e:
+ if e.errno == errno.ENOSYS:
+ logger.warn('File system does not support locking the '
+ 'credentials file.')
+ elif e.errno == errno.ENOLCK:
+ logger.warn('File system is out of resources for writing the '
+ 'credentials file (is your disk full?).')
+ elif e.errno == errno.EDEADLK:
+ logger.warn('Lock contention on multistore file, opening '
+ 'in read-only mode.')
+ elif e.errno == errno.EACCES:
+ logger.warn('Cannot access credentials file.')
+ else:
+ raise
+ if not self._file.is_locked():
+ self._read_only = True
+ if self._warn_on_readonly:
+ logger.warn('The credentials file (%s) is not writable. '
+ 'Opening in read-only mode. Any refreshed '
+ 'credentials will only be '
+ 'valid for this run.', self._file.filename())
+
+ if os.path.getsize(self._file.filename()) == 0:
+ logger.debug('Initializing empty multistore file')
+ # The multistore is empty so write out an empty file.
+ self._data = {}
+ self._write()
+ elif not self._read_only or self._data is None:
+ # Only refresh the data if we are read/write or we haven't
+ # cached the data yet. If we are readonly, we assume is isn't
+ # changing out from under us and that we only have to read it
+ # once. This prevents us from whacking any new access keys that
+ # we have cached in memory but were unable to write out.
+ self._refresh_data_cache()
+
+ def _unlock(self):
+ """Release the lock on the multistore."""
+ self._file.unlock_and_close()
+ self._thread_lock.release()
+
+ def _locked_json_read(self):
+ """Get the raw content of the multistore file.
+
+ The multistore must be locked when this is called.
+
+ Returns:
+ The contents of the multistore decoded as JSON.
+ """
+ assert self._thread_lock.locked()
+ self._file.file_handle().seek(0)
+ return json.load(self._file.file_handle())
+
+ def _locked_json_write(self, data):
+ """Write a JSON serializable data structure to the multistore.
+
+ The multistore must be locked when this is called.
+
+ Args:
+ data: The data to be serialized and written.
+ """
+ assert self._thread_lock.locked()
+ if self._read_only:
+ return
+ self._file.file_handle().seek(0)
+ json.dump(data, self._file.file_handle(),
+ sort_keys=True, indent=2, separators=(',', ': '))
+ self._file.file_handle().truncate()
+
+ def _refresh_data_cache(self):
+ """Refresh the contents of the multistore.
+
+ The multistore must be locked when this is called.
+
+ Raises:
+ NewerCredentialStoreError: Raised when a newer client has written
+ the store.
+ """
+ self._data = {}
+ try:
+ raw_data = self._locked_json_read()
+ except Exception:
+ logger.warn('Credential data store could not be loaded. '
+ 'Will ignore and overwrite.')
+ return
+
+ version = 0
+ try:
+ version = raw_data['file_version']
+ except Exception:
+ logger.warn('Missing version for credential data store. It may be '
+ 'corrupt or an old version. Overwriting.')
+ if version > 1:
+ raise NewerCredentialStoreError(
+ 'Credential file has file_version of {0}. '
+ 'Only file_version of 1 is supported.'.format(version))
+
+ credentials = []
+ try:
+ credentials = raw_data['data']
+ except (TypeError, KeyError):
+ pass
+
+ for cred_entry in credentials:
+ try:
+ key, credential = self._decode_credential_from_json(cred_entry)
+ self._data[key] = credential
+ except:
+ # If something goes wrong loading a credential, just ignore it
+ logger.info('Error decoding credential, skipping',
+ exc_info=True)
+
+ def _decode_credential_from_json(self, cred_entry):
+ """Load a credential from our JSON serialization.
+
+ Args:
+ cred_entry: A dict entry from the data member of our format
+
+ Returns:
+ (key, cred) where the key is the key tuple and the cred is the
+ OAuth2Credential object.
+ """
+ raw_key = cred_entry['key']
+ key = _dict_to_tuple_key(raw_key)
+ credential = None
+ credential = client.Credentials.new_from_json(
+ json.dumps(cred_entry['credential']))
+ return (key, credential)
+
+ def _write(self):
+ """Write the cached data back out.
+
+ The multistore must be locked.
+ """
+ raw_data = {'file_version': 1}
+ raw_creds = []
+ raw_data['data'] = raw_creds
+ for (cred_key, cred) in self._data.items():
+ raw_key = dict(cred_key)
+ raw_cred = json.loads(cred.to_json())
+ raw_creds.append({'key': raw_key, 'credential': raw_cred})
+ self._locked_json_write(raw_data)
+
+ def _get_all_credential_keys(self):
+ """Gets all the registered credential keys in the multistore.
+
+ Returns:
+ A list of dictionaries corresponding to all the keys currently
+ registered
+ """
+ return [dict(key) for key in self._data.keys()]
+
+ def _get_credential(self, key):
+ """Get a credential from the multistore.
+
+ The multistore must be locked.
+
+ Args:
+ key: The key used to retrieve the credential
+
+ Returns:
+ The credential specified or None if not present
+ """
+ return self._data.get(key, None)
+
+ def _update_credential(self, key, cred):
+ """Update a credential and write the multistore.
+
+ This must be called when the multistore is locked.
+
+ Args:
+ key: The key used to retrieve the credential
+ cred: The OAuth2Credential to update/set
+ """
+ self._data[key] = cred
+ self._write()
+
+ def _delete_credential(self, key):
+ """Delete a credential and write the multistore.
+
+ This must be called when the multistore is locked.
+
+ Args:
+ key: The key used to retrieve the credential
+ """
+ try:
+ del self._data[key]
+ except KeyError:
+ pass
+ self._write()
+
+ def _get_storage(self, key):
+ """Get a Storage object to get/set a credential.
+
+ This Storage is a 'view' into the multistore.
+
+ Args:
+ key: The key used to retrieve the credential
+
+ Returns:
+ A Storage object that can be used to get/set this cred
+ """
+ return self._Storage(self, key)
diff --git a/oauth2client/contrib/sqlalchemy.py b/oauth2client/contrib/sqlalchemy.py
new file mode 100644
index 0000000..7d9fd4b
--- /dev/null
+++ b/oauth2client/contrib/sqlalchemy.py
@@ -0,0 +1,173 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""OAuth 2.0 utilities for SQLAlchemy.
+
+Utilities for using OAuth 2.0 in conjunction with a SQLAlchemy.
+
+Configuration
+=============
+
+In order to use this storage, you'll need to create table
+with :class:`oauth2client.contrib.sqlalchemy.CredentialsType` column.
+It's recommended to either put this column on some sort of user info
+table or put the column in a table with a belongs-to relationship to
+a user info table.
+
+Here's an example of a simple table with a :class:`CredentialsType`
+column that's related to a user table by the `user_id` key.
+
+.. code-block:: python
+
+ from sqlalchemy import Column, ForeignKey, Integer
+ from sqlalchemy.ext.declarative import declarative_base
+ from sqlalchemy.orm import relationship
+
+ from oauth2client.contrib.sqlalchemy import CredentialsType
+
+
+ Base = declarative_base()
+
+
+ class Credentials(Base):
+ __tablename__ = 'credentials'
+
+ user_id = Column(Integer, ForeignKey('user.id'))
+ credentials = Column(CredentialsType)
+
+
+ class User(Base):
+ id = Column(Integer, primary_key=True)
+ # bunch of other columns
+ credentials = relationship('Credentials')
+
+
+Usage
+=====
+
+With tables ready, you are now able to store credentials in database.
+We will reuse tables defined above.
+
+.. code-block:: python
+
+ from sqlalchemy.orm import Session
+
+ from oauth2client.client import OAuth2Credentials
+ from oauth2client.contrib.sql_alchemy import Storage
+
+ session = Session()
+ user = session.query(User).first()
+ storage = Storage(
+ session=session,
+ model_class=Credentials,
+ # This is the key column used to identify
+ # the row that stores the credentials.
+ key_name='user_id',
+ key_value=user.id,
+ property_name='credentials',
+ )
+
+ # Store
+ credentials = OAuth2Credentials(...)
+ storage.put(credentials)
+
+ # Retrieve
+ credentials = storage.get()
+
+ # Delete
+ storage.delete()
+
+"""
+
+from __future__ import absolute_import
+
+import sqlalchemy.types
+
+from oauth2client import client
+
+
+class CredentialsType(sqlalchemy.types.PickleType):
+ """Type representing credentials.
+
+ Alias for :class:`sqlalchemy.types.PickleType`.
+ """
+
+
+class Storage(client.Storage):
+ """Store and retrieve a single credential to and from SQLAlchemy.
+ This helper presumes the Credentials
+ have been stored as a Credentials column
+ on a db model class.
+ """
+
+ def __init__(self, session, model_class, key_name,
+ key_value, property_name):
+ """Constructor for Storage.
+
+ Args:
+ session: An instance of :class:`sqlalchemy.orm.Session`.
+ model_class: SQLAlchemy declarative mapping.
+ key_name: string, key name for the entity that has the credentials
+ key_value: key value for the entity that has the credentials
+ property_name: A string indicating which property on the
+ ``model_class`` to store the credentials.
+ This property must be a
+ :class:`CredentialsType` column.
+ """
+ super(Storage, self).__init__()
+
+ self.session = session
+ self.model_class = model_class
+ self.key_name = key_name
+ self.key_value = key_value
+ self.property_name = property_name
+
+ def locked_get(self):
+ """Retrieve stored credential.
+
+ Returns:
+ A :class:`oauth2client.Credentials` instance or `None`.
+ """
+ filters = {self.key_name: self.key_value}
+ query = self.session.query(self.model_class).filter_by(**filters)
+ entity = query.first()
+
+ if entity:
+ credential = getattr(entity, self.property_name)
+ if credential and hasattr(credential, 'set_store'):
+ credential.set_store(self)
+ return credential
+ else:
+ return None
+
+ def locked_put(self, credentials):
+ """Write a credentials to the SQLAlchemy datastore.
+
+ Args:
+ credentials: :class:`oauth2client.Credentials`
+ """
+ filters = {self.key_name: self.key_value}
+ query = self.session.query(self.model_class).filter_by(**filters)
+ entity = query.first()
+
+ if not entity:
+ entity = self.model_class(**filters)
+
+ setattr(entity, self.property_name, credentials)
+ self.session.add(entity)
+
+ def locked_delete(self):
+ """Delete credentials from the SQLAlchemy datastore."""
+ filters = {self.key_name: self.key_value}
+ self.session.query(self.model_class).filter_by(**filters).delete()
diff --git a/oauth2client/contrib/xsrfutil.py b/oauth2client/contrib/xsrfutil.py
new file mode 100644
index 0000000..c03e679
--- /dev/null
+++ b/oauth2client/contrib/xsrfutil.py
@@ -0,0 +1,106 @@
+# Copyright 2014 the Melange authors.
+#
+# 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.
+
+"""Helper methods for creating & verifying XSRF tokens."""
+
+import base64
+import binascii
+import hmac
+import time
+
+from oauth2client import _helpers
+from oauth2client import util
+
+__authors__ = [
+ '"Doug Coker" <dcoker@google.com>',
+ '"Joe Gregorio" <jcgregorio@google.com>',
+]
+
+# Delimiter character
+DELIMITER = b':'
+
+# 1 hour in seconds
+DEFAULT_TIMEOUT_SECS = 60 * 60
+
+
+@util.positional(2)
+def generate_token(key, user_id, action_id='', when=None):
+ """Generates a URL-safe token for the given user, action, time tuple.
+
+ Args:
+ key: secret key to use.
+ user_id: the user ID of the authenticated user.
+ action_id: a string identifier of the action they requested
+ authorization for.
+ when: the time in seconds since the epoch at which the user was
+ authorized for this action. If not set the current time is used.
+
+ Returns:
+ A string XSRF protection token.
+ """
+ digester = hmac.new(_helpers._to_bytes(key, encoding='utf-8'))
+ digester.update(_helpers._to_bytes(str(user_id), encoding='utf-8'))
+ digester.update(DELIMITER)
+ digester.update(_helpers._to_bytes(action_id, encoding='utf-8'))
+ digester.update(DELIMITER)
+ when = _helpers._to_bytes(str(when or int(time.time())), encoding='utf-8')
+ digester.update(when)
+ digest = digester.digest()
+
+ token = base64.urlsafe_b64encode(digest + DELIMITER + when)
+ return token
+
+
+@util.positional(3)
+def validate_token(key, token, user_id, action_id="", current_time=None):
+ """Validates that the given token authorizes the user for the action.
+
+ Tokens are invalid if the time of issue is too old or if the token
+ does not match what generateToken outputs (i.e. the token was forged).
+
+ Args:
+ key: secret key to use.
+ token: a string of the token generated by generateToken.
+ user_id: the user ID of the authenticated user.
+ action_id: a string identifier of the action they requested
+ authorization for.
+
+ Returns:
+ A boolean - True if the user is authorized for the action, False
+ otherwise.
+ """
+ if not token:
+ return False
+ try:
+ decoded = base64.urlsafe_b64decode(token)
+ token_time = int(decoded.split(DELIMITER)[-1])
+ except (TypeError, ValueError, binascii.Error):
+ return False
+ if current_time is None:
+ current_time = time.time()
+ # If the token is too old it's not valid.
+ if current_time - token_time > DEFAULT_TIMEOUT_SECS:
+ return False
+
+ # The given token should match the generated one with the same time.
+ expected_token = generate_token(key, user_id, action_id=action_id,
+ when=token_time)
+ if len(token) != len(expected_token):
+ return False
+
+ # Perform constant time comparison to avoid timing attacks
+ different = 0
+ for x, y in zip(bytearray(token), bytearray(expected_token)):
+ different |= x ^ y
+ return not different
diff --git a/oauth2client/crypt.py b/oauth2client/crypt.py
new file mode 100644
index 0000000..1326098
--- /dev/null
+++ b/oauth2client/crypt.py
@@ -0,0 +1,250 @@
+# -*- coding: utf-8 -*-
+#
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+"""Crypto-related routines for oauth2client."""
+
+import json
+import logging
+import time
+
+from oauth2client import _helpers
+from oauth2client import _pure_python_crypt
+
+
+RsaSigner = _pure_python_crypt.RsaSigner
+RsaVerifier = _pure_python_crypt.RsaVerifier
+
+CLOCK_SKEW_SECS = 300 # 5 minutes in seconds
+AUTH_TOKEN_LIFETIME_SECS = 300 # 5 minutes in seconds
+MAX_TOKEN_LIFETIME_SECS = 86400 # 1 day in seconds
+
+logger = logging.getLogger(__name__)
+
+
+class AppIdentityError(Exception):
+ """Error to indicate crypto failure."""
+
+
+def _bad_pkcs12_key_as_pem(*args, **kwargs):
+ raise NotImplementedError('pkcs12_key_as_pem requires OpenSSL.')
+
+
+try:
+ from oauth2client import _openssl_crypt
+ OpenSSLSigner = _openssl_crypt.OpenSSLSigner
+ OpenSSLVerifier = _openssl_crypt.OpenSSLVerifier
+ pkcs12_key_as_pem = _openssl_crypt.pkcs12_key_as_pem
+except ImportError: # pragma: NO COVER
+ OpenSSLVerifier = None
+ OpenSSLSigner = None
+ pkcs12_key_as_pem = _bad_pkcs12_key_as_pem
+
+try:
+ from oauth2client import _pycrypto_crypt
+ PyCryptoSigner = _pycrypto_crypt.PyCryptoSigner
+ PyCryptoVerifier = _pycrypto_crypt.PyCryptoVerifier
+except ImportError: # pragma: NO COVER
+ PyCryptoVerifier = None
+ PyCryptoSigner = None
+
+
+if OpenSSLSigner:
+ Signer = OpenSSLSigner
+ Verifier = OpenSSLVerifier
+elif PyCryptoSigner: # pragma: NO COVER
+ Signer = PyCryptoSigner
+ Verifier = PyCryptoVerifier
+else: # pragma: NO COVER
+ Signer = RsaSigner
+ Verifier = RsaVerifier
+
+
+def make_signed_jwt(signer, payload, key_id=None):
+ """Make a signed JWT.
+
+ See http://self-issued.info/docs/draft-jones-json-web-token.html.
+
+ Args:
+ signer: crypt.Signer, Cryptographic signer.
+ payload: dict, Dictionary of data to convert to JSON and then sign.
+ key_id: string, (Optional) Key ID header.
+
+ Returns:
+ string, The JWT for the payload.
+ """
+ header = {'typ': 'JWT', 'alg': 'RS256'}
+ if key_id is not None:
+ header['kid'] = key_id
+
+ segments = [
+ _helpers._urlsafe_b64encode(_helpers._json_encode(header)),
+ _helpers._urlsafe_b64encode(_helpers._json_encode(payload)),
+ ]
+ signing_input = b'.'.join(segments)
+
+ signature = signer.sign(signing_input)
+ segments.append(_helpers._urlsafe_b64encode(signature))
+
+ logger.debug(str(segments))
+
+ return b'.'.join(segments)
+
+
+def _verify_signature(message, signature, certs):
+ """Verifies signed content using a list of certificates.
+
+ Args:
+ message: string or bytes, The message to verify.
+ signature: string or bytes, The signature on the message.
+ certs: iterable, certificates in PEM format.
+
+ Raises:
+ AppIdentityError: If none of the certificates can verify the message
+ against the signature.
+ """
+ for pem in certs:
+ verifier = Verifier.from_string(pem, is_x509_cert=True)
+ if verifier.verify(message, signature):
+ return
+
+ # If we have not returned, no certificate confirms the signature.
+ raise AppIdentityError('Invalid token signature')
+
+
+def _check_audience(payload_dict, audience):
+ """Checks audience field from a JWT payload.
+
+ Does nothing if the passed in ``audience`` is null.
+
+ Args:
+ payload_dict: dict, A dictionary containing a JWT payload.
+ audience: string or NoneType, an audience to check for in
+ the JWT payload.
+
+ Raises:
+ AppIdentityError: If there is no ``'aud'`` field in the payload
+ dictionary but there is an ``audience`` to check.
+ AppIdentityError: If the ``'aud'`` field in the payload dictionary
+ does not match the ``audience``.
+ """
+ if audience is None:
+ return
+
+ audience_in_payload = payload_dict.get('aud')
+ if audience_in_payload is None:
+ raise AppIdentityError(
+ 'No aud field in token: {0}'.format(payload_dict))
+ if audience_in_payload != audience:
+ raise AppIdentityError('Wrong recipient, {0} != {1}: {2}'.format(
+ audience_in_payload, audience, payload_dict))
+
+
+def _verify_time_range(payload_dict):
+ """Verifies the issued at and expiration from a JWT payload.
+
+ Makes sure the current time (in UTC) falls between the issued at and
+ expiration for the JWT (with some skew allowed for via
+ ``CLOCK_SKEW_SECS``).
+
+ Args:
+ payload_dict: dict, A dictionary containing a JWT payload.
+
+ Raises:
+ AppIdentityError: If there is no ``'iat'`` field in the payload
+ dictionary.
+ AppIdentityError: If there is no ``'exp'`` field in the payload
+ dictionary.
+ AppIdentityError: If the JWT expiration is too far in the future (i.e.
+ if the expiration would imply a token lifetime
+ longer than what is allowed.)
+ AppIdentityError: If the token appears to have been issued in the
+ future (up to clock skew).
+ AppIdentityError: If the token appears to have expired in the past
+ (up to clock skew).
+ """
+ # Get the current time to use throughout.
+ now = int(time.time())
+
+ # Make sure issued at and expiration are in the payload.
+ issued_at = payload_dict.get('iat')
+ if issued_at is None:
+ raise AppIdentityError(
+ 'No iat field in token: {0}'.format(payload_dict))
+ expiration = payload_dict.get('exp')
+ if expiration is None:
+ raise AppIdentityError(
+ 'No exp field in token: {0}'.format(payload_dict))
+
+ # Make sure the expiration gives an acceptable token lifetime.
+ if expiration >= now + MAX_TOKEN_LIFETIME_SECS:
+ raise AppIdentityError(
+ 'exp field too far in future: {0}'.format(payload_dict))
+
+ # Make sure (up to clock skew) that the token wasn't issued in the future.
+ earliest = issued_at - CLOCK_SKEW_SECS
+ if now < earliest:
+ raise AppIdentityError('Token used too early, {0} < {1}: {2}'.format(
+ now, earliest, payload_dict))
+ # Make sure (up to clock skew) that the token isn't already expired.
+ latest = expiration + CLOCK_SKEW_SECS
+ if now > latest:
+ raise AppIdentityError('Token used too late, {0} > {1}: {2}'.format(
+ now, latest, payload_dict))
+
+
+def verify_signed_jwt_with_certs(jwt, certs, audience=None):
+ """Verify a JWT against public certs.
+
+ See http://self-issued.info/docs/draft-jones-json-web-token.html.
+
+ Args:
+ jwt: string, A JWT.
+ certs: dict, Dictionary where values of public keys in PEM format.
+ audience: string, The audience, 'aud', that this JWT should contain. If
+ None then the JWT's 'aud' parameter is not verified.
+
+ Returns:
+ dict, The deserialized JSON payload in the JWT.
+
+ Raises:
+ AppIdentityError: if any checks are failed.
+ """
+ jwt = _helpers._to_bytes(jwt)
+
+ if jwt.count(b'.') != 2:
+ raise AppIdentityError(
+ 'Wrong number of segments in token: {0}'.format(jwt))
+
+ header, payload, signature = jwt.split(b'.')
+ message_to_sign = header + b'.' + payload
+ signature = _helpers._urlsafe_b64decode(signature)
+
+ # Parse token.
+ payload_bytes = _helpers._urlsafe_b64decode(payload)
+ try:
+ payload_dict = json.loads(_helpers._from_bytes(payload_bytes))
+ except:
+ raise AppIdentityError('Can\'t parse token: {0}'.format(payload_bytes))
+
+ # Verify that the signature matches the message.
+ _verify_signature(message_to_sign, signature, certs.values())
+
+ # Verify the issued at and created times in the payload.
+ _verify_time_range(payload_dict)
+
+ # Check audience.
+ _check_audience(payload_dict, audience)
+
+ return payload_dict
diff --git a/oauth2client/file.py b/oauth2client/file.py
new file mode 100644
index 0000000..feede11
--- /dev/null
+++ b/oauth2client/file.py
@@ -0,0 +1,106 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Utilities for OAuth.
+
+Utilities for making it easier to work with OAuth 2.0
+credentials.
+"""
+
+import os
+import threading
+
+from oauth2client import client
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+class CredentialsFileSymbolicLinkError(Exception):
+ """Credentials files must not be symbolic links."""
+
+
+class Storage(client.Storage):
+ """Store and retrieve a single credential to and from a file."""
+
+ def __init__(self, filename):
+ super(Storage, self).__init__(lock=threading.Lock())
+ self._filename = filename
+
+ def _validate_file(self):
+ if os.path.islink(self._filename):
+ raise CredentialsFileSymbolicLinkError(
+ 'File: {0} is a symbolic link.'.format(self._filename))
+
+ def locked_get(self):
+ """Retrieve Credential from file.
+
+ Returns:
+ oauth2client.client.Credentials
+
+ Raises:
+ CredentialsFileSymbolicLinkError if the file is a symbolic link.
+ """
+ credentials = None
+ self._validate_file()
+ try:
+ f = open(self._filename, 'rb')
+ content = f.read()
+ f.close()
+ except IOError:
+ return credentials
+
+ try:
+ credentials = client.Credentials.new_from_json(content)
+ credentials.set_store(self)
+ except ValueError:
+ pass
+
+ return credentials
+
+ def _create_file_if_needed(self):
+ """Create an empty file if necessary.
+
+ This method will not initialize the file. Instead it implements a
+ simple version of "touch" to ensure the file has been created.
+ """
+ if not os.path.exists(self._filename):
+ old_umask = os.umask(0o177)
+ try:
+ open(self._filename, 'a+b').close()
+ finally:
+ os.umask(old_umask)
+
+ def locked_put(self, credentials):
+ """Write Credentials to file.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+
+ Raises:
+ CredentialsFileSymbolicLinkError if the file is a symbolic link.
+ """
+ self._create_file_if_needed()
+ self._validate_file()
+ f = open(self._filename, 'w')
+ f.write(credentials.to_json())
+ f.close()
+
+ def locked_delete(self):
+ """Delete Credentials file.
+
+ Args:
+ credentials: Credentials, the credentials to store.
+ """
+ os.unlink(self._filename)
diff --git a/oauth2client/service_account.py b/oauth2client/service_account.py
new file mode 100644
index 0000000..bdcfd69
--- /dev/null
+++ b/oauth2client/service_account.py
@@ -0,0 +1,673 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""oauth2client Service account credentials class."""
+
+import base64
+import copy
+import datetime
+import json
+import time
+
+import oauth2client
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client import crypt
+from oauth2client import transport
+from oauth2client import util
+
+
+_PASSWORD_DEFAULT = 'notasecret'
+_PKCS12_KEY = '_private_key_pkcs12'
+_PKCS12_ERROR = r"""
+This library only implements PKCS#12 support via the pyOpenSSL library.
+Either install pyOpenSSL, or please convert the .p12 file
+to .pem format:
+ $ cat key.p12 | \
+ > openssl pkcs12 -nodes -nocerts -passin pass:notasecret | \
+ > openssl rsa > key.pem
+"""
+
+
+class ServiceAccountCredentials(client.AssertionCredentials):
+ """Service Account credential for OAuth 2.0 signed JWT grants.
+
+ Supports
+
+ * JSON keyfile (typically contains a PKCS8 key stored as
+ PEM text)
+ * ``.p12`` key (stores PKCS12 key and certificate)
+
+ Makes an assertion to server using a signed JWT assertion in exchange
+ for an access token.
+
+ This credential does not require a flow to instantiate because it
+ represents a two legged flow, and therefore has all of the required
+ information to generate and refresh its own access tokens.
+
+ Args:
+ service_account_email: string, The email associated with the
+ service account.
+ signer: ``crypt.Signer``, A signer which can be used to sign content.
+ scopes: List or string, (Optional) Scopes to use when acquiring
+ an access token.
+ private_key_id: string, (Optional) Private key identifier. Typically
+ only used with a JSON keyfile. Can be sent in the
+ header of a JWT token assertion.
+ client_id: string, (Optional) Client ID for the project that owns the
+ service account.
+ user_agent: string, (Optional) User agent to use when sending
+ request.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ kwargs: dict, Extra key-value pairs (both strings) to send in the
+ payload body when making an assertion.
+ """
+
+ MAX_TOKEN_LIFETIME_SECS = 3600
+ """Max lifetime of the token (one hour, in seconds)."""
+
+ NON_SERIALIZED_MEMBERS = (
+ frozenset(['_signer']) |
+ client.AssertionCredentials.NON_SERIALIZED_MEMBERS)
+ """Members that aren't serialized when object is converted to JSON."""
+
+ # Can be over-ridden by factory constructors. Used for
+ # serialization/deserialization purposes.
+ _private_key_pkcs8_pem = None
+ _private_key_pkcs12 = None
+ _private_key_password = None
+
+ def __init__(self,
+ service_account_email,
+ signer,
+ scopes='',
+ private_key_id=None,
+ client_id=None,
+ user_agent=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ **kwargs):
+
+ super(ServiceAccountCredentials, self).__init__(
+ None, user_agent=user_agent, token_uri=token_uri,
+ revoke_uri=revoke_uri)
+
+ self._service_account_email = service_account_email
+ self._signer = signer
+ self._scopes = util.scopes_to_string(scopes)
+ self._private_key_id = private_key_id
+ self.client_id = client_id
+ self._user_agent = user_agent
+ self._kwargs = kwargs
+
+ def _to_json(self, strip, to_serialize=None):
+ """Utility function that creates JSON repr. of a credentials object.
+
+ Over-ride is needed since PKCS#12 keys will not in general be JSON
+ serializable.
+
+ Args:
+ strip: array, An array of names of members to exclude from the
+ JSON.
+ to_serialize: dict, (Optional) The properties for this object
+ that will be serialized. This allows callers to
+ modify before serializing.
+
+ Returns:
+ string, a JSON representation of this instance, suitable to pass to
+ from_json().
+ """
+ if to_serialize is None:
+ to_serialize = copy.copy(self.__dict__)
+ pkcs12_val = to_serialize.get(_PKCS12_KEY)
+ if pkcs12_val is not None:
+ to_serialize[_PKCS12_KEY] = base64.b64encode(pkcs12_val)
+ return super(ServiceAccountCredentials, self)._to_json(
+ strip, to_serialize=to_serialize)
+
+ @classmethod
+ def _from_parsed_json_keyfile(cls, keyfile_dict, scopes,
+ token_uri=None, revoke_uri=None):
+ """Helper for factory constructors from JSON keyfile.
+
+ Args:
+ keyfile_dict: dict-like object, The parsed dictionary-like object
+ containing the contents of the JSON keyfile.
+ scopes: List or string, Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for OAuth 2.0 provider token endpoint.
+ If unset and not present in keyfile_dict, defaults
+ to Google's endpoints.
+ revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint.
+ If unset and not present in keyfile_dict, defaults
+ to Google's endpoints.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile contents.
+
+ Raises:
+ ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
+ KeyError, if one of the expected keys is not present in
+ the keyfile.
+ """
+ creds_type = keyfile_dict.get('type')
+ if creds_type != client.SERVICE_ACCOUNT:
+ raise ValueError('Unexpected credentials type', creds_type,
+ 'Expected', client.SERVICE_ACCOUNT)
+
+ service_account_email = keyfile_dict['client_email']
+ private_key_pkcs8_pem = keyfile_dict['private_key']
+ private_key_id = keyfile_dict['private_key_id']
+ client_id = keyfile_dict['client_id']
+ if not token_uri:
+ token_uri = keyfile_dict.get('token_uri',
+ oauth2client.GOOGLE_TOKEN_URI)
+ if not revoke_uri:
+ revoke_uri = keyfile_dict.get('revoke_uri',
+ oauth2client.GOOGLE_REVOKE_URI)
+
+ signer = crypt.Signer.from_string(private_key_pkcs8_pem)
+ credentials = cls(service_account_email, signer, scopes=scopes,
+ private_key_id=private_key_id,
+ client_id=client_id, token_uri=token_uri,
+ revoke_uri=revoke_uri)
+ credentials._private_key_pkcs8_pem = private_key_pkcs8_pem
+ return credentials
+
+ @classmethod
+ def from_json_keyfile_name(cls, filename, scopes='',
+ token_uri=None, revoke_uri=None):
+
+ """Factory constructor from JSON keyfile by name.
+
+ Args:
+ filename: string, The location of the keyfile.
+ scopes: List or string, (Optional) Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for OAuth 2.0 provider token endpoint.
+ If unset and not present in the key file, defaults
+ to Google's endpoints.
+ revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint.
+ If unset and not present in the key file, defaults
+ to Google's endpoints.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile.
+
+ Raises:
+ ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
+ KeyError, if one of the expected keys is not present in
+ the keyfile.
+ """
+ with open(filename, 'r') as file_obj:
+ client_credentials = json.load(file_obj)
+ return cls._from_parsed_json_keyfile(client_credentials, scopes,
+ token_uri=token_uri,
+ revoke_uri=revoke_uri)
+
+ @classmethod
+ def from_json_keyfile_dict(cls, keyfile_dict, scopes='',
+ token_uri=None, revoke_uri=None):
+ """Factory constructor from parsed JSON keyfile.
+
+ Args:
+ keyfile_dict: dict-like object, The parsed dictionary-like object
+ containing the contents of the JSON keyfile.
+ scopes: List or string, (Optional) Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for OAuth 2.0 provider token endpoint.
+ If unset and not present in keyfile_dict, defaults
+ to Google's endpoints.
+ revoke_uri: string, URI for OAuth 2.0 provider revoke endpoint.
+ If unset and not present in keyfile_dict, defaults
+ to Google's endpoints.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile.
+
+ Raises:
+ ValueError, if the credential type is not :data:`SERVICE_ACCOUNT`.
+ KeyError, if one of the expected keys is not present in
+ the keyfile.
+ """
+ return cls._from_parsed_json_keyfile(keyfile_dict, scopes,
+ token_uri=token_uri,
+ revoke_uri=revoke_uri)
+
+ @classmethod
+ def _from_p12_keyfile_contents(cls, service_account_email,
+ private_key_pkcs12,
+ private_key_password=None, scopes='',
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI):
+ """Factory constructor from JSON keyfile.
+
+ Args:
+ service_account_email: string, The email associated with the
+ service account.
+ private_key_pkcs12: string, The contents of a PKCS#12 keyfile.
+ private_key_password: string, (Optional) Password for PKCS#12
+ private key. Defaults to ``notasecret``.
+ scopes: List or string, (Optional) Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile.
+
+ Raises:
+ NotImplementedError if pyOpenSSL is not installed / not the
+ active crypto library.
+ """
+ if private_key_password is None:
+ private_key_password = _PASSWORD_DEFAULT
+ if crypt.Signer is not crypt.OpenSSLSigner:
+ raise NotImplementedError(_PKCS12_ERROR)
+ signer = crypt.Signer.from_string(private_key_pkcs12,
+ private_key_password)
+ credentials = cls(service_account_email, signer, scopes=scopes,
+ token_uri=token_uri, revoke_uri=revoke_uri)
+ credentials._private_key_pkcs12 = private_key_pkcs12
+ credentials._private_key_password = private_key_password
+ return credentials
+
+ @classmethod
+ def from_p12_keyfile(cls, service_account_email, filename,
+ private_key_password=None, scopes='',
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI):
+
+ """Factory constructor from JSON keyfile.
+
+ Args:
+ service_account_email: string, The email associated with the
+ service account.
+ filename: string, The location of the PKCS#12 keyfile.
+ private_key_password: string, (Optional) Password for PKCS#12
+ private key. Defaults to ``notasecret``.
+ scopes: List or string, (Optional) Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile.
+
+ Raises:
+ NotImplementedError if pyOpenSSL is not installed / not the
+ active crypto library.
+ """
+ with open(filename, 'rb') as file_obj:
+ private_key_pkcs12 = file_obj.read()
+ return cls._from_p12_keyfile_contents(
+ service_account_email, private_key_pkcs12,
+ private_key_password=private_key_password, scopes=scopes,
+ token_uri=token_uri, revoke_uri=revoke_uri)
+
+ @classmethod
+ def from_p12_keyfile_buffer(cls, service_account_email, file_buffer,
+ private_key_password=None, scopes='',
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI):
+ """Factory constructor from JSON keyfile.
+
+ Args:
+ service_account_email: string, The email associated with the
+ service account.
+ file_buffer: stream, A buffer that implements ``read()``
+ and contains the PKCS#12 key contents.
+ private_key_password: string, (Optional) Password for PKCS#12
+ private key. Defaults to ``notasecret``.
+ scopes: List or string, (Optional) Scopes to use when acquiring an
+ access token.
+ token_uri: string, URI for token endpoint. For convenience defaults
+ to Google's endpoints but any OAuth 2.0 provider can be
+ used.
+ revoke_uri: string, URI for revoke endpoint. For convenience
+ defaults to Google's endpoints but any OAuth 2.0
+ provider can be used.
+
+ Returns:
+ ServiceAccountCredentials, a credentials object created from
+ the keyfile.
+
+ Raises:
+ NotImplementedError if pyOpenSSL is not installed / not the
+ active crypto library.
+ """
+ private_key_pkcs12 = file_buffer.read()
+ return cls._from_p12_keyfile_contents(
+ service_account_email, private_key_pkcs12,
+ private_key_password=private_key_password, scopes=scopes,
+ token_uri=token_uri, revoke_uri=revoke_uri)
+
+ def _generate_assertion(self):
+ """Generate the assertion that will be used in the request."""
+ now = int(time.time())
+ payload = {
+ 'aud': self.token_uri,
+ 'scope': self._scopes,
+ 'iat': now,
+ 'exp': now + self.MAX_TOKEN_LIFETIME_SECS,
+ 'iss': self._service_account_email,
+ }
+ payload.update(self._kwargs)
+ return crypt.make_signed_jwt(self._signer, payload,
+ key_id=self._private_key_id)
+
+ def sign_blob(self, blob):
+ """Cryptographically sign a blob (of bytes).
+
+ Implements abstract method
+ :meth:`oauth2client.client.AssertionCredentials.sign_blob`.
+
+ Args:
+ blob: bytes, Message to be signed.
+
+ Returns:
+ tuple, A pair of the private key ID used to sign the blob and
+ the signed contents.
+ """
+ return self._private_key_id, self._signer.sign(blob)
+
+ @property
+ def service_account_email(self):
+ """Get the email for the current service account.
+
+ Returns:
+ string, The email associated with the service account.
+ """
+ return self._service_account_email
+
+ @property
+ def serialization_data(self):
+ # NOTE: This is only useful for JSON keyfile.
+ return {
+ 'type': 'service_account',
+ 'client_email': self._service_account_email,
+ 'private_key_id': self._private_key_id,
+ 'private_key': self._private_key_pkcs8_pem,
+ 'client_id': self.client_id,
+ }
+
+ @classmethod
+ def from_json(cls, json_data):
+ """Deserialize a JSON-serialized instance.
+
+ Inverse to :meth:`to_json`.
+
+ Args:
+ json_data: dict or string, Serialized JSON (as a string or an
+ already parsed dictionary) representing a credential.
+
+ Returns:
+ ServiceAccountCredentials from the serialized data.
+ """
+ if not isinstance(json_data, dict):
+ json_data = json.loads(_helpers._from_bytes(json_data))
+
+ private_key_pkcs8_pem = None
+ pkcs12_val = json_data.get(_PKCS12_KEY)
+ password = None
+ if pkcs12_val is None:
+ private_key_pkcs8_pem = json_data['_private_key_pkcs8_pem']
+ signer = crypt.Signer.from_string(private_key_pkcs8_pem)
+ else:
+ # NOTE: This assumes that private_key_pkcs8_pem is not also
+ # in the serialized data. This would be very incorrect
+ # state.
+ pkcs12_val = base64.b64decode(pkcs12_val)
+ password = json_data['_private_key_password']
+ signer = crypt.Signer.from_string(pkcs12_val, password)
+
+ credentials = cls(
+ json_data['_service_account_email'],
+ signer,
+ scopes=json_data['_scopes'],
+ private_key_id=json_data['_private_key_id'],
+ client_id=json_data['client_id'],
+ user_agent=json_data['_user_agent'],
+ **json_data['_kwargs']
+ )
+ if private_key_pkcs8_pem is not None:
+ credentials._private_key_pkcs8_pem = private_key_pkcs8_pem
+ if pkcs12_val is not None:
+ credentials._private_key_pkcs12 = pkcs12_val
+ if password is not None:
+ credentials._private_key_password = password
+ credentials.invalid = json_data['invalid']
+ credentials.access_token = json_data['access_token']
+ credentials.token_uri = json_data['token_uri']
+ credentials.revoke_uri = json_data['revoke_uri']
+ token_expiry = json_data.get('token_expiry', None)
+ if token_expiry is not None:
+ credentials.token_expiry = datetime.datetime.strptime(
+ token_expiry, client.EXPIRY_FORMAT)
+ return credentials
+
+ def create_scoped_required(self):
+ return not self._scopes
+
+ def create_scoped(self, scopes):
+ result = self.__class__(self._service_account_email,
+ self._signer,
+ scopes=scopes,
+ private_key_id=self._private_key_id,
+ client_id=self.client_id,
+ user_agent=self._user_agent,
+ **self._kwargs)
+ result.token_uri = self.token_uri
+ result.revoke_uri = self.revoke_uri
+ result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
+ result._private_key_pkcs12 = self._private_key_pkcs12
+ result._private_key_password = self._private_key_password
+ return result
+
+ def create_with_claims(self, claims):
+ """Create credentials that specify additional claims.
+
+ Args:
+ claims: dict, key-value pairs for claims.
+
+ Returns:
+ ServiceAccountCredentials, a copy of the current service account
+ credentials with updated claims to use when obtaining access
+ tokens.
+ """
+ new_kwargs = dict(self._kwargs)
+ new_kwargs.update(claims)
+ result = self.__class__(self._service_account_email,
+ self._signer,
+ scopes=self._scopes,
+ private_key_id=self._private_key_id,
+ client_id=self.client_id,
+ user_agent=self._user_agent,
+ **new_kwargs)
+ result.token_uri = self.token_uri
+ result.revoke_uri = self.revoke_uri
+ result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
+ result._private_key_pkcs12 = self._private_key_pkcs12
+ result._private_key_password = self._private_key_password
+ return result
+
+ def create_delegated(self, sub):
+ """Create credentials that act as domain-wide delegation of authority.
+
+ Use the ``sub`` parameter as the subject to delegate on behalf of
+ that user.
+
+ For example::
+
+ >>> account_sub = 'foo@email.com'
+ >>> delegate_creds = creds.create_delegated(account_sub)
+
+ Args:
+ sub: string, An email address that this service account will
+ act on behalf of (via domain-wide delegation).
+
+ Returns:
+ ServiceAccountCredentials, a copy of the current service account
+ updated to act on behalf of ``sub``.
+ """
+ return self.create_with_claims({'sub': sub})
+
+
+def _datetime_to_secs(utc_time):
+ # TODO(issue 298): use time_delta.total_seconds()
+ # time_delta.total_seconds() not supported in Python 2.6
+ epoch = datetime.datetime(1970, 1, 1)
+ time_delta = utc_time - epoch
+ return time_delta.days * 86400 + time_delta.seconds
+
+
+class _JWTAccessCredentials(ServiceAccountCredentials):
+ """Self signed JWT credentials.
+
+ Makes an assertion to server using a self signed JWT from service account
+ credentials. These credentials do NOT use OAuth 2.0 and instead
+ authenticate directly.
+ """
+ _MAX_TOKEN_LIFETIME_SECS = 3600
+ """Max lifetime of the token (one hour, in seconds)."""
+
+ def __init__(self,
+ service_account_email,
+ signer,
+ scopes=None,
+ private_key_id=None,
+ client_id=None,
+ user_agent=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ additional_claims=None):
+ if additional_claims is None:
+ additional_claims = {}
+ super(_JWTAccessCredentials, self).__init__(
+ service_account_email,
+ signer,
+ private_key_id=private_key_id,
+ client_id=client_id,
+ user_agent=user_agent,
+ token_uri=token_uri,
+ revoke_uri=revoke_uri,
+ **additional_claims)
+
+ def authorize(self, http):
+ """Authorize an httplib2.Http instance with a JWT assertion.
+
+ Unless specified, the 'aud' of the assertion will be the base
+ uri of the request.
+
+ Args:
+ http: An instance of ``httplib2.Http`` or something that acts
+ like it.
+ Returns:
+ A modified instance of http that was passed in.
+ Example::
+ h = httplib2.Http()
+ h = credentials.authorize(h)
+ """
+ transport.wrap_http_for_jwt_access(self, http)
+ return http
+
+ def get_access_token(self, http=None, additional_claims=None):
+ """Create a signed jwt.
+
+ Args:
+ http: unused
+ additional_claims: dict, additional claims to add to
+ the payload of the JWT.
+ Returns:
+ An AccessTokenInfo with the signed jwt
+ """
+ if additional_claims is None:
+ if self.access_token is None or self.access_token_expired:
+ self.refresh(None)
+ return client.AccessTokenInfo(
+ access_token=self.access_token, expires_in=self._expires_in())
+ else:
+ # Create a 1 time token
+ token, unused_expiry = self._create_token(additional_claims)
+ return client.AccessTokenInfo(
+ access_token=token, expires_in=self._MAX_TOKEN_LIFETIME_SECS)
+
+ def revoke(self, http):
+ """Cannot revoke JWTAccessCredentials tokens."""
+ pass
+
+ def create_scoped_required(self):
+ # JWTAccessCredentials are unscoped by definition
+ return True
+
+ def create_scoped(self, scopes, token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI):
+ # Returns an OAuth2 credentials with the given scope
+ result = ServiceAccountCredentials(self._service_account_email,
+ self._signer,
+ scopes=scopes,
+ private_key_id=self._private_key_id,
+ client_id=self.client_id,
+ user_agent=self._user_agent,
+ token_uri=token_uri,
+ revoke_uri=revoke_uri,
+ **self._kwargs)
+ if self._private_key_pkcs8_pem is not None:
+ result._private_key_pkcs8_pem = self._private_key_pkcs8_pem
+ if self._private_key_pkcs12 is not None:
+ result._private_key_pkcs12 = self._private_key_pkcs12
+ if self._private_key_password is not None:
+ result._private_key_password = self._private_key_password
+ return result
+
+ def refresh(self, http):
+ self._refresh(None)
+
+ def _refresh(self, http_request):
+ self.access_token, self.token_expiry = self._create_token()
+
+ def _create_token(self, additional_claims=None):
+ now = client._UTCNOW()
+ lifetime = datetime.timedelta(seconds=self._MAX_TOKEN_LIFETIME_SECS)
+ expiry = now + lifetime
+ payload = {
+ 'iat': _datetime_to_secs(now),
+ 'exp': _datetime_to_secs(expiry),
+ 'iss': self._service_account_email,
+ 'sub': self._service_account_email
+ }
+ payload.update(self._kwargs)
+ if additional_claims is not None:
+ payload.update(additional_claims)
+ jwt = crypt.make_signed_jwt(self._signer, payload,
+ key_id=self._private_key_id)
+ return jwt.decode('ascii'), expiry
diff --git a/oauth2client/tools.py b/oauth2client/tools.py
new file mode 100644
index 0000000..8947157
--- /dev/null
+++ b/oauth2client/tools.py
@@ -0,0 +1,256 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Command-line tools for authenticating via OAuth 2.0
+
+Do the OAuth 2.0 Web Server dance for a command line application. Stores the
+generated credentials in a common file that is used by other example apps in
+the same directory.
+"""
+
+from __future__ import print_function
+
+import logging
+import socket
+import sys
+
+from six.moves import BaseHTTPServer
+from six.moves import http_client
+from six.moves import input
+from six.moves import urllib
+
+from oauth2client import client
+from oauth2client import util
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+__all__ = ['argparser', 'run_flow', 'message_if_missing']
+
+_CLIENT_SECRETS_MESSAGE = """WARNING: Please configure OAuth 2.0
+
+To make this sample run you will need to populate the client_secrets.json file
+found at:
+
+ {file_path}
+
+with information from the APIs Console <https://code.google.com/apis/console>.
+
+"""
+
+_FAILED_START_MESSAGE = """
+Failed to start a local webserver listening on either port 8080
+or port 8090. Please check your firewall settings and locally
+running programs that may be blocking or using those ports.
+
+Falling back to --noauth_local_webserver and continuing with
+authorization.
+"""
+
+_BROWSER_OPENED_MESSAGE = """
+Your browser has been opened to visit:
+
+ {address}
+
+If your browser is on a different machine then exit and re-run this
+application with the command-line parameter
+
+ --noauth_local_webserver
+"""
+
+_GO_TO_LINK_MESSAGE = """
+Go to the following link in your browser:
+
+ {address}
+"""
+
+
+def _CreateArgumentParser():
+ try:
+ import argparse
+ except ImportError: # pragma: NO COVER
+ return None
+ parser = argparse.ArgumentParser(add_help=False)
+ parser.add_argument('--auth_host_name', default='localhost',
+ help='Hostname when running a local web server.')
+ parser.add_argument('--noauth_local_webserver', action='store_true',
+ default=False, help='Do not run a local web server.')
+ parser.add_argument('--auth_host_port', default=[8080, 8090], type=int,
+ nargs='*', help='Port web server should listen on.')
+ parser.add_argument(
+ '--logging_level', default='ERROR',
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
+ help='Set the logging level of detail.')
+ return parser
+
+# argparser is an ArgumentParser that contains command-line options expected
+# by tools.run(). Pass it in as part of the 'parents' argument to your own
+# ArgumentParser.
+argparser = _CreateArgumentParser()
+
+
+class ClientRedirectServer(BaseHTTPServer.HTTPServer):
+ """A server to handle OAuth 2.0 redirects back to localhost.
+
+ Waits for a single request and parses the query parameters
+ into query_params and then stops serving.
+ """
+ query_params = {}
+
+
+class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler):
+ """A handler for OAuth 2.0 redirects back to localhost.
+
+ Waits for a single request and parses the query parameters
+ into the servers query_params and then stops serving.
+ """
+
+ def do_GET(self):
+ """Handle a GET request.
+
+ Parses the query parameters and prints a message
+ if the flow has completed. Note that we can't detect
+ if an error occurred.
+ """
+ self.send_response(http_client.OK)
+ self.send_header("Content-type", "text/html")
+ self.end_headers()
+ query = self.path.split('?', 1)[-1]
+ query = dict(urllib.parse.parse_qsl(query))
+ self.server.query_params = query
+ self.wfile.write(
+ b"<html><head><title>Authentication Status</title></head>")
+ self.wfile.write(
+ b"<body><p>The authentication flow has completed.</p>")
+ self.wfile.write(b"</body></html>")
+
+ def log_message(self, format, *args):
+ """Do not log messages to stdout while running as cmd. line program."""
+
+
+@util.positional(3)
+def run_flow(flow, storage, flags=None, http=None):
+ """Core code for a command-line application.
+
+ The ``run()`` function is called from your application and runs
+ through all the steps to obtain credentials. It takes a ``Flow``
+ argument and attempts to open an authorization server page in the
+ user's default web browser. The server asks the user to grant your
+ application access to the user's data. If the user grants access,
+ the ``run()`` function returns new credentials. The new credentials
+ are also stored in the ``storage`` argument, which updates the file
+ associated with the ``Storage`` object.
+
+ It presumes it is run from a command-line application and supports the
+ following flags:
+
+ ``--auth_host_name`` (string, default: ``localhost``)
+ Host name to use when running a local web server to handle
+ redirects during OAuth authorization.
+
+ ``--auth_host_port`` (integer, default: ``[8080, 8090]``)
+ Port to use when running a local web server to handle redirects
+ during OAuth authorization. Repeat this option to specify a list
+ of values.
+
+ ``--[no]auth_local_webserver`` (boolean, default: ``True``)
+ Run a local web server to handle redirects during OAuth
+ authorization.
+
+ The tools module defines an ``ArgumentParser`` the already contains the
+ flag definitions that ``run()`` requires. You can pass that
+ ``ArgumentParser`` to your ``ArgumentParser`` constructor::
+
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ parents=[tools.argparser])
+ flags = parser.parse_args(argv)
+
+ Args:
+ flow: Flow, an OAuth 2.0 Flow to step through.
+ storage: Storage, a ``Storage`` to store the credential in.
+ flags: ``argparse.Namespace``, (Optional) The command-line flags. This
+ is the object returned from calling ``parse_args()`` on
+ ``argparse.ArgumentParser`` as described above. Defaults
+ to ``argparser.parse_args()``.
+ http: An instance of ``httplib2.Http.request`` or something that
+ acts like it.
+
+ Returns:
+ Credentials, the obtained credential.
+ """
+ if flags is None:
+ flags = argparser.parse_args()
+ logging.getLogger().setLevel(getattr(logging, flags.logging_level))
+ if not flags.noauth_local_webserver:
+ success = False
+ port_number = 0
+ for port in flags.auth_host_port:
+ port_number = port
+ try:
+ httpd = ClientRedirectServer((flags.auth_host_name, port),
+ ClientRedirectHandler)
+ except socket.error:
+ pass
+ else:
+ success = True
+ break
+ flags.noauth_local_webserver = not success
+ if not success:
+ print(_FAILED_START_MESSAGE)
+
+ if not flags.noauth_local_webserver:
+ oauth_callback = 'http://{host}:{port}/'.format(
+ host=flags.auth_host_name, port=port_number)
+ else:
+ oauth_callback = client.OOB_CALLBACK_URN
+ flow.redirect_uri = oauth_callback
+ authorize_url = flow.step1_get_authorize_url()
+
+ if not flags.noauth_local_webserver:
+ import webbrowser
+ webbrowser.open(authorize_url, new=1, autoraise=True)
+ print(_BROWSER_OPENED_MESSAGE.format(address=authorize_url))
+ else:
+ print(_GO_TO_LINK_MESSAGE.format(address=authorize_url))
+
+ code = None
+ if not flags.noauth_local_webserver:
+ httpd.handle_request()
+ if 'error' in httpd.query_params:
+ sys.exit('Authentication request was rejected.')
+ if 'code' in httpd.query_params:
+ code = httpd.query_params['code']
+ else:
+ print('Failed to find "code" in the query parameters '
+ 'of the redirect.')
+ sys.exit('Try running with --noauth_local_webserver.')
+ else:
+ code = input('Enter verification code: ').strip()
+
+ try:
+ credential = flow.step2_exchange(code, http=http)
+ except client.FlowExchangeError as e:
+ sys.exit('Authentication has failed: {0}'.format(e))
+
+ storage.put(credential)
+ credential.set_store(storage)
+ print('Authentication successful.')
+
+ return credential
+
+
+def message_if_missing(filename):
+ """Helpful message to display if the CLIENT_SECRETS file is missing."""
+ return _CLIENT_SECRETS_MESSAGE.format(file_path=filename)
diff --git a/oauth2client/transport.py b/oauth2client/transport.py
new file mode 100644
index 0000000..8dbc60d
--- /dev/null
+++ b/oauth2client/transport.py
@@ -0,0 +1,245 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import logging
+
+import httplib2
+import six
+from six.moves import http_client
+
+from oauth2client._helpers import _to_bytes
+
+
+_LOGGER = logging.getLogger(__name__)
+# Properties present in file-like streams / buffers.
+_STREAM_PROPERTIES = ('read', 'seek', 'tell')
+
+# Google Data client libraries may need to set this to [401, 403].
+REFRESH_STATUS_CODES = (http_client.UNAUTHORIZED,)
+
+
+class MemoryCache(object):
+ """httplib2 Cache implementation which only caches locally."""
+
+ def __init__(self):
+ self.cache = {}
+
+ def get(self, key):
+ return self.cache.get(key)
+
+ def set(self, key, value):
+ self.cache[key] = value
+
+ def delete(self, key):
+ self.cache.pop(key, None)
+
+
+def get_cached_http():
+ """Return an HTTP object which caches results returned.
+
+ This is intended to be used in methods like
+ oauth2client.client.verify_id_token(), which calls to the same URI
+ to retrieve certs.
+
+ Returns:
+ httplib2.Http, an HTTP object with a MemoryCache
+ """
+ return _CACHED_HTTP
+
+
+def get_http_object():
+ """Return a new HTTP object.
+
+ Returns:
+ httplib2.Http, an HTTP object.
+ """
+ return httplib2.Http()
+
+
+def _initialize_headers(headers):
+ """Creates a copy of the headers.
+
+ Args:
+ headers: dict, request headers to copy.
+
+ Returns:
+ dict, the copied headers or a new dictionary if the headers
+ were None.
+ """
+ return {} if headers is None else dict(headers)
+
+
+def _apply_user_agent(headers, user_agent):
+ """Adds a user-agent to the headers.
+
+ Args:
+ headers: dict, request headers to add / modify user
+ agent within.
+ user_agent: str, the user agent to add.
+
+ Returns:
+ dict, the original headers passed in, but modified if the
+ user agent is not None.
+ """
+ if user_agent is not None:
+ if 'user-agent' in headers:
+ headers['user-agent'] = (user_agent + ' ' + headers['user-agent'])
+ else:
+ headers['user-agent'] = user_agent
+
+ return headers
+
+
+def clean_headers(headers):
+ """Forces header keys and values to be strings, i.e not unicode.
+
+ The httplib module just concats the header keys and values in a way that
+ may make the message header a unicode string, which, if it then tries to
+ contatenate to a binary request body may result in a unicode decode error.
+
+ Args:
+ headers: dict, A dictionary of headers.
+
+ Returns:
+ The same dictionary but with all the keys converted to strings.
+ """
+ clean = {}
+ try:
+ for k, v in six.iteritems(headers):
+ if not isinstance(k, six.binary_type):
+ k = str(k)
+ if not isinstance(v, six.binary_type):
+ v = str(v)
+ clean[_to_bytes(k)] = _to_bytes(v)
+ except UnicodeEncodeError:
+ from oauth2client.client import NonAsciiHeaderError
+ raise NonAsciiHeaderError(k, ': ', v)
+ return clean
+
+
+def wrap_http_for_auth(credentials, http):
+ """Prepares an HTTP object's request method for auth.
+
+ Wraps HTTP requests with logic to catch auth failures (typically
+ identified via a 401 status code). In the event of failure, tries
+ to refresh the token used and then retry the original request.
+
+ Args:
+ credentials: Credentials, the credentials used to identify
+ the authenticated user.
+ http: httplib2.Http, an http object to be used to make
+ auth requests.
+ """
+ orig_request_method = http.request
+
+ # The closure that will replace 'httplib2.Http.request'.
+ def new_request(uri, method='GET', body=None, headers=None,
+ redirections=httplib2.DEFAULT_MAX_REDIRECTS,
+ connection_type=None):
+ if not credentials.access_token:
+ _LOGGER.info('Attempting refresh to obtain '
+ 'initial access_token')
+ credentials._refresh(orig_request_method)
+
+ # Clone and modify the request headers to add the appropriate
+ # Authorization header.
+ headers = _initialize_headers(headers)
+ credentials.apply(headers)
+ _apply_user_agent(headers, credentials.user_agent)
+
+ body_stream_position = None
+ # Check if the body is a file-like stream.
+ if all(getattr(body, stream_prop, None) for stream_prop in
+ _STREAM_PROPERTIES):
+ body_stream_position = body.tell()
+
+ resp, content = orig_request_method(uri, method, body,
+ clean_headers(headers),
+ redirections, connection_type)
+
+ # A stored token may expire between the time it is retrieved and
+ # the time the request is made, so we may need to try twice.
+ max_refresh_attempts = 2
+ for refresh_attempt in range(max_refresh_attempts):
+ if resp.status not in REFRESH_STATUS_CODES:
+ break
+ _LOGGER.info('Refreshing due to a %s (attempt %s/%s)',
+ resp.status, refresh_attempt + 1,
+ max_refresh_attempts)
+ credentials._refresh(orig_request_method)
+ credentials.apply(headers)
+ if body_stream_position is not None:
+ body.seek(body_stream_position)
+
+ resp, content = orig_request_method(uri, method, body,
+ clean_headers(headers),
+ redirections, connection_type)
+
+ return resp, content
+
+ # Replace the request method with our own closure.
+ http.request = new_request
+
+ # Set credentials as a property of the request method.
+ setattr(http.request, 'credentials', credentials)
+
+
+def wrap_http_for_jwt_access(credentials, http):
+ """Prepares an HTTP object's request method for JWT access.
+
+ Wraps HTTP requests with logic to catch auth failures (typically
+ identified via a 401 status code). In the event of failure, tries
+ to refresh the token used and then retry the original request.
+
+ Args:
+ credentials: _JWTAccessCredentials, the credentials used to identify
+ a service account that uses JWT access tokens.
+ http: httplib2.Http, an http object to be used to make
+ auth requests.
+ """
+ orig_request_method = http.request
+ wrap_http_for_auth(credentials, http)
+ # The new value of ``http.request`` set by ``wrap_http_for_auth``.
+ authenticated_request_method = http.request
+
+ # The closure that will replace 'httplib2.Http.request'.
+ def new_request(uri, method='GET', body=None, headers=None,
+ redirections=httplib2.DEFAULT_MAX_REDIRECTS,
+ connection_type=None):
+ if 'aud' in credentials._kwargs:
+ # Preemptively refresh token, this is not done for OAuth2
+ if (credentials.access_token is None or
+ credentials.access_token_expired):
+ credentials.refresh(None)
+ return authenticated_request_method(uri, method, body,
+ headers, redirections,
+ connection_type)
+ else:
+ # If we don't have an 'aud' (audience) claim,
+ # create a 1-time token with the uri root as the audience
+ headers = _initialize_headers(headers)
+ _apply_user_agent(headers, credentials.user_agent)
+ uri_root = uri.split('?', 1)[0]
+ token, unused_expiry = credentials._create_token({'aud': uri_root})
+
+ headers['Authorization'] = 'Bearer ' + token
+ return orig_request_method(uri, method, body,
+ clean_headers(headers),
+ redirections, connection_type)
+
+ # Replace the request method with our own closure.
+ http.request = new_request
+
+
+_CACHED_HTTP = httplib2.Http(MemoryCache())
diff --git a/oauth2client/util.py b/oauth2client/util.py
new file mode 100644
index 0000000..e3ba62b
--- /dev/null
+++ b/oauth2client/util.py
@@ -0,0 +1,206 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Common utility library."""
+
+import functools
+import inspect
+import logging
+
+import six
+from six.moves import urllib
+
+
+__author__ = [
+ 'rafek@google.com (Rafe Kaplan)',
+ 'guido@google.com (Guido van Rossum)',
+]
+
+__all__ = [
+ 'positional',
+ 'POSITIONAL_WARNING',
+ 'POSITIONAL_EXCEPTION',
+ 'POSITIONAL_IGNORE',
+]
+
+logger = logging.getLogger(__name__)
+
+POSITIONAL_WARNING = 'WARNING'
+POSITIONAL_EXCEPTION = 'EXCEPTION'
+POSITIONAL_IGNORE = 'IGNORE'
+POSITIONAL_SET = frozenset([POSITIONAL_WARNING, POSITIONAL_EXCEPTION,
+ POSITIONAL_IGNORE])
+
+positional_parameters_enforcement = POSITIONAL_WARNING
+
+
+def positional(max_positional_args):
+ """A decorator to declare that only the first N arguments my be positional.
+
+ This decorator makes it easy to support Python 3 style keyword-only
+ parameters. For example, in Python 3 it is possible to write::
+
+ def fn(pos1, *, kwonly1=None, kwonly1=None):
+ ...
+
+ All named parameters after ``*`` must be a keyword::
+
+ fn(10, 'kw1', 'kw2') # Raises exception.
+ fn(10, kwonly1='kw1') # Ok.
+
+ Example
+ ^^^^^^^
+
+ To define a function like above, do::
+
+ @positional(1)
+ def fn(pos1, kwonly1=None, kwonly2=None):
+ ...
+
+ If no default value is provided to a keyword argument, it becomes a
+ required keyword argument::
+
+ @positional(0)
+ def fn(required_kw):
+ ...
+
+ This must be called with the keyword parameter::
+
+ fn() # Raises exception.
+ fn(10) # Raises exception.
+ fn(required_kw=10) # Ok.
+
+ When defining instance or class methods always remember to account for
+ ``self`` and ``cls``::
+
+ class MyClass(object):
+
+ @positional(2)
+ def my_method(self, pos1, kwonly1=None):
+ ...
+
+ @classmethod
+ @positional(2)
+ def my_method(cls, pos1, kwonly1=None):
+ ...
+
+ The positional decorator behavior is controlled by
+ ``util.positional_parameters_enforcement``, which may be set to
+ ``POSITIONAL_EXCEPTION``, ``POSITIONAL_WARNING`` or
+ ``POSITIONAL_IGNORE`` to raise an exception, log a warning, or do
+ nothing, respectively, if a declaration is violated.
+
+ Args:
+ max_positional_arguments: Maximum number of positional arguments. All
+ parameters after the this index must be
+ keyword only.
+
+ Returns:
+ A decorator that prevents using arguments after max_positional_args
+ from being used as positional parameters.
+
+ Raises:
+ TypeError: if a key-word only argument is provided as a positional
+ parameter, but only if
+ util.positional_parameters_enforcement is set to
+ POSITIONAL_EXCEPTION.
+ """
+
+ def positional_decorator(wrapped):
+ @functools.wraps(wrapped)
+ def positional_wrapper(*args, **kwargs):
+ if len(args) > max_positional_args:
+ plural_s = ''
+ if max_positional_args != 1:
+ plural_s = 's'
+ message = ('{function}() takes at most {args_max} positional '
+ 'argument{plural} ({args_given} given)'.format(
+ function=wrapped.__name__,
+ args_max=max_positional_args,
+ args_given=len(args),
+ plural=plural_s))
+ if positional_parameters_enforcement == POSITIONAL_EXCEPTION:
+ raise TypeError(message)
+ elif positional_parameters_enforcement == POSITIONAL_WARNING:
+ logger.warning(message)
+ return wrapped(*args, **kwargs)
+ return positional_wrapper
+
+ if isinstance(max_positional_args, six.integer_types):
+ return positional_decorator
+ else:
+ args, _, _, defaults = inspect.getargspec(max_positional_args)
+ return positional(len(args) - len(defaults))(max_positional_args)
+
+
+def scopes_to_string(scopes):
+ """Converts scope value to a string.
+
+ If scopes is a string then it is simply passed through. If scopes is an
+ iterable then a string is returned that is all the individual scopes
+ concatenated with spaces.
+
+ Args:
+ scopes: string or iterable of strings, the scopes.
+
+ Returns:
+ The scopes formatted as a single string.
+ """
+ if isinstance(scopes, six.string_types):
+ return scopes
+ else:
+ return ' '.join(scopes)
+
+
+def string_to_scopes(scopes):
+ """Converts stringifed scope value to a list.
+
+ If scopes is a list then it is simply passed through. If scopes is an
+ string then a list of each individual scope is returned.
+
+ Args:
+ scopes: a string or iterable of strings, the scopes.
+
+ Returns:
+ The scopes in a list.
+ """
+ if not scopes:
+ return []
+ if isinstance(scopes, six.string_types):
+ return scopes.split(' ')
+ else:
+ return scopes
+
+
+def _add_query_parameter(url, name, value):
+ """Adds a query parameter to a url.
+
+ Replaces the current value if it already exists in the URL.
+
+ Args:
+ url: string, url to add the query parameter to.
+ name: string, query parameter name.
+ value: string, query parameter value.
+
+ Returns:
+ Updated query parameter. Does not update the url if value is None.
+ """
+ if value is None:
+ return url
+ else:
+ parsed = list(urllib.parse.urlparse(url))
+ q = dict(urllib.parse.parse_qsl(parsed[4]))
+ q[name] = value
+ parsed[4] = urllib.parse.urlencode(q)
+ return urllib.parse.urlunparse(parsed)
diff --git a/samples/call_compute_service.py b/samples/call_compute_service.py
new file mode 100644
index 0000000..72beef0
--- /dev/null
+++ b/samples/call_compute_service.py
@@ -0,0 +1,22 @@
+# To be used to test GoogleCredentials.get_application_default()
+# from local machine and GCE.
+# The GCE virtual machine needs to have both service account and
+# Compute API enabled.
+# See: https://developers.google.com/compute/docs/authentication
+
+from googleapiclient.discovery import build
+
+from oauth2client.client import GoogleCredentials
+
+
+PROJECT = 'bamboo-machine-422' # Provide your own GCE project here
+ZONE = 'us-central1-a' # Put here a zone which has some VMs
+
+
+credentials = GoogleCredentials.get_application_default()
+service = build('compute', 'v1', credentials=credentials)
+
+request = service.instances().list(project=PROJECT, zone=ZONE)
+response = request.execute()
+
+print(response)
diff --git a/samples/googleappengine/app.yaml b/samples/googleappengine/app.yaml
new file mode 100644
index 0000000..a299030
--- /dev/null
+++ b/samples/googleappengine/app.yaml
@@ -0,0 +1,10 @@
+application: bamboo-machine-422
+version: 2
+runtime: python27
+api_version: 1
+threadsafe: true
+
+handlers:
+- url: /.*
+ script: call_compute_service_from_gae.app
+
diff --git a/samples/googleappengine/call_compute_service_from_gae.py b/samples/googleappengine/call_compute_service_from_gae.py
new file mode 100644
index 0000000..3557f58
--- /dev/null
+++ b/samples/googleappengine/call_compute_service_from_gae.py
@@ -0,0 +1,27 @@
+# To be used to test GoogleCredentials.get_application_default()
+# from devel GAE (ie, dev_appserver.py).
+
+from googleapiclient.discovery import build
+import webapp2
+
+from oauth2client.client import GoogleCredentials
+
+
+PROJECT = 'bamboo-machine-422' # Provide your own GCE project here
+ZONE = 'us-central1-a' # Put here a zone which has some VMs
+
+
+def get_instances():
+ credentials = GoogleCredentials.get_application_default()
+ service = build('compute', 'v1', credentials=credentials)
+ request = service.instances().list(project=PROJECT, zone=ZONE)
+ return request.execute()
+
+
+class MainPage(webapp2.RequestHandler):
+
+ def get(self):
+ self.response.write(get_instances())
+
+
+app = webapp2.WSGIApplication([('/', MainPage), ], debug=True)
diff --git a/samples/oauth2_for_devices.py b/samples/oauth2_for_devices.py
new file mode 100644
index 0000000..6e70ff1
--- /dev/null
+++ b/samples/oauth2_for_devices.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+# See: https://developers.google.com/accounts/docs/OAuth2ForDevices
+
+from googleapiclient.discovery import build
+import httplib2
+from six.moves import input
+
+from oauth2client.client import OAuth2WebServerFlow
+
+CLIENT_ID = "some+client+id"
+CLIENT_SECRET = "some+client+secret"
+SCOPES = ("https://www.googleapis.com/auth/youtube",)
+
+flow = OAuth2WebServerFlow(CLIENT_ID, CLIENT_SECRET, " ".join(SCOPES))
+
+# Step 1: get user code and verification URL
+# https://developers.google.com/accounts/docs/OAuth2ForDevices#obtainingacode
+flow_info = flow.step1_get_device_and_user_codes()
+print("Enter the following code at {0}: {1}".format(flow_info.verification_url,
+ flow_info.user_code))
+print("Then press Enter.")
+input()
+
+# Step 2: get credentials
+# https://developers.google.com/accounts/docs/OAuth2ForDevices#obtainingatoken
+credentials = flow.step2_exchange(device_flow_info=flow_info)
+print("Access token: {0}".format(credentials.access_token))
+print("Refresh token: {0}".format(credentials.refresh_token))
+
+# Get YouTube service
+# https://developers.google.com/accounts/docs/OAuth2ForDevices#callinganapi
+youtube = build("youtube", "v3", http=credentials.authorize(httplib2.Http()))
diff --git a/scripts/build_docs.sh b/scripts/build_docs.sh
new file mode 100755
index 0000000..170b113
--- /dev/null
+++ b/scripts/build_docs.sh
@@ -0,0 +1,34 @@
+#!/bin/bash
+#
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+#
+# Build the oauth2client docs.
+
+set -e
+
+rm -rf docs/_build/* docs/source/*
+sphinx-apidoc --separate --force -o docs/source oauth2client
+# We only have one package, so modules.rst is overkill.
+rm -f docs/source/modules.rst
+
+# If anything has changed
+if [[ -n "$(git diff -- docs/)" ]]; then
+ echo "sphinx-apidoc generated changes that are not checked in to version control."
+ exit 1
+fi
+
+cd docs
+make html
+cd ..
diff --git a/scripts/fetch_gae_sdk.py b/scripts/fetch_gae_sdk.py
new file mode 100755
index 0000000..24a6db5
--- /dev/null
+++ b/scripts/fetch_gae_sdk.py
@@ -0,0 +1,85 @@
+#!/usr/bin/env python
+"""Fetch the most recent GAE SDK and decompress it in the current directory.
+
+Usage:
+ fetch_gae_sdk.py [<dest_dir>]
+
+Current releases are listed here:
+ https://www.googleapis.com/storage/v1/b/appengine-sdks/o?prefix=featured
+"""
+from __future__ import print_function
+
+import json
+import os
+import StringIO
+import sys
+import urllib2
+import zipfile
+
+
+_SDK_URL = (
+ 'https://www.googleapis.com/storage/v1/b/appengine-sdks/o?prefix=featured')
+
+
+def get_gae_versions():
+ try:
+ version_info_json = urllib2.urlopen(_SDK_URL).read()
+ except:
+ return {}
+ try:
+ version_info = json.loads(version_info_json)
+ except:
+ return {}
+ return version_info.get('items', {})
+
+
+def _version_tuple(v):
+ version_string = os.path.splitext(v['name'])[0].rpartition('_')[2]
+ return tuple(int(x) for x in version_string.split('.'))
+
+
+def get_sdk_urls(sdk_versions):
+ python_releases = [v for v in sdk_versions
+ if v['name'].startswith('featured/google_appengine')]
+ current_releases = sorted(python_releases, key=_version_tuple,
+ reverse=True)
+ return [release['mediaLink'] for release in current_releases]
+
+
+def main(argv):
+ if len(argv) > 2:
+ print('Usage: {0} [<destination_dir>]'.format(argv[0]))
+ return 1
+ dest_dir = argv[1] if len(argv) > 1 else '.'
+ if not os.path.exists(dest_dir):
+ os.makedirs(dest_dir)
+
+ if os.path.exists(os.path.join(dest_dir, 'google_appengine')):
+ print('GAE SDK already installed at {0}, exiting.'.format(dest_dir))
+ return 0
+
+ sdk_versions = get_gae_versions()
+ if not sdk_versions:
+ print('Error fetching GAE SDK version info')
+ return 1
+ sdk_urls = get_sdk_urls(sdk_versions)
+ for sdk_url in sdk_urls:
+ try:
+ sdk_contents = StringIO.StringIO(urllib2.urlopen(sdk_url).read())
+ break
+ except:
+ pass
+ else:
+ print('Could not read SDK from any of ', sdk_urls)
+ return 1
+ sdk_contents.seek(0)
+ try:
+ zip_contents = zipfile.ZipFile(sdk_contents)
+ zip_contents.extractall(dest_dir)
+ except:
+ print('Error extracting SDK contents')
+ return 1
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv[:]))
diff --git a/scripts/install.sh b/scripts/install.sh
new file mode 100755
index 0000000..0ef7ad2
--- /dev/null
+++ b/scripts/install.sh
@@ -0,0 +1,31 @@
+#!/bin/bash
+
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+set -ev
+
+pip install tox
+if [[ "${TOX_ENV}" == "pypy" ]]; then
+ git clone https://github.com/yyuu/pyenv.git ${HOME}/.pyenv
+ PYENV_ROOT="${HOME}/.pyenv"
+ PATH="${PYENV_ROOT}/bin:${PATH}"
+ eval "$(pyenv init -)"
+ pyenv install pypy-2.6.0
+ pyenv global pypy-2.6.0
+fi
+
+if [[ "${TOX_ENV}" == "gae" && ! -d ${GAE_PYTHONPATH} ]]; then
+ python scripts/fetch_gae_sdk.py `dirname ${GAE_PYTHONPATH}`
+fi
diff --git a/scripts/local_test_setup.sample b/scripts/local_test_setup.sample
new file mode 100644
index 0000000..a7a3d10
--- /dev/null
+++ b/scripts/local_test_setup.sample
@@ -0,0 +1,5 @@
+export OAUTH2CLIENT_TEST_JSON_KEY_PATH="tests/data/gcloud/application_default_credentials.json"
+export OAUTH2CLIENT_TEST_P12_KEY_PATH="tests/data/privatekey.p12"
+export OAUTH2CLIENT_TEST_P12_KEY_EMAIL="project-foo@developer.gserviceaccount.com"
+export OAUTH2CLIENT_TEST_USER_KEY_PATH="tests/data/gcloud/application_default_credentials_authorized_user.json"
+export OAUTH2CLIENT_TEST_USER_KEY_EMAIL="foo@gmail.com"
diff --git a/scripts/run.sh b/scripts/run.sh
new file mode 100755
index 0000000..0b537e2
--- /dev/null
+++ b/scripts/run.sh
@@ -0,0 +1,25 @@
+#!/bin/bash
+
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+set -ev
+
+if [[ "${TOX_ENV}" == "pypy" ]]; then
+ PYENV_ROOT="${HOME}/.pyenv"
+ PATH="${PYENV_ROOT}/bin:${PATH}"
+ eval "$(pyenv init -)"
+ pyenv global pypy-2.6.0
+fi
+tox -e ${TOX_ENV}
diff --git a/scripts/run_gce_system_tests.py b/scripts/run_gce_system_tests.py
new file mode 100644
index 0000000..d446f9c
--- /dev/null
+++ b/scripts/run_gce_system_tests.py
@@ -0,0 +1,56 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import json
+
+import httplib2
+from six.moves import http_client
+from six.moves import urllib
+import unittest2
+
+from oauth2client import GOOGLE_TOKEN_INFO_URI
+from oauth2client.client import GoogleCredentials
+from oauth2client.contrib.gce import AppAssertionCredentials
+
+
+class TestComputeEngine(unittest2.TestCase):
+
+ def test_application_default(self):
+ default_creds = GoogleCredentials.get_application_default()
+ self.assertIsInstance(default_creds, AppAssertionCredentials)
+
+ def test_token_info(self):
+ credentials = AppAssertionCredentials([])
+ http = httplib2.Http()
+
+ # First refresh to get the access token.
+ self.assertIsNone(credentials.access_token)
+ credentials.refresh(http)
+ self.assertIsNotNone(credentials.access_token)
+
+ # Then check the access token against the token info API.
+ query_params = {'access_token': credentials.access_token}
+ token_uri = (GOOGLE_TOKEN_INFO_URI + '?' +
+ urllib.parse.urlencode(query_params))
+ response, content = http.request(token_uri)
+ self.assertEqual(response.status, http_client.OK)
+
+ content = content.decode('utf-8')
+ payload = json.loads(content)
+ self.assertEqual(payload['access_type'], 'offline')
+ self.assertLessEqual(int(payload['expires_in']), 3600)
+
+
+if __name__ == '__main__':
+ unittest2.main()
diff --git a/scripts/run_system_tests.py b/scripts/run_system_tests.py
new file mode 100644
index 0000000..ce99e7c
--- /dev/null
+++ b/scripts/run_system_tests.py
@@ -0,0 +1,108 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import json
+import os
+
+import httplib2
+from six.moves import http_client
+
+import oauth2client
+from oauth2client import client
+from oauth2client.service_account import ServiceAccountCredentials
+
+
+JSON_KEY_PATH = os.getenv('OAUTH2CLIENT_TEST_JSON_KEY_PATH')
+P12_KEY_PATH = os.getenv('OAUTH2CLIENT_TEST_P12_KEY_PATH')
+P12_KEY_EMAIL = os.getenv('OAUTH2CLIENT_TEST_P12_KEY_EMAIL')
+USER_KEY_PATH = os.getenv('OAUTH2CLIENT_TEST_USER_KEY_PATH')
+USER_KEY_EMAIL = os.getenv('OAUTH2CLIENT_TEST_USER_KEY_EMAIL')
+
+SCOPE = ('https://www.googleapis.com/auth/plus.login',
+ 'https://www.googleapis.com/auth/plus.me',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile')
+USER_INFO = 'https://www.googleapis.com/oauth2/v2/userinfo'
+
+
+def _require_environ():
+ if (JSON_KEY_PATH is None or P12_KEY_PATH is None or
+ P12_KEY_EMAIL is None or USER_KEY_PATH is None or
+ USER_KEY_EMAIL is None):
+ raise EnvironmentError('Expected environment variables to be set:',
+ 'OAUTH2CLIENT_TEST_JSON_KEY_PATH',
+ 'OAUTH2CLIENT_TEST_P12_KEY_PATH',
+ 'OAUTH2CLIENT_TEST_P12_KEY_EMAIL',
+ 'OAUTH2CLIENT_TEST_USER_KEY_PATH',
+ 'OAUTH2CLIENT_TEST_USER_KEY_EMAIL')
+
+ if not os.path.isfile(JSON_KEY_PATH):
+ raise EnvironmentError(JSON_KEY_PATH, 'is not a file')
+ if not os.path.isfile(P12_KEY_PATH):
+ raise EnvironmentError(P12_KEY_PATH, 'is not a file')
+ if not os.path.isfile(USER_KEY_PATH):
+ raise EnvironmentError(USER_KEY_PATH, 'is not a file')
+
+
+def _check_user_info(credentials, expected_email):
+ http = credentials.authorize(httplib2.Http())
+ response, content = http.request(USER_INFO)
+ if response.status != http_client.OK:
+ raise ValueError('Expected 200 OK response.')
+
+ content = content.decode('utf-8')
+ payload = json.loads(content)
+ if payload['email'] != expected_email:
+ raise ValueError('User info email does not match credentials.')
+
+
+def run_json():
+ credentials = ServiceAccountCredentials.from_json_keyfile_name(
+ JSON_KEY_PATH, scopes=SCOPE)
+ service_account_email = credentials._service_account_email
+ _check_user_info(credentials, service_account_email)
+
+
+def run_p12():
+ credentials = ServiceAccountCredentials.from_p12_keyfile(
+ P12_KEY_EMAIL, P12_KEY_PATH, scopes=SCOPE)
+ _check_user_info(credentials, P12_KEY_EMAIL)
+
+
+def run_user_json():
+ with open(USER_KEY_PATH, 'r') as file_object:
+ client_credentials = json.load(file_object)
+
+ credentials = client.GoogleCredentials(
+ access_token=None,
+ client_id=client_credentials['client_id'],
+ client_secret=client_credentials['client_secret'],
+ refresh_token=client_credentials['refresh_token'],
+ token_expiry=None,
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ user_agent='Python client library',
+ )
+
+ _check_user_info(credentials, USER_KEY_EMAIL)
+
+
+def main():
+ _require_environ()
+ run_json()
+ run_p12()
+ run_user_json()
+
+
+if __name__ == '__main__':
+ main()
diff --git a/scripts/run_system_tests.sh b/scripts/run_system_tests.sh
new file mode 100755
index 0000000..7169eb7
--- /dev/null
+++ b/scripts/run_system_tests.sh
@@ -0,0 +1,48 @@
+#!/bin/bash
+
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+set -ev
+
+
+# If we're on Travis, we need to set up the environment.
+if [[ "${TRAVIS}" == "true" ]]; then
+ # If merging to master and not a pull request, run system test.
+ if [[ "${TRAVIS_BRANCH}" == "master" ]] && \
+ [[ "${TRAVIS_PULL_REQUEST}" == "false" ]]; then
+ echo "Running in Travis during merge, decrypting stored key file."
+ # Convert encrypted JSON key file into decrypted file to be used.
+ openssl aes-256-cbc -K ${OAUTH2CLIENT_KEY} \
+ -iv ${OAUTH2CLIENT_IV} \
+ -in tests/data/key.json.enc \
+ -out ${OAUTH2CLIENT_TEST_JSON_KEY_PATH} -d
+ # Convert encrypted P12 key file into decrypted file to be used.
+ openssl aes-256-cbc -K ${OAUTH2CLIENT_KEY} \
+ -iv ${OAUTH2CLIENT_IV} \
+ -in tests/data/key.p12.enc \
+ -out ${OAUTH2CLIENT_TEST_P12_KEY_PATH} -d
+ # Convert encrypted User JSON key file into decrypted file to be used.
+ openssl aes-256-cbc -K ${OAUTH2CLIENT_KEY} \
+ -iv ${OAUTH2CLIENT_IV} \
+ -in tests/data/user-key.json.enc \
+ -out ${OAUTH2CLIENT_TEST_USER_KEY_PATH} -d
+ else
+ echo "Running in Travis during non-merge to master, doing nothing."
+ exit
+ fi
+fi
+
+# Run the system tests for each tested package.
+python scripts/run_system_tests.py
diff --git a/setup.py b/setup.py
new file mode 100644
index 0000000..686d1db
--- /dev/null
+++ b/setup.py
@@ -0,0 +1,72 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Setup script for oauth2client.
+
+Also installs included versions of third party libraries, if those libraries
+are not already installed.
+"""
+from __future__ import print_function
+
+import sys
+
+from setuptools import find_packages
+from setuptools import setup
+
+import oauth2client
+
+if sys.version_info < (2, 6):
+ print('oauth2client requires python2 version >= 2.6.', file=sys.stderr)
+ sys.exit(1)
+if (3, 1) <= sys.version_info < (3, 3):
+ print('oauth2client requires python3 version >= 3.3.', file=sys.stderr)
+ sys.exit(1)
+
+install_requires = [
+ 'httplib2>=0.9.1',
+ 'pyasn1>=0.1.7',
+ 'pyasn1-modules>=0.0.5',
+ 'rsa>=3.1.4',
+ 'six>=1.6.1',
+]
+
+long_desc = """The oauth2client is a client library for OAuth 2.0."""
+
+version = oauth2client.__version__
+
+setup(
+ name="oauth2client",
+ version=version,
+ description="OAuth 2.0 client library",
+ long_description=long_desc,
+ author="Google Inc.",
+ url="http://github.com/google/oauth2client/",
+ install_requires=install_requires,
+ packages=find_packages(),
+ license="Apache 2.0",
+ keywords="google oauth 2.0 http client",
+ classifiers=[
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Development Status :: 5 - Production/Stable',
+ 'Intended Audience :: Developers',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX',
+ 'Topic :: Internet :: WWW/HTTP',
+ ],
+)
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 0000000..5f6567c
--- /dev/null
+++ b/tests/__init__.py
@@ -0,0 +1,22 @@
+# 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.
+
+"""Test package set-up."""
+
+from oauth2client import util
+
+__author__ = 'afshar@google.com (Ali Afshar)'
+
+
+def setup_package():
+ """Run on testing package."""
+ util.positional_parameters_enforcement = util.POSITIONAL_EXCEPTION
diff --git a/tests/contrib/__init__.py b/tests/contrib/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/contrib/__init__.py
diff --git a/tests/contrib/django_util/__init__.py b/tests/contrib/django_util/__init__.py
new file mode 100644
index 0000000..378c6ff
--- /dev/null
+++ b/tests/contrib/django_util/__init__.py
@@ -0,0 +1,44 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Setups the Django test environment and provides helper classes."""
+
+import django
+from django import test
+from django.contrib.sessions.backends.file import SessionStore
+from django.test.runner import DiscoverRunner
+
+django.setup()
+default_app_config = 'tests.contrib.django_util.apps.AppConfig'
+
+
+class TestWithDjangoEnvironment(test.TestCase):
+ @classmethod
+ def setUpClass(cls):
+ django.setup()
+ cls.runner = DiscoverRunner()
+ cls.runner.setup_test_environment()
+ cls.old_config = cls.runner.setup_databases()
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.runner.teardown_databases(cls.old_config)
+ cls.runner.teardown_test_environment()
+
+ def setUp(self):
+ self.factory = test.RequestFactory()
+
+ store = SessionStore()
+ store.save()
+ self.session = store
diff --git a/tests/contrib/django_util/apps.py b/tests/contrib/django_util/apps.py
new file mode 100644
index 0000000..da74dd5
--- /dev/null
+++ b/tests/contrib/django_util/apps.py
@@ -0,0 +1,27 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Defines a configuration for our test application.
+
+Having a test application enables us to use the Django test database and
+other useful features."""
+
+from django.apps import AppConfig
+
+
+class DjangoOrmTestApp(AppConfig):
+ """App Config for Django Helper."""
+ name = 'tests.contrib.django_util'
+ verbose_name = "Django Test App"
+ label = "DjangoORMTestApp"
diff --git a/tests/contrib/django_util/models.py b/tests/contrib/django_util/models.py
new file mode 100644
index 0000000..50bf68b
--- /dev/null
+++ b/tests/contrib/django_util/models.py
@@ -0,0 +1,25 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Models used in our tests"""
+
+from django.contrib.auth.models import User
+from django.db import models
+
+from oauth2client.contrib.django_util.models import CredentialsField
+
+
+class CredentialsModel(models.Model):
+ user_id = models.OneToOneField(User)
+ credentials = CredentialsField()
diff --git a/tests/contrib/django_util/settings.py b/tests/contrib/django_util/settings.py
new file mode 100644
index 0000000..0c78634
--- /dev/null
+++ b/tests/contrib/django_util/settings.py
@@ -0,0 +1,47 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Provides a base Django settings module used by the rest of the tests."""
+
+import os
+
+INSTALLED_APPS = [
+ 'django.contrib.admin',
+ 'django.contrib.auth',
+ 'django.contrib.contenttypes',
+ 'django.contrib.sessions',
+ 'django.contrib.messages',
+ 'django.contrib.staticfiles',
+ 'oauth2client.contrib.django_util',
+ 'tests.contrib.django_util.apps.DjangoOrmTestApp',
+]
+
+SECRET_KEY = 'this string is not a real django secret key'
+DATABASES = {
+ 'default': {
+ 'ENGINE': 'django.db.backends.sqlite3',
+ 'NAME': os.path.join('.', 'db.sqlite3'),
+ }
+}
+MIDDLEWARE_CLASSES = (
+ 'django.contrib.sessions.middleware.SessionMiddleware'
+)
+
+ALLOWED_HOSTS = ['testserver']
+
+GOOGLE_OAUTH2_CLIENT_ID = 'client_id2'
+GOOGLE_OAUTH2_CLIENT_SECRET = 'hunter2'
+GOOGLE_OAUTH2_SCOPES = ('https://www.googleapis.com/auth/cloud-platform',)
+
+ROOT_URLCONF = 'tests.contrib.django_util.test_django_util'
diff --git a/tests/contrib/django_util/test_decorators.py b/tests/contrib/django_util/test_decorators.py
new file mode 100644
index 0000000..846c6dd
--- /dev/null
+++ b/tests/contrib/django_util/test_decorators.py
@@ -0,0 +1,246 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Tests for the django_util decorators."""
+
+import copy
+
+from django import http
+import django.conf
+from django.contrib.auth.models import AnonymousUser, User
+import mock
+from six.moves import http_client
+from six.moves import reload_module
+from six.moves.urllib import parse
+from tests.contrib.django_util import TestWithDjangoEnvironment
+
+import oauth2client.contrib.django_util
+from oauth2client.contrib.django_util import decorators
+
+
+class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(OAuth2EnabledDecoratorTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+
+ # OAuth2 Settings gets configured based on Django settings
+ # at import time, so in order for us to reload the settings
+ # we need to reload the module
+ reload_module(oauth2client.contrib.django_util)
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ def tearDown(self):
+ super(OAuth2EnabledDecoratorTest, self).tearDown()
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_no_credentials_without_credentials(self):
+ request = self.factory.get('/test')
+ request.session = self.session
+
+ @decorators.oauth_enabled
+ def test_view(request):
+ return http.HttpResponse("test") # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertEqual(response.status_code, http_client.OK)
+ self.assertIsNotNone(request.oauth)
+ self.assertFalse(request.oauth.has_credentials())
+ self.assertIsNone(request.oauth.http)
+
+ @mock.patch('oauth2client.client.OAuth2Credentials')
+ def test_has_credentials_in_storage(self, OAuth2Credentials):
+ request = self.factory.get('/test')
+ request.session = mock.MagicMock()
+
+ credentials_mock = mock.Mock(
+ scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES))
+ credentials_mock.has_scopes.return_value = True
+ credentials_mock.invalid = False
+ credentials_mock.scopes = set([])
+ OAuth2Credentials.from_json.return_value = credentials_mock
+
+ @decorators.oauth_enabled
+ def test_view(request):
+ return http.HttpResponse('test')
+
+ response = test_view(request)
+ self.assertEqual(response.status_code, http_client.OK)
+ self.assertEqual(response.content, b'test')
+ self.assertTrue(request.oauth.has_credentials())
+ self.assertIsNotNone(request.oauth.http)
+ self.assertSetEqual(
+ request.oauth.scopes,
+ set(django.conf.settings.GOOGLE_OAUTH2_SCOPES))
+
+ @mock.patch('oauth2client.contrib.dictionary_storage.DictionaryStorage')
+ def test_specified_scopes(self, dictionary_storage_mock):
+ request = self.factory.get('/test')
+ request.session = mock.MagicMock()
+
+ credentials_mock = mock.Mock(
+ scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES))
+ credentials_mock.has_scopes = True
+ credentials_mock.is_valid = True
+ dictionary_storage_mock.get.return_value = credentials_mock
+
+ @decorators.oauth_enabled(scopes=['additional-scope'])
+ def test_view(request):
+ return http.HttpResponse('hello world') # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertEqual(response.status_code, http_client.OK)
+ self.assertIsNotNone(request.oauth)
+ self.assertFalse(request.oauth.has_credentials())
+
+
+class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(OAuth2RequiredDecoratorTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+
+ reload_module(oauth2client.contrib.django_util)
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ def tearDown(self):
+ super(OAuth2RequiredDecoratorTest, self).tearDown()
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_redirects_without_credentials(self):
+ request = self.factory.get('/test')
+ request.session = self.session
+
+ @decorators.oauth_required
+ def test_view(request):
+ return http.HttpResponse('test') # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ self.assertEqual(parse.urlparse(response['Location']).path,
+ '/oauth2/oauth2authorize/')
+ self.assertIn(
+ 'return_url=%2Ftest', parse.urlparse(response['Location']).query)
+
+ self.assertEqual(response.status_code,
+ http.HttpResponseRedirect.status_code)
+
+ @mock.patch('oauth2client.contrib.django_util.UserOAuth2', autospec=True)
+ def test_has_credentials_in_storage(self, UserOAuth2):
+ request = self.factory.get('/test')
+ request.session = mock.MagicMock()
+
+ @decorators.oauth_required
+ def test_view(request):
+ return http.HttpResponse("test")
+
+ my_user_oauth = mock.MagicMock()
+
+ UserOAuth2.return_value = my_user_oauth
+ my_user_oauth.has_credentials.return_value = True
+
+ response = test_view(request)
+ self.assertEqual(response.status_code, http_client.OK)
+ self.assertEqual(response.content, b"test")
+
+ @mock.patch('oauth2client.client.OAuth2Credentials')
+ def test_has_credentials_in_storage_no_scopes(
+ self, OAuth2Credentials):
+ request = self.factory.get('/test')
+
+ request.session = mock.MagicMock()
+ credentials_mock = mock.Mock(
+ scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES))
+ credentials_mock.has_scopes.return_value = False
+
+ OAuth2Credentials.from_json.return_value = credentials_mock
+
+ @decorators.oauth_required
+ def test_view(request):
+ return http.HttpResponse("test") # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertEqual(
+ response.status_code, django.http.HttpResponseRedirect.status_code)
+
+ @mock.patch('oauth2client.client.OAuth2Credentials')
+ def test_specified_scopes(self, OAuth2Credentials):
+ request = self.factory.get('/test')
+ request.session = mock.MagicMock()
+
+ credentials_mock = mock.Mock(
+ scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES))
+ credentials_mock.has_scopes = False
+ OAuth2Credentials.from_json.return_value = credentials_mock
+
+ @decorators.oauth_required(scopes=['additional-scope'])
+ def test_view(request):
+ return http.HttpResponse("hello world") # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertEqual(
+ response.status_code, django.http.HttpResponseRedirect.status_code)
+
+
+class OAuth2RequiredDecoratorStorageModelTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(OAuth2RequiredDecoratorStorageModelTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+
+ STORAGE_MODEL = {
+ 'model': 'tests.contrib.django_util.models.CredentialsModel',
+ 'user_property': 'user_id',
+ 'credentials_property': 'credentials'
+ }
+ django.conf.settings.GOOGLE_OAUTH2_STORAGE_MODEL = STORAGE_MODEL
+
+ reload_module(oauth2client.contrib.django_util)
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ def tearDown(self):
+ super(OAuth2RequiredDecoratorStorageModelTest, self).tearDown()
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_redirects_anonymous_to_login(self):
+ request = self.factory.get('/test')
+ request.session = self.session
+ request.user = AnonymousUser()
+
+ @decorators.oauth_required
+ def test_view(request):
+ return http.HttpResponse("test") # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ self.assertEqual(parse.urlparse(response['Location']).path,
+ django.conf.settings.LOGIN_URL)
+
+ def test_redirects_user_to_oauth_authorize(self):
+ request = self.factory.get('/test')
+ request.session = self.session
+ request.user = User.objects.create_user(
+ username='bill3', email='bill@example.com', password='hunter2')
+
+ @decorators.oauth_required
+ def test_view(request):
+ return http.HttpResponse("test") # pragma: NO COVER
+
+ response = test_view(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ self.assertEqual(parse.urlparse(response['Location']).path,
+ '/oauth2/oauth2authorize/')
diff --git a/tests/contrib/django_util/test_django_models.py b/tests/contrib/django_util/test_django_models.py
new file mode 100644
index 0000000..aeaed15
--- /dev/null
+++ b/tests/contrib/django_util/test_django_models.py
@@ -0,0 +1,99 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Django model tests.
+
+Unit tests for models and fields defined by the django_util helper.
+"""
+
+import base64
+import pickle
+
+from tests.contrib.django_util.models import CredentialsModel
+
+import unittest2
+
+from oauth2client._helpers import _from_bytes
+from oauth2client.client import Credentials
+from oauth2client.contrib.django_util.models import CredentialsField
+
+
+class TestCredentialsField(unittest2.TestCase):
+
+ def setUp(self):
+ self.fake_model = CredentialsModel()
+ self.fake_model_field = self.fake_model._meta.get_field('credentials')
+ self.field = CredentialsField(null=True)
+ self.credentials = Credentials()
+ self.pickle_str = _from_bytes(
+ base64.b64encode(pickle.dumps(self.credentials)))
+
+ def test_field_is_text(self):
+ self.assertEqual(self.field.get_internal_type(), 'BinaryField')
+
+ def test_field_unpickled(self):
+ self.assertIsInstance(
+ self.field.to_python(self.pickle_str), Credentials)
+
+ def test_field_already_unpickled(self):
+ self.assertIsInstance(
+ self.field.to_python(self.credentials), Credentials)
+
+ def test_none_field_unpickled(self):
+ self.assertIsNone(self.field.to_python(None))
+
+ def test_from_db_value(self):
+ value = self.field.from_db_value(
+ self.pickle_str, None, None, None)
+ self.assertIsInstance(value, Credentials)
+
+ def test_field_unpickled_none(self):
+ self.assertEqual(self.field.to_python(None), None)
+
+ def test_field_pickled(self):
+ prep_value = self.field.get_db_prep_value(self.credentials,
+ connection=None)
+ self.assertEqual(prep_value, self.pickle_str)
+
+ def test_field_value_to_string(self):
+ self.fake_model.credentials = self.credentials
+ value_str = self.fake_model_field.value_to_string(self.fake_model)
+ self.assertEqual(value_str, self.pickle_str)
+
+ def test_field_value_to_string_none(self):
+ self.fake_model.credentials = None
+ value_str = self.fake_model_field.value_to_string(self.fake_model)
+ self.assertIsNone(value_str)
+
+ def test_credentials_without_null(self):
+ credentials = CredentialsField()
+ self.assertTrue(credentials.null)
+
+
+class CredentialWithSetStore(CredentialsField):
+ def __init__(self):
+ self.model = CredentialWithSetStore
+
+ def set_store(self, storage):
+ pass # pragma: NO COVER
+
+
+class FakeCredentialsModelMock(object):
+
+ credentials = CredentialWithSetStore()
+
+
+class FakeCredentialsModelMockNoSet(object):
+
+ credentials = CredentialsField()
diff --git a/tests/contrib/django_util/test_django_storage.py b/tests/contrib/django_util/test_django_storage.py
new file mode 100644
index 0000000..8f76b18
--- /dev/null
+++ b/tests/contrib/django_util/test_django_storage.py
@@ -0,0 +1,164 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Tests for the DjangoORM storage class."""
+
+# Mock a Django environment
+import datetime
+
+from django.db import models
+import mock
+import unittest2
+
+from oauth2client import GOOGLE_TOKEN_URI
+from oauth2client.client import OAuth2Credentials
+from oauth2client.contrib.django_util.models import CredentialsField
+from oauth2client.contrib.django_util.storage import (
+ DjangoORMStorage as Storage)
+
+
+class TestStorage(unittest2.TestCase):
+ def setUp(self):
+ access_token = 'foo'
+ client_id = 'some_client_id'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ user_agent = 'refresh_checker/1.0'
+
+ self.credentials = OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, GOOGLE_TOKEN_URI,
+ user_agent)
+
+ self.key_name = 'id'
+ self.key_value = '1'
+ self.property_name = 'credentials'
+
+ def test_constructor(self):
+ storage = Storage(FakeCredentialsModel, self.key_name,
+ self.key_value, self.property_name)
+
+ self.assertEqual(storage.model_class, FakeCredentialsModel)
+ self.assertEqual(storage.key_name, self.key_name)
+ self.assertEqual(storage.key_value, self.key_value)
+ self.assertEqual(storage.property_name, self.property_name)
+
+ @mock.patch('django.db.models')
+ def test_locked_get(self, djangoModel):
+ fake_model_with_credentials = FakeCredentialsModelMock()
+ entities = [
+ fake_model_with_credentials
+ ]
+ filter_mock = mock.Mock(return_value=entities)
+ object_mock = mock.Mock()
+ object_mock.filter = filter_mock
+ FakeCredentialsModelMock.objects = object_mock
+
+ storage = Storage(FakeCredentialsModelMock, self.key_name,
+ self.key_value, self.property_name)
+ credential = storage.locked_get()
+ self.assertEqual(
+ credential, fake_model_with_credentials.credentials)
+
+ @mock.patch('django.db.models')
+ def test_locked_get_no_entities(self, djangoModel):
+ entities = []
+ filter_mock = mock.Mock(return_value=entities)
+ object_mock = mock.Mock()
+ object_mock.filter = filter_mock
+ FakeCredentialsModelMock.objects = object_mock
+
+ storage = Storage(FakeCredentialsModelMock, self.key_name,
+ self.key_value, self.property_name)
+ credential = storage.locked_get()
+ self.assertIsNone(credential)
+
+ @mock.patch('django.db.models')
+ def test_locked_get_no_set_store(self, djangoModel):
+ fake_model_with_credentials = FakeCredentialsModelMockNoSet()
+ entities = [
+ fake_model_with_credentials
+ ]
+ filter_mock = mock.Mock(return_value=entities)
+ object_mock = mock.Mock()
+ object_mock.filter = filter_mock
+ FakeCredentialsModelMockNoSet.objects = object_mock
+
+ storage = Storage(FakeCredentialsModelMockNoSet, self.key_name,
+ self.key_value, self.property_name)
+ credential = storage.locked_get()
+ self.assertEqual(
+ credential, fake_model_with_credentials.credentials)
+
+ @mock.patch('django.db.models')
+ def test_locked_put(self, djangoModel):
+ entity_mock = mock.Mock(credentials=None)
+ objects = mock.Mock(
+ get_or_create=mock.Mock(return_value=(entity_mock, None)))
+ FakeCredentialsModelMock.objects = objects
+ storage = Storage(FakeCredentialsModelMock, self.key_name,
+ self.key_value, self.property_name)
+ storage.locked_put(self.credentials)
+
+ @mock.patch('django.db.models')
+ def test_locked_delete(self, djangoModel):
+ class FakeEntities(object):
+ def __init__(self):
+ self.deleted = False
+
+ def delete(self):
+ self.deleted = True
+
+ fake_entities = FakeEntities()
+ entities = fake_entities
+
+ filter_mock = mock.Mock(return_value=entities)
+ object_mock = mock.Mock()
+ object_mock.filter = filter_mock
+ FakeCredentialsModelMock.objects = object_mock
+ storage = Storage(FakeCredentialsModelMock, self.key_name,
+ self.key_value, self.property_name)
+ storage.locked_delete()
+ self.assertTrue(fake_entities.deleted)
+
+
+class CredentialWithSetStore(CredentialsField):
+ def __init__(self):
+ self.model = CredentialWithSetStore
+
+ def set_store(self, storage):
+ pass
+
+
+class FakeCredentialsModel(models.Model):
+ credentials = CredentialsField()
+
+
+class FakeCredentialsModelMock(object):
+ def __init__(self, *args, **kwargs):
+ self.model = FakeCredentialsModelMock
+ self.saved = False
+ self.deleted = False
+
+ credentials = CredentialWithSetStore()
+
+
+class FakeCredentialsModelMockNoSet(object):
+ def __init__(self, set_store=False, *args, **kwargs):
+ self.model = FakeCredentialsModelMock
+ self.saved = False
+ self.deleted = False
+
+ credentials = CredentialsField()
diff --git a/tests/contrib/django_util/test_django_util.py b/tests/contrib/django_util/test_django_util.py
new file mode 100644
index 0000000..84457cb
--- /dev/null
+++ b/tests/contrib/django_util/test_django_util.py
@@ -0,0 +1,172 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Tests the initialization logic of django_util."""
+
+import copy
+
+import django.conf
+from django.conf.urls import include, url
+from django.contrib.auth.models import AnonymousUser
+from django.core import exceptions
+import mock
+from six.moves import reload_module
+from tests.contrib.django_util import TestWithDjangoEnvironment
+import unittest2
+
+from oauth2client.contrib import django_util
+import oauth2client.contrib.django_util
+from oauth2client.contrib.django_util import (
+ _CREDENTIALS_KEY, get_storage, site, UserOAuth2)
+
+
+urlpatterns = [
+ url(r'^oauth2/', include(site.urls))
+]
+
+
+class OAuth2SetupTest(unittest2.TestCase):
+
+ def setUp(self):
+ self.save_settings = copy.deepcopy(django.conf.settings)
+ # OAuth2 Settings gets configured based on Django settings
+ # at import time, so in order for us to reload the settings
+ # we need to reload the module
+ reload_module(oauth2client.contrib.django_util)
+
+ def tearDown(self):
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ @mock.patch("oauth2client.contrib.django_util.clientsecrets")
+ def test_settings_initialize(self, clientsecrets):
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_SECRETS_JSON = 'file.json'
+ clientsecrets.loadfile.return_value = (
+ clientsecrets.TYPE_WEB,
+ {
+ 'client_id': 'myid',
+ 'client_secret': 'hunter2'
+ }
+ )
+
+ oauth2_settings = django_util.OAuth2Settings(django.conf.settings)
+ self.assertTrue(clientsecrets.loadfile.called)
+ self.assertEqual(oauth2_settings.client_id, 'myid')
+ self.assertEqual(oauth2_settings.client_secret, 'hunter2')
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_SECRETS_JSON = None
+
+ @mock.patch("oauth2client.contrib.django_util.clientsecrets")
+ def test_settings_initialize_invalid_type(self, clientsecrets):
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_SECRETS_JSON = 'file.json'
+ clientsecrets.loadfile.return_value = (
+ "wrong_type",
+ {
+ 'client_id': 'myid',
+ 'client_secret': 'hunter2'
+ }
+ )
+
+ with self.assertRaises(ValueError):
+ django_util.OAuth2Settings.__init__(
+ object.__new__(django_util.OAuth2Settings),
+ django.conf.settings)
+
+ @mock.patch("oauth2client.contrib.django_util.clientsecrets")
+ def test_no_settings(self, clientsecrets):
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_SECRETS_JSON = None
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_SECRET = None
+ django.conf.settings.GOOGLE_OAUTH2_CLIENT_ID = None
+
+ with self.assertRaises(exceptions.ImproperlyConfigured):
+ django_util.OAuth2Settings.__init__(
+ object.__new__(django_util.OAuth2Settings),
+ django.conf.settings)
+
+ @mock.patch("oauth2client.contrib.django_util.clientsecrets")
+ def test_no_session_middleware(self, clientsecrets):
+ django.conf.settings.MIDDLEWARE_CLASSES = ()
+
+ with self.assertRaises(exceptions.ImproperlyConfigured):
+ django_util.OAuth2Settings.__init__(
+ object.__new__(django_util.OAuth2Settings),
+ django.conf.settings)
+
+ def test_storage_model(self):
+ STORAGE_MODEL = {
+ 'model': 'tests.contrib.django_util.models.CredentialsModel',
+ 'user_property': 'user_id',
+ 'credentials_property': 'credentials'
+ }
+ django.conf.settings.GOOGLE_OAUTH2_STORAGE_MODEL = STORAGE_MODEL
+ oauth2_settings = django_util.OAuth2Settings(django.conf.settings)
+ self.assertEqual(oauth2_settings.storage_model, STORAGE_MODEL['model'])
+ self.assertEqual(oauth2_settings.storage_model_user_property,
+ STORAGE_MODEL['user_property'])
+ self.assertEqual(oauth2_settings.storage_model_credentials_property,
+ STORAGE_MODEL['credentials_property'])
+
+
+class MockObjectWithSession(object):
+ def __init__(self, session):
+ self.session = session
+
+
+class SessionStorageTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(SessionStorageTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+ reload_module(oauth2client.contrib.django_util)
+
+ def tearDown(self):
+ super(SessionStorageTest, self).tearDown()
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_session_delete(self):
+ self.session[_CREDENTIALS_KEY] = "test_val"
+ request = MockObjectWithSession(self.session)
+ django_storage = get_storage(request)
+ django_storage.delete()
+ self.assertIsNone(self.session.get(_CREDENTIALS_KEY))
+
+ def test_session_delete_nothing(self):
+ request = MockObjectWithSession(self.session)
+ django_storage = get_storage(request)
+ django_storage.delete()
+
+
+class TestUserOAuth2Object(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(TestUserOAuth2Object, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+ STORAGE_MODEL = {
+ 'model': 'tests.contrib.django_util.models.CredentialsModel',
+ 'user_property': 'user_id',
+ 'credentials_property': 'credentials'
+ }
+ django.conf.settings.GOOGLE_OAUTH2_STORAGE_MODEL = STORAGE_MODEL
+ reload_module(oauth2client.contrib.django_util)
+
+ def tearDown(self):
+ super(TestUserOAuth2Object, self).tearDown()
+ import django.conf
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_get_credentials_anon_user(self):
+ request = self.factory.get('oauth2/oauth2authorize',
+ data={'return_url': '/return_endpoint'})
+ request.session = self.session
+ request.user = AnonymousUser()
+ oauth2 = UserOAuth2(request)
+ self.assertIsNone(oauth2.credentials)
diff --git a/tests/contrib/django_util/test_views.py b/tests/contrib/django_util/test_views.py
new file mode 100644
index 0000000..df0d11c
--- /dev/null
+++ b/tests/contrib/django_util/test_views.py
@@ -0,0 +1,274 @@
+# Copyright 2016 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Unit test for django_util views"""
+
+import copy
+import json
+
+import django
+from django import http
+import django.conf
+from django.contrib.auth.models import AnonymousUser, User
+import mock
+from six.moves import reload_module
+
+from tests.contrib.django_util import TestWithDjangoEnvironment
+from tests.contrib.django_util.models import CredentialsModel
+
+from oauth2client.client import FlowExchangeError, OAuth2WebServerFlow
+import oauth2client.contrib.django_util
+from oauth2client.contrib.django_util import views
+from oauth2client.contrib.django_util.models import CredentialsField
+
+
+class OAuth2AuthorizeTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(OAuth2AuthorizeTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+ reload_module(oauth2client.contrib.django_util)
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ def tearDown(self):
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_authorize_works(self):
+ request = self.factory.get('oauth2/oauth2authorize')
+ request.session = self.session
+ request.user = self.user
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+
+ def test_authorize_anonymous_user(self):
+ request = self.factory.get('oauth2/oauth2authorize')
+ request.session = self.session
+ request.user = AnonymousUser()
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+
+ def test_authorize_works_explicit_return_url(self):
+ request = self.factory.get('oauth2/oauth2authorize',
+ data={'return_url': '/return_endpoint'})
+ request.session = self.session
+ request.user = self.user
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+
+
+class Oauth2AuthorizeStorageModelTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(Oauth2AuthorizeStorageModelTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+
+ STORAGE_MODEL = {
+ 'model': 'tests.contrib.django_util.models.CredentialsModel',
+ 'user_property': 'user_id',
+ 'credentials_property': 'credentials'
+ }
+ django.conf.settings.GOOGLE_OAUTH2_STORAGE_MODEL = STORAGE_MODEL
+
+ # OAuth2 Settings gets configured based on Django settings
+ # at import time, so in order for us to reload the settings
+ # we need to reload the module
+ reload_module(oauth2client.contrib.django_util)
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ def tearDown(self):
+ django.conf.settings = copy.deepcopy(self.save_settings)
+
+ def test_authorize_works(self):
+ request = self.factory.get('oauth2/oauth2authorize')
+ request.session = self.session
+ request.user = self.user
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ # redirects to Google oauth
+ self.assertIn('accounts.google.com', response.url)
+
+ def test_authorize_anonymous_user_redirects_login(self):
+ request = self.factory.get('oauth2/oauth2authorize')
+ request.session = self.session
+ request.user = AnonymousUser()
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ # redirects to Django login
+ self.assertIn(django.conf.settings.LOGIN_URL, response.url)
+
+ def test_authorize_works_explicit_return_url(self):
+ request = self.factory.get('oauth2/oauth2authorize',
+ data={'return_url': '/return_endpoint'})
+ request.session = self.session
+ request.user = self.user
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+
+ def test_authorized_user_not_logged_in_redirects(self):
+ request = self.factory.get('oauth2/oauth2authorize',
+ data={'return_url': '/return_endpoint'})
+ request.session = self.session
+
+ authorized_user = User.objects.create_user(
+ username='bill2', email='bill@example.com', password='hunter2')
+ credentials = CredentialsField()
+
+ CredentialsModel.objects.create(
+ user_id=authorized_user,
+ credentials=credentials)
+
+ request.user = authorized_user
+ response = views.oauth2_authorize(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+
+
+class Oauth2CallbackTest(TestWithDjangoEnvironment):
+
+ def setUp(self):
+ super(Oauth2CallbackTest, self).setUp()
+ self.save_settings = copy.deepcopy(django.conf.settings)
+ reload_module(oauth2client.contrib.django_util)
+
+ self.CSRF_TOKEN = 'token'
+ self.RETURN_URL = 'http://return-url.com'
+ self.fake_state = {
+ 'csrf_token': self.CSRF_TOKEN,
+ 'return_url': self.RETURN_URL,
+ 'scopes': django.conf.settings.GOOGLE_OAUTH2_SCOPES
+ }
+ self.user = User.objects.create_user(
+ username='bill', email='bill@example.com', password='hunter2')
+
+ @mock.patch('oauth2client.contrib.django_util.views.pickle')
+ def test_callback_works(self, pickle):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'state': json.dumps(self.fake_state),
+ 'code': 123
+ })
+
+ self.session['google_oauth2_csrf_token'] = self.CSRF_TOKEN
+
+ flow = OAuth2WebServerFlow(
+ client_id='clientid',
+ client_secret='clientsecret',
+ scope=['email'],
+ state=json.dumps(self.fake_state),
+ redirect_uri=request.build_absolute_uri("oauth2/oauth2callback"))
+
+ name = 'google_oauth2_flow_{0}'.format(self.CSRF_TOKEN)
+ self.session[name] = pickle.dumps(flow)
+ flow.step2_exchange = mock.Mock()
+ pickle.loads.return_value = flow
+
+ request.session = self.session
+ request.user = self.user
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseRedirect)
+ self.assertEqual(
+ response.status_code, django.http.HttpResponseRedirect.status_code)
+ self.assertEqual(response['Location'], self.RETURN_URL)
+
+ @mock.patch('oauth2client.contrib.django_util.views.pickle')
+ def test_callback_handles_bad_flow_exchange(self, pickle):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ "state": json.dumps(self.fake_state),
+ "code": 123
+ })
+
+ self.session['google_oauth2_csrf_token'] = self.CSRF_TOKEN
+
+ flow = OAuth2WebServerFlow(
+ client_id='clientid',
+ client_secret='clientsecret',
+ scope=['email'],
+ state=json.dumps(self.fake_state),
+ redirect_uri=request.build_absolute_uri('oauth2/oauth2callback'))
+
+ self.session['google_oauth2_flow_{0}'.format(self.CSRF_TOKEN)] \
+ = pickle.dumps(flow)
+
+ def local_throws(code):
+ raise FlowExchangeError('test')
+
+ flow.step2_exchange = local_throws
+ pickle.loads.return_value = flow
+
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+
+ def test_error_returns_bad_request(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'error': 'There was an error in your authorization.',
+ })
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+ self.assertIn(b'Authorization failed', response.content)
+
+ def test_no_session(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'code': 123,
+ 'state': json.dumps(self.fake_state)
+ })
+
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+ self.assertEqual(
+ response.content, b'No existing session for this flow.')
+
+ def test_missing_state_returns_bad_request(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'code': 123
+ })
+ self.session['google_oauth2_csrf_token'] = "token"
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+
+ def test_bad_state(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'code': 123,
+ 'state': json.dumps({'wrong': 'state'})
+ })
+ self.session['google_oauth2_csrf_token'] = 'token'
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+ self.assertEqual(response.content, b'Invalid state parameter.')
+
+ def test_bad_csrf(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ "state": json.dumps(self.fake_state),
+ "code": 123
+ })
+ self.session['google_oauth2_csrf_token'] = 'WRONG TOKEN'
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+ self.assertEqual(response.content, b'Invalid CSRF token.')
+
+ def test_no_saved_flow(self):
+ request = self.factory.get('oauth2/oauth2callback', data={
+ 'state': json.dumps(self.fake_state),
+ 'code': 123
+ })
+ self.session['google_oauth2_csrf_token'] = self.CSRF_TOKEN
+ self.session['google_oauth2_flow_{0}'.format(self.CSRF_TOKEN)] = None
+ request.session = self.session
+ response = views.oauth2_callback(request)
+ self.assertIsInstance(response, http.HttpResponseBadRequest)
+ self.assertEqual(response.content, b'Missing Oauth2 flow.')
diff --git a/tests/contrib/test__appengine_ndb.py b/tests/contrib/test__appengine_ndb.py
new file mode 100644
index 0000000..41e3805
--- /dev/null
+++ b/tests/contrib/test__appengine_ndb.py
@@ -0,0 +1,166 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import json
+import os
+
+from google.appengine.ext import ndb
+from google.appengine.ext import testbed
+import mock
+import unittest2
+
+from oauth2client import client
+from oauth2client.contrib import appengine
+
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+
+
+def datafile(filename):
+ return os.path.join(DATA_DIR, filename)
+
+
+class TestNDBModel(ndb.Model):
+ flow = appengine.FlowNDBProperty()
+ creds = appengine.CredentialsNDBProperty()
+
+
+class TestFlowNDBProperty(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_flow_get_put(self):
+ instance = TestNDBModel(
+ flow=client.flow_from_clientsecrets(
+ datafile('client_secrets.json'), 'foo', redirect_uri='oob'),
+ id='foo'
+ )
+ instance.put()
+ retrieved = TestNDBModel.get_by_id('foo')
+
+ self.assertEqual('foo_client_id', retrieved.flow.client_id)
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_success(self, mock_logger):
+ flow_prop = TestNDBModel.flow
+ flow_val = client.flow_from_clientsecrets(
+ datafile('client_secrets.json'), 'foo', redirect_uri='oob')
+ flow_prop._validate(flow_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(flow_val))
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_none(self, mock_logger):
+ flow_prop = TestNDBModel.flow
+ flow_val = None
+ flow_prop._validate(flow_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(flow_val))
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_bad_type(self, mock_logger):
+ flow_prop = TestNDBModel.flow
+ flow_val = object()
+ with self.assertRaises(TypeError):
+ flow_prop._validate(flow_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(flow_val))
+
+
+class TestCredentialsNDBProperty(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_valid_creds_get_put(self):
+ creds = client.Credentials()
+ instance = TestNDBModel(creds=creds, id='bar')
+ instance.put()
+ retrieved = TestNDBModel.get_by_id('bar')
+ self.assertIsInstance(retrieved.creds, client.Credentials)
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_success(self, mock_logger):
+ creds_prop = TestNDBModel.creds
+ creds_val = client.Credentials()
+ creds_prop._validate(creds_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(creds_val))
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_none(self, mock_logger):
+ creds_prop = TestNDBModel.creds
+ creds_val = None
+ creds_prop._validate(creds_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(creds_val))
+
+ @mock.patch('oauth2client.contrib._appengine_ndb._LOGGER')
+ def test_validate_bad_type(self, mock_logger):
+ creds_prop = TestNDBModel.creds
+ creds_val = object()
+ with self.assertRaises(TypeError):
+ creds_prop._validate(creds_val)
+ mock_logger.info.assert_called_once_with('validate: Got type %s',
+ type(creds_val))
+
+ def test__to_base_type_valid_creds(self):
+ creds_prop = TestNDBModel.creds
+ creds = client.Credentials()
+ creds_json = json.loads(creds_prop._to_base_type(creds))
+ self.assertDictEqual(creds_json, {
+ '_class': 'Credentials',
+ '_module': 'oauth2client.client',
+ 'token_expiry': None,
+ })
+
+ def test__to_base_type_null_creds(self):
+ creds_prop = TestNDBModel.creds
+ self.assertEqual(creds_prop._to_base_type(None), '')
+
+ def test__from_base_type_valid_creds(self):
+ creds_prop = TestNDBModel.creds
+ creds_json = json.dumps({
+ '_class': 'Credentials',
+ '_module': 'oauth2client.client',
+ 'token_expiry': None,
+ })
+ creds = creds_prop._from_base_type(creds_json)
+ self.assertIsInstance(creds, client.Credentials)
+
+ def test__from_base_type_false_value(self):
+ creds_prop = TestNDBModel.creds
+ self.assertIsNone(creds_prop._from_base_type(''))
+ self.assertIsNone(creds_prop._from_base_type(False))
+ self.assertIsNone(creds_prop._from_base_type(None))
+ self.assertIsNone(creds_prop._from_base_type([]))
+ self.assertIsNone(creds_prop._from_base_type({}))
+
+ def test__from_base_type_bad_json(self):
+ creds_prop = TestNDBModel.creds
+ creds_json = '{JK-I-AM-NOT-JSON'
+ self.assertIsNone(creds_prop._from_base_type(creds_json))
diff --git a/tests/contrib/test_appengine.py b/tests/contrib/test_appengine.py
new file mode 100644
index 0000000..cdaf6c5
--- /dev/null
+++ b/tests/contrib/test_appengine.py
@@ -0,0 +1,1073 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+import datetime
+import json
+import os
+import tempfile
+import time
+
+import dev_appserver
+
+dev_appserver.fix_sys_path()
+
+from google.appengine.api import apiproxy_stub
+from google.appengine.api import apiproxy_stub_map
+from google.appengine.api import app_identity
+from google.appengine.api import memcache
+from google.appengine.api import users
+from google.appengine.api.memcache import memcache_stub
+from google.appengine.ext import db
+from google.appengine.ext import ndb
+from google.appengine.ext import testbed
+import httplib2
+import mock
+from six.moves import urllib
+import unittest2
+import webapp2
+from webtest import TestApp
+
+import oauth2client
+from oauth2client import client
+from oauth2client import clientsecrets
+from oauth2client.contrib import appengine
+from ..http_mock import CacheMock
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data')
+
+
+def datafile(filename):
+ return os.path.join(DATA_DIR, filename)
+
+
+def load_and_cache(existing_file, fakename, cache_mock):
+ client_type, client_info = clientsecrets._loadfile(datafile(existing_file))
+ cache_mock.cache[fakename] = {client_type: client_info}
+
+
+class UserMock(object):
+ """Mock the app engine user service"""
+
+ def __call__(self):
+ return self
+
+ def user_id(self):
+ return 'foo_user'
+
+
+class UserNotLoggedInMock(object):
+ """Mock the app engine user service"""
+
+ def __call__(self):
+ return None
+
+
+class Http2Mock(object):
+ """Mock httplib2.Http"""
+ status = 200
+ content = {
+ 'access_token': 'foo_access_token',
+ 'refresh_token': 'foo_refresh_token',
+ 'expires_in': 3600,
+ 'extra': 'value',
+ }
+
+ def request(self, token_uri, method, body, headers, *args, **kwargs):
+ self.body = body
+ self.headers = headers
+ return self, json.dumps(self.content)
+
+
+class TestAppAssertionCredentials(unittest2.TestCase):
+ account_name = "service_account_name@appspot.com"
+ signature = "signature"
+
+ class AppIdentityStubImpl(apiproxy_stub.APIProxyStub):
+
+ def __init__(self, key_name=None, sig_bytes=None,
+ svc_acct=None):
+ super(TestAppAssertionCredentials.AppIdentityStubImpl,
+ self).__init__('app_identity_service')
+ self._key_name = key_name
+ self._sig_bytes = sig_bytes
+ self._sign_calls = []
+ self._svc_acct = svc_acct
+ self._get_acct_name_calls = 0
+
+ def _Dynamic_GetAccessToken(self, request, response):
+ response.set_access_token('a_token_123')
+ response.set_expiration_time(time.time() + 1800)
+
+ def _Dynamic_SignForApp(self, request, response):
+ response.set_key_name(self._key_name)
+ response.set_signature_bytes(self._sig_bytes)
+ self._sign_calls.append(request.bytes_to_sign())
+
+ def _Dynamic_GetServiceAccountName(self, request, response):
+ response.set_service_account_name(self._svc_acct)
+ self._get_acct_name_calls += 1
+
+ class ErroringAppIdentityStubImpl(apiproxy_stub.APIProxyStub):
+
+ def __init__(self):
+ super(TestAppAssertionCredentials.ErroringAppIdentityStubImpl,
+ self).__init__('app_identity_service')
+
+ def _Dynamic_GetAccessToken(self, request, response):
+ raise app_identity.BackendDeadlineExceeded()
+
+ def test_raise_correct_type_of_exception(self):
+ app_identity_stub = self.ErroringAppIdentityStubImpl()
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+ app_identity_stub)
+ apiproxy_stub_map.apiproxy.RegisterStub(
+ 'memcache', memcache_stub.MemcacheServiceStub())
+
+ scope = 'http://www.googleapis.com/scope'
+ credentials = appengine.AppAssertionCredentials(scope)
+ http = httplib2.Http()
+ with self.assertRaises(client.AccessTokenRefreshError):
+ credentials.refresh(http)
+
+ def test_get_access_token_on_refresh(self):
+ app_identity_stub = self.AppIdentityStubImpl()
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub("app_identity_service",
+ app_identity_stub)
+ apiproxy_stub_map.apiproxy.RegisterStub(
+ 'memcache', memcache_stub.MemcacheServiceStub())
+
+ scope = [
+ "http://www.googleapis.com/scope",
+ "http://www.googleapis.com/scope2"]
+ credentials = appengine.AppAssertionCredentials(scope)
+ http = httplib2.Http()
+ credentials.refresh(http)
+ self.assertEqual('a_token_123', credentials.access_token)
+
+ json = credentials.to_json()
+ credentials = client.Credentials.new_from_json(json)
+ self.assertEqual(
+ 'http://www.googleapis.com/scope http://www.googleapis.com/scope2',
+ credentials.scope)
+
+ scope = ('http://www.googleapis.com/scope '
+ 'http://www.googleapis.com/scope2')
+ credentials = appengine.AppAssertionCredentials(scope)
+ http = httplib2.Http()
+ credentials.refresh(http)
+ self.assertEqual('a_token_123', credentials.access_token)
+ self.assertEqual(
+ 'http://www.googleapis.com/scope http://www.googleapis.com/scope2',
+ credentials.scope)
+
+ def test_custom_service_account(self):
+ scope = "http://www.googleapis.com/scope"
+ account_id = "service_account_name_2@appspot.com"
+
+ with mock.patch.object(app_identity, 'get_access_token',
+ return_value=('a_token_456', None),
+ autospec=True) as get_access_token:
+ credentials = appengine.AppAssertionCredentials(
+ scope, service_account_id=account_id)
+ http = httplib2.Http()
+ credentials.refresh(http)
+
+ self.assertEqual('a_token_456', credentials.access_token)
+ self.assertEqual(scope, credentials.scope)
+ get_access_token.assert_called_once_with(
+ [scope], service_account_id=account_id)
+
+ def test_create_scoped_required_without_scopes(self):
+ credentials = appengine.AppAssertionCredentials([])
+ self.assertTrue(credentials.create_scoped_required())
+
+ def test_create_scoped_required_with_scopes(self):
+ credentials = appengine.AppAssertionCredentials(['dummy_scope'])
+ self.assertFalse(credentials.create_scoped_required())
+
+ def test_create_scoped(self):
+ credentials = appengine.AppAssertionCredentials([])
+ new_credentials = credentials.create_scoped(['dummy_scope'])
+ self.assertNotEqual(credentials, new_credentials)
+ self.assertIsInstance(
+ new_credentials, appengine.AppAssertionCredentials)
+ self.assertEqual('dummy_scope', new_credentials.scope)
+
+ def test_sign_blob(self):
+ key_name = b'1234567890'
+ sig_bytes = b'himom'
+ app_identity_stub = self.AppIdentityStubImpl(
+ key_name=key_name, sig_bytes=sig_bytes)
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+ app_identity_stub)
+ credentials = appengine.AppAssertionCredentials([])
+ to_sign = b'blob'
+ self.assertEqual(app_identity_stub._sign_calls, [])
+ result = credentials.sign_blob(to_sign)
+ self.assertEqual(result, (key_name, sig_bytes))
+ self.assertEqual(app_identity_stub._sign_calls, [to_sign])
+
+ def test_service_account_email(self):
+ acct_name = 'new-value@appspot.gserviceaccount.com'
+ app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+ app_identity_stub)
+
+ credentials = appengine.AppAssertionCredentials([])
+ self.assertIsNone(credentials._service_account_email)
+ self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+ self.assertEqual(credentials.service_account_email, acct_name)
+ self.assertIsNotNone(credentials._service_account_email)
+ self.assertEqual(app_identity_stub._get_acct_name_calls, 1)
+
+ def test_service_account_email_already_set(self):
+ acct_name = 'existing@appspot.gserviceaccount.com'
+ credentials = appengine.AppAssertionCredentials([])
+ credentials._service_account_email = acct_name
+
+ app_identity_stub = self.AppIdentityStubImpl(svc_acct=acct_name)
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub('app_identity_service',
+ app_identity_stub)
+
+ self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+ self.assertEqual(credentials.service_account_email, acct_name)
+ self.assertEqual(app_identity_stub._get_acct_name_calls, 0)
+
+ def test_get_access_token(self):
+ app_identity_stub = self.AppIdentityStubImpl()
+ apiproxy_stub_map.apiproxy = apiproxy_stub_map.APIProxyStubMap()
+ apiproxy_stub_map.apiproxy.RegisterStub("app_identity_service",
+ app_identity_stub)
+ apiproxy_stub_map.apiproxy.RegisterStub(
+ 'memcache', memcache_stub.MemcacheServiceStub())
+
+ credentials = appengine.AppAssertionCredentials(['dummy_scope'])
+ token = credentials.get_access_token()
+ self.assertEqual('a_token_123', token.access_token)
+ self.assertEqual(None, token.expires_in)
+
+ def test_save_to_well_known_file(self):
+ os.environ[client._CLOUDSDK_CONFIG_ENV_VAR] = tempfile.mkdtemp()
+ credentials = appengine.AppAssertionCredentials([])
+ with self.assertRaises(NotImplementedError):
+ client.save_to_well_known_file(credentials)
+ del os.environ[client._CLOUDSDK_CONFIG_ENV_VAR]
+
+
+class TestFlowModel(db.Model):
+ flow = appengine.FlowProperty()
+
+
+class FlowPropertyTest(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+
+ self.flow = client.flow_from_clientsecrets(
+ datafile('client_secrets.json'),
+ 'foo',
+ redirect_uri='oob')
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_flow_get_put(self):
+ instance = TestFlowModel(
+ flow=self.flow,
+ key_name='foo'
+ )
+ instance.put()
+ retrieved = TestFlowModel.get_by_key_name('foo')
+
+ self.assertEqual('foo_client_id', retrieved.flow.client_id)
+
+ def test_make_value_from_datastore_none(self):
+ self.assertIsNone(
+ appengine.FlowProperty().make_value_from_datastore(None))
+
+ def test_validate(self):
+ appengine.FlowProperty().validate(None)
+ with self.assertRaises(db.BadValueError):
+ appengine.FlowProperty().validate(42)
+
+
+class TestCredentialsModel(db.Model):
+ credentials = appengine.CredentialsProperty()
+
+
+class CredentialsPropertyTest(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+
+ access_token = 'foo'
+ client_id = 'some_client_id'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ user_agent = 'refresh_checker/1.0'
+ self.credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, oauth2client.GOOGLE_TOKEN_URI,
+ user_agent)
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_credentials_get_put(self):
+ instance = TestCredentialsModel(
+ credentials=self.credentials,
+ key_name='foo'
+ )
+ instance.put()
+ retrieved = TestCredentialsModel.get_by_key_name('foo')
+
+ self.assertEqual(
+ self.credentials.to_json(),
+ retrieved.credentials.to_json())
+
+ def test_make_value_from_datastore(self):
+ self.assertIsNone(
+ appengine.CredentialsProperty().make_value_from_datastore(None))
+ self.assertIsNone(
+ appengine.CredentialsProperty().make_value_from_datastore(''))
+ self.assertIsNone(
+ appengine.CredentialsProperty().make_value_from_datastore('{'))
+
+ decoded = appengine.CredentialsProperty().make_value_from_datastore(
+ self.credentials.to_json())
+ self.assertEqual(
+ self.credentials.to_json(),
+ decoded.to_json())
+
+ def test_validate(self):
+ appengine.CredentialsProperty().validate(self.credentials)
+ appengine.CredentialsProperty().validate(None)
+ with self.assertRaises(db.BadValueError):
+ appengine.CredentialsProperty().validate(42)
+
+
+def _http_request(*args, **kwargs):
+ resp = httplib2.Response({'status': '200'})
+ content = json.dumps({'access_token': 'bar'})
+
+ return resp, content
+
+
+class StorageByKeyNameTest(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_user_stub()
+
+ access_token = 'foo'
+ client_id = 'some_client_id'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ user_agent = 'refresh_checker/1.0'
+ self.credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, oauth2client.GOOGLE_TOKEN_URI,
+ user_agent)
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_bad_ctor(self):
+ with self.assertRaises(ValueError):
+ appengine.StorageByKeyName(appengine.CredentialsModel, None, None)
+
+ def test__is_ndb(self):
+ storage = appengine.StorageByKeyName(
+ object(), 'foo', 'credentials')
+
+ with self.assertRaises(TypeError):
+ storage._is_ndb()
+
+ storage._model = type(object)
+ with self.assertRaises(TypeError):
+ storage._is_ndb()
+
+ storage._model = appengine.CredentialsModel
+ self.assertFalse(storage._is_ndb())
+
+ storage._model = appengine.CredentialsNDBModel
+ self.assertTrue(storage._is_ndb())
+
+ def test_get_and_put_simple(self):
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsModel, 'foo', 'credentials')
+
+ self.assertEqual(None, storage.get())
+ self.credentials.set_store(storage)
+
+ self.credentials._refresh(_http_request)
+ credmodel = appengine.CredentialsModel.get_by_key_name('foo')
+ self.assertEqual('bar', credmodel.credentials.access_token)
+
+ def test_get_and_put_cached(self):
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsModel, 'foo', 'credentials', cache=memcache)
+
+ self.assertEqual(None, storage.get())
+ self.credentials.set_store(storage)
+
+ self.credentials._refresh(_http_request)
+ credmodel = appengine.CredentialsModel.get_by_key_name('foo')
+ self.assertEqual('bar', credmodel.credentials.access_token)
+
+ # Now remove the item from the cache.
+ memcache.delete('foo')
+
+ # Check that getting refreshes the cache.
+ credentials = storage.get()
+ self.assertEqual('bar', credentials.access_token)
+ self.assertNotEqual(None, memcache.get('foo'))
+
+ # Deleting should clear the cache.
+ storage.delete()
+ credentials = storage.get()
+ self.assertEqual(None, credentials)
+ self.assertEqual(None, memcache.get('foo'))
+
+ def test_get_and_put_set_store_on_cache_retrieval(self):
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsModel, 'foo', 'credentials', cache=memcache)
+
+ self.assertEqual(None, storage.get())
+ self.credentials.set_store(storage)
+ storage.put(self.credentials)
+ # Pre-bug 292 old_creds wouldn't have storage, and the _refresh
+ # wouldn't be able to store the updated cred back into the storage.
+ old_creds = storage.get()
+ self.assertEqual(old_creds.access_token, 'foo')
+ old_creds.invalid = True
+ old_creds._refresh(_http_request)
+ new_creds = storage.get()
+ self.assertEqual(new_creds.access_token, 'bar')
+
+ def test_get_and_put_ndb(self):
+ # Start empty
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsNDBModel, 'foo', 'credentials')
+ self.assertEqual(None, storage.get())
+
+ # Refresh storage and retrieve without using storage
+ self.credentials.set_store(storage)
+ self.credentials._refresh(_http_request)
+ credmodel = appengine.CredentialsNDBModel.get_by_id('foo')
+ self.assertEqual('bar', credmodel.credentials.access_token)
+ self.assertEqual(credmodel.credentials.to_json(),
+ self.credentials.to_json())
+
+ def test_delete_ndb(self):
+ # Start empty
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsNDBModel, 'foo', 'credentials')
+ self.assertEqual(None, storage.get())
+
+ # Add credentials to model with storage, and check equivalent
+ # w/o storage
+ storage.put(self.credentials)
+ credmodel = appengine.CredentialsNDBModel.get_by_id('foo')
+ self.assertEqual(credmodel.credentials.to_json(),
+ self.credentials.to_json())
+
+ # Delete and make sure empty
+ storage.delete()
+ self.assertEqual(None, storage.get())
+
+ def test_get_and_put_mixed_ndb_storage_db_get(self):
+ # Start empty
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsNDBModel, 'foo', 'credentials')
+ self.assertEqual(None, storage.get())
+
+ # Set NDB store and refresh to add to storage
+ self.credentials.set_store(storage)
+ self.credentials._refresh(_http_request)
+
+ # Retrieve same key from DB model to confirm mixing works
+ credmodel = appengine.CredentialsModel.get_by_key_name('foo')
+ self.assertEqual('bar', credmodel.credentials.access_token)
+ self.assertEqual(self.credentials.to_json(),
+ credmodel.credentials.to_json())
+
+ def test_get_and_put_mixed_db_storage_ndb_get(self):
+ # Start empty
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsModel, 'foo', 'credentials')
+ self.assertEqual(None, storage.get())
+
+ # Set DB store and refresh to add to storage
+ self.credentials.set_store(storage)
+ self.credentials._refresh(_http_request)
+
+ # Retrieve same key from NDB model to confirm mixing works
+ credmodel = appengine.CredentialsNDBModel.get_by_id('foo')
+ self.assertEqual('bar', credmodel.credentials.access_token)
+ self.assertEqual(self.credentials.to_json(),
+ credmodel.credentials.to_json())
+
+ def test_delete_db_ndb_mixed(self):
+ # Start empty
+ storage_ndb = appengine.StorageByKeyName(
+ appengine.CredentialsNDBModel, 'foo', 'credentials')
+ storage = appengine.StorageByKeyName(
+ appengine.CredentialsModel, 'foo', 'credentials')
+
+ # First DB, then NDB
+ self.assertEqual(None, storage.get())
+ storage.put(self.credentials)
+ self.assertNotEqual(None, storage.get())
+
+ storage_ndb.delete()
+ self.assertEqual(None, storage.get())
+
+ # First NDB, then DB
+ self.assertEqual(None, storage_ndb.get())
+ storage_ndb.put(self.credentials)
+
+ storage.delete()
+ self.assertNotEqual(None, storage_ndb.get())
+ # NDB uses memcache and an instance cache (Context)
+ ndb.get_context().clear_cache()
+ memcache.flush_all()
+ self.assertEqual(None, storage_ndb.get())
+
+
+class MockRequest(object):
+ url = 'https://example.org'
+
+ def relative_url(self, rel):
+ return self.url + rel
+
+
+class MockRequestHandler(object):
+ request = MockRequest()
+
+
+class DecoratorTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+ self.testbed.init_user_stub()
+
+ decorator = appengine.OAuth2Decorator(
+ client_id='foo_client_id', client_secret='foo_client_secret',
+ scope=['foo_scope', 'bar_scope'], user_agent='foo')
+
+ self._finish_setup(decorator, user_mock=UserMock)
+
+ def _finish_setup(self, decorator, user_mock):
+ self.decorator = decorator
+ self.had_credentials = False
+ self.found_credentials = None
+ self.should_raise = False
+ parent = self
+
+ class TestRequiredHandler(webapp2.RequestHandler):
+ @decorator.oauth_required
+ def get(self):
+ parent.assertTrue(decorator.has_credentials())
+ parent.had_credentials = True
+ parent.found_credentials = decorator.credentials
+ if parent.should_raise:
+ raise parent.should_raise
+
+ class TestAwareHandler(webapp2.RequestHandler):
+ @decorator.oauth_aware
+ def get(self, *args, **kwargs):
+ self.response.out.write('Hello World!')
+ assert(kwargs['year'] == '2012')
+ assert(kwargs['month'] == '01')
+ if decorator.has_credentials():
+ parent.had_credentials = True
+ parent.found_credentials = decorator.credentials
+ if parent.should_raise:
+ raise parent.should_raise
+
+ routes = [
+ ('/oauth2callback', self.decorator.callback_handler()),
+ ('/foo_path', TestRequiredHandler),
+ webapp2.Route(r'/bar_path/<year:\d{4}>/<month:\d{2}>',
+ handler=TestAwareHandler, name='bar'),
+ ]
+ application = webapp2.WSGIApplication(routes, debug=True)
+
+ self.app = TestApp(application, extra_environ={
+ 'wsgi.url_scheme': 'http',
+ 'HTTP_HOST': 'localhost',
+ })
+ self.current_user = user_mock()
+ users.get_current_user = self.current_user
+ self.httplib2_orig = httplib2.Http
+ httplib2.Http = Http2Mock
+
+ def tearDown(self):
+ self.testbed.deactivate()
+ httplib2.Http = self.httplib2_orig
+
+ def test_in_error(self):
+ # NOTE: This branch is never reached. _in_error is not set by any code
+ # path. It appears to be intended to be set during construction.
+ self.decorator._in_error = True
+ self.decorator._message = 'foobar'
+
+ response = self.app.get('http://localhost/foo_path')
+ self.assertIn('foobar', response.body)
+
+ response = self.app.get('http://localhost/bar_path/1234/56')
+ self.assertIn('foobar', response.body)
+
+ def test_callback_application(self):
+ app = self.decorator.callback_application()
+ self.assertEqual(
+ app.router.match_routes[0].handler.__name__,
+ 'OAuth2Handler')
+
+ def test_required(self):
+ # An initial request to an oauth_required decorated path should be a
+ # redirect to start the OAuth dance.
+ self.assertEqual(self.decorator.flow, None)
+ self.assertEqual(self.decorator.credentials, None)
+ response = self.app.get('http://localhost/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+ q = urllib.parse.parse_qs(
+ response.headers['Location'].split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback',
+ q['redirect_uri'][0])
+ self.assertEqual('foo_client_id', q['client_id'][0])
+ self.assertEqual('foo_scope bar_scope', q['scope'][0])
+ self.assertEqual('http://localhost/foo_path',
+ q['state'][0].rsplit(':', 1)[0])
+ self.assertEqual('code', q['response_type'][0])
+ self.assertEqual(False, self.decorator.has_credentials())
+
+ with mock.patch.object(appengine, '_parse_state_value',
+ return_value='foo_path',
+ autospec=True) as parse_state_value:
+ # Now simulate the callback to /oauth2callback.
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'foo_path:xsrfkey123',
+ })
+ parts = response.headers['Location'].split('?', 1)
+ self.assertEqual('http://localhost/foo_path', parts[0])
+ self.assertEqual(None, self.decorator.credentials)
+ if self.decorator._token_response_param:
+ response_query = urllib.parse.parse_qs(parts[1])
+ response = response_query[
+ self.decorator._token_response_param][0]
+ self.assertEqual(Http2Mock.content,
+ json.loads(urllib.parse.unquote(response)))
+ self.assertEqual(self.decorator.flow, self.decorator._tls.flow)
+ self.assertEqual(self.decorator.credentials,
+ self.decorator._tls.credentials)
+
+ parse_state_value.assert_called_once_with(
+ 'foo_path:xsrfkey123', self.current_user)
+
+ # Now requesting the decorated path should work.
+ response = self.app.get('/foo_path')
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual(True, self.had_credentials)
+ self.assertEqual('foo_refresh_token',
+ self.found_credentials.refresh_token)
+ self.assertEqual('foo_access_token',
+ self.found_credentials.access_token)
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Raising an exception still clears the Credentials.
+ self.should_raise = Exception('')
+ with self.assertRaises(Exception):
+ self.app.get('/foo_path')
+ self.should_raise = False
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Access token refresh error should start the dance again
+ self.should_raise = client.AccessTokenRefreshError()
+ response = self.app.get('/foo_path')
+ self.should_raise = False
+ self.assertTrue(response.status.startswith('302'))
+ query_params = urllib.parse.parse_qs(
+ response.headers['Location'].split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback',
+ query_params['redirect_uri'][0])
+
+ # Invalidate the stored Credentials.
+ self.found_credentials.invalid = True
+ self.found_credentials.store.put(self.found_credentials)
+
+ # Invalid Credentials should start the OAuth dance again.
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+ query_params = urllib.parse.parse_qs(
+ response.headers['Location'].split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback',
+ query_params['redirect_uri'][0])
+
+ def test_storage_delete(self):
+ # An initial request to an oauth_required decorated path should be a
+ # redirect to start the OAuth dance.
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+
+ with mock.patch.object(appengine, '_parse_state_value',
+ return_value='foo_path',
+ autospec=True) as parse_state_value:
+ # Now simulate the callback to /oauth2callback.
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'foo_path:xsrfkey123',
+ })
+ self.assertEqual('http://localhost/foo_path',
+ response.headers['Location'])
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Now requesting the decorated path should work.
+ response = self.app.get('/foo_path')
+
+ self.assertTrue(self.had_credentials)
+
+ # Credentials should be cleared after each call.
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Invalidate the stored Credentials.
+ self.found_credentials.store.delete()
+
+ # Invalid Credentials should start the OAuth dance again.
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+
+ parse_state_value.assert_called_once_with(
+ 'foo_path:xsrfkey123', self.current_user)
+
+ def test_aware(self):
+ # An initial request to an oauth_aware decorated path should
+ # not redirect.
+ response = self.app.get('http://localhost/bar_path/2012/01')
+ self.assertEqual('Hello World!', response.body)
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual(False, self.decorator.has_credentials())
+ url = self.decorator.authorize_url()
+ q = urllib.parse.parse_qs(url.split('?', 1)[1])
+ self.assertEqual('http://localhost/oauth2callback',
+ q['redirect_uri'][0])
+ self.assertEqual('foo_client_id', q['client_id'][0])
+ self.assertEqual('foo_scope bar_scope', q['scope'][0])
+ self.assertEqual('http://localhost/bar_path/2012/01',
+ q['state'][0].rsplit(':', 1)[0])
+ self.assertEqual('code', q['response_type'][0])
+
+ with mock.patch.object(appengine, '_parse_state_value',
+ return_value='bar_path',
+ autospec=True) as parse_state_value:
+ # Now simulate the callback to /oauth2callback.
+ url = self.decorator.authorize_url()
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'bar_path:xsrfkey456',
+ })
+
+ self.assertEqual('http://localhost/bar_path',
+ response.headers['Location'])
+ self.assertEqual(False, self.decorator.has_credentials())
+ parse_state_value.assert_called_once_with(
+ 'bar_path:xsrfkey456', self.current_user)
+
+ # Now requesting the decorated path will have credentials.
+ response = self.app.get('/bar_path/2012/01')
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual('Hello World!', response.body)
+ self.assertEqual(True, self.had_credentials)
+ self.assertEqual('foo_refresh_token',
+ self.found_credentials.refresh_token)
+ self.assertEqual('foo_access_token',
+ self.found_credentials.access_token)
+
+ # Credentials should be cleared after each call.
+ self.assertEqual(None, self.decorator.credentials)
+
+ # Raising an exception still clears the Credentials.
+ self.should_raise = Exception('')
+ with self.assertRaises(Exception):
+ self.app.get('/bar_path/2012/01')
+ self.should_raise = False
+ self.assertEqual(None, self.decorator.credentials)
+
+ def test_error_in_step2(self):
+ # An initial request to an oauth_aware decorated path should
+ # not redirect.
+ response = self.app.get('/bar_path/2012/01')
+ self.decorator.authorize_url()
+ response = self.app.get('/oauth2callback', {
+ 'error': 'Bad<Stuff>Happened\''
+ })
+ self.assertEqual('200 OK', response.status)
+ self.assertTrue('Bad<Stuff>Happened'' in response.body)
+
+ def test_kwargs_are_passed_to_underlying_flow(self):
+ decorator = appengine.OAuth2Decorator(
+ client_id='foo_client_id', client_secret='foo_client_secret',
+ user_agent='foo_user_agent', scope=['foo_scope', 'bar_scope'],
+ access_type='offline', prompt='consent',
+ revoke_uri='dummy_revoke_uri')
+ request_handler = MockRequestHandler()
+ decorator._create_flow(request_handler)
+
+ self.assertEqual('https://example.org/oauth2callback',
+ decorator.flow.redirect_uri)
+ self.assertEqual('offline', decorator.flow.params['access_type'])
+ self.assertEqual('consent', decorator.flow.params['prompt'])
+ self.assertEqual('foo_user_agent', decorator.flow.user_agent)
+ self.assertEqual('dummy_revoke_uri', decorator.flow.revoke_uri)
+ self.assertEqual(None, decorator.flow.params.get('user_agent', None))
+ self.assertEqual(decorator.flow, decorator._tls.flow)
+
+ def test_token_response_param(self):
+ self.decorator._token_response_param = 'foobar'
+ self.test_required()
+
+ def test_decorator_from_client_secrets(self):
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'])
+ self._finish_setup(decorator, user_mock=UserMock)
+
+ self.assertFalse(decorator._in_error)
+ self.decorator = decorator
+ self.test_required()
+ http = self.decorator.http()
+ self.assertEquals('foo_access_token',
+ http.request.credentials.access_token)
+
+ # revoke_uri is not required
+ self.assertEqual(self.decorator._revoke_uri,
+ 'https://accounts.google.com/o/oauth2/revoke')
+ self.assertEqual(self.decorator._revoke_uri,
+ self.decorator.credentials.revoke_uri)
+
+ def test_decorator_from_client_secrets_toplevel(self):
+ decorator_patch = mock.patch(
+ 'oauth2client.contrib.appengine.OAuth2DecoratorFromClientSecrets')
+
+ with decorator_patch as decorator_mock:
+ filename = datafile('client_secrets.json')
+ appengine.oauth2decorator_from_clientsecrets(
+ filename, scope='foo_scope')
+ decorator_mock.assert_called_once_with(
+ filename,
+ 'foo_scope',
+ cache=None,
+ message=None)
+
+ def test_decorator_from_client_secrets_bad_type(self):
+ # NOTE: this code path is not currently reachable, as the only types
+ # that oauth2client.clientsecrets can load is web and installed, so
+ # this test forces execution of this code path. Despite not being
+ # normally reachable, this should remain in case future types of
+ # credentials are added.
+
+ loadfile_patch = mock.patch(
+ 'oauth2client.contrib.appengine.clientsecrets.loadfile')
+ with loadfile_patch as loadfile_mock:
+ loadfile_mock.return_value = ('badtype', None)
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ appengine.OAuth2DecoratorFromClientSecrets(
+ 'doesntmatter.json',
+ scope=['foo_scope', 'bar_scope'])
+
+ def test_decorator_from_client_secrets_kwargs(self):
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'],
+ prompt='consent')
+ self.assertIn('prompt', decorator._kwargs)
+
+ def test_decorator_from_cached_client_secrets(self):
+ cache_mock = CacheMock()
+ load_and_cache('client_secrets.json', 'secret', cache_mock)
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ # filename, scope, message=None, cache=None
+ 'secret', '', cache=cache_mock)
+ self.assertFalse(decorator._in_error)
+
+ def test_decorator_from_client_secrets_not_logged_in_required(self):
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'], message='NotLoggedInMessage')
+ self.decorator = decorator
+ self._finish_setup(decorator, user_mock=UserNotLoggedInMock)
+
+ self.assertFalse(decorator._in_error)
+
+ # An initial request to an oauth_required decorated path should be a
+ # redirect to login.
+ response = self.app.get('/foo_path')
+ self.assertTrue(response.status.startswith('302'))
+ self.assertTrue('Login' in str(response))
+
+ def test_decorator_from_client_secrets_not_logged_in_aware(self):
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'], message='NotLoggedInMessage')
+ self.decorator = decorator
+ self._finish_setup(decorator, user_mock=UserNotLoggedInMock)
+
+ # An initial request to an oauth_aware decorated path should be a
+ # redirect to login.
+ response = self.app.get('/bar_path/2012/03')
+ self.assertTrue(response.status.startswith('302'))
+ self.assertTrue('Login' in str(response))
+
+ def test_decorator_from_unfilled_client_secrets_required(self):
+ MESSAGE = 'File is missing'
+ try:
+ appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('unfilled_client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'], message=MESSAGE)
+ except clientsecrets.InvalidClientSecretsError:
+ pass
+
+ def test_decorator_from_unfilled_client_secrets_aware(self):
+ MESSAGE = 'File is missing'
+ try:
+ appengine.OAuth2DecoratorFromClientSecrets(
+ datafile('unfilled_client_secrets.json'),
+ scope=['foo_scope', 'bar_scope'], message=MESSAGE)
+ except clientsecrets.InvalidClientSecretsError:
+ pass
+
+ def test_decorator_from_client_secrets_with_optional_settings(self):
+ # Test that the decorator works with the absense of a revoke_uri in
+ # the client secrets.
+ loadfile_patch = mock.patch(
+ 'oauth2client.contrib.appengine.clientsecrets.loadfile')
+ with loadfile_patch as loadfile_mock:
+ loadfile_mock.return_value = (clientsecrets.TYPE_WEB, {
+ "client_id": "foo_client_id",
+ "client_secret": "foo_client_secret",
+ "redirect_uris": [],
+ "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth",
+ "token_uri": "https://www.googleapis.com/oauth2/v4/token",
+ # No revoke URI
+ })
+
+ decorator = appengine.OAuth2DecoratorFromClientSecrets(
+ 'doesntmatter.json',
+ scope=['foo_scope', 'bar_scope'])
+
+ self.assertEqual(decorator._revoke_uri, oauth2client.GOOGLE_REVOKE_URI)
+ # This is never set, but it's consistent with other tests.
+ self.assertFalse(decorator._in_error)
+
+ def test_invalid_state(self):
+ with mock.patch.object(appengine, '_parse_state_value',
+ return_value=None, autospec=True):
+ # Now simulate the callback to /oauth2callback.
+ response = self.app.get('/oauth2callback', {
+ 'code': 'foo_access_code',
+ 'state': 'foo_path:xsrfkey123',
+ })
+ self.assertEqual('200 OK', response.status)
+ self.assertEqual('The authorization request failed', response.body)
+
+
+class DecoratorXsrfSecretTests(unittest2.TestCase):
+ """Test xsrf_secret_key."""
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_build_and_parse_state(self):
+ secret = appengine.xsrf_secret_key()
+
+ # Secret shouldn't change from call to call.
+ secret2 = appengine.xsrf_secret_key()
+ self.assertEqual(secret, secret2)
+
+ # Secret shouldn't change if memcache goes away.
+ memcache.delete(appengine.XSRF_MEMCACHE_ID,
+ namespace=appengine.OAUTH2CLIENT_NAMESPACE)
+ secret3 = appengine.xsrf_secret_key()
+ self.assertEqual(secret2, secret3)
+
+ # Secret should change if both memcache and the model goes away.
+ memcache.delete(appengine.XSRF_MEMCACHE_ID,
+ namespace=appengine.OAUTH2CLIENT_NAMESPACE)
+ model = appengine.SiteXsrfSecretKey.get_or_insert('site')
+ model.delete()
+
+ secret4 = appengine.xsrf_secret_key()
+ self.assertNotEqual(secret3, secret4)
+
+ def test_ndb_insert_db_get(self):
+ secret = appengine._generate_new_xsrf_secret_key()
+ appengine.SiteXsrfSecretKeyNDB(id='site', secret=secret).put()
+
+ site_key = appengine.SiteXsrfSecretKey.get_by_key_name('site')
+ self.assertEqual(site_key.secret, secret)
+
+ def test_db_insert_ndb_get(self):
+ secret = appengine._generate_new_xsrf_secret_key()
+ appengine.SiteXsrfSecretKey(key_name='site', secret=secret).put()
+
+ site_key = appengine.SiteXsrfSecretKeyNDB.get_by_id('site')
+ self.assertEqual(site_key.secret, secret)
+
+
+class DecoratorXsrfProtectionTests(unittest2.TestCase):
+ """Test _build_state_value and _parse_state_value."""
+
+ def setUp(self):
+ self.testbed = testbed.Testbed()
+ self.testbed.activate()
+ self.testbed.init_datastore_v3_stub()
+ self.testbed.init_memcache_stub()
+
+ def tearDown(self):
+ self.testbed.deactivate()
+
+ def test_build_and_parse_state(self):
+ state = appengine._build_state_value(MockRequestHandler(), UserMock())
+ self.assertEqual(
+ 'https://example.org',
+ appengine._parse_state_value(state, UserMock()))
+ redirect_uri = appengine._parse_state_value(state[1:], UserMock())
+ self.assertIsNone(redirect_uri)
diff --git a/tests/contrib/test_devshell.py b/tests/contrib/test_devshell.py
new file mode 100644
index 0000000..659a53b
--- /dev/null
+++ b/tests/contrib/test_devshell.py
@@ -0,0 +1,266 @@
+# Copyright 2015 Google Inc. All Rights Reserved.
+#
+# 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.
+
+"""Tests for oauth2client.contrib.devshell."""
+
+import datetime
+import json
+import os
+import socket
+import threading
+
+import mock
+import unittest2
+
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client.contrib import devshell
+
+# A dummy value to use for the expires_in field
+# in CredentialInfoResponse.
+EXPIRES_IN = 1000
+DEFAULT_CREDENTIAL_JSON = json.dumps([
+ 'joe@example.com',
+ 'fooproj',
+ 'sometoken',
+ EXPIRES_IN
+])
+
+
+class TestCredentialInfoResponse(unittest2.TestCase):
+
+ def test_constructor_with_non_list(self):
+ json_non_list = '{}'
+ with self.assertRaises(ValueError):
+ devshell.CredentialInfoResponse(json_non_list)
+
+ def test_constructor_with_bad_json(self):
+ json_non_list = '{BADJSON'
+ with self.assertRaises(ValueError):
+ devshell.CredentialInfoResponse(json_non_list)
+
+ def test_constructor_empty_list(self):
+ info_response = devshell.CredentialInfoResponse('[]')
+ self.assertEqual(info_response.user_email, None)
+ self.assertEqual(info_response.project_id, None)
+ self.assertEqual(info_response.access_token, None)
+ self.assertEqual(info_response.expires_in, None)
+
+ def test_constructor_full_list(self):
+ user_email = 'user_email'
+ project_id = 'project_id'
+ access_token = 'access_token'
+ expires_in = 1
+ json_string = json.dumps(
+ [user_email, project_id, access_token, expires_in])
+ info_response = devshell.CredentialInfoResponse(json_string)
+ self.assertEqual(info_response.user_email, user_email)
+ self.assertEqual(info_response.project_id, project_id)
+ self.assertEqual(info_response.access_token, access_token)
+ self.assertEqual(info_response.expires_in, expires_in)
+
+
+class Test_SendRecv(unittest2.TestCase):
+
+ def test_port_zero(self):
+ with mock.patch('oauth2client.contrib.devshell.os') as os_mod:
+ os_mod.getenv = mock.MagicMock(name='getenv', return_value=0)
+ with self.assertRaises(devshell.NoDevshellServer):
+ devshell._SendRecv()
+ os_mod.getenv.assert_called_once_with(devshell.DEVSHELL_ENV, 0)
+
+ def test_no_newline_in_received_header(self):
+ non_zero_port = 1
+ sock = mock.MagicMock()
+
+ header_without_newline = ''
+ sock.recv(6).decode = mock.MagicMock(
+ name='decode', return_value=header_without_newline)
+
+ with mock.patch('oauth2client.contrib.devshell.os') as os_mod:
+ os_mod.getenv = mock.MagicMock(name='getenv',
+ return_value=non_zero_port)
+ with mock.patch('oauth2client.contrib.devshell.socket') as socket:
+ socket.socket = mock.MagicMock(name='socket',
+ return_value=sock)
+ with self.assertRaises(devshell.CommunicationError):
+ devshell._SendRecv()
+ os_mod.getenv.assert_called_once_with(devshell.DEVSHELL_ENV, 0)
+ socket.socket.assert_called_once_with()
+ sock.recv(6).decode.assert_called_once_with()
+
+ data = devshell.CREDENTIAL_INFO_REQUEST_JSON
+ msg = _helpers._to_bytes(
+ '{0}\n{1}'.format(len(data), data), encoding='utf-8')
+ expected_sock_calls = [
+ mock.call.recv(6), # From the set-up above
+ mock.call.connect(('localhost', non_zero_port)),
+ mock.call.sendall(msg),
+ mock.call.recv(6),
+ mock.call.recv(6), # From the check above
+ ]
+ self.assertEqual(sock.method_calls, expected_sock_calls)
+
+
+class _AuthReferenceServer(threading.Thread):
+
+ def __init__(self, response=None):
+ super(_AuthReferenceServer, self).__init__(None)
+ self.response = response or DEFAULT_CREDENTIAL_JSON
+ self.bad_request = False
+
+ def __enter__(self):
+ return self.start_server()
+
+ def start_server(self):
+ self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ self._socket.bind(('localhost', 0))
+ port = self._socket.getsockname()[1]
+ os.environ[devshell.DEVSHELL_ENV] = str(port)
+ self._socket.listen(0)
+ self.daemon = True
+ self.start()
+ return self
+
+ def __exit__(self, e_type, value, traceback):
+ self.stop_server()
+
+ def stop_server(self):
+ del os.environ[devshell.DEVSHELL_ENV]
+ self._socket.close()
+
+ def run(self):
+ s = None
+ try:
+ # Do not set the timeout on the socket, leave it in the blocking
+ # mode as setting the timeout seems to cause spurious EAGAIN
+ # errors on OSX.
+ self._socket.settimeout(None)
+
+ s, unused_addr = self._socket.accept()
+ resp_buffer = ''
+ resp_1 = s.recv(6).decode()
+ nstr, extra = resp_1.split('\n', 1)
+ resp_buffer = extra
+ n = int(nstr)
+ to_read = n - len(extra)
+ if to_read > 0:
+ resp_buffer += _helpers._from_bytes(
+ s.recv(to_read, socket.MSG_WAITALL))
+ if resp_buffer != devshell.CREDENTIAL_INFO_REQUEST_JSON:
+ self.bad_request = True
+ l = len(self.response)
+ s.sendall('{0}\n{1}'.format(l, self.response).encode())
+ finally:
+ # Will fail if s is None, but these tests never encounter
+ # that scenario.
+ s.close()
+
+
+class DevshellCredentialsTests(unittest2.TestCase):
+
+ def test_signals_no_server(self):
+ with self.assertRaises(devshell.NoDevshellServer):
+ devshell.DevshellCredentials()
+
+ def test_bad_message_to_mock_server(self):
+ request_content = devshell.CREDENTIAL_INFO_REQUEST_JSON + 'extrastuff'
+ request_message = _helpers._to_bytes(
+ '{0}\n{1}'.format(len(request_content), request_content))
+ response_message = 'foobar'
+ with _AuthReferenceServer(response_message) as auth_server:
+ self.assertFalse(auth_server.bad_request)
+ sock = socket.socket()
+ port = int(os.getenv(devshell.DEVSHELL_ENV, 0))
+ sock.connect(('localhost', port))
+ sock.sendall(request_message)
+
+ # Mimic the receive part of _SendRecv
+ header = sock.recv(6).decode()
+ len_str, result = header.split('\n', 1)
+ to_read = int(len_str) - len(result)
+ result += sock.recv(to_read, socket.MSG_WAITALL).decode()
+
+ self.assertTrue(auth_server.bad_request)
+ self.assertEqual(result, response_message)
+
+ def test_request_response(self):
+ with _AuthReferenceServer():
+ response = devshell._SendRecv()
+ self.assertEqual(response.user_email, 'joe@example.com')
+ self.assertEqual(response.project_id, 'fooproj')
+ self.assertEqual(response.access_token, 'sometoken')
+
+ def test_no_refresh_token(self):
+ with _AuthReferenceServer():
+ creds = devshell.DevshellCredentials()
+ self.assertEquals(None, creds.refresh_token)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_reads_credentials(self, utcnow):
+ NOW = datetime.datetime(1992, 12, 31)
+ utcnow.return_value = NOW
+ with _AuthReferenceServer():
+ creds = devshell.DevshellCredentials()
+ self.assertEqual('joe@example.com', creds.user_email)
+ self.assertEqual('fooproj', creds.project_id)
+ self.assertEqual('sometoken', creds.access_token)
+ self.assertEqual(
+ NOW + datetime.timedelta(seconds=EXPIRES_IN),
+ creds.token_expiry)
+ utcnow.assert_called_once_with()
+
+ def test_handles_skipped_fields(self):
+ with _AuthReferenceServer('["joe@example.com"]'):
+ creds = devshell.DevshellCredentials()
+ self.assertEqual('joe@example.com', creds.user_email)
+ self.assertEqual(None, creds.project_id)
+ self.assertEqual(None, creds.access_token)
+ self.assertEqual(None, creds.token_expiry)
+
+ def test_handles_tiny_response(self):
+ with _AuthReferenceServer('[]'):
+ creds = devshell.DevshellCredentials()
+ self.assertEqual(None, creds.user_email)
+ self.assertEqual(None, creds.project_id)
+ self.assertEqual(None, creds.access_token)
+
+ def test_handles_ignores_extra_fields(self):
+ with _AuthReferenceServer(
+ '["joe@example.com", "fooproj", "sometoken", 1, "extra"]'):
+ creds = devshell.DevshellCredentials()
+ self.assertEqual('joe@example.com', creds.user_email)
+ self.assertEqual('fooproj', creds.project_id)
+ self.assertEqual('sometoken', creds.access_token)
+
+ def test_refuses_to_save_to_well_known_file(self):
+ ORIGINAL_ISDIR = os.path.isdir
+ try:
+ os.path.isdir = lambda path: True
+ with _AuthReferenceServer():
+ creds = devshell.DevshellCredentials()
+ with self.assertRaises(NotImplementedError):
+ client.save_to_well_known_file(creds)
+ finally:
+ os.path.isdir = ORIGINAL_ISDIR
+
+ def test_from_json(self):
+ with self.assertRaises(NotImplementedError):
+ devshell.DevshellCredentials.from_json(None)
+
+ def test_serialization_data(self):
+ with _AuthReferenceServer('[]'):
+ credentials = devshell.DevshellCredentials()
+ with self.assertRaises(NotImplementedError):
+ getattr(credentials, 'serialization_data')
diff --git a/tests/contrib/test_dictionary_storage.py b/tests/contrib/test_dictionary_storage.py
new file mode 100644
index 0000000..888c938
--- /dev/null
+++ b/tests/contrib/test_dictionary_storage.py
@@ -0,0 +1,107 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client.contrib.dictionary_storage"""
+
+import unittest2
+
+import oauth2client
+from oauth2client import client
+from oauth2client.contrib import dictionary_storage
+
+
+def _generate_credentials(scopes=None):
+ return client.OAuth2Credentials(
+ 'access_tokenz',
+ 'client_idz',
+ 'client_secretz',
+ 'refresh_tokenz',
+ '3600',
+ oauth2client.GOOGLE_TOKEN_URI,
+ 'Test',
+ id_token={
+ 'sub': '123',
+ 'email': 'user@example.com'
+ },
+ scopes=scopes)
+
+
+class DictionaryStorageTests(unittest2.TestCase):
+
+ def test_constructor_defaults(self):
+ dictionary = {}
+ key = 'test-key'
+ storage = dictionary_storage.DictionaryStorage(dictionary, key)
+
+ self.assertEqual(dictionary, storage._dictionary)
+ self.assertEqual(key, storage._key)
+ self.assertIsNone(storage._lock)
+
+ def test_constructor_explicit(self):
+ dictionary = {}
+ key = 'test-key'
+ storage = dictionary_storage.DictionaryStorage(dictionary, key)
+
+ lock = object()
+ storage = dictionary_storage.DictionaryStorage(
+ dictionary, key, lock=lock)
+ self.assertEqual(storage._lock, lock)
+
+ def test_get(self):
+ credentials = _generate_credentials()
+ dictionary = {}
+ key = 'credentials'
+ storage = dictionary_storage.DictionaryStorage(dictionary, key)
+
+ self.assertIsNone(storage.get())
+
+ dictionary[key] = credentials.to_json()
+ returned = storage.get()
+
+ self.assertIsNotNone(returned)
+ self.assertEqual(returned.access_token, credentials.access_token)
+ self.assertEqual(returned.id_token, credentials.id_token)
+ self.assertEqual(returned.refresh_token, credentials.refresh_token)
+ self.assertEqual(returned.client_id, credentials.client_id)
+
+ def test_put(self):
+ credentials = _generate_credentials()
+ dictionary = {}
+ key = 'credentials'
+ storage = dictionary_storage.DictionaryStorage(dictionary, key)
+
+ storage.put(credentials)
+ returned = storage.get()
+
+ self.assertIn(key, dictionary)
+ self.assertIsNotNone(returned)
+ self.assertEqual(returned.access_token, credentials.access_token)
+ self.assertEqual(returned.id_token, credentials.id_token)
+ self.assertEqual(returned.refresh_token, credentials.refresh_token)
+ self.assertEqual(returned.client_id, credentials.client_id)
+
+ def test_delete(self):
+ credentials = _generate_credentials()
+ dictionary = {}
+ key = 'credentials'
+ storage = dictionary_storage.DictionaryStorage(dictionary, key)
+
+ storage.put(credentials)
+
+ self.assertIn(key, dictionary)
+
+ storage.delete()
+
+ self.assertNotIn(key, dictionary)
+ self.assertIsNone(storage.get())
diff --git a/tests/contrib/test_flask_util.py b/tests/contrib/test_flask_util.py
new file mode 100644
index 0000000..74cb218
--- /dev/null
+++ b/tests/contrib/test_flask_util.py
@@ -0,0 +1,531 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for the Flask utilities"""
+
+import datetime
+import json
+import logging
+
+import flask
+import httplib2
+import mock
+import six.moves.http_client as httplib
+import six.moves.urllib.parse as urlparse
+import unittest2
+
+import oauth2client
+from oauth2client import client
+from oauth2client import clientsecrets
+from oauth2client.contrib import flask_util
+
+
+__author__ = 'jonwayne@google.com (Jon Wayne Parrott)'
+
+
+class Http2Mock(object):
+ """Mock httplib2.Http for code exchange / refresh"""
+
+ def __init__(self, status=httplib.OK, **kwargs):
+ self.status = status
+ self.content = {
+ 'access_token': 'foo_access_token',
+ 'refresh_token': 'foo_refresh_token',
+ 'expires_in': 3600,
+ 'extra': 'value',
+ }
+ self.content.update(kwargs)
+
+ def request(self, token_uri, method, body, headers, *args, **kwargs):
+ self.body = body
+ self.headers = headers
+ return (self, json.dumps(self.content).encode('utf-8'))
+
+ def __enter__(self):
+ self.httplib2_orig = httplib2.Http
+ httplib2.Http = self
+ return self
+
+ def __exit__(self, exc_type, exc_value, traceback):
+ httplib2.Http = self.httplib2_orig
+
+ def __call__(self, *args, **kwargs):
+ return self
+
+
+class FlaskOAuth2Tests(unittest2.TestCase):
+
+ def setUp(self):
+ self.app = flask.Flask(__name__)
+ self.app.testing = True
+ self.app.config['SECRET_KEY'] = 'notasecert'
+ self.app.logger.setLevel(logging.CRITICAL)
+ self.oauth2 = flask_util.UserOAuth2(
+ self.app,
+ client_id='client_idz',
+ client_secret='client_secretz')
+
+ def _generate_credentials(self, scopes=None):
+ return client.OAuth2Credentials(
+ 'access_tokenz',
+ 'client_idz',
+ 'client_secretz',
+ 'refresh_tokenz',
+ datetime.datetime.utcnow() + datetime.timedelta(seconds=3600),
+ oauth2client.GOOGLE_TOKEN_URI,
+ 'Test',
+ id_token={
+ 'sub': '123',
+ 'email': 'user@example.com'
+ },
+ scopes=scopes)
+
+ def test_explicit_configuration(self):
+ oauth2 = flask_util.UserOAuth2(
+ flask.Flask(__name__), client_id='id', client_secret='secret')
+
+ self.assertEqual(oauth2.client_id, 'id')
+ self.assertEqual(oauth2.client_secret, 'secret')
+
+ return_val = (
+ clientsecrets.TYPE_WEB,
+ {'client_id': 'id', 'client_secret': 'secret'})
+
+ with mock.patch('oauth2client.clientsecrets.loadfile',
+ return_value=return_val):
+
+ oauth2 = flask_util.UserOAuth2(
+ flask.Flask(__name__), client_secrets_file='file.json')
+
+ self.assertEqual(oauth2.client_id, 'id')
+ self.assertEqual(oauth2.client_secret, 'secret')
+
+ def test_delayed_configuration(self):
+ app = flask.Flask(__name__)
+ oauth2 = flask_util.UserOAuth2()
+ oauth2.init_app(app, client_id='id', client_secret='secret')
+ self.assertEqual(oauth2.app, app)
+
+ def test_explicit_storage(self):
+ storage_mock = mock.Mock()
+ oauth2 = flask_util.UserOAuth2(
+ flask.Flask(__name__), storage=storage_mock, client_id='id',
+ client_secret='secret')
+ self.assertEqual(oauth2.storage, storage_mock)
+
+ def test_explicit_scopes(self):
+ oauth2 = flask_util.UserOAuth2(
+ flask.Flask(__name__), scopes=['1', '2'], client_id='id',
+ client_secret='secret')
+ self.assertEqual(oauth2.scopes, ['1', '2'])
+
+ def test_bad_client_secrets(self):
+ return_val = (
+ 'other',
+ {'client_id': 'id', 'client_secret': 'secret'})
+
+ with mock.patch('oauth2client.clientsecrets.loadfile',
+ return_value=return_val):
+ with self.assertRaises(ValueError):
+ flask_util.UserOAuth2(flask.Flask(__name__),
+ client_secrets_file='file.json')
+
+ def test_app_configuration(self):
+ app = flask.Flask(__name__)
+ app.config['GOOGLE_OAUTH2_CLIENT_ID'] = 'id'
+ app.config['GOOGLE_OAUTH2_CLIENT_SECRET'] = 'secret'
+
+ oauth2 = flask_util.UserOAuth2(app)
+
+ self.assertEqual(oauth2.client_id, 'id')
+ self.assertEqual(oauth2.client_secret, 'secret')
+
+ return_val = (
+ clientsecrets.TYPE_WEB,
+ {'client_id': 'id2', 'client_secret': 'secret2'})
+
+ with mock.patch('oauth2client.clientsecrets.loadfile',
+ return_value=return_val):
+
+ app = flask.Flask(__name__)
+ app.config['GOOGLE_OAUTH2_CLIENT_SECRETS_FILE'] = 'file.json'
+ oauth2 = flask_util.UserOAuth2(app)
+
+ self.assertEqual(oauth2.client_id, 'id2')
+ self.assertEqual(oauth2.client_secret, 'secret2')
+
+ def test_no_configuration(self):
+ with self.assertRaises(ValueError):
+ flask_util.UserOAuth2(flask.Flask(__name__))
+
+ def test_create_flow(self):
+ with self.app.test_request_context():
+ flow = self.oauth2._make_flow()
+ state = json.loads(flow.params['state'])
+ self.assertIn('google_oauth2_csrf_token', flask.session)
+ self.assertEqual(
+ flask.session['google_oauth2_csrf_token'], state['csrf_token'])
+ self.assertEqual(flow.client_id, self.oauth2.client_id)
+ self.assertEqual(flow.client_secret, self.oauth2.client_secret)
+ self.assertIn('http', flow.redirect_uri)
+ self.assertIn('oauth2callback', flow.redirect_uri)
+
+ flow = self.oauth2._make_flow(return_url='/return_url')
+ state = json.loads(flow.params['state'])
+ self.assertEqual(state['return_url'], '/return_url')
+
+ flow = self.oauth2._make_flow(extra_arg='test')
+ self.assertEqual(flow.params['extra_arg'], 'test')
+
+ # Test extra args specified in the constructor.
+ app = flask.Flask(__name__)
+ app.config['SECRET_KEY'] = 'notasecert'
+ oauth2 = flask_util.UserOAuth2(
+ app, client_id='client_id', client_secret='secret',
+ extra_arg='test')
+
+ with app.test_request_context():
+ flow = oauth2._make_flow()
+ self.assertEqual(flow.params['extra_arg'], 'test')
+
+ def test_authorize_view(self):
+ with self.app.test_client() as client:
+ response = client.get('/oauth2authorize')
+ location = response.headers['Location']
+ q = urlparse.parse_qs(location.split('?', 1)[1])
+ state = json.loads(q['state'][0])
+
+ self.assertIn(oauth2client.GOOGLE_AUTH_URI, location)
+ self.assertNotIn(self.oauth2.client_secret, location)
+ self.assertIn(self.oauth2.client_id, q['client_id'])
+ self.assertEqual(
+ flask.session['google_oauth2_csrf_token'], state['csrf_token'])
+ self.assertEqual(state['return_url'], '/')
+
+ with self.app.test_client() as client:
+ response = client.get('/oauth2authorize?return_url=/test')
+ location = response.headers['Location']
+ q = urlparse.parse_qs(location.split('?', 1)[1])
+ state = json.loads(q['state'][0])
+ self.assertEqual(state['return_url'], '/test')
+
+ with self.app.test_client() as client:
+ response = client.get('/oauth2authorize?extra_param=test')
+ location = response.headers['Location']
+ self.assertIn('extra_param=test', location)
+
+ def _setup_callback_state(self, client, **kwargs):
+ with self.app.test_request_context():
+ # Flask doesn't create a request context with a session
+ # transaction for some reason, so, set up the flow here,
+ # then apply it to the session in the transaction.
+ if not kwargs:
+ self.oauth2._make_flow(return_url='/return_url')
+ else:
+ self.oauth2._make_flow(**kwargs)
+
+ with client.session_transaction() as session:
+ session.update(flask.session)
+ csrf_token = session['google_oauth2_csrf_token']
+ flow = flask_util._get_flow_for_token(csrf_token)
+ state = flow.params['state']
+
+ return state
+
+ def test_callback_view(self):
+ self.oauth2.storage = mock.Mock()
+ with self.app.test_client() as client:
+ with Http2Mock() as http:
+ state = self._setup_callback_state(client)
+
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+
+ self.assertEqual(response.status_code, httplib.FOUND)
+ self.assertIn('/return_url', response.headers['Location'])
+ self.assertIn(self.oauth2.client_secret, http.body)
+ self.assertIn('codez', http.body)
+ self.assertTrue(self.oauth2.storage.put.called)
+
+ def test_authorize_callback(self):
+ self.oauth2.authorize_callback = mock.Mock()
+ self.test_callback_view()
+ self.assertTrue(self.oauth2.authorize_callback.called)
+
+ def test_callback_view_errors(self):
+ # Error supplied to callback
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_csrf_token'] = 'tokenz'
+
+ response = client.get('/oauth2callback?state={}&error=something')
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+ self.assertIn('something', response.data.decode('utf-8'))
+
+ # CSRF mismatch
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_csrf_token'] = 'goodstate'
+
+ state = json.dumps({
+ 'csrf_token': 'badstate',
+ 'return_url': '/return_url'
+ })
+
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+
+ # KeyError, no CSRF state.
+ with self.app.test_client() as client:
+ response = client.get('/oauth2callback?state={}&code=codez')
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+
+ # Code exchange error
+ with self.app.test_client() as client:
+ state = self._setup_callback_state(client)
+
+ with Http2Mock(status=httplib.INTERNAL_SERVER_ERROR):
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+
+ # Invalid state json
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_csrf_token'] = 'tokenz'
+
+ state = '[{'
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+
+ # Missing flow.
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_csrf_token'] = 'tokenz'
+
+ state = json.dumps({
+ 'csrf_token': 'tokenz',
+ 'return_url': '/return_url'
+ })
+
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+ self.assertEqual(response.status_code, httplib.BAD_REQUEST)
+
+ def test_no_credentials(self):
+ with self.app.test_request_context():
+ self.assertFalse(self.oauth2.has_credentials())
+ self.assertTrue(self.oauth2.credentials is None)
+ self.assertTrue(self.oauth2.user_id is None)
+ self.assertTrue(self.oauth2.email is None)
+ with self.assertRaises(ValueError):
+ self.oauth2.http()
+ self.assertFalse(self.oauth2.storage.get())
+ self.oauth2.storage.delete()
+
+ def test_with_credentials(self):
+ credentials = self._generate_credentials()
+ with self.app.test_request_context():
+ self.oauth2.storage.put(credentials)
+ self.assertEqual(
+ self.oauth2.credentials.access_token, credentials.access_token)
+ self.assertEqual(
+ self.oauth2.credentials.refresh_token,
+ credentials.refresh_token)
+ self.assertEqual(self.oauth2.user_id, '123')
+ self.assertEqual(self.oauth2.email, 'user@example.com')
+ self.assertTrue(self.oauth2.http())
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_with_expired_credentials(self, utcnow):
+ utcnow.return_value = datetime.datetime(1990, 5, 29)
+
+ credentials = self._generate_credentials()
+ credentials.token_expiry = datetime.datetime(1990, 5, 28)
+
+ # Has a refresh token, so this should be fine.
+ with self.app.test_request_context():
+ self.oauth2.storage.put(credentials)
+ self.assertTrue(self.oauth2.has_credentials())
+
+ # Without a refresh token this should return false.
+ credentials.refresh_token = None
+ with self.app.test_request_context():
+ self.oauth2.storage.put(credentials)
+ self.assertFalse(self.oauth2.has_credentials())
+
+ def test_bad_id_token(self):
+ credentials = self._generate_credentials()
+ credentials.id_token = {}
+ with self.app.test_request_context():
+ self.oauth2.storage.put(credentials)
+ self.assertTrue(self.oauth2.user_id is None)
+ self.assertTrue(self.oauth2.email is None)
+
+ def test_required(self):
+ @self.app.route('/protected')
+ @self.oauth2.required
+ def index():
+ return 'Hello'
+
+ # No credentials, should redirect
+ with self.app.test_client() as client:
+ response = client.get('/protected')
+ self.assertEqual(response.status_code, httplib.FOUND)
+ self.assertIn('oauth2authorize', response.headers['Location'])
+ self.assertIn('protected', response.headers['Location'])
+
+ credentials = self._generate_credentials(scopes=self.oauth2.scopes)
+
+ # With credentials, should allow
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_credentials'] = credentials.to_json()
+
+ response = client.get('/protected')
+ self.assertEqual(response.status_code, httplib.OK)
+ self.assertIn('Hello', response.data.decode('utf-8'))
+
+ # Expired credentials with refresh token, should allow.
+ credentials.token_expiry = datetime.datetime(1990, 5, 28)
+ with mock.patch('oauth2client.client._UTCNOW') as utcnow:
+ utcnow.return_value = datetime.datetime(1990, 5, 29)
+
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_credentials'] = (
+ credentials.to_json())
+
+ response = client.get('/protected')
+ self.assertEqual(response.status_code, httplib.OK)
+ self.assertIn('Hello', response.data.decode('utf-8'))
+
+ # Expired credentials without a refresh token, should redirect.
+ credentials.refresh_token = None
+ with mock.patch('oauth2client.client._UTCNOW') as utcnow:
+ utcnow.return_value = datetime.datetime(1990, 5, 29)
+
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_credentials'] = (
+ credentials.to_json())
+
+ response = client.get('/protected')
+ self.assertEqual(response.status_code, httplib.FOUND)
+ self.assertIn('oauth2authorize', response.headers['Location'])
+ self.assertIn('protected', response.headers['Location'])
+
+ def _create_incremental_auth_app(self):
+ self.app = flask.Flask(__name__)
+ self.app.testing = True
+ self.app.config['SECRET_KEY'] = 'notasecert'
+ self.oauth2 = flask_util.UserOAuth2(
+ self.app,
+ client_id='client_idz',
+ client_secret='client_secretz',
+ include_granted_scopes=True)
+
+ @self.app.route('/one')
+ @self.oauth2.required(scopes=['one'])
+ def one():
+ return 'Hello'
+
+ @self.app.route('/two')
+ @self.oauth2.required(scopes=['two', 'three'])
+ def two():
+ return 'Hello'
+
+ def test_incremental_auth(self):
+ self._create_incremental_auth_app()
+
+ # No credentials, should redirect
+ with self.app.test_client() as client:
+ response = client.get('/one')
+ self.assertIn('one', response.headers['Location'])
+ self.assertEqual(response.status_code, httplib.FOUND)
+
+ # Credentials for one. /one should allow, /two should redirect.
+ credentials = self._generate_credentials(scopes=['email', 'one'])
+
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_credentials'] = credentials.to_json()
+
+ response = client.get('/one')
+ self.assertEqual(response.status_code, httplib.OK)
+
+ response = client.get('/two')
+ self.assertIn('two', response.headers['Location'])
+ self.assertEqual(response.status_code, httplib.FOUND)
+
+ # Starting the authorization flow should include the
+ # include_granted_scopes parameter as well as the scopes.
+ response = client.get(response.headers['Location'][17:])
+ q = urlparse.parse_qs(
+ response.headers['Location'].split('?', 1)[1])
+ self.assertIn('include_granted_scopes', q)
+ self.assertEqual(
+ set(q['scope'][0].split(' ')),
+ set(['one', 'email', 'two', 'three']))
+
+ # Actually call two() without a redirect.
+ credentials2 = self._generate_credentials(
+ scopes=['email', 'two', 'three'])
+
+ with self.app.test_client() as client:
+ with client.session_transaction() as session:
+ session['google_oauth2_credentials'] = credentials2.to_json()
+
+ response = client.get('/two')
+ self.assertEqual(response.status_code, httplib.OK)
+
+ def test_incremental_auth_exchange(self):
+ self._create_incremental_auth_app()
+
+ with Http2Mock():
+ with self.app.test_client() as client:
+ state = self._setup_callback_state(
+ client,
+ return_url='/return_url',
+ # Incremental auth scopes.
+ scopes=['one', 'two'])
+
+ response = client.get(
+ '/oauth2callback?state={0}&code=codez'.format(state))
+ self.assertEqual(response.status_code, httplib.FOUND)
+
+ credentials = self.oauth2.credentials
+ self.assertTrue(
+ credentials.has_scopes(['email', 'one', 'two']))
+
+ def test_refresh(self):
+ with self.app.test_request_context():
+ with mock.patch('flask.session'):
+ self.oauth2.storage.put(self._generate_credentials())
+
+ self.oauth2.credentials.refresh(
+ Http2Mock(access_token='new_token'))
+
+ self.assertEqual(
+ self.oauth2.storage.get().access_token, 'new_token')
+
+ def test_delete(self):
+ with self.app.test_request_context():
+
+ self.oauth2.storage.put(self._generate_credentials())
+ self.oauth2.storage.delete()
+
+ self.assertNotIn('google_oauth2_credentials', flask.session)
diff --git a/tests/contrib/test_gce.py b/tests/contrib/test_gce.py
new file mode 100644
index 0000000..e71bd44
--- /dev/null
+++ b/tests/contrib/test_gce.py
@@ -0,0 +1,152 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client.contrib.gce."""
+
+import datetime
+import json
+
+import httplib2
+import mock
+from six.moves import http_client
+from tests.contrib.test_metadata import request_mock
+import unittest2
+
+from oauth2client import client
+from oauth2client.contrib import gce
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+SERVICE_ACCOUNT_INFO = {
+ 'scopes': ['a', 'b'],
+ 'email': 'a@example.com',
+ 'aliases': ['default']
+}
+
+
+class AppAssertionCredentialsTests(unittest2.TestCase):
+
+ def test_constructor(self):
+ credentials = gce.AppAssertionCredentials()
+ self.assertIsNone(credentials.assertion_type, None)
+ self.assertIsNone(credentials.service_account_email)
+ self.assertIsNone(credentials.scopes)
+ self.assertTrue(credentials.invalid)
+
+ @mock.patch('warnings.warn')
+ def test_constructor_with_scopes(self, warn_mock):
+ scope = 'http://example.com/a http://example.com/b'
+ scopes = scope.split()
+ credentials = gce.AppAssertionCredentials(scopes=scopes)
+ self.assertEqual(credentials.scopes, None)
+ self.assertEqual(credentials.assertion_type, None)
+ warn_mock.assert_called_once_with(gce._SCOPES_WARNING)
+
+ def test_to_json(self):
+ credentials = gce.AppAssertionCredentials()
+ with self.assertRaises(NotImplementedError):
+ credentials.to_json()
+
+ def test_from_json(self):
+ with self.assertRaises(NotImplementedError):
+ gce.AppAssertionCredentials.from_json({})
+
+ @mock.patch('oauth2client.contrib._metadata.get_token',
+ side_effect=[('A', datetime.datetime.min),
+ ('B', datetime.datetime.max)])
+ @mock.patch('oauth2client.contrib._metadata.get_service_account_info',
+ return_value=SERVICE_ACCOUNT_INFO)
+ def test_refresh_token(self, get_info, get_token):
+ http_request = mock.MagicMock()
+ http_mock = mock.MagicMock(request=http_request)
+ credentials = gce.AppAssertionCredentials()
+ credentials.invalid = False
+ credentials.service_account_email = 'a@example.com'
+ self.assertIsNone(credentials.access_token)
+ credentials.get_access_token(http=http_mock)
+ self.assertEqual(credentials.access_token, 'A')
+ self.assertTrue(credentials.access_token_expired)
+ get_token.assert_called_with(http_request,
+ service_account='a@example.com')
+ credentials.get_access_token(http=http_mock)
+ self.assertEqual(credentials.access_token, 'B')
+ self.assertFalse(credentials.access_token_expired)
+ get_token.assert_called_with(http_request,
+ service_account='a@example.com')
+ get_info.assert_not_called()
+
+ def test_refresh_token_failed_fetch(self):
+ http_request = request_mock(
+ http_client.NOT_FOUND,
+ 'application/json',
+ json.dumps({'access_token': 'a', 'expires_in': 100})
+ )
+ credentials = gce.AppAssertionCredentials()
+ credentials.invalid = False
+ credentials.service_account_email = 'a@example.com'
+ with self.assertRaises(client.HttpAccessTokenRefreshError):
+ credentials._refresh(http_request)
+
+ def test_serialization_data(self):
+ credentials = gce.AppAssertionCredentials()
+ with self.assertRaises(NotImplementedError):
+ getattr(credentials, 'serialization_data')
+
+ def test_create_scoped_required(self):
+ credentials = gce.AppAssertionCredentials()
+ self.assertFalse(credentials.create_scoped_required())
+
+ def test_sign_blob_not_implemented(self):
+ credentials = gce.AppAssertionCredentials([])
+ with self.assertRaises(NotImplementedError):
+ credentials.sign_blob(b'blob')
+
+ @mock.patch('oauth2client.contrib._metadata.get_service_account_info',
+ return_value=SERVICE_ACCOUNT_INFO)
+ def test_retrieve_scopes(self, metadata):
+ http_request = mock.MagicMock()
+ http_mock = mock.MagicMock(request=http_request)
+ credentials = gce.AppAssertionCredentials()
+ self.assertTrue(credentials.invalid)
+ self.assertIsNone(credentials.scopes)
+ scopes = credentials.retrieve_scopes(http_mock)
+ self.assertEqual(scopes, SERVICE_ACCOUNT_INFO['scopes'])
+ self.assertFalse(credentials.invalid)
+ credentials.retrieve_scopes(http_mock)
+ # Assert scopes weren't refetched
+ metadata.assert_called_once_with(http_request,
+ service_account='default')
+
+ @mock.patch('oauth2client.contrib._metadata.get_service_account_info',
+ side_effect=httplib2.HttpLib2Error('No Such Email'))
+ def test_retrieve_scopes_bad_email(self, metadata):
+ http_request = mock.MagicMock()
+ http_mock = mock.MagicMock(request=http_request)
+ credentials = gce.AppAssertionCredentials(email='b@example.com')
+ with self.assertRaises(httplib2.HttpLib2Error):
+ credentials.retrieve_scopes(http_mock)
+
+ metadata.assert_called_once_with(http_request,
+ service_account='b@example.com')
+
+ def test_save_to_well_known_file(self):
+ import os
+ ORIGINAL_ISDIR = os.path.isdir
+ try:
+ os.path.isdir = lambda path: True
+ credentials = gce.AppAssertionCredentials()
+ with self.assertRaises(NotImplementedError):
+ client.save_to_well_known_file(credentials)
+ finally:
+ os.path.isdir = ORIGINAL_ISDIR
diff --git a/tests/contrib/test_keyring_storage.py b/tests/contrib/test_keyring_storage.py
new file mode 100644
index 0000000..5d274c0
--- /dev/null
+++ b/tests/contrib/test_keyring_storage.py
@@ -0,0 +1,171 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Tests for oauth2client.contrib.keyring_storage."""
+
+import datetime
+import threading
+
+import keyring
+import mock
+import unittest2
+
+import oauth2client
+from oauth2client import client
+from oauth2client.contrib import keyring_storage
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+class KeyringStorageTests(unittest2.TestCase):
+
+ def test_constructor(self):
+ service_name = 'my_unit_test'
+ user_name = 'me'
+ store = keyring_storage.Storage(service_name, user_name)
+ self.assertEqual(store._service_name, service_name)
+ self.assertEqual(store._user_name, user_name)
+ lock_type = type(threading.Lock())
+ self.assertIsInstance(store._lock, lock_type)
+
+ def test_acquire_lock(self):
+ store = keyring_storage.Storage('my_unit_test', 'me')
+ store._lock = lock = _FakeLock()
+ self.assertEqual(lock._acquire_count, 0)
+ store.acquire_lock()
+ self.assertEqual(lock._acquire_count, 1)
+
+ def test_release_lock(self):
+ store = keyring_storage.Storage('my_unit_test', 'me')
+ store._lock = lock = _FakeLock()
+ self.assertEqual(lock._release_count, 0)
+ store.release_lock()
+ self.assertEqual(lock._release_count, 1)
+
+ def test_locked_get(self):
+ service_name = 'my_unit_test'
+ user_name = 'me'
+ mock_content = (object(), 'mock_content')
+ mock_return_creds = mock.MagicMock()
+ mock_return_creds.set_store = set_store = mock.MagicMock(
+ name='set_store')
+ with mock.patch.object(keyring, 'get_password',
+ return_value=mock_content,
+ autospec=True) as get_password:
+ class_name = 'oauth2client.client.Credentials'
+ with mock.patch(class_name) as MockCreds:
+ MockCreds.new_from_json = new_from_json = mock.MagicMock(
+ name='new_from_json', return_value=mock_return_creds)
+ store = keyring_storage.Storage(service_name, user_name)
+ credentials = store.locked_get()
+ new_from_json.assert_called_once_with(mock_content)
+ get_password.assert_called_once_with(service_name, user_name)
+ self.assertEqual(credentials, mock_return_creds)
+ set_store.assert_called_once_with(store)
+
+ def test_locked_put(self):
+ service_name = 'my_unit_test'
+ user_name = 'me'
+ store = keyring_storage.Storage(service_name, user_name)
+ with mock.patch.object(keyring, 'set_password',
+ return_value=None,
+ autospec=True) as set_password:
+ credentials = mock.MagicMock()
+ to_json_ret = object()
+ credentials.to_json = to_json = mock.MagicMock(
+ name='to_json', return_value=to_json_ret)
+ store.locked_put(credentials)
+ to_json.assert_called_once_with()
+ set_password.assert_called_once_with(service_name, user_name,
+ to_json_ret)
+
+ def test_locked_delete(self):
+ service_name = 'my_unit_test'
+ user_name = 'me'
+ store = keyring_storage.Storage(service_name, user_name)
+ with mock.patch.object(keyring, 'set_password',
+ return_value=None,
+ autospec=True) as set_password:
+ store.locked_delete()
+ set_password.assert_called_once_with(service_name, user_name, '')
+
+ def test_get_with_no_credentials_stored(self):
+ with mock.patch.object(keyring, 'get_password',
+ return_value=None,
+ autospec=True) as get_password:
+ store = keyring_storage.Storage('my_unit_test', 'me')
+ credentials = store.get()
+ self.assertEquals(None, credentials)
+ get_password.assert_called_once_with('my_unit_test', 'me')
+
+ def test_get_with_malformed_json_credentials_stored(self):
+ with mock.patch.object(keyring, 'get_password',
+ return_value='{',
+ autospec=True) as get_password:
+ store = keyring_storage.Storage('my_unit_test', 'me')
+ credentials = store.get()
+ self.assertEquals(None, credentials)
+ get_password.assert_called_once_with('my_unit_test', 'me')
+
+ def test_get_and_set_with_json_credentials_stored(self):
+ access_token = 'foo'
+ client_id = 'some_client_id'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ user_agent = 'refresh_checker/1.0'
+
+ credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, oauth2client.GOOGLE_TOKEN_URI,
+ user_agent)
+
+ # Setting autospec on a mock with an iterable side_effect is
+ # currently broken (http://bugs.python.org/issue17826), so instead
+ # we patch twice.
+ with mock.patch.object(keyring, 'get_password',
+ return_value=None,
+ autospec=True) as get_password:
+ with mock.patch.object(keyring, 'set_password',
+ return_value=None,
+ autospec=True) as set_password:
+ store = keyring_storage.Storage('my_unit_test', 'me')
+ self.assertEquals(None, store.get())
+
+ store.put(credentials)
+
+ set_password.assert_called_once_with(
+ 'my_unit_test', 'me', credentials.to_json())
+ get_password.assert_called_once_with('my_unit_test', 'me')
+
+ with mock.patch.object(keyring, 'get_password',
+ return_value=credentials.to_json(),
+ autospec=True) as get_password:
+ restored = store.get()
+ self.assertEqual('foo', restored.access_token)
+ self.assertEqual('some_client_id', restored.client_id)
+ get_password.assert_called_once_with('my_unit_test', 'me')
+
+
+class _FakeLock(object):
+
+ _acquire_count = 0
+ _release_count = 0
+
+ def acquire(self):
+ self._acquire_count += 1
+
+ def release(self):
+ self._release_count += 1
diff --git a/tests/contrib/test_locked_file.py b/tests/contrib/test_locked_file.py
new file mode 100644
index 0000000..384bef3
--- /dev/null
+++ b/tests/contrib/test_locked_file.py
@@ -0,0 +1,244 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import errno
+import os
+import sys
+import tempfile
+
+import mock
+import unittest2
+
+from oauth2client.contrib import locked_file
+
+
+class TestOpener(unittest2.TestCase):
+ def _make_one(self):
+ _filehandle, filename = tempfile.mkstemp()
+ os.close(_filehandle)
+ return locked_file._Opener(filename, 'r+', 'r'), filename
+
+ def test_ctor(self):
+ instance, filename = self._make_one()
+ self.assertFalse(instance._locked)
+ self.assertEqual(instance._filename, filename)
+ self.assertEqual(instance._mode, 'r+')
+ self.assertEqual(instance._fallback_mode, 'r')
+ self.assertIsNone(instance._fh)
+ self.assertIsNone(instance._lock_fd)
+
+ def test_is_locked(self):
+ instance, _ = self._make_one()
+ self.assertFalse(instance.is_locked())
+ instance._locked = True
+ self.assertTrue(instance.is_locked())
+
+ def test_file_handle(self):
+ instance, _ = self._make_one()
+ self.assertIsNone(instance.file_handle())
+ fh = mock.Mock()
+ instance._fh = fh
+ self.assertEqual(instance.file_handle(), fh)
+
+ def test_filename(self):
+ instance, filename = self._make_one()
+ self.assertEqual(instance.filename(), filename)
+
+ def test_open_and_lock(self):
+ instance, _ = self._make_one()
+ instance.open_and_lock(1, 1)
+
+ def test_unlock_and_close(self):
+ instance, _ = self._make_one()
+ instance.unlock_and_close()
+
+
+class TestPosixOpener(TestOpener):
+ def _make_one(self):
+ _filehandle, filename = tempfile.mkstemp()
+ os.close(_filehandle)
+ return locked_file._PosixOpener(filename, 'r+', 'r'), filename
+
+ def test_relock_fail(self):
+ instance, _ = self._make_one()
+ instance.open_and_lock(1, 1)
+
+ self.assertTrue(instance.is_locked())
+ self.assertIsNotNone(instance.file_handle())
+ with self.assertRaises(locked_file.AlreadyLockedException):
+ instance.open_and_lock(1, 1)
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ def test_lock_access_error_fallback_mode(self, mock_open):
+ # NOTE: This is a bad case. The behavior here should be that the
+ # error gets re-raised, but the module lets the if statement fall
+ # through.
+ instance, _ = self._make_one()
+ mock_open.side_effect = [IOError(errno.ENOENT, '')]
+ instance.open_and_lock(1, 1)
+
+ self.assertIsNone(instance.file_handle())
+ self.assertTrue(instance.is_locked())
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ def test_lock_non_access_error(self, mock_open):
+ instance, _ = self._make_one()
+ fh_mock = mock.Mock()
+ mock_open.side_effect = [IOError(errno.EACCES, ''), fh_mock]
+ instance.open_and_lock(1, 1)
+
+ self.assertEqual(instance.file_handle(), fh_mock)
+ self.assertFalse(instance.is_locked())
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ def test_lock_unexpected_error(self, mock_open):
+ instance, _ = self._make_one()
+
+ with mock.patch('os.open') as mock_os_open:
+ mock_os_open.side_effect = [OSError(errno.EPERM, '')]
+ with self.assertRaises(OSError):
+ instance.open_and_lock(1, 1)
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ @mock.patch('oauth2client.contrib.locked_file.logger')
+ @mock.patch('time.time')
+ def test_lock_timeout_error(self, mock_time, mock_logger, mock_open):
+ instance, _ = self._make_one()
+ # Make it seem like 10 seconds have passed between calls.
+ mock_time.side_effect = [0, 10]
+
+ with mock.patch('os.open') as mock_os_open:
+ # Raising EEXIST should cause it to try to retry locking.
+ mock_os_open.side_effect = [OSError(errno.EEXIST, '')]
+ instance.open_and_lock(1, 1)
+ self.assertFalse(instance.is_locked())
+ self.assertTrue(mock_logger.warn.called)
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ @mock.patch('oauth2client.contrib.locked_file.logger')
+ @mock.patch('time.time')
+ def test_lock_timeout_error_no_fh(self, mock_time, mock_logger, mock_open):
+ instance, _ = self._make_one()
+ # Make it seem like 10 seconds have passed between calls.
+ mock_time.side_effect = [0, 10]
+ # This will cause the retry loop to enter without a file handle.
+ fh_mock = mock.Mock()
+ mock_open.side_effect = [IOError(errno.ENOENT, ''), fh_mock]
+
+ with mock.patch('os.open') as mock_os_open:
+ # Raising EEXIST should cause it to try to retry locking.
+ mock_os_open.side_effect = [OSError(errno.EEXIST, '')]
+ instance.open_and_lock(1, 1)
+ self.assertFalse(instance.is_locked())
+ self.assertTrue(mock_logger.warn.called)
+ self.assertEqual(instance.file_handle(), fh_mock)
+
+ @mock.patch('oauth2client.contrib.locked_file.open', create=True)
+ @mock.patch('time.time')
+ @mock.patch('time.sleep')
+ def test_lock_retry_success(self, mock_sleep, mock_time, mock_open):
+ instance, _ = self._make_one()
+ # Make it seem like 1 second has passed between calls. Extra values
+ # are needed by the logging module.
+ mock_time.side_effect = [0, 1]
+
+ with mock.patch('os.open') as mock_os_open:
+ # Raising EEXIST should cause it to try to retry locking.
+ mock_os_open.side_effect = [
+ OSError(errno.EEXIST, ''), mock.Mock()]
+ instance.open_and_lock(10, 1)
+ print(mock_os_open.call_args_list)
+ self.assertTrue(instance.is_locked())
+ mock_sleep.assert_called_with(1)
+
+ @mock.patch('oauth2client.contrib.locked_file.os')
+ def test_unlock(self, os_mock):
+ instance, _ = self._make_one()
+ instance._locked = True
+ lock_fd_mock = instance._lock_fd = mock.Mock()
+ instance._fh = mock.Mock()
+
+ instance.unlock_and_close()
+
+ self.assertFalse(instance.is_locked())
+ os_mock.close.assert_called_once_with(lock_fd_mock)
+ self.assertTrue(os_mock.unlink.called)
+ self.assertTrue(instance._fh.close.called)
+
+
+class TestLockedFile(unittest2.TestCase):
+
+ @mock.patch('oauth2client.contrib.locked_file._PosixOpener')
+ def _make_one(self, opener_ctor_mock):
+ opener_mock = mock.Mock()
+ opener_ctor_mock.return_value = opener_mock
+ return locked_file.LockedFile(
+ 'a_file', 'r+', 'r', use_native_locking=False), opener_mock
+
+ @mock.patch('oauth2client.contrib.locked_file._PosixOpener')
+ def test_ctor_minimal(self, opener_mock):
+ locked_file.LockedFile(
+ 'a_file', 'r+', 'r', use_native_locking=False)
+ opener_mock.assert_called_with('a_file', 'r+', 'r')
+
+ @mock.patch.dict('sys.modules', {
+ 'oauth2client.contrib._win32_opener': mock.Mock()})
+ def test_ctor_native_win32(self):
+ _win32_opener_mock = sys.modules['oauth2client.contrib._win32_opener']
+ locked_file.LockedFile(
+ 'a_file', 'r+', 'r', use_native_locking=True)
+ _win32_opener_mock._Win32Opener.assert_called_with('a_file', 'r+', 'r')
+
+ @mock.patch.dict('sys.modules', {
+ 'oauth2client.contrib._win32_opener': None,
+ 'oauth2client.contrib._fcntl_opener': mock.Mock()})
+ def test_ctor_native_fcntl(self):
+ _fnctl_opener_mock = sys.modules['oauth2client.contrib._fcntl_opener']
+ locked_file.LockedFile(
+ 'a_file', 'r+', 'r', use_native_locking=True)
+ _fnctl_opener_mock._FcntlOpener.assert_called_with('a_file', 'r+', 'r')
+
+ @mock.patch('oauth2client.contrib.locked_file._PosixOpener')
+ @mock.patch.dict('sys.modules', {
+ 'oauth2client.contrib._win32_opener': None,
+ 'oauth2client.contrib._fcntl_opener': None})
+ def test_ctor_native_posix_fallback(self, opener_mock):
+ locked_file.LockedFile(
+ 'a_file', 'r+', 'r', use_native_locking=True)
+ opener_mock.assert_called_with('a_file', 'r+', 'r')
+
+ def test_filename(self):
+ instance, opener = self._make_one()
+ opener._filename = 'some file'
+ self.assertEqual(instance.filename(), 'some file')
+
+ def test_file_handle(self):
+ instance, opener = self._make_one()
+ self.assertEqual(instance.file_handle(), opener.file_handle())
+ self.assertTrue(opener.file_handle.called)
+
+ def test_is_locked(self):
+ instance, opener = self._make_one()
+ self.assertEqual(instance.is_locked(), opener.is_locked())
+ self.assertTrue(opener.is_locked.called)
+
+ def test_open_and_lock(self):
+ instance, opener = self._make_one()
+ instance.open_and_lock()
+ opener.open_and_lock.assert_called_with(0, 0.05)
+
+ def test_unlock_and_close(self):
+ instance, opener = self._make_one()
+ instance.unlock_and_close()
+ opener.unlock_and_close.assert_called_with()
diff --git a/tests/contrib/test_metadata.py b/tests/contrib/test_metadata.py
new file mode 100644
index 0000000..7f11d04
--- /dev/null
+++ b/tests/contrib/test_metadata.py
@@ -0,0 +1,97 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import datetime
+import json
+
+import httplib2
+import mock
+from six.moves import http_client
+import unittest2
+
+from oauth2client.contrib import _metadata
+
+PATH = 'instance/service-accounts/default'
+DATA = {'foo': 'bar'}
+EXPECTED_URL = (
+ 'http://metadata.google.internal/computeMetadata/v1/instance'
+ '/service-accounts/default')
+EXPECTED_KWARGS = dict(headers=_metadata.METADATA_HEADERS)
+
+
+def request_mock(status, content_type, content):
+ return mock.MagicMock(return_value=(
+ httplib2.Response(
+ {'status': status, 'content-type': content_type}
+ ),
+ content.encode('utf-8')
+ ))
+
+
+class TestMetadata(unittest2.TestCase):
+
+ def test_get_success_json(self):
+ http_request = request_mock(
+ http_client.OK, 'application/json', json.dumps(DATA))
+ self.assertEqual(
+ _metadata.get(http_request, PATH),
+ DATA
+ )
+ http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS)
+
+ def test_get_success_string(self):
+ http_request = request_mock(
+ http_client.OK, 'text/html', '<p>Hello World!</p>')
+ self.assertEqual(
+ _metadata.get(http_request, PATH),
+ '<p>Hello World!</p>'
+ )
+ http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS)
+
+ def test_get_failure(self):
+ http_request = request_mock(
+ http_client.NOT_FOUND, 'text/html', '<p>Error</p>')
+ with self.assertRaises(httplib2.HttpLib2Error):
+ _metadata.get(http_request, PATH)
+
+ http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS)
+
+ @mock.patch(
+ 'oauth2client.client._UTCNOW',
+ return_value=datetime.datetime.min)
+ def test_get_token_success(self, now):
+ http_request = request_mock(
+ http_client.OK,
+ 'application/json',
+ json.dumps({'access_token': 'a', 'expires_in': 100})
+ )
+ token, expiry = _metadata.get_token(http_request=http_request)
+ self.assertEqual(token, 'a')
+ self.assertEqual(
+ expiry, datetime.datetime.min + datetime.timedelta(seconds=100))
+ http_request.assert_called_once_with(
+ EXPECTED_URL + '/token',
+ **EXPECTED_KWARGS
+ )
+ now.assert_called_once_with()
+
+ def test_service_account_info(self):
+ http_request = request_mock(
+ http_client.OK, 'application/json', json.dumps(DATA))
+ info = _metadata.get_service_account_info(http_request)
+ self.assertEqual(info, DATA)
+ http_request.assert_called_once_with(
+ EXPECTED_URL + '/?recursive=True',
+ **EXPECTED_KWARGS
+ )
diff --git a/tests/contrib/test_multiprocess_file_storage.py b/tests/contrib/test_multiprocess_file_storage.py
new file mode 100644
index 0000000..bf30c14
--- /dev/null
+++ b/tests/contrib/test_multiprocess_file_storage.py
@@ -0,0 +1,313 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client.multistore_file."""
+
+import contextlib
+import datetime
+import json
+import multiprocessing
+import os
+import tempfile
+
+import fasteners
+import mock
+from six import StringIO
+import unittest2
+
+from oauth2client import client
+from oauth2client.contrib import multiprocess_file_storage
+
+from ..http_mock import HttpMockSequence
+
+
+@contextlib.contextmanager
+def scoped_child_process(target, **kwargs):
+ die_event = multiprocessing.Event()
+ ready_event = multiprocessing.Event()
+ process = multiprocessing.Process(
+ target=target, args=(die_event, ready_event), kwargs=kwargs)
+ process.start()
+ try:
+ ready_event.wait()
+ yield
+ finally:
+ die_event.set()
+ process.join(5)
+
+
+def _create_test_credentials(expiration=None):
+ access_token = 'foo'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = expiration or (
+ datetime.datetime.utcnow() + datetime.timedelta(seconds=3600))
+ token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
+ user_agent = 'refresh_checker/1.0'
+
+ credentials = client.OAuth2Credentials(
+ access_token, 'test-client-id', client_secret,
+ refresh_token, token_expiry, token_uri,
+ user_agent)
+ return credentials
+
+
+def _generate_token_response_http(new_token='new_token'):
+ token_response = json.dumps({
+ 'access_token': new_token,
+ 'expires_in': '3600',
+ })
+ http = HttpMockSequence([
+ ({'status': '200'}, token_response),
+ ])
+
+ return http
+
+
+class MultiprocessStorageBehaviorTests(unittest2.TestCase):
+
+ def setUp(self):
+ filehandle, self.filename = tempfile.mkstemp(
+ 'oauth2client_test.data')
+ os.close(filehandle)
+
+ def tearDown(self):
+ try:
+ os.unlink(self.filename)
+ os.unlink('{0}.lock'.format(self.filename))
+ except OSError: # pragma: NO COVER
+ pass
+
+ def test_basic_operations(self):
+ credentials = _create_test_credentials()
+
+ store = multiprocess_file_storage.MultiprocessFileStorage(
+ self.filename, 'basic')
+
+ # Save credentials
+ store.put(credentials)
+ credentials = store.get()
+
+ self.assertIsNotNone(credentials)
+ self.assertEqual('foo', credentials.access_token)
+
+ # Reset internal cache, ensure credentials were saved.
+ store._backend._credentials = {}
+ credentials = store.get()
+
+ self.assertIsNotNone(credentials)
+ self.assertEqual('foo', credentials.access_token)
+
+ # Delete credentials
+ store.delete()
+ credentials = store.get()
+
+ self.assertIsNone(credentials)
+
+ def test_single_process_refresh(self):
+ store = multiprocess_file_storage.MultiprocessFileStorage(
+ self.filename, 'single-process')
+ credentials = _create_test_credentials()
+ credentials.set_store(store)
+
+ http = _generate_token_response_http()
+ credentials.refresh(http)
+ self.assertEqual(credentials.access_token, 'new_token')
+
+ retrieved = store.get()
+ self.assertEqual(retrieved.access_token, 'new_token')
+
+ def test_multi_process_refresh(self):
+ # This will test that two processes attempting to refresh credentials
+ # will only refresh once.
+ store = multiprocess_file_storage.MultiprocessFileStorage(
+ self.filename, 'multi-process')
+ credentials = _create_test_credentials()
+ credentials.set_store(store)
+ store.put(credentials)
+
+ def child_process_func(
+ die_event, ready_event, check_event): # pragma: NO COVER
+ store = multiprocess_file_storage.MultiprocessFileStorage(
+ self.filename, 'multi-process')
+
+ credentials = store.get()
+ self.assertIsNotNone(credentials)
+
+ # Make sure this thread gets to refresh first.
+ original_acquire_lock = store.acquire_lock
+
+ def replacement_acquire_lock(*args, **kwargs):
+ result = original_acquire_lock(*args, **kwargs)
+ ready_event.set()
+ check_event.wait()
+ return result
+
+ credentials.store.acquire_lock = replacement_acquire_lock
+
+ http = _generate_token_response_http('b')
+ credentials.refresh(http)
+
+ self.assertEqual(credentials.access_token, 'b')
+
+ check_event = multiprocessing.Event()
+ with scoped_child_process(child_process_func, check_event=check_event):
+ # The lock should be currently held by the child process.
+ self.assertFalse(
+ store._backend._process_lock.acquire(blocking=False))
+ check_event.set()
+
+ # The child process will refresh first, so we should end up
+ # with 'b' as the token.
+ http = mock.Mock()
+ credentials.refresh(http=http)
+ self.assertEqual(credentials.access_token, 'b')
+ self.assertFalse(http.request.called)
+
+ retrieved = store.get()
+ self.assertEqual(retrieved.access_token, 'b')
+
+ def test_read_only_file_fail_lock(self):
+ credentials = _create_test_credentials()
+
+ # Grab the lock in another process, preventing this process from
+ # acquiring the lock.
+ def child_process(die_event, ready_event): # pragma: NO COVER
+ lock = fasteners.InterProcessLock(
+ '{0}.lock'.format(self.filename))
+ with lock:
+ ready_event.set()
+ die_event.wait()
+
+ with scoped_child_process(child_process):
+ store = multiprocess_file_storage.MultiprocessFileStorage(
+ self.filename, 'fail-lock')
+ store.put(credentials)
+ self.assertTrue(store._backend._read_only)
+
+ # These credentials should still be in the store's memory-only cache.
+ self.assertIsNotNone(store.get())
+
+
+class MultiprocessStorageUnitTests(unittest2.TestCase):
+
+ def setUp(self):
+ filehandle, self.filename = tempfile.mkstemp(
+ 'oauth2client_test.data')
+ os.close(filehandle)
+
+ def tearDown(self):
+ try:
+ os.unlink(self.filename)
+ os.unlink('{0}.lock'.format(self.filename))
+ except OSError: # pragma: NO COVER
+ pass
+
+ def test__create_file_if_needed(self):
+ self.assertFalse(
+ multiprocess_file_storage._create_file_if_needed(self.filename))
+ os.unlink(self.filename)
+ self.assertTrue(
+ multiprocess_file_storage._create_file_if_needed(self.filename))
+ self.assertTrue(
+ os.path.exists(self.filename))
+
+ def test__get_backend(self):
+ backend_one = multiprocess_file_storage._get_backend('file_a')
+ backend_two = multiprocess_file_storage._get_backend('file_a')
+ backend_three = multiprocess_file_storage._get_backend('file_b')
+
+ self.assertIs(backend_one, backend_two)
+ self.assertIsNot(backend_one, backend_three)
+
+ def test__read_write_credentials_file(self):
+ credentials = _create_test_credentials()
+ contents = StringIO()
+
+ multiprocess_file_storage._write_credentials_file(
+ contents, {'key': credentials})
+
+ contents.seek(0)
+ data = json.load(contents)
+ self.assertEqual(data['file_version'], 2)
+ self.assertTrue(data['credentials']['key'])
+
+ # Read it back.
+ contents.seek(0)
+ results = multiprocess_file_storage._load_credentials_file(contents)
+ self.assertEqual(
+ results['key'].access_token, credentials.access_token)
+
+ # Add an invalid credential and try reading it back. It should ignore
+ # the invalid one but still load the valid one.
+ data['credentials']['invalid'] = '123'
+ results = multiprocess_file_storage._load_credentials_file(
+ StringIO(json.dumps(data)))
+ self.assertNotIn('invalid', results)
+ self.assertEqual(
+ results['key'].access_token, credentials.access_token)
+
+ def test__load_credentials_file_invalid_json(self):
+ contents = StringIO('{[')
+ self.assertEqual(
+ multiprocess_file_storage._load_credentials_file(contents), {})
+
+ def test__load_credentials_file_no_file_version(self):
+ contents = StringIO('{}')
+ self.assertEqual(
+ multiprocess_file_storage._load_credentials_file(contents), {})
+
+ def test__load_credentials_file_bad_file_version(self):
+ contents = StringIO(json.dumps({'file_version': 1}))
+ self.assertEqual(
+ multiprocess_file_storage._load_credentials_file(contents), {})
+
+ def test__load_credentials_no_open_file(self):
+ backend = multiprocess_file_storage._get_backend(self.filename)
+ backend._credentials = mock.Mock()
+ backend._credentials.update.side_effect = AssertionError()
+ backend._load_credentials()
+
+ def test_acquire_lock_nonexistent_file(self):
+ backend = multiprocess_file_storage._get_backend(self.filename)
+ os.unlink(self.filename)
+ backend._process_lock = mock.Mock()
+ backend._process_lock.acquire.return_value = False
+ backend.acquire_lock()
+ self.assertIsNone(backend._file)
+
+ def test_release_lock_with_no_file(self):
+ backend = multiprocess_file_storage._get_backend(self.filename)
+ backend._file = None
+ backend._read_only = True
+ backend._thread_lock.acquire()
+ backend.release_lock()
+
+ def test__refresh_predicate(self):
+ backend = multiprocess_file_storage._get_backend(self.filename)
+
+ credentials = _create_test_credentials()
+ self.assertFalse(backend._refresh_predicate(credentials))
+
+ credentials.invalid = True
+ self.assertTrue(backend._refresh_predicate(credentials))
+
+ credentials = _create_test_credentials(
+ expiration=(
+ datetime.datetime.utcnow() - datetime.timedelta(seconds=3600)))
+ self.assertTrue(backend._refresh_predicate(credentials))
+
+
+if __name__ == '__main__': # pragma: NO COVER
+ unittest2.main()
diff --git a/tests/contrib/test_multistore_file.py b/tests/contrib/test_multistore_file.py
new file mode 100644
index 0000000..b5cb598
--- /dev/null
+++ b/tests/contrib/test_multistore_file.py
@@ -0,0 +1,383 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client.multistore_file."""
+
+import datetime
+import errno
+import os
+import stat
+import tempfile
+
+import mock
+import unittest2
+
+from oauth2client import client
+from oauth2client import util
+from oauth2client.contrib import locked_file
+from oauth2client.contrib import multistore_file
+
+_filehandle, FILENAME = tempfile.mkstemp('oauth2client_test.data')
+os.close(_filehandle)
+
+
+class _MockLockedFile(object):
+
+ def __init__(self, filename_str, error_class, error_code):
+ self.filename_str = filename_str
+ self.error_class = error_class
+ self.error_code = error_code
+ self.open_and_lock_called = False
+
+ def open_and_lock(self):
+ self.open_and_lock_called = True
+ raise self.error_class(self.error_code, '')
+
+ def is_locked(self):
+ return False
+
+ def filename(self):
+ return self.filename_str
+
+
+class Test__dict_to_tuple_key(unittest2.TestCase):
+
+ def test_key_conversions(self):
+ key1, val1 = 'somekey', 'some value'
+ key2, val2 = 'another', 'something else'
+ key3, val3 = 'onemore', 'foo'
+ test_dict = {
+ key1: val1,
+ key2: val2,
+ key3: val3,
+ }
+ tuple_key = multistore_file._dict_to_tuple_key(test_dict)
+
+ # the resulting key should be naturally sorted
+ expected_output = (
+ (key2, val2),
+ (key3, val3),
+ (key1, val1),
+ )
+ self.assertTupleEqual(expected_output, tuple_key)
+ # check we get the original dictionary back
+ self.assertDictEqual(test_dict, dict(tuple_key))
+
+
+class MultistoreFileTests(unittest2.TestCase):
+
+ def tearDown(self):
+ try:
+ os.unlink(FILENAME)
+ except OSError:
+ pass
+
+ def setUp(self):
+ try:
+ os.unlink(FILENAME)
+ except OSError:
+ pass
+
+ def _create_test_credentials(self, client_id='some_client_id',
+ expiration=None):
+ access_token = 'foo'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = expiration or datetime.datetime.utcnow()
+ token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
+ user_agent = 'refresh_checker/1.0'
+
+ credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, token_uri,
+ user_agent)
+ return credentials
+
+ def test_lock_file_raises_ioerror(self):
+ filehandle, filename = tempfile.mkstemp()
+ os.close(filehandle)
+
+ try:
+ for error_code in (errno.EDEADLK, errno.ENOSYS, errno.ENOLCK,
+ errno.EACCES):
+ for error_class in (IOError, OSError):
+ multistore = multistore_file._MultiStore(filename)
+ multistore._file = _MockLockedFile(
+ filename, error_class, error_code)
+ # Should not raise though the underlying file class did.
+ multistore._lock()
+ self.assertTrue(multistore._file.open_and_lock_called)
+ finally:
+ os.unlink(filename)
+
+ def test_lock_file_raise_unexpected_error(self):
+ filehandle, filename = tempfile.mkstemp()
+ os.close(filehandle)
+
+ try:
+ multistore = multistore_file._MultiStore(filename)
+ multistore._file = _MockLockedFile(filename, IOError, errno.EBUSY)
+ with self.assertRaises(IOError):
+ multistore._lock()
+ self.assertTrue(multistore._file.open_and_lock_called)
+ finally:
+ os.unlink(filename)
+
+ def test_read_only_file_fail_lock(self):
+ credentials = self._create_test_credentials()
+
+ open(FILENAME, 'a+b').close()
+ os.chmod(FILENAME, 0o400)
+
+ store = multistore_file.get_credential_storage(
+ FILENAME,
+ credentials.client_id,
+ credentials.user_agent,
+ ['some-scope', 'some-other-scope'])
+
+ store.put(credentials)
+ if os.name == 'posix': # pragma: NO COVER
+ self.assertTrue(store._multistore._read_only)
+ os.chmod(FILENAME, 0o600)
+
+ def test_read_only_file_fail_lock_no_warning(self):
+ open(FILENAME, 'a+b').close()
+ os.chmod(FILENAME, 0o400)
+
+ multistore = multistore_file._MultiStore(FILENAME)
+
+ with mock.patch.object(multistore_file.logger, 'warn') as mock_warn:
+ multistore._warn_on_readonly = False
+ multistore._lock()
+ self.assertFalse(mock_warn.called)
+
+ def test_lock_skip_refresh(self):
+ with open(FILENAME, 'w') as f:
+ f.write('123')
+ os.chmod(FILENAME, 0o400)
+
+ multistore = multistore_file._MultiStore(FILENAME)
+
+ refresh_patch = mock.patch.object(
+ multistore, '_refresh_data_cache')
+
+ with refresh_patch as refresh_mock:
+ multistore._data = {}
+ multistore._lock()
+ self.assertFalse(refresh_mock.called)
+
+ @unittest2.skipIf(not hasattr(os, 'symlink'), 'No symlink available')
+ def test_multistore_no_symbolic_link_files(self):
+ SYMFILENAME = FILENAME + 'sym'
+ os.symlink(FILENAME, SYMFILENAME)
+ store = multistore_file.get_credential_storage(
+ SYMFILENAME,
+ 'some_client_id',
+ 'user-agent/1.0',
+ ['some-scope', 'some-other-scope'])
+ try:
+ with self.assertRaises(
+ locked_file.CredentialsFileSymbolicLinkError):
+ store.get()
+ finally:
+ os.unlink(SYMFILENAME)
+
+ def test_multistore_non_existent_file(self):
+ store = multistore_file.get_credential_storage(
+ FILENAME,
+ 'some_client_id',
+ 'user-agent/1.0',
+ ['some-scope', 'some-other-scope'])
+
+ credentials = store.get()
+ self.assertEquals(None, credentials)
+
+ def test_multistore_file(self):
+ credentials = self._create_test_credentials()
+
+ store = multistore_file.get_credential_storage(
+ FILENAME,
+ credentials.client_id,
+ credentials.user_agent,
+ ['some-scope', 'some-other-scope'])
+
+ # Save credentials
+ store.put(credentials)
+ credentials = store.get()
+
+ self.assertNotEquals(None, credentials)
+ self.assertEquals('foo', credentials.access_token)
+
+ # Delete credentials
+ store.delete()
+ credentials = store.get()
+
+ self.assertEquals(None, credentials)
+
+ if os.name == 'posix': # pragma: NO COVER
+ self.assertEquals(
+ 0o600, stat.S_IMODE(os.stat(FILENAME).st_mode))
+
+ def test_multistore_file_custom_key(self):
+ credentials = self._create_test_credentials()
+
+ custom_key = {'myapp': 'testing', 'clientid': 'some client'}
+ store = multistore_file.get_credential_storage_custom_key(
+ FILENAME, custom_key)
+
+ store.put(credentials)
+ stored_credentials = store.get()
+
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token,
+ stored_credentials.access_token)
+
+ store.delete()
+ stored_credentials = store.get()
+
+ self.assertEquals(None, stored_credentials)
+
+ def test_multistore_file_custom_string_key(self):
+ credentials = self._create_test_credentials()
+
+ # store with string key
+ store = multistore_file.get_credential_storage_custom_string_key(
+ FILENAME, 'mykey')
+
+ store.put(credentials)
+ stored_credentials = store.get()
+
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token,
+ stored_credentials.access_token)
+
+ # try retrieving with a dictionary
+ multistore_file.get_credential_storage_custom_string_key(
+ FILENAME, {'key': 'mykey'})
+ stored_credentials = store.get()
+ self.assertNotEquals(None, stored_credentials)
+ self.assertEqual(credentials.access_token,
+ stored_credentials.access_token)
+
+ store.delete()
+ stored_credentials = store.get()
+
+ self.assertEquals(None, stored_credentials)
+
+ def test_multistore_file_backwards_compatibility(self):
+ credentials = self._create_test_credentials()
+ scopes = ['scope1', 'scope2']
+
+ # store the credentials using the legacy key method
+ store = multistore_file.get_credential_storage(
+ FILENAME, 'client_id', 'user_agent', scopes)
+ store.put(credentials)
+
+ # retrieve the credentials using a custom key that matches the
+ # legacy key
+ key = {'clientId': 'client_id', 'userAgent': 'user_agent',
+ 'scope': util.scopes_to_string(scopes)}
+ store = multistore_file.get_credential_storage_custom_key(
+ FILENAME, key)
+ stored_credentials = store.get()
+
+ self.assertEqual(credentials.access_token,
+ stored_credentials.access_token)
+
+ def test_multistore_file_get_all_keys(self):
+ # start with no keys
+ keys = multistore_file.get_all_credential_keys(FILENAME)
+ self.assertEquals([], keys)
+
+ # store credentials
+ credentials = self._create_test_credentials(client_id='client1')
+ custom_key = {'myapp': 'testing', 'clientid': 'client1'}
+ store1 = multistore_file.get_credential_storage_custom_key(
+ FILENAME, custom_key)
+ store1.put(credentials)
+
+ keys = multistore_file.get_all_credential_keys(FILENAME)
+ self.assertEquals([custom_key], keys)
+
+ # store more credentials
+ credentials = self._create_test_credentials(client_id='client2')
+ string_key = 'string_key'
+ store2 = multistore_file.get_credential_storage_custom_string_key(
+ FILENAME, string_key)
+ store2.put(credentials)
+
+ keys = multistore_file.get_all_credential_keys(FILENAME)
+ self.assertEquals(2, len(keys))
+ self.assertTrue(custom_key in keys)
+ self.assertTrue({'key': string_key} in keys)
+
+ # back to no keys
+ store1.delete()
+ store2.delete()
+ keys = multistore_file.get_all_credential_keys(FILENAME)
+ self.assertEquals([], keys)
+
+ def _refresh_data_cache_helper(self):
+ multistore = multistore_file._MultiStore(FILENAME)
+ json_patch = mock.patch.object(multistore, '_locked_json_read')
+
+ return multistore, json_patch
+
+ def test__refresh_data_cache_bad_json(self):
+ multistore, json_patch = self._refresh_data_cache_helper()
+
+ with json_patch as json_mock:
+ json_mock.side_effect = ValueError('')
+ multistore._refresh_data_cache()
+ self.assertTrue(json_mock.called)
+ self.assertEqual(multistore._data, {})
+
+ def test__refresh_data_cache_bad_version(self):
+ multistore, json_patch = self._refresh_data_cache_helper()
+
+ with json_patch as json_mock:
+ json_mock.return_value = {}
+ multistore._refresh_data_cache()
+ self.assertTrue(json_mock.called)
+ self.assertEqual(multistore._data, {})
+
+ def test__refresh_data_cache_newer_version(self):
+ multistore, json_patch = self._refresh_data_cache_helper()
+
+ with json_patch as json_mock:
+ json_mock.return_value = {'file_version': 5}
+ with self.assertRaises(multistore_file.NewerCredentialStoreError):
+ multistore._refresh_data_cache()
+ self.assertTrue(json_mock.called)
+
+ def test__refresh_data_cache_bad_credentials(self):
+ multistore, json_patch = self._refresh_data_cache_helper()
+
+ with json_patch as json_mock:
+ json_mock.return_value = {
+ 'file_version': 1,
+ 'data': [
+ {'lol': 'this is a bad credential object.'}
+ ]}
+ multistore._refresh_data_cache()
+ self.assertTrue(json_mock.called)
+ self.assertEqual(multistore._data, {})
+
+ def test__delete_credential_nonexistent(self):
+ multistore = multistore_file._MultiStore(FILENAME)
+
+ with mock.patch.object(multistore, '_write') as write_mock:
+ multistore._data = {}
+ multistore._delete_credential('nonexistent_key')
+ self.assertTrue(write_mock.called)
diff --git a/tests/contrib/test_sqlalchemy.py b/tests/contrib/test_sqlalchemy.py
new file mode 100644
index 0000000..421f516
--- /dev/null
+++ b/tests/contrib/test_sqlalchemy.py
@@ -0,0 +1,119 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import datetime
+
+import sqlalchemy
+import sqlalchemy.ext.declarative
+import sqlalchemy.orm
+import unittest2
+
+import oauth2client
+import oauth2client.client
+import oauth2client.contrib.sqlalchemy
+
+Base = sqlalchemy.ext.declarative.declarative_base()
+
+
+class DummyModel(Base):
+ __tablename__ = 'dummy'
+
+ id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True)
+ # we will query against this, because of ROWID
+ key = sqlalchemy.Column(sqlalchemy.Integer)
+ credentials = sqlalchemy.Column(
+ oauth2client.contrib.sqlalchemy.CredentialsType)
+
+
+class TestSQLAlchemyStorage(unittest2.TestCase):
+ def setUp(self):
+ engine = sqlalchemy.create_engine('sqlite://')
+ Base.metadata.create_all(engine)
+
+ self.session = sqlalchemy.orm.sessionmaker(bind=engine)
+ self.credentials = oauth2client.client.OAuth2Credentials(
+ access_token='token',
+ client_id='client_id',
+ client_secret='client_secret',
+ refresh_token='refresh_token',
+ token_expiry=datetime.datetime.utcnow(),
+ token_uri=oauth2client.GOOGLE_TOKEN_URI,
+ user_agent='DummyAgent',
+ )
+
+ def tearDown(self):
+ session = self.session()
+ session.query(DummyModel).filter_by(key=1).delete()
+ session.commit()
+
+ def compare_credentials(self, result):
+ self.assertEqual(result.access_token, self.credentials.access_token)
+ self.assertEqual(result.client_id, self.credentials.client_id)
+ self.assertEqual(result.client_secret, self.credentials.client_secret)
+ self.assertEqual(result.refresh_token, self.credentials.refresh_token)
+ self.assertEqual(result.token_expiry, self.credentials.token_expiry)
+ self.assertEqual(result.token_uri, self.credentials.token_uri)
+ self.assertEqual(result.user_agent, self.credentials.user_agent)
+
+ def test_get(self):
+ session = self.session()
+ credentials_storage = oauth2client.contrib.sqlalchemy.Storage(
+ session=session,
+ model_class=DummyModel,
+ key_name='key',
+ key_value=1,
+ property_name='credentials',
+ )
+ self.assertIsNone(credentials_storage.get())
+ session.add(DummyModel(
+ key=1,
+ credentials=self.credentials,
+ ))
+ session.commit()
+
+ self.compare_credentials(credentials_storage.get())
+
+ def test_put(self):
+ session = self.session()
+ oauth2client.contrib.sqlalchemy.Storage(
+ session=session,
+ model_class=DummyModel,
+ key_name='key',
+ key_value=1,
+ property_name='credentials',
+ ).put(self.credentials)
+ session.commit()
+
+ entity = session.query(DummyModel).filter_by(key=1).first()
+ self.compare_credentials(entity.credentials)
+
+ def test_delete(self):
+ session = self.session()
+ session.add(DummyModel(
+ key=1,
+ credentials=self.credentials,
+ ))
+ session.commit()
+
+ query = session.query(DummyModel).filter_by(key=1)
+ self.assertIsNotNone(query.first())
+ oauth2client.contrib.sqlalchemy.Storage(
+ session=session,
+ model_class=DummyModel,
+ key_name='key',
+ key_value=1,
+ property_name='credentials',
+ ).delete()
+ session.commit()
+ self.assertIsNone(query.first())
diff --git a/tests/contrib/test_xsrfutil.py b/tests/contrib/test_xsrfutil.py
new file mode 100644
index 0000000..64b842f
--- /dev/null
+++ b/tests/contrib/test_xsrfutil.py
@@ -0,0 +1,293 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Tests for oauth2client.contrib.xsrfutil."""
+
+import base64
+
+import mock
+import unittest2
+
+from oauth2client import _helpers
+from oauth2client.contrib import xsrfutil
+
+# Jan 17 2008, 5:40PM
+TEST_KEY = b'test key'
+# Jan. 17, 2008 22:40:32.081230 UTC
+TEST_TIME = 1200609642081230
+TEST_USER_ID_1 = 123832983
+TEST_USER_ID_2 = 938297432
+TEST_ACTION_ID_1 = b'some_action'
+TEST_ACTION_ID_2 = b'some_other_action'
+TEST_EXTRA_INFO_1 = b'extra_info_1'
+TEST_EXTRA_INFO_2 = b'more_extra_info'
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+class Test_generate_token(unittest2.TestCase):
+
+ def test_bad_positional(self):
+ # Need 2 positional arguments.
+ with self.assertRaises(TypeError):
+ xsrfutil.generate_token(None)
+ # At most 2 positional arguments.
+ with self.assertRaises(TypeError):
+ xsrfutil.generate_token(None, None, None)
+
+ def test_it(self):
+ digest = b'foobar'
+ digester = mock.MagicMock()
+ digester.digest = mock.MagicMock(name='digest', return_value=digest)
+ with mock.patch('oauth2client.contrib.xsrfutil.hmac') as hmac:
+ hmac.new = mock.MagicMock(name='new', return_value=digester)
+ token = xsrfutil.generate_token(TEST_KEY,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ when=TEST_TIME)
+ hmac.new.assert_called_once_with(TEST_KEY)
+ digester.digest.assert_called_once_with()
+
+ expected_digest_calls = [
+ mock.call.update(_helpers._to_bytes(str(TEST_USER_ID_1))),
+ mock.call.update(xsrfutil.DELIMITER),
+ mock.call.update(TEST_ACTION_ID_1),
+ mock.call.update(xsrfutil.DELIMITER),
+ mock.call.update(_helpers._to_bytes(str(TEST_TIME))),
+ ]
+ self.assertEqual(digester.method_calls, expected_digest_calls)
+
+ expected_token_as_bytes = (digest + xsrfutil.DELIMITER +
+ _helpers._to_bytes(str(TEST_TIME)))
+ expected_token = base64.urlsafe_b64encode(
+ expected_token_as_bytes)
+ self.assertEqual(token, expected_token)
+
+ def test_with_system_time(self):
+ digest = b'foobar'
+ curr_time = 1440449755.74
+ digester = mock.MagicMock()
+ digester.digest = mock.MagicMock(name='digest', return_value=digest)
+ with mock.patch('oauth2client.contrib.xsrfutil.hmac') as hmac:
+ hmac.new = mock.MagicMock(name='new', return_value=digester)
+
+ with mock.patch('oauth2client.contrib.xsrfutil.time') as time:
+ time.time = mock.MagicMock(name='time', return_value=curr_time)
+ # when= is omitted
+ token = xsrfutil.generate_token(TEST_KEY,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1)
+
+ hmac.new.assert_called_once_with(TEST_KEY)
+ time.time.assert_called_once_with()
+ digester.digest.assert_called_once_with()
+
+ expected_digest_calls = [
+ mock.call.update(_helpers._to_bytes(str(TEST_USER_ID_1))),
+ mock.call.update(xsrfutil.DELIMITER),
+ mock.call.update(TEST_ACTION_ID_1),
+ mock.call.update(xsrfutil.DELIMITER),
+ mock.call.update(_helpers._to_bytes(str(int(curr_time)))),
+ ]
+ self.assertEqual(digester.method_calls, expected_digest_calls)
+
+ expected_token_as_bytes = (
+ digest + xsrfutil.DELIMITER +
+ _helpers._to_bytes(str(int(curr_time))))
+ expected_token = base64.urlsafe_b64encode(
+ expected_token_as_bytes)
+ self.assertEqual(token, expected_token)
+
+
+class Test_validate_token(unittest2.TestCase):
+
+ def test_bad_positional(self):
+ # Need 3 positional arguments.
+ with self.assertRaises(TypeError):
+ xsrfutil.validate_token(None, None)
+ # At most 3 positional arguments.
+ with self.assertRaises(TypeError):
+ xsrfutil.validate_token(None, None, None, None)
+
+ def test_no_token(self):
+ key = token = user_id = None
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id))
+
+ def test_token_not_valid_base64(self):
+ key = user_id = None
+ token = b'a' # Bad padding
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id))
+
+ def test_token_non_integer(self):
+ key = user_id = None
+ token = base64.b64encode(b'abc' + xsrfutil.DELIMITER + b'xyz')
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id))
+
+ def test_token_too_old_implicit_current_time(self):
+ token_time = 123456789
+ curr_time = token_time + xsrfutil.DEFAULT_TIMEOUT_SECS + 1
+
+ key = user_id = None
+ token = base64.b64encode(_helpers._to_bytes(str(token_time)))
+ with mock.patch('oauth2client.contrib.xsrfutil.time') as time:
+ time.time = mock.MagicMock(name='time', return_value=curr_time)
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id))
+ time.time.assert_called_once_with()
+
+ def test_token_too_old_explicit_current_time(self):
+ token_time = 123456789
+ curr_time = token_time + xsrfutil.DEFAULT_TIMEOUT_SECS + 1
+
+ key = user_id = None
+ token = base64.b64encode(_helpers._to_bytes(str(token_time)))
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id,
+ current_time=curr_time))
+
+ def test_token_length_differs_from_generated(self):
+ token_time = 123456789
+ # Make sure it isn't too old.
+ curr_time = token_time + xsrfutil.DEFAULT_TIMEOUT_SECS - 1
+
+ key = object()
+ user_id = object()
+ action_id = object()
+ token = base64.b64encode(_helpers._to_bytes(str(token_time)))
+ generated_token = b'a'
+ # Make sure the token length comparison will fail.
+ self.assertNotEqual(len(token), len(generated_token))
+
+ with mock.patch('oauth2client.contrib.xsrfutil.generate_token',
+ return_value=generated_token) as gen_tok:
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id,
+ current_time=curr_time,
+ action_id=action_id))
+ gen_tok.assert_called_once_with(key, user_id, action_id=action_id,
+ when=token_time)
+
+ def test_token_differs_from_generated_but_same_length(self):
+ token_time = 123456789
+ # Make sure it isn't too old.
+ curr_time = token_time + xsrfutil.DEFAULT_TIMEOUT_SECS - 1
+
+ key = object()
+ user_id = object()
+ action_id = object()
+ token = base64.b64encode(_helpers._to_bytes(str(token_time)))
+ # It is encoded as b'MTIzNDU2Nzg5', which has length 12.
+ generated_token = b'M' * 12
+ # Make sure the token length comparison will succeed, but the token
+ # comparison will fail.
+ self.assertEqual(len(token), len(generated_token))
+ self.assertNotEqual(token, generated_token)
+
+ with mock.patch('oauth2client.contrib.xsrfutil.generate_token',
+ return_value=generated_token) as gen_tok:
+ self.assertFalse(xsrfutil.validate_token(key, token, user_id,
+ current_time=curr_time,
+ action_id=action_id))
+ gen_tok.assert_called_once_with(key, user_id, action_id=action_id,
+ when=token_time)
+
+ def test_success(self):
+ token_time = 123456789
+ # Make sure it isn't too old.
+ curr_time = token_time + xsrfutil.DEFAULT_TIMEOUT_SECS - 1
+
+ key = object()
+ user_id = object()
+ action_id = object()
+ token = base64.b64encode(_helpers._to_bytes(str(token_time)))
+ with mock.patch('oauth2client.contrib.xsrfutil.generate_token',
+ return_value=token) as gen_tok:
+ self.assertTrue(xsrfutil.validate_token(key, token, user_id,
+ current_time=curr_time,
+ action_id=action_id))
+ gen_tok.assert_called_once_with(key, user_id, action_id=action_id,
+ when=token_time)
+
+
+class XsrfUtilTests(unittest2.TestCase):
+ """Test xsrfutil functions."""
+
+ def testGenerateAndValidateToken(self):
+ """Test generating and validating a token."""
+ token = xsrfutil.generate_token(TEST_KEY,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ when=TEST_TIME)
+
+ # Check that the token is considered valid when it should be.
+ self.assertTrue(xsrfutil.validate_token(TEST_KEY,
+ token,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=TEST_TIME))
+
+ # Should still be valid 15 minutes later.
+ later15mins = TEST_TIME + 15 * 60
+ self.assertTrue(xsrfutil.validate_token(TEST_KEY,
+ token,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later15mins))
+
+ # But not if beyond the timeout.
+ later2hours = TEST_TIME + 2 * 60 * 60
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ token,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later2hours))
+
+ # Or if the key is different.
+ self.assertFalse(xsrfutil.validate_token('another key',
+ token,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later15mins))
+
+ # Or the user ID....
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ token,
+ TEST_USER_ID_2,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later15mins))
+
+ # Or the action ID...
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ token,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_2,
+ current_time=later15mins))
+
+ # Invalid when truncated
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ token[:-1],
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later15mins))
+
+ # Invalid with extra garbage
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ token + b'x',
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1,
+ current_time=later15mins))
+
+ # Invalid with token of None
+ self.assertFalse(xsrfutil.validate_token(TEST_KEY,
+ None,
+ TEST_USER_ID_1,
+ action_id=TEST_ACTION_ID_1))
diff --git a/tests/data/app.yaml b/tests/data/app.yaml
new file mode 100644
index 0000000..9268dfa
--- /dev/null
+++ b/tests/data/app.yaml
@@ -0,0 +1,10 @@
+# Dummy app.yaml to placate nosegae.
+application: oauth2client
+version: 1
+runtime: python27
+api_version: 1
+threadsafe: yes
+
+handlers:
+- url: /.*
+ script: null.app
diff --git a/tests/data/certs.json b/tests/data/certs.json
new file mode 100644
index 0000000..fa09416
--- /dev/null
+++ b/tests/data/certs.json
@@ -0,0 +1,3 @@
+{
+"foo": "-----BEGIN CERTIFICATE-----\r\nMIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV\r\nBAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV\r\nMRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB\r\nCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM\r\n7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer\r\nuQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp\r\ngyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4\r\n+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3\r\nZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O\r\ngN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh\r\nGaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD\r\nAQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr\r\nodJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk\r\n+JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9\r\novNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql\r\nybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT\r\ncDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB\r\n-----END CERTIFICATE-----\r\n"
+}
diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json
new file mode 100644
index 0000000..5356103
--- /dev/null
+++ b/tests/data/client_secrets.json
@@ -0,0 +1,10 @@
+{
+ "web": {
+ "client_id": "foo_client_id",
+ "client_secret": "foo_client_secret",
+ "redirect_uris": [],
+ "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth",
+ "token_uri": "https://www.googleapis.com/oauth2/v4/token",
+ "revoke_uri": "https://accounts.google.com/o/oauth2/revoke"
+ }
+}
diff --git a/tests/data/gcloud/application_default_credentials.json b/tests/data/gcloud/application_default_credentials.json
new file mode 100644
index 0000000..f011013
--- /dev/null
+++ b/tests/data/gcloud/application_default_credentials.json
@@ -0,0 +1,7 @@
+{
+ "private_key_id": "ABCDEF",
+ "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: <No Attributes>\n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n",
+ "client_email": "dummy@google.com",
+ "client_id": "123",
+ "type": "service_account"
+}
diff --git a/tests/data/gcloud/application_default_credentials_authorized_user.json b/tests/data/gcloud/application_default_credentials_authorized_user.json
new file mode 100644
index 0000000..4787ace
--- /dev/null
+++ b/tests/data/gcloud/application_default_credentials_authorized_user.json
@@ -0,0 +1,6 @@
+{
+ "client_id": "123",
+ "client_secret": "secret",
+ "refresh_token": "alabalaportocala",
+ "type": "authorized_user"
+}
diff --git a/tests/data/gcloud/application_default_credentials_malformed_1.json b/tests/data/gcloud/application_default_credentials_malformed_1.json
new file mode 100644
index 0000000..fe9fd1e
--- /dev/null
+++ b/tests/data/gcloud/application_default_credentials_malformed_1.json
@@ -0,0 +1,9 @@
+{
+ "type": "serviceaccount",
+ "client_id": "123",
+ "client_secret": "secret",
+ "refresh_token": "alabalaportocala",
+ "client_email": "dummy@google.com",
+ "private_key_id": "ABCDEF",
+ "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: <No Attributes>\n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n"
+}
\ No newline at end of file
diff --git a/tests/data/gcloud/application_default_credentials_malformed_2.json b/tests/data/gcloud/application_default_credentials_malformed_2.json
new file mode 100644
index 0000000..6f1ae52
--- /dev/null
+++ b/tests/data/gcloud/application_default_credentials_malformed_2.json
@@ -0,0 +1,8 @@
+{
+ "type": "service_account",
+ "client_id": "123",
+ "client_secret": "secret",
+ "refresh_token": "alabalaportocala",
+ "client_email": "dummy@google.com",
+ "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: <No Attributes>\n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n"
+}
\ No newline at end of file
diff --git a/tests/data/gcloud/application_default_credentials_malformed_3.json b/tests/data/gcloud/application_default_credentials_malformed_3.json
new file mode 100644
index 0000000..efed137
--- /dev/null
+++ b/tests/data/gcloud/application_default_credentials_malformed_3.json
@@ -0,0 +1,9 @@
+{
+ "type": "service_account"
+ "client_id": "123",
+ "client_secret": "secret",
+ "refresh_token": "alabalaportocala",
+ "client_email": "dummy@google.com",
+ "private_key_id": "ABCDEF",
+ "private_key": "Bag Attributes\n friendlyName: key\n localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC\nKey Attributes: <No Attributes>\n-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi\ntUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p\noJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR\naIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt\nw21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE\nGKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp\n+qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN\nTzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4\nQoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG\nDy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo\nf1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR\n+DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p\nIwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a\nc3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7\nSgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0\njGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY\niuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5\nsdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO\nGCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk\nBrn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk\nt7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2\nDwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS\nLZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB\nWGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa\nXUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB\nVL5h7N0VstYhGgycuPpcIUQa\n-----END PRIVATE KEY-----\n"
+}
\ No newline at end of file
diff --git a/tests/data/key.json.enc b/tests/data/key.json.enc
new file mode 100644
index 0000000..1cf0705
--- /dev/null
+++ b/tests/data/key.json.enc
Binary files differ
diff --git a/tests/data/key.p12.enc b/tests/data/key.p12.enc
new file mode 100644
index 0000000..5e2f6ec
--- /dev/null
+++ b/tests/data/key.p12.enc
Binary files differ
diff --git a/tests/data/pem_from_pkcs12.pem b/tests/data/pem_from_pkcs12.pem
new file mode 100644
index 0000000..2d77e10
--- /dev/null
+++ b/tests/data/pem_from_pkcs12.pem
@@ -0,0 +1,32 @@
+Bag Attributes
+ friendlyName: key
+ localKeyID: 22 7E 04 FC 64 48 20 83 1E C1 BD E3 F5 2F 44 7D EA 99 A5 BC
+Key Attributes: <No Attributes>
+-----BEGIN PRIVATE KEY-----
+MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDh6PSnttDsv+vi
+tUZTP1E3hVBah6PUGDWZhYgNiyW8quTWCmPvBmCR2YzuhUrY5+CtKP8UJOQico+p
+oJHSAPsrzSr6YsGs3c9SQOslBmm9Fkh9/f/GZVTVZ6u5AsUmOcVvZ2q7Sz8Vj/aR
+aIm0EJqRe9cQ5vvN9sg25rIv4xKwIZJ1VixKWJLmpCmDINqn7xvl+ldlUmSr3aGt
+w21uSDuEJhQlzO3yf2FwJMkJ9SkCm9oVDXyl77OnKXj5bOQ/rojbyGeIxDJSUDWE
+GKyRPuqKi6rSbwg6h2G/Z9qBJkqM5NNTbGRIFz/9/LdmmwvtaqCxlLtD7RVEryAp
++qTGDk5hAgMBAAECggEBAMYYfNDEYpf4A2SdCLne/9zrrfZ0kphdUkL48MDPj5vN
+TzTRj6f9s5ixZ/+QKn3hdwbguCx13QbH5mocP0IjUhyqoFFHYAWxyyaZfpjM8tO4
+QoEYxby3BpjLe62UXESUzChQSytJZFwIDXKcdIPNO3zvVzufEJcfG5no2b9cIvsG
+Dy6J1FNILWxCtDIqBM+G1B1is9DhZnUDgn0iKzINiZmh1I1l7k/4tMnozVIKAfwo
+f1kYjG/d2IzDM02mTeTElz3IKeNriaOIYTZgI26xLJxTkiFnBV4JOWFAZw15X+yR
++DrjGSIkTfhzbLa20Vt3AFM+LFK0ZoXT2dRnjbYPjQECgYEA+9XJFGwLcEX6pl1p
+IwXAjXKJdju9DDn4lmHTW0Pbw25h1EXONwm/NPafwsWmPll9kW9IwsxUQVUyBC9a
+c3Q7rF1e8ai/qqVFRIZof275MI82ciV2Mw8Hz7FPAUyoju5CvnjAEH4+irt1VE/7
+SgdvQ1gDBQFegS69ijdz+cOhFxkCgYEA5aVoseMy/gIlsCvNPyw9+Jz/zBpKItX0
+jGzdF7lhERRO2cursujKaoHntRckHcE3P/Z4K565bvVq+VaVG0T/BcBKPmPHrLmY
+iuVXidltW7Jh9/RCVwb5+BvqlwlC470PEwhqoUatY/fPJ74srztrqJHvp1L29FT5
+sdmlJW8YwokCgYAUa3dMgp5C0knKp5RY1KSSU5E11w4zKZgwiWob4lq1dAPWtHpO
+GCo63yyBHImoUJVP75gUw4Cpc4EEudo5tlkIVuHV8nroGVKOhd9/Rb5K47Hke4kk
+Brn5a0Ues9qPDF65Fw1ryPDFSwHufjXAAO5SpZZJF51UGDgiNvDedbBgMQKBgHSk
+t7DjPhtW69234eCckD2fQS5ijBV1p2lMQmCygGM0dXiawvN02puOsCqDPoz+fxm2
+DwPY80cw0M0k9UeMnBxHt25JMDrDan/iTbxu++T/jlNrdebOXFlxlI5y3c7fULDS
+LZcNVzTXwhjlt7yp6d0NgzTyJw2ju9BiREfnTiRBAoGBAOPHrTOnPyjO+bVcCPTB
+WGLsbBd77mVPGIuL0XGrvbVYPE8yIcNbZcthd8VXL/38Ygy8SIZh2ZqsrU1b5WFa
+XUMLnGEODSS8x/GmW3i3KeirW5OxBNjfUzEF4XkJP8m41iTdsQEXQf9DdUY7X+CB
+VL5h7N0VstYhGgycuPpcIUQa
+-----END PRIVATE KEY-----
diff --git a/tests/data/pem_from_pkcs12_alternate.pem b/tests/data/pem_from_pkcs12_alternate.pem
new file mode 100644
index 0000000..5744354
--- /dev/null
+++ b/tests/data/pem_from_pkcs12_alternate.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj
+7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/
+xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs
+SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18
+pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk
+SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk
+nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq
+HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y
+nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9
+IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2
+YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU
+Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ
+vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP
+B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl
+aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2
+eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI
+aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk
+klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ
+CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu
+UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg
+soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28
+bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH
+504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL
+YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx
+BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/data/privatekey.p12 b/tests/data/privatekey.p12
new file mode 100644
index 0000000..c369ecb
--- /dev/null
+++ b/tests/data/privatekey.p12
Binary files differ
diff --git a/tests/data/privatekey.pem b/tests/data/privatekey.pem
new file mode 100644
index 0000000..5744354
--- /dev/null
+++ b/tests/data/privatekey.pem
@@ -0,0 +1,27 @@
+-----BEGIN RSA PRIVATE KEY-----
+MIIEpAIBAAKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj
+7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/
+xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYs
+SliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18
+pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xk
+SBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABAoIBAQDGGHzQxGKX+ANk
+nQi53v/c6632dJKYXVJC+PDAz4+bzU800Y+n/bOYsWf/kCp94XcG4Lgsdd0Gx+Zq
+HD9CI1IcqqBRR2AFscsmmX6YzPLTuEKBGMW8twaYy3utlFxElMwoUEsrSWRcCA1y
+nHSDzTt871c7nxCXHxuZ6Nm/XCL7Bg8uidRTSC1sQrQyKgTPhtQdYrPQ4WZ1A4J9
+IisyDYmZodSNZe5P+LTJ6M1SCgH8KH9ZGIxv3diMwzNNpk3kxJc9yCnja4mjiGE2
+YCNusSycU5IhZwVeCTlhQGcNeV/skfg64xkiJE34c2y2ttFbdwBTPixStGaF09nU
+Z422D40BAoGBAPvVyRRsC3BF+qZdaSMFwI1yiXY7vQw5+JZh01tD28NuYdRFzjcJ
+vzT2n8LFpj5ZfZFvSMLMVEFVMgQvWnN0O6xdXvGov6qlRUSGaH9u+TCPNnIldjMP
+B8+xTwFMqI7uQr54wBB+Poq7dVRP+0oHb0NYAwUBXoEuvYo3c/nDoRcZAoGBAOWl
+aLHjMv4CJbArzT8sPfic/8waSiLV9Ixs3Re5YREUTtnLq7LoymqB57UXJB3BNz/2
+eCueuW71avlWlRtE/wXASj5jx6y5mIrlV4nZbVuyYff0QlcG+fgb6pcJQuO9DxMI
+aqFGrWP3zye+LK87a6iR76dS9vRU+bHZpSVvGMKJAoGAFGt3TIKeQtJJyqeUWNSk
+klORNdcOMymYMIlqG+JatXQD1rR6ThgqOt8sgRyJqFCVT++YFMOAqXOBBLnaObZZ
+CFbh1fJ66BlSjoXff0W+SuOx5HuJJAa5+WtFHrPajwxeuRcNa8jwxUsB7n41wADu
+UqWWSRedVBg4Ijbw3nWwYDECgYB0pLew4z4bVuvdt+HgnJA9n0EuYowVdadpTEJg
+soBjNHV4msLzdNqbjrAqgz6M/n8Ztg8D2PNHMNDNJPVHjJwcR7duSTA6w2p/4k28
+bvvk/45Ta3XmzlxZcZSOct3O31Cw0i2XDVc018IY5be8qendDYM08icNo7vQYkRH
+504kQQKBgQDjx60zpz8ozvm1XAj0wVhi7GwXe+5lTxiLi9Fxq721WDxPMiHDW2XL
+YXfFVy/9/GIMvEiGYdmarK1NW+VhWl1DC5xhDg0kvMfxplt4tynoq1uTsQTY31Mx
+BeF5CT/JuNYk3bEBF0H/Q3VGO1/ggVS+YezdFbLWIRoMnLj6XCFEGg==
+-----END RSA PRIVATE KEY-----
diff --git a/tests/data/privatekey.pub b/tests/data/privatekey.pub
new file mode 100644
index 0000000..11fdaa4
--- /dev/null
+++ b/tests/data/privatekey.pub
@@ -0,0 +1,8 @@
+-----BEGIN RSA PUBLIC KEY-----
+MIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZg
+kdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU
+1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS
+5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+z
+pyl4+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc/
+/fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQAB
+-----END RSA PUBLIC KEY-----
diff --git a/tests/data/public_cert.pem b/tests/data/public_cert.pem
new file mode 100644
index 0000000..7af6ca3
--- /dev/null
+++ b/tests/data/public_cert.pem
@@ -0,0 +1,19 @@
+-----BEGIN CERTIFICATE-----
+MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV
+BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV
+MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB
+CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM
+7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer
+uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp
+gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4
++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3
+ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O
+gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh
+GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD
+AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr
+odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk
++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9
+ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql
+ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT
+cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB
+-----END CERTIFICATE-----
diff --git a/tests/data/publickey_openssl.pem b/tests/data/publickey_openssl.pem
new file mode 100644
index 0000000..893ee79
--- /dev/null
+++ b/tests/data/publickey_openssl.pem
@@ -0,0 +1,9 @@
+-----BEGIN PUBLIC KEY-----
+MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA4ej0p7bQ7L/r4rVGUz9R
+N4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM7oVK2OfgrSj/FCTkInKPqaCR0gD7
+K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1WeruQLFJjnFb2dqu0s/FY/2kWiJtBCa
+kXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQpgyDap+8b5fpXZVJkq92hrcNtbkg7
+hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4+WzkP66I28hniMQyUlA1hBiskT7q
+iouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3ZpsL7WqgsZS7Q+0VRK8gKfqkxg5O
+YQIDAQAB
+-----END PUBLIC KEY-----
diff --git a/tests/data/unfilled_client_secrets.json b/tests/data/unfilled_client_secrets.json
new file mode 100644
index 0000000..a85ca01
--- /dev/null
+++ b/tests/data/unfilled_client_secrets.json
@@ -0,0 +1,9 @@
+{
+ "web": {
+ "client_id": "[[INSERT CLIENT ID HERE]]",
+ "client_secret": "[[INSERT CLIENT SECRET HERE]]",
+ "redirect_uris": [],
+ "auth_uri": "https://accounts.google.com/o/oauth2/v2/auth",
+ "token_uri": "https://www.googleapis.com/oauth2/v4/token"
+ }
+}
diff --git a/tests/data/user-key.json.enc b/tests/data/user-key.json.enc
new file mode 100644
index 0000000..03e1bc6
--- /dev/null
+++ b/tests/data/user-key.json.enc
Binary files differ
diff --git a/tests/http_mock.py b/tests/http_mock.py
new file mode 100644
index 0000000..6053299
--- /dev/null
+++ b/tests/http_mock.py
@@ -0,0 +1,112 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Copy of googleapiclient.http's mock functionality."""
+
+import httplib2
+
+# TODO(craigcitro): Find a cleaner way to share this code with googleapiclient.
+
+
+class HttpMock(object):
+ """Mock of httplib2.Http"""
+
+ def __init__(self, headers=None):
+ """HttpMock constructor.
+
+ Args:
+ headers: dict, header to return with response
+ """
+ if headers is None:
+ headers = {'status': '200'}
+ self.data = None
+ self.response_headers = headers
+ self.headers = None
+ self.uri = None
+ self.method = None
+ self.body = None
+ self.headers = None
+
+ def request(self, uri,
+ method='GET',
+ body=None,
+ headers=None,
+ redirections=1,
+ connection_type=None):
+ self.uri = uri
+ self.method = method
+ self.body = body
+ self.headers = headers
+ return httplib2.Response(self.response_headers), self.data
+
+
+class HttpMockSequence(object):
+ """Mock of httplib2.Http
+
+ Mocks a sequence of calls to request returning different responses for each
+ call. Create an instance initialized with the desired response headers
+ and content and then use as if an httplib2.Http instance::
+
+ http = HttpMockSequence([
+ ({'status': '401'}, b''),
+ ({'status': '200'}, b'{"access_token":"1/3w","expires_in":3600}'),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ resp, content = http.request("http://examples.com")
+
+ There are special values you can pass in for content to trigger
+ behavours that are helpful in testing.
+
+ * 'echo_request_headers' means return the request headers in the response
+ body
+ * 'echo_request_body' means return the request body in the response body
+ """
+
+ def __init__(self, iterable):
+ """HttpMockSequence constructor.
+
+ Args:
+ iterable: iterable, a sequence of pairs of (headers, body)
+ """
+ self._iterable = iterable
+ self.follow_redirects = True
+ self.requests = []
+
+ def request(self, uri,
+ method='GET',
+ body=None,
+ headers=None,
+ redirections=1,
+ connection_type=None):
+ resp, content = self._iterable.pop(0)
+ self.requests.append({'uri': uri, 'body': body, 'headers': headers})
+ # Read any underlying stream before sending the request.
+ body_stream_content = (body.read()
+ if getattr(body, 'read', None) else None)
+ if content == 'echo_request_headers':
+ content = headers
+ elif content == 'echo_request_body':
+ content = (body
+ if body_stream_content is None else body_stream_content)
+ return httplib2.Response(resp), content
+
+
+class CacheMock(object):
+
+ def __init__(self):
+ self.cache = {}
+
+ def get(self, key, namespace=''):
+ # ignoring namespace for easier testing
+ return self.cache.get(key, None)
diff --git a/tests/test__helpers.py b/tests/test__helpers.py
new file mode 100644
index 0000000..cd54186
--- /dev/null
+++ b/tests/test__helpers.py
@@ -0,0 +1,114 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+"""Unit tests for oauth2client._helpers."""
+
+import unittest2
+
+from oauth2client import _helpers
+
+
+class Test__parse_pem_key(unittest2.TestCase):
+
+ def test_valid_input(self):
+ test_string = b'1234-----BEGIN FOO BAR BAZ'
+ result = _helpers._parse_pem_key(test_string)
+ self.assertEqual(result, test_string[4:])
+
+ def test_bad_input(self):
+ test_string = b'DOES NOT HAVE DASHES'
+ result = _helpers._parse_pem_key(test_string)
+ self.assertEqual(result, None)
+
+
+class Test__json_encode(unittest2.TestCase):
+
+ def test_dictionary_input(self):
+ # Use only a single key since dictionary hash order
+ # is non-deterministic.
+ data = {u'foo': 10}
+ result = _helpers._json_encode(data)
+ self.assertEqual(result, '{"foo":10}')
+
+ def test_list_input(self):
+ data = [42, 1337]
+ result = _helpers._json_encode(data)
+ self.assertEqual(result, '[42,1337]')
+
+
+class Test__to_bytes(unittest2.TestCase):
+
+ def test_with_bytes(self):
+ value = b'bytes-val'
+ self.assertEqual(_helpers._to_bytes(value), value)
+
+ def test_with_unicode(self):
+ value = u'string-val'
+ encoded_value = b'string-val'
+ self.assertEqual(_helpers._to_bytes(value), encoded_value)
+
+ def test_with_nonstring_type(self):
+ value = object()
+ with self.assertRaises(ValueError):
+ _helpers._to_bytes(value)
+
+
+class Test__from_bytes(unittest2.TestCase):
+
+ def test_with_unicode(self):
+ value = u'bytes-val'
+ self.assertEqual(_helpers._from_bytes(value), value)
+
+ def test_with_bytes(self):
+ value = b'string-val'
+ decoded_value = u'string-val'
+ self.assertEqual(_helpers._from_bytes(value), decoded_value)
+
+ def test_with_nonstring_type(self):
+ value = object()
+ with self.assertRaises(ValueError):
+ _helpers._from_bytes(value)
+
+
+class Test__urlsafe_b64encode(unittest2.TestCase):
+
+ DEADBEEF_ENCODED = b'ZGVhZGJlZWY'
+
+ def test_valid_input_bytes(self):
+ test_string = b'deadbeef'
+ result = _helpers._urlsafe_b64encode(test_string)
+ self.assertEqual(result, self.DEADBEEF_ENCODED)
+
+ def test_valid_input_unicode(self):
+ test_string = u'deadbeef'
+ result = _helpers._urlsafe_b64encode(test_string)
+ self.assertEqual(result, self.DEADBEEF_ENCODED)
+
+
+class Test__urlsafe_b64decode(unittest2.TestCase):
+
+ def test_valid_input_bytes(self):
+ test_string = b'ZGVhZGJlZWY'
+ result = _helpers._urlsafe_b64decode(test_string)
+ self.assertEqual(result, b'deadbeef')
+
+ def test_valid_input_unicode(self):
+ test_string = b'ZGVhZGJlZWY'
+ result = _helpers._urlsafe_b64decode(test_string)
+ self.assertEqual(result, b'deadbeef')
+
+ def test_bad_input(self):
+ import binascii
+ bad_string = b'+'
+ with self.assertRaises((TypeError, binascii.Error)):
+ _helpers._urlsafe_b64decode(bad_string)
diff --git a/tests/test__pure_python_crypt.py b/tests/test__pure_python_crypt.py
new file mode 100644
index 0000000..3c2962a
--- /dev/null
+++ b/tests/test__pure_python_crypt.py
@@ -0,0 +1,183 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client._pure_python_crypt."""
+
+import os
+
+import mock
+from pyasn1_modules import pem
+import rsa
+import six
+import unittest2
+
+from oauth2client import _helpers
+from oauth2client import _pure_python_crypt
+from oauth2client import crypt
+
+
+class TestRsaVerifier(unittest2.TestCase):
+
+ PUBLIC_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'privatekey.pub')
+ PUBLIC_CERT_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'public_cert.pem')
+ PRIVATE_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'privatekey.pem')
+
+ def _load_public_key_bytes(self):
+ with open(self.PUBLIC_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def _load_public_cert_bytes(self):
+ with open(self.PUBLIC_CERT_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def _load_private_key_bytes(self):
+ with open(self.PRIVATE_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def test_verify_success(self):
+ to_sign = b'foo'
+ signer = crypt.RsaSigner.from_string(self._load_private_key_bytes())
+ actual_signature = signer.sign(to_sign)
+
+ verifier = crypt.RsaVerifier.from_string(
+ self._load_public_key_bytes(), is_x509_cert=False)
+ self.assertTrue(verifier.verify(to_sign, actual_signature))
+
+ def test_verify_unicode_success(self):
+ to_sign = u'foo'
+ signer = crypt.RsaSigner.from_string(self._load_private_key_bytes())
+ actual_signature = signer.sign(to_sign)
+
+ verifier = crypt.RsaVerifier.from_string(
+ self._load_public_key_bytes(), is_x509_cert=False)
+ self.assertTrue(verifier.verify(to_sign, actual_signature))
+
+ def test_verify_failure(self):
+ verifier = crypt.RsaVerifier.from_string(
+ self._load_public_key_bytes(), is_x509_cert=False)
+ bad_signature1 = b''
+ self.assertFalse(verifier.verify(b'foo', bad_signature1))
+ bad_signature2 = b'a'
+ self.assertFalse(verifier.verify(b'foo', bad_signature2))
+
+ def test_from_string_pub_key(self):
+ public_key = self._load_public_key_bytes()
+ verifier = crypt.RsaVerifier.from_string(
+ public_key, is_x509_cert=False)
+ self.assertIsInstance(verifier, crypt.RsaVerifier)
+ self.assertIsInstance(verifier._pubkey, rsa.key.PublicKey)
+
+ def test_from_string_pub_key_unicode(self):
+ public_key = _helpers._from_bytes(self._load_public_key_bytes())
+ verifier = crypt.RsaVerifier.from_string(
+ public_key, is_x509_cert=False)
+ self.assertIsInstance(verifier, crypt.RsaVerifier)
+ self.assertIsInstance(verifier._pubkey, rsa.key.PublicKey)
+
+ def test_from_string_pub_cert(self):
+ public_cert = self._load_public_cert_bytes()
+ verifier = crypt.RsaVerifier.from_string(
+ public_cert, is_x509_cert=True)
+ self.assertIsInstance(verifier, crypt.RsaVerifier)
+ self.assertIsInstance(verifier._pubkey, rsa.key.PublicKey)
+
+ def test_from_string_pub_cert_unicode(self):
+ public_cert = _helpers._from_bytes(self._load_public_cert_bytes())
+ verifier = crypt.RsaVerifier.from_string(
+ public_cert, is_x509_cert=True)
+ self.assertIsInstance(verifier, crypt.RsaVerifier)
+ self.assertIsInstance(verifier._pubkey, rsa.key.PublicKey)
+
+ def test_from_string_pub_cert_failure(self):
+ cert_bytes = self._load_public_cert_bytes()
+ true_der = rsa.pem.load_pem(cert_bytes, 'CERTIFICATE')
+ with mock.patch('rsa.pem.load_pem',
+ return_value=true_der + b'extra') as load_pem:
+ with self.assertRaises(ValueError):
+ crypt.RsaVerifier.from_string(cert_bytes, is_x509_cert=True)
+ load_pem.assert_called_once_with(cert_bytes, 'CERTIFICATE')
+
+
+class TestRsaSigner(unittest2.TestCase):
+
+ PKCS1_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'privatekey.pem')
+ PKCS8_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'pem_from_pkcs12.pem')
+ PKCS12_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'privatekey.p12')
+
+ def _load_pkcs1_key_bytes(self):
+ with open(self.PKCS1_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def _load_pkcs8_key_bytes(self):
+ with open(self.PKCS8_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def _load_pkcs12_key_bytes(self):
+ with open(self.PKCS12_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def test_from_string_pkcs1(self):
+ key_bytes = self._load_pkcs1_key_bytes()
+ signer = crypt.RsaSigner.from_string(key_bytes)
+ self.assertIsInstance(signer, crypt.RsaSigner)
+ self.assertIsInstance(signer._key, rsa.key.PrivateKey)
+
+ def test_from_string_pkcs1_unicode(self):
+ key_bytes = _helpers._from_bytes(self._load_pkcs1_key_bytes())
+ signer = crypt.RsaSigner.from_string(key_bytes)
+ self.assertIsInstance(signer, crypt.RsaSigner)
+ self.assertIsInstance(signer._key, rsa.key.PrivateKey)
+
+ def test_from_string_pkcs8(self):
+ key_bytes = self._load_pkcs8_key_bytes()
+ signer = crypt.RsaSigner.from_string(key_bytes)
+ self.assertIsInstance(signer, crypt.RsaSigner)
+ self.assertIsInstance(signer._key, rsa.key.PrivateKey)
+
+ def test_from_string_pkcs8_extra_bytes(self):
+ key_bytes = self._load_pkcs8_key_bytes()
+ _, pem_bytes = pem.readPemBlocksFromFile(
+ six.StringIO(_helpers._from_bytes(key_bytes)),
+ _pure_python_crypt._PKCS8_MARKER)
+
+ with mock.patch('pyasn1.codec.der.decoder.decode') as mock_decode:
+ key_info, remaining = None, 'extra'
+ mock_decode.return_value = (key_info, remaining)
+ with self.assertRaises(ValueError):
+ crypt.RsaSigner.from_string(key_bytes)
+ # Verify mock was called.
+ mock_decode.assert_called_once_with(
+ pem_bytes, asn1Spec=_pure_python_crypt._PKCS8_SPEC)
+
+ def test_from_string_pkcs8_unicode(self):
+ key_bytes = _helpers._from_bytes(self._load_pkcs8_key_bytes())
+ signer = crypt.RsaSigner.from_string(key_bytes)
+ self.assertIsInstance(signer, crypt.RsaSigner)
+ self.assertIsInstance(signer._key, rsa.key.PrivateKey)
+
+ def test_from_string_pkcs12(self):
+ key_bytes = self._load_pkcs12_key_bytes()
+ with self.assertRaises(ValueError):
+ crypt.RsaSigner.from_string(key_bytes)
+
+ def test_from_string_bogus_key(self):
+ key_bytes = 'bogus-key'
+ with self.assertRaises(ValueError):
+ crypt.RsaSigner.from_string(key_bytes)
diff --git a/tests/test__pycrypto_crypt.py b/tests/test__pycrypto_crypt.py
new file mode 100644
index 0000000..2f45291
--- /dev/null
+++ b/tests/test__pycrypto_crypt.py
@@ -0,0 +1,73 @@
+# Copyright 2015 Google Inc. All rights reserved.
+#
+# 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.
+"""Unit tests for oauth2client._pycrypto_crypt."""
+
+import os
+
+import unittest2
+
+from oauth2client import crypt
+
+
+class TestPyCryptoVerifier(unittest2.TestCase):
+
+ PUBLIC_CERT_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'public_cert.pem')
+ PRIVATE_KEY_FILENAME = os.path.join(os.path.dirname(__file__),
+ 'data', 'privatekey.pem')
+
+ def _load_public_cert_bytes(self):
+ with open(self.PUBLIC_CERT_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def _load_private_key_bytes(self):
+ with open(self.PRIVATE_KEY_FILENAME, 'rb') as fh:
+ return fh.read()
+
+ def test_verify_success(self):
+ to_sign = b'foo'
+ signer = crypt.PyCryptoSigner.from_string(
+ self._load_private_key_bytes())
+ actual_signature = signer.sign(to_sign)
+
+ verifier = crypt.PyCryptoVerifier.from_string(
+ self._load_public_cert_bytes(), is_x509_cert=True)
+ self.assertTrue(verifier.verify(to_sign, actual_signature))
+
+ def test_verify_failure(self):
+ verifier = crypt.PyCryptoVerifier.from_string(
+ self._load_public_cert_bytes(), is_x509_cert=True)
+ bad_signature = b''
+ self.assertFalse(verifier.verify(b'foo', bad_signature))
+
+ def test_verify_bad_key(self):
+ verifier = crypt.PyCryptoVerifier.from_string(
+ self._load_public_cert_bytes(), is_x509_cert=True)
+ bad_signature = b''
+ self.assertFalse(verifier.verify(b'foo', bad_signature))
+
+ def test_from_string_unicode_key(self):
+ public_key = self._load_public_cert_bytes()
+ public_key = public_key.decode('utf-8')
+ verifier = crypt.PyCryptoVerifier.from_string(
+ public_key, is_x509_cert=True)
+ self.assertIsInstance(verifier, crypt.PyCryptoVerifier)
+
+
+class TestPyCryptoSigner(unittest2.TestCase):
+
+ def test_from_string_bad_key(self):
+ key_bytes = 'definitely-not-pem-format'
+ with self.assertRaises(NotImplementedError):
+ crypt.PyCryptoSigner.from_string(key_bytes)
diff --git a/tests/test_client.py b/tests/test_client.py
new file mode 100644
index 0000000..db75603
--- /dev/null
+++ b/tests/test_client.py
@@ -0,0 +1,2358 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Oauth2client tests
+
+Unit tests for oauth2client.
+"""
+
+import base64
+import contextlib
+import copy
+import datetime
+import json
+import os
+import socket
+import sys
+import tempfile
+
+import httplib2
+import mock
+import six
+from six.moves import http_client
+from six.moves import urllib
+import unittest2
+
+import oauth2client
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client import clientsecrets
+from oauth2client import service_account
+from oauth2client import util
+from .http_mock import CacheMock
+from .http_mock import HttpMock
+from .http_mock import HttpMockSequence
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+
+
+# TODO(craigcitro): This is duplicated from
+# googleapiclient.test_discovery; consolidate these definitions.
+def assertUrisEqual(testcase, expected, actual):
+ """Test that URIs are the same, up to reordering of query parameters."""
+ expected = urllib.parse.urlparse(expected)
+ actual = urllib.parse.urlparse(actual)
+ testcase.assertEqual(expected.scheme, actual.scheme)
+ testcase.assertEqual(expected.netloc, actual.netloc)
+ testcase.assertEqual(expected.path, actual.path)
+ testcase.assertEqual(expected.params, actual.params)
+ testcase.assertEqual(expected.fragment, actual.fragment)
+ expected_query = urllib.parse.parse_qs(expected.query)
+ actual_query = urllib.parse.parse_qs(actual.query)
+ for name in expected_query.keys():
+ testcase.assertEqual(expected_query[name], actual_query[name])
+ for name in actual_query.keys():
+ testcase.assertEqual(expected_query[name], actual_query[name])
+
+
+def datafile(filename):
+ return os.path.join(DATA_DIR, filename)
+
+
+def load_and_cache(existing_file, fakename, cache_mock):
+ client_type, client_info = clientsecrets._loadfile(datafile(existing_file))
+ cache_mock.cache[fakename] = {client_type: client_info}
+
+
+class CredentialsTests(unittest2.TestCase):
+
+ def test_to_from_json(self):
+ credentials = client.Credentials()
+ json = credentials.to_json()
+ client.Credentials.new_from_json(json)
+
+ def test_authorize_abstract(self):
+ credentials = client.Credentials()
+ http = object()
+ with self.assertRaises(NotImplementedError):
+ credentials.authorize(http)
+
+ def test_refresh_abstract(self):
+ credentials = client.Credentials()
+ http = object()
+ with self.assertRaises(NotImplementedError):
+ credentials.refresh(http)
+
+ def test_revoke_abstract(self):
+ credentials = client.Credentials()
+ http = object()
+ with self.assertRaises(NotImplementedError):
+ credentials.revoke(http)
+
+ def test_apply_abstract(self):
+ credentials = client.Credentials()
+ headers = {}
+ with self.assertRaises(NotImplementedError):
+ credentials.apply(headers)
+
+ def test__to_json_basic(self):
+ credentials = client.Credentials()
+ json_payload = credentials._to_json([])
+ # str(bytes) in Python2 and str(unicode) in Python3
+ self.assertIsInstance(json_payload, str)
+ payload = json.loads(json_payload)
+ expected_payload = {
+ '_class': client.Credentials.__name__,
+ '_module': client.Credentials.__module__,
+ 'token_expiry': None,
+ }
+ self.assertEqual(payload, expected_payload)
+
+ def test__to_json_with_strip(self):
+ credentials = client.Credentials()
+ credentials.foo = 'bar'
+ credentials.baz = 'quux'
+ to_strip = ['foo']
+ json_payload = credentials._to_json(to_strip)
+ # str(bytes) in Python2 and str(unicode) in Python3
+ self.assertIsInstance(json_payload, str)
+ payload = json.loads(json_payload)
+ expected_payload = {
+ '_class': client.Credentials.__name__,
+ '_module': client.Credentials.__module__,
+ 'token_expiry': None,
+ 'baz': credentials.baz,
+ }
+ self.assertEqual(payload, expected_payload)
+
+ def test__to_json_to_serialize(self):
+ credentials = client.Credentials()
+ to_serialize = {
+ 'foo': b'bar',
+ 'baz': u'quux',
+ 'st': set(['a', 'b']),
+ }
+ orig_vals = to_serialize.copy()
+ json_payload = credentials._to_json([], to_serialize=to_serialize)
+ # str(bytes) in Python2 and str(unicode) in Python3
+ self.assertIsInstance(json_payload, str)
+ payload = json.loads(json_payload)
+ expected_payload = {
+ '_class': client.Credentials.__name__,
+ '_module': client.Credentials.__module__,
+ 'token_expiry': None,
+ }
+ expected_payload.update(to_serialize)
+ # Special-case the set.
+ expected_payload['st'] = list(expected_payload['st'])
+ # Special-case the bytes.
+ expected_payload['foo'] = u'bar'
+ self.assertEqual(payload, expected_payload)
+ # Make sure the method call didn't modify our dictionary.
+ self.assertEqual(to_serialize, orig_vals)
+
+ @mock.patch.object(client.Credentials, '_to_json',
+ return_value=object())
+ def test_to_json(self, to_json):
+ credentials = client.Credentials()
+ self.assertEqual(credentials.to_json(), to_json.return_value)
+ to_json.assert_called_once_with(
+ client.Credentials.NON_SERIALIZED_MEMBERS)
+
+ def test_new_from_json_no_data(self):
+ creds_data = {}
+ json_data = json.dumps(creds_data)
+ with self.assertRaises(KeyError):
+ client.Credentials.new_from_json(json_data)
+
+ def test_new_from_json_basic_data(self):
+ creds_data = {
+ '_module': 'oauth2client.client',
+ '_class': 'Credentials',
+ }
+ json_data = json.dumps(creds_data)
+ credentials = client.Credentials.new_from_json(json_data)
+ self.assertIsInstance(credentials, client.Credentials)
+
+ def test_new_from_json_old_name(self):
+ creds_data = {
+ '_module': 'oauth2client.googleapiclient.client',
+ '_class': 'Credentials',
+ }
+ json_data = json.dumps(creds_data)
+ credentials = client.Credentials.new_from_json(json_data)
+ self.assertIsInstance(credentials, client.Credentials)
+
+ def test_new_from_json_bad_module(self):
+ creds_data = {
+ '_module': 'oauth2client.foobar',
+ '_class': 'Credentials',
+ }
+ json_data = json.dumps(creds_data)
+ with self.assertRaises(ImportError):
+ client.Credentials.new_from_json(json_data)
+
+ def test_new_from_json_bad_class(self):
+ creds_data = {
+ '_module': 'oauth2client.client',
+ '_class': 'NopeNotCredentials',
+ }
+ json_data = json.dumps(creds_data)
+ with self.assertRaises(AttributeError):
+ client.Credentials.new_from_json(json_data)
+
+ def test_from_json(self):
+ unused_data = {}
+ credentials = client.Credentials.from_json(unused_data)
+ self.assertIsInstance(credentials, client.Credentials)
+ self.assertEqual(credentials.__dict__, {})
+
+
+class TestStorage(unittest2.TestCase):
+
+ def test_locked_get_abstract(self):
+ storage = client.Storage()
+ with self.assertRaises(NotImplementedError):
+ storage.locked_get()
+
+ def test_locked_put_abstract(self):
+ storage = client.Storage()
+ credentials = object()
+ with self.assertRaises(NotImplementedError):
+ storage.locked_put(credentials)
+
+ def test_locked_delete_abstract(self):
+ storage = client.Storage()
+ with self.assertRaises(NotImplementedError):
+ storage.locked_delete()
+
+
+@contextlib.contextmanager
+def mock_module_import(module):
+ """Place a dummy objects in sys.modules to mock an import test."""
+ parts = module.split('.')
+ entries = ['.'.join(parts[:i + 1]) for i in range(len(parts))]
+ for entry in entries:
+ sys.modules[entry] = object()
+
+ try:
+ yield
+
+ finally:
+ for entry in entries:
+ del sys.modules[entry]
+
+
+class GoogleCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.os_name = os.name
+ client.SETTINGS.env_name = None
+
+ def tearDown(self):
+ self.reset_env('SERVER_SOFTWARE')
+ self.reset_env(client.GOOGLE_APPLICATION_CREDENTIALS)
+ self.reset_env('APPDATA')
+ os.name = self.os_name
+
+ def reset_env(self, env):
+ """Set the environment variable 'env' to 'value'."""
+ os.environ.pop(env, None)
+
+ def validate_service_account_credentials(self, credentials):
+ self.assertIsInstance(
+ credentials, service_account.ServiceAccountCredentials)
+ self.assertEqual('123', credentials.client_id)
+ self.assertEqual('dummy@google.com',
+ credentials._service_account_email)
+ self.assertEqual('ABCDEF', credentials._private_key_id)
+ self.assertEqual('', credentials._scopes)
+
+ def validate_google_credentials(self, credentials):
+ self.assertIsInstance(credentials, client.GoogleCredentials)
+ self.assertEqual(None, credentials.access_token)
+ self.assertEqual('123', credentials.client_id)
+ self.assertEqual('secret', credentials.client_secret)
+ self.assertEqual('alabalaportocala', credentials.refresh_token)
+ self.assertEqual(None, credentials.token_expiry)
+ self.assertEqual(oauth2client.GOOGLE_TOKEN_URI, credentials.token_uri)
+ self.assertEqual('Python client library', credentials.user_agent)
+
+ def get_a_google_credentials_object(self):
+ return client.GoogleCredentials(None, None, None, None,
+ None, None, None, None)
+
+ def test_create_scoped_required(self):
+ self.assertFalse(
+ self.get_a_google_credentials_object().create_scoped_required())
+
+ def test_create_scoped(self):
+ credentials = self.get_a_google_credentials_object()
+ self.assertEqual(credentials, credentials.create_scoped(None))
+ self.assertEqual(credentials,
+ credentials.create_scoped(['dummy_scope']))
+
+ @mock.patch.object(client.GoogleCredentials,
+ '_implicit_credentials_from_files',
+ return_value=None)
+ @mock.patch.object(client.GoogleCredentials,
+ '_implicit_credentials_from_gce')
+ @mock.patch.object(client, '_in_gae_environment',
+ return_value=True)
+ @mock.patch.object(client, '_get_application_default_credential_GAE',
+ return_value=object())
+ def test_get_application_default_in_gae(self, gae_adc, in_gae,
+ from_gce, from_files):
+ credentials = client.GoogleCredentials.get_application_default()
+ self.assertEqual(credentials, gae_adc.return_value)
+ from_files.assert_called_once_with()
+ in_gae.assert_called_once_with()
+ from_gce.assert_not_called()
+
+ @mock.patch.object(client.GoogleCredentials,
+ '_implicit_credentials_from_gae',
+ return_value=None)
+ @mock.patch.object(client.GoogleCredentials,
+ '_implicit_credentials_from_files',
+ return_value=None)
+ @mock.patch.object(client, '_in_gce_environment',
+ return_value=True)
+ @mock.patch.object(client, '_get_application_default_credential_GCE',
+ return_value=object())
+ def test_get_application_default_in_gce(self, gce_adc, in_gce,
+ from_files, from_gae):
+ credentials = client.GoogleCredentials.get_application_default()
+ self.assertEqual(credentials, gce_adc.return_value)
+ in_gce.assert_called_once_with()
+ from_gae.assert_called_once_with()
+ from_files.assert_called_once_with()
+
+ def test_environment_check_gae_production(self):
+ with mock_module_import('google.appengine'):
+ self._environment_check_gce_helper(
+ server_software='Google App Engine/XYZ')
+
+ def test_environment_check_gae_local(self):
+ with mock_module_import('google.appengine'):
+ self._environment_check_gce_helper(
+ server_software='Development/XYZ')
+
+ def test_environment_check_fastpath(self):
+ with mock_module_import('google.appengine'):
+ self._environment_check_gce_helper(
+ server_software='Development/XYZ')
+
+ def test_environment_caching(self):
+ os.environ['SERVER_SOFTWARE'] = 'Development/XYZ'
+ with mock_module_import('google.appengine'):
+ self.assertTrue(client._in_gae_environment())
+ os.environ['SERVER_SOFTWARE'] = ''
+ # Even though we no longer pass the environment check, it
+ # is cached.
+ self.assertTrue(client._in_gae_environment())
+
+ def _environment_check_gce_helper(self, status_ok=True, socket_error=False,
+ server_software=''):
+ response = mock.MagicMock()
+ if status_ok:
+ response.status = http_client.OK
+ response.getheader = mock.MagicMock(
+ name='getheader',
+ return_value=client._DESIRED_METADATA_FLAVOR)
+ else:
+ response.status = http_client.NOT_FOUND
+
+ connection = mock.MagicMock()
+ connection.getresponse = mock.MagicMock(name='getresponse',
+ return_value=response)
+ if socket_error:
+ connection.getresponse.side_effect = socket.error()
+
+ with mock.patch('oauth2client.client.os') as os_module:
+ os_module.environ = {client._SERVER_SOFTWARE: server_software}
+ with mock.patch('oauth2client.client.six') as six_module:
+ http_client_module = six_module.moves.http_client
+ http_client_module.HTTPConnection = mock.MagicMock(
+ name='HTTPConnection', return_value=connection)
+
+ if server_software == '':
+ self.assertFalse(client._in_gae_environment())
+ else:
+ self.assertTrue(client._in_gae_environment())
+
+ if status_ok and not socket_error and server_software == '':
+ self.assertTrue(client._in_gce_environment())
+ else:
+ self.assertFalse(client._in_gce_environment())
+
+ if server_software == '':
+ http_client_module.HTTPConnection.assert_called_once_with(
+ client._GCE_METADATA_HOST,
+ timeout=client.GCE_METADATA_TIMEOUT)
+ connection.getresponse.assert_called_once_with()
+ # Remaining calls are not "getresponse"
+ headers = {
+ client._METADATA_FLAVOR_HEADER: (
+ client._DESIRED_METADATA_FLAVOR),
+ }
+ self.assertEqual(connection.method_calls, [
+ mock.call.request('GET', '/',
+ headers=headers),
+ mock.call.close(),
+ ])
+ self.assertEqual(response.method_calls, [])
+ if status_ok and not socket_error:
+ response.getheader.assert_called_once_with(
+ client._METADATA_FLAVOR_HEADER)
+ else:
+ self.assertEqual(
+ http_client_module.HTTPConnection.mock_calls, [])
+ self.assertEqual(connection.getresponse.mock_calls, [])
+ # Remaining calls are not "getresponse"
+ self.assertEqual(connection.method_calls, [])
+ self.assertEqual(response.method_calls, [])
+ self.assertEqual(response.getheader.mock_calls, [])
+
+ def test_environment_check_gce_production(self):
+ self._environment_check_gce_helper(status_ok=True)
+
+ def test_environment_check_gce_prod_with_working_gae_imports(self):
+ with mock_module_import('google.appengine'):
+ self._environment_check_gce_helper(status_ok=True)
+
+ def test_environment_check_gce_timeout(self):
+ self._environment_check_gce_helper(socket_error=True)
+
+ def test_environ_check_gae_module_unknown(self):
+ with mock_module_import('google.appengine'):
+ self._environment_check_gce_helper(status_ok=False)
+
+ def test_environment_check_unknown(self):
+ self._environment_check_gce_helper(status_ok=False)
+
+ def test_get_environment_variable_file(self):
+ environment_variable_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ os.environ[client.GOOGLE_APPLICATION_CREDENTIALS] = (
+ environment_variable_file)
+ self.assertEqual(environment_variable_file,
+ client._get_environment_variable_file())
+
+ def test_get_environment_variable_file_error(self):
+ nonexistent_file = datafile('nonexistent')
+ os.environ[client.GOOGLE_APPLICATION_CREDENTIALS] = nonexistent_file
+ expected_err_msg = (
+ 'File {0} \(pointed by {1} environment variable\) does not '
+ 'exist!'.format(
+ nonexistent_file, client.GOOGLE_APPLICATION_CREDENTIALS))
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ client._get_environment_variable_file()
+
+ @mock.patch.dict(os.environ, {}, clear=True)
+ def test_get_environment_variable_file_without_env_var(self):
+ self.assertIsNone(client._get_environment_variable_file())
+
+ @mock.patch('os.name', new='nt')
+ @mock.patch.dict(os.environ, {'APPDATA': DATA_DIR}, clear=True)
+ def test_get_well_known_file_on_windows(self):
+ well_known_file = datafile(
+ os.path.join(client._CLOUDSDK_CONFIG_DIRECTORY,
+ client._WELL_KNOWN_CREDENTIALS_FILE))
+ self.assertEqual(well_known_file, client._get_well_known_file())
+
+ @mock.patch('os.name', new='nt')
+ @mock.patch.dict(os.environ, {'SystemDrive': 'G:'}, clear=True)
+ def test_get_well_known_file_on_windows_without_appdata(self):
+ well_known_file = os.path.join('G:', '\\',
+ client._CLOUDSDK_CONFIG_DIRECTORY,
+ client._WELL_KNOWN_CREDENTIALS_FILE)
+ self.assertEqual(well_known_file, client._get_well_known_file())
+
+ @mock.patch.dict(os.environ,
+ {client._CLOUDSDK_CONFIG_ENV_VAR: 'CUSTOM_DIR'},
+ clear=True)
+ def test_get_well_known_file_with_custom_config_dir(self):
+ CUSTOM_DIR = os.environ[client._CLOUDSDK_CONFIG_ENV_VAR]
+ EXPECTED_FILE = os.path.join(CUSTOM_DIR,
+ client._WELL_KNOWN_CREDENTIALS_FILE)
+ well_known_file = client._get_well_known_file()
+ self.assertEqual(well_known_file, EXPECTED_FILE)
+
+ def test_get_adc_from_file_service_account(self):
+ credentials_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ credentials = client._get_application_default_credential_from_file(
+ credentials_file)
+ self.validate_service_account_credentials(credentials)
+
+ def test_save_to_well_known_file_service_account(self):
+ credential_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ credentials = client._get_application_default_credential_from_file(
+ credential_file)
+ temp_credential_file = datafile(
+ os.path.join('gcloud',
+ 'temp_well_known_file_service_account.json'))
+ client.save_to_well_known_file(credentials, temp_credential_file)
+ with open(temp_credential_file) as f:
+ d = json.load(f)
+ self.assertEqual('service_account', d['type'])
+ self.assertEqual('123', d['client_id'])
+ self.assertEqual('dummy@google.com', d['client_email'])
+ self.assertEqual('ABCDEF', d['private_key_id'])
+ os.remove(temp_credential_file)
+
+ @mock.patch('os.path.isdir', return_value=False)
+ def test_save_well_known_file_with_non_existent_config_dir(self,
+ isdir_mock):
+ credential_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ credentials = client._get_application_default_credential_from_file(
+ credential_file)
+ with self.assertRaises(OSError):
+ client.save_to_well_known_file(credentials)
+ config_dir = os.path.join(os.path.expanduser('~'), '.config', 'gcloud')
+ isdir_mock.assert_called_once_with(config_dir)
+
+ def test_get_adc_from_file_authorized_user(self):
+ credentials_file = datafile(os.path.join(
+ 'gcloud',
+ 'application_default_credentials_authorized_user.json'))
+ credentials = client._get_application_default_credential_from_file(
+ credentials_file)
+ self.validate_google_credentials(credentials)
+
+ def test_save_to_well_known_file_authorized_user(self):
+ credentials_file = datafile(os.path.join(
+ 'gcloud',
+ 'application_default_credentials_authorized_user.json'))
+ credentials = client._get_application_default_credential_from_file(
+ credentials_file)
+ temp_credential_file = datafile(
+ os.path.join('gcloud',
+ 'temp_well_known_file_authorized_user.json'))
+ client.save_to_well_known_file(credentials, temp_credential_file)
+ with open(temp_credential_file) as f:
+ d = json.load(f)
+ self.assertEqual('authorized_user', d['type'])
+ self.assertEqual('123', d['client_id'])
+ self.assertEqual('secret', d['client_secret'])
+ self.assertEqual('alabalaportocala', d['refresh_token'])
+ os.remove(temp_credential_file)
+
+ def test_get_application_default_credential_from_malformed_file_1(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_1.json'))
+ expected_err_msg = (
+ "'type' field should be defined \(and have one of the '{0}' or "
+ "'{1}' values\)".format(client.AUTHORIZED_USER,
+ client.SERVICE_ACCOUNT))
+
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ client._get_application_default_credential_from_file(
+ credentials_file)
+
+ def test_get_application_default_credential_from_malformed_file_2(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_2.json'))
+ expected_err_msg = (
+ 'The following field\(s\) must be defined: private_key_id')
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ client._get_application_default_credential_from_file(
+ credentials_file)
+
+ def test_get_application_default_credential_from_malformed_file_3(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_3.json'))
+ with self.assertRaises(ValueError):
+ client._get_application_default_credential_from_file(
+ credentials_file)
+
+ def test_raise_exception_for_missing_fields(self):
+ missing_fields = ['first', 'second', 'third']
+ expected_err_msg = ('The following field\(s\) must be defined: ' +
+ ', '.join(missing_fields))
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ client._raise_exception_for_missing_fields(missing_fields)
+
+ def test_raise_exception_for_reading_json(self):
+ credential_file = 'any_file'
+ extra_help = ' be good'
+ error = client.ApplicationDefaultCredentialsError('stuff happens')
+ expected_err_msg = ('An error was encountered while reading '
+ 'json file: ' + credential_file +
+ extra_help + ': ' + str(error))
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ client._raise_exception_for_reading_json(
+ credential_file, extra_help, error)
+
+ @mock.patch('oauth2client.client._in_gce_environment')
+ @mock.patch('oauth2client.client._in_gae_environment', return_value=False)
+ @mock.patch('oauth2client.client._get_environment_variable_file')
+ @mock.patch('oauth2client.client._get_well_known_file')
+ def test_get_adc_from_env_var_service_account(self, *stubs):
+ # Set up stubs.
+ get_well_known, get_env_file, in_gae, in_gce = stubs
+ get_env_file.return_value = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+
+ credentials = client.GoogleCredentials.get_application_default()
+ self.validate_service_account_credentials(credentials)
+
+ get_env_file.assert_called_once_with()
+ get_well_known.assert_not_called()
+ in_gae.assert_not_called()
+ in_gce.assert_not_called()
+
+ def test_env_name(self):
+ self.assertEqual(None, client.SETTINGS.env_name)
+ self.test_get_adc_from_env_var_service_account()
+ self.assertEqual(client.DEFAULT_ENV_NAME, client.SETTINGS.env_name)
+
+ @mock.patch('oauth2client.client._in_gce_environment')
+ @mock.patch('oauth2client.client._in_gae_environment', return_value=False)
+ @mock.patch('oauth2client.client._get_environment_variable_file')
+ @mock.patch('oauth2client.client._get_well_known_file')
+ def test_get_adc_from_env_var_authorized_user(self, *stubs):
+ # Set up stubs.
+ get_well_known, get_env_file, in_gae, in_gce = stubs
+ get_env_file.return_value = datafile(os.path.join(
+ 'gcloud',
+ 'application_default_credentials_authorized_user.json'))
+
+ credentials = client.GoogleCredentials.get_application_default()
+ self.validate_google_credentials(credentials)
+
+ get_env_file.assert_called_once_with()
+ get_well_known.assert_not_called()
+ in_gae.assert_not_called()
+ in_gce.assert_not_called()
+
+ @mock.patch('oauth2client.client._in_gce_environment')
+ @mock.patch('oauth2client.client._in_gae_environment', return_value=False)
+ @mock.patch('oauth2client.client._get_environment_variable_file')
+ @mock.patch('oauth2client.client._get_well_known_file')
+ def test_get_adc_from_env_var_malformed_file(self, *stubs):
+ # Set up stubs.
+ get_well_known, get_env_file, in_gae, in_gce = stubs
+ get_env_file.return_value = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_3.json'))
+
+ expected_err = client.ApplicationDefaultCredentialsError
+ with self.assertRaises(expected_err) as exc_manager:
+ client.GoogleCredentials.get_application_default()
+
+ self.assertTrue(str(exc_manager.exception).startswith(
+ 'An error was encountered while reading json file: ' +
+ get_env_file.return_value + ' (pointed to by ' +
+ client.GOOGLE_APPLICATION_CREDENTIALS + ' environment variable):'))
+
+ get_env_file.assert_called_once_with()
+ get_well_known.assert_not_called()
+ in_gae.assert_not_called()
+ in_gce.assert_not_called()
+
+ @mock.patch('oauth2client.client._in_gce_environment', return_value=False)
+ @mock.patch('oauth2client.client._in_gae_environment', return_value=False)
+ @mock.patch('oauth2client.client._get_environment_variable_file',
+ return_value=None)
+ @mock.patch('oauth2client.client._get_well_known_file',
+ return_value='BOGUS_FILE')
+ def test_get_adc_env_not_set_up(self, *stubs):
+ # Unpack stubs.
+ get_well_known, get_env_file, in_gae, in_gce = stubs
+ # Make sure the well-known file actually doesn't exist.
+ self.assertFalse(os.path.exists(get_well_known.return_value))
+
+ expected_err = client.ApplicationDefaultCredentialsError
+ with self.assertRaises(expected_err) as exc_manager:
+ client.GoogleCredentials.get_application_default()
+
+ self.assertEqual(client.ADC_HELP_MSG, str(exc_manager.exception))
+ get_env_file.assert_called_once_with()
+ get_well_known.assert_called_once_with()
+ in_gae.assert_called_once_with()
+ in_gce.assert_called_once_with()
+
+ @mock.patch('oauth2client.client._in_gce_environment', return_value=False)
+ @mock.patch('oauth2client.client._in_gae_environment', return_value=False)
+ @mock.patch('oauth2client.client._get_environment_variable_file',
+ return_value=None)
+ @mock.patch('oauth2client.client._get_well_known_file')
+ def test_get_adc_env_from_well_known(self, *stubs):
+ # Unpack stubs.
+ get_well_known, get_env_file, in_gae, in_gce = stubs
+ # Make sure the well-known file is an actual file.
+ get_well_known.return_value = __file__
+ # Make sure the well-known file actually doesn't exist.
+ self.assertTrue(os.path.exists(get_well_known.return_value))
+
+ method_name = \
+ 'oauth2client.client._get_application_default_credential_from_file'
+ result_creds = object()
+ with mock.patch(method_name,
+ return_value=result_creds) as get_from_file:
+ result = client.GoogleCredentials.get_application_default()
+ self.assertEqual(result, result_creds)
+ get_from_file.assert_called_once_with(__file__)
+
+ get_env_file.assert_called_once_with()
+ get_well_known.assert_called_once_with()
+ in_gae.assert_not_called()
+ in_gce.assert_not_called()
+
+ def test_from_stream_service_account(self):
+ credentials_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ credentials = self.get_a_google_credentials_object().from_stream(
+ credentials_file)
+ self.validate_service_account_credentials(credentials)
+
+ def test_from_stream_authorized_user(self):
+ credentials_file = datafile(os.path.join(
+ 'gcloud',
+ 'application_default_credentials_authorized_user.json'))
+ credentials = self.get_a_google_credentials_object().from_stream(
+ credentials_file)
+ self.validate_google_credentials(credentials)
+
+ def test_from_stream_missing_file(self):
+ credentials_filename = None
+ expected_err_msg = (r'The parameter passed to the from_stream\(\) '
+ r'method should point to a file.')
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ self.get_a_google_credentials_object().from_stream(
+ credentials_filename)
+
+ def test_from_stream_malformed_file_1(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_1.json'))
+ expected_err_msg = (
+ 'An error was encountered while reading json file: ' +
+ credentials_file +
+ ' \(provided as parameter to the from_stream\(\) method\): ' +
+ "'type' field should be defined \(and have one of the '" +
+ client.AUTHORIZED_USER + "' or '" + client.SERVICE_ACCOUNT +
+ "' values\)")
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ self.get_a_google_credentials_object().from_stream(
+ credentials_file)
+
+ def test_from_stream_malformed_file_2(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_2.json'))
+ expected_err_msg = (
+ 'An error was encountered while reading json file: ' +
+ credentials_file +
+ ' \(provided as parameter to the from_stream\(\) method\): '
+ 'The following field\(s\) must be defined: '
+ 'private_key_id')
+ with self.assertRaisesRegexp(client.ApplicationDefaultCredentialsError,
+ expected_err_msg):
+ self.get_a_google_credentials_object().from_stream(
+ credentials_file)
+
+ def test_from_stream_malformed_file_3(self):
+ credentials_file = datafile(
+ os.path.join('gcloud',
+ 'application_default_credentials_malformed_3.json'))
+ with self.assertRaises(client.ApplicationDefaultCredentialsError):
+ self.get_a_google_credentials_object().from_stream(
+ credentials_file)
+
+ def test_to_from_json_authorized_user(self):
+ filename = 'application_default_credentials_authorized_user.json'
+ credentials_file = datafile(os.path.join('gcloud', filename))
+ creds = client.GoogleCredentials.from_stream(credentials_file)
+ json = creds.to_json()
+ creds2 = client.GoogleCredentials.from_json(json)
+
+ self.assertEqual(creds.__dict__, creds2.__dict__)
+
+ def test_to_from_json_service_account(self):
+ credentials_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ creds1 = client.GoogleCredentials.from_stream(credentials_file)
+ # Convert to and then back from json.
+ creds2 = client.GoogleCredentials.from_json(creds1.to_json())
+
+ creds1_vals = creds1.__dict__
+ creds1_vals.pop('_signer')
+ creds2_vals = creds2.__dict__
+ creds2_vals.pop('_signer')
+ self.assertEqual(creds1_vals, creds2_vals)
+
+ def test_to_from_json_service_account_scoped(self):
+ credentials_file = datafile(
+ os.path.join('gcloud', client._WELL_KNOWN_CREDENTIALS_FILE))
+ creds1 = client.GoogleCredentials.from_stream(credentials_file)
+ creds1 = creds1.create_scoped(['dummy_scope'])
+ # Convert to and then back from json.
+ creds2 = client.GoogleCredentials.from_json(creds1.to_json())
+
+ creds1_vals = creds1.__dict__
+ creds1_vals.pop('_signer')
+ creds2_vals = creds2.__dict__
+ creds2_vals.pop('_signer')
+ self.assertEqual(creds1_vals, creds2_vals)
+
+ def test_parse_expiry(self):
+ dt = datetime.datetime(2016, 1, 1)
+ parsed_expiry = client._parse_expiry(dt)
+ self.assertEqual('2016-01-01T00:00:00Z', parsed_expiry)
+
+ def test_bad_expiry(self):
+ dt = object()
+ parsed_expiry = client._parse_expiry(dt)
+ self.assertEqual(None, parsed_expiry)
+
+
+class DummyDeleteStorage(client.Storage):
+ delete_called = False
+
+ def locked_delete(self):
+ self.delete_called = True
+
+
+def _token_revoke_test_helper(testcase, status, revoke_raise,
+ valid_bool_value, token_attr):
+ current_store = getattr(testcase.credentials, 'store', None)
+
+ dummy_store = DummyDeleteStorage()
+ testcase.credentials.set_store(dummy_store)
+
+ actual_do_revoke = testcase.credentials._do_revoke
+ testcase.token_from_revoke = None
+
+ def do_revoke_stub(http_request, token):
+ testcase.token_from_revoke = token
+ return actual_do_revoke(http_request, token)
+ testcase.credentials._do_revoke = do_revoke_stub
+
+ http = HttpMock(headers={'status': status})
+ if revoke_raise:
+ testcase.assertRaises(client.TokenRevokeError,
+ testcase.credentials.revoke, http)
+ else:
+ testcase.credentials.revoke(http)
+
+ testcase.assertEqual(getattr(testcase.credentials, token_attr),
+ testcase.token_from_revoke)
+ testcase.assertEqual(valid_bool_value, testcase.credentials.invalid)
+ testcase.assertEqual(valid_bool_value, dummy_store.delete_called)
+
+ testcase.credentials.set_store(current_store)
+
+
+class BasicCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ access_token = 'foo'
+ client_id = 'some_client_id'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = datetime.datetime.utcnow()
+ user_agent = 'refresh_checker/1.0'
+ self.credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, oauth2client.GOOGLE_TOKEN_URI,
+ user_agent, revoke_uri=oauth2client.GOOGLE_REVOKE_URI,
+ scopes='foo', token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI)
+
+ # Provoke a failure if @util.positional is not respected.
+ self.old_positional_enforcement = (
+ util.positional_parameters_enforcement)
+ util.positional_parameters_enforcement = (
+ util.POSITIONAL_EXCEPTION)
+
+ def tearDown(self):
+ util.positional_parameters_enforcement = (
+ self.old_positional_enforcement)
+
+ def test_token_refresh_success(self):
+ for status_code in client.REFRESH_STATUS_CODES:
+ token_response = {'access_token': '1/3w', 'expires_in': 3600}
+ http = HttpMockSequence([
+ ({'status': status_code}, b''),
+ ({'status': '200'}, json.dumps(token_response).encode(
+ 'utf-8')),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(b'Bearer 1/3w', content[b'Authorization'])
+ self.assertFalse(self.credentials.access_token_expired)
+ self.assertEqual(token_response, self.credentials.token_response)
+
+ def test_recursive_authorize(self):
+ """Tests that OAuth2Credentials doesn't intro. new method constraints.
+
+ Formerly, OAuth2Credentials.authorize monkeypatched the request method
+ of its httplib2.Http argument with a wrapper annotated with
+ @util.positional(1). Since the original method has no such annotation,
+ that meant that the wrapper was violating the contract of the original
+ method by adding a new requirement to it. And in fact the wrapper
+ itself doesn't even respect that requirement. So before the removal of
+ the annotation, this test would fail.
+ """
+ token_response = {'access_token': '1/3w', 'expires_in': 3600}
+ encoded_response = json.dumps(token_response).encode('utf-8')
+ http = HttpMockSequence([
+ ({'status': '200'}, encoded_response),
+ ])
+ http = self.credentials.authorize(http)
+ http = self.credentials.authorize(http)
+ http.request('http://example.com')
+
+ def test_token_refresh_failure(self):
+ for status_code in client.REFRESH_STATUS_CODES:
+ http = HttpMockSequence([
+ ({'status': status_code}, b''),
+ ({'status': http_client.BAD_REQUEST},
+ b'{"error":"access_denied"}'),
+ ])
+ http = self.credentials.authorize(http)
+ with self.assertRaises(
+ client.HttpAccessTokenRefreshError) as exc_manager:
+ http.request('http://example.com')
+ self.assertEqual(http_client.BAD_REQUEST,
+ exc_manager.exception.status)
+ self.assertTrue(self.credentials.access_token_expired)
+ self.assertEqual(None, self.credentials.token_response)
+
+ def test_token_revoke_success(self):
+ _token_revoke_test_helper(
+ self, '200', revoke_raise=False,
+ valid_bool_value=True, token_attr='refresh_token')
+
+ def test_token_revoke_failure(self):
+ _token_revoke_test_helper(
+ self, '400', revoke_raise=True,
+ valid_bool_value=False, token_attr='refresh_token')
+
+ def test_token_revoke_fallback(self):
+ original_credentials = self.credentials.to_json()
+ self.credentials.refresh_token = None
+ _token_revoke_test_helper(
+ self, '200', revoke_raise=False,
+ valid_bool_value=True, token_attr='access_token')
+ self.credentials = self.credentials.from_json(original_credentials)
+
+ def test_non_401_error_response(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b''),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(http_client.BAD_REQUEST, resp.status)
+ self.assertEqual(None, self.credentials.token_response)
+
+ def test_to_from_json(self):
+ json = self.credentials.to_json()
+ instance = client.OAuth2Credentials.from_json(json)
+ self.assertEqual(client.OAuth2Credentials, type(instance))
+ instance.token_expiry = None
+ self.credentials.token_expiry = None
+
+ self.assertEqual(instance.__dict__, self.credentials.__dict__)
+
+ def test_from_json_token_expiry(self):
+ data = json.loads(self.credentials.to_json())
+ data['token_expiry'] = None
+ instance = client.OAuth2Credentials.from_json(json.dumps(data))
+ self.assertIsInstance(instance, client.OAuth2Credentials)
+
+ def test_from_json_bad_token_expiry(self):
+ data = json.loads(self.credentials.to_json())
+ data['token_expiry'] = 'foobar'
+ instance = client.OAuth2Credentials.from_json(json.dumps(data))
+ self.assertIsInstance(instance, client.OAuth2Credentials)
+
+ def test_unicode_header_checks(self):
+ access_token = u'foo'
+ client_id = u'some_client_id'
+ client_secret = u'cOuDdkfjxxnv+'
+ refresh_token = u'1/0/a.df219fjls0'
+ token_expiry = str(datetime.datetime.utcnow())
+ token_uri = str(oauth2client.GOOGLE_TOKEN_URI)
+ revoke_uri = str(oauth2client.GOOGLE_REVOKE_URI)
+ user_agent = u'refresh_checker/1.0'
+ credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret, refresh_token,
+ token_expiry, token_uri, user_agent, revoke_uri=revoke_uri)
+
+ # First, test that we correctly encode basic objects, making sure
+ # to include a bytes object. Note that oauth2client will normalize
+ # everything to bytes, no matter what python version we're in.
+ http = credentials.authorize(HttpMock())
+ headers = {u'foo': 3, b'bar': True, 'baz': b'abc'}
+ cleaned_headers = {b'foo': b'3', b'bar': b'True', b'baz': b'abc'}
+ http.request(u'http://example.com', method=u'GET', headers=headers)
+ for k, v in cleaned_headers.items():
+ self.assertTrue(k in http.headers)
+ self.assertEqual(v, http.headers[k])
+
+ # Next, test that we do fail on unicode.
+ unicode_str = six.unichr(40960) + 'abcd'
+ with self.assertRaises(client.NonAsciiHeaderError):
+ http.request(u'http://example.com', method=u'GET',
+ headers={u'foo': unicode_str})
+
+ def test_no_unicode_in_request_params(self):
+ access_token = u'foo'
+ client_id = u'some_client_id'
+ client_secret = u'cOuDdkfjxxnv+'
+ refresh_token = u'1/0/a.df219fjls0'
+ token_expiry = str(datetime.datetime.utcnow())
+ token_uri = str(oauth2client.GOOGLE_TOKEN_URI)
+ revoke_uri = str(oauth2client.GOOGLE_REVOKE_URI)
+ user_agent = u'refresh_checker/1.0'
+ credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret, refresh_token,
+ token_expiry, token_uri, user_agent, revoke_uri=revoke_uri)
+
+ http = HttpMock()
+ http = credentials.authorize(http)
+ http.request(u'http://example.com', method=u'GET',
+ headers={u'foo': u'bar'})
+ for k, v in six.iteritems(http.headers):
+ self.assertIsInstance(k, six.binary_type)
+ self.assertIsInstance(v, six.binary_type)
+
+ # Test again with unicode strings that can't simply be converted
+ # to ASCII.
+ with self.assertRaises(client.NonAsciiHeaderError):
+ http.request(
+ u'http://example.com', method=u'GET',
+ headers={u'foo': u'\N{COMET}'})
+
+ self.credentials.token_response = 'foobar'
+ instance = client.OAuth2Credentials.from_json(
+ self.credentials.to_json())
+ self.assertEqual('foobar', instance.token_response)
+
+ def test__expires_in_no_expiry(self):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ self.assertIsNone(credentials.token_expiry)
+ self.assertIsNone(credentials._expires_in())
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test__expires_in_expired(self, utcnow):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ credentials.token_expiry = datetime.datetime.utcnow()
+ now = credentials.token_expiry + datetime.timedelta(seconds=1)
+ self.assertLess(credentials.token_expiry, now)
+ utcnow.return_value = now
+ self.assertEqual(credentials._expires_in(), 0)
+ utcnow.assert_called_once_with()
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test__expires_in_not_expired(self, utcnow):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ credentials.token_expiry = datetime.datetime.utcnow()
+ seconds = 1234
+ now = credentials.token_expiry - datetime.timedelta(seconds=seconds)
+ self.assertLess(now, credentials.token_expiry)
+ utcnow.return_value = now
+ self.assertEqual(credentials._expires_in(), seconds)
+ utcnow.assert_called_once_with()
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_get_access_token(self, utcnow):
+ # Configure the patch.
+ seconds = 11
+ NOW = datetime.datetime(1992, 12, 31, second=seconds)
+ utcnow.return_value = NOW
+
+ lifetime = 2 # number of seconds in which the token expires
+ EXPIRY_TIME = datetime.datetime(1992, 12, 31,
+ second=seconds + lifetime)
+
+ token1 = u'first_token'
+ token_response_first = {
+ 'access_token': token1,
+ 'expires_in': lifetime,
+ }
+ token2 = u'second_token'
+ token_response_second = {
+ 'access_token': token2,
+ 'expires_in': lifetime,
+ }
+ http = HttpMockSequence([
+ ({'status': '200'}, json.dumps(token_response_first).encode(
+ 'utf-8')),
+ ({'status': '200'}, json.dumps(token_response_second).encode(
+ 'utf-8')),
+ ])
+
+ # Use the current credentials but unset the expiry and
+ # the access token.
+ credentials = copy.deepcopy(self.credentials)
+ credentials.access_token = None
+ credentials.token_expiry = None
+
+ # Get Access Token, First attempt.
+ self.assertEqual(credentials.access_token, None)
+ self.assertFalse(credentials.access_token_expired)
+ self.assertEqual(credentials.token_expiry, None)
+ token = credentials.get_access_token(http=http)
+ self.assertEqual(credentials.token_expiry, EXPIRY_TIME)
+ self.assertEqual(token1, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertEqual(token_response_first, credentials.token_response)
+ # Two utcnow calls are expected:
+ # - get_access_token() -> _do_refresh_request (setting expires in)
+ # - get_access_token() -> _expires_in()
+ expected_utcnow_calls = [mock.call()] * 2
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+
+ # Get Access Token, Second Attempt (not expired)
+ self.assertEqual(credentials.access_token, token1)
+ self.assertFalse(credentials.access_token_expired)
+ token = credentials.get_access_token(http=http)
+ # Make sure no refresh occurred since the token was not expired.
+ self.assertEqual(token1, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertEqual(token_response_first, credentials.token_response)
+ # Three more utcnow calls are expected:
+ # - access_token_expired
+ # - get_access_token() -> access_token_expired
+ # - get_access_token -> _expires_in
+ expected_utcnow_calls = [mock.call()] * (2 + 3)
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+
+ # Get Access Token, Third Attempt (force expiration)
+ self.assertEqual(credentials.access_token, token1)
+ credentials.token_expiry = NOW # Manually force expiry.
+ self.assertTrue(credentials.access_token_expired)
+ token = credentials.get_access_token(http=http)
+ # Make sure refresh occurred since the token was not expired.
+ self.assertEqual(token2, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertFalse(credentials.access_token_expired)
+ self.assertEqual(token_response_second,
+ credentials.token_response)
+ # Five more utcnow calls are expected:
+ # - access_token_expired
+ # - get_access_token -> access_token_expired
+ # - get_access_token -> _do_refresh_request
+ # - get_access_token -> _expires_in
+ # - access_token_expired
+ expected_utcnow_calls = [mock.call()] * (2 + 3 + 5)
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+
+ @mock.patch.object(client.OAuth2Credentials, 'refresh')
+ @mock.patch.object(client.OAuth2Credentials, '_expires_in',
+ return_value=1835)
+ def test_get_access_token_without_http(self, expires_in, refresh_mock):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ # Make sure access_token_expired returns True
+ credentials.invalid = True
+ # Specify a token so we can use it in the response.
+ credentials.access_token = 'ya29-s3kr3t'
+
+ with mock.patch('httplib2.Http',
+ return_value=object) as http_kls:
+ token_info = credentials.get_access_token()
+ expires_in.assert_called_once_with()
+ refresh_mock.assert_called_once_with(http_kls.return_value)
+
+ self.assertIsInstance(token_info, client.AccessTokenInfo)
+ self.assertEqual(token_info.access_token,
+ credentials.access_token)
+ self.assertEqual(token_info.expires_in,
+ expires_in.return_value)
+
+ @mock.patch.object(client.OAuth2Credentials, 'refresh')
+ @mock.patch.object(client.OAuth2Credentials, '_expires_in',
+ return_value=1835)
+ def test_get_access_token_with_http(self, expires_in, refresh_mock):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ # Make sure access_token_expired returns True
+ credentials.invalid = True
+ # Specify a token so we can use it in the response.
+ credentials.access_token = 'ya29-s3kr3t'
+
+ http_obj = object()
+ token_info = credentials.get_access_token(http_obj)
+ self.assertIsInstance(token_info, client.AccessTokenInfo)
+ self.assertEqual(token_info.access_token,
+ credentials.access_token)
+ self.assertEqual(token_info.expires_in,
+ expires_in.return_value)
+
+ expires_in.assert_called_once_with()
+ refresh_mock.assert_called_once_with(http_obj)
+
+ @mock.patch.object(client.OAuth2Credentials,
+ '_generate_refresh_request_headers',
+ return_value=object())
+ @mock.patch.object(client.OAuth2Credentials,
+ '_generate_refresh_request_body',
+ return_value=object())
+ @mock.patch('oauth2client.client.logger')
+ def _do_refresh_request_test_helper(self, response, content,
+ error_msg, logger, gen_body,
+ gen_headers, store=None):
+ credentials = client.OAuth2Credentials(None, None, None, None,
+ None, None, None)
+ credentials.store = store
+ http_request = mock.Mock()
+ http_request.return_value = response, content
+
+ with self.assertRaises(
+ client.HttpAccessTokenRefreshError) as exc_manager:
+ credentials._do_refresh_request(http_request)
+
+ self.assertEqual(exc_manager.exception.args, (error_msg,))
+ self.assertEqual(exc_manager.exception.status, response.status)
+ http_request.assert_called_once_with(None, body=gen_body.return_value,
+ headers=gen_headers.return_value,
+ method='POST')
+
+ call1 = mock.call('Refreshing access_token')
+ failure_template = 'Failed to retrieve access token: %s'
+ call2 = mock.call(failure_template, content)
+ self.assertEqual(logger.info.mock_calls, [call1, call2])
+ if store is not None:
+ store.locked_put.assert_called_once_with(credentials)
+
+ def test__do_refresh_request_non_json_failure(self):
+ response = httplib2.Response({
+ 'status': int(http_client.BAD_REQUEST),
+ })
+ content = u'Bad request'
+ error_msg = 'Invalid response {0}.'.format(int(response.status))
+ self._do_refresh_request_test_helper(response, content, error_msg)
+
+ def test__do_refresh_request_basic_failure(self):
+ response = httplib2.Response({
+ 'status': int(http_client.INTERNAL_SERVER_ERROR),
+ })
+ content = u'{}'
+ error_msg = 'Invalid response {0}.'.format(int(response.status))
+ self._do_refresh_request_test_helper(response, content, error_msg)
+
+ def test__do_refresh_request_failure_w_json_error(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_GATEWAY,
+ })
+ error_msg = 'Hi I am an error not a bearer'
+ content = json.dumps({'error': error_msg})
+ self._do_refresh_request_test_helper(response, content, error_msg)
+
+ def test__do_refresh_request_failure_w_json_error_and_store(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_GATEWAY,
+ })
+ error_msg = 'Where are we going wearer?'
+ content = json.dumps({'error': error_msg})
+ store = mock.MagicMock()
+ self._do_refresh_request_test_helper(response, content, error_msg,
+ store=store)
+
+ def test__do_refresh_request_failure_w_json_error_and_desc(self):
+ response = httplib2.Response({
+ 'status': http_client.SERVICE_UNAVAILABLE,
+ })
+ base_error = 'Ruckus'
+ error_desc = 'Can you describe the ruckus'
+ content = json.dumps({
+ 'error': base_error,
+ 'error_description': error_desc,
+ })
+ error_msg = '{0}: {1}'.format(base_error, error_desc)
+ self._do_refresh_request_test_helper(response, content, error_msg)
+
+ @mock.patch('oauth2client.client.logger')
+ def _do_revoke_test_helper(self, response, content,
+ error_msg, logger, store=None):
+ credentials = client.OAuth2Credentials(
+ None, None, None, None, None, None, None,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI)
+ credentials.store = store
+ http_request = mock.Mock()
+ http_request.return_value = response, content
+ token = u's3kr3tz'
+
+ if response.status == http_client.OK:
+ self.assertFalse(credentials.invalid)
+ self.assertIsNone(credentials._do_revoke(http_request, token))
+ self.assertTrue(credentials.invalid)
+ if store is not None:
+ store.delete.assert_called_once_with()
+ else:
+ self.assertFalse(credentials.invalid)
+ with self.assertRaises(client.TokenRevokeError) as exc_manager:
+ credentials._do_revoke(http_request, token)
+ # Make sure invalid was not flipped on.
+ self.assertFalse(credentials.invalid)
+ self.assertEqual(exc_manager.exception.args, (error_msg,))
+ if store is not None:
+ store.delete.assert_not_called()
+
+ revoke_uri = oauth2client.GOOGLE_REVOKE_URI + '?token=' + token
+ http_request.assert_called_once_with(revoke_uri)
+
+ logger.info.assert_called_once_with('Revoking token')
+
+ def test__do_revoke_success(self):
+ response = httplib2.Response({
+ 'status': http_client.OK,
+ })
+ self._do_revoke_test_helper(response, b'', None)
+
+ def test__do_revoke_success_with_store(self):
+ response = httplib2.Response({
+ 'status': http_client.OK,
+ })
+ store = mock.MagicMock()
+ self._do_revoke_test_helper(response, b'', None, store=store)
+
+ def test__do_revoke_non_json_failure(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_REQUEST,
+ })
+ content = u'Bad request'
+ error_msg = 'Invalid response {0}.'.format(response.status)
+ self._do_revoke_test_helper(response, content, error_msg)
+
+ def test__do_revoke_basic_failure(self):
+ response = httplib2.Response({
+ 'status': http_client.INTERNAL_SERVER_ERROR,
+ })
+ content = u'{}'
+ error_msg = 'Invalid response {0}.'.format(response.status)
+ self._do_revoke_test_helper(response, content, error_msg)
+
+ def test__do_revoke_failure_w_json_error(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_GATEWAY,
+ })
+ error_msg = 'Hi I am an error not a bearer'
+ content = json.dumps({'error': error_msg})
+ self._do_revoke_test_helper(response, content, error_msg)
+
+ def test__do_revoke_failure_w_json_error_and_store(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_GATEWAY,
+ })
+ error_msg = 'Where are we going wearer?'
+ content = json.dumps({'error': error_msg})
+ store = mock.MagicMock()
+ self._do_revoke_test_helper(response, content, error_msg,
+ store=store)
+
+ @mock.patch('oauth2client.client.logger')
+ def _do_retrieve_scopes_test_helper(self, response, content,
+ error_msg, logger, scopes=None):
+ credentials = client.OAuth2Credentials(
+ None, None, None, None, None, None, None,
+ token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI)
+ http_request = mock.Mock()
+ http_request.return_value = response, content
+ token = u's3kr3tz'
+
+ if response.status == http_client.OK:
+ self.assertEqual(credentials.scopes, set())
+ self.assertIsNone(
+ credentials._do_retrieve_scopes(http_request, token))
+ self.assertEqual(credentials.scopes, scopes)
+ else:
+ self.assertEqual(credentials.scopes, set())
+ with self.assertRaises(client.Error) as exc_manager:
+ credentials._do_retrieve_scopes(http_request, token)
+ # Make sure scopes were not changed.
+ self.assertEqual(credentials.scopes, set())
+ self.assertEqual(exc_manager.exception.args, (error_msg,))
+
+ token_uri = client._update_query_params(
+ oauth2client.GOOGLE_TOKEN_INFO_URI,
+ {'fields': 'scope', 'access_token': token})
+ self.assertEqual(len(http_request.mock_calls), 1)
+ scopes_call = http_request.mock_calls[0]
+ call_args = scopes_call[1]
+ self.assertEqual(len(call_args), 1)
+ called_uri = call_args[0]
+ assertUrisEqual(self, token_uri, called_uri)
+ logger.info.assert_called_once_with('Refreshing scopes')
+
+ def test__do_retrieve_scopes_success_bad_json(self):
+ response = httplib2.Response({
+ 'status': http_client.OK,
+ })
+ invalid_json = b'{'
+ with self.assertRaises(ValueError):
+ self._do_retrieve_scopes_test_helper(response, invalid_json, None)
+
+ def test__do_retrieve_scopes_success(self):
+ response = httplib2.Response({
+ 'status': http_client.OK,
+ })
+ content = b'{"scope": "foo bar"}'
+ self._do_retrieve_scopes_test_helper(response, content, None,
+ scopes=set(['foo', 'bar']))
+
+ def test__do_retrieve_scopes_non_json_failure(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_REQUEST,
+ })
+ content = u'Bad request'
+ error_msg = 'Invalid response {0}.'.format(response.status)
+ self._do_retrieve_scopes_test_helper(response, content, error_msg)
+
+ def test__do_retrieve_scopes_basic_failure(self):
+ response = httplib2.Response({
+ 'status': http_client.INTERNAL_SERVER_ERROR,
+ })
+ content = u'{}'
+ error_msg = 'Invalid response {0}.'.format(response.status)
+ self._do_retrieve_scopes_test_helper(response, content, error_msg)
+
+ def test__do_retrieve_scopes_failure_w_json_error(self):
+ response = httplib2.Response({
+ 'status': http_client.BAD_GATEWAY,
+ })
+ error_msg = 'Error desc I sit at a desk'
+ content = json.dumps({'error_description': error_msg})
+ self._do_retrieve_scopes_test_helper(response, content, error_msg)
+
+ def test_has_scopes(self):
+ self.assertTrue(self.credentials.has_scopes('foo'))
+ self.assertTrue(self.credentials.has_scopes(['foo']))
+ self.assertFalse(self.credentials.has_scopes('bar'))
+ self.assertFalse(self.credentials.has_scopes(['bar']))
+
+ self.credentials.scopes = set(['foo', 'bar'])
+ self.assertTrue(self.credentials.has_scopes('foo'))
+ self.assertTrue(self.credentials.has_scopes('bar'))
+ self.assertFalse(self.credentials.has_scopes('baz'))
+ self.assertTrue(self.credentials.has_scopes(['foo', 'bar']))
+ self.assertFalse(self.credentials.has_scopes(['foo', 'baz']))
+
+ self.credentials.scopes = set([])
+ self.assertFalse(self.credentials.has_scopes('foo'))
+
+ def test_retrieve_scopes(self):
+ info_response_first = {'scope': 'foo bar'}
+ info_response_second = {'error_description': 'abcdef'}
+ http = HttpMockSequence([
+ ({'status': '200'}, json.dumps(info_response_first).encode(
+ 'utf-8')),
+ ({'status': '400'}, json.dumps(info_response_second).encode(
+ 'utf-8')),
+ ({'status': '500'}, b''),
+ ])
+
+ self.credentials.retrieve_scopes(http)
+ self.assertEqual(set(['foo', 'bar']), self.credentials.scopes)
+
+ with self.assertRaises(client.Error):
+ self.credentials.retrieve_scopes(http)
+
+ with self.assertRaises(client.Error):
+ self.credentials.retrieve_scopes(http)
+
+ def test_refresh_updates_id_token(self):
+ for status_code in client.REFRESH_STATUS_CODES:
+ body = {'foo': 'bar'}
+ body_json = json.dumps(body).encode('ascii')
+ payload = base64.urlsafe_b64encode(body_json).strip(b'=')
+ jwt = b'stuff.' + payload + b'.signature'
+
+ token_response = (b'{'
+ b' "access_token":"1/3w",'
+ b' "expires_in":3600,'
+ b' "id_token": "' + jwt + b'"'
+ b'}')
+ http = HttpMockSequence([
+ ({'status': status_code}, b''),
+ ({'status': '200'}, token_response),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(self.credentials.id_token, body)
+
+
+class AccessTokenCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ access_token = 'foo'
+ user_agent = 'refresh_checker/1.0'
+ self.credentials = client.AccessTokenCredentials(
+ access_token, user_agent,
+ revoke_uri=oauth2client.GOOGLE_REVOKE_URI)
+
+ def test_token_refresh_success(self):
+ for status_code in client.REFRESH_STATUS_CODES:
+ http = HttpMockSequence([
+ ({'status': status_code}, b''),
+ ])
+ http = self.credentials.authorize(http)
+ with self.assertRaises(client.AccessTokenCredentialsError):
+ resp, content = http.request('http://example.com')
+
+ def test_token_revoke_success(self):
+ _token_revoke_test_helper(
+ self, '200', revoke_raise=False,
+ valid_bool_value=True, token_attr='access_token')
+
+ def test_token_revoke_failure(self):
+ _token_revoke_test_helper(
+ self, '400', revoke_raise=True,
+ valid_bool_value=False, token_attr='access_token')
+
+ def test_non_401_error_response(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b''),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(http_client.BAD_REQUEST, resp.status)
+
+ def test_auth_header_sent(self):
+ http = HttpMockSequence([
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(b'Bearer foo', content[b'Authorization'])
+
+
+class TestAssertionCredentials(unittest2.TestCase):
+ assertion_text = 'This is the assertion'
+ assertion_type = 'http://www.google.com/assertionType'
+
+ class AssertionCredentialsTestImpl(client.AssertionCredentials):
+
+ def _generate_assertion(self):
+ return TestAssertionCredentials.assertion_text
+
+ def setUp(self):
+ user_agent = 'fun/2.0'
+ self.credentials = self.AssertionCredentialsTestImpl(
+ self.assertion_type, user_agent=user_agent)
+
+ def test__generate_assertion_abstract(self):
+ credentials = client.AssertionCredentials(None)
+ with self.assertRaises(NotImplementedError):
+ credentials._generate_assertion()
+
+ def test_assertion_body(self):
+ body = urllib.parse.parse_qs(
+ self.credentials._generate_refresh_request_body())
+ self.assertEqual(self.assertion_text, body['assertion'][0])
+ self.assertEqual('urn:ietf:params:oauth:grant-type:jwt-bearer',
+ body['grant_type'][0])
+
+ def test_assertion_refresh(self):
+ http = HttpMockSequence([
+ ({'status': '200'}, b'{"access_token":"1/3w"}'),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = self.credentials.authorize(http)
+ resp, content = http.request('http://example.com')
+ self.assertEqual(b'Bearer 1/3w', content[b'Authorization'])
+
+ def test_token_revoke_success(self):
+ _token_revoke_test_helper(
+ self, '200', revoke_raise=False,
+ valid_bool_value=True, token_attr='access_token')
+
+ def test_token_revoke_failure(self):
+ _token_revoke_test_helper(
+ self, '400', revoke_raise=True,
+ valid_bool_value=False, token_attr='access_token')
+
+ def test_sign_blob_abstract(self):
+ credentials = client.AssertionCredentials(None)
+ with self.assertRaises(NotImplementedError):
+ credentials.sign_blob(b'blob')
+
+
+class UpdateQueryParamsTest(unittest2.TestCase):
+ def test_update_query_params_no_params(self):
+ uri = 'http://www.google.com'
+ updated = client._update_query_params(uri, {'a': 'b'})
+ self.assertEqual(updated, uri + '?a=b')
+
+ def test_update_query_params_existing_params(self):
+ uri = 'http://www.google.com?x=y'
+ updated = client._update_query_params(uri, {'a': 'b', 'c': 'd&'})
+ hardcoded_update = uri + '&a=b&c=d%26'
+ assertUrisEqual(self, updated, hardcoded_update)
+
+
+class ExtractIdTokenTest(unittest2.TestCase):
+ """Tests client._extract_id_token()."""
+
+ def test_extract_success(self):
+ body = {'foo': 'bar'}
+ body_json = json.dumps(body).encode('ascii')
+ payload = base64.urlsafe_b64encode(body_json).strip(b'=')
+ jwt = b'stuff.' + payload + b'.signature'
+
+ extracted = client._extract_id_token(jwt)
+ self.assertEqual(extracted, body)
+
+ def test_extract_failure(self):
+ body = {'foo': 'bar'}
+ body_json = json.dumps(body).encode('ascii')
+ payload = base64.urlsafe_b64encode(body_json).strip(b'=')
+ jwt = b'stuff.' + payload
+ with self.assertRaises(client.VerifyJwtTokenError):
+ client._extract_id_token(jwt)
+
+
+class OAuth2WebServerFlowTest(unittest2.TestCase):
+
+ def setUp(self):
+ self.flow = client.OAuth2WebServerFlow(
+ client_id='client_id+1',
+ client_secret='secret+1',
+ scope='foo',
+ redirect_uri=client.OOB_CALLBACK_URN,
+ user_agent='unittest-sample/1.0',
+ revoke_uri='dummy_revoke_uri',
+ )
+
+ def test_construct_authorize_url(self):
+ authorize_url = self.flow.step1_get_authorize_url(state='state+1')
+
+ parsed = urllib.parse.urlparse(authorize_url)
+ q = urllib.parse.parse_qs(parsed[4])
+ self.assertEqual('client_id+1', q['client_id'][0])
+ self.assertEqual('code', q['response_type'][0])
+ self.assertEqual('foo', q['scope'][0])
+ self.assertEqual(client.OOB_CALLBACK_URN, q['redirect_uri'][0])
+ self.assertEqual('offline', q['access_type'][0])
+ self.assertEqual('state+1', q['state'][0])
+
+ def test_override_flow_via_kwargs(self):
+ """Passing kwargs to override defaults."""
+ flow = client.OAuth2WebServerFlow(
+ client_id='client_id+1',
+ client_secret='secret+1',
+ scope='foo',
+ redirect_uri=client.OOB_CALLBACK_URN,
+ user_agent='unittest-sample/1.0',
+ access_type='online',
+ response_type='token'
+ )
+ authorize_url = flow.step1_get_authorize_url()
+
+ parsed = urllib.parse.urlparse(authorize_url)
+ q = urllib.parse.parse_qs(parsed[4])
+ self.assertEqual('client_id+1', q['client_id'][0])
+ self.assertEqual('token', q['response_type'][0])
+ self.assertEqual('foo', q['scope'][0])
+ self.assertEqual(client.OOB_CALLBACK_URN, q['redirect_uri'][0])
+ self.assertEqual('online', q['access_type'][0])
+
+ def test__oauth2_web_server_flow_params(self):
+ params = client._oauth2_web_server_flow_params({})
+ self.assertEqual(params['access_type'], 'offline')
+ self.assertEqual(params['response_type'], 'code')
+
+ params = client._oauth2_web_server_flow_params({
+ 'approval_prompt': 'force'})
+ self.assertEqual(params['prompt'], 'consent')
+ self.assertNotIn('approval_prompt', params)
+
+ params = client._oauth2_web_server_flow_params({
+ 'approval_prompt': 'other'})
+ self.assertEqual(params['approval_prompt'], 'other')
+
+ @mock.patch('oauth2client.client.logger')
+ def test_step1_get_authorize_url_redirect_override(self, logger):
+ flow = client.OAuth2WebServerFlow('client_id+1', scope='foo',
+ redirect_uri=client.OOB_CALLBACK_URN)
+ alt_redirect = 'foo:bar'
+ self.assertEqual(flow.redirect_uri, client.OOB_CALLBACK_URN)
+ result = flow.step1_get_authorize_url(redirect_uri=alt_redirect)
+ # Make sure the redirect value was updated.
+ self.assertEqual(flow.redirect_uri, alt_redirect)
+ query_params = {
+ 'client_id': flow.client_id,
+ 'redirect_uri': alt_redirect,
+ 'scope': flow.scope,
+ 'access_type': 'offline',
+ 'response_type': 'code',
+ }
+ expected = client._update_query_params(flow.auth_uri, query_params)
+ assertUrisEqual(self, expected, result)
+ # Check stubs.
+ self.assertEqual(logger.warning.call_count, 1)
+
+ def test_step1_get_authorize_url_without_redirect(self):
+ flow = client.OAuth2WebServerFlow('client_id+1', scope='foo',
+ redirect_uri=None)
+ with self.assertRaises(ValueError):
+ flow.step1_get_authorize_url(redirect_uri=None)
+
+ def test_step1_get_authorize_url_without_login_hint(self):
+ login_hint = 'There are wascally wabbits nearby'
+ flow = client.OAuth2WebServerFlow('client_id+1', scope='foo',
+ redirect_uri=client.OOB_CALLBACK_URN,
+ login_hint=login_hint)
+ result = flow.step1_get_authorize_url()
+ query_params = {
+ 'client_id': flow.client_id,
+ 'login_hint': login_hint,
+ 'redirect_uri': client.OOB_CALLBACK_URN,
+ 'scope': flow.scope,
+ 'access_type': 'offline',
+ 'response_type': 'code',
+ }
+ expected = client._update_query_params(flow.auth_uri, query_params)
+ assertUrisEqual(self, expected, result)
+
+ def test_step1_get_device_and_user_codes_wo_device_uri(self):
+ flow = client.OAuth2WebServerFlow('CID', scope='foo', device_uri=None)
+ with self.assertRaises(ValueError):
+ flow.step1_get_device_and_user_codes()
+
+ def _step1_get_device_and_user_codes_helper(
+ self, extra_headers=None, user_agent=None, default_http=False,
+ content=None):
+ flow = client.OAuth2WebServerFlow('CID', scope='foo',
+ user_agent=user_agent)
+ device_code = 'bfc06756-062e-430f-9f0f-460ca44724e5'
+ user_code = '5faf2780-fc83-11e5-9bc2-00c2c63e5792'
+ ver_url = 'http://foo.bar'
+ if content is None:
+ content = json.dumps({
+ 'device_code': device_code,
+ 'user_code': user_code,
+ 'verification_url': ver_url,
+ })
+ http = HttpMockSequence([
+ ({'status': http_client.OK}, content),
+ ])
+ if default_http:
+ with mock.patch('httplib2.Http', return_value=http):
+ result = flow.step1_get_device_and_user_codes()
+ else:
+ result = flow.step1_get_device_and_user_codes(http=http)
+
+ expected = client.DeviceFlowInfo(
+ device_code, user_code, None, ver_url, None)
+ self.assertEqual(result, expected)
+ self.assertEqual(len(http.requests), 1)
+ self.assertEqual(
+ http.requests[0]['uri'], oauth2client.GOOGLE_DEVICE_URI)
+ body = http.requests[0]['body']
+ self.assertEqual(urllib.parse.parse_qs(body),
+ {'client_id': [flow.client_id],
+ 'scope': [flow.scope]})
+ headers = {'content-type': 'application/x-www-form-urlencoded'}
+ if extra_headers is not None:
+ headers.update(extra_headers)
+ self.assertEqual(http.requests[0]['headers'], headers)
+
+ def test_step1_get_device_and_user_codes(self):
+ self._step1_get_device_and_user_codes_helper()
+
+ def test_step1_get_device_and_user_codes_w_user_agent(self):
+ user_agent = 'spiderman'
+ extra_headers = {'user-agent': user_agent}
+ self._step1_get_device_and_user_codes_helper(
+ user_agent=user_agent, extra_headers=extra_headers)
+
+ def test_step1_get_device_and_user_codes_w_default_http(self):
+ self._step1_get_device_and_user_codes_helper(default_http=True)
+
+ def test_step1_get_device_and_user_codes_bad_payload(self):
+ non_json_content = b'{'
+ with self.assertRaises(client.OAuth2DeviceCodeError):
+ self._step1_get_device_and_user_codes_helper(
+ content=non_json_content)
+
+ def _step1_get_device_and_user_codes_fail_helper(self, status,
+ content, error_msg):
+ flow = client.OAuth2WebServerFlow('CID', scope='foo')
+ http = HttpMockSequence([
+ ({'status': status}, content),
+ ])
+ with self.assertRaises(client.OAuth2DeviceCodeError) as exc_manager:
+ flow.step1_get_device_and_user_codes(http=http)
+
+ self.assertEqual(exc_manager.exception.args, (error_msg,))
+
+ def test_step1_get_device_and_user_codes_non_json_failure(self):
+ status = int(http_client.BAD_REQUEST)
+ content = 'Nope not JSON.'
+ error_msg = 'Invalid response {0}.'.format(status)
+ self._step1_get_device_and_user_codes_fail_helper(status, content,
+ error_msg)
+
+ def test_step1_get_device_and_user_codes_basic_failure(self):
+ status = int(http_client.INTERNAL_SERVER_ERROR)
+ content = b'{}'
+ error_msg = 'Invalid response {0}.'.format(status)
+ self._step1_get_device_and_user_codes_fail_helper(status, content,
+ error_msg)
+
+ def test_step1_get_device_and_user_codes_failure_w_json_error(self):
+ status = int(http_client.BAD_GATEWAY)
+ base_error = 'ZOMG user codes failure.'
+ content = json.dumps({'error': base_error})
+ error_msg = 'Invalid response {0}. Error: {1}'.format(status,
+ base_error)
+ self._step1_get_device_and_user_codes_fail_helper(status, content,
+ error_msg)
+
+ def test_step2_exchange_no_input(self):
+ flow = client.OAuth2WebServerFlow('client_id+1', scope='foo')
+ with self.assertRaises(ValueError):
+ flow.step2_exchange()
+
+ def test_step2_exchange_code_and_device_flow(self):
+ flow = client.OAuth2WebServerFlow('client_id+1', scope='foo')
+ with self.assertRaises(ValueError):
+ flow.step2_exchange(code='code', device_flow_info='dfi')
+
+ def test_scope_is_required(self):
+ with self.assertRaises(TypeError):
+ client.OAuth2WebServerFlow('client_id+1')
+
+ def test_exchange_failure(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b'{"error":"invalid_request"}'),
+ ])
+
+ with self.assertRaises(client.FlowExchangeError):
+ self.flow.step2_exchange(code='some random code', http=http)
+
+ def test_urlencoded_exchange_failure(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b'error=invalid_request'),
+ ])
+
+ with self.assertRaisesRegexp(client.FlowExchangeError,
+ 'invalid_request'):
+ self.flow.step2_exchange(code='some random code', http=http)
+
+ def test_exchange_failure_with_json_error(self):
+ # Some providers have 'error' attribute as a JSON object
+ # in place of regular string.
+ # This test makes sure no strange object-to-string coversion
+ # exceptions are being raised instead of FlowExchangeError.
+ payload = (b'{'
+ b' "error": {'
+ b' "message": "Error validating verification code.",'
+ b' "type": "OAuthException"'
+ b' }'
+ b'}')
+ http = HttpMockSequence([({'status': '400'}, payload)])
+
+ with self.assertRaises(client.FlowExchangeError):
+ self.flow.step2_exchange(code='some random code', http=http)
+
+ def _exchange_success_test_helper(self, code=None, device_flow_info=None):
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "expires_in":3600,'
+ b' "refresh_token":"8xLOxBtZp8"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+ credentials = self.flow.step2_exchange(
+ code=code, device_flow_info=device_flow_info, http=http)
+ self.assertEqual('SlAV32hkKG', credentials.access_token)
+ self.assertNotEqual(None, credentials.token_expiry)
+ self.assertEqual('8xLOxBtZp8', credentials.refresh_token)
+ self.assertEqual('dummy_revoke_uri', credentials.revoke_uri)
+ self.assertEqual(set(['foo']), credentials.scopes)
+
+ def test_exchange_success(self):
+ self._exchange_success_test_helper(code='some random code')
+
+ def test_exchange_success_with_device_flow_info(self):
+ device_flow_info = client.DeviceFlowInfo(
+ 'some random code', None, None, None, None)
+ self._exchange_success_test_helper(device_flow_info=device_flow_info)
+
+ def test_exchange_success_binary_code(self):
+ binary_code = b'some random code'
+ access_token = 'SlAV32hkKG'
+ expires_in = '3600'
+ refresh_token = '8xLOxBtZp8'
+ revoke_uri = 'dummy_revoke_uri'
+
+ payload = ('{'
+ ' "access_token":"' + access_token + '",'
+ ' "expires_in":' + expires_in + ','
+ ' "refresh_token":"' + refresh_token + '"'
+ '}')
+ http = HttpMockSequence(
+ [({'status': '200'}, _helpers._to_bytes(payload))])
+ credentials = self.flow.step2_exchange(code=binary_code, http=http)
+ self.assertEqual(access_token, credentials.access_token)
+ self.assertIsNotNone(credentials.token_expiry)
+ self.assertEqual(refresh_token, credentials.refresh_token)
+ self.assertEqual(revoke_uri, credentials.revoke_uri)
+ self.assertEqual(set(['foo']), credentials.scopes)
+
+ def test_exchange_dictlike(self):
+ class FakeDict(object):
+ def __init__(self, d):
+ self.d = d
+
+ def __getitem__(self, name):
+ return self.d[name]
+
+ def __contains__(self, name):
+ return name in self.d
+
+ code = 'some random code'
+ not_a_dict = FakeDict({'code': code})
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "expires_in":3600,'
+ b' "refresh_token":"8xLOxBtZp8"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+
+ credentials = self.flow.step2_exchange(code=not_a_dict, http=http)
+ self.assertEqual('SlAV32hkKG', credentials.access_token)
+ self.assertNotEqual(None, credentials.token_expiry)
+ self.assertEqual('8xLOxBtZp8', credentials.refresh_token)
+ self.assertEqual('dummy_revoke_uri', credentials.revoke_uri)
+ self.assertEqual(set(['foo']), credentials.scopes)
+ request_code = urllib.parse.parse_qs(
+ http.requests[0]['body'])['code'][0]
+ self.assertEqual(code, request_code)
+
+ def test_exchange_using_authorization_header(self):
+ auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=',
+ flow = client.OAuth2WebServerFlow(
+ client_id='client_id+1',
+ authorization_header=auth_header,
+ scope='foo',
+ redirect_uri=client.OOB_CALLBACK_URN,
+ user_agent='unittest-sample/1.0',
+ revoke_uri='dummy_revoke_uri',
+ )
+ http = HttpMockSequence([
+ ({'status': '200'}, b'access_token=SlAV32hkKG'),
+ ])
+
+ credentials = flow.step2_exchange(code='some random code', http=http)
+ self.assertEqual('SlAV32hkKG', credentials.access_token)
+
+ test_request = http.requests[0]
+ # Did we pass the Authorization header?
+ self.assertEqual(test_request['headers']['Authorization'], auth_header)
+ # Did we omit client_secret from POST body?
+ self.assertTrue('client_secret' not in test_request['body'])
+
+ def test_urlencoded_exchange_success(self):
+ http = HttpMockSequence([
+ ({'status': '200'}, b'access_token=SlAV32hkKG&expires_in=3600'),
+ ])
+
+ credentials = self.flow.step2_exchange(code='some random code',
+ http=http)
+ self.assertEqual('SlAV32hkKG', credentials.access_token)
+ self.assertNotEqual(None, credentials.token_expiry)
+
+ def test_urlencoded_expires_param(self):
+ http = HttpMockSequence([
+ # Note the 'expires=3600' where you'd normally
+ # have if named 'expires_in'
+ ({'status': '200'}, b'access_token=SlAV32hkKG&expires=3600'),
+ ])
+
+ credentials = self.flow.step2_exchange(code='some random code',
+ http=http)
+ self.assertNotEqual(None, credentials.token_expiry)
+
+ def test_exchange_no_expires_in(self):
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "refresh_token":"8xLOxBtZp8"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+
+ credentials = self.flow.step2_exchange(code='some random code',
+ http=http)
+ self.assertEqual(None, credentials.token_expiry)
+
+ def test_urlencoded_exchange_no_expires_in(self):
+ http = HttpMockSequence([
+ # This might be redundant but just to make sure
+ # urlencoded access_token gets parsed correctly
+ ({'status': '200'}, b'access_token=SlAV32hkKG'),
+ ])
+
+ credentials = self.flow.step2_exchange(code='some random code',
+ http=http)
+ self.assertEqual(None, credentials.token_expiry)
+
+ def test_exchange_fails_if_no_code(self):
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "refresh_token":"8xLOxBtZp8"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+
+ code = {'error': 'thou shall not pass'}
+ with self.assertRaisesRegexp(
+ client.FlowExchangeError, 'shall not pass'):
+ self.flow.step2_exchange(code=code, http=http)
+
+ def test_exchange_id_token_fail(self):
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "refresh_token":"8xLOxBtZp8",'
+ b' "id_token": "stuff.payload"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+
+ with self.assertRaises(client.VerifyJwtTokenError):
+ self.flow.step2_exchange(code='some random code', http=http)
+
+ def test_exchange_id_token(self):
+ body = {'foo': 'bar'}
+ body_json = json.dumps(body).encode('ascii')
+ payload = base64.urlsafe_b64encode(body_json).strip(b'=')
+ jwt = (base64.urlsafe_b64encode(b'stuff') + b'.' + payload + b'.' +
+ base64.urlsafe_b64encode(b'signature'))
+
+ payload = (b'{'
+ b' "access_token":"SlAV32hkKG",'
+ b' "refresh_token":"8xLOxBtZp8",'
+ b' "id_token": "' + jwt + b'"'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+ credentials = self.flow.step2_exchange(code='some random code',
+ http=http)
+ self.assertEqual(credentials.id_token, body)
+
+
+class FlowFromCachedClientsecrets(unittest2.TestCase):
+
+ def test_flow_from_clientsecrets_cached(self):
+ cache_mock = CacheMock()
+ load_and_cache('client_secrets.json', 'some_secrets', cache_mock)
+
+ flow = client.flow_from_clientsecrets(
+ 'some_secrets', '', redirect_uri='oob', cache=cache_mock)
+ self.assertEqual('foo_client_secret', flow.client_secret)
+
+ @mock.patch('oauth2client.clientsecrets.loadfile')
+ def _flow_from_clientsecrets_success_helper(self, loadfile_mock,
+ device_uri=None,
+ revoke_uri=None):
+ client_type = clientsecrets.TYPE_WEB
+ client_info = {
+ 'auth_uri': 'auth_uri',
+ 'token_uri': 'token_uri',
+ 'client_id': 'client_id',
+ 'client_secret': 'client_secret',
+ }
+ if revoke_uri is not None:
+ client_info['revoke_uri'] = revoke_uri
+ loadfile_mock.return_value = client_type, client_info
+ filename = object()
+ scope = ['baz']
+ cache = object()
+
+ if device_uri is not None:
+ result = client.flow_from_clientsecrets(
+ filename, scope, cache=cache, device_uri=device_uri)
+ self.assertEqual(result.device_uri, device_uri)
+ else:
+ result = client.flow_from_clientsecrets(
+ filename, scope, cache=cache)
+
+ self.assertIsInstance(result, client.OAuth2WebServerFlow)
+ loadfile_mock.assert_called_once_with(filename, cache=cache)
+
+ def test_flow_from_clientsecrets_success(self):
+ self._flow_from_clientsecrets_success_helper()
+
+ def test_flow_from_clientsecrets_success_w_device_uri(self):
+ device_uri = 'http://device.uri'
+ self._flow_from_clientsecrets_success_helper(device_uri=device_uri)
+
+ def test_flow_from_clientsecrets_success_w_revoke_uri(self):
+ revoke_uri = 'http://revoke.uri'
+ self._flow_from_clientsecrets_success_helper(revoke_uri=revoke_uri)
+
+ @mock.patch('oauth2client.clientsecrets.loadfile',
+ side_effect=clientsecrets.InvalidClientSecretsError)
+ def test_flow_from_clientsecrets_invalid(self, loadfile_mock):
+ filename = object()
+ cache = object()
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ client.flow_from_clientsecrets(
+ filename, None, cache=cache, message=None)
+ loadfile_mock.assert_called_once_with(filename, cache=cache)
+
+ @mock.patch('oauth2client.clientsecrets.loadfile',
+ side_effect=clientsecrets.InvalidClientSecretsError)
+ @mock.patch('sys.exit')
+ def test_flow_from_clientsecrets_invalid_w_msg(self, sys_exit,
+ loadfile_mock):
+ filename = object()
+ cache = object()
+ message = 'hi mom'
+
+ client.flow_from_clientsecrets(
+ filename, None, cache=cache, message=message)
+ sys_exit.assert_called_once_with(message)
+ loadfile_mock.assert_called_once_with(filename, cache=cache)
+
+ @mock.patch('oauth2client.clientsecrets.loadfile',
+ side_effect=clientsecrets.InvalidClientSecretsError('foobar'))
+ @mock.patch('sys.exit')
+ def test_flow_from_clientsecrets_invalid_w_msg_and_text(self, sys_exit,
+ loadfile_mock):
+ filename = object()
+ cache = object()
+ message = 'hi mom'
+ expected = ('The client secrets were invalid: '
+ '\n{0}\n{1}'.format('foobar', 'hi mom'))
+
+ client.flow_from_clientsecrets(
+ filename, None, cache=cache, message=message)
+ sys_exit.assert_called_once_with(expected)
+ loadfile_mock.assert_called_once_with(filename, cache=cache)
+
+ @mock.patch('oauth2client.clientsecrets.loadfile')
+ def test_flow_from_clientsecrets_unknown_flow(self, loadfile_mock):
+ client_type = 'UNKNOWN'
+ loadfile_mock.return_value = client_type, None
+ filename = object()
+ cache = object()
+
+ err_msg = ('This OAuth 2.0 flow is unsupported: '
+ '{0!r}'.format(client_type))
+ with self.assertRaisesRegexp(client.UnknownClientSecretsFlowError,
+ err_msg):
+ client.flow_from_clientsecrets(filename, None, cache=cache)
+
+ loadfile_mock.assert_called_once_with(filename, cache=cache)
+
+
+class CredentialsFromCodeTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.client_id = 'client_id_abc'
+ self.client_secret = 'secret_use_code'
+ self.scope = 'foo'
+ self.code = '12345abcde'
+ self.redirect_uri = 'postmessage'
+
+ def test_exchange_code_for_token(self):
+ token = 'asdfghjkl'
+ payload = json.dumps({'access_token': token, 'expires_in': 3600})
+ http = HttpMockSequence([
+ ({'status': '200'}, payload.encode('utf-8')),
+ ])
+ credentials = client.credentials_from_code(
+ self.client_id, self.client_secret, self.scope,
+ self.code, http=http, redirect_uri=self.redirect_uri)
+ self.assertEqual(credentials.access_token, token)
+ self.assertNotEqual(None, credentials.token_expiry)
+ self.assertEqual(set(['foo']), credentials.scopes)
+
+ def test_exchange_code_for_token_fail(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b'{"error":"invalid_request"}'),
+ ])
+
+ with self.assertRaises(client.FlowExchangeError):
+ client.credentials_from_code(
+ self.client_id, self.client_secret, self.scope,
+ self.code, http=http, redirect_uri=self.redirect_uri)
+
+ def test_exchange_code_and_file_for_token(self):
+ payload = (b'{'
+ b' "access_token":"asdfghjkl",'
+ b' "expires_in":3600'
+ b'}')
+ http = HttpMockSequence([({'status': '200'}, payload)])
+ credentials = client.credentials_from_clientsecrets_and_code(
+ datafile('client_secrets.json'), self.scope,
+ self.code, http=http)
+ self.assertEqual(credentials.access_token, 'asdfghjkl')
+ self.assertNotEqual(None, credentials.token_expiry)
+ self.assertEqual(set(['foo']), credentials.scopes)
+
+ def test_exchange_code_and_cached_file_for_token(self):
+ http = HttpMockSequence([
+ ({'status': '200'}, b'{ "access_token":"asdfghjkl"}'),
+ ])
+ cache_mock = CacheMock()
+ load_and_cache('client_secrets.json', 'some_secrets', cache_mock)
+
+ credentials = client.credentials_from_clientsecrets_and_code(
+ 'some_secrets', self.scope,
+ self.code, http=http, cache=cache_mock)
+ self.assertEqual(credentials.access_token, 'asdfghjkl')
+ self.assertEqual(set(['foo']), credentials.scopes)
+
+ def test_exchange_code_and_file_for_token_fail(self):
+ http = HttpMockSequence([
+ ({'status': '400'}, b'{"error":"invalid_request"}'),
+ ])
+
+ with self.assertRaises(client.FlowExchangeError):
+ client.credentials_from_clientsecrets_and_code(
+ datafile('client_secrets.json'), self.scope,
+ self.code, http=http)
+
+
+class Test__save_private_file(unittest2.TestCase):
+
+ def _save_helper(self, filename):
+ contents = []
+ contents_str = '[]'
+ client._save_private_file(filename, contents)
+ with open(filename, 'r') as f:
+ stored_contents = f.read()
+ self.assertEqual(stored_contents, contents_str)
+
+ stat_mode = os.stat(filename).st_mode
+ # Octal 777, only last 3 positions matter for permissions mask.
+ stat_mode &= 0o777
+ self.assertEqual(stat_mode, 0o600)
+
+ def test_new(self):
+ filename = tempfile.mktemp()
+ self.assertFalse(os.path.exists(filename))
+ self._save_helper(filename)
+
+ def test_existing(self):
+ filename = tempfile.mktemp()
+ with open(filename, 'w') as f:
+ f.write('a bunch of nonsense longer than []')
+ self.assertTrue(os.path.exists(filename))
+ self._save_helper(filename)
+
+
+class Test__get_application_default_credential_GAE(unittest2.TestCase):
+
+ @mock.patch.dict('sys.modules', {
+ 'oauth2client.contrib.appengine': mock.Mock()})
+ def test_it(self):
+ gae_mod = sys.modules['oauth2client.contrib.appengine']
+ gae_mod.AppAssertionCredentials = creds_kls = mock.Mock()
+ creds_kls.return_value = object()
+ credentials = client._get_application_default_credential_GAE()
+ self.assertEqual(credentials, creds_kls.return_value)
+ creds_kls.assert_called_once_with([])
+
+
+class Test__get_application_default_credential_GCE(unittest2.TestCase):
+
+ @mock.patch.dict('sys.modules', {
+ 'oauth2client.contrib.gce': mock.Mock()})
+ def test_it(self):
+ gce_mod = sys.modules['oauth2client.contrib.gce']
+ gce_mod.AppAssertionCredentials = creds_kls = mock.Mock()
+ creds_kls.return_value = object()
+ credentials = client._get_application_default_credential_GCE()
+ self.assertEqual(credentials, creds_kls.return_value)
+ creds_kls.assert_called_once_with()
+
+
+class Test__require_crypto_or_die(unittest2.TestCase):
+
+ @mock.patch.object(client, 'HAS_CRYPTO', new=True)
+ def test_with_crypto(self):
+ self.assertIsNone(client._require_crypto_or_die())
+
+ @mock.patch.object(client, 'HAS_CRYPTO', new=False)
+ def test_without_crypto(self):
+ with self.assertRaises(client.CryptoUnavailableError):
+ client._require_crypto_or_die()
+
+
+class TestDeviceFlowInfo(unittest2.TestCase):
+
+ DEVICE_CODE = 'e80ff179-fd65-416c-9dbf-56a23e5d23e4'
+ USER_CODE = '4bbd8b82-fc73-11e5-adf3-00c2c63e5792'
+ VER_URL = 'http://foo.bar'
+
+ def test_FromResponse(self):
+ response = {
+ 'device_code': self.DEVICE_CODE,
+ 'user_code': self.USER_CODE,
+ 'verification_url': self.VER_URL,
+ }
+ result = client.DeviceFlowInfo.FromResponse(response)
+ expected_result = client.DeviceFlowInfo(
+ self.DEVICE_CODE, self.USER_CODE, None, self.VER_URL, None)
+ self.assertEqual(result, expected_result)
+
+ def test_FromResponse_fallback_to_uri(self):
+ response = {
+ 'device_code': self.DEVICE_CODE,
+ 'user_code': self.USER_CODE,
+ 'verification_uri': self.VER_URL,
+ }
+ result = client.DeviceFlowInfo.FromResponse(response)
+ expected_result = client.DeviceFlowInfo(
+ self.DEVICE_CODE, self.USER_CODE, None, self.VER_URL, None)
+ self.assertEqual(result, expected_result)
+
+ def test_FromResponse_missing_url(self):
+ response = {
+ 'device_code': self.DEVICE_CODE,
+ 'user_code': self.USER_CODE,
+ }
+ with self.assertRaises(client.OAuth2DeviceCodeError):
+ client.DeviceFlowInfo.FromResponse(response)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_FromResponse_with_expires_in(self, utcnow):
+ expires_in = 23
+ response = {
+ 'device_code': self.DEVICE_CODE,
+ 'user_code': self.USER_CODE,
+ 'verification_url': self.VER_URL,
+ 'expires_in': expires_in,
+ }
+ now = datetime.datetime(1999, 1, 1, 12, 30, 27)
+ expire = datetime.datetime(1999, 1, 1, 12, 30, 27 + expires_in)
+ utcnow.return_value = now
+
+ result = client.DeviceFlowInfo.FromResponse(response)
+ expected_result = client.DeviceFlowInfo(
+ self.DEVICE_CODE, self.USER_CODE, None, self.VER_URL, expire)
+ self.assertEqual(result, expected_result)
diff --git a/tests/test_clientsecrets.py b/tests/test_clientsecrets.py
new file mode 100644
index 0000000..42eb8c7
--- /dev/null
+++ b/tests/test_clientsecrets.py
@@ -0,0 +1,280 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for oauth2client.clientsecrets."""
+
+import errno
+from io import StringIO
+import os
+import tempfile
+
+import unittest2
+
+import oauth2client
+from oauth2client import _helpers
+from oauth2client import clientsecrets
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+DATA_DIR = os.path.join(os.path.dirname(__file__), 'data')
+VALID_FILE = os.path.join(DATA_DIR, 'client_secrets.json')
+INVALID_FILE = os.path.join(DATA_DIR, 'unfilled_client_secrets.json')
+NONEXISTENT_FILE = os.path.join(
+ os.path.dirname(__file__), 'afilethatisntthere.json')
+
+
+class Test__validate_clientsecrets(unittest2.TestCase):
+
+ def test_with_none(self):
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(None)
+
+ def test_with_other_than_one_key(self):
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets({})
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets({'one': 'val', 'two': 'val'})
+
+ def test_with_non_dictionary(self):
+ non_dict = [None]
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(non_dict)
+
+ def test_invalid_client_type(self):
+ fake_type = 'fake_type'
+ self.assertNotEqual(fake_type, clientsecrets.TYPE_WEB)
+ self.assertNotEqual(fake_type, clientsecrets.TYPE_INSTALLED)
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets({fake_type: None})
+
+ def test_missing_required_type_web(self):
+ required = clientsecrets.VALID_CLIENT[
+ clientsecrets.TYPE_WEB]['required']
+ # We will certainly have less than all 5 keys.
+ self.assertEqual(len(required), 5)
+
+ clientsecrets_dict = {
+ clientsecrets.TYPE_WEB: {'not_required': None},
+ }
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(clientsecrets_dict)
+
+ def test_string_not_configured_type_web(self):
+ string_props = clientsecrets.VALID_CLIENT[
+ clientsecrets.TYPE_WEB]['string']
+
+ self.assertTrue('client_id' in string_props)
+ clientsecrets_dict = {
+ clientsecrets.TYPE_WEB: {
+ 'client_id': '[[template]]',
+ 'client_secret': 'seekrit',
+ 'redirect_uris': None,
+ 'auth_uri': None,
+ 'token_uri': None,
+ },
+ }
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(clientsecrets_dict)
+
+ def test_missing_required_type_installed(self):
+ required = clientsecrets.VALID_CLIENT[
+ clientsecrets.TYPE_INSTALLED]['required']
+ # We will certainly have less than all 5 keys.
+ self.assertEqual(len(required), 5)
+
+ clientsecrets_dict = {
+ clientsecrets.TYPE_INSTALLED: {'not_required': None},
+ }
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(clientsecrets_dict)
+
+ def test_string_not_configured_type_installed(self):
+ string_props = clientsecrets.VALID_CLIENT[
+ clientsecrets.TYPE_INSTALLED]['string']
+
+ self.assertTrue('client_id' in string_props)
+ clientsecrets_dict = {
+ clientsecrets.TYPE_INSTALLED: {
+ 'client_id': '[[template]]',
+ 'client_secret': 'seekrit',
+ 'redirect_uris': None,
+ 'auth_uri': None,
+ 'token_uri': None,
+ },
+ }
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._validate_clientsecrets(clientsecrets_dict)
+
+ def test_success_type_web(self):
+ client_info = {
+ 'client_id': 'eye-dee',
+ 'client_secret': 'seekrit',
+ 'redirect_uris': None,
+ 'auth_uri': None,
+ 'token_uri': None,
+ }
+ clientsecrets_dict = {
+ clientsecrets.TYPE_WEB: client_info,
+ }
+ result = clientsecrets._validate_clientsecrets(clientsecrets_dict)
+ self.assertEqual(result, (clientsecrets.TYPE_WEB, client_info))
+
+ def test_success_type_installed(self):
+ client_info = {
+ 'client_id': 'eye-dee',
+ 'client_secret': 'seekrit',
+ 'redirect_uris': None,
+ 'auth_uri': None,
+ 'token_uri': None,
+ }
+ clientsecrets_dict = {
+ clientsecrets.TYPE_INSTALLED: client_info,
+ }
+ result = clientsecrets._validate_clientsecrets(clientsecrets_dict)
+ self.assertEqual(result, (clientsecrets.TYPE_INSTALLED, client_info))
+
+
+class Test__loadfile(unittest2.TestCase):
+
+ def test_success(self):
+ client_type, client_info = clientsecrets._loadfile(VALID_FILE)
+ expected_client_info = {
+ 'client_id': 'foo_client_id',
+ 'client_secret': 'foo_client_secret',
+ 'redirect_uris': [],
+ 'auth_uri': oauth2client.GOOGLE_AUTH_URI,
+ 'token_uri': oauth2client.GOOGLE_TOKEN_URI,
+ 'revoke_uri': oauth2client.GOOGLE_REVOKE_URI,
+ }
+ self.assertEqual(client_type, clientsecrets.TYPE_WEB)
+ self.assertEqual(client_info, expected_client_info)
+
+ def test_non_existent(self):
+ path = os.path.join(DATA_DIR, 'fake.json')
+ self.assertFalse(os.path.exists(path))
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets._loadfile(path)
+
+ def test_bad_json(self):
+ filename = tempfile.mktemp()
+ with open(filename, 'wb') as file_obj:
+ file_obj.write(b'[')
+ with self.assertRaises(ValueError):
+ clientsecrets._loadfile(filename)
+
+
+class OAuth2CredentialsTests(unittest2.TestCase):
+
+ def test_validate_error(self):
+ payload = (
+ b'{'
+ b' "web": {'
+ b' "client_id": "[[CLIENT ID REQUIRED]]",'
+ b' "client_secret": "[[CLIENT SECRET REQUIRED]]",'
+ b' "redirect_uris": ["http://localhost:8080/oauth2callback"],'
+ b' "auth_uri": "",'
+ b' "token_uri": ""'
+ b' }'
+ b'}')
+ ERRORS = [
+ ('{}', 'Invalid'),
+ ('{"foo": {}}', 'Unknown'),
+ ('{"web": {}}', 'Missing'),
+ ('{"web": {"client_id": "dkkd"}}', 'Missing'),
+ (payload, 'Property'),
+ ]
+ for src, match in ERRORS:
+ # Ensure that it is unicode
+ src = _helpers._from_bytes(src)
+ # Test load(s)
+ with self.assertRaises(
+ clientsecrets.InvalidClientSecretsError) as exc_manager:
+ clientsecrets.loads(src)
+
+ self.assertTrue(str(exc_manager.exception).startswith(match))
+
+ # Test loads(fp)
+ with self.assertRaises(
+ clientsecrets.InvalidClientSecretsError) as exc_manager:
+ fp = StringIO(src)
+ clientsecrets.load(fp)
+
+ self.assertTrue(str(exc_manager.exception).startswith(match))
+
+ def test_load_by_filename_missing_file(self):
+ with self.assertRaises(
+ clientsecrets.InvalidClientSecretsError) as exc_manager:
+ clientsecrets._loadfile(NONEXISTENT_FILE)
+
+ self.assertEquals(exc_manager.exception.args[1], NONEXISTENT_FILE)
+ self.assertEquals(exc_manager.exception.args[3], errno.ENOENT)
+
+
+class CachedClientsecretsTests(unittest2.TestCase):
+
+ class CacheMock(object):
+ def __init__(self):
+ self.cache = {}
+ self.last_get_ns = None
+ self.last_set_ns = None
+
+ def get(self, key, namespace=''):
+ # ignoring namespace for easier testing
+ self.last_get_ns = namespace
+ return self.cache.get(key, None)
+
+ def set(self, key, value, namespace=''):
+ # ignoring namespace for easier testing
+ self.last_set_ns = namespace
+ self.cache[key] = value
+
+ def setUp(self):
+ self.cache_mock = self.CacheMock()
+
+ def test_cache_miss(self):
+ client_type, client_info = clientsecrets.loadfile(
+ VALID_FILE, cache=self.cache_mock)
+ self.assertEqual('web', client_type)
+ self.assertEqual('foo_client_secret', client_info['client_secret'])
+
+ cached = self.cache_mock.cache[VALID_FILE]
+ self.assertEqual({client_type: client_info}, cached)
+
+ # make sure we're using non-empty namespace
+ ns = self.cache_mock.last_set_ns
+ self.assertTrue(bool(ns))
+ # make sure they're equal
+ self.assertEqual(ns, self.cache_mock.last_get_ns)
+
+ def test_cache_hit(self):
+ self.cache_mock.cache[NONEXISTENT_FILE] = {'web': 'secret info'}
+
+ client_type, client_info = clientsecrets.loadfile(
+ NONEXISTENT_FILE, cache=self.cache_mock)
+ self.assertEqual('web', client_type)
+ self.assertEqual('secret info', client_info)
+ # make sure we didn't do any set() RPCs
+ self.assertEqual(None, self.cache_mock.last_set_ns)
+
+ def test_validation(self):
+ with self.assertRaises(clientsecrets.InvalidClientSecretsError):
+ clientsecrets.loadfile(INVALID_FILE, cache=self.cache_mock)
+
+ def test_without_cache(self):
+ # this also ensures loadfile() is backward compatible
+ client_type, client_info = clientsecrets.loadfile(VALID_FILE)
+ self.assertEqual('web', client_type)
+ self.assertEqual('foo_client_secret', client_info['client_secret'])
diff --git a/tests/test_crypt.py b/tests/test_crypt.py
new file mode 100644
index 0000000..b7534bd
--- /dev/null
+++ b/tests/test_crypt.py
@@ -0,0 +1,313 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+import base64
+import os
+
+import mock
+import unittest2
+
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client import crypt
+from oauth2client import service_account
+
+
+def data_filename(filename):
+ return os.path.join(os.path.dirname(__file__), 'data', filename)
+
+
+def datafile(filename):
+ with open(data_filename(filename), 'rb') as file_obj:
+ return file_obj.read()
+
+
+class Test__bad_pkcs12_key_as_pem(unittest2.TestCase):
+
+ def test_fails(self):
+ with self.assertRaises(NotImplementedError):
+ crypt._bad_pkcs12_key_as_pem()
+
+
+class Test_pkcs12_key_as_pem(unittest2.TestCase):
+
+ def _make_svc_account_creds(self, private_key_file='privatekey.p12'):
+ filename = data_filename(private_key_file)
+ credentials = (
+ service_account.ServiceAccountCredentials.from_p12_keyfile(
+ 'some_account@example.com', filename,
+ scopes='read+write'))
+ credentials._kwargs['sub'] = 'joe@example.org'
+ return credentials
+
+ def _succeeds_helper(self, password=None):
+ self.assertEqual(True, client.HAS_OPENSSL)
+
+ credentials = self._make_svc_account_creds()
+ if password is None:
+ password = credentials._private_key_password
+ pem_contents = crypt.pkcs12_key_as_pem(
+ credentials._private_key_pkcs12, password)
+ pkcs12_key_as_pem = datafile('pem_from_pkcs12.pem')
+ pkcs12_key_as_pem = _helpers._parse_pem_key(pkcs12_key_as_pem)
+ alternate_pem = datafile('pem_from_pkcs12_alternate.pem')
+ self.assertTrue(pem_contents in [pkcs12_key_as_pem, alternate_pem])
+
+ def test_succeeds(self):
+ self._succeeds_helper()
+
+ def test_succeeds_with_unicode_password(self):
+ password = u'notasecret'
+ self._succeeds_helper(password)
+
+
+class Test__verify_signature(unittest2.TestCase):
+
+ def test_success_single_cert(self):
+ cert_value = 'cert-value'
+ certs = [cert_value]
+ message = object()
+ signature = object()
+
+ verifier = mock.MagicMock()
+ verifier.verify = mock.MagicMock(name='verify', return_value=True)
+ with mock.patch('oauth2client.crypt.Verifier') as Verifier:
+ Verifier.from_string = mock.MagicMock(name='from_string',
+ return_value=verifier)
+ result = crypt._verify_signature(message, signature, certs)
+ self.assertEqual(result, None)
+
+ # Make sure our mocks were called as expected.
+ Verifier.from_string.assert_called_once_with(cert_value,
+ is_x509_cert=True)
+ verifier.verify.assert_called_once_with(message, signature)
+
+ def test_success_multiple_certs(self):
+ cert_value1 = 'cert-value1'
+ cert_value2 = 'cert-value2'
+ cert_value3 = 'cert-value3'
+ certs = [cert_value1, cert_value2, cert_value3]
+ message = object()
+ signature = object()
+
+ verifier = mock.MagicMock()
+ # Use side_effect to force all 3 cert values to be used by failing
+ # to verify on the first two.
+ verifier.verify = mock.MagicMock(name='verify',
+ side_effect=[False, False, True])
+ with mock.patch('oauth2client.crypt.Verifier') as Verifier:
+ Verifier.from_string = mock.MagicMock(name='from_string',
+ return_value=verifier)
+ result = crypt._verify_signature(message, signature, certs)
+ self.assertEqual(result, None)
+
+ # Make sure our mocks were called three times.
+ expected_from_string_calls = [
+ mock.call(cert_value1, is_x509_cert=True),
+ mock.call(cert_value2, is_x509_cert=True),
+ mock.call(cert_value3, is_x509_cert=True),
+ ]
+ self.assertEqual(Verifier.from_string.mock_calls,
+ expected_from_string_calls)
+ expected_verify_calls = [mock.call(message, signature)] * 3
+ self.assertEqual(verifier.verify.mock_calls,
+ expected_verify_calls)
+
+ def test_failure(self):
+ cert_value = 'cert-value'
+ certs = [cert_value]
+ message = object()
+ signature = object()
+
+ verifier = mock.MagicMock()
+ verifier.verify = mock.MagicMock(name='verify', return_value=False)
+ with mock.patch('oauth2client.crypt.Verifier') as Verifier:
+ Verifier.from_string = mock.MagicMock(name='from_string',
+ return_value=verifier)
+ with self.assertRaises(crypt.AppIdentityError):
+ crypt._verify_signature(message, signature, certs)
+
+ # Make sure our mocks were called as expected.
+ Verifier.from_string.assert_called_once_with(cert_value,
+ is_x509_cert=True)
+ verifier.verify.assert_called_once_with(message, signature)
+
+
+class Test__check_audience(unittest2.TestCase):
+
+ def test_null_audience(self):
+ result = crypt._check_audience(None, None)
+ self.assertEqual(result, None)
+
+ def test_success(self):
+ audience = 'audience'
+ payload_dict = {'aud': audience}
+ result = crypt._check_audience(payload_dict, audience)
+ # No exception and no result.
+ self.assertEqual(result, None)
+
+ def test_missing_aud(self):
+ audience = 'audience'
+ payload_dict = {}
+ with self.assertRaises(crypt.AppIdentityError):
+ crypt._check_audience(payload_dict, audience)
+
+ def test_wrong_aud(self):
+ audience1 = 'audience1'
+ audience2 = 'audience2'
+ self.assertNotEqual(audience1, audience2)
+ payload_dict = {'aud': audience1}
+ with self.assertRaises(crypt.AppIdentityError):
+ crypt._check_audience(payload_dict, audience2)
+
+
+class Test__verify_time_range(unittest2.TestCase):
+
+ def _exception_helper(self, payload_dict):
+ exception_caught = None
+ try:
+ crypt._verify_time_range(payload_dict)
+ except crypt.AppIdentityError as exc:
+ exception_caught = exc
+
+ return exception_caught
+
+ def test_without_issued_at(self):
+ payload_dict = {}
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'No iat field in token'))
+
+ def test_without_expiration(self):
+ payload_dict = {'iat': 'iat'}
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'No exp field in token'))
+
+ def test_with_bad_token_lifetime(self):
+ current_time = 123456
+ payload_dict = {
+ 'iat': 'iat',
+ 'exp': current_time + crypt.MAX_TOKEN_LIFETIME_SECS + 1,
+ }
+ with mock.patch('oauth2client.crypt.time') as time:
+ time.time = mock.MagicMock(name='time',
+ return_value=current_time)
+
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'exp field too far in future'))
+
+ def test_with_issued_at_in_future(self):
+ current_time = 123456
+ payload_dict = {
+ 'iat': current_time + crypt.CLOCK_SKEW_SECS + 1,
+ 'exp': current_time + crypt.MAX_TOKEN_LIFETIME_SECS - 1,
+ }
+ with mock.patch('oauth2client.crypt.time') as time:
+ time.time = mock.MagicMock(name='time',
+ return_value=current_time)
+
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'Token used too early'))
+
+ def test_with_expiration_in_the_past(self):
+ current_time = 123456
+ payload_dict = {
+ 'iat': current_time,
+ 'exp': current_time - crypt.CLOCK_SKEW_SECS - 1,
+ }
+ with mock.patch('oauth2client.crypt.time') as time:
+ time.time = mock.MagicMock(name='time',
+ return_value=current_time)
+
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'Token used too late'))
+
+ def test_success(self):
+ current_time = 123456
+ payload_dict = {
+ 'iat': current_time,
+ 'exp': current_time + crypt.MAX_TOKEN_LIFETIME_SECS - 1,
+ }
+ with mock.patch('oauth2client.crypt.time') as time:
+ time.time = mock.MagicMock(name='time',
+ return_value=current_time)
+
+ exception_caught = self._exception_helper(payload_dict)
+ self.assertEqual(exception_caught, None)
+
+
+class Test_verify_signed_jwt_with_certs(unittest2.TestCase):
+
+ def test_jwt_no_segments(self):
+ exception_caught = None
+ try:
+ crypt.verify_signed_jwt_with_certs(b'', None)
+ except crypt.AppIdentityError as exc:
+ exception_caught = exc
+
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'Wrong number of segments in token'))
+
+ def test_jwt_payload_bad_json(self):
+ header = signature = b''
+ payload = base64.b64encode(b'{BADJSON')
+ jwt = b'.'.join([header, payload, signature])
+
+ exception_caught = None
+ try:
+ crypt.verify_signed_jwt_with_certs(jwt, None)
+ except crypt.AppIdentityError as exc:
+ exception_caught = exc
+
+ self.assertNotEqual(exception_caught, None)
+ self.assertTrue(str(exception_caught).startswith(
+ 'Can\'t parse token'))
+
+ @mock.patch('oauth2client.crypt._check_audience')
+ @mock.patch('oauth2client.crypt._verify_time_range')
+ @mock.patch('oauth2client.crypt._verify_signature')
+ def test_success(self, verify_sig, verify_time, check_aud):
+ certs = mock.MagicMock()
+ cert_values = object()
+ certs.values = mock.MagicMock(name='values',
+ return_value=cert_values)
+ audience = object()
+
+ header = b'header'
+ signature_bytes = b'signature'
+ signature = base64.b64encode(signature_bytes)
+ payload_dict = {'a': 'b'}
+ payload = base64.b64encode(b'{"a": "b"}')
+ jwt = b'.'.join([header, payload, signature])
+
+ result = crypt.verify_signed_jwt_with_certs(
+ jwt, certs, audience=audience)
+ self.assertEqual(result, payload_dict)
+
+ message_to_sign = header + b'.' + payload
+ verify_sig.assert_called_once_with(
+ message_to_sign, signature_bytes, cert_values)
+ verify_time.assert_called_once_with(payload_dict)
+ check_aud.assert_called_once_with(payload_dict, audience)
+ certs.values.assert_called_once_with()
diff --git a/tests/test_file.py b/tests/test_file.py
new file mode 100644
index 0000000..924acb4
--- /dev/null
+++ b/tests/test_file.py
@@ -0,0 +1,243 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Oauth2client.file tests
+
+Unit tests for oauth2client.file
+"""
+
+import copy
+import datetime
+import json
+import os
+import pickle
+import stat
+import tempfile
+
+import six
+from six.moves import http_client
+import unittest2
+
+from oauth2client import client
+from oauth2client import file
+from .http_mock import HttpMockSequence
+
+try:
+ # Python2
+ from future_builtins import oct
+except: # pragma: NO COVER
+ pass
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+_filehandle, FILENAME = tempfile.mkstemp('oauth2client_test.data')
+os.close(_filehandle)
+
+
+class OAuth2ClientFileTests(unittest2.TestCase):
+
+ def tearDown(self):
+ try:
+ os.unlink(FILENAME)
+ except OSError:
+ pass
+
+ def setUp(self):
+ try:
+ os.unlink(FILENAME)
+ except OSError:
+ pass
+
+ def _create_test_credentials(self, client_id='some_client_id',
+ expiration=None):
+ access_token = 'foo'
+ client_secret = 'cOuDdkfjxxnv+'
+ refresh_token = '1/0/a.df219fjls0'
+ token_expiry = expiration or datetime.datetime.utcnow()
+ token_uri = 'https://www.google.com/accounts/o8/oauth2/token'
+ user_agent = 'refresh_checker/1.0'
+
+ credentials = client.OAuth2Credentials(
+ access_token, client_id, client_secret,
+ refresh_token, token_expiry, token_uri,
+ user_agent)
+ return credentials
+
+ def test_non_existent_file_storage(self):
+ s = file.Storage(FILENAME)
+ credentials = s.get()
+ self.assertEquals(None, credentials)
+
+ @unittest2.skipIf(not hasattr(os, 'symlink'), 'No symlink available')
+ def test_no_sym_link_credentials(self):
+ SYMFILENAME = FILENAME + '.sym'
+ os.symlink(FILENAME, SYMFILENAME)
+ s = file.Storage(SYMFILENAME)
+ try:
+ with self.assertRaises(file.CredentialsFileSymbolicLinkError):
+ s.get()
+ finally:
+ os.unlink(SYMFILENAME)
+
+ def test_pickle_and_json_interop(self):
+ # Write a file with a pickled OAuth2Credentials.
+ credentials = self._create_test_credentials()
+
+ f = open(FILENAME, 'wb')
+ pickle.dump(credentials, f)
+ f.close()
+
+ # Storage should be not be able to read that object, as the capability
+ # to read and write credentials as pickled objects has been removed.
+ s = file.Storage(FILENAME)
+ read_credentials = s.get()
+ self.assertEquals(None, read_credentials)
+
+ # Now write it back out and confirm it has been rewritten as JSON
+ s.put(credentials)
+ with open(FILENAME) as f:
+ data = json.load(f)
+
+ self.assertEquals(data['access_token'], 'foo')
+ self.assertEquals(data['_class'], 'OAuth2Credentials')
+ self.assertEquals(data['_module'], client.OAuth2Credentials.__module__)
+
+ def test_token_refresh_store_expired(self):
+ expiration = (datetime.datetime.utcnow() -
+ datetime.timedelta(minutes=15))
+ credentials = self._create_test_credentials(expiration=expiration)
+
+ s = file.Storage(FILENAME)
+ s.put(credentials)
+ credentials = s.get()
+ new_cred = copy.copy(credentials)
+ new_cred.access_token = 'bar'
+ s.put(new_cred)
+
+ access_token = '1/3w'
+ token_response = {'access_token': access_token, 'expires_in': 3600}
+ http = HttpMockSequence([
+ ({'status': '200'}, json.dumps(token_response).encode('utf-8')),
+ ])
+
+ credentials._refresh(http.request)
+ self.assertEquals(credentials.access_token, access_token)
+
+ def test_token_refresh_store_expires_soon(self):
+ # Tests the case where an access token that is valid when it is read
+ # from the store expires before the original request succeeds.
+ expiration = (datetime.datetime.utcnow() +
+ datetime.timedelta(minutes=15))
+ credentials = self._create_test_credentials(expiration=expiration)
+
+ s = file.Storage(FILENAME)
+ s.put(credentials)
+ credentials = s.get()
+ new_cred = copy.copy(credentials)
+ new_cred.access_token = 'bar'
+ s.put(new_cred)
+
+ access_token = '1/3w'
+ token_response = {'access_token': access_token, 'expires_in': 3600}
+ http = HttpMockSequence([
+ ({'status': str(int(http_client.UNAUTHORIZED))},
+ b'Initial token expired'),
+ ({'status': str(int(http_client.UNAUTHORIZED))},
+ b'Store token expired'),
+ ({'status': str(int(http_client.OK))},
+ json.dumps(token_response).encode('utf-8')),
+ ({'status': str(int(http_client.OK))},
+ b'Valid response to original request')
+ ])
+
+ credentials.authorize(http)
+ http.request('https://example.com')
+ self.assertEqual(credentials.access_token, access_token)
+
+ def test_token_refresh_good_store(self):
+ expiration = (datetime.datetime.utcnow() +
+ datetime.timedelta(minutes=15))
+ credentials = self._create_test_credentials(expiration=expiration)
+
+ s = file.Storage(FILENAME)
+ s.put(credentials)
+ credentials = s.get()
+ new_cred = copy.copy(credentials)
+ new_cred.access_token = 'bar'
+ s.put(new_cred)
+
+ credentials._refresh(None)
+ self.assertEquals(credentials.access_token, 'bar')
+
+ def test_token_refresh_stream_body(self):
+ expiration = (datetime.datetime.utcnow() +
+ datetime.timedelta(minutes=15))
+ credentials = self._create_test_credentials(expiration=expiration)
+
+ s = file.Storage(FILENAME)
+ s.put(credentials)
+ credentials = s.get()
+ new_cred = copy.copy(credentials)
+ new_cred.access_token = 'bar'
+ s.put(new_cred)
+
+ valid_access_token = '1/3w'
+ token_response = {'access_token': valid_access_token,
+ 'expires_in': 3600}
+ http = HttpMockSequence([
+ ({'status': str(int(http_client.UNAUTHORIZED))},
+ b'Initial token expired'),
+ ({'status': str(int(http_client.UNAUTHORIZED))},
+ b'Store token expired'),
+ ({'status': str(int(http_client.OK))},
+ json.dumps(token_response).encode('utf-8')),
+ ({'status': str(int(http_client.OK))}, 'echo_request_body')
+ ])
+
+ body = six.StringIO('streaming body')
+
+ credentials.authorize(http)
+ _, content = http.request('https://example.com', body=body)
+ self.assertEqual(content, 'streaming body')
+ self.assertEqual(credentials.access_token, valid_access_token)
+
+ def test_credentials_delete(self):
+ credentials = self._create_test_credentials()
+
+ s = file.Storage(FILENAME)
+ s.put(credentials)
+ credentials = s.get()
+ self.assertNotEquals(None, credentials)
+ s.delete()
+ credentials = s.get()
+ self.assertEquals(None, credentials)
+
+ def test_access_token_credentials(self):
+ access_token = 'foo'
+ user_agent = 'refresh_checker/1.0'
+
+ credentials = client.AccessTokenCredentials(access_token, user_agent)
+
+ s = file.Storage(FILENAME)
+ credentials = s.put(credentials)
+ credentials = s.get()
+
+ self.assertNotEquals(None, credentials)
+ self.assertEquals('foo', credentials.access_token)
+
+ self.assertTrue(os.path.exists(FILENAME))
+
+ if os.name == 'posix': # pragma: NO COVER
+ mode = os.stat(FILENAME).st_mode
+ self.assertEquals('0o600', oct(stat.S_IMODE(mode)))
diff --git a/tests/test_jwt.py b/tests/test_jwt.py
new file mode 100644
index 0000000..ecc58e8
--- /dev/null
+++ b/tests/test_jwt.py
@@ -0,0 +1,329 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Unit tests for JWT related methods in oauth2client."""
+
+import os
+import tempfile
+import time
+
+import mock
+import unittest2
+
+from oauth2client import _helpers
+from oauth2client import client
+from oauth2client import crypt
+from oauth2client import file
+from oauth2client import service_account
+from .http_mock import HttpMockSequence
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+_FORMATS_TO_CONSTRUCTOR_ARGS = {
+ 'p12': 'private_key_pkcs12',
+ 'pem': 'private_key_pkcs8_pem',
+}
+
+
+def data_filename(filename):
+ return os.path.join(os.path.dirname(__file__), 'data', filename)
+
+
+def datafile(filename):
+ with open(data_filename(filename), 'rb') as file_obj:
+ return file_obj.read()
+
+
+class CryptTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.format_ = 'p12'
+ self.signer = crypt.OpenSSLSigner
+ self.verifier = crypt.OpenSSLVerifier
+
+ def test_sign_and_verify(self):
+ self._check_sign_and_verify('privatekey.' + self.format_)
+
+ def test_sign_and_verify_from_converted_pkcs12(self):
+ # Tests that following instructions to convert from PKCS12 to
+ # PEM works.
+ if self.format_ == 'pem':
+ self._check_sign_and_verify('pem_from_pkcs12.pem')
+
+ def _check_sign_and_verify(self, private_key_file):
+ private_key = datafile(private_key_file)
+ public_key = datafile('public_cert.pem')
+
+ # We pass in a non-bytes password to make sure all branches
+ # are traversed in tests.
+ signer = self.signer.from_string(private_key,
+ password=u'notasecret')
+ signature = signer.sign('foo')
+
+ verifier = self.verifier.from_string(public_key, True)
+ self.assertTrue(verifier.verify(b'foo', signature))
+
+ self.assertFalse(verifier.verify(b'bar', signature))
+ self.assertFalse(verifier.verify(b'foo', b'bad signagure'))
+ self.assertFalse(verifier.verify(b'foo', u'bad signagure'))
+
+ def _check_jwt_failure(self, jwt, expected_error):
+ public_key = datafile('public_cert.pem')
+ certs = {'foo': public_key}
+ audience = ('https://www.googleapis.com/auth/id?client_id='
+ 'external_public_key@testing.gserviceaccount.com')
+
+ with self.assertRaises(crypt.AppIdentityError) as exc_manager:
+ crypt.verify_signed_jwt_with_certs(jwt, certs, audience)
+
+ self.assertTrue(expected_error in str(exc_manager.exception))
+
+ def _create_signed_jwt(self):
+ private_key = datafile('privatekey.' + self.format_)
+ signer = self.signer.from_string(private_key)
+ audience = 'some_audience_address@testing.gserviceaccount.com'
+ now = int(time.time())
+
+ return crypt.make_signed_jwt(signer, {
+ 'aud': audience,
+ 'iat': now,
+ 'exp': now + 300,
+ 'user': 'billy bob',
+ 'metadata': {'meta': 'data'},
+ })
+
+ def test_verify_id_token(self):
+ jwt = self._create_signed_jwt()
+ public_key = datafile('public_cert.pem')
+ certs = {'foo': public_key}
+ audience = 'some_audience_address@testing.gserviceaccount.com'
+ contents = crypt.verify_signed_jwt_with_certs(jwt, certs, audience)
+ self.assertEqual('billy bob', contents['user'])
+ self.assertEqual('data', contents['metadata']['meta'])
+
+ def test_verify_id_token_with_certs_uri(self):
+ jwt = self._create_signed_jwt()
+
+ http = HttpMockSequence([
+ ({'status': '200'}, datafile('certs.json')),
+ ])
+
+ contents = client.verify_id_token(
+ jwt, 'some_audience_address@testing.gserviceaccount.com',
+ http=http)
+ self.assertEqual('billy bob', contents['user'])
+ self.assertEqual('data', contents['metadata']['meta'])
+
+ def test_verify_id_token_with_certs_uri_default_http(self):
+ jwt = self._create_signed_jwt()
+
+ http = HttpMockSequence([
+ ({'status': '200'}, datafile('certs.json')),
+ ])
+
+ with mock.patch('oauth2client.transport._CACHED_HTTP', new=http):
+ contents = client.verify_id_token(
+ jwt, 'some_audience_address@testing.gserviceaccount.com')
+
+ self.assertEqual('billy bob', contents['user'])
+ self.assertEqual('data', contents['metadata']['meta'])
+
+ def test_verify_id_token_with_certs_uri_fails(self):
+ jwt = self._create_signed_jwt()
+ test_email = 'some_audience_address@testing.gserviceaccount.com'
+
+ http = HttpMockSequence([
+ ({'status': '404'}, datafile('certs.json')),
+ ])
+
+ with self.assertRaises(client.VerifyJwtTokenError):
+ client.verify_id_token(jwt, test_email, http=http)
+
+ def test_verify_id_token_bad_tokens(self):
+ private_key = datafile('privatekey.' + self.format_)
+
+ # Wrong number of segments
+ self._check_jwt_failure('foo', 'Wrong number of segments')
+
+ # Not json
+ self._check_jwt_failure('foo.bar.baz', 'Can\'t parse token')
+
+ # Bad signature
+ jwt = b'.'.join([b'foo',
+ _helpers._urlsafe_b64encode('{"a":"b"}'),
+ b'baz'])
+ self._check_jwt_failure(jwt, 'Invalid token signature')
+
+ # No expiration
+ signer = self.signer.from_string(private_key)
+ audience = ('https:#www.googleapis.com/auth/id?client_id='
+ 'external_public_key@testing.gserviceaccount.com')
+ jwt = crypt.make_signed_jwt(signer, {
+ 'aud': audience,
+ 'iat': time.time(),
+ })
+ self._check_jwt_failure(jwt, 'No exp field in token')
+
+ # No issued at
+ jwt = crypt.make_signed_jwt(signer, {
+ 'aud': 'audience',
+ 'exp': time.time() + 400,
+ })
+ self._check_jwt_failure(jwt, 'No iat field in token')
+
+ # Too early
+ jwt = crypt.make_signed_jwt(signer, {
+ 'aud': 'audience',
+ 'iat': time.time() + 301,
+ 'exp': time.time() + 400,
+ })
+ self._check_jwt_failure(jwt, 'Token used too early')
+
+ # Too late
+ jwt = crypt.make_signed_jwt(signer, {
+ 'aud': 'audience',
+ 'iat': time.time() - 500,
+ 'exp': time.time() - 301,
+ })
+ self._check_jwt_failure(jwt, 'Token used too late')
+
+ # Wrong target
+ jwt = crypt.make_signed_jwt(signer, {
+ 'aud': 'somebody else',
+ 'iat': time.time(),
+ 'exp': time.time() + 300,
+ })
+ self._check_jwt_failure(jwt, 'Wrong recipient')
+
+ def test_from_string_non_509_cert(self):
+ # Use a private key instead of a certificate to test the other branch
+ # of from_string().
+ public_key = datafile('privatekey.pem')
+ verifier = self.verifier.from_string(public_key, is_x509_cert=False)
+ self.assertIsInstance(verifier, self.verifier)
+
+
+class PEMCryptTestsPyCrypto(CryptTests):
+
+ def setUp(self):
+ self.format_ = 'pem'
+ self.signer = crypt.PyCryptoSigner
+ self.verifier = crypt.PyCryptoVerifier
+
+
+class PEMCryptTestsOpenSSL(CryptTests):
+
+ def setUp(self):
+ self.format_ = 'pem'
+ self.signer = crypt.OpenSSLSigner
+ self.verifier = crypt.OpenSSLVerifier
+
+
+class SignedJwtAssertionCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.format_ = 'p12'
+ crypt.Signer = crypt.OpenSSLSigner
+
+ def _make_credentials(self):
+ private_key = datafile('privatekey.' + self.format_)
+ signer = crypt.Signer.from_string(private_key)
+ credentials = service_account.ServiceAccountCredentials(
+ 'some_account@example.com', signer,
+ scopes='read+write',
+ sub='joe@example.org')
+ if self.format_ == 'pem':
+ credentials._private_key_pkcs8_pem = private_key
+ elif self.format_ == 'p12':
+ credentials._private_key_pkcs12 = private_key
+ credentials._private_key_password = (
+ service_account._PASSWORD_DEFAULT)
+ else: # pragma: NO COVER
+ raise ValueError('Unexpected format.')
+ return credentials
+
+ def test_credentials_good(self):
+ credentials = self._make_credentials()
+ http = HttpMockSequence([
+ ({'status': '200'}, b'{"access_token":"1/3w","expires_in":3600}'),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = credentials.authorize(http)
+ resp, content = http.request('http://example.org')
+ self.assertEqual(b'Bearer 1/3w', content[b'Authorization'])
+
+ def test_credentials_to_from_json(self):
+ credentials = self._make_credentials()
+ json = credentials.to_json()
+ restored = client.Credentials.new_from_json(json)
+ self.assertEqual(credentials._private_key_pkcs12,
+ restored._private_key_pkcs12)
+ self.assertEqual(credentials._private_key_password,
+ restored._private_key_password)
+ self.assertEqual(credentials._kwargs, restored._kwargs)
+
+ def _credentials_refresh(self, credentials):
+ http = HttpMockSequence([
+ ({'status': '200'}, b'{"access_token":"1/3w","expires_in":3600}'),
+ ({'status': '401'}, b''),
+ ({'status': '200'}, b'{"access_token":"3/3w","expires_in":3600}'),
+ ({'status': '200'}, 'echo_request_headers'),
+ ])
+ http = credentials.authorize(http)
+ _, content = http.request('http://example.org')
+ return content
+
+ def test_credentials_refresh_without_storage(self):
+ credentials = self._make_credentials()
+ content = self._credentials_refresh(credentials)
+ self.assertEqual(b'Bearer 3/3w', content[b'Authorization'])
+
+ def test_credentials_refresh_with_storage(self):
+ credentials = self._make_credentials()
+
+ filehandle, filename = tempfile.mkstemp()
+ os.close(filehandle)
+ store = file.Storage(filename)
+ store.put(credentials)
+ credentials.set_store(store)
+
+ content = self._credentials_refresh(credentials)
+
+ self.assertEqual(b'Bearer 3/3w', content[b'Authorization'])
+ os.unlink(filename)
+
+
+class PEMSignedJwtAssertionCredentialsOpenSSLTests(
+ SignedJwtAssertionCredentialsTests):
+
+ def setUp(self):
+ self.format_ = 'pem'
+ crypt.Signer = crypt.OpenSSLSigner
+
+
+class PEMSignedJwtAssertionCredentialsPyCryptoTests(
+ SignedJwtAssertionCredentialsTests):
+
+ def setUp(self):
+ self.format_ = 'pem'
+ crypt.Signer = crypt.PyCryptoSigner
+
+
+class TestHasOpenSSLFlag(unittest2.TestCase):
+
+ def test_true(self):
+ self.assertEqual(True, client.HAS_OPENSSL)
+ self.assertEqual(True, client.HAS_CRYPTO)
diff --git a/tests/test_service_account.py b/tests/test_service_account.py
new file mode 100644
index 0000000..699e699
--- /dev/null
+++ b/tests/test_service_account.py
@@ -0,0 +1,580 @@
+# Copyright 2014 Google Inc. All rights reserved.
+#
+# 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.
+
+"""Oauth2client tests.
+
+Unit tests for service account credentials implemented using RSA.
+"""
+
+import datetime
+import json
+import os
+import tempfile
+
+import httplib2
+import mock
+import rsa
+from six import BytesIO
+import unittest2
+
+from oauth2client import client
+from oauth2client import crypt
+from oauth2client import service_account
+from .http_mock import HttpMockSequence
+
+
+def data_filename(filename):
+ return os.path.join(os.path.dirname(__file__), 'data', filename)
+
+
+def datafile(filename):
+ with open(data_filename(filename), 'rb') as file_obj:
+ return file_obj.read()
+
+
+class ServiceAccountCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.client_id = '123'
+ self.service_account_email = 'dummy@google.com'
+ self.private_key_id = 'ABCDEF'
+ self.private_key = datafile('pem_from_pkcs12.pem')
+ self.scopes = ['dummy_scope']
+ self.signer = crypt.Signer.from_string(self.private_key)
+ self.credentials = service_account.ServiceAccountCredentials(
+ self.service_account_email,
+ self.signer,
+ private_key_id=self.private_key_id,
+ client_id=self.client_id,
+ )
+
+ def test__to_json_override(self):
+ signer = object()
+ creds = service_account.ServiceAccountCredentials(
+ 'name@email.com', signer)
+ self.assertEqual(creds._signer, signer)
+ # Serialize over-ridden data (unrelated to ``creds``).
+ to_serialize = {'unrelated': 'data'}
+ serialized_str = creds._to_json([], to_serialize.copy())
+ serialized_data = json.loads(serialized_str)
+ expected_serialized = {
+ '_class': 'ServiceAccountCredentials',
+ '_module': 'oauth2client.service_account',
+ 'token_expiry': None,
+ }
+ expected_serialized.update(to_serialize)
+ self.assertEqual(serialized_data, expected_serialized)
+
+ def test_sign_blob(self):
+ private_key_id, signature = self.credentials.sign_blob('Google')
+ self.assertEqual(self.private_key_id, private_key_id)
+
+ pub_key = rsa.PublicKey.load_pkcs1_openssl_pem(
+ datafile('publickey_openssl.pem'))
+
+ self.assertTrue(rsa.pkcs1.verify(b'Google', signature, pub_key))
+
+ with self.assertRaises(rsa.pkcs1.VerificationError):
+ rsa.pkcs1.verify(b'Orest', signature, pub_key)
+ with self.assertRaises(rsa.pkcs1.VerificationError):
+ rsa.pkcs1.verify(b'Google', b'bad signature', pub_key)
+
+ def test_service_account_email(self):
+ self.assertEqual(self.service_account_email,
+ self.credentials.service_account_email)
+
+ @staticmethod
+ def _from_json_keyfile_name_helper(payload, scopes=None,
+ token_uri=None, revoke_uri=None):
+ filehandle, filename = tempfile.mkstemp()
+ os.close(filehandle)
+ try:
+ with open(filename, 'w') as file_obj:
+ json.dump(payload, file_obj)
+ return (
+ service_account.ServiceAccountCredentials
+ .from_json_keyfile_name(
+ filename, scopes=scopes, token_uri=token_uri,
+ revoke_uri=revoke_uri))
+ finally:
+ os.remove(filename)
+
+ @mock.patch('oauth2client.crypt.Signer.from_string',
+ return_value=object())
+ def test_from_json_keyfile_name_factory(self, signer_factory):
+ client_id = 'id123'
+ client_email = 'foo@bar.com'
+ private_key_id = 'pkid456'
+ private_key = 's3kr3tz'
+ payload = {
+ 'type': client.SERVICE_ACCOUNT,
+ 'client_id': client_id,
+ 'client_email': client_email,
+ 'private_key_id': private_key_id,
+ 'private_key': private_key,
+ }
+ scopes = ['foo', 'bar']
+ token_uri = 'baz'
+ revoke_uri = 'qux'
+ base_creds = self._from_json_keyfile_name_helper(
+ payload, scopes=scopes, token_uri=token_uri, revoke_uri=revoke_uri)
+ self.assertEqual(base_creds._signer, signer_factory.return_value)
+ signer_factory.assert_called_once_with(private_key)
+
+ payload['token_uri'] = token_uri
+ payload['revoke_uri'] = revoke_uri
+ creds_with_uris_from_file = self._from_json_keyfile_name_helper(
+ payload, scopes=scopes)
+ for creds in (base_creds, creds_with_uris_from_file):
+ self.assertIsInstance(
+ creds, service_account.ServiceAccountCredentials)
+ self.assertEqual(creds.client_id, client_id)
+ self.assertEqual(creds._service_account_email, client_email)
+ self.assertEqual(creds._private_key_id, private_key_id)
+ self.assertEqual(creds._private_key_pkcs8_pem, private_key)
+ self.assertEqual(creds._scopes, ' '.join(scopes))
+ self.assertEqual(creds.token_uri, token_uri)
+ self.assertEqual(creds.revoke_uri, revoke_uri)
+
+ def test_from_json_keyfile_name_factory_bad_type(self):
+ type_ = 'bad-type'
+ self.assertNotEqual(type_, client.SERVICE_ACCOUNT)
+ payload = {'type': type_}
+ with self.assertRaises(ValueError):
+ self._from_json_keyfile_name_helper(payload)
+
+ def test_from_json_keyfile_name_factory_missing_field(self):
+ payload = {
+ 'type': client.SERVICE_ACCOUNT,
+ 'client_id': 'my-client',
+ }
+ with self.assertRaises(KeyError):
+ self._from_json_keyfile_name_helper(payload)
+
+ def _from_p12_keyfile_helper(self, private_key_password=None, scopes='',
+ token_uri=None, revoke_uri=None):
+ service_account_email = 'name@email.com'
+ filename = data_filename('privatekey.p12')
+ with open(filename, 'rb') as file_obj:
+ key_contents = file_obj.read()
+ creds_from_filename = (
+ service_account.ServiceAccountCredentials.from_p12_keyfile(
+ service_account_email, filename,
+ private_key_password=private_key_password,
+ scopes=scopes, token_uri=token_uri, revoke_uri=revoke_uri))
+ creds_from_file_contents = (
+ service_account.ServiceAccountCredentials.from_p12_keyfile_buffer(
+ service_account_email, BytesIO(key_contents),
+ private_key_password=private_key_password,
+ scopes=scopes, token_uri=token_uri, revoke_uri=revoke_uri))
+ for creds in (creds_from_filename, creds_from_file_contents):
+ self.assertIsInstance(
+ creds, service_account.ServiceAccountCredentials)
+ self.assertIsNone(creds.client_id)
+ self.assertEqual(creds._service_account_email,
+ service_account_email)
+ self.assertIsNone(creds._private_key_id)
+ self.assertIsNone(creds._private_key_pkcs8_pem)
+ self.assertEqual(creds._private_key_pkcs12, key_contents)
+ if private_key_password is not None:
+ self.assertEqual(creds._private_key_password,
+ private_key_password)
+ self.assertEqual(creds._scopes, ' '.join(scopes))
+ self.assertEqual(creds.token_uri, token_uri)
+ self.assertEqual(creds.revoke_uri, revoke_uri)
+
+ def _p12_not_implemented_helper(self):
+ service_account_email = 'name@email.com'
+ filename = data_filename('privatekey.p12')
+ with self.assertRaises(NotImplementedError):
+ service_account.ServiceAccountCredentials.from_p12_keyfile(
+ service_account_email, filename)
+
+ @mock.patch('oauth2client.crypt.Signer', new=crypt.PyCryptoSigner)
+ def test_from_p12_keyfile_with_pycrypto(self):
+ self._p12_not_implemented_helper()
+
+ @mock.patch('oauth2client.crypt.Signer', new=crypt.RsaSigner)
+ def test_from_p12_keyfile_with_rsa(self):
+ self._p12_not_implemented_helper()
+
+ def test_from_p12_keyfile_defaults(self):
+ self._from_p12_keyfile_helper()
+
+ def test_from_p12_keyfile_explicit(self):
+ password = 'notasecret'
+ self._from_p12_keyfile_helper(private_key_password=password,
+ scopes=['foo', 'bar'],
+ token_uri='baz', revoke_uri='qux')
+
+ def test_create_scoped_required_without_scopes(self):
+ self.assertTrue(self.credentials.create_scoped_required())
+
+ def test_create_scoped_required_with_scopes(self):
+ signer = object()
+ self.credentials = service_account.ServiceAccountCredentials(
+ self.service_account_email,
+ signer,
+ scopes=self.scopes,
+ private_key_id=self.private_key_id,
+ client_id=self.client_id,
+ )
+ self.assertFalse(self.credentials.create_scoped_required())
+
+ def test_create_scoped(self):
+ new_credentials = self.credentials.create_scoped(self.scopes)
+ self.assertNotEqual(self.credentials, new_credentials)
+ self.assertIsInstance(new_credentials,
+ service_account.ServiceAccountCredentials)
+ self.assertEqual('dummy_scope', new_credentials._scopes)
+
+ def test_create_delegated(self):
+ signer = object()
+ sub = 'foo@email.com'
+ creds = service_account.ServiceAccountCredentials(
+ 'name@email.com', signer)
+ self.assertNotIn('sub', creds._kwargs)
+ delegated_creds = creds.create_delegated(sub)
+ self.assertEqual(delegated_creds._kwargs['sub'], sub)
+ # Make sure the original is unchanged.
+ self.assertNotIn('sub', creds._kwargs)
+
+ def test_create_delegated_existing_sub(self):
+ signer = object()
+ sub1 = 'existing@email.com'
+ sub2 = 'new@email.com'
+ creds = service_account.ServiceAccountCredentials(
+ 'name@email.com', signer, sub=sub1)
+ self.assertEqual(creds._kwargs['sub'], sub1)
+ delegated_creds = creds.create_delegated(sub2)
+ self.assertEqual(delegated_creds._kwargs['sub'], sub2)
+ # Make sure the original is unchanged.
+ self.assertEqual(creds._kwargs['sub'], sub1)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_access_token(self, utcnow):
+ # Configure the patch.
+ seconds = 11
+ NOW = datetime.datetime(1992, 12, 31, second=seconds)
+ utcnow.return_value = NOW
+
+ # Create a custom credentials with a mock signer.
+ signer = mock.MagicMock()
+ signed_value = b'signed-content'
+ signer.sign = mock.MagicMock(name='sign',
+ return_value=signed_value)
+ credentials = service_account.ServiceAccountCredentials(
+ self.service_account_email,
+ signer,
+ private_key_id=self.private_key_id,
+ client_id=self.client_id,
+ )
+
+ # Begin testing.
+ lifetime = 2 # number of seconds in which the token expires
+ EXPIRY_TIME = datetime.datetime(1992, 12, 31,
+ second=seconds + lifetime)
+
+ token1 = u'first_token'
+ token_response_first = {
+ 'access_token': token1,
+ 'expires_in': lifetime,
+ }
+ token2 = u'second_token'
+ token_response_second = {
+ 'access_token': token2,
+ 'expires_in': lifetime,
+ }
+ http = HttpMockSequence([
+ ({'status': '200'},
+ json.dumps(token_response_first).encode('utf-8')),
+ ({'status': '200'},
+ json.dumps(token_response_second).encode('utf-8')),
+ ])
+
+ # Get Access Token, First attempt.
+ self.assertIsNone(credentials.access_token)
+ self.assertFalse(credentials.access_token_expired)
+ self.assertIsNone(credentials.token_expiry)
+ token = credentials.get_access_token(http=http)
+ self.assertEqual(credentials.token_expiry, EXPIRY_TIME)
+ self.assertEqual(token1, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertEqual(token_response_first,
+ credentials.token_response)
+ # Two utcnow calls are expected:
+ # - get_access_token() -> _do_refresh_request (setting expires in)
+ # - get_access_token() -> _expires_in()
+ expected_utcnow_calls = [mock.call()] * 2
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+ # One call to sign() expected: Actual refresh was needed.
+ self.assertEqual(len(signer.sign.mock_calls), 1)
+
+ # Get Access Token, Second Attempt (not expired)
+ self.assertEqual(credentials.access_token, token1)
+ self.assertFalse(credentials.access_token_expired)
+ token = credentials.get_access_token(http=http)
+ # Make sure no refresh occurred since the token was not expired.
+ self.assertEqual(token1, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertEqual(token_response_first, credentials.token_response)
+ # Three more utcnow calls are expected:
+ # - access_token_expired
+ # - get_access_token() -> access_token_expired
+ # - get_access_token -> _expires_in
+ expected_utcnow_calls = [mock.call()] * (2 + 3)
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+ # No call to sign() expected: the token was not expired.
+ self.assertEqual(len(signer.sign.mock_calls), 1 + 0)
+
+ # Get Access Token, Third Attempt (force expiration)
+ self.assertEqual(credentials.access_token, token1)
+ credentials.token_expiry = NOW # Manually force expiry.
+ self.assertTrue(credentials.access_token_expired)
+ token = credentials.get_access_token(http=http)
+ # Make sure refresh occurred since the token was not expired.
+ self.assertEqual(token2, token.access_token)
+ self.assertEqual(lifetime, token.expires_in)
+ self.assertFalse(credentials.access_token_expired)
+ self.assertEqual(token_response_second,
+ credentials.token_response)
+ # Five more utcnow calls are expected:
+ # - access_token_expired
+ # - get_access_token -> access_token_expired
+ # - get_access_token -> _do_refresh_request
+ # - get_access_token -> _expires_in
+ # - access_token_expired
+ expected_utcnow_calls = [mock.call()] * (2 + 3 + 5)
+ self.assertEqual(expected_utcnow_calls, utcnow.mock_calls)
+ # One more call to sign() expected: Actual refresh was needed.
+ self.assertEqual(len(signer.sign.mock_calls), 1 + 0 + 1)
+
+ self.assertEqual(credentials.access_token, token2)
+
+TOKEN_LIFE = service_account._JWTAccessCredentials._MAX_TOKEN_LIFETIME_SECS
+T1 = 42
+T1_DATE = datetime.datetime(1970, 1, 1, second=T1)
+T1_EXPIRY = T1 + TOKEN_LIFE
+T1_EXPIRY_DATE = T1_DATE + datetime.timedelta(seconds=TOKEN_LIFE)
+
+T2 = T1 + 100
+T2_DATE = T1_DATE + datetime.timedelta(seconds=100)
+T2_EXPIRY = T2 + TOKEN_LIFE
+T2_EXPIRY_DATE = T2_DATE + datetime.timedelta(seconds=TOKEN_LIFE)
+
+T3 = T1 + TOKEN_LIFE + 1
+T3_DATE = T1_DATE + datetime.timedelta(seconds=TOKEN_LIFE + 1)
+T3_EXPIRY = T3 + TOKEN_LIFE
+T3_EXPIRY_DATE = T3_DATE + datetime.timedelta(seconds=TOKEN_LIFE)
+
+
+class JWTAccessCredentialsTests(unittest2.TestCase):
+
+ def setUp(self):
+ self.client_id = '123'
+ self.service_account_email = 'dummy@google.com'
+ self.private_key_id = 'ABCDEF'
+ self.private_key = datafile('pem_from_pkcs12.pem')
+ self.signer = crypt.Signer.from_string(self.private_key)
+ self.url = 'https://test.url.com'
+ self.jwt = service_account._JWTAccessCredentials(
+ self.service_account_email, self.signer,
+ private_key_id=self.private_key_id, client_id=self.client_id,
+ additional_claims={'aud': self.url})
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ @mock.patch('time.time')
+ def test_get_access_token_no_claims(self, time, utcnow):
+ utcnow.return_value = T1_DATE
+ time.return_value = T1
+
+ token_info = self.jwt.get_access_token()
+ payload = crypt.verify_signed_jwt_with_certs(
+ token_info.access_token,
+ {'key': datafile('public_cert.pem')}, audience=self.url)
+ self.assertEqual(payload['iss'], self.service_account_email)
+ self.assertEqual(payload['sub'], self.service_account_email)
+ self.assertEqual(payload['iat'], T1)
+ self.assertEqual(payload['exp'], T1_EXPIRY)
+ self.assertEqual(token_info.expires_in, T1_EXPIRY - T1)
+
+ # Verify that we vend the same token after 100 seconds
+ utcnow.return_value = T2_DATE
+ token_info = self.jwt.get_access_token()
+ payload = crypt.verify_signed_jwt_with_certs(
+ token_info.access_token,
+ {'key': datafile('public_cert.pem')}, audience=self.url)
+ self.assertEqual(payload['iat'], T1)
+ self.assertEqual(payload['exp'], T1_EXPIRY)
+ self.assertEqual(token_info.expires_in, T1_EXPIRY - T2)
+
+ # Verify that we vend a new token after _MAX_TOKEN_LIFETIME_SECS
+ utcnow.return_value = T3_DATE
+ time.return_value = T3
+ token_info = self.jwt.get_access_token()
+ payload = crypt.verify_signed_jwt_with_certs(
+ token_info.access_token,
+ {'key': datafile('public_cert.pem')}, audience=self.url)
+ expires_in = token_info.expires_in
+ self.assertEqual(payload['iat'], T3)
+ self.assertEqual(payload['exp'], T3_EXPIRY)
+ self.assertEqual(expires_in, T3_EXPIRY - T3)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ @mock.patch('time.time')
+ def test_get_access_token_additional_claims(self, time, utcnow):
+ utcnow.return_value = T1_DATE
+ time.return_value = T1
+
+ token_info = self.jwt.get_access_token(
+ additional_claims={'aud': 'https://test2.url.com',
+ 'sub': 'dummy2@google.com'
+ })
+ payload = crypt.verify_signed_jwt_with_certs(
+ token_info.access_token,
+ {'key': datafile('public_cert.pem')},
+ audience='https://test2.url.com')
+ expires_in = token_info.expires_in
+ self.assertEqual(payload['iss'], self.service_account_email)
+ self.assertEqual(payload['sub'], 'dummy2@google.com')
+ self.assertEqual(payload['iat'], T1)
+ self.assertEqual(payload['exp'], T1_EXPIRY)
+ self.assertEqual(expires_in, T1_EXPIRY - T1)
+
+ def test_revoke(self):
+ self.jwt.revoke(None)
+
+ def test_create_scoped_required(self):
+ self.assertTrue(self.jwt.create_scoped_required())
+
+ def test_create_scoped(self):
+ self.jwt._private_key_pkcs12 = ''
+ self.jwt._private_key_password = ''
+
+ new_credentials = self.jwt.create_scoped('dummy_scope')
+ self.assertNotEqual(self.jwt, new_credentials)
+ self.assertIsInstance(
+ new_credentials, service_account.ServiceAccountCredentials)
+ self.assertEqual('dummy_scope', new_credentials._scopes)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ @mock.patch('time.time')
+ def test_authorize_success(self, time, utcnow):
+ utcnow.return_value = T1_DATE
+ time.return_value = T1
+
+ def mock_request(uri, method='GET', body=None, headers=None,
+ redirections=0, connection_type=None):
+ self.assertEqual(uri, self.url)
+ bearer, token = headers[b'Authorization'].split()
+ payload = crypt.verify_signed_jwt_with_certs(
+ token,
+ {'key': datafile('public_cert.pem')},
+ audience=self.url)
+ self.assertEqual(payload['iss'], self.service_account_email)
+ self.assertEqual(payload['sub'], self.service_account_email)
+ self.assertEqual(payload['iat'], T1)
+ self.assertEqual(payload['exp'], T1_EXPIRY)
+ self.assertEqual(uri, self.url)
+ self.assertEqual(bearer, b'Bearer')
+ return (httplib2.Response({'status': '200'}), b'')
+
+ h = httplib2.Http()
+ h.request = mock_request
+ self.jwt.authorize(h)
+ h.request(self.url)
+
+ # Ensure we use the cached token
+ utcnow.return_value = T2_DATE
+ h.request(self.url)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ @mock.patch('time.time')
+ def test_authorize_no_aud(self, time, utcnow):
+ utcnow.return_value = T1_DATE
+ time.return_value = T1
+
+ jwt = service_account._JWTAccessCredentials(
+ self.service_account_email, self.signer,
+ private_key_id=self.private_key_id, client_id=self.client_id)
+
+ def mock_request(uri, method='GET', body=None, headers=None,
+ redirections=0, connection_type=None):
+ self.assertEqual(uri, self.url)
+ bearer, token = headers[b'Authorization'].split()
+ payload = crypt.verify_signed_jwt_with_certs(
+ token,
+ {'key': datafile('public_cert.pem')},
+ audience=self.url)
+ self.assertEqual(payload['iss'], self.service_account_email)
+ self.assertEqual(payload['sub'], self.service_account_email)
+ self.assertEqual(payload['iat'], T1)
+ self.assertEqual(payload['exp'], T1_EXPIRY)
+ self.assertEqual(uri, self.url)
+ self.assertEqual(bearer, b'Bearer')
+ return httplib2.Response({'status': '200'}), b''
+
+ h = httplib2.Http()
+ h.request = mock_request
+ jwt.authorize(h)
+ h.request(self.url)
+
+ # Ensure we do not cache the token
+ self.assertIsNone(jwt.access_token)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_authorize_stale_token(self, utcnow):
+ utcnow.return_value = T1_DATE
+ # Create an initial token
+ h = HttpMockSequence([({'status': '200'}, b''),
+ ({'status': '200'}, b'')])
+ self.jwt.authorize(h)
+ h.request(self.url)
+ token_1 = self.jwt.access_token
+
+ # Expire the token
+ utcnow.return_value = T3_DATE
+ h.request(self.url)
+ token_2 = self.jwt.access_token
+ self.assertEquals(self.jwt.token_expiry, T3_EXPIRY_DATE)
+ self.assertNotEqual(token_1, token_2)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_authorize_401(self, utcnow):
+ utcnow.return_value = T1_DATE
+
+ h = HttpMockSequence([
+ ({'status': '200'}, b''),
+ ({'status': '401'}, b''),
+ ({'status': '200'}, b'')])
+ self.jwt.authorize(h)
+ h.request(self.url)
+ token_1 = self.jwt.access_token
+
+ utcnow.return_value = T2_DATE
+ self.assertEquals(h.request(self.url)[0].status, 200)
+ token_2 = self.jwt.access_token
+ # Check the 401 forced a new token
+ self.assertNotEqual(token_1, token_2)
+
+ @mock.patch('oauth2client.client._UTCNOW')
+ def test_refresh(self, utcnow):
+ utcnow.return_value = T1_DATE
+ token_1 = self.jwt.access_token
+
+ utcnow.return_value = T2_DATE
+ self.jwt.refresh(None)
+ token_2 = self.jwt.access_token
+ self.assertEquals(self.jwt.token_expiry, T2_EXPIRY_DATE)
+ self.assertNotEqual(token_1, token_2)
diff --git a/tests/test_tools.py b/tests/test_tools.py
new file mode 100644
index 0000000..369f567
--- /dev/null
+++ b/tests/test_tools.py
@@ -0,0 +1,192 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import socket
+import sys
+import threading
+
+import mock
+from six.moves.urllib import request
+import unittest2
+
+from oauth2client import client
+from oauth2client import tools
+
+try:
+ import argparse
+except ImportError: # pragma: NO COVER
+ raise unittest2.SkipTest('argparase unavailable.')
+
+
+class TestClientRedirectServer(unittest2.TestCase):
+ """Test the ClientRedirectServer and ClientRedirectHandler classes."""
+
+ def test_ClientRedirectServer(self):
+ # create a ClientRedirectServer and run it in a thread to listen
+ # for a mock GET request with the access token
+ # the server should return a 200 message and store the token
+ httpd = tools.ClientRedirectServer(('localhost', 0),
+ tools.ClientRedirectHandler)
+ code = 'foo'
+ url = 'http://localhost:{0}?code={1}'.format(
+ httpd.server_address[1], code)
+ t = threading.Thread(target=httpd.handle_request)
+ t.setDaemon(True)
+ t.start()
+ f = request.urlopen(url)
+ self.assertTrue(f.read())
+ t.join()
+ httpd.server_close()
+ self.assertEqual(httpd.query_params.get('code'), code)
+
+
+class TestRunFlow(unittest2.TestCase):
+
+ def setUp(self):
+ self.server = mock.Mock()
+ self.flow = mock.Mock()
+ self.storage = mock.Mock()
+ self.credentials = mock.Mock()
+
+ self.flow.step1_get_authorize_url.return_value = (
+ 'http://example.com/auth')
+ self.flow.step2_exchange.return_value = self.credentials
+
+ self.flags = argparse.Namespace(
+ noauth_local_webserver=True, logging_level='INFO')
+ self.server_flags = argparse.Namespace(
+ noauth_local_webserver=False,
+ logging_level='INFO',
+ auth_host_port=[8080, ],
+ auth_host_name='localhost')
+
+ @mock.patch.object(sys, 'argv', ['ignored', '--noauth_local_webserver'])
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.input')
+ def test_run_flow_no_webserver(self, input_mock, logging_mock):
+ input_mock.return_value = 'auth_code'
+
+ # Successful exchange.
+ returned_credentials = tools.run_flow(self.flow, self.storage)
+
+ self.assertEqual(self.credentials, returned_credentials)
+ self.assertEqual(self.flow.redirect_uri, client.OOB_CALLBACK_URN)
+ self.flow.step2_exchange.assert_called_once_with(
+ 'auth_code', http=None)
+ self.storage.put.assert_called_once_with(self.credentials)
+ self.credentials.set_store.assert_called_once_with(self.storage)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.input')
+ def test_run_flow_no_webserver_explicit_flags(
+ self, input_mock, logging_mock):
+ input_mock.return_value = 'auth_code'
+
+ # Successful exchange.
+ returned_credentials = tools.run_flow(
+ self.flow, self.storage, flags=self.flags)
+
+ self.assertEqual(self.credentials, returned_credentials)
+ self.assertEqual(self.flow.redirect_uri, client.OOB_CALLBACK_URN)
+ self.flow.step2_exchange.assert_called_once_with(
+ 'auth_code', http=None)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.input')
+ def test_run_flow_no_webserver_exchange_error(
+ self, input_mock, logging_mock):
+ input_mock.return_value = 'auth_code'
+ self.flow.step2_exchange.side_effect = client.FlowExchangeError()
+
+ # Error while exchanging.
+ with self.assertRaises(SystemExit):
+ tools.run_flow(self.flow, self.storage, flags=self.flags)
+
+ self.flow.step2_exchange.assert_called_once_with(
+ 'auth_code', http=None)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.ClientRedirectServer')
+ @mock.patch('webbrowser.open')
+ def test_run_flow_webserver(
+ self, webbrowser_open_mock, server_ctor_mock, logging_mock):
+ server_ctor_mock.return_value = self.server
+ self.server.query_params = {'code': 'auth_code'}
+
+ # Successful exchange.
+ returned_credentials = tools.run_flow(
+ self.flow, self.storage, flags=self.server_flags)
+
+ self.assertEqual(self.credentials, returned_credentials)
+ self.assertEqual(self.flow.redirect_uri, 'http://localhost:8080/')
+ self.flow.step2_exchange.assert_called_once_with(
+ 'auth_code', http=None)
+ self.storage.put.assert_called_once_with(self.credentials)
+ self.credentials.set_store.assert_called_once_with(self.storage)
+ self.assertTrue(self.server.handle_request.called)
+ webbrowser_open_mock.assert_called_once_with(
+ 'http://example.com/auth', autoraise=True, new=1)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.ClientRedirectServer')
+ @mock.patch('webbrowser.open')
+ def test_run_flow_webserver_exchange_error(
+ self, webbrowser_open_mock, server_ctor_mock, logging_mock):
+ server_ctor_mock.return_value = self.server
+ self.server.query_params = {'error': 'any error'}
+
+ # Exchange returned an error code.
+ with self.assertRaises(SystemExit):
+ tools.run_flow(self.flow, self.storage, flags=self.server_flags)
+
+ self.assertTrue(self.server.handle_request.called)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.ClientRedirectServer')
+ @mock.patch('webbrowser.open')
+ def test_run_flow_webserver_no_code(
+ self, webbrowser_open_mock, server_ctor_mock, logging_mock):
+ server_ctor_mock.return_value = self.server
+ self.server.query_params = {}
+
+ # No code found in response
+ with self.assertRaises(SystemExit):
+ tools.run_flow(self.flow, self.storage, flags=self.server_flags)
+
+ self.assertTrue(self.server.handle_request.called)
+
+ @mock.patch('oauth2client.tools.logging')
+ @mock.patch('oauth2client.tools.ClientRedirectServer')
+ @mock.patch('oauth2client.tools.input')
+ def test_run_flow_webserver_fallback(
+ self, input_mock, server_ctor_mock, logging_mock):
+ server_ctor_mock.side_effect = socket.error()
+ input_mock.return_value = 'auth_code'
+
+ # It should catch the socket error and proceed as if
+ # noauth_local_webserver was specified.
+ returned_credentials = tools.run_flow(
+ self.flow, self.storage, flags=self.server_flags)
+
+ self.assertEqual(self.credentials, returned_credentials)
+ self.assertEqual(self.flow.redirect_uri, client.OOB_CALLBACK_URN)
+ self.flow.step2_exchange.assert_called_once_with(
+ 'auth_code', http=None)
+ self.assertTrue(server_ctor_mock.called)
+ self.assertFalse(self.server.handle_request.called)
+
+
+class TestMessageIfMissing(unittest2.TestCase):
+ def test_message_if_missing(self):
+ self.assertIn('somefile.txt', tools.message_if_missing('somefile.txt'))
diff --git a/tests/test_transport.py b/tests/test_transport.py
new file mode 100644
index 0000000..e9782a8
--- /dev/null
+++ b/tests/test_transport.py
@@ -0,0 +1,131 @@
+# Copyright 2016 Google Inc. All rights reserved.
+#
+# 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.
+
+import httplib2
+import mock
+import unittest2
+
+from oauth2client import client
+from oauth2client import transport
+
+
+class TestMemoryCache(unittest2.TestCase):
+
+ def test_get_set_delete(self):
+ cache = transport.MemoryCache()
+ self.assertIsNone(cache.get('foo'))
+ self.assertIsNone(cache.delete('foo'))
+ cache.set('foo', 'bar')
+ self.assertEqual('bar', cache.get('foo'))
+ cache.delete('foo')
+ self.assertIsNone(cache.get('foo'))
+
+
+class Test_get_cached_http(unittest2.TestCase):
+
+ def test_global(self):
+ cached_http = transport.get_cached_http()
+ self.assertIsInstance(cached_http, httplib2.Http)
+ self.assertIsInstance(cached_http.cache, transport.MemoryCache)
+
+ def test_value(self):
+ cache = object()
+ with mock.patch('oauth2client.transport._CACHED_HTTP', new=cache):
+ result = transport.get_cached_http()
+ self.assertIs(result, cache)
+
+
+class Test_get_http_object(unittest2.TestCase):
+
+ @mock.patch.object(httplib2, 'Http', return_value=object())
+ def test_it(self, http_klass):
+ result = transport.get_http_object()
+ self.assertEqual(result, http_klass.return_value)
+
+
+class Test__initialize_headers(unittest2.TestCase):
+
+ def test_null(self):
+ result = transport._initialize_headers(None)
+ self.assertEqual(result, {})
+
+ def test_copy(self):
+ headers = {'a': 1, 'b': 2}
+ result = transport._initialize_headers(headers)
+ self.assertEqual(result, headers)
+ self.assertIsNot(result, headers)
+
+
+class Test__apply_user_agent(unittest2.TestCase):
+
+ def test_null(self):
+ headers = object()
+ result = transport._apply_user_agent(headers, None)
+ self.assertIs(result, headers)
+
+ def test_new_agent(self):
+ headers = {}
+ user_agent = 'foo'
+ result = transport._apply_user_agent(headers, user_agent)
+ self.assertIs(result, headers)
+ self.assertEqual(result, {'user-agent': user_agent})
+
+ def test_append(self):
+ orig_agent = 'bar'
+ headers = {'user-agent': orig_agent}
+ user_agent = 'baz'
+ result = transport._apply_user_agent(headers, user_agent)
+ self.assertIs(result, headers)
+ final_agent = user_agent + ' ' + orig_agent
+ self.assertEqual(result, {'user-agent': final_agent})
+
+
+class Test_clean_headers(unittest2.TestCase):
+
+ def test_no_modify(self):
+ headers = {b'key': b'val'}
+ result = transport.clean_headers(headers)
+ self.assertIsNot(result, headers)
+ self.assertEqual(result, headers)
+
+ def test_cast_unicode(self):
+ headers = {u'key': u'val'}
+ header_bytes = {b'key': b'val'}
+ result = transport.clean_headers(headers)
+ self.assertIsNot(result, headers)
+ self.assertEqual(result, header_bytes)
+
+ def test_unicode_failure(self):
+ headers = {u'key': u'\u2603'}
+ with self.assertRaises(client.NonAsciiHeaderError):
+ transport.clean_headers(headers)
+
+ def test_cast_object(self):
+ headers = {b'key': True}
+ header_str = {b'key': b'True'}
+ result = transport.clean_headers(headers)
+ self.assertIsNot(result, headers)
+ self.assertEqual(result, header_str)
+
+
+class Test_wrap_http_for_auth(unittest2.TestCase):
+
+ def test_wrap(self):
+ credentials = object()
+ http = mock.Mock()
+ http.request = orig_req_method = object()
+ result = transport.wrap_http_for_auth(credentials, http)
+ self.assertIsNone(result)
+ self.assertNotEqual(http.request, orig_req_method)
+ self.assertIs(http.request.credentials, credentials)
diff --git a/tests/test_util.py b/tests/test_util.py
new file mode 100644
index 0000000..533460f
--- /dev/null
+++ b/tests/test_util.py
@@ -0,0 +1,122 @@
+"""Unit tests for oauth2client.util."""
+
+import mock
+import unittest2
+
+from oauth2client import util
+
+
+__author__ = 'jcgregorio@google.com (Joe Gregorio)'
+
+
+class PositionalTests(unittest2.TestCase):
+
+ def test_usage(self):
+ util.positional_parameters_enforcement = util.POSITIONAL_EXCEPTION
+
+ # 1 positional arg, 1 keyword-only arg.
+ @util.positional(1)
+ def fn(pos, kwonly=None):
+ return True
+
+ self.assertTrue(fn(1))
+ self.assertTrue(fn(1, kwonly=2))
+ with self.assertRaises(TypeError):
+ fn(1, 2)
+
+ # No positional, but a required keyword arg.
+ @util.positional(0)
+ def fn2(required_kw):
+ return True
+
+ self.assertTrue(fn2(required_kw=1))
+ with self.assertRaises(TypeError):
+ fn2(1)
+
+ # Unspecified positional, should automatically figure out 1 positional
+ # 1 keyword-only (same as first case above).
+ @util.positional
+ def fn3(pos, kwonly=None):
+ return True
+
+ self.assertTrue(fn3(1))
+ self.assertTrue(fn3(1, kwonly=2))
+ with self.assertRaises(TypeError):
+ fn3(1, 2)
+
+ @mock.patch('oauth2client.util.logger')
+ def test_enforcement_warning(self, mock_logger):
+ util.positional_parameters_enforcement = util.POSITIONAL_WARNING
+
+ @util.positional(1)
+ def fn(pos, kwonly=None):
+ return True
+
+ self.assertTrue(fn(1, 2))
+ self.assertTrue(mock_logger.warning.called)
+
+ @mock.patch('oauth2client.util.logger')
+ def test_enforcement_ignore(self, mock_logger):
+ util.positional_parameters_enforcement = util.POSITIONAL_IGNORE
+
+ @util.positional(1)
+ def fn(pos, kwonly=None):
+ return True
+
+ self.assertTrue(fn(1, 2))
+ self.assertFalse(mock_logger.warning.called)
+
+
+class ScopeToStringTests(unittest2.TestCase):
+
+ def test_iterables(self):
+ cases = [
+ ('', ''),
+ ('', ()),
+ ('', []),
+ ('', ('',)),
+ ('', ['', ]),
+ ('a', ('a',)),
+ ('b', ['b', ]),
+ ('a b', ['a', 'b']),
+ ('a b', ('a', 'b')),
+ ('a b', 'a b'),
+ ('a b', (s for s in ['a', 'b'])),
+ ]
+ for expected, case in cases:
+ self.assertEqual(expected, util.scopes_to_string(case))
+
+
+class StringToScopeTests(unittest2.TestCase):
+
+ def test_conversion(self):
+ cases = [
+ (['a', 'b'], ['a', 'b']),
+ ('', []),
+ ('a', ['a']),
+ ('a b c d e f', ['a', 'b', 'c', 'd', 'e', 'f']),
+ ]
+
+ for case, expected in cases:
+ self.assertEqual(expected, util.string_to_scopes(case))
+
+
+class AddQueryParameterTests(unittest2.TestCase):
+
+ def test__add_query_parameter(self):
+ self.assertEqual(
+ util._add_query_parameter('/action', 'a', None),
+ '/action')
+ self.assertEqual(
+ util._add_query_parameter('/action', 'a', 'b'),
+ '/action?a=b')
+ self.assertEqual(
+ util._add_query_parameter('/action?a=b', 'a', 'c'),
+ '/action?a=c')
+ # Order is non-deterministic.
+ self.assertIn(
+ util._add_query_parameter('/action?a=b', 'c', 'd'),
+ ['/action?a=b&c=d', '/action?c=d&a=b'])
+ self.assertEqual(
+ util._add_query_parameter('/action', 'a', ' ='),
+ '/action?a=+%3D')
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..b0781a8
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,178 @@
+[tox]
+envlist = py26,py27,py33,py34,py35,pypy,gae,cover
+
+[testenv]
+basedeps = mock>=1.3.0
+ pycrypto>=2.6
+ cryptography>=1.0
+ pyopenssl>=0.14
+ webtest
+ nose
+ flask
+ unittest2
+ sqlalchemy
+ fasteners
+deps = {[testenv]basedeps}
+ django
+ keyring
+setenv =
+ pypy: with_gmp=no
+ DJANGO_SETTINGS_MODULE=tests.contrib.django_util.settings
+commands = nosetests --ignore-files=test_appengine\.py --ignore-files=test__appengine_ndb\.py {posargs}
+
+[coverbase]
+basepython = python2.7
+commands =
+ nosetests \
+ --with-coverage \
+ --cover-package=oauth2client \
+ --cover-package=tests \
+ --cover-erase \
+ --cover-tests \
+ --cover-branches \
+ --ignore-files=test_appengine\.py \
+ --ignore-files=test__appengine_ndb\.py
+ nosetests \
+ --with-coverage \
+ --cover-package=oauth2client.contrib.appengine \
+ --cover-package=oauth2client.contrib._appengine_ndb \
+ --cover-package=tests.contrib.test_appengine \
+ --cover-package=tests.contrib.test__appengine_ndb \
+ --with-gae \
+ --cover-tests \
+ --cover-branches \
+ --gae-application=tests/data \
+ --gae-lib-root={env:GAE_PYTHONPATH:google_appengine} \
+ --logging-level=INFO \
+ tests/contrib/test_appengine.py \
+ tests/contrib/test__appengine_ndb.py
+deps = {[testenv]deps}
+ coverage
+ nosegae
+
+[testenv:py26]
+basepython =
+ python2.6
+commands =
+ nosetests \
+ --ignore-files=test_appengine\.py \
+ --ignore-files=test__appengine_ndb\.py \
+ --ignore-files=test_keyring_storage\.py \
+ --exclude-dir=oauth2client/contrib/django_util \
+ --exclude-dir=tests/contrib/django_util \
+ {posargs}
+deps = {[testenv]basedeps}
+ nose-exclude
+
+[testenv:py33]
+basepython =
+ python3.3
+commands =
+ nosetests \
+ --ignore-files=test_appengine\.py \
+ --ignore-files=test__appengine_ndb\.py \
+ --ignore-files=test_django_orm\.py \
+ --ignore-files=test_django_settings\.py \
+ --ignore-files=test_django_util\.py \
+ --exclude-dir=oauth2client/contrib/django_util \
+ --exclude-dir=tests/contrib/django_util \
+ {posargs}
+deps = {[testenv]basedeps}
+ keyring
+ nose-exclude
+
+[testenv:cover]
+basepython = {[coverbase]basepython}
+commands =
+ {[coverbase]commands}
+ coverage report --show-missing --cover-min-percentage=100
+deps =
+ {[coverbase]deps}
+
+[testenv:coveralls]
+basepython = {[coverbase]basepython}
+commands =
+ {[coverbase]commands}
+ coverage report --show-missing
+ coveralls
+deps =
+ {[coverbase]deps}
+ coveralls
+passenv = {[testenv:system-tests]passenv}
+
+[testenv:docs]
+basepython = python2.7
+deps =
+ {[testenv:cover]deps}
+ python-gflags
+ pyyaml
+ sphinx>=1.3b2
+ sphinx-rtd-theme
+ webapp2
+commands = {toxinidir}/scripts/build_docs.sh
+
+[testenv:gae]
+basepython = python2.7
+deps = {[testenv]basedeps}
+ nosegae
+commands =
+ nosetests \
+ --with-gae \
+ --gae-lib-root={env:GAE_PYTHONPATH:google_appengine} \
+ --gae-application=tests/data \
+ --logging-level=INFO \
+ tests/contrib/test_appengine.py \
+ tests/contrib/test__appengine_ndb.py
+
+[testenv:system-tests]
+basepython =
+ python2.7
+commands =
+ {toxinidir}/scripts/run_system_tests.sh
+deps =
+ pycrypto>=2.6
+ cryptography>=1.0
+ pyopenssl>=0.14
+passenv = GOOGLE_* OAUTH2CLIENT_* TRAVIS*
+
+[testenv:system-tests3]
+basepython =
+ python3.4
+commands =
+ {toxinidir}/scripts/run_system_tests.sh
+deps =
+ pycrypto>=2.6
+ cryptography>=1.0
+ pyopenssl>=0.14
+passenv = {[testenv:system-tests]passenv}
+
+[testenv:gce-system-tests]
+basepython =
+ python2.7
+commands =
+ python {toxinidir}/scripts/run_gce_system_tests.py
+deps =
+ pycrypto>=2.6
+ unittest2
+passenv = {[testenv:system-tests]passenv}
+
+[testenv:flake8]
+commands = flake8 --import-order-style google {posargs}
+deps =
+ flake8-putty
+ flake8-import-order
+
+[flake8]
+exclude = .tox,.git,./*.egg,build,
+application-import-names = oauth2client
+putty-ignore =
+ # E402 module level import not at top of file
+ # These files have needed configurations defined before import
+ docs/conf.py : E402
+ tests/contrib/test_appengine.py : E402
+ # Additionally, ignore E100 (imports in wrong order) for Django configuration
+ tests/contrib/test_django_orm.py : E402,I100
+ # E501 line too long
+ # Ignore lines over 80 chars that include "http:" or "https:"
+ /http:/ : E501
+ /https:/ : E501