diff options
author | Haibo Huang <hhb@google.com> | 2019-02-05 13:12:22 -0800 |
---|---|---|
committer | android-build-merger <android-build-merger@google.com> | 2019-02-05 13:12:22 -0800 |
commit | 6d9ea50ac0a52d4cf8405d1e6a187ffeca09efdc (patch) | |
tree | 317c2b40271ca4aabfd8e2a2454727353928e9e6 | |
parent | 876fb74ec66f981e60e76de4adedab6b7cb56789 (diff) | |
parent | f39f212da02ff1fbcecc54e76a42f77ae4f31665 (diff) | |
download | oauth2client-6d9ea50ac0a52d4cf8405d1e6a187ffeca09efdc.tar.gz |
Upgrade oauth2client to v4.1.3 am: 5acb77a57d am: cb79ec1592
am: f39f212da0
Change-Id: I78f9b1da6abee9e406689673398506ebafdb37d3
134 files changed, 3435 insertions, 3411 deletions
diff --git a/.coveragerc b/.coveragerc index 0151e07..3a3e2cd 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1,6 +1,10 @@ +[run] +branch = True + [report] omit = */samples/* + */conftest.py # Don't report coverage over platform-specific modules. oauth2client/contrib/_fcntl_opener.py oauth2client/contrib/_win32_opener.py diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 0000000..2ce3395 --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1,3 @@ +**Note**: oauth2client is now deprecated. As such, it is unlikely that we will +address or respond to your issue. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..1fbd4d2 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ +**Note**: oauth2client is now deprecated. As such, it is unlikely that we will +review or merge to your pull request. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). @@ -10,6 +10,7 @@ docs/_build # Test files .tox/ +.cache/ # Django test database db.sqlite3 diff --git a/.travis.yml b/.travis.yml index a8c01fa..47570be 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,41 +1,46 @@ 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 + - python: 2.7 + env: TOX_ENV=flake8 + - python: 2.7 + env: TOX_ENV=docs + - python: 2.7 + env: TOX_ENV=gae + - python: 2.7 + env: TOX_ENV=py27 + - python: 3.4 + env: TOX_ENV=py34 + - python: 3.5 + env: TOX_ENV=py35 + - python: 2.7 + env: TOX_ENV=system-tests + - python: 3.4 + env: TOX_ENV=system-tests3 + - python: 2.7 + env: TOX_ENV=cover 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 + - ${HOME}/.pyenv install: - ./scripts/install.sh script: - ./scripts/run.sh after_success: -- if [[ "${TOX_ENV}" == "gae" ]]; then tox -e coveralls; fi +- if [[ "${TOX_ENV}" == "cover" ]]; then coveralls; fi notifications: email: false deploy: provider: pypi user: gcloudpypi + distributions: sdist bdist_wheel password: secure: "C9ImNa5kbdnrQNfX9ww4PUtQIr3tN+nfxl7eDkP1B8Qr0QNYjrjov7x+DLImkKvmoJd3dxYtYIpLE9esObUHu0gKHYxqymNHtuAAyoBOUfPtmp0vIEse9brNKMtaey5Ngk7ZWz9EHKBBqRHxqgN+Giby+K9Ta3K3urJIq6urYhE=" on: diff --git a/CHANGELOG.md b/CHANGELOG.md index bf33dea..2523d29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,84 @@ # CHANGELOG +## v4.1.3 + +**Note**: oauth2client is deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). + +* Changed OAuth2 endpoints to use oauth2.googleapis.com variants. (#742) + +## v4.1.2 + +**Note**: oauth2client is deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). + +Bug fixes: +* Fix packaging issue had erroneously installed the test package. (#688) + +## v4.1.1 + +**Note**: oauth2client is deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). + +New features: +* Allow passing prompt='consent' via the flow_from_clientsecrets. (#717) + +## v4.1.0 + +**Note**: oauth2client is now deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). + +New features: +* Allow customizing the GCE metadata service address via an env var. (#704) +* Store original encoded and signed identity JWT in OAuth2Credentials. (#680) +* Use jsonpickle in django contrib, if available. (#676) + +Bug fixes: +* Typo fixes. (#668, #697) +* Remove b64 padding from PKCE values, per RFC7636. (#683) +* Include LICENSE in Manifest.in. (#694) +* Fix tests and CI. (#705, #712, #713) +* Escape callback error code in flask_util. (#710) + +## v4.0.0 + +New features: +* New Django samples. (#636) +* Add support for RFC7636 PKCE. (#588) +* Release as a universal wheel. (#665) + +Bug fixes: +* Fix django authorization redirect by correctly checking validity of credentials. (#651) +* Correct query loss when using parse_qsl to dict. (#622) +* Switch django models from pickle to jsonpickle. (#614) +* Support new MIDDLEWARE Django 1.10 setting. (#623) +* Remove usage of os.environ.setdefault. (#621) +* Handle missing storage files correctly. (#576) +* Try to revoke token with POST when getting a 405. (#662) + +Internal changes: +* Use transport module for GCE environment check. (#612) +* Remove __author__ lines and add contributors.md. (#627) +* Clean up imports. (#625) +* Use transport.request in tests. (#607) +* Drop unittest2 dependency (#610) +* Remove backslash line continuations. (#608) +* Use transport helpers in system tests. (#606) +* Clean up usage of HTTP mocks in tests. (#605) +* Remove all uses of MagicMock. (#598) +* Migrate test runner to pytest. (#569) +* Merge util.py and _helpers.py. (#579) +* Remove httplib2 imports from non-transport modules. (#577) + +Breaking changes: +* Drop Python 3.3 support. (#603) +* Drop Python 2.6 support. (#590) +* Remove multistore_file. (#589) + ## v3.0.0 * Populate `token_expiry` for GCE credentials. (#473) diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..46b2a08 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,43 @@ +# Contributor Code of Conduct + +As contributors and maintainers of this project, +and in the interest of fostering an open and welcoming community, +we pledge to respect all people who contribute through reporting issues, +posting feature requests, updating documentation, +submitting pull requests or patches, and other activities. + +We are committed to making participation in this project +a harassment-free experience for everyone, +regardless of level of experience, gender, gender identity and expression, +sexual orientation, disability, personal appearance, +body size, race, ethnicity, age, religion, or nationality. + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery +* Personal attacks +* Trolling or insulting/derogatory comments +* Public or private harassment +* Publishing other's private information, +such as physical or electronic +addresses, without explicit permission +* Other unethical or unprofessional conduct. + +Project maintainers have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct. +By adopting this Code of Conduct, +project maintainers commit themselves to fairly and consistently +applying these principles to every aspect of managing this project. +Project maintainers who do not follow or enforce the Code of Conduct +may be permanently removed from the project team. + +This code of conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. + +Instances of abusive, harassing, or otherwise unacceptable behavior +may be reported by opening an issue +or contacting one or more of the project maintainers. + +This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, +available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15b9455..990c534 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,10 +127,6 @@ Running Tests 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 -------------------- @@ -202,7 +198,5 @@ we'll be able to accept your pull requests. [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/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000..00bd09f --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,95 @@ +# Contribors to oauth2client + +## Maintainers + +* [Nathaniel Manista](https://github.com/nathanielmanistaatgoogle) +* [Jon Wayne Parrott](https://github.com/jonparrott) +* [Danny Hermes](https://github.com/dhermes) + +Previous maintainers: + +* [Craig Citro](https://github.com/craigcitro) +* [Joe Gregorio](https://github.com/jcgregorio) + +## Contributors + +This list is generated from git commit authors. + +* aalexand <aalexand@google.com> +* Aaron <aaronwinter@users.noreply.github.com> +* Adam Chainz <me@adamj.eu> +* ade@google.com +* Alexandre Vivien <alx.vivien@gmail.com> +* Ali Afshar <afshar@google.com> +* Andrzej Pragacz <apragacz@o2.pl> +* api.nickm@gmail.com +* Ben Demaree <bendemaree@gmail.com> +* Bill Prin <waprin@gmail.com, waprin@google.com> +* Brendan McCollam <brendan@mccoll.am, bmccollam@uchicago.edu> +* Craig Citro <craigcitro@gmail.com, craigcitro@google.com> +* Dan Ring <dfring@gmail.com> +* Daniel Hermes <dhermes@google.com, daniel.j.hermes@gmail.com> +* Danilo Akamine <danilowz@gmail.com> +* daryl herzmann <akrherz@iastate.edu> +* dlorenc <lorenc.d@gmail.com> +* Dominik Miedziński <dominik@mdzn.pl> +* dr. Kertész Csaba-Zoltán <cskertesz@gmail.com> +* Dustin Farris <dustin@dustinfarris.com> +* Eddie Warner <happyspace@gmail.com> +* Edwin Amsler <EdwinGuy@GMail.com> +* elibixby <elibixby@google.com> +* Emanuele Pucciarelli <ep@acm.org> +* Eric Koleda <eric.koleda@google.com> +* Frederik Creemers <frederikcreemers@gmail.com> +* Guido van Rossum <guido@google.com> +* Harsh Vardhan <harshvd95@gmail.com> +* Herr Kaste <thdz.x@gmx.net> +* INADA Naoki <inada-n@klab.com> +* JacobMoshenko <moshenko@google.com> +* Jay Lee <jay0lee@gmail.com> +* Jed Hartman <jhartman@google.com> +* Jeff Terrace <jterrace@gmail.com, jterrace@google.com> +* Jeffrey Sorensen <sorensenjs@users.noreply.github.com> +* Jeremi Joslin <jeremi@collabspot.com> +* Jin Liu <liujin@google.com> +* Joe Beda <jbeda@google.com> +* Joe Gregorio <jcgregorio@google.com, joe.gregorio@gmail.com> +* Johan Euphrosine <proppy@google.com> +* John Asmuth <jasmuth@gmail.com, jasmuth@google.com> +* John Vandenberg <jayvdb@gmail.com> +* Jon Wayne Parrott <jon.wayne.parrott@gmail.com, jonwayne@google.com> +* Jose Alcerreca <jalc@google.com> +* KCs <cskertesz@gmail.com> +* Keith Maxwell <keith.maxwell@gmail.com> +* Ken Payson <kpayson@google.com> +* Kevin Regan <regank@google.com> +* lraccomando <lraccomando@gmail.com> +* Luar Roji <cyberplant@users.noreply.github.com> +* Luke Blanshard <leadpipe@google.com> +* Marc Cohen <marccohen@google.com> +* Mark Pellegrini <markpell@google.com> +* Martin Trigaux <mat@odoo.com> +* Matt McDonald <mmcdonald@google.com> +* Nathan Naze <nanaze@gmail.com> +* Nathaniel Manista <nathaniel@google.com> +* Orest Bolohan <orest@google.com> +* Pat Ferate <pferate@gmail.com> +* Patrick Costello <pcostello@google.com> +* Rafe Kaplan <rafek@google.com> +* rahulpaul@google.com <rahulpaul@google.com> +* RM Saksida <rsaksida@gmail.com> +* Robert Kaplow <rkaplow@google.com> +* Robert Spies <wilford@google.com> +* Sergei Trofimovich <siarheit@google.com> +* sgomes@google.com <sgomes@google.com> +* Simon Cadman <src@niftiestsoftware.com> +* soltanmm <soltanmm@users.noreply.github.com> +* Sébastien de Melo <sebastien.de-melo@ubicast.eu> +* takuya sato <sato-taku@klab.com> +* thobrla <thobrla@google.com> +* Tom Miller <tom.h.miller@gmail.com> +* Tony Aiuto <aiuto@google.com> +* Travis Hobrla <thobrla@google.com> +* Veres Lajos <vlajos@gmail.com> +* Vivek Seth <vivekseth.m@gmail.com> +* Éamonn McManus <eamonn@mcmanus.net> @@ -1,16 +1,205 @@ - 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 + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ - http://www.apache.org/licenses/LICENSE-2.0 + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - 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. + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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 ================= @@ -18,5 +207,4 @@ 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 index 39f5637..4f2ba45 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,2 @@ -include README.md -recursive-exclude tests * +include README.md LICENSE CHANGELOG.md +recursive-include tests * @@ -1,7 +1,5 @@ name: "oauth2client" -description: - "This is a client library for accessing resources protected by OAuth 2.0." - +description: "This is a client library for accessing resources protected by OAuth 2.0." third_party { url { type: HOMEPAGE @@ -11,6 +9,10 @@ third_party { type: GIT value: "https://github.com/google/oauth2client" } - version: "v3.0.0" - last_upgrade_date { year: 2018 month: 6 day: 6 } + version: "v4.1.3" + last_upgrade_date { + year: 2019 + month: 2 + day: 1 + } } @@ -4,6 +4,10 @@ This is a client library for accessing resources protected by OAuth 2.0. +**Note**: oauth2client is now deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +[google-auth](https://google-auth.readthedocs.io) and [oauthlib](http://oauthlib.readthedocs.io/). For more details on the deprecation, see [oauth2client deprecation](https://google-auth.readthedocs.io/en/latest/oauth2client-deprecation.html). + Installation ============ @@ -23,7 +27,7 @@ agreement. Supported Python Versions ========================= -We support Python 2.6, 2.7, 3.3+. More information [in the docs][2]. +We support Python 2.7 and 3.4+. 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/index.rst b/docs/index.rst index 0543e1a..4b9f38a 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,6 +1,14 @@ oauth2client ============ +.. note:: oauth2client is now deprecated. No more features will be added to the +libraries and the core team is turning down support. We recommend you use +`google-auth`_ and `oauthlib`_. For more details on the deprecation, see `oauth2client deprecation`_. + +.. _google-auth: https://google-auth.readthedocs.io +.. _oauthlib: http://oauthlib.readthedocs.io/ +.. _oauth2client deprecation: https://google-auth.readthedocs.io/en/latest/oauth2client-deprecation.html + *making OAuth2 just a little less painful* ``oauth2client`` makes it easy to interact with OAuth2-protected resources, @@ -107,18 +115,24 @@ contributor license agreement. Supported Python Versions ------------------------- -We support Python 2.6, 2.7, 3.3+. (Whatever this file says, the truth is +We support Python 2.7 and 3.4+. (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: +3.4. 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 +* Prominent projects like `django`_ have `dropped support`_ for earlier + versions (3.3 support dropped in December 2015, and 2.6 support + `dropped`_ in September 2014) .. _open-source projects: http://docs.python-requests.org/en/latest/ .. _Unicode literal support: https://www.python.org/dev/peps/pep-0414/ +.. _django: https://docs.djangoproject.com/ +.. _dropped support: https://docs.djangoproject.com/en/1.9/faq/install/#what-python-version-can-i-use-with-django +.. _dropped: https://docs.djangoproject.com/en/1.7/faq/install/#what-python-version-can-i-use-with-django diff --git a/docs/source/oauth2client.client.rst b/docs/source/oauth2client.client.rst index f3b1832..edb2f97 100644 --- a/docs/source/oauth2client.client.rst +++ b/docs/source/oauth2client.client.rst @@ -1,5 +1,5 @@ -oauth2client.client module -========================== +oauth2client\.client module +=========================== .. automodule:: oauth2client.client :members: diff --git a/docs/source/oauth2client.clientsecrets.rst b/docs/source/oauth2client.clientsecrets.rst index d666564..a839444 100644 --- a/docs/source/oauth2client.clientsecrets.rst +++ b/docs/source/oauth2client.clientsecrets.rst @@ -1,5 +1,5 @@ -oauth2client.clientsecrets module -================================= +oauth2client\.clientsecrets module +================================== .. automodule:: oauth2client.clientsecrets :members: diff --git a/docs/source/oauth2client.contrib.appengine.rst b/docs/source/oauth2client.contrib.appengine.rst index 7f3d5e2..5051495 100644 --- a/docs/source/oauth2client.contrib.appengine.rst +++ b/docs/source/oauth2client.contrib.appengine.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.appengine module -===================================== +oauth2client\.contrib\.appengine module +======================================= .. automodule:: oauth2client.contrib.appengine :members: diff --git a/docs/source/oauth2client.contrib.devshell.rst b/docs/source/oauth2client.contrib.devshell.rst index 20d5c41..66691bd 100644 --- a/docs/source/oauth2client.contrib.devshell.rst +++ b/docs/source/oauth2client.contrib.devshell.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.devshell module -==================================== +oauth2client\.contrib\.devshell module +====================================== .. automodule:: oauth2client.contrib.devshell :members: diff --git a/docs/source/oauth2client.contrib.dictionary_storage.rst b/docs/source/oauth2client.contrib.dictionary_storage.rst index 1b59a2c..b94a079 100644 --- a/docs/source/oauth2client.contrib.dictionary_storage.rst +++ b/docs/source/oauth2client.contrib.dictionary_storage.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.dictionary_storage module -============================================== +oauth2client\.contrib\.dictionary\_storage module +================================================= .. automodule:: oauth2client.contrib.dictionary_storage :members: diff --git a/docs/source/oauth2client.contrib.django_util.apps.rst b/docs/source/oauth2client.contrib.django_util.apps.rst index b7c91ae..1ffe1af 100644 --- a/docs/source/oauth2client.contrib.django_util.apps.rst +++ b/docs/source/oauth2client.contrib.django_util.apps.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.apps module -============================================ +oauth2client\.contrib\.django\_util\.apps module +================================================ .. automodule:: oauth2client.contrib.django_util.apps :members: diff --git a/docs/source/oauth2client.contrib.django_util.decorators.rst b/docs/source/oauth2client.contrib.django_util.decorators.rst index 07350bc..2eb9dcf 100644 --- a/docs/source/oauth2client.contrib.django_util.decorators.rst +++ b/docs/source/oauth2client.contrib.django_util.decorators.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.decorators module -================================================== +oauth2client\.contrib\.django\_util\.decorators module +====================================================== .. automodule:: oauth2client.contrib.django_util.decorators :members: diff --git a/docs/source/oauth2client.contrib.django_util.models.rst b/docs/source/oauth2client.contrib.django_util.models.rst index 4be59d3..91d3b8d 100644 --- a/docs/source/oauth2client.contrib.django_util.models.rst +++ b/docs/source/oauth2client.contrib.django_util.models.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.models module -============================================== +oauth2client\.contrib\.django\_util\.models module +================================================== .. automodule:: oauth2client.contrib.django_util.models :members: diff --git a/docs/source/oauth2client.contrib.django_util.rst b/docs/source/oauth2client.contrib.django_util.rst index f60195a..8247134 100644 --- a/docs/source/oauth2client.contrib.django_util.rst +++ b/docs/source/oauth2client.contrib.django_util.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util package -======================================== +oauth2client\.contrib\.django\_util package +=========================================== Submodules ---------- diff --git a/docs/source/oauth2client.contrib.django_util.signals.rst b/docs/source/oauth2client.contrib.django_util.signals.rst index 70b5d2d..9a18252 100644 --- a/docs/source/oauth2client.contrib.django_util.signals.rst +++ b/docs/source/oauth2client.contrib.django_util.signals.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.signals module -=============================================== +oauth2client\.contrib\.django\_util\.signals module +=================================================== .. automodule:: oauth2client.contrib.django_util.signals :members: diff --git a/docs/source/oauth2client.contrib.django_util.site.rst b/docs/source/oauth2client.contrib.django_util.site.rst index a271b98..5f5dae0 100644 --- a/docs/source/oauth2client.contrib.django_util.site.rst +++ b/docs/source/oauth2client.contrib.django_util.site.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.site module -============================================ +oauth2client\.contrib\.django\_util\.site module +================================================ .. automodule:: oauth2client.contrib.django_util.site :members: diff --git a/docs/source/oauth2client.contrib.django_util.storage.rst b/docs/source/oauth2client.contrib.django_util.storage.rst index 393e738..4340a4c 100644 --- a/docs/source/oauth2client.contrib.django_util.storage.rst +++ b/docs/source/oauth2client.contrib.django_util.storage.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.storage module -=============================================== +oauth2client\.contrib\.django\_util\.storage module +=================================================== .. automodule:: oauth2client.contrib.django_util.storage :members: diff --git a/docs/source/oauth2client.contrib.django_util.views.rst b/docs/source/oauth2client.contrib.django_util.views.rst index 4cbbea0..dfba37f 100644 --- a/docs/source/oauth2client.contrib.django_util.views.rst +++ b/docs/source/oauth2client.contrib.django_util.views.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.django_util.views module -============================================= +oauth2client\.contrib\.django\_util\.views module +================================================= .. automodule:: oauth2client.contrib.django_util.views :members: diff --git a/docs/source/oauth2client.contrib.flask_util.rst b/docs/source/oauth2client.contrib.flask_util.rst index 8ff2355..c11c9ba 100644 --- a/docs/source/oauth2client.contrib.flask_util.rst +++ b/docs/source/oauth2client.contrib.flask_util.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.flask_util module -====================================== +oauth2client\.contrib\.flask\_util module +========================================= .. automodule:: oauth2client.contrib.flask_util :members: diff --git a/docs/source/oauth2client.contrib.gce.rst b/docs/source/oauth2client.contrib.gce.rst index a3748b6..d0b7a15 100644 --- a/docs/source/oauth2client.contrib.gce.rst +++ b/docs/source/oauth2client.contrib.gce.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.gce module -=============================== +oauth2client\.contrib\.gce module +================================= .. automodule:: oauth2client.contrib.gce :members: diff --git a/docs/source/oauth2client.contrib.keyring_storage.rst b/docs/source/oauth2client.contrib.keyring_storage.rst index 0fd7476..286e84a 100644 --- a/docs/source/oauth2client.contrib.keyring_storage.rst +++ b/docs/source/oauth2client.contrib.keyring_storage.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.keyring_storage module -=========================================== +oauth2client\.contrib\.keyring\_storage module +============================================== .. automodule:: oauth2client.contrib.keyring_storage :members: diff --git a/docs/source/oauth2client.contrib.locked_file.rst b/docs/source/oauth2client.contrib.locked_file.rst deleted file mode 100644 index 1076e29..0000000 --- a/docs/source/oauth2client.contrib.locked_file.rst +++ /dev/null @@ -1,7 +0,0 @@ -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 index 6f683a0..eb6c0c0 100644 --- a/docs/source/oauth2client.contrib.multiprocess_file_storage.rst +++ b/docs/source/oauth2client.contrib.multiprocess_file_storage.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.multiprocess_file_storage module -===================================================== +oauth2client\.contrib\.multiprocess\_file\_storage module +========================================================= .. automodule:: oauth2client.contrib.multiprocess_file_storage :members: diff --git a/docs/source/oauth2client.contrib.multistore_file.rst b/docs/source/oauth2client.contrib.multistore_file.rst deleted file mode 100644 index 2787b10..0000000 --- a/docs/source/oauth2client.contrib.multistore_file.rst +++ /dev/null @@ -1,7 +0,0 @@ -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 index 44be6f9..644278d 100644 --- a/docs/source/oauth2client.contrib.rst +++ b/docs/source/oauth2client.contrib.rst @@ -1,5 +1,5 @@ -oauth2client.contrib package -============================ +oauth2client\.contrib package +============================= Subpackages ----------- @@ -19,9 +19,7 @@ Submodules 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 diff --git a/docs/source/oauth2client.contrib.sqlalchemy.rst b/docs/source/oauth2client.contrib.sqlalchemy.rst index 94eeeec..c4a634e 100644 --- a/docs/source/oauth2client.contrib.sqlalchemy.rst +++ b/docs/source/oauth2client.contrib.sqlalchemy.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.sqlalchemy module -====================================== +oauth2client\.contrib\.sqlalchemy module +======================================== .. automodule:: oauth2client.contrib.sqlalchemy :members: diff --git a/docs/source/oauth2client.contrib.xsrfutil.rst b/docs/source/oauth2client.contrib.xsrfutil.rst index dd5e8d6..eec497d 100644 --- a/docs/source/oauth2client.contrib.xsrfutil.rst +++ b/docs/source/oauth2client.contrib.xsrfutil.rst @@ -1,5 +1,5 @@ -oauth2client.contrib.xsrfutil module -==================================== +oauth2client\.contrib\.xsrfutil module +====================================== .. automodule:: oauth2client.contrib.xsrfutil :members: diff --git a/docs/source/oauth2client.crypt.rst b/docs/source/oauth2client.crypt.rst index c3b6acc..d03cf50 100644 --- a/docs/source/oauth2client.crypt.rst +++ b/docs/source/oauth2client.crypt.rst @@ -1,5 +1,5 @@ -oauth2client.crypt module -========================= +oauth2client\.crypt module +========================== .. automodule:: oauth2client.crypt :members: diff --git a/docs/source/oauth2client.file.rst b/docs/source/oauth2client.file.rst index 52a9e94..bf804ff 100644 --- a/docs/source/oauth2client.file.rst +++ b/docs/source/oauth2client.file.rst @@ -1,5 +1,5 @@ -oauth2client.file module -======================== +oauth2client\.file module +========================= .. automodule:: oauth2client.file :members: diff --git a/docs/source/oauth2client.rst b/docs/source/oauth2client.rst index 65de8ac..11f64e4 100644 --- a/docs/source/oauth2client.rst +++ b/docs/source/oauth2client.rst @@ -20,7 +20,6 @@ Submodules oauth2client.service_account oauth2client.tools oauth2client.transport - oauth2client.util Module contents --------------- diff --git a/docs/source/oauth2client.service_account.rst b/docs/source/oauth2client.service_account.rst index 0d3b382..c370246 100644 --- a/docs/source/oauth2client.service_account.rst +++ b/docs/source/oauth2client.service_account.rst @@ -1,5 +1,5 @@ -oauth2client.service_account module -=================================== +oauth2client\.service\_account module +===================================== .. automodule:: oauth2client.service_account :members: diff --git a/docs/source/oauth2client.tools.rst b/docs/source/oauth2client.tools.rst index 240ad52..86be326 100644 --- a/docs/source/oauth2client.tools.rst +++ b/docs/source/oauth2client.tools.rst @@ -1,5 +1,5 @@ -oauth2client.tools module -========================= +oauth2client\.tools module +========================== .. automodule:: oauth2client.tools :members: diff --git a/docs/source/oauth2client.transport.rst b/docs/source/oauth2client.transport.rst index 1c6dbb0..c5440f1 100644 --- a/docs/source/oauth2client.transport.rst +++ b/docs/source/oauth2client.transport.rst @@ -1,5 +1,5 @@ -oauth2client.transport module -============================= +oauth2client\.transport module +============================== .. automodule:: oauth2client.transport :members: diff --git a/docs/source/oauth2client.util.rst b/docs/source/oauth2client.util.rst deleted file mode 100644 index 21dc8c8..0000000 --- a/docs/source/oauth2client.util.rst +++ /dev/null @@ -1,7 +0,0 @@ -oauth2client.util module -======================== - -.. automodule:: oauth2client.util - :members: - :undoc-members: - :show-inheritance: diff --git a/oauth2client/__init__.py b/oauth2client/__init__.py index 28384bb..92bc191 100644 --- a/oauth2client/__init__.py +++ b/oauth2client/__init__.py @@ -14,10 +14,11 @@ """Client library for using OAuth2, especially with Google APIs.""" -__version__ = '3.0.0' +__version__ = '4.1.3' 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' +GOOGLE_DEVICE_URI = 'https://oauth2.googleapis.com/device/code' +GOOGLE_REVOKE_URI = 'https://oauth2.googleapis.com/revoke' +GOOGLE_TOKEN_URI = 'https://oauth2.googleapis.com/token' +GOOGLE_TOKEN_INFO_URI = 'https://oauth2.googleapis.com/tokeninfo' + diff --git a/oauth2client/_helpers.py b/oauth2client/_helpers.py index cb959c5..e912397 100644 --- a/oauth2client/_helpers.py +++ b/oauth2client/_helpers.py @@ -11,12 +11,248 @@ # 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 functools +import inspect import json +import logging +import os +import warnings import six +from six.moves import urllib + + +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 + +_SYM_LINK_MESSAGE = 'File: {0}: Is a symbolic link.' +_IS_DIR_MESSAGE = '{0}: Is a directory' +_MISSING_FILE_MESSAGE = 'Cannot access {0}: No such file or directory' + + +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 + ``_helpers.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 + _helpers.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 [] + elif isinstance(scopes, six.string_types): + return scopes.split(' ') + else: + return scopes + + +def parse_unique_urlencoded(content): + """Parses unique key-value parameters from urlencoded content. + + Args: + content: string, URL-encoded key-value pairs. + + Returns: + dict, The key-value pairs from ``content``. + + Raises: + ValueError: if one of the keys is repeated. + """ + urlencoded_params = urllib.parse.parse_qs(content) + params = {} + for key, value in six.iteritems(urlencoded_params): + if len(value) != 1: + msg = ('URL-encoded content contains a repeated value:' + '%s -> %s' % (key, ', '.join(value))) + raise ValueError(msg) + params[key] = value[0] + return params + + +def update_query_params(uri, params): + """Updates a URI with new query parameters. + + If a given key from ``params`` is repeated in the ``uri``, then + the URI will be considered invalid and an error will occur. + + If the URI is valid, then each value from ``params`` will + replace the corresponding value in the query parameters (if + it exists). + + 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 = parse_unique_urlencoded(parts.query) + query_params.update(params) + new_query = urllib.parse.urlencode(query_params) + new_parts = parts._replace(query=new_query) + return urllib.parse.urlunparse(new_parts) + + +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: + return update_query_params(url, {name: value}) + + +def validate_file(filename): + if os.path.islink(filename): + raise IOError(_SYM_LINK_MESSAGE.format(filename)) + elif os.path.isdir(filename): + raise IOError(_IS_DIR_MESSAGE.format(filename)) + elif not os.path.isfile(filename): + warnings.warn(_MISSING_FILE_MESSAGE.format(filename)) def _parse_pem_key(raw_key_input): diff --git a/oauth2client/_pkce.py b/oauth2client/_pkce.py new file mode 100644 index 0000000..e4952d8 --- /dev/null +++ b/oauth2client/_pkce.py @@ -0,0 +1,67 @@ +# 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. + +""" +Utility functions for implementing Proof Key for Code Exchange (PKCE) by OAuth +Public Clients + +See RFC7636. +""" + +import base64 +import hashlib +import os + + +def code_verifier(n_bytes=64): + """ + Generates a 'code_verifier' as described in section 4.1 of RFC 7636. + + This is a 'high-entropy cryptographic random string' that will be + impractical for an attacker to guess. + + Args: + n_bytes: integer between 31 and 96, inclusive. default: 64 + number of bytes of entropy to include in verifier. + + Returns: + Bytestring, representing urlsafe base64-encoded random data. + """ + verifier = base64.urlsafe_b64encode(os.urandom(n_bytes)).rstrip(b'=') + # https://tools.ietf.org/html/rfc7636#section-4.1 + # minimum length of 43 characters and a maximum length of 128 characters. + if len(verifier) < 43: + raise ValueError("Verifier too short. n_bytes must be > 30.") + elif len(verifier) > 128: + raise ValueError("Verifier too long. n_bytes must be < 97.") + else: + return verifier + + +def code_challenge(verifier): + """ + Creates a 'code_challenge' as described in section 4.2 of RFC 7636 + by taking the sha256 hash of the verifier and then urlsafe + base64-encoding it. + + Args: + verifier: bytestring, representing a code_verifier as generated by + code_verifier(). + + Returns: + Bytestring, representing a urlsafe base64-encoded sha256 hash digest, + without '=' padding. + """ + digest = hashlib.sha256(verifier).digest() + return base64.urlsafe_b64encode(digest).rstrip(b'=') diff --git a/oauth2client/client.py b/oauth2client/client.py index 8956443..7618960 100644 --- a/oauth2client/client.py +++ b/oauth2client/client.py @@ -34,13 +34,11 @@ from six.moves import urllib import oauth2client from oauth2client import _helpers +from oauth2client import _pkce 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: @@ -100,20 +98,20 @@ AccessTokenInfo = collections.namedtuple( 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') +NO_GCE_CHECK = os.getenv('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')) + GCE_METADATA_TIMEOUT = int(os.getenv('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' +_GCE_METADATA_URI = 'http://' + os.getenv('GCE_METADATA_IP', '169.254.169.254') +_METADATA_FLAVOR_HEADER = 'metadata-flavor' # lowercase header _DESIRED_METADATA_FLAVOR = 'Google' +_GCE_HEADERS = {_METADATA_FLAVOR_HEADER: _DESIRED_METADATA_FLAVOR} # Expose utcnow() at module level to allow for # easier testing (by replacing with a stub). @@ -440,23 +438,6 @@ class Storage(object): 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. @@ -466,11 +447,11 @@ class OAuth2Credentials(Credentials): OAuth2Credentials objects may be safely pickled and unpickled. """ - @util.positional(8) + @_helpers.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): + token_info_uri=None, id_token_jwt=None): """Create an instance of OAuth2Credentials. This constructor is not usually called by the user, instead @@ -493,8 +474,11 @@ class OAuth2Credentials(Credentials): 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. + token_info_uri: string, the URI for the token info endpoint. + Defaults to None; scopes can not be refreshed if + this is None. + id_token_jwt: string, the encoded and signed identity JWT. The + decoded version of this is stored in id_token. Notes: store: callable, A callable that when passed a Credential @@ -512,8 +496,9 @@ class OAuth2Credentials(Credentials): self.user_agent = user_agent self.revoke_uri = revoke_uri self.id_token = id_token + self.id_token_jwt = id_token_jwt self.token_response = token_response - self.scopes = set(util.string_to_scopes(scopes or [])) + self.scopes = set(_helpers.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 @@ -557,7 +542,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the refresh request. """ - self._refresh(http.request) + self._refresh(http) def revoke(self, http): """Revokes a refresh_token and makes the credentials void. @@ -566,7 +551,7 @@ class OAuth2Credentials(Credentials): http: httplib2.Http, an http object to be used to make the revoke request. """ - self._revoke(http.request) + self._revoke(http) def apply(self, headers): """Add the authorization to the headers. @@ -592,7 +577,7 @@ class OAuth2Credentials(Credentials): not have scopes. In both cases, you can use refresh_scopes() to obtain the canonical set of scopes. """ - scopes = util.string_to_scopes(scopes) + scopes = _helpers.string_to_scopes(scopes) return set(scopes).issubset(self.scopes) def retrieve_scopes(self, http): @@ -607,7 +592,7 @@ class OAuth2Credentials(Credentials): Returns: A set of strings containing the canonical list of scopes. """ - self._retrieve_scopes(http.request) + self._retrieve_scopes(http) return self.scopes @classmethod @@ -640,6 +625,7 @@ class OAuth2Credentials(Credentials): data['user_agent'], revoke_uri=data.get('revoke_uri', None), id_token=data.get('id_token', None), + id_token_jwt=data.get('id_token_jwt', None), token_response=data.get('token_response', None), scopes=data.get('scopes', None), token_info_uri=data.get('token_info_uri', None)) @@ -746,7 +732,7 @@ class OAuth2Credentials(Credentials): return headers - def _refresh(self, http_request): + def _refresh(self, http): """Refreshes the access_token. This method first checks by reading the Storage object if available. @@ -754,15 +740,13 @@ class OAuth2Credentials(Credentials): refresh is completed. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - refresh request. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. """ if not self.store: - self._do_refresh_request(http_request) + self._do_refresh_request(http) else: self.store.acquire_lock() try: @@ -774,17 +758,15 @@ class OAuth2Credentials(Credentials): logger.info('Updated access_token read from Storage') self._updateFromCredential(new_cred) else: - self._do_refresh_request(http_request) + self._do_refresh_request(http) finally: self.store.release_lock() - def _do_refresh_request(self, http_request): + def _do_refresh_request(self, http): """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. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. @@ -793,8 +775,9 @@ class OAuth2Credentials(Credentials): headers = self._generate_refresh_request_headers() logger.info('Refreshing access_token') - resp, content = http_request( - self.token_uri, method='POST', body=body, headers=headers) + resp, content = transport.request( + http, self.token_uri, method='POST', + body=body, headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: d = json.loads(content) @@ -808,8 +791,10 @@ class OAuth2Credentials(Credentials): self.token_expiry = None if 'id_token' in d: self.id_token = _extract_id_token(d['id_token']) + self.id_token_jwt = d['id_token'] else: self.id_token = None + self.id_token_jwt = None # On temporary refresh errors, the user does not actually have to # re-authorize, so we unflag here. self.invalid = False @@ -819,7 +804,7 @@ class OAuth2Credentials(Credentials): # 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']) + error_msg = 'Invalid response {0}.'.format(resp.status) try: d = json.loads(content) if 'error' in d: @@ -833,23 +818,19 @@ class OAuth2Credentials(Credentials): pass raise HttpAccessTokenRefreshError(error_msg, status=resp.status) - def _revoke(self, http_request): + def _revoke(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.refresh_token or self.access_token) + self._do_revoke(http, self.refresh_token or self.access_token) - def _do_revoke(self, http_request, token): + def _do_revoke(self, http, 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. + http: an object to be used to make HTTP requests. token: A string used as the token to be revoked. Can be either an access_token or refresh_token. @@ -859,8 +840,13 @@ class OAuth2Credentials(Credentials): """ 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) + token_revoke_uri = _helpers.update_query_params( + self.revoke_uri, query_params) + resp, content = transport.request(http, token_revoke_uri) + if resp.status == http_client.METHOD_NOT_ALLOWED: + body = urllib.parse.urlencode(query_params) + resp, content = transport.request(http, token_revoke_uri, + method='POST', body=body) if resp.status == http_client.OK: self.invalid = True else: @@ -876,23 +862,19 @@ class OAuth2Credentials(Credentials): if self.store: self.store.delete() - def _retrieve_scopes(self, http_request): + def _retrieve_scopes(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_retrieve_scopes(http_request, self.access_token) + self._do_retrieve_scopes(http, self.access_token) - def _do_retrieve_scopes(self, http_request, token): + def _do_retrieve_scopes(self, http, 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. + http: an object to be used to make HTTP requests. token: A string used as the token to identify the credentials to the provider. @@ -902,13 +884,13 @@ class OAuth2Credentials(Credentials): """ 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) + token_info_uri = _helpers.update_query_params( + self.token_info_uri, query_params) + resp, content = transport.request(http, 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', ''))) + self.scopes = set(_helpers.string_to_scopes(d.get('scope', ''))) else: error_msg = 'Invalid response {0}.'.format(resp.status) try: @@ -977,19 +959,25 @@ class AccessTokenCredentials(OAuth2Credentials): data['user_agent']) return retval - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access token. + + Args: + http: unused HTTP object. + + Raises: + AccessTokenCredentialsError: always + """ raise AccessTokenCredentialsError( 'The access_token is expired or invalid and can\'t be refreshed.') - def _revoke(self, http_request): + def _revoke(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.access_token) + self._do_revoke(http, self.access_token) def _detect_gce_environment(): @@ -1005,21 +993,16 @@ def _detect_gce_environment(): # 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) - + http = transport.get_http_object(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) + response, _ = transport.request( + http, _GCE_METADATA_URI, headers=_GCE_HEADERS) + return ( + response.status == http_client.OK and + response.get(_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(): @@ -1469,7 +1452,7 @@ class AssertionCredentials(GoogleCredentials): AssertionCredentials objects may be safely pickled and unpickled. """ - @util.positional(2) + @_helpers.positional(2) def __init__(self, assertion_type, user_agent=None, token_uri=oauth2client.GOOGLE_TOKEN_URI, revoke_uri=oauth2client.GOOGLE_REVOKE_URI, @@ -1511,15 +1494,13 @@ class AssertionCredentials(GoogleCredentials): """Generate assertion string to be used in the access token request.""" raise NotImplementedError - def _revoke(self, http_request): + def _revoke(self, http): """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. + http: an object to be used to make HTTP requests. """ - self._do_revoke(http_request, self.access_token) + self._do_revoke(http, self.access_token) def sign_blob(self, blob): """Cryptographically sign a blob (of bytes). @@ -1545,7 +1526,7 @@ def _require_crypto_or_die(): raise CryptoUnavailableError('No crypto library available') -@util.positional(2) +@_helpers.positional(2) def verify_id_token(id_token, audience, http=None, cert_uri=ID_TOKEN_VERIFICATION_CERTS): """Verifies a signed JWT id_token. @@ -1572,7 +1553,7 @@ def verify_id_token(id_token, audience, http=None, if http is None: http = transport.get_cached_http() - resp, content = http.request(cert_uri) + resp, content = transport.request(http, 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) @@ -1624,7 +1605,7 @@ def _parse_exchange_token_response(content): except Exception: # different JSON libs raise different exceptions, # so we just do a catch-all here - resp = dict(urllib.parse.parse_qsl(content)) + resp = _helpers.parse_unique_urlencoded(content) # some providers respond with 'expires', others with 'expires_in' if resp and 'expires' in resp: @@ -1633,7 +1614,7 @@ def _parse_exchange_token_response(content): return resp -@util.positional(4) +@_helpers.positional(4) def credentials_from_code(client_id, client_secret, scope, code, redirect_uri='postmessage', http=None, user_agent=None, @@ -1641,7 +1622,9 @@ def credentials_from_code(client_id, client_secret, scope, code, 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): + token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, + pkce=False, + code_verifier=None): """Exchanges an authorization code for an OAuth2Credentials object. Args: @@ -1665,6 +1648,15 @@ def credentials_from_code(client_id, client_secret, scope, code, device_uri: string, URI for device authorization endpoint. For convenience defaults to Google's endpoints but any OAuth 2.0 provider can be used. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1675,16 +1667,20 @@ def credentials_from_code(client_id, client_secret, scope, code, """ 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, + 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) + token_info_uri=token_info_uri, + pkce=pkce, + code_verifier=code_verifier) credentials = flow.step2_exchange(code, http=http) return credentials -@util.positional(3) +@_helpers.positional(3) def credentials_from_clientsecrets_and_code(filename, scope, code, message=None, redirect_uri='postmessage', @@ -1713,6 +1709,15 @@ def credentials_from_clientsecrets_and_code(filename, scope, code, 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 + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. Returns: An OAuth2Credentials object. @@ -1803,7 +1808,7 @@ class OAuth2WebServerFlow(Flow): OAuth2WebServerFlow objects may be safely pickled and unpickled. """ - @util.positional(4) + @_helpers.positional(4) def __init__(self, client_id, client_secret=None, scope=None, @@ -1816,6 +1821,8 @@ class OAuth2WebServerFlow(Flow): device_uri=oauth2client.GOOGLE_DEVICE_URI, token_info_uri=oauth2client.GOOGLE_TOKEN_INFO_URI, authorization_header=None, + pkce=False, + code_verifier=None, **kwargs): """Constructor for OAuth2WebServerFlow. @@ -1853,6 +1860,15 @@ class OAuth2WebServerFlow(Flow): require a client to authenticate using a header value instead of passing client_secret in the POST body. + pkce: boolean, default: False, Generate and include a "Proof Key + for Code Exchange" (PKCE) with your authorization and token + requests. This adds security for installed applications that + cannot protect a client_secret. See RFC 7636 for details. + code_verifier: bytestring or None, default: None, parameter passed + as part of the code exchange when pkce=True. If + None, a code_verifier will automatically be + generated as part of step1_get_authorize_url(). See + RFC 7636 for details. **kwargs: dict, The keyword arguments are all optional and required parameters for the OAuth calls. """ @@ -1862,7 +1878,7 @@ class OAuth2WebServerFlow(Flow): 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.scope = _helpers.scopes_to_string(scope) self.redirect_uri = redirect_uri self.login_hint = login_hint self.user_agent = user_agent @@ -1872,9 +1888,11 @@ class OAuth2WebServerFlow(Flow): self.device_uri = device_uri self.token_info_uri = token_info_uri self.authorization_header = authorization_header + self._pkce = pkce + self.code_verifier = code_verifier self.params = _oauth2_web_server_flow_params(kwargs) - @util.positional(1) + @_helpers.positional(1) def step1_get_authorize_url(self, redirect_uri=None, state=None): """Returns a URI to redirect to the provider. @@ -1912,10 +1930,17 @@ class OAuth2WebServerFlow(Flow): query_params['state'] = state if self.login_hint is not None: query_params['login_hint'] = self.login_hint + if self._pkce: + if not self.code_verifier: + self.code_verifier = _pkce.code_verifier() + challenge = _pkce.code_challenge(self.code_verifier) + query_params['code_challenge'] = challenge + query_params['code_challenge_method'] = 'S256' + query_params.update(self.params) - return _update_query_params(self.auth_uri, query_params) + return _helpers.update_query_params(self.auth_uri, query_params) - @util.positional(1) + @_helpers.positional(1) def step1_get_device_and_user_codes(self, http=None): """Returns a user code and the verification URL where to enter it @@ -1940,8 +1965,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = http.request(self.device_uri, method='POST', body=body, - headers=headers) + resp, content = transport.request( + http, self.device_uri, method='POST', body=body, headers=headers) content = _helpers._from_bytes(content) if resp.status == http_client.OK: try: @@ -1963,7 +1988,7 @@ class OAuth2WebServerFlow(Flow): pass raise OAuth2DeviceCodeError(error_msg) - @util.positional(2) + @_helpers.positional(2) def step2_exchange(self, code=None, http=None, device_flow_info=None): """Exchanges a code for OAuth2Credentials. @@ -2006,6 +2031,8 @@ class OAuth2WebServerFlow(Flow): } if self.client_secret is not None: post_data['client_secret'] = self.client_secret + if self._pkce: + post_data['code_verifier'] = self.code_verifier if device_flow_info is not None: post_data['grant_type'] = 'http://oauth.net/grant_type/device/1.0' else: @@ -2023,8 +2050,8 @@ class OAuth2WebServerFlow(Flow): if http is None: http = transport.get_http_object() - resp, content = http.request(self.token_uri, method='POST', body=body, - headers=headers) + resp, content = transport.request( + http, 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'] @@ -2039,15 +2066,17 @@ class OAuth2WebServerFlow(Flow): token_expiry = delta + _UTCNOW() extracted_id_token = None + id_token_jwt = None if 'id_token' in d: extracted_id_token = _extract_id_token(d['id_token']) + id_token_jwt = 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, + id_token_jwt=id_token_jwt, token_response=d, scopes=self.scope, token_info_uri=self.token_info_uri) else: logger.info('Failed to retrieve access token: %s', content) @@ -2060,10 +2089,11 @@ class OAuth2WebServerFlow(Flow): raise FlowExchangeError(error_msg) -@util.positional(2) +@_helpers.positional(2) def flow_from_clientsecrets(filename, scope, redirect_uri=None, message=None, cache=None, login_hint=None, - device_uri=None): + device_uri=None, pkce=None, code_verifier=None, + prompt=None): """Create a Flow from a clientsecrets file. Will create the right kind of Flow based on the contents of the @@ -2112,10 +2142,17 @@ def flow_from_clientsecrets(filename, scope, redirect_uri=None, '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 + optional = ( + 'revoke_uri', + 'device_uri', + 'pkce', + 'code_verifier', + 'prompt' + ) + for param in optional: + if locals()[param] is not None: + constructor_kwargs[param] = locals()[param] + return OAuth2WebServerFlow( client_info['client_id'], client_info['client_secret'], scope, **constructor_kwargs) diff --git a/oauth2client/clientsecrets.py b/oauth2client/clientsecrets.py index 4b43e66..1598142 100644 --- a/oauth2client/clientsecrets.py +++ b/oauth2client/clientsecrets.py @@ -22,7 +22,6 @@ import json import six -__author__ = 'jcgregorio@google.com (Joe Gregorio)' # Properties that make a client_secrets.json file valid. TYPE_WEB = 'web' diff --git a/oauth2client/contrib/_fcntl_opener.py b/oauth2client/contrib/_fcntl_opener.py deleted file mode 100644 index ae6c85b..0000000 --- a/oauth2client/contrib/_fcntl_opener.py +++ /dev/null @@ -1,81 +0,0 @@ -# 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 index 10e6a69..564cd39 100644 --- a/oauth2client/contrib/_metadata.py +++ b/oauth2client/contrib/_metadata.py @@ -19,29 +19,28 @@ See https://cloud.google.com/compute/docs/metadata import datetime import json +import os -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 +from oauth2client import transport -METADATA_ROOT = 'http://metadata.google.internal/computeMetadata/v1/' +METADATA_ROOT = 'http://{}/computeMetadata/v1/'.format( + os.getenv('GCE_METADATA_ROOT', 'metadata.google.internal')) METADATA_HEADERS = {'Metadata-Flavor': 'Google'} -def get(http_request, path, root=METADATA_ROOT, recursive=None): +def get(http, path, root=METADATA_ROOT, recursive=None): """Fetch a resource from the metadata server. Args: + http: an object to be used to make HTTP requests. 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. + 'instance/service-accounts/default' 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 @@ -51,15 +50,14 @@ def get(http_request, path, root=METADATA_ROOT, recursive=None): A dictionary if the metadata server returns JSON, otherwise a string. Raises: - httplib2.Httplib2Error if an error corrured while retrieving metadata. + http_client.HTTPException if an error corrured while + retrieving metadata. """ url = urlparse.urljoin(root, path) - url = util._add_query_parameter(url, 'recursive', recursive) + url = _helpers._add_query_parameter(url, 'recursive', recursive) - response, content = http_request( - url, - headers=METADATA_HEADERS - ) + response, content = transport.request( + http, url, headers=METADATA_HEADERS) if response.status == http_client.OK: decoded = _helpers._from_bytes(content) @@ -68,21 +66,20 @@ def get(http_request, path, root=METADATA_ROOT, recursive=None): else: return decoded else: - raise httplib2.HttpLib2Error( + raise http_client.HTTPException( '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'): +def get_service_account_info(http, service_account='default'): """Get information about a service account from the metadata server. Args: + http: an object to be used to make HTTP requests. 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: @@ -94,21 +91,19 @@ def get_service_account_info(http_request, service_account='default'): } """ return get( - http_request, + http, 'instance/service-accounts/{0}/'.format(service_account), recursive=True) -def get_token(http_request, service_account='default'): +def get_token(http, service_account='default'): """Fetch an oauth token for the Args: + http: an object to be used to make HTTP requests. 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 @@ -116,7 +111,7 @@ def get_token(http_request, service_account='default'): that indicates when the access token will expire. """ token_json = get( - http_request, + http, 'instance/service-accounts/{0}/token'.format(service_account)) token_expiry = client._UTCNOW() + datetime.timedelta( seconds=token_json['expires_in']) diff --git a/oauth2client/contrib/_win32_opener.py b/oauth2client/contrib/_win32_opener.py deleted file mode 100644 index 34b4f48..0000000 --- a/oauth2client/contrib/_win32_opener.py +++ /dev/null @@ -1,106 +0,0 @@ -# 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 index 661105e..c1326ee 100644 --- a/oauth2client/contrib/appengine.py +++ b/oauth2client/contrib/appengine.py @@ -29,13 +29,13 @@ 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 _helpers from oauth2client import client from oauth2client import clientsecrets -from oauth2client import util +from oauth2client import transport from oauth2client.contrib import xsrfutil # This is a temporary fix for a Google internal issue. @@ -45,8 +45,6 @@ except ImportError: # pragma: NO COVER _appengine_ndb = None -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - logger = logging.getLogger(__name__) OAUTH2CLIENT_NAMESPACE = 'oauth2client#ns' @@ -131,7 +129,7 @@ class AppAssertionCredentials(client.AssertionCredentials): information to generate and refresh its own access tokens. """ - @util.positional(2) + @_helpers.positional(2) def __init__(self, scope, **kwargs): """Constructor for AppAssertionCredentials @@ -143,7 +141,7 @@ class AppAssertionCredentials(client.AssertionCredentials): or unspecified, the default service account for the app is used. """ - self.scope = util.scopes_to_string(scope) + self.scope = _helpers.scopes_to_string(scope) self._kwargs = kwargs self.service_account_id = kwargs.get('service_account_id', None) self._service_account_email = None @@ -157,17 +155,15 @@ class AppAssertionCredentials(client.AssertionCredentials): data = json.loads(json_data) return AppAssertionCredentials(data['scope']) - def _refresh(self, http_request): - """Refreshes the access_token. + def _refresh(self, http): + """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. + http: unused HTTP object Raises: AccessTokenRefreshError: When the refresh fails. @@ -305,7 +301,7 @@ class StorageByKeyName(client.Storage): and that entities are stored by key_name. """ - @util.positional(4) + @_helpers.positional(4) def __init__(self, model, key_name, property_name, cache=None, user=None): """Constructor for Storage. @@ -523,7 +519,7 @@ class OAuth2Decorator(object): flow = property(get_flow, set_flow) - @util.positional(4) + @_helpers.positional(4) def __init__(self, client_id, client_secret, scope, auth_uri=oauth2client.GOOGLE_AUTH_URI, token_uri=oauth2client.GOOGLE_TOKEN_URI, @@ -590,7 +586,7 @@ class OAuth2Decorator(object): self.credentials = None self._client_id = client_id self._client_secret = client_secret - self._scope = util.scopes_to_string(scope) + self._scope = _helpers.scopes_to_string(scope) self._auth_uri = auth_uri self._token_uri = token_uri self._revoke_uri = revoke_uri @@ -742,7 +738,8 @@ class OAuth2Decorator(object): *args: Positional arguments passed to httplib2.Http constructor. **kwargs: Positional arguments passed to httplib2.Http constructor. """ - return self.credentials.authorize(httplib2.Http(*args, **kwargs)) + return self.credentials.authorize( + transport.get_http_object(*args, **kwargs)) @property def callback_path(self): @@ -804,7 +801,7 @@ class OAuth2Decorator(object): if (decorator._token_response_param and credentials.token_response): resp_json = json.dumps(credentials.token_response) - redirect_uri = util._add_query_parameter( + redirect_uri = _helpers._add_query_parameter( redirect_uri, decorator._token_response_param, resp_json) @@ -848,7 +845,7 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): """ - @util.positional(3) + @_helpers.positional(3) def __init__(self, filename, scope, message=None, cache=None, **kwargs): """Constructor @@ -891,7 +888,7 @@ class OAuth2DecoratorFromClientSecrets(OAuth2Decorator): self._message = 'Please configure your application for OAuth 2.0.' -@util.positional(2) +@_helpers.positional(2) def oauth2decorator_from_clientsecrets(filename, scope, message=None, cache=None): """Creates an OAuth2Decorator populated from a clientsecrets file. diff --git a/oauth2client/contrib/devshell.py b/oauth2client/contrib/devshell.py index b8bb978..691765f 100644 --- a/oauth2client/contrib/devshell.py +++ b/oauth2client/contrib/devshell.py @@ -37,6 +37,7 @@ class CommunicationError(Error): 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. @@ -117,7 +118,12 @@ class DevshellCredentials(client.GoogleCredentials): user_agent) self._refresh(None) - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access token. + + Args: + http: unused HTTP object + """ self.devshell_response = _SendRecv() self.access_token = self.devshell_response.access_token expires_in = self.devshell_response.expires_in diff --git a/oauth2client/contrib/django_util/__init__.py b/oauth2client/contrib/django_util/__init__.py index 5449e32..644a8f9 100644 --- a/oauth2client/contrib/django_util/__init__.py +++ b/oauth2client/contrib/django_util/__init__.py @@ -52,6 +52,9 @@ Add the helper to your INSTALLED_APPS: This helper also requires the Django Session Middleware, so ``django.contrib.sessions.middleware`` should be in INSTALLED_APPS as well. +MIDDLEWARE or MIDDLEWARE_CLASSES (in Django versions <1.10) should also +contain the string 'django.contrib.sessions.middleware.SessionMiddleware'. + Add the client secrets created earlier to the settings. You can either specify the path to the credentials file in JSON format @@ -228,10 +231,10 @@ 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 import transport from oauth2client.contrib import dictionary_storage from oauth2client.contrib.django_util import storage @@ -335,16 +338,26 @@ class OAuth2Settings(object): 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) + info = _get_oauth2_client_id_and_secret(settings_instance) + self.client_id, self.client_secret = info + + # Django 1.10 deprecated MIDDLEWARE_CLASSES in favor of MIDDLEWARE + middleware_settings = getattr(settings_instance, 'MIDDLEWARE', None) + if middleware_settings is None: + middleware_settings = getattr( + settings_instance, 'MIDDLEWARE_CLASSES', None) + if middleware_settings is None: + raise exceptions.ImproperlyConfigured( + 'Django settings has neither MIDDLEWARE nor MIDDLEWARE_CLASSES' + 'configured') - if ('django.contrib.sessions.middleware.SessionMiddleware' - not in settings_instance.MIDDLEWARE_CLASSES): + if ('django.contrib.sessions.middleware.SessionMiddleware' not in + middleware_settings): 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\'.') + 'The Google OAuth2 Helper requires session middleware to ' + 'be installed. Edit your MIDDLEWARE_CLASSES or MIDDLEWARE ' + 'setting to include \'django.contrib.sessions.middleware.' + 'SessionMiddleware\'.') (self.storage_model, self.storage_model_user_property, self.storage_model_credentials_property) = _get_storage_model() @@ -470,8 +483,7 @@ class UserOAuth2(object): @property def http(self): - """Helper method to create an HTTP client authorized with OAuth2 - credentials.""" + """Helper: create HTTP client authorized with OAuth2 credentials.""" if self.has_credentials(): - return self.credentials.authorize(httplib2.Http()) + return self.credentials.authorize(transport.get_http_object()) return None diff --git a/oauth2client/contrib/django_util/models.py b/oauth2client/contrib/django_util/models.py index 87e1da7..37cc697 100644 --- a/oauth2client/contrib/django_util/models.py +++ b/oauth2client/contrib/django_util/models.py @@ -19,6 +19,7 @@ import pickle from django.db import models from django.utils import encoding +import jsonpickle import oauth2client @@ -48,7 +49,12 @@ class CredentialsField(models.Field): elif isinstance(value, oauth2client.client.Credentials): return value else: - return pickle.loads(base64.b64decode(encoding.smart_bytes(value))) + try: + return jsonpickle.decode( + base64.b64decode(encoding.smart_bytes(value)).decode()) + except ValueError: + return pickle.loads( + base64.b64decode(encoding.smart_bytes(value))) def get_prep_value(self, value): """Overrides ``models.Field`` method. This is used to convert @@ -58,7 +64,8 @@ class CredentialsField(models.Field): if value is None: return None else: - return encoding.smart_text(base64.b64encode(pickle.dumps(value))) + return encoding.smart_text( + base64.b64encode(jsonpickle.encode(value).encode())) def value_to_string(self, obj): """Convert the field value from the provided model to a string. diff --git a/oauth2client/contrib/django_util/views.py b/oauth2client/contrib/django_util/views.py index 4d8ae03..1835208 100644 --- a/oauth2client/contrib/django_util/views.py +++ b/oauth2client/contrib/django_util/views.py @@ -22,13 +22,14 @@ 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 django.utils import html +import jsonpickle from six.moves.urllib import parse from oauth2client import client @@ -71,7 +72,7 @@ def _make_flow(request, scopes, return_url=None): urlresolvers.reverse("google_oauth:callback"))) flow_key = _FLOW_KEY.format(csrf_token) - request.session[flow_key] = pickle.dumps(flow) + request.session[flow_key] = jsonpickle.encode(flow) return flow @@ -89,7 +90,7 @@ def _get_flow_for_token(csrf_token, request): 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) + return None if flow_pickle is None else jsonpickle.decode(flow_pickle) def oauth2_callback(request): @@ -109,6 +110,7 @@ def oauth2_callback(request): if 'error' in request.GET: reason = request.GET.get( 'error_description', request.GET.get('error', '')) + reason = html.escape(reason) return http.HttpResponseBadRequest( 'Authorization failed {0}'.format(reason)) @@ -170,7 +172,10 @@ def oauth2_authorize(request): A redirect to Google OAuth2 Authorization. """ return_url = request.GET.get('return_url', None) + if not return_url: + return_url = request.META.get('HTTP_REFERER', '/') + scopes = request.GET.getlist('scopes', django_util.oauth2_settings.scopes) # Model storage (but not session storage) requires a logged in user if django_util.oauth2_settings.storage_model: if not request.user.is_authenticated(): @@ -178,13 +183,11 @@ def oauth2_authorize(request): 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) + else: + user_oauth = django_util.UserOAuth2(request, scopes, return_url) + if user_oauth.has_credentials(): + 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 index 47c3df1..fabd613 100644 --- a/oauth2client/contrib/flask_util.py +++ b/oauth2client/contrib/flask_util.py @@ -176,19 +176,18 @@ try: from flask import request from flask import session from flask import url_for + import markupsafe 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 import transport 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}' @@ -390,6 +389,7 @@ class UserOAuth2(object): if 'error' in request.args: reason = request.args.get( 'error_description', request.args.get('error', '')) + reason = markupsafe.escape(reason) return ('Authorization failed: {0}'.format(reason), httplib.BAD_REQUEST) @@ -553,4 +553,5 @@ class UserOAuth2(object): """ if not self.credentials: raise ValueError('No credentials available.') - return self.credentials.authorize(httplib2.Http(*args, **kwargs)) + return self.credentials.authorize( + transport.get_http_object(*args, **kwargs)) diff --git a/oauth2client/contrib/gce.py b/oauth2client/contrib/gce.py index f3a6ca1..aaab15f 100644 --- a/oauth2client/contrib/gce.py +++ b/oauth2client/contrib/gce.py @@ -20,14 +20,12 @@ Utilities for making it easier to use OAuth 2.0 on Google Compute Engine. import logging import warnings -import httplib2 +from six.moves import http_client from oauth2client import client from oauth2client.contrib import _metadata -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - logger = logging.getLogger(__name__) _SCOPES_WARNING = """\ @@ -98,44 +96,40 @@ class AppAssertionCredentials(client.AssertionCredentials): Returns: A set of strings containing the canonical list of scopes. """ - self._retrieve_info(http.request) + self._retrieve_info(http) return self.scopes - def _retrieve_info(self, http_request): - """Validates invalid service accounts by retrieving service account info. + def _retrieve_info(self, http): + """Retrieves service account info for invalid credentials. Args: - http_request: callable, a callable that matches the method - signature of httplib2.Http.request, used to make the - request to the metadata server + http: an object to be used to make HTTP requests. """ if self.invalid: info = _metadata.get_service_account_info( - http_request, + http, 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. + def _refresh(self, http): + """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. + http: an object to be used to make HTTP requests. Raises: HttpAccessTokenRefreshError: When the refresh fails. """ try: - self._retrieve_info(http_request) + self._retrieve_info(http) 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)) + http, service_account=self.service_account_email) + except http_client.HTTPException as err: + raise client.HttpAccessTokenRefreshError(str(err)) @property def serialization_data(self): diff --git a/oauth2client/contrib/keyring_storage.py b/oauth2client/contrib/keyring_storage.py index f4f2e30..4af9448 100644 --- a/oauth2client/contrib/keyring_storage.py +++ b/oauth2client/contrib/keyring_storage.py @@ -24,9 +24,6 @@ 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. diff --git a/oauth2client/contrib/locked_file.py b/oauth2client/contrib/locked_file.py deleted file mode 100644 index 0d28ebb..0000000 --- a/oauth2client/contrib/locked_file.py +++ /dev/null @@ -1,234 +0,0 @@ -# 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/multistore_file.py b/oauth2client/contrib/multistore_file.py deleted file mode 100644 index 10f4cb4..0000000 --- a/oauth2client/contrib/multistore_file.py +++ /dev/null @@ -1,505 +0,0 @@ -# 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/xsrfutil.py b/oauth2client/contrib/xsrfutil.py index c03e679..7c3ec03 100644 --- a/oauth2client/contrib/xsrfutil.py +++ b/oauth2client/contrib/xsrfutil.py @@ -20,12 +20,7 @@ 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':' @@ -34,7 +29,7 @@ DELIMITER = b':' DEFAULT_TIMEOUT_SECS = 60 * 60 -@util.positional(2) +@_helpers.positional(2) def generate_token(key, user_id, action_id='', when=None): """Generates a URL-safe token for the given user, action, time tuple. @@ -62,7 +57,7 @@ def generate_token(key, user_id, action_id='', when=None): return token -@util.positional(3) +@_helpers.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. diff --git a/oauth2client/file.py b/oauth2client/file.py index feede11..3551c80 100644 --- a/oauth2client/file.py +++ b/oauth2client/file.py @@ -21,16 +21,10 @@ credentials. import os import threading +from oauth2client import _helpers 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.""" @@ -38,11 +32,6 @@ class Storage(client.Storage): 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. @@ -50,10 +39,10 @@ class Storage(client.Storage): oauth2client.client.Credentials Raises: - CredentialsFileSymbolicLinkError if the file is a symbolic link. + IOError if the file is a symbolic link. """ credentials = None - self._validate_file() + _helpers.validate_file(self._filename) try: f = open(self._filename, 'rb') content = f.read() @@ -89,10 +78,10 @@ class Storage(client.Storage): credentials: Credentials, the credentials to store. Raises: - CredentialsFileSymbolicLinkError if the file is a symbolic link. + IOError if the file is a symbolic link. """ self._create_file_if_needed() - self._validate_file() + _helpers.validate_file(self._filename) f = open(self._filename, 'w') f.write(credentials.to_json()) f.close() diff --git a/oauth2client/service_account.py b/oauth2client/service_account.py index bdcfd69..540bfaa 100644 --- a/oauth2client/service_account.py +++ b/oauth2client/service_account.py @@ -25,7 +25,6 @@ from oauth2client import _helpers from oauth2client import client from oauth2client import crypt from oauth2client import transport -from oauth2client import util _PASSWORD_DEFAULT = 'notasecret' @@ -110,7 +109,7 @@ class ServiceAccountCredentials(client.AssertionCredentials): self._service_account_email = service_account_email self._signer = signer - self._scopes = util.scopes_to_string(scopes) + self._scopes = _helpers.scopes_to_string(scopes) self._private_key_id = private_key_id self.client_id = client_id self._user_agent = user_agent @@ -650,9 +649,22 @@ class _JWTAccessCredentials(ServiceAccountCredentials): return result def refresh(self, http): + """Refreshes the access_token. + + The HTTP object is unused since no request needs to be made to + get a new token, it can just be generated locally. + + Args: + http: unused HTTP object + """ self._refresh(None) - def _refresh(self, http_request): + def _refresh(self, http): + """Refreshes the access_token. + + Args: + http: unused HTTP object + """ self.access_token, self.token_expiry = self._create_token() def _create_token(self, additional_claims=None): diff --git a/oauth2client/tools.py b/oauth2client/tools.py index 8947157..5166993 100644 --- a/oauth2client/tools.py +++ b/oauth2client/tools.py @@ -30,11 +30,10 @@ from six.moves import http_client from six.moves import input from six.moves import urllib +from oauth2client import _helpers 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 @@ -93,6 +92,7 @@ def _CreateArgumentParser(): 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. @@ -123,22 +123,22 @@ class ClientRedirectHandler(BaseHTTPServer.BaseHTTPRequestHandler): if an error occurred. """ self.send_response(http_client.OK) - self.send_header("Content-type", "text/html") + self.send_header('Content-type', 'text/html') self.end_headers() - query = self.path.split('?', 1)[-1] - query = dict(urllib.parse.parse_qsl(query)) + parts = urllib.parse.urlparse(self.path) + query = _helpers.parse_unique_urlencoded(parts.query) self.server.query_params = query self.wfile.write( - b"<html><head><title>Authentication Status</title></head>") + 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>") + 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) +@_helpers.positional(3) def run_flow(flow, storage, flags=None, http=None): """Core code for a command-line application. diff --git a/oauth2client/transport.py b/oauth2client/transport.py index 8dbc60d..79a61f1 100644 --- a/oauth2client/transport.py +++ b/oauth2client/transport.py @@ -18,7 +18,7 @@ import httplib2 import six from six.moves import http_client -from oauth2client._helpers import _to_bytes +from oauth2client import _helpers _LOGGER = logging.getLogger(__name__) @@ -58,13 +58,19 @@ def get_cached_http(): return _CACHED_HTTP -def get_http_object(): +def get_http_object(*args, **kwargs): """Return a new HTTP object. + Args: + *args: tuple, The positional arguments to be passed when + contructing a new HTTP object. + **kwargs: dict, The keyword arguments to be passed when + contructing a new HTTP object. + Returns: httplib2.Http, an HTTP object. """ - return httplib2.Http() + return httplib2.Http(*args, **kwargs) def _initialize_headers(headers): @@ -121,7 +127,7 @@ def clean_headers(headers): k = str(k) if not isinstance(v, six.binary_type): v = str(v) - clean[_to_bytes(k)] = _to_bytes(v) + clean[_helpers._to_bytes(k)] = _helpers._to_bytes(v) except UnicodeEncodeError: from oauth2client.client import NonAsciiHeaderError raise NonAsciiHeaderError(k, ': ', v) @@ -164,9 +170,9 @@ def wrap_http_for_auth(credentials, http): _STREAM_PROPERTIES): body_stream_position = body.tell() - resp, content = orig_request_method(uri, method, body, - clean_headers(headers), - redirections, connection_type) + resp, content = request(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. @@ -182,9 +188,9 @@ def wrap_http_for_auth(credentials, http): 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) + resp, content = request(orig_request_method, uri, method, body, + clean_headers(headers), + redirections, connection_type) return resp, content @@ -192,7 +198,7 @@ def wrap_http_for_auth(credentials, http): http.request = new_request # Set credentials as a property of the request method. - setattr(http.request, 'credentials', credentials) + http.request.credentials = credentials def wrap_http_for_jwt_access(credentials, http): @@ -222,9 +228,9 @@ def wrap_http_for_jwt_access(credentials, http): 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) + return request(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 @@ -234,12 +240,46 @@ def wrap_http_for_jwt_access(credentials, http): 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) + return request(orig_request_method, uri, method, body, + clean_headers(headers), + redirections, connection_type) # Replace the request method with our own closure. http.request = new_request + # Set credentials as a property of the request method. + http.request.credentials = credentials + + +def request(http, uri, method='GET', body=None, headers=None, + redirections=httplib2.DEFAULT_MAX_REDIRECTS, + connection_type=None): + """Make an HTTP request with an HTTP object and arguments. + + Args: + http: httplib2.Http, an http object to be used to make requests. + uri: string, The URI to be requested. + method: string, The HTTP method to use for the request. Defaults + to 'GET'. + body: string, The payload / body in HTTP request. By default + there is no payload. + headers: dict, Key-value pairs of request headers. By default + there are no headers. + redirections: int, The number of allowed 203 redirects for + the request. Defaults to 5. + connection_type: httplib.HTTPConnection, a subclass to be used for + establishing connection. If not set, the type + will be determined from the ``uri``. + + Returns: + tuple, a pair of a httplib2.Response with the status code and other + headers and the bytes of the content returned. + """ + # NOTE: Allowing http or http.request is temporary (See Issue 601). + http_callable = getattr(http, 'request', http) + return http_callable(uri, method=method, body=body, headers=headers, + redirections=redirections, + connection_type=connection_type) + _CACHED_HTTP = httplib2.Http(MemoryCache()) diff --git a/oauth2client/util.py b/oauth2client/util.py deleted file mode 100644 index e3ba62b..0000000 --- a/oauth2client/util.py +++ /dev/null @@ -1,206 +0,0 @@ -# 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/django/README.md b/samples/django/README.md new file mode 100644 index 0000000..27c0fda --- /dev/null +++ b/samples/django/README.md @@ -0,0 +1,21 @@ +# Django Samples + +These two sample Django apps provide a skeleton for the two main use cases of the +`oauth2client.contrib.django_util` helpers. + +Please see the +[core docs](https://oauth2client.readthedocs.io/en/latest/) for more information and usage examples. + +## google_user + +This is the simpler use case of the library. It assumes you are using Google OAuth as your primary +authorization and authentication mechanism for your application. Users log in with their Google ID +and their OAuth2 credentials are stored inside the session. + +## django_user + +This is the use case where the application is already using the Django authorization system and +has a Django model with a `django.contrib.auth.models.User` field, and would like to attach +a Google OAuth2 credentials object to that model. Users have to login, and then can login with +their Google account to associate the Google account with the user in the Django system. +Credentials will be stored in the Django ORM backend. diff --git a/samples/django/django_user/manage.py b/samples/django/django_user/manage.py new file mode 100755 index 0000000..1a30708 --- /dev/null +++ b/samples/django/django_user/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# 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 os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myoauth.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/samples/django/django_user/myoauth/__init__.py b/samples/django/django_user/myoauth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/samples/django/django_user/myoauth/__init__.py diff --git a/samples/django/django_user/myoauth/settings.py b/samples/django/django_user/myoauth/settings.py new file mode 100644 index 0000000..5ef2f99 --- /dev/null +++ b/samples/django/django_user/myoauth/settings.py @@ -0,0 +1,115 @@ +# 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. + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'eiw+mvmua#98n@p2xq+c#liz@r2&#-s07nkgz)+$zcl^o4$-$o' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'polls', + 'oauth2client.contrib.django_util', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', +) + +ROOT_URLCONF = 'myoauth.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'myoauth.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.8/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.8/howto/static-files/ + +STATIC_URL = '/static/' + +GOOGLE_OAUTH2_CLIENT_ID = 'YOUR_CLIENT_ID' + +GOOGLE_OAUTH2_CLIENT_SECRET = 'YOUR_CLIENT_SECRET' + +GOOGLE_OAUTH2_SCOPES = ( + 'email', 'profile') + +GOOGLE_OAUTH2_STORAGE_MODEL = { + 'model': 'polls.models.CredentialsModel', + 'user_property': 'user_id', + 'credentials_property': 'credential', +} + +LOGIN_URL = '/login' diff --git a/samples/django/django_user/myoauth/urls.py b/samples/django/django_user/myoauth/urls.py new file mode 100644 index 0000000..6636b4e --- /dev/null +++ b/samples/django/django_user/myoauth/urls.py @@ -0,0 +1,30 @@ +# 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. + +from django.conf import urls +from django.contrib import admin +import django.contrib.auth.views +from polls import views + +import oauth2client.contrib.django_util.site as django_util_site + + +urlpatterns = [ + urls.url(r'^$', views.index), + urls.url(r'^profile_required$', views.get_profile_required), + urls.url(r'^profile_enabled$', views.get_profile_optional), + urls.url(r'^admin/', urls.include(admin.site.urls)), + urls.url(r'^login', django.contrib.auth.views.login, name="login"), + urls.url(r'^oauth2/', urls.include(django_util_site.urls)), +] diff --git a/samples/django/django_user/myoauth/wsgi.py b/samples/django/django_user/myoauth/wsgi.py new file mode 100644 index 0000000..39ee648 --- /dev/null +++ b/samples/django/django_user/myoauth/wsgi.py @@ -0,0 +1,21 @@ +# 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 os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myoauth.settings") + +application = get_wsgi_application() diff --git a/samples/django/django_user/polls/__init__.py b/samples/django/django_user/polls/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/samples/django/django_user/polls/__init__.py diff --git a/samples/django/django_user/polls/models.py b/samples/django/django_user/polls/models.py new file mode 100644 index 0000000..563f66e --- /dev/null +++ b/samples/django/django_user/polls/models.py @@ -0,0 +1,23 @@ +# 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. + +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) + credential = CredentialsField() diff --git a/samples/django/django_user/polls/templates/registration/login.html b/samples/django/django_user/polls/templates/registration/login.html new file mode 100644 index 0000000..c43450e --- /dev/null +++ b/samples/django/django_user/polls/templates/registration/login.html @@ -0,0 +1,45 @@ +<!-- +# Copyright 2016 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. +--> + +{% if form.errors %} +<p>Your username and password didn't match. Please try again.</p> +{% endif %} + +{% if next %} +{% if user.is_authenticated %} +<p>Your account doesn't have access to this page. To proceed, + please login with an account that has access.</p> +{% else %} +<p>Please login to see this page.</p> +{% endif %} +{% endif %} + +<form method="post" action="{% url 'login' %}"> + {% csrf_token %} + <table> + <tr> + <td>{{ form.username.label_tag }}</td> + <td>{{ form.username }}</td> + </tr> + <tr> + <td>{{ form.password.label_tag }}</td> + <td>{{ form.password }}</td> + </tr> + </table> + + <input type="submit" value="login" /> + <input type="hidden" name="next" value="{{ next }}" /> +</form> diff --git a/samples/django/django_user/polls/views.py b/samples/django/django_user/polls/views.py new file mode 100644 index 0000000..5888330 --- /dev/null +++ b/samples/django/django_user/polls/views.py @@ -0,0 +1,41 @@ +# 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. + +from django.http import HttpResponse + +from oauth2client.contrib.django_util import decorators + + +def index(request): + return HttpResponse("Hello world!") + + +@decorators.oauth_required +def get_profile_required(request): + resp, content = request.oauth.http.request( + 'https://www.googleapis.com/plus/v1/people/me') + return HttpResponse(content) + + +@decorators.oauth_enabled +def get_profile_optional(request): + if request.oauth.has_credentials(): + # this could be passed into a view + # request.oauth.http is also initialized + return HttpResponse('User email: {}'.format( + request.oauth.credentials.id_token['email'])) + else: + return HttpResponse( + 'Here is an OAuth Authorize link:<a href="{}">Authorize</a>' + .format(request.oauth.get_authorize_redirect())) diff --git a/samples/django/django_user/requirements.txt b/samples/django/django_user/requirements.txt new file mode 100644 index 0000000..b42af1f --- /dev/null +++ b/samples/django/django_user/requirements.txt @@ -0,0 +1,3 @@ +Django==1.10.0 +oauth2client==3.0.0 +jsonpickle==0.9.3 diff --git a/samples/django/google_user/manage.py b/samples/django/google_user/manage.py new file mode 100755 index 0000000..1a30708 --- /dev/null +++ b/samples/django/google_user/manage.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# 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 os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myoauth.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/samples/django/google_user/myoauth/__init__.py b/samples/django/google_user/myoauth/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/samples/django/google_user/myoauth/__init__.py diff --git a/samples/django/google_user/myoauth/settings.py b/samples/django/google_user/myoauth/settings.py new file mode 100644 index 0000000..e08661d --- /dev/null +++ b/samples/django/google_user/myoauth/settings.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. + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Quick-start development settings - unsuitable for production +# See https://docs.djangoproject.com/en/1.8/howto/deployment/checklist/ + +# SECURITY WARNING: keep the secret key used in production secret! +SECRET_KEY = 'eiw+mvmua#98n@p2xq+c#liz@r2&#-s07nkgz)+$zcl^o4$-$o' + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = True + +ALLOWED_HOSTS = [] + +# Application definition + +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'polls', + 'oauth2client.contrib.django_util', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', +) + +ROOT_URLCONF = 'myoauth.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'myoauth.wsgi.application' + +# Database +# https://docs.djangoproject.com/en/1.8/ref/settings/#databases + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.sqlite3', + 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), + } +} + +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ + +LANGUAGE_CODE = 'en-us' + +TIME_ZONE = 'UTC' + +USE_I18N = True + +USE_L10N = True + +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.8/howto/static-files/ + +STATIC_URL = '/static/' + +GOOGLE_OAUTH2_CLIENT_ID = 'YOUR_CLIENT_ID' + +GOOGLE_OAUTH2_CLIENT_SECRET = 'YOUR_CLIENT_SECRET' + +GOOGLE_OAUTH2_SCOPES = ( + 'email', 'profile') diff --git a/samples/django/google_user/myoauth/urls.py b/samples/django/google_user/myoauth/urls.py new file mode 100644 index 0000000..4d3d0a1 --- /dev/null +++ b/samples/django/google_user/myoauth/urls.py @@ -0,0 +1,26 @@ +# 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. + +from django.conf import urls +from polls import views + +import oauth2client.contrib.django_util.site as django_util_site + + +urlpatterns = [ + urls.url(r'^$', views.index), + urls.url(r'^profile_required$', views.get_profile_required), + urls.url(r'^profile_enabled$', views.get_profile_optional), + urls.url(r'^oauth2/', urls.include(django_util_site.urls)) +] diff --git a/samples/django/google_user/myoauth/wsgi.py b/samples/django/google_user/myoauth/wsgi.py new file mode 100644 index 0000000..39ee648 --- /dev/null +++ b/samples/django/google_user/myoauth/wsgi.py @@ -0,0 +1,21 @@ +# 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 os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "myoauth.settings") + +application = get_wsgi_application() diff --git a/samples/django/google_user/polls/__init__.py b/samples/django/google_user/polls/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/samples/django/google_user/polls/__init__.py diff --git a/samples/django/google_user/polls/views.py b/samples/django/google_user/polls/views.py new file mode 100644 index 0000000..e4b9119 --- /dev/null +++ b/samples/django/google_user/polls/views.py @@ -0,0 +1,41 @@ +# 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. + +from django import http + +from oauth2client.contrib.django_util import decorators + + +def index(request): + return http.HttpResponse("Hello world!") + + +@decorators.oauth_required +def get_profile_required(request): + resp, content = request.oauth.http.request( + 'https://www.googleapis.com/plus/v1/people/me') + return http.HttpResponse(content) + + +@decorators.oauth_enabled +def get_profile_optional(request): + if request.oauth.has_credentials(): + # this could be passed into a view + # request.oauth.http is also initialized + return http.HttpResponse('User email: {}'.format( + request.oauth.credentials.id_token['email'])) + else: + return http.HttpResponse( + 'Here is an OAuth Authorize link:<a href="{}">Authorize</a>' + .format(request.oauth.get_authorize_redirect())) diff --git a/samples/django/google_user/requirements.txt b/samples/django/google_user/requirements.txt new file mode 100644 index 0000000..b42af1f --- /dev/null +++ b/samples/django/google_user/requirements.txt @@ -0,0 +1,3 @@ +Django==1.10.0 +oauth2client==3.0.0 +jsonpickle==0.9.3 diff --git a/scripts/fetch_gae_sdk.py b/scripts/fetch_gae_sdk.py deleted file mode 100755 index 24a6db5..0000000 --- a/scripts/fetch_gae_sdk.py +++ /dev/null @@ -1,85 +0,0 @@ -#!/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 index 0ef7ad2..e1ed5c5 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -16,16 +16,18 @@ 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 +pip install --upgrade pip setuptools tox coveralls + +# App Engine tests require the App Engine SDK. +if [[ "${TOX_ENV}" == "gae" || "${TOX_ENV}" == "cover" ]]; then + pip install gcp-devrel-py-tools + gcp-devrel-py-tools download-appengine-sdk `dirname ${GAE_PYTHONPATH}` fi -if [[ "${TOX_ENV}" == "gae" && ! -d ${GAE_PYTHONPATH} ]]; then - python scripts/fetch_gae_sdk.py `dirname ${GAE_PYTHONPATH}` +# Travis ships with an old version of PyPy, so install at least version 2.6. +if [[ "${TOX_ENV}" == "pypy" ]]; then + if [ ! -d "${HOME}/.pyenv/bin" ]; then + git clone https://github.com/yyuu/pyenv.git ${HOME}/.pyenv + fi + ${HOME}/.pyenv/bin/pyenv install --skip-existing pypy-2.6.0 fi diff --git a/scripts/run.sh b/scripts/run.sh index 0b537e2..c774f24 100755 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -16,10 +16,11 @@ set -ev +# If in the pypy environment, activate the never version of pypy provided by +# pyenv. if [[ "${TOX_ENV}" == "pypy" ]]; then - PYENV_ROOT="${HOME}/.pyenv" - PATH="${PYENV_ROOT}/bin:${PATH}" - eval "$(pyenv init -)" - pyenv global pypy-2.6.0 + PATH="${HOME}/.pyenv/versions/pypy-2.6.0/bin:${PATH}" + export PATH fi + tox -e ${TOX_ENV} diff --git a/scripts/run_gce_system_tests.py b/scripts/run_gce_system_tests.py index d446f9c..80794bd 100644 --- a/scripts/run_gce_system_tests.py +++ b/scripts/run_gce_system_tests.py @@ -13,26 +13,26 @@ # limitations under the License. import json +import unittest -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 +import oauth2client +from oauth2client import client +from oauth2client import transport +from oauth2client.contrib import gce -class TestComputeEngine(unittest2.TestCase): +class TestComputeEngine(unittest.TestCase): def test_application_default(self): - default_creds = GoogleCredentials.get_application_default() - self.assertIsInstance(default_creds, AppAssertionCredentials) + default_creds = client.GoogleCredentials.get_application_default() + self.assertIsInstance(default_creds, gce.AppAssertionCredentials) def test_token_info(self): - credentials = AppAssertionCredentials([]) - http = httplib2.Http() + credentials = gce.AppAssertionCredentials([]) + http = transport.get_http_object() # First refresh to get the access token. self.assertIsNone(credentials.access_token) @@ -41,9 +41,9 @@ class TestComputeEngine(unittest2.TestCase): # Then check the access token against the token info API. query_params = {'access_token': credentials.access_token} - token_uri = (GOOGLE_TOKEN_INFO_URI + '?' + + token_uri = (oauth2client.GOOGLE_TOKEN_INFO_URI + '?' + urllib.parse.urlencode(query_params)) - response, content = http.request(token_uri) + response, content = transport.request(http, token_uri) self.assertEqual(response.status, http_client.OK) content = content.decode('utf-8') @@ -53,4 +53,4 @@ class TestComputeEngine(unittest2.TestCase): if __name__ == '__main__': - unittest2.main() + unittest.main() diff --git a/scripts/run_system_tests.py b/scripts/run_system_tests.py index ce99e7c..4c9c80c 100644 --- a/scripts/run_system_tests.py +++ b/scripts/run_system_tests.py @@ -15,12 +15,12 @@ import json import os -import httplib2 from six.moves import http_client import oauth2client from oauth2client import client -from oauth2client.service_account import ServiceAccountCredentials +from oauth2client import service_account +from oauth2client import transport JSON_KEY_PATH = os.getenv('OAUTH2CLIENT_TEST_JSON_KEY_PATH') @@ -56,8 +56,8 @@ def _require_environ(): def _check_user_info(credentials, expected_email): - http = credentials.authorize(httplib2.Http()) - response, content = http.request(USER_INFO) + http = credentials.authorize(transport.get_http_object()) + response, content = transport.request(http, USER_INFO) if response.status != http_client.OK: raise ValueError('Expected 200 OK response.') @@ -68,14 +68,14 @@ def _check_user_info(credentials, expected_email): def run_json(): - credentials = ServiceAccountCredentials.from_json_keyfile_name( - JSON_KEY_PATH, scopes=SCOPE) + factory = service_account.ServiceAccountCredentials.from_json_keyfile_name + credentials = factory(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( + credentials = service_account.ServiceAccountCredentials.from_p12_keyfile( P12_KEY_EMAIL, P12_KEY_PATH, scopes=SCOPE) _check_user_info(credentials, P12_KEY_EMAIL) diff --git a/scripts/run_system_tests.sh b/scripts/run_system_tests.sh index 7169eb7..2e10e5c 100755 --- a/scripts/run_system_tests.sh +++ b/scripts/run_system_tests.sh @@ -19,10 +19,9 @@ 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." + # If secure variables are available, run system test. + if [[ "${TRAVIS_SECURE_ENV_VARS}" ]]; then + echo "Running in Travis, 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} \ @@ -34,8 +33,8 @@ if [[ "${TRAVIS}" == "true" ]]; then -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} \ + openssl aes-256-cbc -K ${encrypted_1ee98544e5ca_key} \ + -iv ${encrypted_1ee98544e5ca_iv} \ -in tests/data/user-key.json.enc \ -out ${OAUTH2CLIENT_TEST_USER_KEY_PATH} -d else diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..2a9acf1 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,2 @@ +[bdist_wheel] +universal = 1 @@ -26,11 +26,11 @@ from setuptools import setup import oauth2client -if sys.version_info < (2, 6): - print('oauth2client requires python2 version >= 2.6.', file=sys.stderr) +if sys.version_info < (2, 7): + print('oauth2client requires python2 version >= 2.7.', file=sys.stderr) sys.exit(1) -if (3, 1) <= sys.version_info < (3, 3): - print('oauth2client requires python3 version >= 3.3.', file=sys.stderr) +if (3, 1) <= sys.version_info < (3, 4): + print('oauth2client requires python3 version >= 3.4.', file=sys.stderr) sys.exit(1) install_requires = [ @@ -41,29 +41,36 @@ install_requires = [ 'six>=1.6.1', ] -long_desc = """The oauth2client is a client library for OAuth 2.0.""" +long_desc = """ +oauth2client is a client library for OAuth 2.0. + +Note: oauth2client is now deprecated. No more features will be added to the + libraries and the core team is turning down support. We recommend you use + `google-auth <https://google-auth.readthedocs.io>`__ and + `oauthlib <http://oauthlib.readthedocs.io/>`__. +""" version = oauth2client.__version__ setup( - name="oauth2client", + name='oauth2client', version=version, - description="OAuth 2.0 client library", + description='OAuth 2.0 client library', long_description=long_desc, - author="Google Inc.", - url="http://github.com/google/oauth2client/", + author='Google Inc.', + author_email='jonwayne+oauth2client@google.com', + url='http://github.com/google/oauth2client/', install_requires=install_requires, - packages=find_packages(), - license="Apache 2.0", - keywords="google oauth 2.0 http client", + packages=find_packages(exclude=('tests*',)), + 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', + 'Programming Language :: Python :: 3.5', + 'Development Status :: 7 - Inactive', 'Intended Audience :: Developers', 'License :: OSI Approved :: Apache Software License', 'Operating System :: POSIX', diff --git a/tests/__init__.py b/tests/__init__.py index 5f6567c..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,22 +0,0 @@ -# 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/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..caadb80 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,31 @@ +# 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. + +"""Py.test hooks.""" + +from oauth2client import _helpers + + +def pytest_addoption(parser): + """Adds the --gae-sdk option to py.test. + + This is used to enable the GAE tests. This has to be in this conftest.py + due to the way py.test collects conftest files.""" + parser.addoption('--gae-sdk') + + +def pytest_configure(config): + """Py.test hook called before loading tests.""" + # Default of POSITIONAL_WARNING is too verbose for testing + _helpers.positional_parameters_enforcement = _helpers.POSITIONAL_EXCEPTION diff --git a/tests/contrib/appengine/__init__.py b/tests/contrib/appengine/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/tests/contrib/appengine/__init__.py diff --git a/tests/contrib/appengine/conftest.py b/tests/contrib/appengine/conftest.py new file mode 100644 index 0000000..b56fbcd --- /dev/null +++ b/tests/contrib/appengine/conftest.py @@ -0,0 +1,53 @@ +# 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. + +"""App Engine py.test configuration.""" + +import sys + +from six.moves import reload_module + + +def set_up_gae_environment(sdk_path): + """Set up appengine SDK third-party imports. + + The App Engine SDK does terrible things to the global interpreter state. + Because of this, this stuff can't be neatly undone. As such, it can't be + a fixture. + """ + if 'google' in sys.modules: + # Some packages, such as protobuf, clobber the google + # namespace package. This prevents that. + reload_module(sys.modules['google']) + + # This sets up google-provided libraries. + sys.path.insert(0, sdk_path) + import dev_appserver + dev_appserver.fix_sys_path() + + # Fixes timezone and other os-level items. + import google.appengine.tools.os_compat # noqa: unused import + + +def pytest_configure(config): + """Configures the App Engine SDK imports on py.test startup.""" + if config.getoption('gae_sdk') is not None: + set_up_gae_environment(config.getoption('gae_sdk')) + + +def pytest_ignore_collect(path, config): + """Skip App Engine tests when --gae-sdk is not specified.""" + return ( + 'contrib/appengine' in str(path) and + config.getoption('gae_sdk') is None) diff --git a/tests/contrib/test__appengine_ndb.py b/tests/contrib/appengine/test__appengine_ndb.py index 41e3805..9af1dcc 100644 --- a/tests/contrib/test__appengine_ndb.py +++ b/tests/contrib/appengine/test__appengine_ndb.py @@ -14,17 +14,17 @@ import json import os +import unittest 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') +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'data') def datafile(filename): @@ -36,7 +36,7 @@ class TestNDBModel(ndb.Model): creds = appengine.CredentialsNDBProperty() -class TestFlowNDBProperty(unittest2.TestCase): +class TestFlowNDBProperty(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -85,7 +85,7 @@ class TestFlowNDBProperty(unittest2.TestCase): type(flow_val)) -class TestCredentialsNDBProperty(unittest2.TestCase): +class TestCredentialsNDBProperty(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() diff --git a/tests/contrib/test_appengine.py b/tests/contrib/appengine/test_appengine.py index cdaf6c5..36d2713 100644 --- a/tests/contrib/test_appengine.py +++ b/tests/contrib/appengine/test_appengine.py @@ -17,10 +17,7 @@ import json import os import tempfile import time - -import dev_appserver - -dev_appserver.fix_sys_path() +import unittest from google.appengine.api import apiproxy_stub from google.appengine.api import apiproxy_stub_map @@ -31,10 +28,9 @@ 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 +from six.moves import urllib_parse import webapp2 from webtest import TestApp @@ -42,11 +38,20 @@ import oauth2client from oauth2client import client from oauth2client import clientsecrets from oauth2client.contrib import appengine -from ..http_mock import CacheMock +from tests import http_mock -__author__ = 'jcgregorio@google.com (Joe Gregorio)' -DATA_DIR = os.path.join(os.path.dirname(__file__), '..', 'data') +DATA_DIR = os.path.join(os.path.dirname(__file__), '..', '..', 'data') +DEFAULT_RESP = """\ +{ + "access_token": "foo_access_token", + "expires_in": 3600, + "extra": "value", + "refresh_token": "foo_refresh_token" +} +""" +BASIC_TOKEN = 'bar' +BASIC_RESP = json.dumps({'access_token': BASIC_TOKEN}) def datafile(filename): @@ -75,23 +80,7 @@ class UserNotLoggedInMock(object): 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): +class TestAppAssertionCredentials(unittest.TestCase): account_name = "service_account_name@appspot.com" signature = "signature" @@ -139,7 +128,7 @@ class TestAppAssertionCredentials(unittest2.TestCase): scope = 'http://www.googleapis.com/scope' credentials = appengine.AppAssertionCredentials(scope) - http = httplib2.Http() + http = http_mock.HttpMock(data=DEFAULT_RESP) with self.assertRaises(client.AccessTokenRefreshError): credentials.refresh(http) @@ -155,7 +144,7 @@ class TestAppAssertionCredentials(unittest2.TestCase): "http://www.googleapis.com/scope", "http://www.googleapis.com/scope2"] credentials = appengine.AppAssertionCredentials(scope) - http = httplib2.Http() + http = http_mock.HttpMock(data=DEFAULT_RESP) credentials.refresh(http) self.assertEqual('a_token_123', credentials.access_token) @@ -168,7 +157,7 @@ class TestAppAssertionCredentials(unittest2.TestCase): scope = ('http://www.googleapis.com/scope ' 'http://www.googleapis.com/scope2') credentials = appengine.AppAssertionCredentials(scope) - http = httplib2.Http() + http = http_mock.HttpMock(data=DEFAULT_RESP) credentials.refresh(http) self.assertEqual('a_token_123', credentials.access_token) self.assertEqual( @@ -184,7 +173,7 @@ class TestAppAssertionCredentials(unittest2.TestCase): autospec=True) as get_access_token: credentials = appengine.AppAssertionCredentials( scope, service_account_id=account_id) - http = httplib2.Http() + http = http_mock.HttpMock(data=DEFAULT_RESP) credentials.refresh(http) self.assertEqual('a_token_456', credentials.access_token) @@ -276,7 +265,7 @@ class TestFlowModel(db.Model): flow = appengine.FlowProperty() -class FlowPropertyTest(unittest2.TestCase): +class FlowPropertyTest(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -315,7 +304,7 @@ class TestCredentialsModel(db.Model): credentials = appengine.CredentialsProperty() -class CredentialsPropertyTest(unittest2.TestCase): +class CredentialsPropertyTest(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -369,14 +358,7 @@ class CredentialsPropertyTest(unittest2.TestCase): 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): +class StorageByKeyNameTest(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -420,6 +402,23 @@ class StorageByKeyNameTest(unittest2.TestCase): storage._model = appengine.CredentialsNDBModel self.assertTrue(storage._is_ndb()) + def _verify_basic_refresh(self, http): + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, oauth2client.GOOGLE_TOKEN_URI) + self.assertEqual(http.method, 'POST') + expected_body = { + 'grant_type': ['refresh_token'], + 'client_id': [self.credentials.client_id], + 'client_secret': [self.credentials.client_secret], + 'refresh_token': [self.credentials.refresh_token], + } + self.assertEqual(urllib_parse.parse_qs(http.body), expected_body) + expected_headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'user-agent': self.credentials.user_agent, + } + self.assertEqual(http.headers, expected_headers) + def test_get_and_put_simple(self): storage = appengine.StorageByKeyName( appengine.CredentialsModel, 'foo', 'credentials') @@ -427,9 +426,12 @@ class StorageByKeyNameTest(unittest2.TestCase): self.assertEqual(None, storage.get()) self.credentials.set_store(storage) - self.credentials._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + self.credentials._refresh(http) credmodel = appengine.CredentialsModel.get_by_key_name('foo') - self.assertEqual('bar', credmodel.credentials.access_token) + self.assertEqual(BASIC_TOKEN, credmodel.credentials.access_token) + # Verify mock. + self._verify_basic_refresh(http) def test_get_and_put_cached(self): storage = appengine.StorageByKeyName( @@ -438,16 +440,17 @@ class StorageByKeyNameTest(unittest2.TestCase): self.assertEqual(None, storage.get()) self.credentials.set_store(storage) - self.credentials._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + self.credentials._refresh(http) credmodel = appengine.CredentialsModel.get_by_key_name('foo') - self.assertEqual('bar', credmodel.credentials.access_token) + self.assertEqual(BASIC_TOKEN, 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.assertEqual(BASIC_TOKEN, credentials.access_token) self.assertNotEqual(None, memcache.get('foo')) # Deleting should clear the cache. @@ -456,6 +459,9 @@ class StorageByKeyNameTest(unittest2.TestCase): self.assertEqual(None, credentials) self.assertEqual(None, memcache.get('foo')) + # Verify mock. + self._verify_basic_refresh(http) + def test_get_and_put_set_store_on_cache_retrieval(self): storage = appengine.StorageByKeyName( appengine.CredentialsModel, 'foo', 'credentials', cache=memcache) @@ -468,9 +474,13 @@ class StorageByKeyNameTest(unittest2.TestCase): old_creds = storage.get() self.assertEqual(old_creds.access_token, 'foo') old_creds.invalid = True - old_creds._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + old_creds._refresh(http) new_creds = storage.get() - self.assertEqual(new_creds.access_token, 'bar') + self.assertEqual(new_creds.access_token, BASIC_TOKEN) + + # Verify mock. + self._verify_basic_refresh(http) def test_get_and_put_ndb(self): # Start empty @@ -480,12 +490,16 @@ class StorageByKeyNameTest(unittest2.TestCase): # Refresh storage and retrieve without using storage self.credentials.set_store(storage) - self.credentials._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + self.credentials._refresh(http) credmodel = appengine.CredentialsNDBModel.get_by_id('foo') - self.assertEqual('bar', credmodel.credentials.access_token) + self.assertEqual(BASIC_TOKEN, credmodel.credentials.access_token) self.assertEqual(credmodel.credentials.to_json(), self.credentials.to_json()) + # Verify mock. + self._verify_basic_refresh(http) + def test_delete_ndb(self): # Start empty storage = appengine.StorageByKeyName( @@ -511,14 +525,18 @@ class StorageByKeyNameTest(unittest2.TestCase): # Set NDB store and refresh to add to storage self.credentials.set_store(storage) - self.credentials._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + self.credentials._refresh(http) # 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(BASIC_TOKEN, credmodel.credentials.access_token) self.assertEqual(self.credentials.to_json(), credmodel.credentials.to_json()) + # Verify mock. + self._verify_basic_refresh(http) + def test_get_and_put_mixed_db_storage_ndb_get(self): # Start empty storage = appengine.StorageByKeyName( @@ -527,14 +545,18 @@ class StorageByKeyNameTest(unittest2.TestCase): # Set DB store and refresh to add to storage self.credentials.set_store(storage) - self.credentials._refresh(_http_request) + http = http_mock.HttpMock(data=BASIC_RESP) + self.credentials._refresh(http) # 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(BASIC_TOKEN, credmodel.credentials.access_token) self.assertEqual(self.credentials.to_json(), credmodel.credentials.to_json()) + # Verify mock. + self._verify_basic_refresh(http) + def test_delete_db_ndb_mixed(self): # Start empty storage_ndb = appengine.StorageByKeyName( @@ -573,7 +595,7 @@ class MockRequestHandler(object): request = MockRequest() -class DecoratorTests(unittest2.TestCase): +class DecoratorTests(unittest.TestCase): def setUp(self): self.testbed = testbed.Testbed() @@ -630,12 +652,9 @@ class DecoratorTests(unittest2.TestCase): }) 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 @@ -655,7 +674,9 @@ class DecoratorTests(unittest2.TestCase): app.router.match_routes[0].handler.__name__, 'OAuth2Handler') - def test_required(self): + @mock.patch('oauth2client.transport.get_http_object') + def test_required(self, new_http): + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) # An initial request to an oauth_required decorated path should be a # redirect to start the OAuth dance. self.assertEqual(self.decorator.flow, None) @@ -688,7 +709,7 @@ class DecoratorTests(unittest2.TestCase): response_query = urllib.parse.parse_qs(parts[1]) response = response_query[ self.decorator._token_response_param][0] - self.assertEqual(Http2Mock.content, + self.assertEqual(json.loads(DEFAULT_RESP), json.loads(urllib.parse.unquote(response))) self.assertEqual(self.decorator.flow, self.decorator._tls.flow) self.assertEqual(self.decorator.credentials, @@ -736,7 +757,12 @@ class DecoratorTests(unittest2.TestCase): self.assertEqual('http://localhost/oauth2callback', query_params['redirect_uri'][0]) - def test_storage_delete(self): + # Check the mocks were called. + new_http.assert_called_once_with() + + @mock.patch('oauth2client.transport.get_http_object') + def test_storage_delete(self, new_http): + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) # An initial request to an oauth_required decorated path should be a # redirect to start the OAuth dance. response = self.app.get('/foo_path') @@ -772,7 +798,12 @@ class DecoratorTests(unittest2.TestCase): parse_state_value.assert_called_once_with( 'foo_path:xsrfkey123', self.current_user) - def test_aware(self): + # Check the mocks were called. + new_http.assert_called_once_with() + + @mock.patch('oauth2client.transport.get_http_object') + def test_aware(self, new_http): + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) # An initial request to an oauth_aware decorated path should # not redirect. response = self.app.get('http://localhost/bar_path/2012/01') @@ -825,6 +856,9 @@ class DecoratorTests(unittest2.TestCase): self.should_raise = False self.assertEqual(None, self.decorator.credentials) + # Check the mocks were called. + new_http.assert_called_once_with() + def test_error_in_step2(self): # An initial request to an oauth_aware decorated path should # not redirect. @@ -855,10 +889,14 @@ class DecoratorTests(unittest2.TestCase): self.assertEqual(decorator.flow, decorator._tls.flow) def test_token_response_param(self): + # No need to set-up a mock since test_required() does. self.decorator._token_response_param = 'foobar' self.test_required() - def test_decorator_from_client_secrets(self): + @mock.patch('oauth2client.transport.get_http_object') + def test_decorator_from_client_secrets(self, new_http): + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) + # Execute test after setting up mock. decorator = appengine.OAuth2DecoratorFromClientSecrets( datafile('client_secrets.json'), scope=['foo_scope', 'bar_scope']) @@ -873,10 +911,13 @@ class DecoratorTests(unittest2.TestCase): # revoke_uri is not required self.assertEqual(self.decorator._revoke_uri, - 'https://accounts.google.com/o/oauth2/revoke') + 'https://oauth2.googleapis.com/revoke') self.assertEqual(self.decorator._revoke_uri, self.decorator.credentials.revoke_uri) + # Check the mocks were called. + new_http.assert_called_once_with() + def test_decorator_from_client_secrets_toplevel(self): decorator_patch = mock.patch( 'oauth2client.contrib.appengine.OAuth2DecoratorFromClientSecrets') @@ -915,7 +956,7 @@ class DecoratorTests(unittest2.TestCase): self.assertIn('prompt', decorator._kwargs) def test_decorator_from_cached_client_secrets(self): - cache_mock = CacheMock() + cache_mock = http_mock.CacheMock() load_and_cache('client_secrets.json', 'secret', cache_mock) decorator = appengine.OAuth2DecoratorFromClientSecrets( # filename, scope, message=None, cache=None @@ -975,11 +1016,11 @@ class DecoratorTests(unittest2.TestCase): '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", + 'client_id': 'foo_client_id', + 'client_secret': 'foo_client_secret', + 'redirect_uris': [], + 'auth_uri': oauth2client.GOOGLE_AUTH_URI, + 'token_uri': oauth2client.GOOGLE_TOKEN_URI, # No revoke URI }) @@ -991,7 +1032,10 @@ class DecoratorTests(unittest2.TestCase): # This is never set, but it's consistent with other tests. self.assertFalse(decorator._in_error) - def test_invalid_state(self): + @mock.patch('oauth2client.transport.get_http_object') + def test_invalid_state(self, new_http): + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) + # Execute test after setting up mock. with mock.patch.object(appengine, '_parse_state_value', return_value=None, autospec=True): # Now simulate the callback to /oauth2callback. @@ -1002,8 +1046,11 @@ class DecoratorTests(unittest2.TestCase): self.assertEqual('200 OK', response.status) self.assertEqual('The authorization request failed', response.body) + # Check the mocks were called. + new_http.assert_called_once_with() + -class DecoratorXsrfSecretTests(unittest2.TestCase): +class DecoratorXsrfSecretTests(unittest.TestCase): """Test xsrf_secret_key.""" def setUp(self): @@ -1052,7 +1099,7 @@ class DecoratorXsrfSecretTests(unittest2.TestCase): self.assertEqual(site_key.secret, secret) -class DecoratorXsrfProtectionTests(unittest2.TestCase): +class DecoratorXsrfProtectionTests(unittest.TestCase): """Test _build_state_value and _parse_state_value.""" def setUp(self): diff --git a/tests/contrib/django_util/test_decorators.py b/tests/contrib/django_util/test_decorators.py index 846c6dd..f237f88 100644 --- a/tests/contrib/django_util/test_decorators.py +++ b/tests/contrib/django_util/test_decorators.py @@ -18,18 +18,18 @@ import copy from django import http import django.conf -from django.contrib.auth.models import AnonymousUser, User +from django.contrib.auth import models as django_models 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 +from tests.contrib import django_util as tests_django_util -class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment): +class OAuth2EnabledDecoratorTest(tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(OAuth2EnabledDecoratorTest, self).setUp() @@ -39,7 +39,7 @@ class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment): # 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( + self.user = django_models.User.objects.create_user( username='bill', email='bill@example.com', password='hunter2') def tearDown(self): @@ -63,7 +63,7 @@ class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment): @mock.patch('oauth2client.client.OAuth2Credentials') def test_has_credentials_in_storage(self, OAuth2Credentials): request = self.factory.get('/test') - request.session = mock.MagicMock() + request.session = mock.Mock() credentials_mock = mock.Mock( scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES)) @@ -88,11 +88,11 @@ class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment): @mock.patch('oauth2client.contrib.dictionary_storage.DictionaryStorage') def test_specified_scopes(self, dictionary_storage_mock): request = self.factory.get('/test') - request.session = mock.MagicMock() + request.session = mock.Mock() credentials_mock = mock.Mock( scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES)) - credentials_mock.has_scopes = True + credentials_mock.has_scopes = mock.Mock(return_value=True) credentials_mock.is_valid = True dictionary_storage_mock.get.return_value = credentials_mock @@ -106,14 +106,14 @@ class OAuth2EnabledDecoratorTest(TestWithDjangoEnvironment): self.assertFalse(request.oauth.has_credentials()) -class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment): +class OAuth2RequiredDecoratorTest(tests_django_util.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( + self.user = django_models.User.objects.create_user( username='bill', email='bill@example.com', password='hunter2') def tearDown(self): @@ -141,13 +141,13 @@ class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment): @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() + request.session = mock.Mock() @decorators.oauth_required def test_view(request): return http.HttpResponse("test") - my_user_oauth = mock.MagicMock() + my_user_oauth = mock.Mock() UserOAuth2.return_value = my_user_oauth my_user_oauth.has_credentials.return_value = True @@ -161,7 +161,7 @@ class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment): self, OAuth2Credentials): request = self.factory.get('/test') - request.session = mock.MagicMock() + request.session = mock.Mock() credentials_mock = mock.Mock( scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES)) credentials_mock.has_scopes.return_value = False @@ -179,11 +179,11 @@ class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment): @mock.patch('oauth2client.client.OAuth2Credentials') def test_specified_scopes(self, OAuth2Credentials): request = self.factory.get('/test') - request.session = mock.MagicMock() + request.session = mock.Mock() credentials_mock = mock.Mock( scopes=set(django.conf.settings.GOOGLE_OAUTH2_SCOPES)) - credentials_mock.has_scopes = False + credentials_mock.has_scopes = mock.Mock(return_value=False) OAuth2Credentials.from_json.return_value = credentials_mock @decorators.oauth_required(scopes=['additional-scope']) @@ -195,7 +195,8 @@ class OAuth2RequiredDecoratorTest(TestWithDjangoEnvironment): response.status_code, django.http.HttpResponseRedirect.status_code) -class OAuth2RequiredDecoratorStorageModelTest(TestWithDjangoEnvironment): +class OAuth2RequiredDecoratorStorageModelTest( + tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(OAuth2RequiredDecoratorStorageModelTest, self).setUp() @@ -209,7 +210,7 @@ class OAuth2RequiredDecoratorStorageModelTest(TestWithDjangoEnvironment): django.conf.settings.GOOGLE_OAUTH2_STORAGE_MODEL = STORAGE_MODEL reload_module(oauth2client.contrib.django_util) - self.user = User.objects.create_user( + self.user = django_models.User.objects.create_user( username='bill', email='bill@example.com', password='hunter2') def tearDown(self): @@ -219,7 +220,7 @@ class OAuth2RequiredDecoratorStorageModelTest(TestWithDjangoEnvironment): def test_redirects_anonymous_to_login(self): request = self.factory.get('/test') request.session = self.session - request.user = AnonymousUser() + request.user = django_models.AnonymousUser() @decorators.oauth_required def test_view(request): @@ -233,7 +234,7 @@ class OAuth2RequiredDecoratorStorageModelTest(TestWithDjangoEnvironment): def test_redirects_user_to_oauth_authorize(self): request = self.factory.get('/test') request.session = self.session - request.user = User.objects.create_user( + request.user = django_models.User.objects.create_user( username='bill3', email='bill@example.com', password='hunter2') @decorators.oauth_required diff --git a/tests/contrib/django_util/test_django_models.py b/tests/contrib/django_util/test_django_models.py index aeaed15..da54965 100644 --- a/tests/contrib/django_util/test_django_models.py +++ b/tests/contrib/django_util/test_django_models.py @@ -19,36 +19,42 @@ Unit tests for models and fields defined by the django_util helper. import base64 import pickle +import unittest -from tests.contrib.django_util.models import CredentialsModel +import jsonpickle -import unittest2 +from oauth2client import _helpers +from oauth2client import client +from oauth2client.contrib.django_util import models +from tests.contrib.django_util import models as tests_models -from oauth2client._helpers import _from_bytes -from oauth2client.client import Credentials -from oauth2client.contrib.django_util.models import CredentialsField - -class TestCredentialsField(unittest2.TestCase): +class TestCredentialsField(unittest.TestCase): def setUp(self): - self.fake_model = CredentialsModel() + self.fake_model = tests_models.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( + self.field = models.CredentialsField(null=True) + self.credentials = client.Credentials() + self.pickle_str = _helpers._from_bytes( base64.b64encode(pickle.dumps(self.credentials))) + self.jsonpickle_str = _helpers._from_bytes( + base64.b64encode(jsonpickle.encode(self.credentials).encode())) 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) + self.field.to_python(self.pickle_str), client.Credentials) + + def test_field_jsonunpickled(self): + self.assertIsInstance( + self.field.to_python(self.jsonpickle_str), client.Credentials) def test_field_already_unpickled(self): self.assertIsInstance( - self.field.to_python(self.credentials), Credentials) + self.field.to_python(self.credentials), client.Credentials) def test_none_field_unpickled(self): self.assertIsNone(self.field.to_python(None)) @@ -56,7 +62,7 @@ class TestCredentialsField(unittest2.TestCase): def test_from_db_value(self): value = self.field.from_db_value( self.pickle_str, None, None, None) - self.assertIsInstance(value, Credentials) + self.assertIsInstance(value, client.Credentials) def test_field_unpickled_none(self): self.assertEqual(self.field.to_python(None), None) @@ -64,12 +70,12 @@ class TestCredentialsField(unittest2.TestCase): def test_field_pickled(self): prep_value = self.field.get_db_prep_value(self.credentials, connection=None) - self.assertEqual(prep_value, self.pickle_str) + self.assertEqual(prep_value, self.jsonpickle_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) + self.assertEqual(value_str, self.jsonpickle_str) def test_field_value_to_string_none(self): self.fake_model.credentials = None @@ -77,11 +83,11 @@ class TestCredentialsField(unittest2.TestCase): self.assertIsNone(value_str) def test_credentials_without_null(self): - credentials = CredentialsField() + credentials = models.CredentialsField() self.assertTrue(credentials.null) -class CredentialWithSetStore(CredentialsField): +class CredentialWithSetStore(models.CredentialsField): def __init__(self): self.model = CredentialWithSetStore @@ -96,4 +102,4 @@ class FakeCredentialsModelMock(object): class FakeCredentialsModelMockNoSet(object): - credentials = CredentialsField() + credentials = models.CredentialsField() diff --git a/tests/contrib/django_util/test_django_storage.py b/tests/contrib/django_util/test_django_storage.py index 8f76b18..a608c94 100644 --- a/tests/contrib/django_util/test_django_storage.py +++ b/tests/contrib/django_util/test_django_storage.py @@ -16,10 +16,10 @@ # Mock a Django environment import datetime +import unittest from django.db import models import mock -import unittest2 from oauth2client import GOOGLE_TOKEN_URI from oauth2client.client import OAuth2Credentials @@ -28,7 +28,7 @@ from oauth2client.contrib.django_util.storage import ( DjangoORMStorage as Storage) -class TestStorage(unittest2.TestCase): +class TestStorage(unittest.TestCase): def setUp(self): access_token = 'foo' client_id = 'some_client_id' diff --git a/tests/contrib/django_util/test_django_util.py b/tests/contrib/django_util/test_django_util.py index 84457cb..82d7be7 100644 --- a/tests/contrib/django_util/test_django_util.py +++ b/tests/contrib/django_util/test_django_util.py @@ -15,20 +15,19 @@ """Tests the initialization logic of django_util.""" import copy +import unittest import django.conf from django.conf.urls import include, url -from django.contrib.auth.models import AnonymousUser +from django.contrib.auth import models as django_models 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) +from oauth2client.contrib.django_util import site +from tests.contrib import django_util as tests_django_util urlpatterns = [ @@ -36,7 +35,7 @@ urlpatterns = [ ] -class OAuth2SetupTest(unittest2.TestCase): +class OAuth2SetupTest(unittest.TestCase): def setUp(self): self.save_settings = copy.deepcopy(django.conf.settings) @@ -101,6 +100,20 @@ class OAuth2SetupTest(unittest2.TestCase): object.__new__(django_util.OAuth2Settings), django.conf.settings) + def test_no_middleware(self): + django.conf.settings.MIDDLEWARE_CLASSES = None + with self.assertRaises(exceptions.ImproperlyConfigured): + django_util.OAuth2Settings.__init__( + object.__new__(django_util.OAuth2Settings), + django.conf.settings) + + def test_middleware_no_classes(self): + django.conf.settings.MIDDLEWARE = ( + django.conf.settings.MIDDLEWARE_CLASSES) + django.conf.settings.MIDDLEWARE_CLASSES = None + # primarily testing this doesn't raise an exception + django_util.OAuth2Settings(django.conf.settings) + def test_storage_model(self): STORAGE_MODEL = { 'model': 'tests.contrib.django_util.models.CredentialsModel', @@ -121,7 +134,7 @@ class MockObjectWithSession(object): self.session = session -class SessionStorageTest(TestWithDjangoEnvironment): +class SessionStorageTest(tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(SessionStorageTest, self).setUp() @@ -133,19 +146,19 @@ class SessionStorageTest(TestWithDjangoEnvironment): django.conf.settings = copy.deepcopy(self.save_settings) def test_session_delete(self): - self.session[_CREDENTIALS_KEY] = "test_val" + self.session[django_util._CREDENTIALS_KEY] = "test_val" request = MockObjectWithSession(self.session) - django_storage = get_storage(request) + django_storage = django_util.get_storage(request) django_storage.delete() - self.assertIsNone(self.session.get(_CREDENTIALS_KEY)) + self.assertIsNone(self.session.get(django_util._CREDENTIALS_KEY)) def test_session_delete_nothing(self): request = MockObjectWithSession(self.session) - django_storage = get_storage(request) + django_storage = django_util.get_storage(request) django_storage.delete() -class TestUserOAuth2Object(TestWithDjangoEnvironment): +class TestUserOAuth2Object(tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(TestUserOAuth2Object, self).setUp() @@ -167,6 +180,6 @@ class TestUserOAuth2Object(TestWithDjangoEnvironment): request = self.factory.get('oauth2/oauth2authorize', data={'return_url': '/return_endpoint'}) request.session = self.session - request.user = AnonymousUser() - oauth2 = UserOAuth2(request) + request.user = django_models.AnonymousUser() + oauth2 = django_util.UserOAuth2(request) self.assertIsNone(oauth2.credentials) diff --git a/tests/contrib/django_util/test_views.py b/tests/contrib/django_util/test_views.py index df0d11c..0b3fe30 100644 --- a/tests/contrib/django_util/test_views.py +++ b/tests/contrib/django_util/test_views.py @@ -20,27 +20,25 @@ import json import django from django import http import django.conf -from django.contrib.auth.models import AnonymousUser, User +from django.contrib.auth import models as django_models 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 +from oauth2client import client import oauth2client.contrib.django_util from oauth2client.contrib.django_util import views -from oauth2client.contrib.django_util.models import CredentialsField +from tests.contrib import django_util as tests_django_util +from tests.contrib.django_util import models as tests_models -class OAuth2AuthorizeTest(TestWithDjangoEnvironment): +class OAuth2AuthorizeTest(tests_django_util.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') + self.user = django_models.User.objects.create_user( + username='bill', email='bill@example.com', password='hunter2') def tearDown(self): django.conf.settings = copy.deepcopy(self.save_settings) @@ -55,7 +53,7 @@ class OAuth2AuthorizeTest(TestWithDjangoEnvironment): def test_authorize_anonymous_user(self): request = self.factory.get('oauth2/oauth2authorize') request.session = self.session - request.user = AnonymousUser() + request.user = django_models.AnonymousUser() response = views.oauth2_authorize(request) self.assertIsInstance(response, http.HttpResponseRedirect) @@ -68,7 +66,8 @@ class OAuth2AuthorizeTest(TestWithDjangoEnvironment): self.assertIsInstance(response, http.HttpResponseRedirect) -class Oauth2AuthorizeStorageModelTest(TestWithDjangoEnvironment): +class Oauth2AuthorizeStorageModelTest( + tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(Oauth2AuthorizeStorageModelTest, self).setUp() @@ -85,7 +84,7 @@ class Oauth2AuthorizeStorageModelTest(TestWithDjangoEnvironment): # 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( + self.user = django_models.User.objects.create_user( username='bill', email='bill@example.com', password='hunter2') def tearDown(self): @@ -103,7 +102,7 @@ class Oauth2AuthorizeStorageModelTest(TestWithDjangoEnvironment): def test_authorize_anonymous_user_redirects_login(self): request = self.factory.get('oauth2/oauth2authorize') request.session = self.session - request.user = AnonymousUser() + request.user = django_models.AnonymousUser() response = views.oauth2_authorize(request) self.assertIsInstance(response, http.HttpResponseRedirect) # redirects to Django login @@ -117,25 +116,53 @@ class Oauth2AuthorizeStorageModelTest(TestWithDjangoEnvironment): response = views.oauth2_authorize(request) self.assertIsInstance(response, http.HttpResponseRedirect) - def test_authorized_user_not_logged_in_redirects(self): + def test_authorized_user_no_credentials_redirects(self): + request = self.factory.get('oauth2/oauth2authorize', + data={'return_url': '/return_endpoint'}) + request.session = self.session + + authorized_user = django_models.User.objects.create_user( + username='bill2', email='bill@example.com', password='hunter2') + + tests_models.CredentialsModel.objects.create( + user_id=authorized_user, + credentials=None) + + request.user = authorized_user + response = views.oauth2_authorize(request) + self.assertIsInstance(response, http.HttpResponseRedirect) + + def test_already_authorized(self): request = self.factory.get('oauth2/oauth2authorize', data={'return_url': '/return_endpoint'}) request.session = self.session - authorized_user = User.objects.create_user( + authorized_user = django_models.User.objects.create_user( username='bill2', email='bill@example.com', password='hunter2') - credentials = CredentialsField() - CredentialsModel.objects.create( + credentials = _Credentials() + tests_models.CredentialsModel.objects.create( user_id=authorized_user, credentials=credentials) request.user = authorized_user response = views.oauth2_authorize(request) self.assertIsInstance(response, http.HttpResponseRedirect) + self.assertEqual(response.url, '/return_endpoint') + + +class _Credentials(object): + # Can't use mock when testing Django models + # https://code.djangoproject.com/ticket/25493 + def __init__(self): + self.invalid = False + self.scopes = set() + + def has_scopes(self, _): + return True -class Oauth2CallbackTest(TestWithDjangoEnvironment): +class Oauth2CallbackTest(tests_django_util.TestWithDjangoEnvironment): def setUp(self): super(Oauth2CallbackTest, self).setUp() @@ -149,11 +176,11 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): 'return_url': self.RETURN_URL, 'scopes': django.conf.settings.GOOGLE_OAUTH2_SCOPES } - self.user = User.objects.create_user( + self.user = django_models.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): + @mock.patch('oauth2client.contrib.django_util.views.jsonpickle') + def test_callback_works(self, jsonpickle_mock): request = self.factory.get('oauth2/oauth2callback', data={ 'state': json.dumps(self.fake_state), 'code': 123 @@ -161,7 +188,7 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): self.session['google_oauth2_csrf_token'] = self.CSRF_TOKEN - flow = OAuth2WebServerFlow( + flow = client.OAuth2WebServerFlow( client_id='clientid', client_secret='clientsecret', scope=['email'], @@ -169,9 +196,10 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): redirect_uri=request.build_absolute_uri("oauth2/oauth2callback")) name = 'google_oauth2_flow_{0}'.format(self.CSRF_TOKEN) - self.session[name] = pickle.dumps(flow) + pickled_flow = object() + self.session[name] = pickled_flow flow.step2_exchange = mock.Mock() - pickle.loads.return_value = flow + jsonpickle_mock.decode.return_value = flow request.session = self.session request.user = self.user @@ -180,9 +208,10 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): self.assertEqual( response.status_code, django.http.HttpResponseRedirect.status_code) self.assertEqual(response['Location'], self.RETURN_URL) + jsonpickle_mock.decode.assert_called_once_with(pickled_flow) - @mock.patch('oauth2client.contrib.django_util.views.pickle') - def test_callback_handles_bad_flow_exchange(self, pickle): + @mock.patch('oauth2client.contrib.django_util.views.jsonpickle') + def test_callback_handles_bad_flow_exchange(self, jsonpickle_mock): request = self.factory.get('oauth2/oauth2callback', data={ "state": json.dumps(self.fake_state), "code": 123 @@ -190,25 +219,27 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): self.session['google_oauth2_csrf_token'] = self.CSRF_TOKEN - flow = OAuth2WebServerFlow( + flow = client.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) + session_key = 'google_oauth2_flow_{0}'.format(self.CSRF_TOKEN) + pickled_flow = object() + self.session[session_key] = pickled_flow def local_throws(code): - raise FlowExchangeError('test') + raise client.FlowExchangeError('test') flow.step2_exchange = local_throws - pickle.loads.return_value = flow + jsonpickle_mock.decode.return_value = flow request.session = self.session response = views.oauth2_callback(request) self.assertIsInstance(response, http.HttpResponseBadRequest) + jsonpickle_mock.decode.assert_called_once_with(pickled_flow) def test_error_returns_bad_request(self): request = self.factory.get('oauth2/oauth2callback', data={ @@ -218,6 +249,15 @@ class Oauth2CallbackTest(TestWithDjangoEnvironment): self.assertIsInstance(response, http.HttpResponseBadRequest) self.assertIn(b'Authorization failed', response.content) + def test_error_escapes_html(self): + request = self.factory.get('oauth2/oauth2callback', data={ + 'error': '<script>bad</script>', + }) + response = views.oauth2_callback(request) + self.assertIsInstance(response, http.HttpResponseBadRequest) + self.assertNotIn(b'<script>', response.content) + self.assertIn(b'<script>', response.content) + def test_no_session(self): request = self.factory.get('oauth2/oauth2callback', data={ 'code': 123, diff --git a/tests/contrib/test_devshell.py b/tests/contrib/test_devshell.py index 659a53b..1346080 100644 --- a/tests/contrib/test_devshell.py +++ b/tests/contrib/test_devshell.py @@ -19,9 +19,9 @@ import json import os import socket import threading +import unittest import mock -import unittest2 from oauth2client import _helpers from oauth2client import client @@ -38,7 +38,7 @@ DEFAULT_CREDENTIAL_JSON = json.dumps([ ]) -class TestCredentialInfoResponse(unittest2.TestCase): +class TestCredentialInfoResponse(unittest.TestCase): def test_constructor_with_non_list(self): json_non_list = '{}' @@ -71,29 +71,29 @@ class TestCredentialInfoResponse(unittest2.TestCase): self.assertEqual(info_response.expires_in, expires_in) -class Test_SendRecv(unittest2.TestCase): +class Test_SendRecv(unittest.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) + os_mod.getenv = mock.Mock(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() + sock = mock.Mock() header_without_newline = '' - sock.recv(6).decode = mock.MagicMock( + sock.recv(6).decode = mock.Mock( 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) + os_mod.getenv = mock.Mock(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) + socket.socket = mock.Mock(name='socket', + return_value=sock) with self.assertRaises(devshell.CommunicationError): devshell._SendRecv() os_mod.getenv.assert_called_once_with(devshell.DEVSHELL_ENV, 0) @@ -160,15 +160,15 @@ class _AuthReferenceServer(threading.Thread): 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()) + response_len = len(self.response) + s.sendall('{0}\n{1}'.format(response_len, self.response).encode()) finally: # Will fail if s is None, but these tests never encounter # that scenario. s.close() -class DevshellCredentialsTests(unittest2.TestCase): +class DevshellCredentialsTests(unittest.TestCase): def test_signals_no_server(self): with self.assertRaises(devshell.NoDevshellServer): diff --git a/tests/contrib/test_dictionary_storage.py b/tests/contrib/test_dictionary_storage.py index 888c938..b9f833b 100644 --- a/tests/contrib/test_dictionary_storage.py +++ b/tests/contrib/test_dictionary_storage.py @@ -14,7 +14,7 @@ """Unit tests for oauth2client.contrib.dictionary_storage""" -import unittest2 +import unittest import oauth2client from oauth2client import client @@ -37,7 +37,7 @@ def _generate_credentials(scopes=None): scopes=scopes) -class DictionaryStorageTests(unittest2.TestCase): +class DictionaryStorageTests(unittest.TestCase): def test_constructor_defaults(self): dictionary = {} diff --git a/tests/contrib/test_flask_util.py b/tests/contrib/test_flask_util.py index 74cb218..112bff0 100644 --- a/tests/contrib/test_flask_util.py +++ b/tests/contrib/test_flask_util.py @@ -17,54 +17,31 @@ import datetime import json import logging +import unittest 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 +from tests import http_mock -__author__ = 'jonwayne@google.com (Jon Wayne Parrott)' +DEFAULT_RESP = """\ +{ + "access_token": "foo_access_token", + "expires_in": 3600, + "extra": "value", + "refresh_token": "foo_refresh_token" +} +""" -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): +class FlaskOAuth2Tests(unittest.TestCase): def setUp(self): self.app = flask.Flask(__name__) @@ -246,7 +223,12 @@ class FlaskOAuth2Tests(unittest2.TestCase): def test_callback_view(self): self.oauth2.storage = mock.Mock() with self.app.test_client() as client: - with Http2Mock() as http: + with mock.patch( + 'oauth2client.transport.get_http_object') as new_http: + # Set-up mock. + http = http_mock.HttpMock(data=DEFAULT_RESP) + new_http.return_value = http + # Run tests. state = self._setup_callback_state(client) response = client.get( @@ -258,6 +240,9 @@ class FlaskOAuth2Tests(unittest2.TestCase): self.assertIn('codez', http.body) self.assertTrue(self.oauth2.storage.put.called) + # Check the mocks were called. + new_http.assert_called_once_with() + def test_authorize_callback(self): self.oauth2.authorize_callback = mock.Mock() self.test_callback_view() @@ -273,6 +258,18 @@ class FlaskOAuth2Tests(unittest2.TestCase): self.assertEqual(response.status_code, httplib.BAD_REQUEST) self.assertIn('something', response.data.decode('utf-8')) + # Error supplied to callback with html + 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=<script>something<script>') + self.assertEqual(response.status_code, httplib.BAD_REQUEST) + self.assertIn( + '<script>something<script>', + response.data.decode('utf-8')) + # CSRF mismatch with self.app.test_client() as client: with client.session_transaction() as session: @@ -296,11 +293,20 @@ class FlaskOAuth2Tests(unittest2.TestCase): with self.app.test_client() as client: state = self._setup_callback_state(client) - with Http2Mock(status=httplib.INTERNAL_SERVER_ERROR): + with mock.patch( + 'oauth2client.transport.get_http_object') as new_http: + # Set-up mock. + new_http.return_value = http_mock.HttpMock( + headers={'status': httplib.INTERNAL_SERVER_ERROR}, + data=DEFAULT_RESP) + # Run tests. response = client.get( '/oauth2callback?state={0}&code=codez'.format(state)) self.assertEqual(response.status_code, httplib.BAD_REQUEST) + # Check the mocks were called. + new_http.assert_called_once_with() + # Invalid state json with self.app.test_client() as client: with client.session_transaction() as session: @@ -495,7 +501,10 @@ class FlaskOAuth2Tests(unittest2.TestCase): def test_incremental_auth_exchange(self): self._create_incremental_auth_app() - with Http2Mock(): + with mock.patch('oauth2client.transport.get_http_object') as new_http: + # Set-up mock. + new_http.return_value = http_mock.HttpMock(data=DEFAULT_RESP) + # Run tests. with self.app.test_client() as client: state = self._setup_callback_state( client, @@ -511,16 +520,21 @@ class FlaskOAuth2Tests(unittest2.TestCase): self.assertTrue( credentials.has_scopes(['email', 'one', 'two'])) + # Check the mocks were called. + new_http.assert_called_once_with() + def test_refresh(self): + token_val = 'new_token' + json_resp = '{"access_token": "%s"}' % (token_val,) + http = http_mock.HttpMock(data=json_resp) 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.oauth2.credentials.refresh(http) self.assertEqual( - self.oauth2.storage.get().access_token, 'new_token') + self.oauth2.storage.get().access_token, token_val) def test_delete(self): with self.app.test_request_context(): diff --git a/tests/contrib/test_gce.py b/tests/contrib/test_gce.py index e71bd44..5f34995 100644 --- a/tests/contrib/test_gce.py +++ b/tests/contrib/test_gce.py @@ -16,26 +16,28 @@ import datetime import json +import os +import unittest -import httplib2 import mock from six.moves import http_client -from tests.contrib.test_metadata import request_mock -import unittest2 +from six.moves import reload_module from oauth2client import client +from oauth2client.contrib import _metadata from oauth2client.contrib import gce +from tests import http_mock -__author__ = 'jcgregorio@google.com (Joe Gregorio)' SERVICE_ACCOUNT_INFO = { 'scopes': ['a', 'b'], 'email': 'a@example.com', 'aliases': ['default'] } +METADATA_PATH = 'instance/service-accounts/a@example.com/token' -class AppAssertionCredentialsTests(unittest2.TestCase): +class AppAssertionCredentialsTests(unittest.TestCase): def test_constructor(self): credentials = gce.AppAssertionCredentials() @@ -68,8 +70,7 @@ class AppAssertionCredentialsTests(unittest2.TestCase): @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) + http_mock = object() credentials = gce.AppAssertionCredentials() credentials.invalid = False credentials.service_account_email = 'a@example.com' @@ -77,26 +78,34 @@ class AppAssertionCredentialsTests(unittest2.TestCase): 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, + get_token.assert_called_with(http_mock, 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, + get_token.assert_called_with(http_mock, 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}) - ) + headers = { + 'status': http_client.NOT_FOUND, + 'content-type': 'application/json', + } + response = json.dumps({'access_token': 'a', 'expires_in': 100}) + http = http_mock.HttpMock(headers=headers, data=response) credentials = gce.AppAssertionCredentials() credentials.invalid = False credentials.service_account_email = 'a@example.com' with self.assertRaises(client.HttpAccessTokenRefreshError): - credentials._refresh(http_request) + credentials._refresh(http) + # Verify mock. + self.assertEqual(http.requests, 1) + expected_uri = _metadata.METADATA_ROOT + METADATA_PATH + self.assertEqual(http.uri, expected_uri) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) def test_serialization_data(self): credentials = gce.AppAssertionCredentials() @@ -115,8 +124,7 @@ class AppAssertionCredentialsTests(unittest2.TestCase): @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) + http_mock = object() credentials = gce.AppAssertionCredentials() self.assertTrue(credentials.invalid) self.assertIsNone(credentials.scopes) @@ -125,19 +133,18 @@ class AppAssertionCredentialsTests(unittest2.TestCase): self.assertFalse(credentials.invalid) credentials.retrieve_scopes(http_mock) # Assert scopes weren't refetched - metadata.assert_called_once_with(http_request, + metadata.assert_called_once_with(http_mock, service_account='default') @mock.patch('oauth2client.contrib._metadata.get_service_account_info', - side_effect=httplib2.HttpLib2Error('No Such Email')) + side_effect=http_client.HTTPException('No Such Email')) def test_retrieve_scopes_bad_email(self, metadata): - http_request = mock.MagicMock() - http_mock = mock.MagicMock(request=http_request) + http_mock = object() credentials = gce.AppAssertionCredentials(email='b@example.com') - with self.assertRaises(httplib2.HttpLib2Error): + with self.assertRaises(http_client.HTTPException): credentials.retrieve_scopes(http_mock) - metadata.assert_called_once_with(http_request, + metadata.assert_called_once_with(http_mock, service_account='b@example.com') def test_save_to_well_known_file(self): @@ -150,3 +157,19 @@ class AppAssertionCredentialsTests(unittest2.TestCase): client.save_to_well_known_file(credentials) finally: os.path.isdir = ORIGINAL_ISDIR + + def test_custom_metadata_root_from_env(self): + headers = {'content-type': 'application/json'} + http = http_mock.HttpMock(headers=headers, data='{}') + fake_metadata_root = 'another.metadata.service' + os.environ['GCE_METADATA_ROOT'] = fake_metadata_root + reload_module(_metadata) + try: + _metadata.get(http, '') + finally: + del os.environ['GCE_METADATA_ROOT'] + reload_module(_metadata) + # Verify mock. + self.assertEqual(http.requests, 1) + expected_uri = 'http://{}/computeMetadata/v1/'.format(fake_metadata_root) + self.assertEqual(http.uri, expected_uri) diff --git a/tests/contrib/test_keyring_storage.py b/tests/contrib/test_keyring_storage.py index 5d274c0..0f8090d 100644 --- a/tests/contrib/test_keyring_storage.py +++ b/tests/contrib/test_keyring_storage.py @@ -16,20 +16,17 @@ import datetime import threading +import unittest 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): +class KeyringStorageTests(unittest.TestCase): def test_constructor(self): service_name = 'my_unit_test' @@ -58,15 +55,15 @@ class KeyringStorageTests(unittest2.TestCase): 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( + mock_return_creds = mock.Mock() + mock_return_creds.set_store = set_store = mock.Mock( 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( + MockCreds.new_from_json = new_from_json = mock.Mock( name='new_from_json', return_value=mock_return_creds) store = keyring_storage.Storage(service_name, user_name) credentials = store.locked_get() @@ -82,9 +79,9 @@ class KeyringStorageTests(unittest2.TestCase): with mock.patch.object(keyring, 'set_password', return_value=None, autospec=True) as set_password: - credentials = mock.MagicMock() + credentials = mock.Mock() to_json_ret = object() - credentials.to_json = to_json = mock.MagicMock( + credentials.to_json = to_json = mock.Mock( name='to_json', return_value=to_json_ret) store.locked_put(credentials) to_json.assert_called_once_with() diff --git a/tests/contrib/test_locked_file.py b/tests/contrib/test_locked_file.py deleted file mode 100644 index 384bef3..0000000 --- a/tests/contrib/test_locked_file.py +++ /dev/null @@ -1,244 +0,0 @@ -# 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 index 7f11d04..cd48f0a 100644 --- a/tests/contrib/test_metadata.py +++ b/tests/contrib/test_metadata.py @@ -14,84 +14,103 @@ import datetime import json +import unittest -import httplib2 import mock from six.moves import http_client -import unittest2 from oauth2client.contrib import _metadata +from tests import http_mock + 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') - )) + headers = {'status': status, 'content-type': content_type} + http = http_mock.HttpMock(headers=headers, + data=content.encode('utf-8')) + return http -class TestMetadata(unittest2.TestCase): +class TestMetadata(unittest.TestCase): def test_get_success_json(self): - http_request = request_mock( + http = request_mock( http_client.OK, 'application/json', json.dumps(DATA)) self.assertEqual( - _metadata.get(http_request, PATH), + _metadata.get(http, PATH), DATA ) - http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS) + + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, EXPECTED_URL) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) def test_get_success_string(self): - http_request = request_mock( + http = request_mock( http_client.OK, 'text/html', '<p>Hello World!</p>') self.assertEqual( - _metadata.get(http_request, PATH), + _metadata.get(http, PATH), '<p>Hello World!</p>' ) - http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS) + + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, EXPECTED_URL) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) def test_get_failure(self): - http_request = request_mock( + http = request_mock( http_client.NOT_FOUND, 'text/html', '<p>Error</p>') - with self.assertRaises(httplib2.HttpLib2Error): - _metadata.get(http_request, PATH) + with self.assertRaises(http_client.HTTPException): + _metadata.get(http, PATH) - http_request.assert_called_once_with(EXPECTED_URL, **EXPECTED_KWARGS) + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, EXPECTED_URL) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) @mock.patch( 'oauth2client.client._UTCNOW', return_value=datetime.datetime.min) def test_get_token_success(self, now): - http_request = request_mock( + http = request_mock( http_client.OK, 'application/json', json.dumps({'access_token': 'a', 'expires_in': 100}) ) - token, expiry = _metadata.get_token(http_request=http_request) + token, expiry = _metadata.get_token(http=http) 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 - ) + # Verify mocks. now.assert_called_once_with() + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, EXPECTED_URL + '/token') + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) def test_service_account_info(self): - http_request = request_mock( + http = request_mock( http_client.OK, 'application/json', json.dumps(DATA)) - info = _metadata.get_service_account_info(http_request) + info = _metadata.get_service_account_info(http) self.assertEqual(info, DATA) - http_request.assert_called_once_with( - EXPECTED_URL + '/?recursive=True', - **EXPECTED_KWARGS - ) + # Verify mock. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, EXPECTED_URL + '/?recursive=True') + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, _metadata.METADATA_HEADERS) diff --git a/tests/contrib/test_multiprocess_file_storage.py b/tests/contrib/test_multiprocess_file_storage.py index bf30c14..d8b91a9 100644 --- a/tests/contrib/test_multiprocess_file_storage.py +++ b/tests/contrib/test_multiprocess_file_storage.py @@ -20,16 +20,16 @@ import json import multiprocessing import os import tempfile +import unittest import fasteners import mock -from six import StringIO -import unittest2 +import six +from six.moves import urllib_parse from oauth2client import client from oauth2client.contrib import multiprocess_file_storage - -from ..http_mock import HttpMockSequence +from tests import http_mock @contextlib.contextmanager @@ -68,14 +68,10 @@ def _generate_token_response_http(new_token='new_token'): 'access_token': new_token, 'expires_in': '3600', }) - http = HttpMockSequence([ - ({'status': '200'}, token_response), - ]) - - return http + return http_mock.HttpMock(data=token_response) -class MultiprocessStorageBehaviorTests(unittest2.TestCase): +class MultiprocessStorageBehaviorTests(unittest.TestCase): def setUp(self): filehandle, self.filename = tempfile.mkstemp( @@ -115,6 +111,23 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): self.assertIsNone(credentials) + def _verify_refresh_payload(self, http, credentials): + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, credentials.token_uri) + self.assertEqual(http.method, 'POST') + expected_body = { + 'grant_type': ['refresh_token'], + 'client_id': [credentials.client_id], + 'client_secret': [credentials.client_secret], + 'refresh_token': [credentials.refresh_token], + } + self.assertEqual(urllib_parse.parse_qs(http.body), expected_body) + expected_headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'user-agent': credentials.user_agent, + } + self.assertEqual(http.headers, expected_headers) + def test_single_process_refresh(self): store = multiprocess_file_storage.MultiprocessFileStorage( self.filename, 'single-process') @@ -128,6 +141,9 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): retrieved = store.get() self.assertEqual(retrieved.access_token, 'new_token') + # Verify mocks. + self._verify_refresh_payload(http, credentials) + def test_multi_process_refresh(self): # This will test that two processes attempting to refresh credentials # will only refresh once. @@ -136,6 +152,7 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): credentials = _create_test_credentials() credentials.set_store(store) store.put(credentials) + actual_token = 'b' def child_process_func( die_event, ready_event, check_event): # pragma: NO COVER @@ -156,10 +173,12 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): credentials.store.acquire_lock = replacement_acquire_lock - http = _generate_token_response_http('b') + http = _generate_token_response_http(actual_token) credentials.refresh(http) + self.assertEqual(credentials.access_token, actual_token) - self.assertEqual(credentials.access_token, 'b') + # Verify mock http. + self._verify_refresh_payload(http, credentials) check_event = multiprocessing.Event() with scoped_child_process(child_process_func, check_event=check_event): @@ -168,15 +187,17 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): 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() + http = _generate_token_response_http('not ' + actual_token) credentials.refresh(http=http) - self.assertEqual(credentials.access_token, 'b') - self.assertFalse(http.request.called) + # The child process will refresh first, so we should end up + # with `actual_token`' as the token. + self.assertEqual(credentials.access_token, actual_token) + + # Make sure the refresh did not make a request. + self.assertEqual(http.requests, 0) retrieved = store.get() - self.assertEqual(retrieved.access_token, 'b') + self.assertEqual(retrieved.access_token, actual_token) def test_read_only_file_fail_lock(self): credentials = _create_test_credentials() @@ -200,7 +221,7 @@ class MultiprocessStorageBehaviorTests(unittest2.TestCase): self.assertIsNotNone(store.get()) -class MultiprocessStorageUnitTests(unittest2.TestCase): +class MultiprocessStorageUnitTests(unittest.TestCase): def setUp(self): filehandle, self.filename = tempfile.mkstemp( @@ -233,7 +254,7 @@ class MultiprocessStorageUnitTests(unittest2.TestCase): def test__read_write_credentials_file(self): credentials = _create_test_credentials() - contents = StringIO() + contents = six.StringIO() multiprocess_file_storage._write_credentials_file( contents, {'key': credentials}) @@ -253,23 +274,23 @@ class MultiprocessStorageUnitTests(unittest2.TestCase): # the invalid one but still load the valid one. data['credentials']['invalid'] = '123' results = multiprocess_file_storage._load_credentials_file( - StringIO(json.dumps(data))) + six.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('{[') + contents = six.StringIO('{[') self.assertEqual( multiprocess_file_storage._load_credentials_file(contents), {}) def test__load_credentials_file_no_file_version(self): - contents = StringIO('{}') + contents = six.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})) + contents = six.StringIO(json.dumps({'file_version': 1})) self.assertEqual( multiprocess_file_storage._load_credentials_file(contents), {}) @@ -310,4 +331,4 @@ class MultiprocessStorageUnitTests(unittest2.TestCase): if __name__ == '__main__': # pragma: NO COVER - unittest2.main() + unittest.main() diff --git a/tests/contrib/test_multistore_file.py b/tests/contrib/test_multistore_file.py deleted file mode 100644 index b5cb598..0000000 --- a/tests/contrib/test_multistore_file.py +++ /dev/null @@ -1,383 +0,0 @@ -# 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 index 421f516..068aa92 100644 --- a/tests/contrib/test_sqlalchemy.py +++ b/tests/contrib/test_sqlalchemy.py @@ -13,11 +13,12 @@ # limitations under the License. import datetime +import unittest +import mock import sqlalchemy import sqlalchemy.ext.declarative import sqlalchemy.orm -import unittest2 import oauth2client import oauth2client.client @@ -36,7 +37,7 @@ class DummyModel(Base): oauth2client.contrib.sqlalchemy.CredentialsType) -class TestSQLAlchemyStorage(unittest2.TestCase): +class TestSQLAlchemyStorage(unittest.TestCase): def setUp(self): engine = sqlalchemy.create_engine('sqlite://') Base.metadata.create_all(engine) @@ -66,7 +67,8 @@ class TestSQLAlchemyStorage(unittest2.TestCase): self.assertEqual(result.token_uri, self.credentials.token_uri) self.assertEqual(result.user_agent, self.credentials.user_agent) - def test_get(self): + @mock.patch('oauth2client.client.OAuth2Credentials.set_store') + def test_get(self, set_store): session = self.session() credentials_storage = oauth2client.contrib.sqlalchemy.Storage( session=session, @@ -75,7 +77,21 @@ class TestSQLAlchemyStorage(unittest2.TestCase): key_value=1, property_name='credentials', ) + # No credentials stored self.assertIsNone(credentials_storage.get()) + + # Invalid credentials stored + session.add(DummyModel( + key=1, + credentials=oauth2client.client.Credentials(), + )) + session.commit() + bad_credentials = credentials_storage.get() + self.assertIsInstance(bad_credentials, oauth2client.client.Credentials) + set_store.assert_not_called() + + # Valid credentials stored + session.query(DummyModel).filter_by(key=1).delete() session.add(DummyModel( key=1, credentials=self.credentials, @@ -83,16 +99,20 @@ class TestSQLAlchemyStorage(unittest2.TestCase): session.commit() self.compare_credentials(credentials_storage.get()) + set_store.assert_called_with(credentials_storage) def test_put(self): session = self.session() - oauth2client.contrib.sqlalchemy.Storage( + storage = oauth2client.contrib.sqlalchemy.Storage( session=session, model_class=DummyModel, key_name='key', key_value=1, property_name='credentials', - ).put(self.credentials) + ) + # Store invalid credentials first to verify overwriting + storage.put(oauth2client.client.Credentials()) + storage.put(self.credentials) session.commit() entity = session.query(DummyModel).filter_by(key=1).first() diff --git a/tests/contrib/test_xsrfutil.py b/tests/contrib/test_xsrfutil.py index 64b842f..3115827 100644 --- a/tests/contrib/test_xsrfutil.py +++ b/tests/contrib/test_xsrfutil.py @@ -15,9 +15,9 @@ """Tests for oauth2client.contrib.xsrfutil.""" import base64 +import unittest import mock -import unittest2 from oauth2client import _helpers from oauth2client.contrib import xsrfutil @@ -34,10 +34,7 @@ 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): +class Test_generate_token(unittest.TestCase): def test_bad_positional(self): # Need 2 positional arguments. @@ -49,10 +46,10 @@ class Test_generate_token(unittest2.TestCase): def test_it(self): digest = b'foobar' - digester = mock.MagicMock() - digester.digest = mock.MagicMock(name='digest', return_value=digest) + digester = mock.Mock() + digester.digest = mock.Mock(name='digest', return_value=digest) with mock.patch('oauth2client.contrib.xsrfutil.hmac') as hmac: - hmac.new = mock.MagicMock(name='new', return_value=digester) + hmac.new = mock.Mock(name='new', return_value=digester) token = xsrfutil.generate_token(TEST_KEY, TEST_USER_ID_1, action_id=TEST_ACTION_ID_1, @@ -78,13 +75,13 @@ class Test_generate_token(unittest2.TestCase): 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) + digester = mock.Mock() + digester.digest = mock.Mock(name='digest', return_value=digest) with mock.patch('oauth2client.contrib.xsrfutil.hmac') as hmac: - hmac.new = mock.MagicMock(name='new', return_value=digester) + hmac.new = mock.Mock(name='new', return_value=digester) with mock.patch('oauth2client.contrib.xsrfutil.time') as time: - time.time = mock.MagicMock(name='time', return_value=curr_time) + time.time = mock.Mock(name='time', return_value=curr_time) # when= is omitted token = xsrfutil.generate_token(TEST_KEY, TEST_USER_ID_1, @@ -111,7 +108,7 @@ class Test_generate_token(unittest2.TestCase): self.assertEqual(token, expected_token) -class Test_validate_token(unittest2.TestCase): +class Test_validate_token(unittest.TestCase): def test_bad_positional(self): # Need 3 positional arguments. @@ -142,7 +139,7 @@ class Test_validate_token(unittest2.TestCase): 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) + time.time = mock.Mock(name='time', return_value=curr_time) self.assertFalse(xsrfutil.validate_token(key, token, user_id)) time.time.assert_called_once_with() @@ -218,7 +215,7 @@ class Test_validate_token(unittest2.TestCase): when=token_time) -class XsrfUtilTests(unittest2.TestCase): +class XsrfUtilTests(unittest.TestCase): """Test xsrfutil functions.""" def testGenerateAndValidateToken(self): diff --git a/tests/data/client_secrets.json b/tests/data/client_secrets.json index 5356103..81079e6 100644 --- a/tests/data/client_secrets.json +++ b/tests/data/client_secrets.json @@ -4,7 +4,7 @@ "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" + "token_uri": "https://oauth2.googleapis.com/token", + "revoke_uri": "https://oauth2.googleapis.com/revoke" } } diff --git a/tests/data/unfilled_client_secrets.json b/tests/data/unfilled_client_secrets.json index a85ca01..8b5d55e 100644 --- a/tests/data/unfilled_client_secrets.json +++ b/tests/data/unfilled_client_secrets.json @@ -4,6 +4,6 @@ "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" + "token_uri": "https://oauth2.googleapis.com/token" } } diff --git a/tests/data/user-key.json.enc b/tests/data/user-key.json.enc Binary files differindex 03e1bc6..5f53207 100644 --- a/tests/data/user-key.json.enc +++ b/tests/data/user-key.json.enc diff --git a/tests/http_mock.py b/tests/http_mock.py index 6053299..a29024f 100644 --- a/tests/http_mock.py +++ b/tests/http_mock.py @@ -12,31 +12,41 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Copy of googleapiclient.http's mock functionality.""" +"""HTTP helpers mock functionality.""" -import httplib2 -# TODO(craigcitro): Find a cleaner way to share this code with googleapiclient. +from six.moves import http_client + + +class ResponseMock(dict): + """Mock HTTP response""" + + def __init__(self, vals=None): + if vals is None: + vals = {} + self.update(vals) + self.status = int(self.get('status', http_client.OK)) class HttpMock(object): - """Mock of httplib2.Http""" + """Mock of HTTP object.""" - def __init__(self, headers=None): + def __init__(self, headers=None, data=None): """HttpMock constructor. Args: headers: dict, header to return with response """ if headers is None: - headers = {'status': '200'} - self.data = None + headers = {'status': http_client.OK} + self.data = data self.response_headers = headers self.headers = None self.uri = None self.method = None self.body = None self.headers = None + self.requests = 0 def request(self, uri, method='GET', @@ -48,22 +58,24 @@ class HttpMock(object): self.method = method self.body = body self.headers = headers - return httplib2.Response(self.response_headers), self.data + self.redirections = redirections + self.requests += 1 + return ResponseMock(self.response_headers), self.data class HttpMockSequence(object): - """Mock of httplib2.Http + """Mock of HTTP object with multiple return values. 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:: + and content and then use as if an HttpMock 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") + 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. @@ -80,7 +92,6 @@ class HttpMockSequence(object): iterable: iterable, a sequence of pairs of (headers, body) """ self._iterable = iterable - self.follow_redirects = True self.requests = [] def request(self, uri, @@ -90,7 +101,12 @@ class HttpMockSequence(object): redirections=1, connection_type=None): resp, content = self._iterable.pop(0) - self.requests.append({'uri': uri, 'body': body, 'headers': headers}) + self.requests.append({ + 'method': method, + '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) @@ -99,7 +115,7 @@ class HttpMockSequence(object): elif content == 'echo_request_body': content = (body if body_stream_content is None else body_stream_content) - return httplib2.Response(resp), content + return ResponseMock(resp), content class CacheMock(object): diff --git a/tests/test__helpers.py b/tests/test__helpers.py index cd54186..00cd38a 100644 --- a/tests/test__helpers.py +++ b/tests/test__helpers.py @@ -11,14 +11,133 @@ # 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 +import unittest + +import mock from oauth2client import _helpers +from tests import test_client + + +class PositionalTests(unittest.TestCase): + + def test_usage(self): + _helpers.positional_parameters_enforcement = ( + _helpers.POSITIONAL_EXCEPTION) + + # 1 positional arg, 1 keyword-only arg. + @_helpers.positional(1) + def function(pos, kwonly=None): + return True + + self.assertTrue(function(1)) + self.assertTrue(function(1, kwonly=2)) + with self.assertRaises(TypeError): + function(1, 2) + + # No positional, but a required keyword arg. + @_helpers.positional(0) + def function2(required_kw): + return True + + self.assertTrue(function2(required_kw=1)) + with self.assertRaises(TypeError): + function2(1) + + # Unspecified positional, should automatically figure out 1 positional + # 1 keyword-only (same as first case above). + @_helpers.positional + def function3(pos, kwonly=None): + return True + + self.assertTrue(function3(1)) + self.assertTrue(function3(1, kwonly=2)) + with self.assertRaises(TypeError): + function3(1, 2) + + @mock.patch('oauth2client._helpers.logger') + def test_enforcement_warning(self, mock_logger): + _helpers.positional_parameters_enforcement = ( + _helpers.POSITIONAL_WARNING) + + @_helpers.positional(1) + def function(pos, kwonly=None): + return True + + self.assertTrue(function(1, 2)) + self.assertTrue(mock_logger.warning.called) + + @mock.patch('oauth2client._helpers.logger') + def test_enforcement_ignore(self, mock_logger): + _helpers.positional_parameters_enforcement = _helpers.POSITIONAL_IGNORE + + @_helpers.positional(1) + def function(pos, kwonly=None): + return True + + self.assertTrue(function(1, 2)) + self.assertFalse(mock_logger.warning.called) + + +class ScopeToStringTests(unittest.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, _helpers.scopes_to_string(case)) + + +class StringToScopeTests(unittest.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, _helpers.string_to_scopes(case)) -class Test__parse_pem_key(unittest2.TestCase): + +class AddQueryParameterTests(unittest.TestCase): + + def test__add_query_parameter(self): + self.assertEqual( + _helpers._add_query_parameter('/action', 'a', None), + '/action') + self.assertEqual( + _helpers._add_query_parameter('/action', 'a', 'b'), + '/action?a=b') + self.assertEqual( + _helpers._add_query_parameter('/action?a=b', 'a', 'c'), + '/action?a=c') + # Order is non-deterministic. + self.assertIn( + _helpers._add_query_parameter('/action?a=b', 'c', 'd'), + ['/action?a=b&c=d', '/action?c=d&a=b']) + self.assertEqual( + _helpers._add_query_parameter('/action', 'a', ' ='), + '/action?a=+%3D') + + +class Test__parse_pem_key(unittest.TestCase): def test_valid_input(self): test_string = b'1234-----BEGIN FOO BAR BAZ' @@ -31,7 +150,7 @@ class Test__parse_pem_key(unittest2.TestCase): self.assertEqual(result, None) -class Test__json_encode(unittest2.TestCase): +class Test__json_encode(unittest.TestCase): def test_dictionary_input(self): # Use only a single key since dictionary hash order @@ -46,7 +165,7 @@ class Test__json_encode(unittest2.TestCase): self.assertEqual(result, '[42,1337]') -class Test__to_bytes(unittest2.TestCase): +class Test__to_bytes(unittest.TestCase): def test_with_bytes(self): value = b'bytes-val' @@ -63,7 +182,7 @@ class Test__to_bytes(unittest2.TestCase): _helpers._to_bytes(value) -class Test__from_bytes(unittest2.TestCase): +class Test__from_bytes(unittest.TestCase): def test_with_unicode(self): value = u'bytes-val' @@ -80,10 +199,15 @@ class Test__from_bytes(unittest2.TestCase): _helpers._from_bytes(value) -class Test__urlsafe_b64encode(unittest2.TestCase): +class Test__urlsafe_b64encode(unittest.TestCase): DEADBEEF_ENCODED = b'ZGVhZGJlZWY' + def test_valid_input_str(self): + test_string = 'deadbeef' + result = _helpers._urlsafe_b64encode(test_string) + self.assertEqual(result, self.DEADBEEF_ENCODED) + def test_valid_input_bytes(self): test_string = b'deadbeef' result = _helpers._urlsafe_b64encode(test_string) @@ -95,20 +219,66 @@ class Test__urlsafe_b64encode(unittest2.TestCase): self.assertEqual(result, self.DEADBEEF_ENCODED) -class Test__urlsafe_b64decode(unittest2.TestCase): +class Test__urlsafe_b64decode(unittest.TestCase): + + DEADBEEF_DECODED = b'deadbeef' + + def test_valid_input_str(self): + test_string = 'ZGVhZGJlZWY' + result = _helpers._urlsafe_b64decode(test_string) + self.assertEqual(result, self.DEADBEEF_DECODED) def test_valid_input_bytes(self): test_string = b'ZGVhZGJlZWY' result = _helpers._urlsafe_b64decode(test_string) - self.assertEqual(result, b'deadbeef') + self.assertEqual(result, self.DEADBEEF_DECODED) def test_valid_input_unicode(self): - test_string = b'ZGVhZGJlZWY' + test_string = u'ZGVhZGJlZWY' result = _helpers._urlsafe_b64decode(test_string) - self.assertEqual(result, b'deadbeef') + self.assertEqual(result, self.DEADBEEF_DECODED) def test_bad_input(self): import binascii bad_string = b'+' with self.assertRaises((TypeError, binascii.Error)): _helpers._urlsafe_b64decode(bad_string) + + +class Test_update_query_params(unittest.TestCase): + + def test_update_query_params_no_params(self): + uri = 'http://www.google.com' + updated = _helpers.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 = _helpers.update_query_params(uri, {'a': 'b', 'c': 'd&'}) + hardcoded_update = uri + '&a=b&c=d%26' + test_client.assertUrisEqual(self, updated, hardcoded_update) + + def test_update_query_params_replace_param(self): + base_uri = 'http://www.google.com' + uri = base_uri + '?x=a' + updated = _helpers.update_query_params(uri, {'x': 'b', 'y': 'c'}) + hardcoded_update = base_uri + '?x=b&y=c' + test_client.assertUrisEqual(self, updated, hardcoded_update) + + def test_update_query_params_repeated_params(self): + uri = 'http://www.google.com?x=a&x=b' + with self.assertRaises(ValueError): + _helpers.update_query_params(uri, {'a': 'c'}) + + +class Test_parse_unique_urlencoded(unittest.TestCase): + + def test_without_repeats(self): + content = 'a=b&c=d' + result = _helpers.parse_unique_urlencoded(content) + self.assertEqual(result, {'a': 'b', 'c': 'd'}) + + def test_with_repeats(self): + content = 'a=b&a=d' + with self.assertRaises(ValueError): + _helpers.parse_unique_urlencoded(content) diff --git a/tests/test__pkce.py b/tests/test__pkce.py new file mode 100644 index 0000000..9f66560 --- /dev/null +++ b/tests/test__pkce.py @@ -0,0 +1,54 @@ +# 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 unittest + +import mock + +from oauth2client import _pkce + + +class PKCETests(unittest.TestCase): + + @mock.patch('oauth2client._pkce.os.urandom') + def test_verifier(self, fake_urandom): + canned_randomness = ( + b'\x98\x10D7\xf3\xb7\xaa\xfc\xdd\xd3M\xe2' + b'\xa3,\x06\xa0\xb0\xa9\xb4\x8f\xcb\xd0' + b'\xf5\x86N2p\x8c]!W\x9a\xed54\x99\x9d' + b'\x8dv\\\xa7/\x81\xf3J\x98\xc3\x90\xee' + b'\xb0\x8c\xb7Zc#\x05M0O\x08\xda\t\x1f\x07' + ) + fake_urandom.return_value = canned_randomness + expected = ( + b'mBBEN_O3qvzd003ioywGoLCptI_L0PWGTjJwjF0hV5rt' + b'NTSZnY12XKcvgfNKmMOQ7rCMt1pjIwVNME8I2gkfBw' + ) + result = _pkce.code_verifier() + self.assertEqual(result, expected) + + def test_verifier_too_long(self): + with self.assertRaises(ValueError) as caught: + _pkce.code_verifier(97) + self.assertIn("too long", str(caught.exception)) + + def test_verifier_too_short(self): + with self.assertRaises(ValueError) as caught: + _pkce.code_verifier(30) + self.assertIn("too short", str(caught.exception)) + + def test_challenge(self): + result = _pkce.code_challenge(b'SOME_VERIFIER') + expected = b'6xJCQsjTtS3zjUwd8_ZqH0SyviGHnp5PsHXWKOCqDuI' + self.assertEqual(result, expected) diff --git a/tests/test__pure_python_crypt.py b/tests/test__pure_python_crypt.py index 3c2962a..e9844b9 100644 --- a/tests/test__pure_python_crypt.py +++ b/tests/test__pure_python_crypt.py @@ -15,19 +15,19 @@ """Unit tests for oauth2client._pure_python_crypt.""" import os +import unittest 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): +class TestRsaVerifier(unittest.TestCase): PUBLIC_KEY_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'privatekey.pub') @@ -112,7 +112,7 @@ class TestRsaVerifier(unittest2.TestCase): load_pem.assert_called_once_with(cert_bytes, 'CERTIFICATE') -class TestRsaSigner(unittest2.TestCase): +class TestRsaSigner(unittest.TestCase): PKCS1_KEY_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'privatekey.pem') diff --git a/tests/test__pycrypto_crypt.py b/tests/test__pycrypto_crypt.py index 2f45291..2ca18ec 100644 --- a/tests/test__pycrypto_crypt.py +++ b/tests/test__pycrypto_crypt.py @@ -14,13 +14,12 @@ """Unit tests for oauth2client._pycrypto_crypt.""" import os - -import unittest2 +import unittest from oauth2client import crypt -class TestPyCryptoVerifier(unittest2.TestCase): +class TestPyCryptoVerifier(unittest.TestCase): PUBLIC_CERT_FILENAME = os.path.join(os.path.dirname(__file__), 'data', 'public_cert.pem') @@ -65,7 +64,7 @@ class TestPyCryptoVerifier(unittest2.TestCase): self.assertIsInstance(verifier, crypt.PyCryptoVerifier) -class TestPyCryptoSigner(unittest2.TestCase): +class TestPyCryptoSigner(unittest.TestCase): def test_from_string_bad_key(self): key_bytes = 'definitely-not-pem-format' diff --git a/tests/test_client.py b/tests/test_client.py index db75603..18b7df4 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -12,10 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Oauth2client tests - -Unit tests for oauth2client. -""" +"""Unit tests for oauth2client.client.""" import base64 import contextlib @@ -26,31 +23,25 @@ import os import socket import sys import tempfile +import unittest -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 +from oauth2client import transport +from tests import http_mock -__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) @@ -77,7 +68,7 @@ def load_and_cache(existing_file, fakename, cache_mock): cache_mock.cache[fakename] = {client_type: client_info} -class CredentialsTests(unittest2.TestCase): +class CredentialsTests(unittest.TestCase): def test_to_from_json(self): credentials = client.Credentials() @@ -221,7 +212,7 @@ class CredentialsTests(unittest2.TestCase): self.assertEqual(credentials.__dict__, {}) -class TestStorage(unittest2.TestCase): +class TestStorage(unittest.TestCase): def test_locked_get_abstract(self): storage = client.Storage() @@ -256,7 +247,7 @@ def mock_module_import(module): del sys.modules[entry] -class GoogleCredentialsTests(unittest2.TestCase): +class GoogleCredentialsTests(unittest.TestCase): def setUp(self): self.os_name = os.name @@ -364,67 +355,41 @@ class GoogleCredentialsTests(unittest2.TestCase): # is cached. self.assertTrue(client._in_gae_environment()) - def _environment_check_gce_helper(self, status_ok=True, socket_error=False, + def _environment_check_gce_helper(self, status_ok=True, 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) + headers = {'status': http_client.OK} + headers.update(client._GCE_HEADERS) 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() + headers = {'status': http_client.NOT_FOUND} + http = http_mock.HttpMock(headers=headers) 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) - + with mock.patch('oauth2client.transport.get_http_object', + return_value=http) as new_http: 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 == '': + if status_ok and server_software == '': self.assertTrue(client._in_gce_environment()) else: self.assertFalse(client._in_gce_environment()) + # Verify mocks. if server_software == '': - http_client_module.HTTPConnection.assert_called_once_with( - client._GCE_METADATA_HOST, + new_http.assert_called_once_with( 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) + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, client._GCE_METADATA_URI) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertEqual(http.headers, client._GCE_HEADERS) 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, []) + new_http.assert_not_called() + self.assertEqual(http.requests, 0) def test_environment_check_gce_production(self): self._environment_check_gce_helper(status_ok=True) @@ -433,8 +398,21 @@ class GoogleCredentialsTests(unittest2.TestCase): 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) + @mock.patch('oauth2client.client.os.environ', + new={client._SERVER_SOFTWARE: ''}) + @mock.patch('oauth2client.transport.get_http_object', + return_value=object()) + @mock.patch('oauth2client.transport.request', + side_effect=socket.timeout()) + def test_environment_check_gce_timeout(self, mock_request, new_http): + self.assertFalse(client._in_gae_environment()) + self.assertFalse(client._in_gce_environment()) + + # Verify mocks. + new_http.assert_called_once_with(timeout=client.GCE_METADATA_TIMEOUT) + mock_request.assert_called_once_with( + new_http.return_value, client._GCE_METADATA_URI, + headers=client._GCE_HEADERS) def test_environ_check_gae_module_unknown(self): with mock_module_import('google.appengine'): @@ -709,8 +687,9 @@ class GoogleCredentialsTests(unittest2.TestCase): # 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' + 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: @@ -840,8 +819,8 @@ class DummyDeleteStorage(client.Storage): self.delete_called = True -def _token_revoke_test_helper(testcase, status, revoke_raise, - valid_bool_value, token_attr): +def _token_revoke_test_helper(testcase, revoke_raise, valid_bool_value, + token_attr, http_mock): current_store = getattr(testcase.credentials, 'store', None) dummy_store = DummyDeleteStorage() @@ -850,17 +829,16 @@ def _token_revoke_test_helper(testcase, status, revoke_raise, actual_do_revoke = testcase.credentials._do_revoke testcase.token_from_revoke = None - def do_revoke_stub(http_request, token): + def do_revoke_stub(http, token): testcase.token_from_revoke = token - return actual_do_revoke(http_request, token) + return actual_do_revoke(http, token) testcase.credentials._do_revoke = do_revoke_stub - http = HttpMock(headers={'status': status}) if revoke_raise: testcase.assertRaises(client.TokenRevokeError, - testcase.credentials.revoke, http) + testcase.credentials.revoke, http_mock) else: - testcase.credentials.revoke(http) + testcase.credentials.revoke(http_mock) testcase.assertEqual(getattr(testcase.credentials, token_attr), testcase.token_from_revoke) @@ -870,7 +848,7 @@ def _token_revoke_test_helper(testcase, status, revoke_raise, testcase.credentials.set_store(current_store) -class BasicCredentialsTests(unittest2.TestCase): +class BasicCredentialsTests(unittest.TestCase): def setUp(self): access_token = 'foo' @@ -885,54 +863,50 @@ class BasicCredentialsTests(unittest2.TestCase): 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. + # Provoke a failure if @_helpers.positional is not respected. self.old_positional_enforcement = ( - util.positional_parameters_enforcement) - util.positional_parameters_enforcement = ( - util.POSITIONAL_EXCEPTION) + _helpers.positional_parameters_enforcement) + _helpers.positional_parameters_enforcement = ( + _helpers.POSITIONAL_EXCEPTION) def tearDown(self): - util.positional_parameters_enforcement = ( + _helpers.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([ + json_resp = json.dumps(token_response).encode('utf-8') + http = http_mock.HttpMockSequence([ ({'status': status_code}, b''), - ({'status': '200'}, json.dumps(token_response).encode( - 'utf-8')), - ({'status': '200'}, 'echo_request_headers'), + ({'status': http_client.OK}, json_resp), + ({'status': http_client.OK}, 'echo_request_headers'), ]) http = self.credentials.authorize(http) - resp, content = http.request('http://example.com') + resp, content = transport.request(http, '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. - """ + # Tests that OAuth2Credentials doesn't introduce new method + # constraints. Formerly, OAuth2Credentials.authorize monkeypatched the + # request method of the passed in HTTP object with a wrapper annotated + # with @_helpers.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 = http_mock.HttpMock(data=encoded_response) http = self.credentials.authorize(http) http = self.credentials.authorize(http) - http.request('http://example.com') + transport.request(http, 'http://example.com') def test_token_refresh_failure(self): for status_code in client.REFRESH_STATUS_CODES: - http = HttpMockSequence([ + http = http_mock.HttpMockSequence([ ({'status': status_code}, b''), ({'status': http_client.BAD_REQUEST}, b'{"error":"access_denied"}'), @@ -940,36 +914,51 @@ class BasicCredentialsTests(unittest2.TestCase): http = self.credentials.authorize(http) with self.assertRaises( client.HttpAccessTokenRefreshError) as exc_manager: - http.request('http://example.com') + transport.request(http, '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): + http = http_mock.HttpMock(headers={'status': http_client.OK}) _token_revoke_test_helper( - self, '200', revoke_raise=False, - valid_bool_value=True, token_attr='refresh_token') + self, revoke_raise=False, valid_bool_value=True, + token_attr='refresh_token', http_mock=http) def test_token_revoke_failure(self): + http = http_mock.HttpMock(headers={'status': http_client.BAD_REQUEST}) _token_revoke_test_helper( - self, '400', revoke_raise=True, - valid_bool_value=False, token_attr='refresh_token') + self, revoke_raise=True, valid_bool_value=False, + token_attr='refresh_token', http_mock=http) def test_token_revoke_fallback(self): original_credentials = self.credentials.to_json() self.credentials.refresh_token = None + + http = http_mock.HttpMock(headers={'status': http_client.OK}) _token_revoke_test_helper( - self, '200', revoke_raise=False, - valid_bool_value=True, token_attr='access_token') + self, revoke_raise=False, valid_bool_value=True, + token_attr='access_token', http_mock=http) self.credentials = self.credentials.from_json(original_credentials) - def test_non_401_error_response(self): - http = HttpMockSequence([ - ({'status': '400'}, b''), + def test_token_revoke_405(self): + original_credentials = self.credentials.to_json() + self.credentials.refresh_token = None + + http = http_mock.HttpMockSequence([ + ({'status': http_client.METHOD_NOT_ALLOWED}, b''), + ({'status': http_client.OK}, b''), ]) + _token_revoke_test_helper( + self, revoke_raise=False, valid_bool_value=True, + token_attr='access_token', http_mock=http) + self.credentials = self.credentials.from_json(original_credentials) + + def test_non_401_error_response(self): + http = http_mock.HttpMock(headers={'status': http_client.BAD_REQUEST}) http = self.credentials.authorize(http) - resp, content = http.request('http://example.com') + resp, content = transport.request(http, 'http://example.com') self.assertEqual(http_client.BAD_REQUEST, resp.status) self.assertEqual(None, self.credentials.token_response) @@ -1010,10 +999,11 @@ class BasicCredentialsTests(unittest2.TestCase): # 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()) + http = credentials.authorize(http_mock.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) + transport.request( + http, 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]) @@ -1021,8 +1011,9 @@ class BasicCredentialsTests(unittest2.TestCase): # 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}) + transport.request( + http, u'http://example.com', method=u'GET', + headers={u'foo': unicode_str}) def test_no_unicode_in_request_params(self): access_token = u'foo' @@ -1037,10 +1028,11 @@ class BasicCredentialsTests(unittest2.TestCase): access_token, client_id, client_secret, refresh_token, token_expiry, token_uri, user_agent, revoke_uri=revoke_uri) - http = HttpMock() + http = http_mock.HttpMock() http = credentials.authorize(http) - http.request(u'http://example.com', method=u'GET', - headers={u'foo': u'bar'}) + transport.request( + http, 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) @@ -1048,8 +1040,8 @@ class BasicCredentialsTests(unittest2.TestCase): # 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', + transport.request( + http, u'http://example.com', method=u'GET', headers={u'foo': u'\N{COMET}'}) self.credentials.token_response = 'foobar' @@ -1107,7 +1099,7 @@ class BasicCredentialsTests(unittest2.TestCase): 'access_token': token2, 'expires_in': lifetime, } - http = HttpMockSequence([ + http = http_mock.HttpMockSequence([ ({'status': '200'}, json.dumps(token_response_first).encode( 'utf-8')), ({'status': '200'}, json.dumps(token_response_second).encode( @@ -1181,11 +1173,12 @@ class BasicCredentialsTests(unittest2.TestCase): # 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: + with mock.patch('oauth2client.transport.get_http_object', + return_value=object()) as new_http: token_info = credentials.get_access_token() expires_in.assert_called_once_with() - refresh_mock.assert_called_once_with(http_kls.return_value) + refresh_mock.assert_called_once_with(new_http.return_value) + new_http.assert_called_once_with() self.assertIsInstance(token_info, client.AccessTokenInfo) self.assertEqual(token_info.access_token, @@ -1225,21 +1218,25 @@ class BasicCredentialsTests(unittest2.TestCase): def _do_refresh_request_test_helper(self, response, content, error_msg, logger, gen_body, gen_headers, store=None): + token_uri = 'http://token_uri' credentials = client.OAuth2Credentials(None, None, None, None, - None, None, None) + None, token_uri, None) credentials.store = store - http_request = mock.Mock() - http_request.return_value = response, content + http = http_mock.HttpMock(headers=response, data=content) with self.assertRaises( client.HttpAccessTokenRefreshError) as exc_manager: - credentials._do_refresh_request(http_request) + credentials._do_refresh_request(http) 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') + + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, token_uri) + self.assertEqual(http.method, 'POST') + self.assertEqual(http.body, gen_body.return_value) + self.assertEqual(http.headers, gen_headers.return_value) call1 = mock.call('Refreshing access_token') failure_template = 'Failed to retrieve access token: %s' @@ -1249,43 +1246,35 @@ class BasicCredentialsTests(unittest2.TestCase): 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), - }) + response = http_mock.ResponseMock({'status': 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), - }) + response = http_mock.ResponseMock( + {'status': 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, - }) + response = http_mock.ResponseMock({'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, - }) + response = http_mock.ResponseMock({'status': http_client.BAD_GATEWAY}) error_msg = 'Where are we going wearer?' content = json.dumps({'error': error_msg}) - store = mock.MagicMock() + store = mock.Mock() 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, - }) + response = http_mock.ResponseMock( + {'status': http_client.SERVICE_UNAVAILABLE}) base_error = 'Ruckus' error_desc = 'Can you describe the ruckus' content = json.dumps({ @@ -1302,20 +1291,20 @@ class BasicCredentialsTests(unittest2.TestCase): 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 + + http = http_mock.HttpMock(headers=response, data=content) token = u's3kr3tz' if response.status == http_client.OK: self.assertFalse(credentials.invalid) - self.assertIsNone(credentials._do_revoke(http_request, token)) + self.assertIsNone(credentials._do_revoke(http, 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) + credentials._do_revoke(http, token) # Make sure invalid was not flipped on. self.assertFalse(credentials.invalid) self.assertEqual(exc_manager.exception.args, (error_msg,)) @@ -1323,54 +1312,49 @@ class BasicCredentialsTests(unittest2.TestCase): store.delete.assert_not_called() revoke_uri = oauth2client.GOOGLE_REVOKE_URI + '?token=' + token - http_request.assert_called_once_with(revoke_uri) + + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, revoke_uri) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertIsNone(http.headers) logger.info.assert_called_once_with('Revoking token') def test__do_revoke_success(self): - response = httplib2.Response({ - 'status': http_client.OK, - }) + response = http_mock.ResponseMock() 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() + response = http_mock.ResponseMock() + store = mock.Mock() 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, - }) + response = http_mock.ResponseMock({'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, - }) + response = http_mock.ResponseMock( + {'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, - }) + response = http_mock.ResponseMock({'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, - }) + response = http_mock.ResponseMock({'status': http_client.BAD_GATEWAY}) error_msg = 'Where are we going wearer?' content = json.dumps({'error': error_msg}) - store = mock.MagicMock() + store = mock.Mock() self._do_revoke_test_helper(response, content, error_msg, store=store) @@ -1380,70 +1364,61 @@ class BasicCredentialsTests(unittest2.TestCase): 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 + http = http_mock.HttpMock(headers=response, data=content) token = u's3kr3tz' if response.status == http_client.OK: self.assertEqual(credentials.scopes, set()) self.assertIsNone( - credentials._do_retrieve_scopes(http_request, token)) + credentials._do_retrieve_scopes(http, 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) + credentials._do_retrieve_scopes(http, 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( + token_uri = _helpers.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) + + # Verify mocks. + self.assertEqual(http.requests, 1) + assertUrisEqual(self, token_uri, http.uri) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertIsNone(http.headers) logger.info.assert_called_once_with('Refreshing scopes') def test__do_retrieve_scopes_success_bad_json(self): - response = httplib2.Response({ - 'status': http_client.OK, - }) + response = http_mock.ResponseMock() 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, - }) + response = http_mock.ResponseMock() 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, - }) + response = http_mock.ResponseMock({'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, - }) + response = http_mock.ResponseMock( + {'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, - }) + response = http_mock.ResponseMock({'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) @@ -1467,7 +1442,7 @@ class BasicCredentialsTests(unittest2.TestCase): def test_retrieve_scopes(self): info_response_first = {'scope': 'foo bar'} info_response_second = {'error_description': 'abcdef'} - http = HttpMockSequence([ + http = http_mock.HttpMockSequence([ ({'status': '200'}, json.dumps(info_response_first).encode( 'utf-8')), ({'status': '400'}, json.dumps(info_response_second).encode( @@ -1496,17 +1471,18 @@ class BasicCredentialsTests(unittest2.TestCase): b' "expires_in":3600,' b' "id_token": "' + jwt + b'"' b'}') - http = HttpMockSequence([ + http = http_mock.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') + resp, content = transport.request(http, 'http://example.com') self.assertEqual(self.credentials.id_token, body) + self.assertEqual(self.credentials.id_token_jwt, jwt.decode()) -class AccessTokenCredentialsTests(unittest2.TestCase): +class AccessTokenCredentialsTests(unittest.TestCase): def setUp(self): access_token = 'foo' @@ -1517,41 +1493,40 @@ class AccessTokenCredentialsTests(unittest2.TestCase): def test_token_refresh_success(self): for status_code in client.REFRESH_STATUS_CODES: - http = HttpMockSequence([ - ({'status': status_code}, b''), - ]) + http = http_mock.HttpMock( + headers={'status': status_code}, data=b'') http = self.credentials.authorize(http) with self.assertRaises(client.AccessTokenCredentialsError): - resp, content = http.request('http://example.com') + resp, content = transport.request(http, 'http://example.com') def test_token_revoke_success(self): + http = http_mock.HttpMock(headers={'status': http_client.OK}) _token_revoke_test_helper( - self, '200', revoke_raise=False, - valid_bool_value=True, token_attr='access_token') + self, revoke_raise=False, valid_bool_value=True, + token_attr='access_token', http_mock=http) def test_token_revoke_failure(self): + http = http_mock.HttpMock(headers={'status': http_client.BAD_REQUEST}) _token_revoke_test_helper( - self, '400', revoke_raise=True, - valid_bool_value=False, token_attr='access_token') + self, revoke_raise=True, valid_bool_value=False, + token_attr='access_token', http_mock=http) def test_non_401_error_response(self): - http = HttpMockSequence([ - ({'status': '400'}, b''), - ]) + http = http_mock.HttpMock(headers={'status': http_client.BAD_REQUEST}) http = self.credentials.authorize(http) - resp, content = http.request('http://example.com') + resp, content = transport.request(http, 'http://example.com') self.assertEqual(http_client.BAD_REQUEST, resp.status) def test_auth_header_sent(self): - http = HttpMockSequence([ + http = http_mock.HttpMockSequence([ ({'status': '200'}, 'echo_request_headers'), ]) http = self.credentials.authorize(http) - resp, content = http.request('http://example.com') + resp, content = transport.request(http, 'http://example.com') self.assertEqual(b'Bearer foo', content[b'Authorization']) -class TestAssertionCredentials(unittest2.TestCase): +class TestAssertionCredentials(unittest.TestCase): assertion_text = 'This is the assertion' assertion_type = 'http://www.google.com/assertionType' @@ -1578,23 +1553,25 @@ class TestAssertionCredentials(unittest2.TestCase): body['grant_type'][0]) def test_assertion_refresh(self): - http = HttpMockSequence([ + http = http_mock.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') + resp, content = transport.request(http, 'http://example.com') self.assertEqual(b'Bearer 1/3w', content[b'Authorization']) def test_token_revoke_success(self): + http = http_mock.HttpMock(headers={'status': http_client.OK}) _token_revoke_test_helper( - self, '200', revoke_raise=False, - valid_bool_value=True, token_attr='access_token') + self, revoke_raise=False, valid_bool_value=True, + token_attr='access_token', http_mock=http) def test_token_revoke_failure(self): + http = http_mock.HttpMock(headers={'status': http_client.BAD_REQUEST}) _token_revoke_test_helper( - self, '400', revoke_raise=True, - valid_bool_value=False, token_attr='access_token') + self, revoke_raise=True, valid_bool_value=False, + token_attr='access_token', http_mock=http) def test_sign_blob_abstract(self): credentials = client.AssertionCredentials(None) @@ -1602,20 +1579,7 @@ class TestAssertionCredentials(unittest2.TestCase): 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): +class ExtractIdTokenTest(unittest.TestCase): """Tests client._extract_id_token().""" def test_extract_success(self): @@ -1636,7 +1600,7 @@ class ExtractIdTokenTest(unittest2.TestCase): client._extract_id_token(jwt) -class OAuth2WebServerFlowTest(unittest2.TestCase): +class OAuth2WebServerFlowTest(unittest.TestCase): def setUp(self): self.flow = client.OAuth2WebServerFlow( @@ -1647,6 +1611,9 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): user_agent='unittest-sample/1.0', revoke_uri='dummy_revoke_uri', ) + self.bad_verifier = b'__NOT_THE_VERIFIER_YOURE_LOOKING_FOR__' + self.good_verifier = b'__TEST_VERIFIER__' + self.good_challenger = b'__TEST_CHALLENGE__' def test_construct_authorize_url(self): authorize_url = self.flow.step1_get_authorize_url(state='state+1') @@ -1711,11 +1678,51 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 'access_type': 'offline', 'response_type': 'code', } - expected = client._update_query_params(flow.auth_uri, query_params) + expected = _helpers.update_query_params(flow.auth_uri, query_params) assertUrisEqual(self, expected, result) # Check stubs. self.assertEqual(logger.warning.call_count, 1) + @mock.patch('oauth2client.client._pkce.code_challenge') + @mock.patch('oauth2client.client._pkce.code_verifier') + def test_step1_get_authorize_url_pkce(self, fake_verifier, fake_challenge): + fake_verifier.return_value = self.good_verifier + fake_challenge.return_value = self.good_challenger + flow = client.OAuth2WebServerFlow( + 'client_id+1', + scope='foo', + redirect_uri='http://example.com', + pkce=True) + auth_url = urllib.parse.urlparse(flow.step1_get_authorize_url()) + self.assertEqual(flow.code_verifier, self.good_verifier) + results = dict(urllib.parse.parse_qsl(auth_url.query)) + self.assertEqual( + results['code_challenge'], self.good_challenger.decode()) + self.assertEqual(results['code_challenge_method'], 'S256') + fake_verifier.assert_called() + fake_challenge.assert_called_with(self.good_verifier) + + @mock.patch('oauth2client.client._pkce.code_challenge') + @mock.patch('oauth2client.client._pkce.code_verifier') + def test_step1_get_authorize_url_pkce_invalid_verifier( + self, fake_verifier, fake_challenge): + fake_verifier.return_value = self.good_verifier + fake_challenge.return_value = self.good_challenger + flow = client.OAuth2WebServerFlow( + 'client_id+1', + scope='foo', + redirect_uri='http://example.com', + pkce=True, + code_verifier=self.bad_verifier) + auth_url = urllib.parse.urlparse(flow.step1_get_authorize_url()) + self.assertEqual(flow.code_verifier, self.bad_verifier) + results = dict(urllib.parse.parse_qsl(auth_url.query)) + self.assertEqual( + results['code_challenge'], self.good_challenger.decode()) + self.assertEqual(results['code_challenge_method'], 'S256') + fake_verifier.assert_not_called() + fake_challenge.assert_called_with(self.bad_verifier) + def test_step1_get_authorize_url_without_redirect(self): flow = client.OAuth2WebServerFlow('client_id+1', scope='foo', redirect_uri=None) @@ -1736,7 +1743,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 'access_type': 'offline', 'response_type': 'code', } - expected = client._update_query_params(flow.auth_uri, query_params) + expected = _helpers.update_query_params(flow.auth_uri, query_params) assertUrisEqual(self, expected, result) def test_step1_get_device_and_user_codes_wo_device_uri(self): @@ -1758,12 +1765,15 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 'user_code': user_code, 'verification_url': ver_url, }) - http = HttpMockSequence([ + http = http_mock.HttpMockSequence([ ({'status': http_client.OK}, content), ]) if default_http: - with mock.patch('httplib2.Http', return_value=http): + with mock.patch('oauth2client.transport.get_http_object', + return_value=http) as new_http: result = flow.step1_get_device_and_user_codes() + # Check the mock was called. + new_http.assert_called_once_with() else: result = flow.step1_get_device_and_user_codes(http=http) @@ -1771,16 +1781,17 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 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]}) + info = http.requests[0] + self.assertEqual(info['uri'], oauth2client.GOOGLE_DEVICE_URI) + expected_body = { + 'client_id': [flow.client_id], + 'scope': [flow.scope], + } + self.assertEqual(urllib.parse.parse_qs(info['body']), expected_body) 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) + self.assertEqual(info['headers'], headers) def test_step1_get_device_and_user_codes(self): self._step1_get_device_and_user_codes_helper() @@ -1803,9 +1814,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 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), - ]) + http = http_mock.HttpMock(headers={'status': status}, data=content) with self.assertRaises(client.OAuth2DeviceCodeError) as exc_manager: flow.step1_get_device_and_user_codes(http=http) @@ -1849,17 +1858,19 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): client.OAuth2WebServerFlow('client_id+1') def test_exchange_failure(self): - http = HttpMockSequence([ - ({'status': '400'}, b'{"error":"invalid_request"}'), - ]) + http = http_mock.HttpMock( + headers={'status': http_client.BAD_REQUEST}, + data=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'), - ]) + http = http_mock.HttpMock( + headers={'status': http_client.BAD_REQUEST}, + data=b'error=invalid_request', + ) with self.assertRaisesRegexp(client.FlowExchangeError, 'invalid_request'): @@ -1876,7 +1887,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "type": "OAuthException"' b' }' b'}') - http = HttpMockSequence([({'status': '400'}, payload)]) + http = http_mock.HttpMock(data=payload) with self.assertRaises(client.FlowExchangeError): self.flow.step2_exchange(code='some random code', http=http) @@ -1887,7 +1898,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "expires_in":3600,' b' "refresh_token":"8xLOxBtZp8"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=payload) credentials = self.flow.step2_exchange( code=code, device_flow_info=device_flow_info, http=http) self.assertEqual('SlAV32hkKG', credentials.access_token) @@ -1916,8 +1927,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): ' "expires_in":' + expires_in + ',' ' "refresh_token":"' + refresh_token + '"' '}') - http = HttpMockSequence( - [({'status': '200'}, _helpers._to_bytes(payload))]) + http = http_mock.HttpMock(data=_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) @@ -1943,7 +1953,9 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "expires_in":3600,' b' "refresh_token":"8xLOxBtZp8"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, payload), + ]) credentials = self.flow.step2_exchange(code=not_a_dict, http=http) self.assertEqual('SlAV32hkKG', credentials.access_token) @@ -1951,10 +1963,29 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): self.assertEqual('8xLOxBtZp8', credentials.refresh_token) self.assertEqual('dummy_revoke_uri', credentials.revoke_uri) self.assertEqual(set(['foo']), credentials.scopes) + self.assertEqual(len(http.requests), 1) request_code = urllib.parse.parse_qs( http.requests[0]['body'])['code'][0] self.assertEqual(code, request_code) + def test_exchange_with_pkce(self): + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b'access_token=SlAV32hkKG'), + ]) + flow = client.OAuth2WebServerFlow( + 'client_id+1', + scope='foo', + redirect_uri='http://example.com', + pkce=True, + code_verifier=self.good_verifier) + flow.step2_exchange(code='some random code', http=http) + + self.assertEqual(len(http.requests), 1) + test_request = http.requests[0] + self.assertIn( + 'code_verifier={0}'.format(self.good_verifier.decode()), + test_request['body']) + def test_exchange_using_authorization_header(self): auth_header = 'Basic Y2xpZW50X2lkKzE6c2Vjexc_managerV0KzE=', flow = client.OAuth2WebServerFlow( @@ -1965,13 +1996,14 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): user_agent='unittest-sample/1.0', revoke_uri='dummy_revoke_uri', ) - http = HttpMockSequence([ - ({'status': '200'}, b'access_token=SlAV32hkKG'), + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b'access_token=SlAV32hkKG'), ]) credentials = flow.step2_exchange(code='some random code', http=http) self.assertEqual('SlAV32hkKG', credentials.access_token) + self.assertEqual(len(http.requests), 1) test_request = http.requests[0] # Did we pass the Authorization header? self.assertEqual(test_request['headers']['Authorization'], auth_header) @@ -1979,9 +2011,8 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 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'), - ]) + http = http_mock.HttpMock( + data=b'access_token=SlAV32hkKG&expires_in=3600') credentials = self.flow.step2_exchange(code='some random code', http=http) @@ -1989,12 +2020,9 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): 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'), - ]) - + # Note the 'expires=3600' where you'd normally + # have if named 'expires_in' + http = http_mock.HttpMock(data=b'access_token=SlAV32hkKG&expires=3600') credentials = self.flow.step2_exchange(code='some random code', http=http) self.assertNotEqual(None, credentials.token_expiry) @@ -2004,18 +2032,16 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "access_token":"SlAV32hkKG",' b' "refresh_token":"8xLOxBtZp8"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=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'), - ]) + # This might be redundant but just to make sure + # urlencoded access_token gets parsed correctly + http = http_mock.HttpMock(data=b'access_token=SlAV32hkKG') credentials = self.flow.step2_exchange(code='some random code', http=http) @@ -2026,7 +2052,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "access_token":"SlAV32hkKG",' b' "refresh_token":"8xLOxBtZp8"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=payload) code = {'error': 'thou shall not pass'} with self.assertRaisesRegexp( @@ -2039,7 +2065,7 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "refresh_token":"8xLOxBtZp8",' b' "id_token": "stuff.payload"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=payload) with self.assertRaises(client.VerifyJwtTokenError): self.flow.step2_exchange(code='some random code', http=http) @@ -2056,16 +2082,17 @@ class OAuth2WebServerFlowTest(unittest2.TestCase): b' "refresh_token":"8xLOxBtZp8",' b' "id_token": "' + jwt + b'"' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=payload) credentials = self.flow.step2_exchange(code='some random code', http=http) self.assertEqual(credentials.id_token, body) + self.assertEqual(credentials.id_token_jwt, jwt.decode()) -class FlowFromCachedClientsecrets(unittest2.TestCase): +class FlowFromCachedClientsecrets(unittest.TestCase): def test_flow_from_clientsecrets_cached(self): - cache_mock = CacheMock() + cache_mock = http_mock.CacheMock() load_and_cache('client_secrets.json', 'some_secrets', cache_mock) flow = client.flow_from_clientsecrets( @@ -2168,7 +2195,7 @@ class FlowFromCachedClientsecrets(unittest2.TestCase): loadfile_mock.assert_called_once_with(filename, cache=cache) -class CredentialsFromCodeTests(unittest2.TestCase): +class CredentialsFromCodeTests(unittest.TestCase): def setUp(self): self.client_id = 'client_id_abc' @@ -2180,9 +2207,7 @@ class CredentialsFromCodeTests(unittest2.TestCase): 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')), - ]) + http = http_mock.HttpMock(data=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) @@ -2191,9 +2216,10 @@ class CredentialsFromCodeTests(unittest2.TestCase): self.assertEqual(set(['foo']), credentials.scopes) def test_exchange_code_for_token_fail(self): - http = HttpMockSequence([ - ({'status': '400'}, b'{"error":"invalid_request"}'), - ]) + http = http_mock.HttpMock( + headers={'status': http_client.BAD_REQUEST}, + data=b'{"error":"invalid_request"}', + ) with self.assertRaises(client.FlowExchangeError): client.credentials_from_code( @@ -2205,7 +2231,7 @@ class CredentialsFromCodeTests(unittest2.TestCase): b' "access_token":"asdfghjkl",' b' "expires_in":3600' b'}') - http = HttpMockSequence([({'status': '200'}, payload)]) + http = http_mock.HttpMock(data=payload) credentials = client.credentials_from_clientsecrets_and_code( datafile('client_secrets.json'), self.scope, self.code, http=http) @@ -2214,10 +2240,8 @@ class CredentialsFromCodeTests(unittest2.TestCase): 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() + http = http_mock.HttpMock(data=b'{ "access_token":"asdfghjkl"}') + cache_mock = http_mock.CacheMock() load_and_cache('client_secrets.json', 'some_secrets', cache_mock) credentials = client.credentials_from_clientsecrets_and_code( @@ -2227,9 +2251,10 @@ class CredentialsFromCodeTests(unittest2.TestCase): self.assertEqual(set(['foo']), credentials.scopes) def test_exchange_code_and_file_for_token_fail(self): - http = HttpMockSequence([ - ({'status': '400'}, b'{"error":"invalid_request"}'), - ]) + http = http_mock.HttpMock( + headers={'status': http_client.BAD_REQUEST}, + data=b'{"error":"invalid_request"}', + ) with self.assertRaises(client.FlowExchangeError): client.credentials_from_clientsecrets_and_code( @@ -2237,7 +2262,7 @@ class CredentialsFromCodeTests(unittest2.TestCase): self.code, http=http) -class Test__save_private_file(unittest2.TestCase): +class Test__save_private_file(unittest.TestCase): def _save_helper(self, filename): contents = [] @@ -2265,7 +2290,7 @@ class Test__save_private_file(unittest2.TestCase): self._save_helper(filename) -class Test__get_application_default_credential_GAE(unittest2.TestCase): +class Test__get_application_default_credential_GAE(unittest.TestCase): @mock.patch.dict('sys.modules', { 'oauth2client.contrib.appengine': mock.Mock()}) @@ -2278,7 +2303,7 @@ class Test__get_application_default_credential_GAE(unittest2.TestCase): creds_kls.assert_called_once_with([]) -class Test__get_application_default_credential_GCE(unittest2.TestCase): +class Test__get_application_default_credential_GCE(unittest.TestCase): @mock.patch.dict('sys.modules', { 'oauth2client.contrib.gce': mock.Mock()}) @@ -2291,7 +2316,7 @@ class Test__get_application_default_credential_GCE(unittest2.TestCase): creds_kls.assert_called_once_with() -class Test__require_crypto_or_die(unittest2.TestCase): +class Test__require_crypto_or_die(unittest.TestCase): @mock.patch.object(client, 'HAS_CRYPTO', new=True) def test_with_crypto(self): @@ -2303,7 +2328,7 @@ class Test__require_crypto_or_die(unittest2.TestCase): client._require_crypto_or_die() -class TestDeviceFlowInfo(unittest2.TestCase): +class TestDeviceFlowInfo(unittest.TestCase): DEVICE_CODE = 'e80ff179-fd65-416c-9dbf-56a23e5d23e4' USER_CODE = '4bbd8b82-fc73-11e5-adf3-00c2c63e5792' diff --git a/tests/test_clientsecrets.py b/tests/test_clientsecrets.py index 42eb8c7..3fa9c30 100644 --- a/tests/test_clientsecrets.py +++ b/tests/test_clientsecrets.py @@ -18,17 +18,13 @@ import errno from io import StringIO import os import tempfile - -import unittest2 +import unittest 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') @@ -36,7 +32,7 @@ NONEXISTENT_FILE = os.path.join( os.path.dirname(__file__), 'afilethatisntthere.json') -class Test__validate_clientsecrets(unittest2.TestCase): +class Test__validate_clientsecrets(unittest.TestCase): def test_with_none(self): with self.assertRaises(clientsecrets.InvalidClientSecretsError): @@ -147,7 +143,7 @@ class Test__validate_clientsecrets(unittest2.TestCase): self.assertEqual(result, (clientsecrets.TYPE_INSTALLED, client_info)) -class Test__loadfile(unittest2.TestCase): +class Test__loadfile(unittest.TestCase): def test_success(self): client_type, client_info = clientsecrets._loadfile(VALID_FILE) @@ -176,7 +172,7 @@ class Test__loadfile(unittest2.TestCase): clientsecrets._loadfile(filename) -class OAuth2CredentialsTests(unittest2.TestCase): +class OAuth2CredentialsTests(unittest.TestCase): def test_validate_error(self): payload = ( @@ -223,7 +219,7 @@ class OAuth2CredentialsTests(unittest2.TestCase): self.assertEquals(exc_manager.exception.args[3], errno.ENOENT) -class CachedClientsecretsTests(unittest2.TestCase): +class CachedClientsecretsTests(unittest.TestCase): class CacheMock(object): def __init__(self): diff --git a/tests/test_crypt.py b/tests/test_crypt.py index b7534bd..bc56697 100644 --- a/tests/test_crypt.py +++ b/tests/test_crypt.py @@ -14,9 +14,9 @@ import base64 import os +import unittest import mock -import unittest2 from oauth2client import _helpers from oauth2client import client @@ -33,14 +33,14 @@ def datafile(filename): return file_obj.read() -class Test__bad_pkcs12_key_as_pem(unittest2.TestCase): +class Test__bad_pkcs12_key_as_pem(unittest.TestCase): def test_fails(self): with self.assertRaises(NotImplementedError): crypt._bad_pkcs12_key_as_pem() -class Test_pkcs12_key_as_pem(unittest2.TestCase): +class Test_pkcs12_key_as_pem(unittest.TestCase): def _make_svc_account_creds(self, private_key_file='privatekey.p12'): filename = data_filename(private_key_file) @@ -72,7 +72,7 @@ class Test_pkcs12_key_as_pem(unittest2.TestCase): self._succeeds_helper(password) -class Test__verify_signature(unittest2.TestCase): +class Test__verify_signature(unittest.TestCase): def test_success_single_cert(self): cert_value = 'cert-value' @@ -80,11 +80,11 @@ class Test__verify_signature(unittest2.TestCase): message = object() signature = object() - verifier = mock.MagicMock() - verifier.verify = mock.MagicMock(name='verify', return_value=True) + verifier = mock.Mock() + verifier.verify = mock.Mock(name='verify', return_value=True) with mock.patch('oauth2client.crypt.Verifier') as Verifier: - Verifier.from_string = mock.MagicMock(name='from_string', - return_value=verifier) + Verifier.from_string = mock.Mock(name='from_string', + return_value=verifier) result = crypt._verify_signature(message, signature, certs) self.assertEqual(result, None) @@ -101,14 +101,14 @@ class Test__verify_signature(unittest2.TestCase): message = object() signature = object() - verifier = mock.MagicMock() + verifier = mock.Mock() # 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]) + verifier.verify = mock.Mock(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) + Verifier.from_string = mock.Mock(name='from_string', + return_value=verifier) result = crypt._verify_signature(message, signature, certs) self.assertEqual(result, None) @@ -130,11 +130,11 @@ class Test__verify_signature(unittest2.TestCase): message = object() signature = object() - verifier = mock.MagicMock() - verifier.verify = mock.MagicMock(name='verify', return_value=False) + verifier = mock.Mock() + verifier.verify = mock.Mock(name='verify', return_value=False) with mock.patch('oauth2client.crypt.Verifier') as Verifier: - Verifier.from_string = mock.MagicMock(name='from_string', - return_value=verifier) + Verifier.from_string = mock.Mock(name='from_string', + return_value=verifier) with self.assertRaises(crypt.AppIdentityError): crypt._verify_signature(message, signature, certs) @@ -144,7 +144,7 @@ class Test__verify_signature(unittest2.TestCase): verifier.verify.assert_called_once_with(message, signature) -class Test__check_audience(unittest2.TestCase): +class Test__check_audience(unittest.TestCase): def test_null_audience(self): result = crypt._check_audience(None, None) @@ -172,7 +172,7 @@ class Test__check_audience(unittest2.TestCase): crypt._check_audience(payload_dict, audience2) -class Test__verify_time_range(unittest2.TestCase): +class Test__verify_time_range(unittest.TestCase): def _exception_helper(self, payload_dict): exception_caught = None @@ -204,8 +204,8 @@ class Test__verify_time_range(unittest2.TestCase): '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) + time.time = mock.Mock(name='time', + return_value=current_time) exception_caught = self._exception_helper(payload_dict) self.assertNotEqual(exception_caught, None) @@ -219,8 +219,8 @@ class Test__verify_time_range(unittest2.TestCase): '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) + time.time = mock.Mock(name='time', + return_value=current_time) exception_caught = self._exception_helper(payload_dict) self.assertNotEqual(exception_caught, None) @@ -234,8 +234,8 @@ class Test__verify_time_range(unittest2.TestCase): '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) + time.time = mock.Mock(name='time', + return_value=current_time) exception_caught = self._exception_helper(payload_dict) self.assertNotEqual(exception_caught, None) @@ -249,14 +249,14 @@ class Test__verify_time_range(unittest2.TestCase): '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) + time.time = mock.Mock(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): +class Test_verify_signed_jwt_with_certs(unittest.TestCase): def test_jwt_no_segments(self): exception_caught = None @@ -288,10 +288,10 @@ class Test_verify_signed_jwt_with_certs(unittest2.TestCase): @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() + certs = mock.Mock() cert_values = object() - certs.values = mock.MagicMock(name='values', - return_value=cert_values) + certs.values = mock.Mock(name='values', + return_value=cert_values) audience = object() header = b'header' diff --git a/tests/test_file.py b/tests/test_file.py index 924acb4..80324d6 100644 --- a/tests/test_file.py +++ b/tests/test_file.py @@ -12,10 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Oauth2client.file tests - -Unit tests for oauth2client.file -""" +"""Unit tests for oauth2client.file.""" import copy import datetime @@ -24,28 +21,31 @@ import os import pickle import stat import tempfile +import unittest +import warnings +import mock import six from six.moves import http_client -import unittest2 +from six.moves import urllib_parse +from oauth2client import _helpers from oauth2client import client -from oauth2client import file -from .http_mock import HttpMockSequence +from oauth2client import file as file_module +from oauth2client import transport +from tests import http_mock try: # Python2 from future_builtins import oct -except: # pragma: NO COVER +except ImportError: # pragma: NO COVER pass -__author__ = 'jcgregorio@google.com (Joe Gregorio)' - _filehandle, FILENAME = tempfile.mkstemp('oauth2client_test.data') os.close(_filehandle) -class OAuth2ClientFileTests(unittest2.TestCase): +class OAuth2ClientFileTests(unittest.TestCase): def tearDown(self): try: @@ -54,6 +54,7 @@ class OAuth2ClientFileTests(unittest2.TestCase): pass def setUp(self): + warnings.simplefilter("ignore") try: os.unlink(FILENAME) except OSError: @@ -74,19 +75,31 @@ class OAuth2ClientFileTests(unittest2.TestCase): user_agent) return credentials - def test_non_existent_file_storage(self): - s = file.Storage(FILENAME) - credentials = s.get() - self.assertEquals(None, credentials) + @mock.patch('warnings.warn') + def test_non_existent_file_storage(self, warn_mock): + storage = file_module.Storage(FILENAME) + credentials = storage.get() + warn_mock.assert_called_with( + _helpers._MISSING_FILE_MESSAGE.format(FILENAME)) + self.assertIsNone(credentials) + + def test_directory_file_storage(self): + storage = file_module.Storage(FILENAME) + os.mkdir(FILENAME) + try: + with self.assertRaises(IOError): + storage.get() + finally: + os.rmdir(FILENAME) - @unittest2.skipIf(not hasattr(os, 'symlink'), 'No symlink available') + @unittest.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) + storage = file_module.Storage(SYMFILENAME) try: - with self.assertRaises(file.CredentialsFileSymbolicLinkError): - s.get() + with self.assertRaises(IOError): + storage.get() finally: os.unlink(SYMFILENAME) @@ -94,20 +107,20 @@ class OAuth2ClientFileTests(unittest2.TestCase): # Write a file with a pickled OAuth2Credentials. credentials = self._create_test_credentials() - f = open(FILENAME, 'wb') - pickle.dump(credentials, f) - f.close() + credentials_file = open(FILENAME, 'wb') + pickle.dump(credentials, credentials_file) + credentials_file.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) + storage = file_module.Storage(FILENAME) + read_credentials = storage.get() + self.assertIsNone(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) + storage.put(credentials) + with open(FILENAME) as credentials_file: + data = json.load(credentials_file) self.assertEquals(data['access_token'], 'foo') self.assertEquals(data['_class'], 'OAuth2Credentials') @@ -118,22 +131,38 @@ class OAuth2ClientFileTests(unittest2.TestCase): datetime.timedelta(minutes=15)) credentials = self._create_test_credentials(expiration=expiration) - s = file.Storage(FILENAME) - s.put(credentials) - credentials = s.get() + storage = file_module.Storage(FILENAME) + storage.put(credentials) + credentials = storage.get() new_cred = copy.copy(credentials) new_cred.access_token = 'bar' - s.put(new_cred) + storage.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')), - ]) + response_content = json.dumps(token_response).encode('utf-8') + http = http_mock.HttpMock(data=response_content) - credentials._refresh(http.request) + credentials._refresh(http) self.assertEquals(credentials.access_token, access_token) + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, credentials.token_uri) + self.assertEqual(http.method, 'POST') + expected_body = { + 'grant_type': ['refresh_token'], + 'client_id': [credentials.client_id], + 'client_secret': [credentials.client_secret], + 'refresh_token': [credentials.refresh_token], + } + self.assertEqual(urllib_parse.parse_qs(http.body), expected_body) + expected_headers = { + 'content-type': 'application/x-www-form-urlencoded', + 'user-agent': credentials.user_agent, + } + self.assertEqual(http.headers, expected_headers) + 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. @@ -141,28 +170,28 @@ class OAuth2ClientFileTests(unittest2.TestCase): datetime.timedelta(minutes=15)) credentials = self._create_test_credentials(expiration=expiration) - s = file.Storage(FILENAME) - s.put(credentials) - credentials = s.get() + storage = file_module.Storage(FILENAME) + storage.put(credentials) + credentials = storage.get() new_cred = copy.copy(credentials) new_cred.access_token = 'bar' - s.put(new_cred) + storage.put(new_cred) access_token = '1/3w' token_response = {'access_token': access_token, 'expires_in': 3600} - http = HttpMockSequence([ - ({'status': str(int(http_client.UNAUTHORIZED))}, + http = http_mock.HttpMockSequence([ + ({'status': http_client.UNAUTHORIZED}, b'Initial token expired'), - ({'status': str(int(http_client.UNAUTHORIZED))}, + ({'status': http_client.UNAUTHORIZED}, b'Store token expired'), - ({'status': str(int(http_client.OK))}, + ({'status': http_client.OK}, json.dumps(token_response).encode('utf-8')), - ({'status': str(int(http_client.OK))}, + ({'status': http_client.OK}, b'Valid response to original request') ]) credentials.authorize(http) - http.request('https://example.com') + transport.request(http, 'https://example.com') self.assertEqual(credentials.access_token, access_token) def test_token_refresh_good_store(self): @@ -170,12 +199,12 @@ class OAuth2ClientFileTests(unittest2.TestCase): datetime.timedelta(minutes=15)) credentials = self._create_test_credentials(expiration=expiration) - s = file.Storage(FILENAME) - s.put(credentials) - credentials = s.get() + storage = file_module.Storage(FILENAME) + storage.put(credentials) + credentials = storage.get() new_cred = copy.copy(credentials) new_cred.access_token = 'bar' - s.put(new_cred) + storage.put(new_cred) credentials._refresh(None) self.assertEquals(credentials.access_token, 'bar') @@ -185,43 +214,43 @@ class OAuth2ClientFileTests(unittest2.TestCase): datetime.timedelta(minutes=15)) credentials = self._create_test_credentials(expiration=expiration) - s = file.Storage(FILENAME) - s.put(credentials) - credentials = s.get() + storage = file_module.Storage(FILENAME) + storage.put(credentials) + credentials = storage.get() new_cred = copy.copy(credentials) new_cred.access_token = 'bar' - s.put(new_cred) + storage.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))}, + http = http_mock.HttpMockSequence([ + ({'status': http_client.UNAUTHORIZED}, b'Initial token expired'), - ({'status': str(int(http_client.UNAUTHORIZED))}, + ({'status': http_client.UNAUTHORIZED}, b'Store token expired'), - ({'status': str(int(http_client.OK))}, + ({'status': http_client.OK}, json.dumps(token_response).encode('utf-8')), - ({'status': str(int(http_client.OK))}, 'echo_request_body') + ({'status': http_client.OK}, 'echo_request_body') ]) body = six.StringIO('streaming body') credentials.authorize(http) - _, content = http.request('https://example.com', body=body) + _, content = transport.request(http, '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) + storage = file_module.Storage(FILENAME) + storage.put(credentials) + credentials = storage.get() + self.assertIsNotNone(credentials) + storage.delete() + credentials = storage.get() + self.assertIsNone(credentials) def test_access_token_credentials(self): access_token = 'foo' @@ -229,11 +258,11 @@ class OAuth2ClientFileTests(unittest2.TestCase): credentials = client.AccessTokenCredentials(access_token, user_agent) - s = file.Storage(FILENAME) - credentials = s.put(credentials) - credentials = s.get() + storage = file_module.Storage(FILENAME) + credentials = storage.put(credentials) + credentials = storage.get() - self.assertNotEquals(None, credentials) + self.assertIsNotNone(credentials) self.assertEquals('foo', credentials.access_token) self.assertTrue(os.path.exists(FILENAME)) diff --git a/tests/test_jwt.py b/tests/test_jwt.py index ecc58e8..6502a4a 100644 --- a/tests/test_jwt.py +++ b/tests/test_jwt.py @@ -17,19 +17,18 @@ import os import tempfile import time +import unittest import mock -import unittest2 +from six.moves import http_client from oauth2client import _helpers from oauth2client import client from oauth2client import crypt -from oauth2client import file +from oauth2client import file as file_module from oauth2client import service_account -from .http_mock import HttpMockSequence - - -__author__ = 'jcgregorio@google.com (Joe Gregorio)' +from oauth2client import transport +from tests import http_mock _FORMATS_TO_CONSTRUCTOR_ARGS = { @@ -47,7 +46,7 @@ def datafile(filename): return file_obj.read() -class CryptTests(unittest2.TestCase): +class CryptTests(unittest.TestCase): def setUp(self): self.format_ = 'p12' @@ -114,25 +113,30 @@ class CryptTests(unittest2.TestCase): self.assertEqual('billy bob', contents['user']) self.assertEqual('data', contents['metadata']['meta']) + def _verify_http_mock(self, http): + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, client.ID_TOKEN_VERIFICATION_CERTS) + self.assertEqual(http.method, 'GET') + self.assertIsNone(http.body) + self.assertIsNone(http.headers) + def test_verify_id_token_with_certs_uri(self): jwt = self._create_signed_jwt() - http = HttpMockSequence([ - ({'status': '200'}, datafile('certs.json')), - ]) - + http = http_mock.HttpMock(data=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']) + # Verify mocks. + self._verify_http_mock(http) + def test_verify_id_token_with_certs_uri_default_http(self): jwt = self._create_signed_jwt() - http = HttpMockSequence([ - ({'status': '200'}, datafile('certs.json')), - ]) + http = http_mock.HttpMock(data=datafile('certs.json')) with mock.patch('oauth2client.transport._CACHED_HTTP', new=http): contents = client.verify_id_token( @@ -141,17 +145,23 @@ class CryptTests(unittest2.TestCase): self.assertEqual('billy bob', contents['user']) self.assertEqual('data', contents['metadata']['meta']) + # Verify mocks. + self._verify_http_mock(http) + 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')), - ]) + http = http_mock.HttpMock( + headers={'status': http_client.NOT_FOUND}, + data=datafile('certs.json')) with self.assertRaises(client.VerifyJwtTokenError): client.verify_id_token(jwt, test_email, http=http) + # Verify mocks. + self._verify_http_mock(http) + def test_verify_id_token_bad_tokens(self): private_key = datafile('privatekey.' + self.format_) @@ -232,12 +242,16 @@ class PEMCryptTestsOpenSSL(CryptTests): self.verifier = crypt.OpenSSLVerifier -class SignedJwtAssertionCredentialsTests(unittest2.TestCase): +class SignedJwtAssertionCredentialsTests(unittest.TestCase): def setUp(self): + self.orig_signer = crypt.Signer self.format_ = 'p12' crypt.Signer = crypt.OpenSSLSigner + def tearDown(self): + crypt.Signer = self.orig_signer + def _make_credentials(self): private_key = datafile('privatekey.' + self.format_) signer = crypt.Signer.from_string(private_key) @@ -257,12 +271,13 @@ class SignedJwtAssertionCredentialsTests(unittest2.TestCase): 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 = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, + b'{"access_token":"1/3w","expires_in":3600}'), + ({'status': http_client.OK}, 'echo_request_headers'), ]) http = credentials.authorize(http) - resp, content = http.request('http://example.org') + resp, content = transport.request(http, 'http://example.org') self.assertEqual(b'Bearer 1/3w', content[b'Authorization']) def test_credentials_to_from_json(self): @@ -276,14 +291,16 @@ class SignedJwtAssertionCredentialsTests(unittest2.TestCase): 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 = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, + b'{"access_token":"1/3w","expires_in":3600}'), + ({'status': http_client.UNAUTHORIZED}, b''), + ({'status': http_client.OK}, + b'{"access_token":"3/3w","expires_in":3600}'), + ({'status': http_client.OK}, 'echo_request_headers'), ]) http = credentials.authorize(http) - _, content = http.request('http://example.org') + _, content = transport.request(http, 'http://example.org') return content def test_credentials_refresh_without_storage(self): @@ -296,7 +313,7 @@ class SignedJwtAssertionCredentialsTests(unittest2.TestCase): filehandle, filename = tempfile.mkstemp() os.close(filehandle) - store = file.Storage(filename) + store = file_module.Storage(filename) store.put(credentials) credentials.set_store(store) @@ -310,19 +327,27 @@ class PEMSignedJwtAssertionCredentialsOpenSSLTests( SignedJwtAssertionCredentialsTests): def setUp(self): + self.orig_signer = crypt.Signer self.format_ = 'pem' crypt.Signer = crypt.OpenSSLSigner + def tearDown(self): + crypt.Signer = self.orig_signer + class PEMSignedJwtAssertionCredentialsPyCryptoTests( SignedJwtAssertionCredentialsTests): def setUp(self): + self.orig_signer = crypt.Signer self.format_ = 'pem' crypt.Signer = crypt.PyCryptoSigner + def tearDown(self): + crypt.Signer = self.orig_signer + -class TestHasOpenSSLFlag(unittest2.TestCase): +class TestHasOpenSSLFlag(unittest.TestCase): def test_true(self): self.assertEqual(True, client.HAS_OPENSSL) diff --git a/tests/test_service_account.py b/tests/test_service_account.py index 699e699..6756d49 100644 --- a/tests/test_service_account.py +++ b/tests/test_service_account.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Oauth2client tests. +"""oauth2client tests. Unit tests for service account credentials implemented using RSA. """ @@ -21,17 +21,18 @@ import datetime import json import os import tempfile +import unittest -import httplib2 import mock import rsa -from six import BytesIO -import unittest2 +import six +from six.moves import http_client from oauth2client import client from oauth2client import crypt from oauth2client import service_account -from .http_mock import HttpMockSequence +from oauth2client import transport +from tests import http_mock def data_filename(filename): @@ -43,9 +44,11 @@ def datafile(filename): return file_obj.read() -class ServiceAccountCredentialsTests(unittest2.TestCase): +class ServiceAccountCredentialsTests(unittest.TestCase): def setUp(self): + self.orig_signer = crypt.Signer + self.orig_verifier = crypt.Verifier self.client_id = '123' self.service_account_email = 'dummy@google.com' self.private_key_id = 'ABCDEF' @@ -59,6 +62,10 @@ class ServiceAccountCredentialsTests(unittest2.TestCase): client_id=self.client_id, ) + def tearDown(self): + crypt.Signer = self.orig_signer + crypt.Verifier = self.orig_verifier + def test__to_json_override(self): signer = object() creds = service_account.ServiceAccountCredentials( @@ -175,7 +182,7 @@ class ServiceAccountCredentialsTests(unittest2.TestCase): 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), + service_account_email, six.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): @@ -270,10 +277,10 @@ class ServiceAccountCredentialsTests(unittest2.TestCase): utcnow.return_value = NOW # Create a custom credentials with a mock signer. - signer = mock.MagicMock() + signer = mock.Mock() signed_value = b'signed-content' - signer.sign = mock.MagicMock(name='sign', - return_value=signed_value) + signer.sign = mock.Mock(name='sign', + return_value=signed_value) credentials = service_account.ServiceAccountCredentials( self.service_account_email, signer, @@ -296,10 +303,10 @@ class ServiceAccountCredentialsTests(unittest2.TestCase): 'access_token': token2, 'expires_in': lifetime, } - http = HttpMockSequence([ - ({'status': '200'}, + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, json.dumps(token_response_first).encode('utf-8')), - ({'status': '200'}, + ({'status': http_client.OK}, json.dumps(token_response_second).encode('utf-8')), ]) @@ -362,6 +369,7 @@ class ServiceAccountCredentialsTests(unittest2.TestCase): 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) @@ -379,7 +387,7 @@ T3_EXPIRY = T3 + TOKEN_LIFE T3_EXPIRY_DATE = T3_DATE + datetime.timedelta(seconds=TOKEN_LIFE) -class JWTAccessCredentialsTests(unittest2.TestCase): +class JWTAccessCredentialsTests(unittest.TestCase): def setUp(self): self.client_id = '123' @@ -400,13 +408,15 @@ class JWTAccessCredentialsTests(unittest2.TestCase): time.return_value = T1 token_info = self.jwt.get_access_token() + certs = {'key': datafile('public_cert.pem')} payload = crypt.verify_signed_jwt_with_certs( - token_info.access_token, - {'key': datafile('public_cert.pem')}, audience=self.url) + token_info.access_token, certs, audience=self.url) + self.assertEqual(len(payload), 5) 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(payload['aud'], self.url) self.assertEqual(token_info.expires_in, T1_EXPIRY - T1) # Verify that we vend the same token after 100 seconds @@ -437,19 +447,20 @@ class JWTAccessCredentialsTests(unittest2.TestCase): 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' - }) + audience = 'https://test2.url.com' + subject = 'dummy2@google.com' + claims = {'aud': audience, 'sub': subject} + token_info = self.jwt.get_access_token(additional_claims=claims) + certs = {'key': datafile('public_cert.pem')} payload = crypt.verify_signed_jwt_with_certs( - token_info.access_token, - {'key': datafile('public_cert.pem')}, - audience='https://test2.url.com') + token_info.access_token, certs, audience=audience) expires_in = token_info.expires_in + self.assertEqual(len(payload), 5) self.assertEqual(payload['iss'], self.service_account_email) - self.assertEqual(payload['sub'], 'dummy2@google.com') + self.assertEqual(payload['sub'], subject) self.assertEqual(payload['iat'], T1) self.assertEqual(payload['exp'], T1_EXPIRY) + self.assertEqual(payload['aud'], audience) self.assertEqual(expires_in, T1_EXPIRY - T1) def test_revoke(self): @@ -474,30 +485,36 @@ class JWTAccessCredentialsTests(unittest2.TestCase): 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() + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b''), + ({'status': http_client.OK}, b''), + ]) + + self.jwt.authorize(http) + transport.request(http, self.url) + + # Ensure we use the cached token + utcnow.return_value = T2_DATE + transport.request(http, self.url) + + # Verify mocks. + certs = {'key': datafile('public_cert.pem')} + self.assertEqual(len(http.requests), 2) + for info in http.requests: + self.assertEqual(info['method'], 'GET') + self.assertEqual(info['uri'], self.url) + self.assertIsNone(info['body']) + self.assertEqual(len(info['headers']), 1) + bearer, token = info['headers'][b'Authorization'].split() + self.assertEqual(bearer, b'Bearer') payload = crypt.verify_signed_jwt_with_certs( - token, - {'key': datafile('public_cert.pem')}, - audience=self.url) + token, certs, audience=self.url) + self.assertEqual(len(payload), 5) 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) + self.assertEqual(payload['aud'], self.url) @mock.patch('oauth2client.client._UTCNOW') @mock.patch('time.time') @@ -509,65 +526,126 @@ class JWTAccessCredentialsTests(unittest2.TestCase): 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'' + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b''), + ]) - h = httplib2.Http() - h.request = mock_request - jwt.authorize(h) - h.request(self.url) + jwt.authorize(http) + transport.request(http, self.url) # Ensure we do not cache the token self.assertIsNone(jwt.access_token) + # Verify mocks. + self.assertEqual(len(http.requests), 1) + info = http.requests[0] + self.assertEqual(info['method'], 'GET') + self.assertEqual(info['uri'], self.url) + self.assertIsNone(info['body']) + self.assertEqual(len(info['headers']), 1) + bearer, token = info['headers'][b'Authorization'].split() + self.assertEqual(bearer, b'Bearer') + certs = {'key': datafile('public_cert.pem')} + payload = crypt.verify_signed_jwt_with_certs( + token, certs, audience=self.url) + self.assertEqual(len(payload), 5) + 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(payload['aud'], self.url) + @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) + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b''), + ({'status': http_client.OK}, b''), + ]) + self.jwt.authorize(http) + transport.request(http, self.url) token_1 = self.jwt.access_token # Expire the token utcnow.return_value = T3_DATE - h.request(self.url) + transport.request(http, self.url) token_2 = self.jwt.access_token self.assertEquals(self.jwt.token_expiry, T3_EXPIRY_DATE) self.assertNotEqual(token_1, token_2) + # Verify mocks. + certs = {'key': datafile('public_cert.pem')} + self.assertEqual(len(http.requests), 2) + issued_at_vals = (T1, T3) + exp_vals = (T1_EXPIRY, T3_EXPIRY) + for info, issued_at, exp_val in zip(http.requests, issued_at_vals, + exp_vals): + self.assertEqual(info['uri'], self.url) + self.assertEqual(info['method'], 'GET') + self.assertIsNone(info['body']) + self.assertEqual(len(info['headers']), 1) + bearer, token = info['headers'][b'Authorization'].split() + self.assertEqual(bearer, b'Bearer') + # To parse the token, skip the time check, since this + # test intentionally has stale tokens. + with mock.patch('oauth2client.crypt._verify_time_range', + return_value=True): + payload = crypt.verify_signed_jwt_with_certs( + token, certs, audience=self.url) + self.assertEqual(len(payload), 5) + self.assertEqual(payload['iss'], self.service_account_email) + self.assertEqual(payload['sub'], self.service_account_email) + self.assertEqual(payload['iat'], issued_at) + self.assertEqual(payload['exp'], exp_val) + self.assertEqual(payload['aud'], self.url) + @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) + http = http_mock.HttpMockSequence([ + ({'status': http_client.OK}, b''), + ({'status': http_client.UNAUTHORIZED}, b''), + ({'status': http_client.OK}, b''), + ]) + self.jwt.authorize(http) + transport.request(http, self.url) token_1 = self.jwt.access_token utcnow.return_value = T2_DATE - self.assertEquals(h.request(self.url)[0].status, 200) + response, _ = transport.request(http, self.url) + self.assertEquals(response.status, http_client.OK) token_2 = self.jwt.access_token # Check the 401 forced a new token self.assertNotEqual(token_1, token_2) + # Verify mocks. + certs = {'key': datafile('public_cert.pem')} + self.assertEqual(len(http.requests), 3) + issued_at_vals = (T1, T1, T2) + exp_vals = (T1_EXPIRY, T1_EXPIRY, T2_EXPIRY) + for info, issued_at, exp_val in zip(http.requests, issued_at_vals, + exp_vals): + self.assertEqual(info['uri'], self.url) + self.assertEqual(info['method'], 'GET') + self.assertIsNone(info['body']) + self.assertEqual(len(info['headers']), 1) + bearer, token = info['headers'][b'Authorization'].split() + self.assertEqual(bearer, b'Bearer') + # To parse the token, skip the time check, since this + # test intentionally has stale tokens. + with mock.patch('oauth2client.crypt._verify_time_range', + return_value=True): + payload = crypt.verify_signed_jwt_with_certs( + token, certs, audience=self.url) + self.assertEqual(len(payload), 5) + self.assertEqual(payload['iss'], self.service_account_email) + self.assertEqual(payload['sub'], self.service_account_email) + self.assertEqual(payload['iat'], issued_at) + self.assertEqual(payload['exp'], exp_val) + self.assertEqual(payload['aud'], self.url) + @mock.patch('oauth2client.client._UTCNOW') def test_refresh(self, utcnow): utcnow.return_value = T1_DATE diff --git a/tests/test_tools.py b/tests/test_tools.py index 369f567..52191f0 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -12,24 +12,20 @@ # See the License for the specific language governing permissions and # limitations under the License. +import argparse import socket import sys import threading +import unittest 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): +class TestClientRedirectServer(unittest.TestCase): """Test the ClientRedirectServer and ClientRedirectHandler classes.""" def test_ClientRedirectServer(self): @@ -51,7 +47,7 @@ class TestClientRedirectServer(unittest2.TestCase): self.assertEqual(httpd.query_params.get('code'), code) -class TestRunFlow(unittest2.TestCase): +class TestRunFlow(unittest.TestCase): def setUp(self): self.server = mock.Mock() @@ -187,6 +183,6 @@ class TestRunFlow(unittest2.TestCase): self.assertFalse(self.server.handle_request.called) -class TestMessageIfMissing(unittest2.TestCase): +class TestMessageIfMissing(unittest.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 index e9782a8..2884200 100644 --- a/tests/test_transport.py +++ b/tests/test_transport.py @@ -12,15 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import unittest + import httplib2 import mock -import unittest2 from oauth2client import client from oauth2client import transport +from tests import http_mock -class TestMemoryCache(unittest2.TestCase): +class TestMemoryCache(unittest.TestCase): def test_get_set_delete(self): cache = transport.MemoryCache() @@ -32,7 +34,7 @@ class TestMemoryCache(unittest2.TestCase): self.assertIsNone(cache.get('foo')) -class Test_get_cached_http(unittest2.TestCase): +class Test_get_cached_http(unittest.TestCase): def test_global(self): cached_http = transport.get_cached_http() @@ -46,15 +48,22 @@ class Test_get_cached_http(unittest2.TestCase): self.assertIs(result, cache) -class Test_get_http_object(unittest2.TestCase): +class Test_get_http_object(unittest.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) + http_klass.assert_called_once_with() + + @mock.patch.object(httplib2, 'Http', return_value=object()) + def test_with_args(self, http_klass): + result = transport.get_http_object(1, 2, foo='bar') + self.assertEqual(result, http_klass.return_value) + http_klass.assert_called_once_with(1, 2, foo='bar') -class Test__initialize_headers(unittest2.TestCase): +class Test__initialize_headers(unittest.TestCase): def test_null(self): result = transport._initialize_headers(None) @@ -67,7 +76,7 @@ class Test__initialize_headers(unittest2.TestCase): self.assertIsNot(result, headers) -class Test__apply_user_agent(unittest2.TestCase): +class Test__apply_user_agent(unittest.TestCase): def test_null(self): headers = object() @@ -91,7 +100,7 @@ class Test__apply_user_agent(unittest2.TestCase): self.assertEqual(result, {'user-agent': final_agent}) -class Test_clean_headers(unittest2.TestCase): +class Test_clean_headers(unittest.TestCase): def test_no_modify(self): headers = {b'key': b'val'} @@ -119,7 +128,7 @@ class Test_clean_headers(unittest2.TestCase): self.assertEqual(result, header_str) -class Test_wrap_http_for_auth(unittest2.TestCase): +class Test_wrap_http_for_auth(unittest.TestCase): def test_wrap(self): credentials = object() @@ -129,3 +138,45 @@ class Test_wrap_http_for_auth(unittest2.TestCase): self.assertIsNone(result) self.assertNotEqual(http.request, orig_req_method) self.assertIs(http.request.credentials, credentials) + + +class Test_request(unittest.TestCase): + + uri = 'http://localhost' + method = 'POST' + body = 'abc' + redirections = 3 + + def test_with_request_attr(self): + mock_result = object() + headers = {'foo': 'bar'} + http = http_mock.HttpMock(headers=headers, data=mock_result) + + response, content = transport.request( + http, self.uri, method=self.method, body=self.body, + redirections=self.redirections) + self.assertEqual(response, headers) + self.assertIs(content, mock_result) + # Verify mocks. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, self.uri) + self.assertEqual(http.method, self.method) + self.assertEqual(http.body, self.body) + self.assertIsNone(http.headers) + + def test_with_callable_http(self): + headers = {} + mock_result = object() + http = http_mock.HttpMock(headers=headers, data=mock_result) + + result = transport.request(http, self.uri, method=self.method, + body=self.body, + redirections=self.redirections) + self.assertEqual(result, (headers, mock_result)) + # Verify mock. + self.assertEqual(http.requests, 1) + self.assertEqual(http.uri, self.uri) + self.assertEqual(http.method, self.method) + self.assertEqual(http.body, self.body) + self.assertIsNone(http.headers) + self.assertEqual(http.redirections, self.redirections) diff --git a/tests/test_util.py b/tests/test_util.py deleted file mode 100644 index 533460f..0000000 --- a/tests/test_util.py +++ /dev/null @@ -1,122 +0,0 @@ -"""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') @@ -1,5 +1,5 @@ [tox] -envlist = py26,py27,py33,py34,py35,pypy,gae,cover +envlist = flake8,py27,py34,py35,gae,cover [testenv] basedeps = mock>=1.3.0 @@ -7,98 +7,43 @@ basedeps = mock>=1.3.0 cryptography>=1.0 pyopenssl>=0.14 webtest - nose + pytest flask - unittest2 sqlalchemy fasteners deps = {[testenv]basedeps} django keyring + jsonpickle 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} +commands = + py.test {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 + py.test \ + --cov=oauth2client \ + --cov=tests + py.test \ + --cov=oauth2client \ + --cov=tests \ + --cov-append \ + --gae-sdk={env:GAE_PYTHONPATH:} \ + tests/contrib/appengine 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 + pytest-cov [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 + coverage report --show-missing --fail-under=100 deps = {[coverbase]deps} - coveralls -passenv = {[testenv:system-tests]passenv} [testenv:docs] basepython = python2.7 @@ -114,15 +59,8 @@ 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 + py.test --gae-sdk={env:GAE_PYTHONPATH:} tests/contrib/appengine [testenv:system-tests] basepython = @@ -133,7 +71,7 @@ deps = pycrypto>=2.6 cryptography>=1.0 pyopenssl>=0.14 -passenv = GOOGLE_* OAUTH2CLIENT_* TRAVIS* +passenv = GOOGLE_* OAUTH2CLIENT_* TRAVIS* encrypted_* [testenv:system-tests3] basepython = @@ -153,7 +91,6 @@ commands = python {toxinidir}/scripts/run_gce_system_tests.py deps = pycrypto>=2.6 - unittest2 passenv = {[testenv:system-tests]passenv} [testenv:flake8] @@ -163,16 +100,17 @@ deps = flake8-import-order [flake8] -exclude = .tox,.git,./*.egg,build, -application-import-names = oauth2client +exclude = .tox,.git,./*.egg,build,.cache,env,__pycache__ +application-import-names = oauth2client, tests putty-ignore = # E402 module level import not at top of file - # These files have needed configurations defined before import + # This file has 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 + # E722 do not use bare except + # Existing sloppy usages. + oauth2client/crypt.py : E722 + oauth2client/contrib/multiprocess_file_storage.py : E722 |