├── .coveragerc ├── .gitignore ├── .python-version ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── Makefile ├── README.rst ├── docs ├── Makefile ├── client_authenticator.rst ├── conf.py ├── error.rst ├── examples │ ├── authorization_code_grant.py │ ├── base_server.py │ ├── client_credentials_grant.py │ ├── implicit_grant.py │ ├── pyramid │ │ ├── README.md │ │ ├── base.py │ │ └── impl.py │ ├── resource_owner_grant.py │ └── tornado_server.py ├── frameworks.rst ├── grant.rst ├── index.rst ├── log.rst ├── migration.rst ├── oauth2.rst ├── store.rst ├── store │ ├── dbapi.rst │ ├── memcache.rst │ ├── memory.rst │ ├── mongodb.rst │ ├── mysql.rst │ └── redisdb.rst ├── token_generator.rst ├── tornado.rst ├── unique_token.rst └── web.rst ├── oauth2 ├── __init__.py ├── client_authenticator.py ├── compatibility.py ├── datatype.py ├── error.py ├── grant.py ├── log.py ├── store │ ├── __init__.py │ ├── dbapi │ │ ├── __init__.py │ │ └── mysql.py │ ├── memcache.py │ ├── memory.py │ ├── mongodb.py │ └── redisdb.py ├── test │ ├── __init__.py │ ├── functional │ │ ├── __init__.py │ │ └── test_authorization_code.py │ ├── store │ │ ├── __init__.py │ │ ├── test_dbapi.py │ │ ├── test_memcache.py │ │ ├── test_memory.py │ │ ├── test_mongodb.py │ │ └── test_redisdb.py │ ├── test_client_authenticator.py │ ├── test_datatype.py │ ├── test_grant.py │ ├── test_oauth2.py │ ├── test_tokengenerator.py │ └── test_web.py ├── tokengenerator.py └── web │ ├── __init__.py │ ├── tornado.py │ └── wsgi.py ├── requirements.txt ├── setup.py └── vagrant ├── Berksfile ├── Gemfile ├── README.md ├── Vagrantfile ├── create_testclient.py ├── mysql-schema.sql ├── setup.sh └── start_provider.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | exclude_lines = 3 | pragma: no cover 4 | raise NotImplementedError 5 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | .project 3 | .pydevproject 4 | .idea/ 5 | .settings/ 6 | 7 | # Python 8 | *.pyc 9 | *.egg-info/ 10 | dist/ 11 | build/ 12 | _build/ 13 | __pycache__ 14 | 15 | # coverage 16 | .coverage 17 | cover/ 18 | 19 | # Vagrant 20 | .vagrant/ 21 | Berksfile.lock 22 | cookbooks/ 23 | Gemfile.lock 24 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.5.2 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - 2.7 5 | - 3.4 6 | - 3.5 7 | - 3.6 8 | env: 9 | - DB=mongodb 10 | - DB=mysql 11 | - DB=redis-server 12 | before_script: 13 | - sh -c "if [ '$DB' = 'mysql' ]; then mysql -e 'CREATE DATABASE IF NOT EXISTS testdb;'; 14 | fi" 15 | services: 16 | - mongodb 17 | - redis-server 18 | install: 19 | - pip install --upgrade setuptools 20 | - pip install -r requirements.txt 21 | script: make test 22 | deploy: 23 | provider: pypi 24 | user: wandhydrant 25 | password: 26 | secure: F1Sg5YXT6ZwsOvo5mfGGLwRTgyaYE/jXQMEyN9Sz223IeuJy8YQYempMAhPZbJY8zKI4FM9KIVNZB6JH9AXjV7fUS4axee8eqrSHFYqEz+gPVk6BYepYtUBw1U6xRbJeMFwNg6kaJvY+TGmyAKEeWyHPLDNEdU+tKHB4fyLnbIc= 27 | on: 28 | tags: true 29 | repo: wndhydrnt/python-oauth2 30 | condition: $TRAVIS_PYTHON_VERSION = '3.6' 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.1.1 2 | 3 | Features: 4 | 5 | - Add message explaining that this project is not maintained ([@wndhydrnt][]) 6 | 7 | ## 1.1.0 8 | 9 | Improvements: 10 | 11 | - Drop support for Python 2.6 ([@wndhydrnt][]) 12 | - Drop support for Python 3.2 ([@wndhydrnt][]) 13 | - Drop support for Python 3.3 ([@wndhydrnt][]) 14 | - Add error message to response for bad redirect URIs ([@SumnerH][]) 15 | 16 | Bugfixes: 17 | 18 | - Fix an exception when requesting an unknown URL on Python 3.x ([@wndhydrnt][]) 19 | 20 | ## 1.0.1 21 | 22 | Bugfixes: 23 | 24 | - Fix an error in `ClientCredentialsGrant` when no expiration of a token is set in the handler ([@wndhydrnt][]) 25 | - Fix an error where the body of a POST request would not be parsed if the `Content-Type` header contains a `charset` ([@wndhydrnt][]) 26 | 27 | ## 1.0.0 28 | 29 | Features: 30 | 31 | - Logging support ([@wndhydrnt][]) 32 | - Each grant accepts its own site adapter (see [Migration notes](http://python-oauth2.readthedocs.org/en/latest/migration.html)) ([@wndhydrnt][]) 33 | - [Tornado](http://www.tornadoweb.org/) adapter 34 | 35 | Improvements: 36 | 37 | - Catch unexpected exceptions and respond with a OAuth2 'server_error' ([@wndhydrnt][]) 38 | - Declare optional dependencies in setup.py ([@wndhydrnt][]) 39 | - Move WSGI server code into its own module ([@wndhydrnt][]) 40 | - Renamed class acting as entrypoint for WSGI server from 'Server' to 'Application' ([@wndhydrnt][]) 41 | - Client Credentials Grant example ([@shupp][]) 42 | - Methods `authenticate` and `render_auth_page` of a Site Adapter accept an instance of `oauth2.datatype.Client` ([@wndhydrnt][]) 43 | 44 | Bugfixes: 45 | 46 | - Fix Resource Owner Grant responding with HTTP status code '500' in case an owner could not be authorized ([@wndhydrnt][]) 47 | - Fix "scope" parameter not being urlencoded ([@wndhydrnt][]) 48 | 49 | ## 0.7.0 50 | 51 | Features: 52 | 53 | - Issue a new refresh token when requesting an access token through the refresh_token grant type ([@jswitzer][]) 54 | - Set the expiration time of a token for each grant individually ([@jswitzer][]) 55 | - Create redis stores ([@bhoomit][]) 56 | - Create mysql stores ([@wndhydrnt][]) 57 | 58 | Improvements: 59 | 60 | - Update Tornado integration docs ([@kivo360][]) 61 | - Add functional tests for supported storage backends. ([@wndhydrnt][]) 62 | 63 | Bugfixes: 64 | 65 | - Fix WSGI adapter not passing a list of tuples as headers in Python 3. ([@wndhydrnt][]) 66 | - Fix request for access token responding '400: Bad Request' in Python 3. ([@wndhydrnt][]) 67 | 68 | ## 0.6.0 69 | 70 | Features: 71 | 72 | - Issue unique access tokens ([@wndhydrnt][]) 73 | - Define what grants a client is allowed to use ([@wndhydrnt][]) 74 | 75 | Improvements: 76 | 77 | - Corrected class references in doc strings (@Trii) 78 | - Proper handling of errors raised by store adapters ([@wndhydrnt][]) 79 | 80 | Bugfixes: 81 | 82 | - Added missing `scopes` parameter in SiteAdapter base class (@Trii) 83 | - Deleting authorization token after usage (@Trii) 84 | - Scope parameter not returned by access token response of Authorization Code Grant ([@wndhydrnt][]) 85 | - Added missing cache control headers to responses containing a token ([@wndhydrnt][]) 86 | - Fixed ClientCredentialsGrant returning a value of 0 of 'expires_in' with refresh token disabled ([@wndhydrnt][]) 87 | 88 | ## 0.5.0 89 | 90 | Features: 91 | 92 | - Added Client Credentials Grant ([@wndhydrnt][]) 93 | - Renamed `oauth2.AuthorizationController` to `oauth2.Provider` ([@wndhydrnt][]) 94 | - Added mongodb store ([@wndhydrnt][]) 95 | 96 | ## 0.4.0 97 | 98 | Features: 99 | 100 | - Added support for refresh tokens ([@wndhydrnt][]) 101 | 102 | ## 0.3.2 103 | 104 | Bugfixes: 105 | 106 | - Fixed a bug where MemcacheTokenStore saved objects instead of dictionaries. ([@wndhydrnt][]) 107 | 108 | ## 0.3.1 109 | 110 | Bugfixes: 111 | 112 | - Fixed a bug causing a supplied redirect uri being ignored if it is not the first entry in the list of a client object. ([@wndhydrnt][]) 113 | 114 | ## 0.3.0 115 | 116 | Features: 117 | 118 | - Headers of a response are returned as a dictionary ([@wndhydrnt][]) 119 | - Status code of a response is an integer ([@wndhydrnt][]) 120 | - Streamlining the integration of storage classes and site adapters by requiring them to raise specified errors ([@wndhydrnt][]) 121 | 122 | ## 0.2.0 123 | 124 | Features: 125 | 126 | - Support for scopes ([@wndhydrnt][]) 127 | - Local token and client stores ([@wndhydrnt][]) 128 | - Memcache token store ([@wndhydrnt][]) 129 | - Support for Python 2.6, 3.2 and 3.3 ([@wndhydrnt][]) 130 | 131 | ## 0.1.0 132 | 133 | Features: 134 | 135 | - Working implementation of Authorization Code Grant ([@wndhydrnt][]) 136 | - Working implementation of Implicit Grant ([@wndhydrnt][]) 137 | - Working implementation of Resource Owner Password Credentials Grant ([@wndhydrnt][]) 138 | 139 | [@wndhydrnt]: https://github.com/wndhydrnt 140 | [@Trii]: https://github.com/Trii 141 | [@jswitzer]: https://github.com/jswitzer 142 | [@kivo360]: https://github.com/kivo360 143 | [@bhoomit]: https://github.com/bhoomit 144 | [@shupp]: https://github.com/shupp 145 | [@SumnerH]: https://github.com/SumnerH 146 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Markus Meyer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # Run All unit tests 2 | test: 3 | nosetests 4 | 5 | unittest: 6 | nosetests --exclude='functional' 7 | 8 | functest: 9 | nosetests --where=oauth2/test/functional 10 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | This project is not maintained anymore. 2 | If you are looking for a OAuth 2.0 library to integrate into your Python application, I recommend `oauthlib `_. 3 | 4 | python-oauth2 5 | ############# 6 | 7 | python-oauth2 is a framework that aims at making it easy to provide authentication 8 | via `OAuth 2.0 `_ within an application stack. 9 | 10 | `Documentation `_ 11 | 12 | Status 13 | ****** 14 | 15 | .. image:: https://travis-ci.org/wndhydrnt/python-oauth2.png?branch=master 16 | :target: https://travis-ci.org/wndhydrnt/python-oauth2 17 | 18 | python-oauth2 has reached its beta phase. All main parts of the `OAuth 2.0 RFC `_ such as the various types of Grants, Refresh Token and Scopes have been implemented. However, bugs might occur or implementation details might be wrong. 19 | 20 | Installation 21 | ************ 22 | 23 | python-oauth2 is available on 24 | `PyPI `_. 25 | 26 | pip install python-oauth2 27 | 28 | Usage 29 | ***** 30 | 31 | Example Authorization server 32 | 33 | .. code-block:: python 34 | 35 | from wsgiref.simple_server import make_server 36 | import oauth2 37 | import oauth2.grant 38 | import oauth2.error 39 | import oauth2.store.memory 40 | import oauth2.tokengenerator 41 | import oauth2.web.wsgi 42 | 43 | 44 | # Create a SiteAdapter to interact with the user. 45 | # This can be used to display confirmation dialogs and the like. 46 | class ExampleSiteAdapter(oauth2.web.AuthorizationCodeGrantSiteAdapter, 47 | oauth2.web.ImplicitGrantSiteAdapter): 48 | TEMPLATE = ''' 49 | 50 | 51 |

52 | confirm 53 |

54 |

55 | deny 56 |

57 | 58 | ''' 59 | 60 | def authenticate(self, request, environ, scopes, client): 61 | # Check if the user has granted access 62 | if request.post_param("confirm") == "confirm": 63 | return {} 64 | 65 | raise oauth2.error.UserNotAuthenticated 66 | 67 | def render_auth_page(self, request, response, environ, scopes, 68 | client): 69 | url = request.path + "?" + request.query_string 70 | response.body = self.TEMPLATE.format(url=url) 71 | return response 72 | 73 | def user_has_denied_access(self, request): 74 | # Check if the user has denied access 75 | if request.post_param("deny") == "deny": 76 | return True 77 | return False 78 | 79 | # Create an in-memory storage to store your client apps. 80 | client_store = oauth2.store.memory.ClientStore() 81 | # Add a client 82 | client_store.add_client(client_id="abc", client_secret="xyz", 83 | redirect_uris=["http://localhost/callback"]) 84 | 85 | site_adapter = ExampleSiteAdapter() 86 | 87 | # Create an in-memory storage to store issued tokens. 88 | # LocalTokenStore can store access and auth tokens 89 | token_store = oauth2.store.memory.TokenStore() 90 | 91 | # Create the controller. 92 | provider = oauth2.Provider( 93 | access_token_store=token_store, 94 | auth_code_store=token_store, 95 | client_store=client_store, 96 | token_generator=oauth2.tokengenerator.Uuid4() 97 | ) 98 | 99 | # Add Grants you want to support 100 | provider.add_grant(oauth2.grant.AuthorizationCodeGrant(site_adapter=site_adapter)) 101 | provider.add_grant(oauth2.grant.ImplicitGrant(site_adapter=site_adapter)) 102 | 103 | # Add refresh token capability and set expiration time of access tokens 104 | # to 30 days 105 | provider.add_grant(oauth2.grant.RefreshToken(expires_in=2592000)) 106 | 107 | # Wrap the controller with the Wsgi adapter 108 | app = oauth2.web.wsgi.Application(provider=provider) 109 | 110 | if __name__ == "__main__": 111 | httpd = make_server('', 8080, app) 112 | httpd.serve_forever() 113 | 114 | 115 | This example only shows how to instantiate the server. 116 | It is not a working example as a client app is missing. Take a look at the 117 | `examples `_ directory. 118 | 119 | Supported storage backends 120 | ************************** 121 | 122 | python-oauth2 does not force you to use a specific database. 123 | It currently supports these storage backends out-of-the-box: 124 | 125 | - MongoDB 126 | - MySQL 127 | - Redis 128 | - Memcached 129 | 130 | However, you are not not bound to these implementations. 131 | By adhering to the interface defined by the base classes in ``oauth2.store``, 132 | you can easily add an implementation of your backend. 133 | It also is possible to mix different backends and e.g. read data of a client 134 | from MongoDB while saving all tokens in memcached for fast access. 135 | 136 | Take a look at the examples in the *examples* directory of the project. 137 | 138 | Site adapter 139 | ************ 140 | 141 | Like for storage, python-oauth2 does not define how you identify a user or 142 | show a confirmation dialogue. 143 | Instead your application should use the API defined by 144 | ``oauth2.web.SiteAdapter``. 145 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/python-oauth2.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/python-oauth2.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/python-oauth2" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/python-oauth2" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /docs/client_authenticator.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.client_authenticator`` --- Client authentication 2 | ========================================================= 3 | 4 | .. automodule:: oauth2.client_authenticator 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # python-oauth2 documentation build configuration file, created by 4 | # sphinx-quickstart on Sun Oct 13 20:22:10 2013. 5 | # 6 | # This file is execfile()d with the current directory set to its containing dir. 7 | # 8 | # Note that not all possible configuration values are present in this 9 | # autogenerated file. 10 | # 11 | # All configuration values have a default; values that are commented out 12 | # serve to show the default. 13 | 14 | import sys, os 15 | 16 | sys.path.insert(0, os.path.abspath("..")) 17 | 18 | import oauth2 19 | 20 | # If extensions (or modules to document with autodoc) are in another directory, 21 | # add these directories to sys.path here. If the directory is relative to the 22 | # documentation root, use os.path.abspath to make it absolute, like shown here. 23 | #sys.path.insert(0, os.path.abspath('.')) 24 | 25 | # -- General configuration ----------------------------------------------------- 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be extensions 31 | # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. 32 | extensions = ['sphinx.ext.autodoc', 'sphinx.ext.viewcode'] 33 | 34 | # The suffix of source filenames. 35 | source_suffix = '.rst' 36 | 37 | # The encoding of source files. 38 | #source_encoding = 'utf-8-sig' 39 | 40 | # The master toctree document. 41 | master_doc = 'index' 42 | 43 | # General information about the project. 44 | project = u'python-oauth2' 45 | copyright = u'2015, Markus Meyer' 46 | 47 | # The version info for the project you're documenting, acts as replacement for 48 | # |version| and |release|, also used in various other places throughout the 49 | # built documents. 50 | # 51 | # The short X.Y version. 52 | version = release = oauth2.VERSION 53 | 54 | # The language for content autogenerated by Sphinx. Refer to documentation 55 | # for a list of supported languages. 56 | #language = None 57 | 58 | # There are two options for replacing |today|: either, you set today to some 59 | # non-false value, then it is used: 60 | #today = '' 61 | # Else, today_fmt is used as the format for a strftime call. 62 | #today_fmt = '%B %d, %Y' 63 | 64 | # List of patterns, relative to source directory, that match files and 65 | # directories to ignore when looking for source files. 66 | exclude_patterns = ['_build'] 67 | 68 | # The reST default role (used for this markup: `text`) to use for all documents. 69 | #default_role = None 70 | 71 | # If true, '()' will be appended to :func: etc. cross-reference text. 72 | #add_function_parentheses = True 73 | 74 | # If true, the current module name will be prepended to all description 75 | # unit titles (such as .. function::). 76 | #add_module_names = True 77 | 78 | # If true, sectionauthor and moduleauthor directives will be shown in the 79 | # output. They are ignored by default. 80 | #show_authors = False 81 | 82 | # The name of the Pygments (syntax highlighting) style to use. 83 | pygments_style = 'sphinx' 84 | 85 | # A list of ignored prefixes for module index sorting. 86 | #modindex_common_prefix = [] 87 | 88 | # If true, keep warnings as "system message" paragraphs in the built documents. 89 | #keep_warnings = False 90 | 91 | 92 | # -- Options for HTML output --------------------------------------------------- 93 | 94 | # The theme to use for HTML and HTML Help pages. See the documentation for 95 | # a list of builtin themes. 96 | html_theme = 'sphinx_rtd_theme' 97 | 98 | # Theme options are theme-specific and customize the look and feel of a theme 99 | # further. For a list of options available for each theme, see the 100 | # documentation. 101 | #html_theme_options = {} 102 | 103 | # Add any paths that contain custom themes here, relative to this directory. 104 | #html_theme_path = [] 105 | 106 | # The name for this set of Sphinx documents. If None, it defaults to 107 | # " v documentation". 108 | #html_title = None 109 | 110 | # A shorter title for the navigation bar. Default is the same as html_title. 111 | #html_short_title = None 112 | 113 | # The name of an image file (relative to this directory) to place at the top 114 | # of the sidebar. 115 | #html_logo = None 116 | 117 | # The name of an image file (within the static path) to use as favicon of the 118 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 119 | # pixels large. 120 | #html_favicon = None 121 | 122 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 123 | # using the given strftime format. 124 | #html_last_updated_fmt = '%b %d, %Y' 125 | 126 | # If true, SmartyPants will be used to convert quotes and dashes to 127 | # typographically correct entities. 128 | #html_use_smartypants = True 129 | 130 | # Custom sidebar templates, maps document names to template names. 131 | #html_sidebars = {} 132 | 133 | # Additional templates that should be rendered to pages, maps page names to 134 | # template names. 135 | #html_additional_pages = {} 136 | 137 | # If false, no module index is generated. 138 | #html_domain_indices = True 139 | 140 | # If false, no index is generated. 141 | #html_use_index = True 142 | 143 | # If true, the index is split into individual pages for each letter. 144 | #html_split_index = False 145 | 146 | # If true, links to the reST sources are added to the pages. 147 | #html_show_sourcelink = True 148 | 149 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 150 | #html_show_sphinx = True 151 | 152 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 153 | #html_show_copyright = True 154 | 155 | # If true, an OpenSearch description file will be output, and all pages will 156 | # contain a tag referring to it. The value of this option must be the 157 | # base URL from which the finished HTML is served. 158 | #html_use_opensearch = '' 159 | 160 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 161 | #html_file_suffix = None 162 | 163 | # Output file base name for HTML help builder. 164 | htmlhelp_basename = 'python-oauth2doc' 165 | 166 | 167 | # -- Options for LaTeX output -------------------------------------------------- 168 | 169 | latex_elements = { 170 | # The paper size ('letterpaper' or 'a4paper'). 171 | #'papersize': 'letterpaper', 172 | 173 | # The font size ('10pt', '11pt' or '12pt'). 174 | #'pointsize': '10pt', 175 | 176 | # Additional stuff for the LaTeX preamble. 177 | #'preamble': '', 178 | } 179 | 180 | # Grouping the document tree into LaTeX files. List of tuples 181 | # (source start file, target name, title, author, documentclass [howto/manual]). 182 | latex_documents = [ 183 | ('index', 'python-oauth2.tex', u'python-oauth2 Documentation', 184 | u'Markus Meyer', 'manual'), 185 | ] 186 | 187 | # The name of an image file (relative to this directory) to place at the top of 188 | # the title page. 189 | #latex_logo = None 190 | 191 | # For "manual" documents, if this is true, then toplevel headings are parts, 192 | # not chapters. 193 | #latex_use_parts = False 194 | 195 | # If true, show page references after internal links. 196 | #latex_show_pagerefs = False 197 | 198 | # If true, show URL addresses after external links. 199 | #latex_show_urls = False 200 | 201 | # Documents to append as an appendix to all manuals. 202 | #latex_appendices = [] 203 | 204 | # If false, no module index is generated. 205 | #latex_domain_indices = True 206 | 207 | 208 | # -- Options for manual page output -------------------------------------------- 209 | 210 | # One entry per manual page. List of tuples 211 | # (source start file, name, description, authors, manual section). 212 | man_pages = [ 213 | ('index', 'python-oauth2', u'python-oauth2 Documentation', 214 | [u'Markus Meyer'], 1) 215 | ] 216 | 217 | # If true, show URL addresses after external links. 218 | #man_show_urls = False 219 | 220 | 221 | # -- Options for Texinfo output ------------------------------------------------ 222 | 223 | # Grouping the document tree into Texinfo files. List of tuples 224 | # (source start file, target name, title, author, 225 | # dir menu entry, description, category) 226 | texinfo_documents = [ 227 | ('index', 'python-oauth2', u'python-oauth2 Documentation', 228 | u'Markus Meyer', 'python-oauth2', 'One line description of project.', 229 | 'Miscellaneous'), 230 | ] 231 | 232 | # Documents to append as an appendix to all manuals. 233 | #texinfo_appendices = [] 234 | 235 | # If false, no module index is generated. 236 | #texinfo_domain_indices = True 237 | 238 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 239 | #texinfo_show_urls = 'footnote' 240 | 241 | # If true, do not generate a @detailmenu in the "Top" node's menu. 242 | #texinfo_no_detailmenu = False 243 | -------------------------------------------------------------------------------- /docs/error.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.error`` --- Error classes 2 | ================================== 3 | 4 | .. automodule:: oauth2.error 5 | 6 | .. autoclass:: AccessTokenNotFound 7 | 8 | .. autoclass:: AuthCodeNotFound 9 | 10 | .. autoclass:: ClientNotFoundError 11 | 12 | .. autoclass:: OAuthBaseError 13 | 14 | .. autoclass:: OAuthInvalidError 15 | 16 | .. autoclass:: UserNotAuthenticated 17 | -------------------------------------------------------------------------------- /docs/examples/authorization_code_grant.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import sys 4 | import urllib 5 | import urlparse 6 | import json 7 | import signal 8 | 9 | from multiprocessing.process import Process 10 | from wsgiref.simple_server import make_server, WSGIRequestHandler 11 | 12 | sys.path.insert(0, os.path.abspath(os.path.realpath(__file__) + '/../../../')) 13 | 14 | from oauth2 import Provider 15 | from oauth2.error import UserNotAuthenticated 16 | from oauth2.store.memory import ClientStore, TokenStore 17 | from oauth2.tokengenerator import Uuid4 18 | from oauth2.web import AuthorizationCodeGrantSiteAdapter 19 | from oauth2.web.wsgi import Application 20 | from oauth2.grant import AuthorizationCodeGrant 21 | 22 | 23 | logging.basicConfig(level=logging.DEBUG) 24 | 25 | 26 | class ClientRequestHandler(WSGIRequestHandler): 27 | """ 28 | Request handler that enables formatting of the log messages on the console. 29 | 30 | This handler is used by the client application. 31 | """ 32 | def address_string(self): 33 | return "client app" 34 | 35 | 36 | class OAuthRequestHandler(WSGIRequestHandler): 37 | """ 38 | Request handler that enables formatting of the log messages on the console. 39 | 40 | This handler is used by the python-oauth2 application. 41 | """ 42 | def address_string(self): 43 | return "python-oauth2" 44 | 45 | 46 | class TestSiteAdapter(AuthorizationCodeGrantSiteAdapter): 47 | """ 48 | This adapter renders a confirmation page so the user can confirm the auth 49 | request. 50 | """ 51 | 52 | CONFIRMATION_TEMPLATE = """ 53 | 54 | 55 |

56 | confirm 57 |

58 |

59 | deny 60 |

61 | 62 | 63 | """ 64 | 65 | def render_auth_page(self, request, response, environ, scopes, client): 66 | url = request.path + "?" + request.query_string 67 | response.body = self.CONFIRMATION_TEMPLATE.format(url=url) 68 | 69 | return response 70 | 71 | def authenticate(self, request, environ, scopes, client): 72 | if request.method == "GET": 73 | if request.get_param("confirm") == "1": 74 | return 75 | raise UserNotAuthenticated 76 | 77 | def user_has_denied_access(self, request): 78 | if request.method == "GET": 79 | if request.get_param("confirm") == "0": 80 | return True 81 | return False 82 | 83 | 84 | class ClientApplication(object): 85 | """ 86 | Very basic application that simulates calls to the API of the 87 | python-oauth2 app. 88 | """ 89 | callback_url = "http://localhost:8081/callback" 90 | client_id = "abc" 91 | client_secret = "xyz" 92 | api_server_url = "http://localhost:8080" 93 | 94 | def __init__(self): 95 | self.access_token = None 96 | self.auth_token = None 97 | self.token_type = "" 98 | 99 | def __call__(self, env, start_response): 100 | if env["PATH_INFO"] == "/app": 101 | status, body, headers = self._serve_application(env) 102 | elif env["PATH_INFO"] == "/callback": 103 | status, body, headers = self._read_auth_token(env) 104 | else: 105 | status = "301 Moved" 106 | body = "" 107 | headers = {"Location": "/app"} 108 | 109 | start_response(status, 110 | [(header, val) for header,val in headers.iteritems()]) 111 | return body 112 | 113 | def _request_access_token(self): 114 | print("Requesting access token...") 115 | 116 | post_params = {"client_id": self.client_id, 117 | "client_secret": self.client_secret, 118 | "code": self.auth_token, 119 | "grant_type": "authorization_code", 120 | "redirect_uri": self.callback_url} 121 | token_endpoint = self.api_server_url + "/token" 122 | 123 | result = urllib.urlopen(token_endpoint, 124 | urllib.urlencode(post_params)) 125 | content = "" 126 | for line in result: 127 | content += line 128 | 129 | result = json.loads(content) 130 | self.access_token = result["access_token"] 131 | self.token_type = result["token_type"] 132 | 133 | confirmation = "Received access token '%s' of type '%s'" % (self.access_token, self.token_type) 134 | print(confirmation) 135 | return "302 Found", "", {"Location": "/app"} 136 | 137 | def _read_auth_token(self, env): 138 | print("Receiving authorization token...") 139 | 140 | query_params = urlparse.parse_qs(env["QUERY_STRING"]) 141 | 142 | if "error" in query_params: 143 | location = "/app?error=" + query_params["error"][0] 144 | return "302 Found", "", {"Location": location} 145 | 146 | self.auth_token = query_params["code"][0] 147 | 148 | print("Received temporary authorization token '%s'" % (self.auth_token,)) 149 | 150 | return "302 Found", "", {"Location": "/app"} 151 | 152 | def _request_auth_token(self): 153 | print("Requesting authorization token...") 154 | 155 | auth_endpoint = self.api_server_url + "/authorize" 156 | query = urllib.urlencode({"client_id": "abc", 157 | "redirect_uri": self.callback_url, 158 | "response_type": "code"}) 159 | 160 | location = "%s?%s" % (auth_endpoint, query) 161 | 162 | return "302 Found", "", {"Location": location} 163 | 164 | def _serve_application(self, env): 165 | query_params = urlparse.parse_qs(env["QUERY_STRING"]) 166 | 167 | if ("error" in query_params 168 | and query_params["error"][0] == "access_denied"): 169 | return "200 OK", "User has denied access", {} 170 | 171 | if self.access_token is None: 172 | if self.auth_token is None: 173 | return self._request_auth_token() 174 | else: 175 | return self._request_access_token() 176 | else: 177 | confirmation = "Current access token '%s' of type '%s'" % (self.access_token, self.token_type) 178 | return "200 OK", str(confirmation), {} 179 | 180 | 181 | def run_app_server(): 182 | app = ClientApplication() 183 | 184 | try: 185 | httpd = make_server('', 8081, app, handler_class=ClientRequestHandler) 186 | 187 | print("Starting Client app on http://localhost:8081/...") 188 | httpd.serve_forever() 189 | except KeyboardInterrupt: 190 | httpd.server_close() 191 | 192 | 193 | def run_auth_server(): 194 | try: 195 | client_store = ClientStore() 196 | client_store.add_client(client_id="abc", client_secret="xyz", 197 | redirect_uris=["http://localhost:8081/callback"]) 198 | 199 | token_store = TokenStore() 200 | 201 | provider = Provider( 202 | access_token_store=token_store, 203 | auth_code_store=token_store, 204 | client_store=client_store, 205 | token_generator=Uuid4()) 206 | provider.add_grant( 207 | AuthorizationCodeGrant(site_adapter=TestSiteAdapter()) 208 | ) 209 | 210 | app = Application(provider=provider) 211 | 212 | httpd = make_server('', 8080, app, handler_class=OAuthRequestHandler) 213 | 214 | print("Starting OAuth2 server on http://localhost:8080/...") 215 | httpd.serve_forever() 216 | except KeyboardInterrupt: 217 | httpd.server_close() 218 | 219 | 220 | def main(): 221 | auth_server = Process(target=run_auth_server) 222 | auth_server.start() 223 | app_server = Process(target=run_app_server) 224 | app_server.start() 225 | print("Access http://localhost:8081/app in your browser") 226 | 227 | def sigint_handler(signal, frame): 228 | print("Terminating servers...") 229 | auth_server.terminate() 230 | auth_server.join() 231 | app_server.terminate() 232 | app_server.join() 233 | 234 | signal.signal(signal.SIGINT, sigint_handler) 235 | 236 | if __name__ == "__main__": 237 | main() 238 | -------------------------------------------------------------------------------- /docs/examples/base_server.py: -------------------------------------------------------------------------------- 1 | from wsgiref.simple_server import make_server 2 | import oauth2 3 | import oauth2.grant 4 | import oauth2.error 5 | import oauth2.store.memory 6 | import oauth2.tokengenerator 7 | import oauth2.web.wsgi 8 | 9 | 10 | # Create a SiteAdapter to interact with the user. 11 | # This can be used to display confirmation dialogs and the like. 12 | class ExampleSiteAdapter(oauth2.web.AuthorizationCodeGrantSiteAdapter, 13 | oauth2.web.ImplicitGrantSiteAdapter): 14 | def authenticate(self, request, environ, scopes, client): 15 | # Check if the user has granted access 16 | if request.post_param("confirm") == "confirm": 17 | return {} 18 | 19 | raise oauth2.error.UserNotAuthenticated 20 | 21 | def render_auth_page(self, request, response, environ, scopes, client): 22 | response.body = ''' 23 | 24 | 25 |
26 | 27 | 28 |
29 | 30 | ''' 31 | return response 32 | 33 | def user_has_denied_access(self, request): 34 | # Check if the user has denied access 35 | if request.post_param("deny") == "deny": 36 | return True 37 | return False 38 | 39 | # Create an in-memory storage to store your client apps. 40 | client_store = oauth2.store.memory.ClientStore() 41 | # Add a client 42 | client_store.add_client(client_id="abc", client_secret="xyz", 43 | redirect_uris=["http://localhost/callback"]) 44 | 45 | site_adapter = ExampleSiteAdapter() 46 | 47 | # Create an in-memory storage to store issued tokens. 48 | # LocalTokenStore can store access and auth tokens 49 | token_store = oauth2.store.memory.TokenStore() 50 | 51 | # Create the controller. 52 | provider = oauth2.Provider( 53 | access_token_store=token_store, 54 | auth_code_store=token_store, 55 | client_store=client_store, 56 | token_generator=oauth2.tokengenerator.Uuid4() 57 | ) 58 | 59 | # Add Grants you want to support 60 | provider.add_grant(oauth2.grant.AuthorizationCodeGrant(site_adapter=site_adapter)) 61 | provider.add_grant(oauth2.grant.ImplicitGrant(site_adapter=site_adapter)) 62 | 63 | # Add refresh token capability and set expiration time of access tokens 64 | # to 30 days 65 | provider.add_grant(oauth2.grant.RefreshToken(expires_in=2592000)) 66 | 67 | # Wrap the controller with the Wsgi adapter 68 | app = oauth2.web.wsgi.Application(provider=provider) 69 | 70 | if __name__ == "__main__": 71 | httpd = make_server('', 8080, app) 72 | httpd.serve_forever() 73 | -------------------------------------------------------------------------------- /docs/examples/client_credentials_grant.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import signal 4 | 5 | from multiprocessing.process import Process 6 | from wsgiref.simple_server import make_server, WSGIRequestHandler 7 | 8 | sys.path.insert(0, os.path.abspath(os.path.realpath(__file__) + '/../../../')) 9 | 10 | from oauth2 import Provider 11 | from oauth2.store.memory import ClientStore, TokenStore 12 | from oauth2.tokengenerator import Uuid4 13 | from oauth2.web.wsgi import Application 14 | from oauth2.grant import ClientCredentialsGrant 15 | 16 | 17 | class OAuthRequestHandler(WSGIRequestHandler): 18 | """ 19 | Request handler that enables formatting of the log messages on the console. 20 | 21 | This handler is used by the python-oauth2 application. 22 | """ 23 | def address_string(self): 24 | return "python-oauth2" 25 | 26 | 27 | def run_auth_server(): 28 | try: 29 | client_store = ClientStore() 30 | client_store.add_client(client_id="abc", client_secret="xyz", 31 | redirect_uris=[]) 32 | 33 | token_store = TokenStore() 34 | token_gen = Uuid4() 35 | token_gen.expires_in['client_credentials'] = 3600 36 | 37 | auth_controller = Provider( 38 | access_token_store=token_store, 39 | auth_code_store=token_store, 40 | client_store=client_store, 41 | token_generator=token_gen) 42 | auth_controller.add_grant(ClientCredentialsGrant()) 43 | 44 | app = Application(provider=auth_controller) 45 | 46 | httpd = make_server('', 8080, app, handler_class=OAuthRequestHandler) 47 | 48 | print("Starting implicit_grant oauth2 server on http://localhost:8080/...") 49 | httpd.serve_forever() 50 | except KeyboardInterrupt: 51 | httpd.server_close() 52 | 53 | def main(): 54 | auth_server = Process(target=run_auth_server) 55 | auth_server.start() 56 | print("To test getting an auth token, execute the following curl command:") 57 | print( 58 | "curl --ipv4 -v -X POST" 59 | " -d 'grant_type=client_credentials&client_id=abc&client_secret=xyz' " 60 | "http://localhost:8080/token" 61 | ) 62 | 63 | def sigint_handler(signal, frame): 64 | print("Terminating server...") 65 | auth_server.terminate() 66 | auth_server.join() 67 | 68 | signal.signal(signal.SIGINT, sigint_handler) 69 | 70 | if __name__ == "__main__": 71 | main() 72 | -------------------------------------------------------------------------------- /docs/examples/implicit_grant.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import signal 4 | import sys 5 | 6 | from multiprocessing import Process 7 | from wsgiref.simple_server import make_server 8 | 9 | sys.path.insert(0, os.path.abspath(os.path.realpath(__file__) + '/../../../')) 10 | 11 | from oauth2 import Provider 12 | from oauth2.error import UserNotAuthenticated 13 | from oauth2.web import ImplicitGrantSiteAdapter 14 | from oauth2.web.wsgi import Application 15 | from oauth2.tokengenerator import Uuid4 16 | from oauth2.grant import ImplicitGrant 17 | from oauth2.store.memory import ClientStore, TokenStore 18 | 19 | 20 | logging.basicConfig(level=logging.DEBUG) 21 | 22 | 23 | class TestSiteAdapter(ImplicitGrantSiteAdapter): 24 | CONFIRMATION_TEMPLATE = """ 25 | 26 | 27 |

28 | confirm 29 |

30 |

31 | deny 32 |

33 | 34 | 35 | """ 36 | 37 | def render_auth_page(self, request, response, environ, scopes, client): 38 | url = request.path + "?" + request.query_string 39 | # Add check if the user is logged or a redirect to the login page here 40 | response.body = self.CONFIRMATION_TEMPLATE.format(url=url) 41 | 42 | return response 43 | 44 | def authenticate(self, request, environ, scopes, client): 45 | if request.method == "GET": 46 | if request.get_param("confirm") == "1": 47 | return 48 | raise UserNotAuthenticated 49 | 50 | def user_has_denied_access(self, request): 51 | if request.method == "GET": 52 | if request.get_param("confirm") == "0": 53 | return True 54 | return False 55 | 56 | 57 | def run_app_server(): 58 | def application(env, start_response): 59 | """ 60 | Serves the local javascript client 61 | """ 62 | 63 | js_app = """ 64 | 65 | 66 | OAuth2 JS Test App 67 | 68 | 69 | 97 | 98 | 99 | """ 100 | 101 | start_response("200 OK", [("Content-Type", "text/html")]) 102 | 103 | return [js_app] 104 | 105 | try: 106 | httpd = make_server('', 8081, application) 107 | 108 | print("Starting implicit_grant app server on http://localhost:8081/...") 109 | httpd.serve_forever() 110 | except KeyboardInterrupt: 111 | httpd.server_close() 112 | 113 | 114 | def run_auth_server(): 115 | try: 116 | client_store = ClientStore() 117 | client_store.add_client(client_id="abc", client_secret="xyz", 118 | redirect_uris=["http://localhost:8081/"]) 119 | 120 | token_store = TokenStore() 121 | 122 | provider = Provider( 123 | access_token_store=token_store, 124 | auth_code_store=token_store, 125 | client_store=client_store, 126 | token_generator=Uuid4()) 127 | provider.add_grant(ImplicitGrant(site_adapter=TestSiteAdapter())) 128 | 129 | app = Application(provider=provider) 130 | 131 | httpd = make_server('', 8080, app) 132 | 133 | print("Starting implicit_grant oauth2 server on http://localhost:8080/...") 134 | httpd.serve_forever() 135 | except KeyboardInterrupt: 136 | httpd.server_close() 137 | 138 | 139 | def main(): 140 | auth_server = Process(target=run_auth_server) 141 | auth_server.start() 142 | app_server = Process(target=run_app_server) 143 | app_server.start() 144 | print("Access http://localhost:8081/ to start the auth flow") 145 | 146 | def sigint_handler(signal, frame): 147 | print("Terminating servers...") 148 | auth_server.terminate() 149 | auth_server.join() 150 | app_server.terminate() 151 | app_server.join() 152 | 153 | signal.signal(signal.SIGINT, sigint_handler) 154 | 155 | if __name__ == "__main__": 156 | main() 157 | -------------------------------------------------------------------------------- /docs/examples/pyramid/README.md: -------------------------------------------------------------------------------- 1 | Pyramid integration example for python-oauth2 2 | 3 | Integrate the example: 4 | 5 | 1. Put classes in base.py in appropriate packages. 6 | 2. impl.py contains controller and site adapter. Also place both of them in appropriate packages. 7 | 3. Implement "password_auth" method in OAuth2SiteAdapter. 8 | 4. Modify "_get_token_store" and "_get_client_store" methods in UserAuthController 9 | 5. Add "config.add_route('authenticateUser', '/user/token')" to "\__init__\.py" 10 | 11 | Add new grant-type: 12 | 13 | 1. Implement auth method like "password_auth" in OAuth2SiteAdapter. 14 | 2. Call "add_grant" on your AuthController 15 | 16 | 17 | Will add working pyramid project soon. 18 | 19 | -------------------------------------------------------------------------------- /docs/examples/pyramid/base.py: -------------------------------------------------------------------------------- 1 | 2 | import json 3 | from pyramid.response import Response as PyramidResponse 4 | from oauth2.web import Response 5 | from oauth2.error import OAuthInvalidError, \ 6 | ClientNotFoundError, OAuthInvalidNoRedirectError, UnsupportedGrantError, ParameterMissingError 7 | from oauth2.client_authenticator import ClientAuthenticator, request_body 8 | from oauth2.tokengenerator import Uuid4 9 | 10 | 11 | class Request(): 12 | """ 13 | Contains data of the current HTTP request. 14 | """ 15 | def __init__(self, env): 16 | self.method = env.method 17 | self.params = env.json_body 18 | self.registry = env.registry 19 | self.headers = env.registry 20 | 21 | def post_param(self, name): 22 | return self.params.get(name) 23 | 24 | 25 | class BaseAuthController(object): 26 | 27 | def __init__(self, request, site_adapter): 28 | self.request = Request(request) 29 | self.site_adapter = site_adapter 30 | self.token_generator = Uuid4() 31 | 32 | self.client_store = self._get_client_store() 33 | self.access_token_store = self._get_token_store() 34 | 35 | self.client_authenticator = ClientAuthenticator( 36 | client_store=self.client_store, 37 | source=request_body 38 | ) 39 | 40 | self.grant_types = []; 41 | 42 | 43 | @classmethod 44 | def _get_token_store(cls): 45 | NotImplementedError 46 | 47 | @classmethod 48 | def _get_client_store(cls): 49 | NotImplementedError 50 | 51 | def add_grant(self, grant): 52 | """ 53 | Adds a Grant that the provider should support. 54 | 55 | :param grant: An instance of a class that extends 56 | :class:`oauth2.grant.GrantHandlerFactory` 57 | """ 58 | if hasattr(grant, "expires_in"): 59 | self.token_generator.expires_in[grant.grant_type] = grant.expires_in 60 | 61 | if hasattr(grant, "refresh_expires_in"): 62 | self.token_generator.refresh_expires_in = grant.refresh_expires_in 63 | 64 | self.grant_types.append(grant) 65 | 66 | 67 | def _determine_grant_type(self, request): 68 | for grant in self.grant_types: 69 | grant_handler = grant(request, self) 70 | if grant_handler is not None: 71 | return grant_handler 72 | raise UnsupportedGrantError 73 | 74 | 75 | def authenticate(self): 76 | response = Response() 77 | grant_type = self._determine_grant_type(self.request) 78 | grant_type.read_validate_params(self.request) 79 | grant_type.process(self.request, response, {}) 80 | return PyramidResponse(body=response.body, status=response.status_code, content_type="application/json") 81 | -------------------------------------------------------------------------------- /docs/examples/pyramid/impl.py: -------------------------------------------------------------------------------- 1 | 2 | from pyramid.view import view_config 3 | 4 | from oauth2.error import UserNotAuthenticated, UserNotExist 5 | from oauth2.web import SiteAdapter 6 | from oauth2.store.redisdb import TokenStore, ClientStore 7 | from oauth2.grant import ResourceOwnerGrant 8 | 9 | from base import BaseAuthController 10 | 11 | import os 12 | import sys 13 | import pyramid 14 | 15 | class OAuth2SiteAdapter(SiteAdapter): 16 | 17 | def authenticate(self, request, environ, scopes): 18 | if request.method == "POST": 19 | if request.post_param("grant_type") == 'password': 20 | return self.password_auth(request) 21 | raise UserNotAuthenticated 22 | 23 | def user_has_denied_access(self, request): 24 | if request.method == "POST": 25 | if request.post_param("confirm") is "0": 26 | return True 27 | return False 28 | 29 | # implement this for resource owner grant 30 | def password_auth(self, request): 31 | session = DBSession() 32 | try: 33 | #validate user credentials 34 | user_id = 123 35 | if True: 36 | return None, user_id 37 | raise UserNotAuthenticated 38 | except: 39 | raise 40 | 41 | 42 | class UserAuthController(BaseAuthController): 43 | 44 | def __init__(self, request): 45 | super(UserAuthController, self).__init__(request, OAuth2SiteAdapter()) 46 | self.add_grant(ResourceOwnerGrant(unique_token=True)) 47 | 48 | @classmethod 49 | def _get_token_store(cls): 50 | settings = get_current_registry().settings 51 | return TokenStore( 52 | host = 127.0.0.1, 53 | port = 6379, 54 | db = 1, 55 | ) 56 | 57 | @classmethod 58 | def _get_client_store(cls): 59 | settings = get_current_registry().settings 60 | return ClientStore( 61 | host = 127.0.0.1, 62 | port = 6379, 63 | db = 2, 64 | ) 65 | 66 | # add this route in __init__.py 67 | @view_config(route_name="authenticateUser", renderer="json", request_method="POST") 68 | def authenticate(self): 69 | return super(UserAuthController, self).authenticate() 70 | 71 | -------------------------------------------------------------------------------- /docs/examples/resource_owner_grant.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import signal 5 | import sys 6 | import urllib2 7 | 8 | from multiprocessing.process import Process 9 | from urllib2 import HTTPError 10 | from wsgiref.simple_server import make_server 11 | 12 | sys.path.insert(0, os.path.abspath(os.path.realpath(__file__) + '/../../../')) 13 | 14 | from oauth2.compatibility import parse_qs, urlencode 15 | from oauth2 import Provider 16 | from oauth2.error import UserNotAuthenticated 17 | from oauth2.store.memory import ClientStore, TokenStore 18 | from oauth2.tokengenerator import Uuid4 19 | from oauth2.web import ResourceOwnerGrantSiteAdapter 20 | from oauth2.web.wsgi import Application 21 | from oauth2.grant import ResourceOwnerGrant 22 | 23 | 24 | logging.basicConfig(level=logging.DEBUG) 25 | 26 | 27 | class ClientApplication(object): 28 | """ 29 | Very basic application that simulates calls to the API of the 30 | python-oauth2 app. 31 | """ 32 | client_id = "abc" 33 | client_secret = "xyz" 34 | token_endpoint = "http://localhost:8080/token" 35 | 36 | LOGIN_TEMPLATE = """ 37 | 38 |

Test Login

39 |
40 | {failed_message} 41 |
42 |
43 |
44 | Username (foo): 45 |
46 |
47 | Password (bar): 48 |
49 |
50 | 51 |
52 |
53 | 54 | """ 55 | 56 | SERVER_ERROR_TEMPLATE = """ 57 | 58 |

OAuth2 server responded with an error

59 | Error type: {error_type} 60 | Error description: {error_description} 61 | 62 | """ 63 | 64 | TOKEN_TEMPLATE = """ 65 | 66 |
Access token: {access_token}
67 |
68 | Reset 69 |
70 | 71 | """ 72 | 73 | def __init__(self): 74 | self.token = None 75 | self.token_type = "" 76 | 77 | def __call__(self, env, start_response): 78 | if env["PATH_INFO"] == "/login": 79 | status, body, headers = self._login(failed=env["QUERY_STRING"] == "failed=1") 80 | elif env["PATH_INFO"] == "/": 81 | status, body, headers = self._display_token() 82 | elif env["PATH_INFO"] == "/request_token": 83 | status, body, headers = self._request_token(env) 84 | elif env["PATH_INFO"] == "/reset": 85 | status, body, headers = self._reset() 86 | else: 87 | status = "301 Moved" 88 | body = "" 89 | headers = {"Location": "/"} 90 | 91 | start_response(status, 92 | [(header, val) for header,val in headers.iteritems()]) 93 | return body 94 | 95 | def _display_token(self): 96 | """ 97 | Display token information or redirect to login prompt if none is 98 | available. 99 | """ 100 | if self.token is None: 101 | return "301 Moved", "", {"Location": "/login"} 102 | 103 | return ("200 OK", 104 | self.TOKEN_TEMPLATE.format( 105 | access_token=self.token["access_token"]), 106 | {"Content-Type": "text/html"}) 107 | 108 | def _login(self, failed=False): 109 | """ 110 | Login prompt 111 | """ 112 | if failed: 113 | content = self.LOGIN_TEMPLATE.format(failed_message="Login failed") 114 | else: 115 | content = self.LOGIN_TEMPLATE.format(failed_message="") 116 | return "200 OK", content, {"Content-Type": "text/html"} 117 | 118 | def _request_token(self, env): 119 | """ 120 | Retrieves a new access token from the OAuth2 server. 121 | """ 122 | params = {} 123 | 124 | content = env['wsgi.input'].read(int(env['CONTENT_LENGTH'])) 125 | post_params = parse_qs(content) 126 | # Convert to dict for easier access 127 | for param, value in post_params.items(): 128 | decoded_param = param.decode('utf-8') 129 | decoded_value = value[0].decode('utf-8') 130 | if decoded_param == "username" or decoded_param == "password": 131 | params[decoded_param] = decoded_value 132 | 133 | params["grant_type"] = "password" 134 | params["client_id"] = self.client_id 135 | params["client_secret"] = self.client_secret 136 | # Request an access token by POSTing a request to the auth server. 137 | try: 138 | response = urllib2.urlopen(self.token_endpoint, urlencode(params)) 139 | except HTTPError, he: 140 | if he.code == 400: 141 | error_body = json.loads(he.read()) 142 | body = self.SERVER_ERROR_TEMPLATE\ 143 | .format(error_type=error_body["error"], 144 | error_description=error_body["error_description"]) 145 | return "400 Bad Request", body, {"Content-Type": "text/html"} 146 | if he.code == 401: 147 | return "302 Found", "", {"Location": "/login?failed=1"} 148 | 149 | self.token = json.load(response) 150 | 151 | return "301 Moved", "", {"Location": "/"} 152 | 153 | def _reset(self): 154 | self.token = None 155 | 156 | return "302 Found", "", {"Location": "/login"} 157 | 158 | 159 | class TestSiteAdapter(ResourceOwnerGrantSiteAdapter): 160 | def authenticate(self, request, environ, scopes, client): 161 | username = request.post_param("username") 162 | password = request.post_param("password") 163 | # A real world application could connect to a database, try to 164 | # retrieve username and password and compare them against the input 165 | if username == "foo" and password == "bar": 166 | return 167 | 168 | raise UserNotAuthenticated 169 | 170 | 171 | def run_app_server(): 172 | app = ClientApplication() 173 | 174 | try: 175 | httpd = make_server('', 8081, app) 176 | 177 | print("Starting Client app on http://localhost:8081/...") 178 | httpd.serve_forever() 179 | except KeyboardInterrupt: 180 | httpd.server_close() 181 | 182 | 183 | def run_auth_server(): 184 | try: 185 | client_store = ClientStore() 186 | client_store.add_client(client_id="abc", client_secret="xyz", 187 | redirect_uris=[]) 188 | 189 | token_store = TokenStore() 190 | 191 | provider = Provider( 192 | access_token_store=token_store, 193 | auth_code_store=token_store, 194 | client_store=client_store, 195 | token_generator=Uuid4()) 196 | 197 | provider.add_grant( 198 | ResourceOwnerGrant(site_adapter=TestSiteAdapter()) 199 | ) 200 | 201 | app = Application(provider=provider) 202 | 203 | httpd = make_server('', 8080, app) 204 | 205 | print("Starting OAuth2 server on http://localhost:8080/...") 206 | httpd.serve_forever() 207 | except KeyboardInterrupt: 208 | httpd.server_close() 209 | 210 | 211 | def main(): 212 | auth_server = Process(target=run_auth_server) 213 | auth_server.start() 214 | app_server = Process(target=run_app_server) 215 | app_server.start() 216 | print("Visit http://localhost:8081/ in your browser") 217 | 218 | def sigint_handler(signal, frame): 219 | print("Terminating servers...") 220 | auth_server.terminate() 221 | auth_server.join() 222 | app_server.terminate() 223 | app_server.join() 224 | 225 | signal.signal(signal.SIGINT, sigint_handler) 226 | 227 | if __name__ == "__main__": 228 | main() 229 | -------------------------------------------------------------------------------- /docs/examples/tornado_server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | import os 4 | import signal 5 | import sys 6 | import urllib 7 | import urlparse 8 | 9 | from multiprocessing.process import Process 10 | 11 | sys.path.insert(0, os.path.abspath(os.path.realpath(__file__) + '/../../../')) 12 | 13 | from oauth2 import Provider 14 | from oauth2.error import UserNotAuthenticated 15 | from oauth2.grant import AuthorizationCodeGrant 16 | from oauth2.tokengenerator import Uuid4 17 | from oauth2.store.memory import ClientStore, TokenStore 18 | from oauth2.web import AuthorizationCodeGrantSiteAdapter 19 | from oauth2.web.tornado import OAuth2Handler 20 | from tornado.ioloop import IOLoop 21 | from tornado.web import Application, url 22 | from wsgiref.simple_server import make_server, WSGIRequestHandler 23 | 24 | 25 | logging.basicConfig(level=logging.DEBUG) 26 | 27 | 28 | class ClientRequestHandler(WSGIRequestHandler): 29 | """ 30 | Request handler that enables formatting of the log messages on the console. 31 | 32 | This handler is used by the client application. 33 | """ 34 | def address_string(self): 35 | return "client app" 36 | 37 | 38 | class OAuthRequestHandler(WSGIRequestHandler): 39 | """ 40 | Request handler that enables formatting of the log messages on the console. 41 | 42 | This handler is used by the python-oauth2 application. 43 | """ 44 | def address_string(self): 45 | return "python-oauth2" 46 | 47 | 48 | class TestSiteAdapter(AuthorizationCodeGrantSiteAdapter): 49 | """ 50 | This adapter renders a confirmation page so the user can confirm the auth 51 | request. 52 | """ 53 | 54 | CONFIRMATION_TEMPLATE = """ 55 | 56 | 57 |

58 | confirm 59 |

60 |

61 | deny 62 |

63 | 64 | 65 | """ 66 | 67 | def render_auth_page(self, request, response, environ, scopes, client): 68 | url = request.path + "?" + request.query_string 69 | response.body = self.CONFIRMATION_TEMPLATE.format(url=url) 70 | 71 | return response 72 | 73 | def authenticate(self, request, environ, scopes, client): 74 | if request.method == "GET": 75 | if request.get_param("confirm") == "1": 76 | return 77 | raise UserNotAuthenticated 78 | 79 | def user_has_denied_access(self, request): 80 | if request.method == "GET": 81 | if request.get_param("confirm") == "0": 82 | return True 83 | return False 84 | 85 | 86 | class ClientApplication(object): 87 | """ 88 | Very basic application that simulates calls to the API of the 89 | python-oauth2 app. 90 | """ 91 | callback_url = "http://localhost:8081/callback" 92 | client_id = "abc" 93 | client_secret = "xyz" 94 | api_server_url = "http://localhost:8080" 95 | 96 | def __init__(self): 97 | self.access_token = None 98 | self.auth_token = None 99 | self.token_type = "" 100 | 101 | def __call__(self, env, start_response): 102 | if env["PATH_INFO"] == "/app": 103 | status, body, headers = self._serve_application(env) 104 | elif env["PATH_INFO"] == "/callback": 105 | status, body, headers = self._read_auth_token(env) 106 | else: 107 | status = "301 Moved" 108 | body = "" 109 | headers = {"Location": "/app"} 110 | 111 | start_response(status, 112 | [(header, val) for header,val in headers.iteritems()]) 113 | return body 114 | 115 | def _request_access_token(self): 116 | print("Requesting access token...") 117 | 118 | post_params = {"client_id": self.client_id, 119 | "client_secret": self.client_secret, 120 | "code": self.auth_token, 121 | "grant_type": "authorization_code", 122 | "redirect_uri": self.callback_url} 123 | token_endpoint = self.api_server_url + "/token" 124 | 125 | result = urllib.urlopen(token_endpoint, 126 | urllib.urlencode(post_params)) 127 | content = "" 128 | for line in result: 129 | content += line 130 | 131 | result = json.loads(content) 132 | self.access_token = result["access_token"] 133 | self.token_type = result["token_type"] 134 | 135 | confirmation = "Received access token '%s' of type '%s'" % (self.access_token, self.token_type) 136 | print(confirmation) 137 | return "302 Found", "", {"Location": "/app"} 138 | 139 | def _read_auth_token(self, env): 140 | print("Receiving authorization token...") 141 | 142 | query_params = urlparse.parse_qs(env["QUERY_STRING"]) 143 | 144 | if "error" in query_params: 145 | location = "/app?error=" + query_params["error"][0] 146 | return "302 Found", "", {"Location": location} 147 | 148 | self.auth_token = query_params["code"][0] 149 | 150 | print("Received temporary authorization token '%s'" % (self.auth_token,)) 151 | 152 | return "302 Found", "", {"Location": "/app"} 153 | 154 | def _request_auth_token(self): 155 | print("Requesting authorization token...") 156 | 157 | auth_endpoint = self.api_server_url + "/authorize" 158 | query = urllib.urlencode({"client_id": "abc", 159 | "redirect_uri": self.callback_url, 160 | "response_type": "code"}) 161 | 162 | location = "%s?%s" % (auth_endpoint, query) 163 | 164 | return "302 Found", "", {"Location": location} 165 | 166 | def _serve_application(self, env): 167 | query_params = urlparse.parse_qs(env["QUERY_STRING"]) 168 | 169 | if ("error" in query_params 170 | and query_params["error"][0] == "access_denied"): 171 | return "200 OK", "User has denied access", {} 172 | 173 | if self.access_token is None: 174 | if self.auth_token is None: 175 | return self._request_auth_token() 176 | else: 177 | return self._request_access_token() 178 | else: 179 | confirmation = "Current access token '%s' of type '%s'" % (self.access_token, self.token_type) 180 | return "200 OK", str(confirmation), {} 181 | 182 | 183 | def run_app_server(): 184 | app = ClientApplication() 185 | 186 | try: 187 | httpd = make_server('', 8081, app, handler_class=ClientRequestHandler) 188 | 189 | print("Starting Client app on http://localhost:8081/...") 190 | httpd.serve_forever() 191 | except KeyboardInterrupt: 192 | httpd.server_close() 193 | 194 | 195 | def run_auth_server(): 196 | client_store = ClientStore() 197 | client_store.add_client(client_id="abc", client_secret="xyz", 198 | redirect_uris=["http://localhost:8081/callback"]) 199 | 200 | token_store = TokenStore() 201 | 202 | provider = Provider(access_token_store=token_store, 203 | auth_code_store=token_store, client_store=client_store, 204 | token_generator=Uuid4()) 205 | provider.add_grant(AuthorizationCodeGrant(site_adapter=TestSiteAdapter())) 206 | 207 | try: 208 | app = Application([ 209 | url(provider.authorize_path, OAuth2Handler, dict(provider=provider)), 210 | url(provider.token_path, OAuth2Handler, dict(provider=provider)), 211 | ]) 212 | 213 | app.listen(8080) 214 | print("Starting OAuth2 server on http://localhost:8080/...") 215 | IOLoop.current().start() 216 | 217 | except KeyboardInterrupt: 218 | IOLoop.close() 219 | 220 | 221 | def main(): 222 | auth_server = Process(target=run_auth_server) 223 | auth_server.start() 224 | app_server = Process(target=run_app_server) 225 | app_server.start() 226 | print("Access http://localhost:8081/app in your browser") 227 | 228 | def sigint_handler(signal, frame): 229 | print("Terminating servers...") 230 | auth_server.terminate() 231 | auth_server.join() 232 | app_server.terminate() 233 | app_server.join() 234 | 235 | signal.signal(signal.SIGINT, sigint_handler) 236 | 237 | if __name__ == "__main__": 238 | main() 239 | -------------------------------------------------------------------------------- /docs/frameworks.rst: -------------------------------------------------------------------------------- 1 | Using ``python-oauth2`` with other frameworks 2 | ============================================= 3 | 4 | .. toctree:: 5 | :maxdepth: 1 6 | 7 | tornado.rst 8 | -------------------------------------------------------------------------------- /docs/grant.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.grant`` --- Grant classes and helpers 2 | ============================================== 3 | 4 | .. automodule:: oauth2.grant 5 | 6 | Helpers and base classes 7 | ------------------------ 8 | 9 | .. autoclass:: GrantHandlerFactory 10 | 11 | .. autoclass:: ScopeGrant 12 | 13 | .. autoclass:: Scope 14 | :members: parse 15 | 16 | .. autoclass:: SiteAdapterMixin 17 | 18 | Grant classes 19 | ------------- 20 | 21 | .. autoclass:: AuthorizationCodeGrant 22 | :show-inheritance: 23 | 24 | .. autoclass:: ImplicitGrant 25 | :show-inheritance: 26 | 27 | .. autoclass:: ResourceOwnerGrant 28 | :show-inheritance: 29 | 30 | .. autoclass:: RefreshToken 31 | :show-inheritance: 32 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. title:: python-oauth2 2 | 3 | .. automodule:: oauth2 4 | 5 | Contents: 6 | 7 | .. toctree:: 8 | :maxdepth: 2 9 | 10 | grant.rst 11 | store.rst 12 | oauth2.rst 13 | web.rst 14 | client_authenticator.rst 15 | token_generator.rst 16 | log.rst 17 | error.rst 18 | unique_token.rst 19 | migration.rst 20 | frameworks.rst 21 | 22 | Indices and tables 23 | ================== 24 | 25 | * :ref:`genindex` 26 | * :ref:`modindex` 27 | * :ref:`search` 28 | -------------------------------------------------------------------------------- /docs/log.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.log`` --- Logging 2 | ========================== 3 | 4 | .. automodule:: oauth2.log 5 | -------------------------------------------------------------------------------- /docs/migration.rst: -------------------------------------------------------------------------------- 1 | Migration 2 | ========= 3 | 4 | 0.7.0 -> 1.0.0 5 | -------------- 6 | 7 | One site adapter per grant 8 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 9 | 10 | Starting from ``1.0.0``, the grants 11 | :class:`oauth2.grant.AuthorizationCodeGrant`, 12 | :class:`oauth2.grant.ImplicitGrant` and 13 | :class:`oauth2.grant.ResourceOwnerGrant` expect the parameter ``site_adapter`` 14 | to be passed to them. 15 | 16 | :class:`oauth2.Provider` does not accept the parameter ``site_adapter`` 17 | anymore. 18 | 19 | The base class ``oauth2.web.SiteAdapter`` does not exist anymore. 20 | 21 | Code that looks like this in version ``0.7.0`` 22 | 23 | .. code-block:: python 24 | 25 | from oauth2 import Provider 26 | from oauth2.web import SiteAdapter 27 | from oauth2.grant import AuthorizationCodeGrant 28 | 29 | class ExampleSiteAdapter(SiteAdapter): 30 | ... 31 | 32 | provider = Provider( 33 | ..., 34 | site_adapter=ExampleSiteAdapter(), 35 | ... 36 | ) 37 | provider.add_grant(AuthorizationCodeGrant()) 38 | 39 | has to be rewritten to look similar to the following 40 | 41 | .. code-block:: python 42 | 43 | from oauth2 import Provider 44 | from oauth2.web import AuthorizationCodeGrantSiteAdapter 45 | from oauth2.grant import AuthorizationCodeGrant 46 | 47 | class ExampleSiteAdapter(AuthorizationCodeGrantSiteAdapter): 48 | # Override the methods defined in AuthorizationCodeGrantSiteAdapter to suite your needs 49 | ... 50 | 51 | # No site_adapter anymore 52 | provider = Provider(...) 53 | 54 | provider.add_grant(AuthorizationCodeGrant(site_adapter=ExampleSiteAdapter())) 55 | 56 | 57 | WSGI adapter classes refactoring 58 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 59 | 60 | All code to connect ``python-oauth2`` with a WSGI compliant server has been 61 | moved to the module ``oauth2.web.wsgi``. 62 | 63 | Also the class ``Wsgi`` has been renamed to ``Application`` and now expects 64 | the parameter ``provider`` instead of ``server``. 65 | 66 | Before: 67 | 68 | .. code-block:: python 69 | 70 | from oauth2.web import Wsgi 71 | 72 | # Instantiating storage and provider... 73 | 74 | app = Wsgi(server=provider) 75 | 76 | 77 | After: 78 | 79 | .. code-block:: python 80 | 81 | from oauth2.web.wsgi import Application 82 | 83 | # Instantiating storage and provider... 84 | 85 | app = Application(provider=provider) 86 | 87 | 88 | Client passed to methods authenticate and render_auth_page of a Site Adapter 89 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 90 | 91 | Before: 92 | 93 | .. code-block:: python 94 | 95 | class ExampleSiteAdapter(AuthenticatingSiteAdapter, UserFacingSiteAdapter): 96 | def authenticate(self, request, environ, scopes): 97 | # code 98 | 99 | def render_auth_page(self, request, response, environ, scopes): 100 | # code 101 | 102 | 103 | After: 104 | 105 | .. code-block:: python 106 | 107 | class ExampleSiteAdapter(AuthenticatingSiteAdapter, UserFacingSiteAdapter): 108 | def authenticate(self, request, environ, scopes, client): 109 | # code 110 | 111 | def render_auth_page(self, request, response, environ, scopes, client): 112 | # code 113 | 114 | 115 | -------------------------------------------------------------------------------- /docs/oauth2.rst: -------------------------------------------------------------------------------- 1 | ``oauth2`` --- Provider Class 2 | ============================= 3 | 4 | .. autoclass:: oauth2.Provider 5 | :members: scope_separator, add_grant, dispatch, enable_unique_tokens 6 | -------------------------------------------------------------------------------- /docs/store.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store`` --- Storing and retrieving data 2 | ================================================ 3 | 4 | .. automodule:: oauth2.store 5 | 6 | Data Types 7 | ---------- 8 | 9 | .. autoclass:: oauth2.datatype.AccessToken 10 | 11 | .. autoclass:: oauth2.datatype.AuthorizationCode 12 | 13 | .. autoclass:: oauth2.datatype.Client 14 | 15 | Base Classes 16 | ------------ 17 | 18 | .. autoclass:: AccessTokenStore 19 | :members: 20 | 21 | .. autoclass:: AuthCodeStore 22 | :members: 23 | 24 | .. autoclass:: ClientStore 25 | :members: 26 | 27 | Implementations 28 | --------------- 29 | 30 | .. toctree:: 31 | :maxdepth: 2 32 | 33 | store/memcache.rst 34 | store/memory.rst 35 | store/mongodb.rst 36 | store/redisdb.rst 37 | store/dbapi.rst 38 | store/mysql.rst 39 | -------------------------------------------------------------------------------- /docs/store/dbapi.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.dbapi`` --- PEP249 compatible stores 2 | =================================================== 3 | 4 | .. automodule:: oauth2.store.dbapi 5 | 6 | .. autoclass:: oauth2.store.dbapi.DatabaseStore 7 | 8 | .. autoclass:: oauth2.store.dbapi.DbApiAccessTokenStore 9 | :members: 10 | 11 | .. autoclass:: oauth2.store.dbapi.DbApiAuthCodeStore 12 | :members: 13 | 14 | .. autoclass:: oauth2.store.dbapi.DbApiClientStore 15 | :members: 16 | -------------------------------------------------------------------------------- /docs/store/memcache.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.memcache`` --- Memcache store adapters 2 | ===================================================== 3 | 4 | .. automodule:: oauth2.store.memcache 5 | 6 | .. autoclass:: TokenStore 7 | -------------------------------------------------------------------------------- /docs/store/memory.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.memory`` --- In-memory store adapters 2 | ===================================================== 3 | 4 | .. automodule:: oauth2.store.memory 5 | 6 | .. autoclass:: ClientStore 7 | :members: 8 | 9 | .. autoclass:: TokenStore 10 | :members: 11 | -------------------------------------------------------------------------------- /docs/store/mongodb.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.mongodb`` --- Mongodb store adapters 2 | =================================================== 3 | 4 | .. automodule:: oauth2.store.mongodb 5 | 6 | .. autoclass:: oauth2.store.mongodb.MongodbStore 7 | 8 | .. autoclass:: oauth2.store.mongodb.AccessTokenStore 9 | 10 | .. autoclass:: oauth2.store.mongodb.AuthCodeStore 11 | 12 | .. autoclass:: oauth2.store.mongodb.ClientStore 13 | -------------------------------------------------------------------------------- /docs/store/mysql.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.dbapi.mysql`` --- Mysql store adapters 2 | ===================================================== 3 | 4 | .. automodule:: oauth2.store.dbapi.mysql 5 | 6 | .. autoclass:: MysqlAccessTokenStore 7 | 8 | .. autoclass:: MysqlAuthCodeStore 9 | 10 | .. autoclass:: MysqlClientStore 11 | -------------------------------------------------------------------------------- /docs/store/redisdb.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.store.redisdb`` --- Redis store adapters 2 | ================================================= 3 | 4 | .. automodule:: oauth2.store.redisdb 5 | 6 | .. autoclass:: oauth2.store.redisdb.TokenStore 7 | 8 | .. autoclass:: oauth2.store.redisdb.ClientStore 9 | -------------------------------------------------------------------------------- /docs/token_generator.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.tokengenerator`` --- Generate Tokens 2 | ============================================= 3 | 4 | .. automodule:: oauth2.tokengenerator 5 | 6 | Base Class 7 | ---------- 8 | 9 | .. autoclass:: TokenGenerator 10 | :members: 11 | 12 | Implementations 13 | --------------- 14 | 15 | .. autoclass:: URandomTokenGenerator 16 | :members: 17 | :show-inheritance: 18 | 19 | .. autoclass:: Uuid4 20 | :members: 21 | :show-inheritance: 22 | -------------------------------------------------------------------------------- /docs/tornado.rst: -------------------------------------------------------------------------------- 1 | Tornado 2 | ======= 3 | 4 | .. automodule:: oauth2.web.tornado 5 | :members: 6 | -------------------------------------------------------------------------------- /docs/unique_token.rst: -------------------------------------------------------------------------------- 1 | Unique Access Tokens 2 | ==================== 3 | 4 | This page explains the concepts of unique access tokens and how to enable this 5 | feature. 6 | 7 | What are unique access tokens? 8 | ------------------------------ 9 | 10 | When the use of unique access tokens is enabled the Provider will respond with 11 | an existing access token to subsequent requests of a client instead of issuing 12 | a new token on each request. 13 | 14 | An existing access token will be returned if the following conditions are 15 | met: 16 | 17 | * The access token has been issued for the requesting client 18 | * The access token has been issued for the same user as in the current request 19 | * The requested scope is the same as in the existing access token 20 | * The requested type is the same as in the existing access token 21 | 22 | .. note:: 23 | 24 | Unique access tokens are currently supported by 25 | :class:`oauth2.grant.AuthorizationCodeGrant` and 26 | :class:`oauth2.grant.ResourceOwnerGrant`. 27 | 28 | Preconditions 29 | ------------- 30 | 31 | As stated in the previous section, a unique access token is bound not only to a 32 | client but also to a user. To make this work the Provider needs some kind of 33 | identifier that is unique for each user (typically the ID of a user in the 34 | database). The identifier is stored along with all the other information of an 35 | access token. It has to be returned as the second item of a tuple by your 36 | implementation of :class:`oauth2.web.AuthenticatingSiteAdapter.authenticate`:: 37 | 38 | class MySiteAdapter(SiteAdapter): 39 | 40 | def authenticate(self, request, environ, scopes): 41 | // Your logic here 42 | 43 | return None, user["id"] 44 | 45 | Enabling the feature 46 | -------------------- 47 | 48 | Unique access tokens are turned off by default. They can be turned on for each 49 | grant individually:: 50 | 51 | auth_code_grant = oauth2.grant.AuthorizationCodeGrant(unique_token=True) 52 | provider = oauth2.Provider() // Parameters omitted for readability 53 | provider.add_grant(auth_code_grant) 54 | 55 | or you can enable them for all grants that support this feature after 56 | initialization of :class:`oauth2.Provider`:: 57 | 58 | provider = oauth2.Provider() // Parameters omitted for readability 59 | provider.enable_unique_tokens() 60 | 61 | .. note:: 62 | 63 | If you enable the feature but forgot to make 64 | :class:`oauth2.web.AuthenticatingSiteAdapter.authenticate` return a user 65 | identifier, the Provider will respond with an error to requests for a 66 | token. 67 | -------------------------------------------------------------------------------- /docs/web.rst: -------------------------------------------------------------------------------- 1 | ``oauth2.web`` --- Interaction over HTTP 2 | ======================================== 3 | 4 | .. automodule:: oauth2.web 5 | 6 | Site adapters 7 | ------------- 8 | 9 | .. autoclass:: UserFacingSiteAdapter 10 | :members: 11 | 12 | .. autoclass:: AuthenticatingSiteAdapter 13 | :members: 14 | 15 | .. autoclass:: AuthorizationCodeGrantSiteAdapter 16 | :members: 17 | :inherited-members: 18 | :show-inheritance: 19 | 20 | .. autoclass:: ImplicitGrantSiteAdapter 21 | :members: 22 | :inherited-members: 23 | :show-inheritance: 24 | 25 | .. autoclass:: ResourceOwnerGrantSiteAdapter 26 | :members: 27 | :inherited-members: 28 | :show-inheritance: 29 | 30 | HTTP flow 31 | --------- 32 | 33 | .. autoclass:: Request 34 | :members: 35 | 36 | .. autoclass:: Response 37 | :members: 38 | -------------------------------------------------------------------------------- /oauth2/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | ============= 3 | python-oauth2 4 | ============= 5 | 6 | python-oauth2 is a framework that aims at making it easy to provide 7 | authentication via `OAuth 2.0 `_ within 8 | an application stack. 9 | 10 | Usage 11 | ===== 12 | 13 | Example: 14 | 15 | .. literalinclude:: examples/base_server.py 16 | 17 | Installation 18 | ============ 19 | 20 | python-oauth2 is available on 21 | `PyPI `_:: 22 | 23 | pip install python-oauth2 24 | 25 | """ 26 | 27 | import json 28 | from oauth2.client_authenticator import ClientAuthenticator, request_body 29 | from oauth2.error import OAuthInvalidError, \ 30 | ClientNotFoundError, OAuthInvalidNoRedirectError, UnsupportedGrantError 31 | from oauth2.log import app_log 32 | from oauth2.web import Response 33 | from oauth2.tokengenerator import Uuid4 34 | from oauth2.grant import Scope, AuthorizationCodeGrant, ImplicitGrant, \ 35 | ClientCredentialsGrant, ResourceOwnerGrant, RefreshToken 36 | 37 | VERSION = "1.1.1" 38 | 39 | 40 | class Provider(object): 41 | """ 42 | Endpoint of requests to the OAuth 2.0 provider. 43 | 44 | :param access_token_store: An object that implements methods defined 45 | by :class:`oauth2.store.AccessTokenStore`. 46 | :type access_token_store: oauth2.store.AccessTokenStore 47 | :param auth_code_store: An object that implements methods defined by 48 | :class:`oauth2.store.AuthCodeStore`. 49 | :type auth_code_store: oauth2.store.AuthCodeStore 50 | :param client_store: An object that implements methods defined by 51 | :class:`oauth2.store.ClientStore`. 52 | :type client_store: oauth2.store.ClientStore 53 | :param token_generator: Object to generate unique tokens. 54 | :type token_generator: oauth2.tokengenerator.TokenGenerator 55 | :param client_authentication_source: A callable which when executed, 56 | authenticates a client. 57 | See :mod:`oauth2.client_authenticator`. 58 | :type client_authentication_source: callable 59 | :param response_class: Class of the response object. 60 | Defaults to :class:`oauth2.web.Response`. 61 | :type response_class: oauth2.web.Response 62 | 63 | .. versionchanged:: 1.0.0 64 | Removed parameter ``site_adapter``. 65 | """ 66 | authorize_path = "/authorize" 67 | token_path = "/token" 68 | 69 | def __init__(self, access_token_store, auth_code_store, client_store, 70 | token_generator, client_authentication_source=request_body, 71 | response_class=Response): 72 | self.grant_types = [] 73 | self._input_handler = None 74 | 75 | self.access_token_store = access_token_store 76 | self.auth_code_store = auth_code_store 77 | self.client_authenticator = ClientAuthenticator( 78 | client_store=client_store, 79 | source=client_authentication_source) 80 | self.response_class = response_class 81 | self.token_generator = token_generator 82 | 83 | def add_grant(self, grant): 84 | """ 85 | Adds a Grant that the provider should support. 86 | 87 | :param grant: An instance of a class that extends 88 | :class:`oauth2.grant.GrantHandlerFactory` 89 | :type grant: oauth2.grant.GrantHandlerFactory 90 | """ 91 | if hasattr(grant, "expires_in"): 92 | self.token_generator.expires_in[grant.grant_type] = grant.expires_in 93 | 94 | if hasattr(grant, "refresh_expires_in"): 95 | self.token_generator.refresh_expires_in = grant.refresh_expires_in 96 | 97 | self.grant_types.append(grant) 98 | 99 | def dispatch(self, request, environ): 100 | """ 101 | Checks which Grant supports the current request and dispatches to it. 102 | 103 | :param request: The incoming request. 104 | :type request: :class:`oauth2.web.Request` 105 | :param environ: Dict containing variables of the environment. 106 | :type environ: dict 107 | 108 | :return: An instance of ``oauth2.web.Response``. 109 | """ 110 | try: 111 | grant_type = self._determine_grant_type(request) 112 | 113 | response = self.response_class() 114 | 115 | grant_type.read_validate_params(request) 116 | 117 | return grant_type.process(request, response, environ) 118 | except OAuthInvalidNoRedirectError: 119 | response = self.response_class() 120 | response.add_header("Content-Type", "application/json") 121 | response.status_code = 400 122 | response.body = json.dumps({ 123 | "error": "invalid_redirect_uri", 124 | "error_description": "Invalid redirect URI" 125 | }) 126 | 127 | return response 128 | except OAuthInvalidError as err: 129 | response = self.response_class() 130 | return grant_type.handle_error(error=err, response=response) 131 | except UnsupportedGrantError: 132 | response = self.response_class() 133 | response.add_header("Content-Type", "application/json") 134 | response.status_code = 400 135 | response.body = json.dumps({ 136 | "error": "unsupported_response_type", 137 | "error_description": "Grant not supported" 138 | }) 139 | 140 | return response 141 | except: 142 | app_log.error("Uncaught Exception", exc_info=True) 143 | response = self.response_class() 144 | return grant_type.handle_error( 145 | error=OAuthInvalidError(error="server_error", 146 | explanation="Internal server error"), 147 | response=response) 148 | 149 | def enable_unique_tokens(self): 150 | """ 151 | Enable the use of unique access tokens on all grant types that support 152 | this option. 153 | """ 154 | for grant_type in self.grant_types: 155 | if hasattr(grant_type, "unique_token"): 156 | grant_type.unique_token = True 157 | 158 | @property 159 | def scope_separator(self, separator): 160 | """ 161 | Sets the separator of values in the scope query parameter. 162 | Defaults to " " (whitespace). 163 | 164 | The following code makes the Provider use "," instead of " ":: 165 | 166 | provider = Provider() 167 | 168 | provider.scope_separator = "," 169 | 170 | Now the scope parameter in the request of a client can look like this: 171 | `scope=foo,bar`. 172 | """ 173 | Scope.separator = separator 174 | 175 | def _determine_grant_type(self, request): 176 | for grant in self.grant_types: 177 | grant_handler = grant(request, self) 178 | if grant_handler is not None: 179 | return grant_handler 180 | 181 | raise UnsupportedGrantError 182 | -------------------------------------------------------------------------------- /oauth2/client_authenticator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Every client that sends a request to obtain an access token needs to 3 | authenticate with the provider. 4 | 5 | The authentication of confidential clients can be handled in several ways, 6 | some of which come bundled with this module. 7 | """ 8 | 9 | from base64 import b64decode 10 | from oauth2.error import OAuthInvalidNoRedirectError, RedirectUriUnknown, \ 11 | OAuthInvalidError, ClientNotFoundError 12 | 13 | 14 | class ClientAuthenticator(object): 15 | """ 16 | Handles authentication of a client both by its identifier as well as by 17 | its identifier and secret. 18 | 19 | :param client_store: The Client Store to retrieve a client from. 20 | :type client_store: oauth2.store.ClientStore 21 | :param source: A callable that returns a tuple 22 | (, ) 23 | :type source: callable 24 | """ 25 | def __init__(self, client_store, source): 26 | self.client_store = client_store 27 | self.source = source 28 | 29 | def by_identifier(self, request): 30 | """ 31 | Authenticates a client by its identifier. 32 | 33 | :param request: The incoming request 34 | :type request: oauth2.web.Request 35 | 36 | :return: The identified client 37 | :rtype: oauth2.datatype.Client 38 | 39 | :raises: :class OAuthInvalidNoRedirectError: 40 | """ 41 | client_id = request.get_param("client_id") 42 | 43 | if client_id is None: 44 | raise OAuthInvalidNoRedirectError(error="missing_client_id") 45 | 46 | try: 47 | client = self.client_store.fetch_by_client_id(client_id) 48 | except ClientNotFoundError: 49 | raise OAuthInvalidNoRedirectError(error="unknown_client") 50 | 51 | redirect_uri = request.get_param("redirect_uri") 52 | if redirect_uri is not None: 53 | try: 54 | client.redirect_uri = redirect_uri 55 | except RedirectUriUnknown: 56 | raise OAuthInvalidNoRedirectError( 57 | error="invalid_redirect_uri") 58 | 59 | return client 60 | 61 | def by_identifier_secret(self, request): 62 | """ 63 | Authenticates a client by its identifier and secret (aka password). 64 | 65 | :param request: The incoming request 66 | :type request: oauth2.web.Request 67 | 68 | :return: The identified client 69 | :rtype: oauth2.datatype.Client 70 | 71 | :raises OAuthInvalidError: If the client could not be found, is not 72 | allowed to to use the current grant or 73 | supplied invalid credentials 74 | """ 75 | client_id, client_secret = self.source(request=request) 76 | 77 | try: 78 | client = self.client_store.fetch_by_client_id(client_id) 79 | except ClientNotFoundError: 80 | raise OAuthInvalidError(error="invalid_client", 81 | explanation="No client could be found") 82 | 83 | grant_type = request.post_param("grant_type") 84 | if client.grant_type_supported(grant_type) is False: 85 | raise OAuthInvalidError(error="unauthorized_client", 86 | explanation="The client is not allowed " 87 | "to use this grant type") 88 | 89 | if client.secret != client_secret: 90 | raise OAuthInvalidError(error="invalid_client", 91 | explanation="Invalid client credentials") 92 | 93 | return client 94 | 95 | 96 | def request_body(request): 97 | """ 98 | Extracts the credentials of a client from the 99 | *application/x-www-form-urlencoded* body of a request. 100 | 101 | Expects the client_id to be the value of the ``client_id`` parameter and 102 | the client_secret to be the value of the ``client_secret`` parameter. 103 | 104 | :param request: The incoming request 105 | :type request: oauth2.web.Request 106 | 107 | :return: A tuple in the format of `(, )` 108 | :rtype: tuple 109 | """ 110 | client_id = request.post_param("client_id") 111 | if client_id is None: 112 | raise OAuthInvalidError(error="invalid_request", 113 | explanation="Missing client identifier") 114 | 115 | client_secret = request.post_param("client_secret") 116 | if client_secret is None: 117 | raise OAuthInvalidError(error="invalid_request", 118 | explanation="Missing client credentials") 119 | 120 | return client_id, client_secret 121 | 122 | 123 | def http_basic_auth(request): 124 | """ 125 | Extracts the credentials of a client using HTTP Basic Auth. 126 | 127 | Expects the ``client_id`` to be the username and the ``client_secret`` to 128 | be the password part of the Authorization header. 129 | 130 | :param request: The incoming request 131 | :type request: oauth2.web.Request 132 | 133 | :return: A tuple in the format of (, )` 134 | :rtype: tuple 135 | """ 136 | auth_header = request.header("authorization") 137 | 138 | if auth_header is None: 139 | raise OAuthInvalidError(error="invalid_request", 140 | explanation="Authorization header is missing") 141 | 142 | auth_parts = auth_header.strip().encode("latin1").split(None) 143 | 144 | if auth_parts[0].strip().lower() != b'basic': 145 | raise OAuthInvalidError( 146 | error="invalid_request", 147 | explanation="Provider supports basic authentication only") 148 | 149 | client_id, client_secret = b64decode(auth_parts[1]).split(b':', 1) 150 | 151 | return client_id.decode("latin1"), client_secret.decode("latin1") 152 | -------------------------------------------------------------------------------- /oauth2/compatibility.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ensures compatibility between python 2.x and python 3.x 3 | """ 4 | 5 | import sys 6 | 7 | if sys.version_info >= (3, 0): 8 | from urllib.parse import parse_qs # pragma: no cover 9 | from urllib.parse import urlencode # pragma: no cover 10 | from urllib.parse import quote # pragma: no cover 11 | else: 12 | from urlparse import parse_qs # pragma: no cover 13 | from urllib import urlencode # pragma: no cover 14 | from urllib import quote # pragma: no cover 15 | -------------------------------------------------------------------------------- /oauth2/datatype.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Definitions of types used by grants. 4 | """ 5 | 6 | import time 7 | from oauth2.error import RedirectUriUnknown 8 | 9 | 10 | class AccessToken(object): 11 | """ 12 | An access token and associated data. 13 | """ 14 | def __init__(self, client_id, grant_type, token, data={}, expires_at=None, 15 | refresh_token=None, refresh_expires_at=None, scopes=[], 16 | user_id=None): 17 | self.client_id = client_id 18 | self.grant_type = grant_type 19 | self.token = token 20 | self.data = data 21 | self.expires_at = expires_at 22 | self.refresh_token = refresh_token 23 | self.refresh_expires_at = refresh_expires_at 24 | self.scopes = scopes 25 | self.user_id = user_id 26 | 27 | @property 28 | def expires_in(self): 29 | """ 30 | Returns the time until the token expires. 31 | 32 | :return: The remaining time until expiration in seconds or 0 if the 33 | token has expired. 34 | """ 35 | time_left = self.expires_at - int(time.time()) 36 | 37 | if time_left > 0: 38 | return time_left 39 | return 0 40 | 41 | def is_expired(self): 42 | """ 43 | Determines if the token has expired. 44 | 45 | :return: `True` if the token has expired. Otherwise `False`. 46 | """ 47 | if self.expires_at is None: 48 | return False 49 | 50 | if self.expires_in > 0: 51 | return False 52 | 53 | return True 54 | 55 | 56 | class AuthorizationCode(object): 57 | """ 58 | Holds an authorization code and additional information. 59 | """ 60 | def __init__(self, client_id, code, expires_at, redirect_uri, scopes, 61 | data=None, user_id=None): 62 | self.client_id = client_id 63 | self.code = code 64 | self.data = data 65 | self.expires_at = expires_at 66 | self.redirect_uri = redirect_uri 67 | self.scopes = scopes 68 | self.user_id = user_id 69 | 70 | def is_expired(self): 71 | if self.expires_at < int(time.time()): 72 | return True 73 | return False 74 | 75 | 76 | class Client(object): 77 | """ 78 | Representation of a client application. 79 | """ 80 | def __init__(self, identifier, secret, authorized_grants=None, 81 | authorized_response_types=None, redirect_uris=None): 82 | """ 83 | :param identifier: The unique identifier of a client. 84 | :param secret: The secret the clients uses to authenticate. 85 | :param authorized_grants: A list of grants under which the client can 86 | request tokens. 87 | All grants are allowed if this value is set 88 | to `None` (default). 89 | :param authorized_response_types: A list of response types of which 90 | the client can request tokens. 91 | All response types are allowed if 92 | this value is set to `None` 93 | (default). 94 | :redirect_uris: A list of redirect uris this client can use. 95 | """ 96 | self.authorized_grants = authorized_grants 97 | self.authorized_response_types = authorized_response_types 98 | self.identifier = identifier 99 | self.secret = secret 100 | 101 | if redirect_uris is None: 102 | self.redirect_uris = [] 103 | else: 104 | self.redirect_uris = redirect_uris 105 | 106 | self._redirect_uri = None 107 | 108 | @property 109 | def redirect_uri(self): 110 | if self._redirect_uri is None: 111 | # redirect_uri is an optional param. 112 | # If not supplied, we use the first entry stored in db as default. 113 | return self.redirect_uris[0] 114 | return self._redirect_uri 115 | 116 | @redirect_uri.setter 117 | def redirect_uri(self, value): 118 | if value not in self.redirect_uris: 119 | raise RedirectUriUnknown 120 | self._redirect_uri = value 121 | 122 | def grant_type_supported(self, grant_type): 123 | """ 124 | Checks if the Client is authorized receive tokens for the given grant. 125 | 126 | :param grant_type: The type of the grant. 127 | 128 | :return: Boolean 129 | """ 130 | if self.authorized_grants is None: 131 | return True 132 | 133 | return grant_type in self.authorized_grants 134 | 135 | def response_type_supported(self, response_type): 136 | """ 137 | Checks if the client is allowed to receive tokens for the given 138 | response type. 139 | 140 | :param response_type: The response type. 141 | 142 | :return: Boolean 143 | """ 144 | if self.authorized_response_types is None: 145 | return True 146 | 147 | return response_type in self.authorized_response_types 148 | -------------------------------------------------------------------------------- /oauth2/error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Errors raised during the OAuth 2.0 flow. 3 | """ 4 | 5 | 6 | class AccessTokenNotFound(Exception): 7 | """ 8 | Error indicating that an access token could not be read from the 9 | storage backend by an instance of :class:`oauth2.store.AccessTokenStore`. 10 | """ 11 | pass 12 | 13 | 14 | class AuthCodeNotFound(Exception): 15 | """ 16 | Error indicating that an authorization code could not be read from the 17 | storage backend by an instance of :class:`oauth2.store.AuthCodeStore`. 18 | """ 19 | pass 20 | 21 | 22 | class ClientNotFoundError(Exception): 23 | """ 24 | Error raised by an implementation of :class:`oauth2.store.ClientStore` if 25 | a client does not exists. 26 | """ 27 | pass 28 | 29 | 30 | class InvalidSiteAdapter(Exception): 31 | """ 32 | Raised by :class:`oauth2.grant.SiteAdapterMixin` in case an invalid site 33 | adapter was passed to the instance. 34 | """ 35 | pass 36 | 37 | 38 | class UserIdentifierMissingError(Exception): 39 | """ 40 | Indicates that the identifier of a user is missing when the use of unique 41 | access token is enabled. 42 | """ 43 | pass 44 | 45 | 46 | class OAuthBaseError(Exception): 47 | """ 48 | Base class used by all OAuth 2.0 errors. 49 | 50 | :param error: Identifier of the error. 51 | :param error_uri: Set this to delivery an URL to your documentation that 52 | describes the error. (optional) 53 | :param explanation: Short message that describes the error. (optional) 54 | """ 55 | def __init__(self, error, error_uri=None, explanation=None): 56 | self.error = error 57 | self.error_uri = error_uri 58 | self.explanation = explanation 59 | 60 | super(OAuthBaseError, self).__init__() 61 | 62 | 63 | class OAuthInvalidError(OAuthBaseError): 64 | """ 65 | Indicates an error during validation of a request. 66 | """ 67 | pass 68 | 69 | 70 | class OAuthInvalidNoRedirectError(OAuthInvalidError): 71 | """ 72 | Indicates an error during validation of a request. 73 | The provider will not inform the client about the error by redirecting to 74 | it. This behaviour is required by the Authorization Request step of the 75 | Authorization Code Grant and Implicit Grant. 76 | """ 77 | pass 78 | 79 | 80 | class UnsupportedGrantError(Exception): 81 | """ 82 | Indicates that a requested grant is not supported by the server. 83 | """ 84 | pass 85 | 86 | 87 | class RedirectUriUnknown(Exception): 88 | """ 89 | Indicates that a redirect_uri is not associated with a client. 90 | """ 91 | pass 92 | 93 | 94 | class UserNotAuthenticated(Exception): 95 | """ 96 | Raised by a :class:`oauth2.web.SiteAdapter` if a user could not be 97 | authenticated. 98 | """ 99 | pass 100 | -------------------------------------------------------------------------------- /oauth2/log.py: -------------------------------------------------------------------------------- 1 | """ 2 | Logging support 3 | 4 | There are two loggers available: 5 | 6 | * ``oauth2.application``: Logging of uncaught exceptions 7 | * ``oauth2.general``: General purpose logging of debug errors and warnings 8 | 9 | If logging has not been configured, you will likely see this error: 10 | 11 | .. code-block:: python 12 | 13 | No handlers could be found for logger "oauth2.application" 14 | 15 | Make sure that logging is configured to avoid this: 16 | 17 | .. code-block:: python 18 | 19 | import logging 20 | logging.basicConfig() 21 | 22 | """ 23 | import logging 24 | 25 | app_log = logging.getLogger("oauth2.application") 26 | gen_log = logging.getLogger("oauth2.general") 27 | -------------------------------------------------------------------------------- /oauth2/store/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Store adapters to persist and retrieve data during the OAuth 2.0 process or 3 | for later use. 4 | This module provides base classes that can be extended to implement your own 5 | solution specific to your needs. 6 | It also includes implementations for popular storage systems like memcache. 7 | """ 8 | 9 | 10 | class AccessTokenStore(object): 11 | """ 12 | Base class for persisting an access token after it has been generated. 13 | 14 | Used by two-legged and three-legged authentication flows. 15 | """ 16 | def save_token(self, access_token): 17 | """ 18 | Stores an access token and additional data. 19 | 20 | :param access_token: An instance of :class:`oauth2.datatype.AccessToken`. 21 | 22 | """ 23 | raise NotImplementedError 24 | 25 | def fetch_existing_token_of_user(self, client_id, grant_type, user_id): 26 | """ 27 | Fetches an access token identified by its client id, type of grant and 28 | user id. 29 | 30 | This method must be implemented to make use of unique access tokens. 31 | 32 | :param client_id: Identifier of the client a token belongs to. 33 | :param grant_type: The type of the grant that created the token 34 | :param user_id: Identifier of the user a token belongs to. 35 | :return: An instance of :class:`oauth2.datatype.AccessToken`. 36 | :raises: :class:`oauth2.error.AccessTokenNotFound` if no data could be 37 | retrieved. 38 | """ 39 | raise NotImplementedError 40 | 41 | def fetch_by_refresh_token(self, refresh_token): 42 | """ 43 | Fetches an access token from the store using its refresh token to 44 | identify it. 45 | 46 | :param refresh_token: A string containing the refresh token. 47 | :return: An instance of :class:`oauth2.datatype.AccessToken`. 48 | :raises: :class:`oauth2.error.AccessTokenNotFound` if no data could be retrieved for 49 | given refresh_token. 50 | """ 51 | raise NotImplementedError 52 | 53 | def delete_refresh_token(self, refresh_token): 54 | """ 55 | Deletes an access token from the store using its refresh token to identify it. 56 | This invalidates both the access token and the refresh token. 57 | 58 | :param refresh_token: A string containing the refresh token. 59 | :return: None. 60 | :raises: :class:`oauth2.error.AccessTokenNotFound` if no data could be retrieved for 61 | given refresh_token. 62 | """ 63 | raise NotImplementedError 64 | 65 | 66 | class AuthCodeStore(object): 67 | """ 68 | Base class for persisting and retrieving an auth token during the 69 | Authorization Code Grant flow. 70 | """ 71 | def fetch_by_code(self, code): 72 | """ 73 | Returns an AuthorizationCode fetched from a storage. 74 | 75 | :param code: The authorization code. 76 | :return: An instance of :class:`oauth2.datatype.AuthorizationCode`. 77 | :raises: :class:`oauth2.error.AuthCodeNotFound` if no data could be retrieved for 78 | given code. 79 | 80 | """ 81 | raise NotImplementedError 82 | 83 | def save_code(self, authorization_code): 84 | """ 85 | Stores the data belonging to an authorization code token. 86 | 87 | :param authorization_code: An instance of 88 | :class:`oauth2.datatype.AuthorizationCode`. 89 | """ 90 | raise NotImplementedError 91 | 92 | def delete_code(self, code): 93 | """ 94 | Deletes an authorization code after it's use per section 4.1.2. 95 | 96 | http://tools.ietf.org/html/rfc6749#section-4.1.2 97 | 98 | :param code: The authorization code. 99 | """ 100 | raise NotImplementedError 101 | 102 | 103 | class ClientStore(object): 104 | """ 105 | Base class for handling OAuth2 clients. 106 | """ 107 | def fetch_by_client_id(self, client_id): 108 | """ 109 | Retrieve a client by its identifier. 110 | 111 | :param client_id: Identifier of a client app. 112 | :return: An instance of :class:`oauth2.datatype.Client`. 113 | :raises: :class:`oauth2.error.ClientNotFoundError` if no data could be retrieved for 114 | given client_id. 115 | """ 116 | raise NotImplementedError 117 | -------------------------------------------------------------------------------- /oauth2/store/dbapi/mysql.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Adapters to use mysql as the storage backend. 4 | 5 | This module uses the API defined in :mod:`oauth2.store.dbapi`. 6 | Therefore no logic is defined here. Instead all classes define the queries 7 | required by :mod:`oauth2.store.dbapi`. 8 | 9 | The queries have been created for the following SQL tables in mind: 10 | 11 | .. code-block:: sql 12 | 13 | CREATE TABLE IF NOT EXISTS `testdb`.`access_tokens` ( 14 | `id` INT NOT NULL AUTO_INCREMENT COMMENT 'Unique identifier', 15 | `client_id` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client. Assuming it is an arbitrary text which is a maximum of 32 characters long.', 16 | `grant_type` ENUM('authorization_code', 'implicit', 'password', 'client_credentials', 'refresh_token') NOT NULL COMMENT 'The type of a grant for which a token has been issued.', 17 | `token` CHAR(36) NOT NULL COMMENT 'The access token.', 18 | `expires_at` TIMESTAMP NULL COMMENT 'The timestamp at which the token expires.', 19 | `refresh_token` CHAR(36) NULL COMMENT 'The refresh token.', 20 | `refresh_expires_at` TIMESTAMP NULL COMMENT 'The timestamp at which the refresh token expires.', 21 | `user_id` INT NULL COMMENT 'The identifier of the user this token belongs to.', 22 | PRIMARY KEY (`id`), 23 | INDEX `fetch_by_refresh_token` (`refresh_token` ASC), 24 | INDEX `fetch_existing_token_of_user` (`client_id` ASC, `grant_type` ASC, `user_id` ASC)) 25 | ENGINE = InnoDB; 26 | 27 | CREATE TABLE IF NOT EXISTS `testdb`.`access_token_scopes` ( 28 | `id` INT NOT NULL AUTO_INCREMENT, 29 | `name` VARCHAR(32) NOT NULL COMMENT 'The name of scope.', 30 | `access_token_id` INT NOT NULL COMMENT 'The unique identifier of the access token this scope belongs to.', 31 | PRIMARY KEY (`id`)) 32 | ENGINE = InnoDB; 33 | 34 | CREATE TABLE IF NOT EXISTS `testdb`.`access_token_data` ( 35 | `id` INT NOT NULL AUTO_INCREMENT, 36 | `key` VARCHAR(32) NOT NULL COMMENT 'The key of an entry converted to the key in a Python dict.', 37 | `value` VARCHAR(32) NOT NULL COMMENT 'The value of an entry converted to the value in a Python dict.', 38 | `access_token_id` INT NOT NULL COMMENT 'The unique identifier of the access token a row belongs to.', 39 | PRIMARY KEY (`id`)) 40 | ENGINE = InnoDB; 41 | 42 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_codes` ( 43 | `id` INT NOT NULL AUTO_INCREMENT, 44 | `client_id` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client. Assuming it is an arbitrary text which is a maximum of 32 characters long.', 45 | `code` CHAR(36) NOT NULL COMMENT 'The authorisation code.', 46 | `expires_at` TIMESTAMP NOT NULL COMMENT 'The timestamp at which the token expires.', 47 | `redirect_uri` VARCHAR(128) NULL COMMENT 'The redirect URI send by the client during the request of an authorisation code.', 48 | `user_id` INT NULL COMMENT 'The identifier of the user this authorisation code belongs to.', 49 | PRIMARY KEY (`id`), 50 | INDEX `fetch_code` (`code` ASC)) 51 | ENGINE = InnoDB; 52 | 53 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_code_data` ( 54 | `id` INT NOT NULL AUTO_INCREMENT, 55 | `key` VARCHAR(32) NOT NULL COMMENT 'The key of an entry converted to the key in a Python dict.', 56 | `value` VARCHAR(32) NOT NULL COMMENT 'The value of an entry converted to the value in a Python dict.', 57 | `auth_code_id` INT NOT NULL COMMENT 'The identifier of the authorisation code that this row belongs to.', 58 | PRIMARY KEY (`id`)) 59 | ENGINE = InnoDB; 60 | 61 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_code_scopes` ( 62 | `id` INT NOT NULL AUTO_INCREMENT, 63 | `name` VARCHAR(32) NOT NULL, 64 | `auth_code_id` INT NOT NULL, 65 | PRIMARY KEY (`id`)) 66 | ENGINE = InnoDB; 67 | 68 | CREATE TABLE IF NOT EXISTS `testdb`.`clients` ( 69 | `id` INT NOT NULL AUTO_INCREMENT, 70 | `identifier` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client.', 71 | `secret` VARCHAR(32) NOT NULL COMMENT 'The secret of a client.', 72 | PRIMARY KEY (`id`)) 73 | ENGINE = InnoDB; 74 | 75 | CREATE TABLE IF NOT EXISTS `testdb`.`client_grants` ( 76 | `id` INT NOT NULL AUTO_INCREMENT, 77 | `name` VARCHAR(32) NOT NULL, 78 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 79 | PRIMARY KEY (`id`)) 80 | ENGINE = InnoDB; 81 | 82 | CREATE TABLE IF NOT EXISTS `testdb`.`client_redirect_uris` ( 83 | `id` INT NOT NULL AUTO_INCREMENT, 84 | `redirect_uri` VARCHAR(128) NOT NULL COMMENT 'A URI of a client.', 85 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 86 | PRIMARY KEY (`id`)) 87 | ENGINE = InnoDB; 88 | 89 | CREATE TABLE IF NOT EXISTS `testdb`.`client_response_types` ( 90 | `id` INT NOT NULL AUTO_INCREMENT, 91 | `response_type` VARCHAR(32) NOT NULL COMMENT 'The response type that a client can use.', 92 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 93 | PRIMARY KEY (`id`)) 94 | ENGINE = InnoDB; 95 | """ 96 | 97 | from oauth2.store.dbapi import DbApiAccessTokenStore, DbApiAuthCodeStore, \ 98 | DbApiClientStore 99 | 100 | 101 | class MysqlAccessTokenStore(DbApiAccessTokenStore): 102 | delete_refresh_token_query = """ 103 | DELETE FROM 104 | `access_tokens` 105 | WHERE 106 | `refresh_token` = %s""" 107 | 108 | fetch_by_refresh_token_query = """ 109 | SELECT 110 | `id`, `client_id`, `grant_type`, `token`, 111 | UNIX_TIMESTAMP(`expires_at`), `refresh_token`, 112 | UNIX_TIMESTAMP(`refresh_expires_at`), `user_id` 113 | FROM 114 | `access_tokens` 115 | WHERE 116 | `refresh_token` = %s 117 | LIMIT 1""" 118 | 119 | fetch_scopes_by_access_token_query = """ 120 | SELECT 121 | `name` 122 | FROM 123 | `access_token_scopes` 124 | WHERE 125 | `access_token_id` = %s""" 126 | 127 | fetch_data_by_access_token_query = """ 128 | SELECT 129 | `key`, `value` 130 | FROM 131 | `access_token_data` 132 | WHERE 133 | `access_token_id` = %s""" 134 | 135 | fetch_existing_token_of_user_query = """ 136 | SELECT 137 | `id`, `client_id`, `grant_type`, `token`, 138 | UNIX_TIMESTAMP(`expires_at`), `refresh_token`, 139 | UNIX_TIMESTAMP(`refresh_expires_at`), `user_id` 140 | FROM 141 | `access_tokens` 142 | WHERE 143 | `client_id` = %s 144 | AND 145 | `grant_type` = %s 146 | AND 147 | `user_id` = %s 148 | ORDER BY 149 | `expires_at` DESC 150 | LIMIT 1""" 151 | 152 | create_access_token_query = """ 153 | INSERT INTO `access_tokens` ( 154 | `client_id`, `grant_type`, `token`, `expires_at`, `refresh_token`, 155 | `refresh_expires_at`, `user_id` 156 | ) VALUES ( 157 | %s, %s, %s, FROM_UNIXTIME(%s), %s, FROM_UNIXTIME(%s), %s 158 | )""" 159 | 160 | create_data_query = """ 161 | INSERT INTO `access_token_data` ( 162 | `key`,`value`, `access_token_id` 163 | ) VALUES ( 164 | %s, %s, %s 165 | )""" 166 | 167 | create_scope_query = """ 168 | INSERT INTO `access_token_scopes` ( 169 | `name`, `access_token_id` 170 | ) VALUES ( 171 | %s, %s 172 | )""" 173 | 174 | 175 | class MysqlAuthCodeStore(DbApiAuthCodeStore): 176 | create_auth_code_query = """ 177 | INSERT INTO `auth_codes` ( 178 | `client_id`,`code`,`expires_at`,`redirect_uri`, `user_id` 179 | ) VALUES ( 180 | %s, %s, FROM_UNIXTIME(%s), %s, %s 181 | )""" 182 | 183 | create_data_query = """ 184 | INSERT INTO `auth_code_data` ( 185 | `key`,`value`, `auth_code_id` 186 | ) VALUES ( 187 | %s, %s, %s 188 | )""" 189 | 190 | create_scope_query = """ 191 | INSERT INTO `auth_code_scopes` ( 192 | `name`, `auth_code_id` 193 | ) VALUES ( 194 | %s, %s 195 | )""" 196 | 197 | delete_code_query = """ 198 | DELETE FROM `auth_codes` WHERE code = %s""" 199 | 200 | fetch_code_query = """ 201 | SELECT 202 | `id`, `client_id`, `code`, UNIX_TIMESTAMP(`expires_at`), 203 | `redirect_uri`, `user_id` 204 | FROM 205 | `auth_codes` 206 | WHERE 207 | `code` = %s""" 208 | 209 | fetch_data_query = """ 210 | SELECT 211 | `key`, `value` 212 | FROM 213 | `auth_code_data` 214 | WHERE 215 | `auth_code_id` = %s""" 216 | 217 | fetch_scopes_query = """ 218 | SELECT 219 | `name` 220 | FROM 221 | `auth_code_scopes` 222 | WHERE 223 | `auth_code_id` = %s""" 224 | 225 | 226 | class MysqlClientStore(DbApiClientStore): 227 | fetch_client_query = """ 228 | SELECT 229 | `id`,`identifier`, `secret` 230 | FROM 231 | `clients` 232 | WHERE 233 | `identifier` = %s""" 234 | 235 | fetch_grants_query = """ 236 | SELECT 237 | `name` 238 | FROM 239 | `client_grants` 240 | WHERE 241 | `client_id` = %s""" 242 | fetch_redirect_uris_query = """ 243 | SELECT 244 | `redirect_uri` 245 | FROM 246 | `client_redirect_uris` 247 | WHERE 248 | `client_id` = %s""" 249 | 250 | fetch_response_types_query = """ 251 | SELECT 252 | `response_type` 253 | FROM 254 | `client_response_types` 255 | WHERE 256 | `client_id` = %s""" 257 | -------------------------------------------------------------------------------- /oauth2/store/memcache.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import memcache 3 | 4 | from oauth2.datatype import AccessToken, AuthorizationCode 5 | from oauth2.error import AccessTokenNotFound, AuthCodeNotFound 6 | from oauth2.store import AccessTokenStore, AuthCodeStore 7 | 8 | 9 | class TokenStore(AccessTokenStore, AuthCodeStore): 10 | """ 11 | Uses memcache to store access tokens and auth tokens. 12 | 13 | This Store supports ``python-memcached``. Arguments are passed to the 14 | underlying client implementation. 15 | 16 | Initialization by passing an object:: 17 | 18 | # This example uses python-memcached 19 | import memcache 20 | 21 | # Somewhere in your application 22 | mc = memcache.Client(servers=['127.0.0.1:11211'], debug=0) 23 | # ... 24 | token_store = TokenStore(mc=mc) 25 | 26 | Initialization using ``python-memcached``:: 27 | 28 | token_store = TokenStore(servers=['127.0.0.1:11211'], debug=0) 29 | 30 | """ 31 | def __init__(self, mc=None, prefix="oauth2", *args, **kwargs): 32 | self.prefix = prefix 33 | 34 | if mc is not None: 35 | self.mc = mc 36 | else: 37 | self.mc = memcache.Client(*args, **kwargs) 38 | 39 | def fetch_by_code(self, code): 40 | """ 41 | Returns data belonging to an authorization code from memcache or 42 | ``None`` if no data was found. 43 | 44 | See :class:`oauth2.store.AuthCodeStore`. 45 | 46 | """ 47 | code_data = self.mc.get(self._generate_cache_key(code)) 48 | 49 | if code_data is None: 50 | raise AuthCodeNotFound 51 | 52 | return AuthorizationCode(**code_data) 53 | 54 | def save_code(self, authorization_code): 55 | """ 56 | Stores the data belonging to an authorization code token in memcache. 57 | 58 | See :class:`oauth2.store.AuthCodeStore`. 59 | 60 | """ 61 | key = self._generate_cache_key(authorization_code.code) 62 | 63 | self.mc.set(key, {"client_id": authorization_code.client_id, 64 | "code": authorization_code.code, 65 | "expires_at": authorization_code.expires_at, 66 | "redirect_uri": authorization_code.redirect_uri, 67 | "scopes": authorization_code.scopes, 68 | "data": authorization_code.data, 69 | "user_id": authorization_code.user_id}) 70 | 71 | def delete_code(self, code): 72 | """ 73 | Deletes an authorization code after use 74 | :param code: The authorization code. 75 | """ 76 | self.mc.delete(self._generate_cache_key(code)) 77 | 78 | def save_token(self, access_token): 79 | """ 80 | Stores the access token and additional data in memcache. 81 | 82 | See :class:`oauth2.store.AccessTokenStore`. 83 | 84 | """ 85 | key = self._generate_cache_key(access_token.token) 86 | self.mc.set(key, access_token.__dict__) 87 | 88 | unique_token_key = self._unique_token_key(access_token.client_id, 89 | access_token.grant_type, 90 | access_token.user_id) 91 | self.mc.set(self._generate_cache_key(unique_token_key), 92 | access_token.__dict__) 93 | 94 | if access_token.refresh_token is not None: 95 | rft_key = self._generate_cache_key(access_token.refresh_token) 96 | self.mc.set(rft_key, access_token.__dict__) 97 | 98 | def delete_refresh_token(self, refresh_token): 99 | """ 100 | Deletes a refresh token after use 101 | :param refresh_token: The refresh token to delete. 102 | """ 103 | access_token = self.fetch_by_refresh_token(refresh_token) 104 | self.mc.delete(self._generate_cache_key(access_token.token)) 105 | self.mc.delete(self._generate_cache_key(refresh_token)) 106 | 107 | def fetch_by_refresh_token(self, refresh_token): 108 | token_data = self.mc.get(refresh_token) 109 | 110 | if token_data is None: 111 | raise AccessTokenNotFound 112 | 113 | return AccessToken(**token_data) 114 | 115 | def fetch_existing_token_of_user(self, client_id, grant_type, user_id): 116 | data = self.mc.get(self._unique_token_key(client_id, grant_type, 117 | user_id)) 118 | 119 | if data is None: 120 | raise AccessTokenNotFound 121 | 122 | return AccessToken(**data) 123 | 124 | def _unique_token_key(self, client_id, grant_type, user_id): 125 | return "{0}_{1}_{2}".format(client_id, grant_type, user_id) 126 | 127 | def _generate_cache_key(self, identifier): 128 | return self.prefix + "_" + identifier 129 | -------------------------------------------------------------------------------- /oauth2/store/memory.py: -------------------------------------------------------------------------------- 1 | """ 2 | Read or write data from or to local memory. 3 | 4 | Though not very valuable in a production setup, these store adapters are great 5 | for testing purposes. 6 | """ 7 | 8 | from oauth2.store import AccessTokenStore, AuthCodeStore, ClientStore 9 | from oauth2.error import AccessTokenNotFound, AuthCodeNotFound, \ 10 | ClientNotFoundError 11 | from oauth2.datatype import Client 12 | 13 | 14 | class ClientStore(ClientStore): 15 | """ 16 | Stores clients in memory. 17 | """ 18 | def __init__(self): 19 | self.clients = {} 20 | 21 | def add_client(self, client_id, client_secret, redirect_uris, 22 | authorized_grants=None, authorized_response_types=None): 23 | """ 24 | Add a client app. 25 | 26 | :param client_id: Identifier of the client app. 27 | :param client_secret: Secret the client app uses for authentication 28 | against the OAuth 2.0 provider. 29 | :param redirect_uris: A ``list`` of URIs to redirect to. 30 | 31 | """ 32 | self.clients[client_id] = Client( 33 | identifier=client_id, 34 | secret=client_secret, 35 | redirect_uris=redirect_uris, 36 | authorized_grants=authorized_grants, 37 | authorized_response_types=authorized_response_types) 38 | 39 | return True 40 | 41 | def fetch_by_client_id(self, client_id): 42 | """ 43 | Retrieve a client by its identifier. 44 | 45 | :param client_id: Identifier of a client app. 46 | :return: An instance of :class:`oauth2.Client`. 47 | :raises: ClientNotFoundError 48 | 49 | """ 50 | if client_id not in self.clients: 51 | raise ClientNotFoundError 52 | 53 | return self.clients[client_id] 54 | 55 | 56 | class TokenStore(AccessTokenStore, AuthCodeStore): 57 | """ 58 | Stores tokens in memory. 59 | 60 | Useful for testing purposes or APIs with a very limited set of clients. 61 | Use memcache or redis as storage to be able to scale. 62 | """ 63 | def __init__(self): 64 | self.access_tokens = {} 65 | self.auth_codes = {} 66 | self.refresh_tokens = {} 67 | self.unique_token_identifier = {} 68 | 69 | def fetch_by_code(self, code): 70 | """ 71 | Returns an AuthorizationCode. 72 | 73 | :param code: The authorization code. 74 | :return: An instance of :class:`oauth2.datatype.AuthorizationCode`. 75 | :raises: :class:`AuthCodeNotFound` if no data could be retrieved for 76 | given code. 77 | 78 | """ 79 | if code not in self.auth_codes: 80 | raise AuthCodeNotFound 81 | 82 | return self.auth_codes[code] 83 | 84 | def save_code(self, authorization_code): 85 | """ 86 | Stores the data belonging to an authorization code token. 87 | 88 | :param authorization_code: An instance of 89 | :class:`oauth2.datatype.AuthorizationCode`. 90 | 91 | """ 92 | self.auth_codes[authorization_code.code] = authorization_code 93 | 94 | return True 95 | 96 | def save_token(self, access_token): 97 | """ 98 | Stores an access token and additional data in memory. 99 | 100 | :param access_token: An instance of :class:`oauth2.datatype.AccessToken`. 101 | """ 102 | self.access_tokens[access_token.token] = access_token 103 | 104 | unique_token_key = self._unique_token_key(access_token.client_id, 105 | access_token.grant_type, 106 | access_token.user_id) 107 | 108 | self.unique_token_identifier[unique_token_key] = access_token.token 109 | 110 | if access_token.refresh_token is not None: 111 | self.refresh_tokens[access_token.refresh_token] = access_token 112 | 113 | return True 114 | 115 | def delete_code(self, code): 116 | """ 117 | Deletes an authorization code after use 118 | :param code: The authorization code. 119 | """ 120 | if code in self.auth_codes: 121 | del self.auth_codes[code] 122 | 123 | def delete_refresh_token(self, refresh_token): 124 | """ 125 | Deletes a refresh token after use 126 | :param refresh_token: The refresh_token. 127 | """ 128 | if refresh_token in self.refresh_tokens: 129 | del self.refresh_tokens[refresh_token] 130 | 131 | def fetch_by_refresh_token(self, refresh_token): 132 | """ 133 | Find an access token by its refresh token. 134 | 135 | :param refresh_token: The refresh token that was assigned to an 136 | ``AccessToken``. 137 | :return: The :class:`oauth2.datatype.AccessToken`. 138 | :raises: :class:`oauth2.error.AccessTokenNotFound` 139 | """ 140 | if refresh_token not in self.refresh_tokens: 141 | raise AccessTokenNotFound 142 | 143 | return self.refresh_tokens[refresh_token] 144 | 145 | def fetch_by_token(self, token): 146 | """ 147 | Returns data associated with an access token or ``None`` if no data 148 | was found. 149 | 150 | Useful for cases like validation where the access token needs to be 151 | read again. 152 | 153 | :param token: A access token code. 154 | :return: An instance of :class:`oauth2.datatype.AccessToken`. 155 | """ 156 | if token not in self.access_tokens: 157 | raise AccessTokenNotFound 158 | 159 | return self.access_tokens[token] 160 | 161 | def fetch_existing_token_of_user(self, client_id, grant_type, user_id): 162 | try: 163 | key = self._unique_token_key(client_id, grant_type, user_id) 164 | token = self.unique_token_identifier[key] 165 | except KeyError: 166 | raise AccessTokenNotFound 167 | 168 | return self.fetch_by_token(token) 169 | 170 | def _unique_token_key(self, client_id, grant_type, user_id): 171 | return "{0}_{1}_{2}".format(client_id, grant_type, user_id) 172 | -------------------------------------------------------------------------------- /oauth2/store/mongodb.py: -------------------------------------------------------------------------------- 1 | """ 2 | Store adapters to read/write data to from/to mongodb using pymongo. 3 | """ 4 | 5 | from oauth2.store import AccessTokenStore, AuthCodeStore, ClientStore 6 | from oauth2.datatype import AccessToken, AuthorizationCode, Client 7 | from oauth2.error import AccessTokenNotFound, AuthCodeNotFound, \ 8 | ClientNotFoundError 9 | import pymongo 10 | 11 | 12 | class MongodbStore(object): 13 | """ 14 | Base class extended by all concrete store adapters. 15 | """ 16 | 17 | def __init__(self, collection): 18 | self.collection = collection 19 | 20 | 21 | class AccessTokenStore(AccessTokenStore, MongodbStore): 22 | """ 23 | Create a new instance like this:: 24 | 25 | from pymongo import MongoClient 26 | 27 | client = MongoClient('localhost', 27017) 28 | 29 | db = client.test_database 30 | 31 | access_token_store = AccessTokenStore(collection=db["access_tokens"]) 32 | 33 | """ 34 | 35 | def fetch_by_refresh_token(self, refresh_token): 36 | data = self.collection.find_one({"refresh_token": refresh_token}) 37 | 38 | if data is None: 39 | raise AccessTokenNotFound 40 | 41 | return AccessToken(client_id=data.get("client_id"), 42 | grant_type=data.get("grant_type"), 43 | token=data.get("token"), 44 | data=data.get("data"), 45 | expires_at=data.get("expires_at"), 46 | refresh_token=data.get("refresh_token"), 47 | refresh_expires_at=data.get("refresh_expires_at"), 48 | scopes=data.get("scopes")) 49 | 50 | def delete_refresh_token(self, refresh_token): 51 | """ 52 | Deletes (invalidates) an old refresh token after use 53 | :param refresh_token: The refresh token. 54 | """ 55 | self.collection.remove({"refresh_token": refresh_token}) 56 | 57 | def fetch_existing_token_of_user(self, client_id, grant_type, user_id): 58 | data = self.collection.find_one({"client_id": client_id, 59 | "grant_type": grant_type, 60 | "user_id": user_id}, 61 | sort=[("expires_at", 62 | pymongo.DESCENDING)]) 63 | 64 | if data is None: 65 | raise AccessTokenNotFound 66 | 67 | return AccessToken(client_id=data.get("client_id"), 68 | grant_type=data.get("grant_type"), 69 | token=data.get("token"), 70 | data=data.get("data"), 71 | expires_at=data.get("expires_at"), 72 | refresh_token=data.get("refresh_token"), 73 | refresh_expires_at=data.get("refresh_expires_at"), 74 | scopes=data.get("scopes"), 75 | user_id=data.get("user_id")) 76 | 77 | def save_token(self, access_token): 78 | self.collection.insert({ 79 | "client_id": access_token.client_id, 80 | "grant_type": access_token.grant_type, 81 | "token": access_token.token, 82 | "data": access_token.data, 83 | "expires_at": access_token.expires_at, 84 | "refresh_token": access_token.refresh_token, 85 | "refresh_expires_at": access_token.refresh_expires_at, 86 | "scopes": access_token.scopes, 87 | "user_id": access_token.user_id}) 88 | 89 | return True 90 | 91 | 92 | class AuthCodeStore(AuthCodeStore, MongodbStore): 93 | """ 94 | Create a new instance like this:: 95 | 96 | from pymongo import MongoClient 97 | 98 | client = MongoClient('localhost', 27017) 99 | 100 | db = client.test_database 101 | 102 | access_token_store = AuthCodeStore(collection=db["auth_codes"]) 103 | 104 | """ 105 | 106 | def fetch_by_code(self, code): 107 | code_data = self.collection.find_one({"code": code}) 108 | 109 | if code_data is None: 110 | raise AuthCodeNotFound 111 | 112 | return AuthorizationCode(client_id=code_data.get("client_id"), 113 | code=code_data.get("code"), 114 | expires_at=code_data.get("expires_at"), 115 | redirect_uri=code_data.get("redirect_uri"), 116 | scopes=code_data.get("scopes"), 117 | data=code_data.get("data"), 118 | user_id=code_data.get("user_id")) 119 | 120 | def save_code(self, authorization_code): 121 | self.collection.insert({ 122 | "client_id": authorization_code.client_id, 123 | "code": authorization_code.code, 124 | "expires_at": authorization_code.expires_at, 125 | "redirect_uri": authorization_code.redirect_uri, 126 | "scopes": authorization_code.scopes, 127 | "data": authorization_code.data, 128 | "user_id": authorization_code.user_id}) 129 | 130 | return True 131 | 132 | def delete_code(self, code): 133 | """ 134 | Deletes an authorization code after use 135 | :param code: The authorization code. 136 | """ 137 | self.collection.remove({"code": code}) 138 | 139 | 140 | class ClientStore(ClientStore, MongodbStore): 141 | """ 142 | Create a new instance like this:: 143 | 144 | from pymongo import MongoClient 145 | 146 | client = MongoClient('localhost', 27017) 147 | 148 | db = client.test_database 149 | 150 | access_token_store = ClientStore(collection=db["clients"]) 151 | 152 | """ 153 | 154 | def fetch_by_client_id(self, client_id): 155 | client_data = self.collection.find_one({"identifier": client_id}) 156 | 157 | if client_data is None: 158 | raise ClientNotFoundError 159 | 160 | return Client( 161 | identifier=client_data.get("identifier"), 162 | secret=client_data.get("secret"), 163 | redirect_uris=client_data.get("redirect_uris"), 164 | authorized_grants=client_data.get("authorized_grants"), 165 | authorized_response_types=client_data.get( 166 | "authorized_response_types" 167 | )) 168 | -------------------------------------------------------------------------------- /oauth2/store/redisdb.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import redis 3 | import json 4 | 5 | from oauth2.datatype import AccessToken, AuthorizationCode, Client 6 | from oauth2.error import AccessTokenNotFound, AuthCodeNotFound, \ 7 | ClientNotFoundError 8 | from oauth2.store import AccessTokenStore, AuthCodeStore, ClientStore 9 | 10 | 11 | class RedisStore(object): 12 | """ 13 | Uses redis to store access tokens and auth tokens. 14 | 15 | This Store supports ``redis``. Arguments are passed to the 16 | underlying client implementation. 17 | 18 | Initialization:: 19 | 20 | import redisdb 21 | 22 | token_store = TokenStore(host="127.0.0.1", 23 | port=6379, 24 | db=0 25 | ) 26 | 27 | """ 28 | def __init__(self, rs=None, prefix="oauth2", *args, **kwargs): 29 | self.prefix = prefix 30 | 31 | if rs is not None: 32 | self.rs = rs 33 | else: 34 | self.rs = redis.StrictRedis(*args, **kwargs) 35 | 36 | def delete(self, name): 37 | cache_key = self._generate_cache_key(name) 38 | 39 | self.rs.delete(cache_key) 40 | 41 | def write(self, name, data): 42 | cache_key = self._generate_cache_key(name) 43 | 44 | self.rs.set(cache_key, json.dumps(data)) 45 | 46 | def read(self, name): 47 | cache_key = self._generate_cache_key(name) 48 | 49 | data = self.rs.get(cache_key) 50 | 51 | if data is None: 52 | return None 53 | 54 | return json.loads(data.decode("utf-8")) 55 | 56 | def _generate_cache_key(self, identifier): 57 | return self.prefix + "_" + identifier 58 | 59 | 60 | class TokenStore(AccessTokenStore, AuthCodeStore, RedisStore): 61 | def fetch_by_code(self, code): 62 | """ 63 | Returns data belonging to an authorization code from redis or 64 | ``None`` if no data was found. 65 | 66 | See :class:`oauth2.store.AuthCodeStore`. 67 | 68 | """ 69 | code_data = self.read(code) 70 | 71 | if code_data is None: 72 | raise AuthCodeNotFound 73 | 74 | return AuthorizationCode(**code_data) 75 | 76 | def save_code(self, authorization_code): 77 | """ 78 | Stores the data belonging to an authorization code token in redis. 79 | 80 | See :class:`oauth2.store.AuthCodeStore`. 81 | 82 | """ 83 | self.write(authorization_code.code, 84 | {"client_id": authorization_code.client_id, 85 | "code": authorization_code.code, 86 | "expires_at": authorization_code.expires_at, 87 | "redirect_uri": authorization_code.redirect_uri, 88 | "scopes": authorization_code.scopes, 89 | "data": authorization_code.data, 90 | "user_id": authorization_code.user_id}) 91 | 92 | def delete_code(self, code): 93 | """ 94 | Deletes an authorization code after use 95 | :param code: The authorization code. 96 | """ 97 | self.delete(code) 98 | 99 | def save_token(self, access_token): 100 | """ 101 | Stores the access token and additional data in redis. 102 | 103 | See :class:`oauth2.store.AccessTokenStore`. 104 | 105 | """ 106 | self.write(access_token.token, access_token.__dict__) 107 | 108 | unique_token_key = self._unique_token_key(access_token.client_id, 109 | access_token.grant_type, 110 | access_token.user_id) 111 | self.write(unique_token_key, access_token.__dict__) 112 | 113 | if access_token.refresh_token is not None: 114 | self.write(access_token.refresh_token, access_token.__dict__) 115 | 116 | def delete_refresh_token(self, refresh_token): 117 | """ 118 | Deletes a refresh token after use 119 | :param refresh_token: The refresh token to delete. 120 | """ 121 | access_token = self.fetch_by_refresh_token(refresh_token) 122 | 123 | self.delete(access_token.token) 124 | 125 | def fetch_by_refresh_token(self, refresh_token): 126 | token_data = self.read(refresh_token) 127 | 128 | if token_data is None: 129 | raise AccessTokenNotFound 130 | 131 | return AccessToken(**token_data) 132 | 133 | def fetch_existing_token_of_user(self, client_id, grant_type, user_id): 134 | unique_token_key = self._unique_token_key(client_id=client_id, 135 | grant_type=grant_type, 136 | user_id=user_id) 137 | token_data = self.read(unique_token_key) 138 | 139 | if token_data is None: 140 | raise AccessTokenNotFound 141 | 142 | return AccessToken(**token_data) 143 | 144 | def _unique_token_key(self, client_id, grant_type, user_id): 145 | return "{0}_{1}_{2}".format(client_id, grant_type, user_id) 146 | 147 | 148 | class ClientStore(ClientStore, RedisStore): 149 | def add_client(self, client_id, client_secret, redirect_uris, 150 | authorized_grants=None, authorized_response_types=None): 151 | """ 152 | Add a client app. 153 | 154 | :param client_id: Identifier of the client app. 155 | :param client_secret: Secret the client app uses for authentication 156 | against the OAuth 2.0 provider. 157 | :param redirect_uris: A ``list`` of URIs to redirect to. 158 | 159 | """ 160 | self.write(client_id, 161 | {"identifier": client_id, 162 | "secret": client_secret, 163 | "redirect_uris": redirect_uris, 164 | "authorized_grants": authorized_grants, 165 | "authorized_response_types": authorized_response_types}) 166 | 167 | return True 168 | 169 | def fetch_by_client_id(self, client_id): 170 | client_data = self.read(client_id) 171 | 172 | if client_data is None: 173 | raise ClientNotFoundError 174 | 175 | return Client(identifier=client_data["identifier"], 176 | secret=client_data["secret"], 177 | redirect_uris=client_data["redirect_uris"], 178 | authorized_grants=client_data["authorized_grants"], 179 | authorized_response_types=client_data["authorized_response_types"]) 180 | -------------------------------------------------------------------------------- /oauth2/test/__init__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | 3 | # Enables unit tests to work under Python 2.6 4 | # Code copied from 5 | # https://github.com/facebook/tornado/blob/master/tornado/test/util.py 6 | if sys.version_info >= (2, 7): 7 | import unittest 8 | else: 9 | import unittest2 as unittest 10 | -------------------------------------------------------------------------------- /oauth2/test/store/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wndhydrnt/python-oauth2/d1f75e321bac049291925b9ee345bf4218f5b7a9/oauth2/test/store/__init__.py -------------------------------------------------------------------------------- /oauth2/test/store/test_memcache.py: -------------------------------------------------------------------------------- 1 | from mock import Mock, call 2 | from oauth2.datatype import AuthorizationCode, AccessToken 3 | from oauth2.error import AuthCodeNotFound, AccessTokenNotFound 4 | from oauth2.store.memcache import TokenStore 5 | from oauth2.test import unittest 6 | 7 | class MemcacheTokenStoreTestCase(unittest.TestCase): 8 | def setUp(self): 9 | self.cache_prefix = "test" 10 | 11 | def _generate_test_cache_key(self, key): 12 | return self.cache_prefix + "_" + key 13 | 14 | def test_fetch_by_code(self): 15 | code = "abc" 16 | saved_data = {"client_id": "myclient", "code": code, 17 | "expires_at": 100, "redirect_uri": "http://localhost", 18 | "scopes": ["foo_read", "foo_write"], 19 | "data": {"name": "test"}} 20 | 21 | mc_mock = Mock(spec=["get"]) 22 | mc_mock.get.return_value = saved_data 23 | 24 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 25 | 26 | auth_code = store.fetch_by_code(code) 27 | 28 | mc_mock.get.assert_called_with(self._generate_test_cache_key(code)) 29 | self.assertEqual(auth_code.client_id, saved_data["client_id"]) 30 | self.assertEqual(auth_code.code, saved_data["code"]) 31 | self.assertEqual(auth_code.expires_at, saved_data["expires_at"]) 32 | self.assertEqual(auth_code.redirect_uri, saved_data["redirect_uri"]) 33 | self.assertEqual(auth_code.scopes, saved_data["scopes"]) 34 | self.assertEqual(auth_code.data, saved_data["data"]) 35 | 36 | def test_fetch_by_code_no_data(self): 37 | mc_mock = Mock(spec=["get"]) 38 | mc_mock.get.return_value = None 39 | 40 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 41 | 42 | with self.assertRaises(AuthCodeNotFound): 43 | store.fetch_by_code("abc") 44 | 45 | def test_save_code(self): 46 | data = {"client_id": "myclient", "code": "abc", "expires_at": 100, 47 | "redirect_uri": "http://localhost", 48 | "scopes": ["foo_read", "foo_write"], 49 | "data": {"name": "test"}, "user_id": 1} 50 | 51 | auth_code = AuthorizationCode(**data) 52 | 53 | cache_key = self._generate_test_cache_key(data["code"]) 54 | 55 | mc_mock = Mock(spec=["set"]) 56 | 57 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 58 | 59 | store.save_code(auth_code) 60 | 61 | mc_mock.set.assert_called_with(cache_key, data) 62 | 63 | def test_save_token(self): 64 | data = {"client_id": "myclient", "token": "xyz", 65 | "data": {"name": "test"}, "scopes": ["foo_read", "foo_write"], 66 | "expires_at": None, "refresh_token": "mno", 67 | "refresh_expires_at": None, 68 | "grant_type": "authorization_code", 69 | "user_id": 123} 70 | 71 | access_token = AccessToken(**data) 72 | 73 | cache_key = self._generate_test_cache_key(access_token.token) 74 | refresh_token_key = self._generate_test_cache_key(access_token.refresh_token) 75 | unique_token_key = self._generate_test_cache_key( 76 | "{0}_{1}_{2}".format(access_token.client_id, 77 | access_token.grant_type, 78 | access_token.user_id) 79 | ) 80 | 81 | mc_mock = Mock(spec=["set"]) 82 | 83 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 84 | 85 | store.save_token(access_token) 86 | 87 | mc_mock.set.assert_has_calls([call(cache_key, data), 88 | call(unique_token_key, data), 89 | call(refresh_token_key, data)]) 90 | 91 | def test_fetch_existing_token_of_user(self): 92 | data = {"client_id": "myclient", "token": "xyz", 93 | "data": {"name": "test"}, "scopes": ["foo_read", "foo_write"], 94 | "expires_at": None, "refresh_token": "mno", 95 | "grant_type": "authorization_code", 96 | "user_id": 123} 97 | 98 | mc_mock = Mock(spec=["get"]) 99 | mc_mock.get.return_value = data 100 | 101 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 102 | 103 | access_token = store.fetch_existing_token_of_user( 104 | client_id="myclient", 105 | grant_type="authorization_code", 106 | user_id=123) 107 | 108 | self.assertTrue(isinstance(access_token, AccessToken)) 109 | 110 | def test_fetch_existing_token_of_user_no_data(self): 111 | mc_mock = Mock(spec=["get"]) 112 | mc_mock.get.return_value = None 113 | 114 | store = TokenStore(mc=mc_mock, prefix=self.cache_prefix) 115 | 116 | with self.assertRaises(AccessTokenNotFound): 117 | store.fetch_existing_token_of_user(client_id="myclient", 118 | grant_type="authorization_code", 119 | user_id=123) 120 | -------------------------------------------------------------------------------- /oauth2/test/store/test_memory.py: -------------------------------------------------------------------------------- 1 | from oauth2.datatype import AuthorizationCode, AccessToken 2 | from oauth2.error import ClientNotFoundError, AuthCodeNotFound 3 | from oauth2.store.memory import ClientStore, TokenStore 4 | from oauth2.test import unittest 5 | 6 | class MemoryClientStoreTestCase(unittest.TestCase): 7 | def test_add_client_and_fetch_by_client_id(self): 8 | expected_client_data = {"client_id": "abc", "client_secret": "xyz", 9 | "redirect_uris": ["http://localhost"]} 10 | 11 | store = ClientStore() 12 | 13 | success = store.add_client(expected_client_data["client_id"], 14 | expected_client_data["client_secret"], 15 | expected_client_data["redirect_uris"]) 16 | self.assertTrue(success) 17 | 18 | client = store.fetch_by_client_id("abc") 19 | 20 | self.assertEqual(client.identifier, expected_client_data["client_id"]) 21 | self.assertEqual(client.secret, expected_client_data["client_secret"]) 22 | self.assertEqual(client.redirect_uris, expected_client_data["redirect_uris"]) 23 | 24 | def test_fetch_by_client_id_no_client(self): 25 | store = ClientStore() 26 | 27 | with self.assertRaises(ClientNotFoundError): 28 | store.fetch_by_client_id("abc") 29 | 30 | class MemoryTokenStoreTestCase(unittest.TestCase): 31 | def setUp(self): 32 | self.access_token_data = {"client_id": "myclient", 33 | "token": "xyz", 34 | "scopes": ["foo_read", "foo_write"], 35 | "data": {"name": "test"}, 36 | "grant_type": "authorization_code"} 37 | self.auth_code = AuthorizationCode("myclient", "abc", 100, 38 | "http://localhost", 39 | ["foo_read", "foo_write"], 40 | {"name": "test"}) 41 | 42 | self.test_store = TokenStore() 43 | 44 | def test_fetch_by_code(self): 45 | with self.assertRaises(AuthCodeNotFound): 46 | self.test_store.fetch_by_code("unknown") 47 | 48 | def test_save_code_and_fetch_by_code(self): 49 | success = self.test_store.save_code(self.auth_code) 50 | self.assertTrue(success) 51 | 52 | result = self.test_store.fetch_by_code(self.auth_code.code) 53 | 54 | self.assertEqual(result, self.auth_code) 55 | 56 | def test_save_token_and_fetch_by_token(self): 57 | access_token = AccessToken(**self.access_token_data) 58 | 59 | success = self.test_store.save_token(access_token) 60 | self.assertTrue(success) 61 | 62 | result = self.test_store.fetch_by_token(access_token.token) 63 | 64 | self.assertEqual(result, access_token) 65 | -------------------------------------------------------------------------------- /oauth2/test/store/test_mongodb.py: -------------------------------------------------------------------------------- 1 | from oauth2.test import unittest 2 | from oauth2.store.mongodb import AccessTokenStore, AuthCodeStore, \ 3 | ClientStore 4 | from mock import Mock 5 | from oauth2.datatype import AccessToken, AuthorizationCode, Client 6 | from oauth2.error import AccessTokenNotFound, AuthCodeNotFound, \ 7 | ClientNotFoundError 8 | 9 | 10 | class MongodbAccessTokenStoreTestCase(unittest.TestCase): 11 | def setUp(self): 12 | self.access_token_data = {"client_id": "myclient", 13 | "grant_type": "authorization_code", 14 | "token": "xyz", 15 | "scopes": ["foo_read", "foo_write"], 16 | "data": {"name": "test"}, 17 | "expires_at": 1000, 18 | "refresh_token": "abcd", 19 | "refresh_expires_at": 2000, 20 | "user_id": None} 21 | 22 | def test_fetch_by_refresh_token(self): 23 | refresh_token = "abcd" 24 | 25 | self.access_token_data["refresh_token"] = refresh_token 26 | 27 | collection_mock = Mock(spec=["find_one"]) 28 | collection_mock.find_one.return_value = self.access_token_data 29 | 30 | store = AccessTokenStore(collection=collection_mock) 31 | token = store.fetch_by_refresh_token(refresh_token=refresh_token) 32 | 33 | collection_mock.find_one.assert_called_with( 34 | {"refresh_token": refresh_token}) 35 | self.assertTrue(isinstance(token, AccessToken)) 36 | self.assertDictEqual(token.__dict__, self.access_token_data) 37 | 38 | def test_fetch_by_refresh_token_no_data(self): 39 | collection_mock = Mock(spec=["find_one"]) 40 | collection_mock.find_one.return_value = None 41 | 42 | store = AccessTokenStore(collection=collection_mock) 43 | 44 | with self.assertRaises(AccessTokenNotFound): 45 | store.fetch_by_refresh_token(refresh_token="abcd") 46 | 47 | def test_fetch_existing_token_of_user(self): 48 | test_data = {"client_id": "myclient", 49 | "grant_type": "authorization_code", 50 | "token": "xyz", 51 | "scopes": ["foo_read", "foo_write"], 52 | "data": {"name": "test"}, 53 | "expires_at": 1000, 54 | "refresh_token": "abcd", 55 | "refresh_expires_at": 2000, 56 | "user_id": 123} 57 | 58 | collection_mock = Mock(spec=["find_one"]) 59 | collection_mock.find_one.return_value = test_data 60 | 61 | store = AccessTokenStore(collection=collection_mock) 62 | 63 | token = store.fetch_existing_token_of_user(client_id="myclient", 64 | grant_type="authorization_code", 65 | user_id=123) 66 | 67 | self.assertTrue(isinstance(token, AccessToken)) 68 | self.assertDictEqual(token.__dict__, test_data) 69 | collection_mock.find_one.assert_called_with({"client_id": "myclient", 70 | "grant_type": "authorization_code", 71 | "user_id": 123}, 72 | sort=[("expires_at", -1)]) 73 | 74 | def test_fetch_existing_token_of_user_no_data(self): 75 | collection_mock = Mock(spec=["find_one"]) 76 | collection_mock.find_one.return_value = None 77 | 78 | store = AccessTokenStore(collection=collection_mock) 79 | 80 | with self.assertRaises(AccessTokenNotFound): 81 | store.fetch_existing_token_of_user(client_id="myclient", 82 | grant_type="authorization_code", 83 | user_id=123) 84 | 85 | def test_save_token(self): 86 | access_token = AccessToken(**self.access_token_data) 87 | 88 | collection_mock = Mock(spec=["insert"]) 89 | 90 | store = AccessTokenStore(collection=collection_mock) 91 | store.save_token(access_token) 92 | 93 | collection_mock.insert.assert_called_with(self.access_token_data) 94 | 95 | class MongodbAuthCodeStoreTestCase(unittest.TestCase): 96 | def setUp(self): 97 | self.auth_code_data = {"client_id": "myclient", "expires_at": 1000, 98 | "redirect_uri": "https://redirect", 99 | "scopes": ["foo", "bar"], "data": {}, 100 | "user_id": None} 101 | 102 | self.collection_mock = Mock(spec=["find_one", "insert", "remove"]) 103 | 104 | def test_fetch_by_code(self): 105 | code = "abcd" 106 | 107 | self.collection_mock.find_one.return_value = self.auth_code_data 108 | 109 | self.auth_code_data["code"] = "abcd" 110 | 111 | store = AuthCodeStore(collection=self.collection_mock) 112 | auth_code = store.fetch_by_code(code=code) 113 | 114 | self.collection_mock.find_one.assert_called_with({"code": "abcd"}) 115 | self.assertTrue(isinstance(auth_code, AuthorizationCode)) 116 | self.assertDictEqual(auth_code.__dict__, self.auth_code_data) 117 | 118 | def test_fetch_by_code_no_data(self): 119 | self.collection_mock.find_one.return_value = None 120 | 121 | store = AuthCodeStore(collection=self.collection_mock) 122 | 123 | with self.assertRaises(AuthCodeNotFound): 124 | store.fetch_by_code(code="abcd") 125 | 126 | def test_save_code(self): 127 | self.auth_code_data["code"] = "abcd" 128 | 129 | auth_code = AuthorizationCode(**self.auth_code_data) 130 | 131 | store = AuthCodeStore(collection=self.collection_mock) 132 | store.save_code(auth_code) 133 | 134 | self.collection_mock.insert.assert_called_with(self.auth_code_data) 135 | 136 | class MongodbClientStoreTestCase(unittest.TestCase): 137 | def test_fetch_by_client_id(self): 138 | client_data = {"identifier": "testclient", "secret": "k#4g6", 139 | "redirect_uris": ["https://redirect"], 140 | "authorized_grants": []} 141 | 142 | collection_mock = Mock(spec=["find_one"]) 143 | collection_mock.find_one.return_value = client_data 144 | 145 | store = ClientStore(collection=collection_mock) 146 | client = store.fetch_by_client_id(client_id=client_data["identifier"]) 147 | 148 | collection_mock.find_one.assert_called_with({ 149 | "identifier": client_data["identifier"]}) 150 | self.assertTrue(isinstance(client, Client)) 151 | self.assertEqual(client.identifier, client_data["identifier"]) 152 | 153 | def test_fetch_by_client_id_no_data(self): 154 | collection_mock = Mock(spec=["find_one"]) 155 | collection_mock.find_one.return_value = None 156 | 157 | store = ClientStore(collection=collection_mock) 158 | 159 | with self.assertRaises(ClientNotFoundError): 160 | store.fetch_by_client_id(client_id="testclient") 161 | -------------------------------------------------------------------------------- /oauth2/test/store/test_redisdb.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | import json 4 | from mock import Mock 5 | 6 | from oauth2.datatype import AccessToken 7 | from oauth2.store.redisdb import TokenStore 8 | from oauth2.test import unittest 9 | 10 | 11 | class TokenStoreTestCase(unittest.TestCase): 12 | def test_delete_refresh_token(self): 13 | refresh_token_id = "def" 14 | access_token = AccessToken(client_id="abc", grant_type="token", 15 | token="xyz") 16 | 17 | redisdb_mock = Mock(spec=["delete", "get"]) 18 | redisdb_mock.get.return_value = bytes(json.dumps(access_token.__dict__).encode('utf-8')) 19 | 20 | store = TokenStore(rs=redisdb_mock) 21 | store.delete_refresh_token(refresh_token_id) 22 | 23 | self.assertEqual(1, redisdb_mock.delete.call_count) 24 | -------------------------------------------------------------------------------- /oauth2/test/test_client_authenticator.py: -------------------------------------------------------------------------------- 1 | import base64 2 | from base64 import b64encode 3 | from oauth2.test import unittest 4 | from mock import Mock 5 | from oauth2.client_authenticator import ClientAuthenticator, http_basic_auth, \ 6 | request_body 7 | from oauth2.datatype import Client 8 | from oauth2.error import OAuthInvalidNoRedirectError, ClientNotFoundError,\ 9 | OAuthInvalidError 10 | from oauth2.store import ClientStore 11 | from oauth2.web.wsgi import Request 12 | 13 | 14 | class ClientAuthenticatorTestCase(unittest.TestCase): 15 | def setUp(self): 16 | self.client = Client(identifier="abc", secret="xyz", 17 | authorized_grants=["authorization_code"], 18 | authorized_response_types=["code"], 19 | redirect_uris=["http://callback"]) 20 | self.client_store_mock = Mock(spec=ClientStore) 21 | 22 | self.source_mock = Mock() 23 | 24 | self.authenticator = ClientAuthenticator( 25 | client_store=self.client_store_mock, 26 | source=self.source_mock) 27 | 28 | def test_by_identifier(self): 29 | redirect_uri = "http://callback" 30 | 31 | self.client_store_mock.fetch_by_client_id.return_value = self.client 32 | 33 | request_mock = Mock(spec=Request) 34 | request_mock.get_param.side_effect = [self.client.identifier, 35 | redirect_uri] 36 | 37 | client = self.authenticator.by_identifier(request=request_mock) 38 | 39 | self.client_store_mock.fetch_by_client_id.\ 40 | assert_called_with(self.client.identifier) 41 | self.assertEqual(client.redirect_uri, redirect_uri) 42 | 43 | def test_by_identifier_client_id_not_set(self): 44 | request_mock = Mock(spec=Request) 45 | request_mock.get_param.return_value = None 46 | 47 | with self.assertRaises(OAuthInvalidNoRedirectError) as expected: 48 | self.authenticator.by_identifier(request=request_mock) 49 | 50 | self.assertEqual(expected.exception.error, "missing_client_id") 51 | 52 | def test_by_identifier_unknown_client(self): 53 | request_mock = Mock(spec=Request) 54 | request_mock.get_param.return_value = "def" 55 | 56 | self.client_store_mock.fetch_by_client_id.\ 57 | side_effect = ClientNotFoundError 58 | 59 | with self.assertRaises(OAuthInvalidNoRedirectError) as expected: 60 | self.authenticator.by_identifier(request=request_mock) 61 | 62 | self.assertEqual(expected.exception.error, "unknown_client") 63 | 64 | def test_by_identifier_unknown_redirect_uri(self): 65 | response_type = "code" 66 | unknown_redirect_uri = "http://unknown.com" 67 | 68 | request_mock = Mock(spec=Request) 69 | request_mock.get_param.side_effect = [self.client.identifier, 70 | response_type, 71 | unknown_redirect_uri] 72 | 73 | self.client_store_mock.fetch_by_client_id.return_value = self.client 74 | 75 | with self.assertRaises(OAuthInvalidNoRedirectError) as expected: 76 | self.authenticator.by_identifier(request=request_mock) 77 | 78 | self.assertEqual(expected.exception.error, "invalid_redirect_uri") 79 | 80 | def test_by_identifier_secret(self): 81 | client_id = "abc" 82 | client_secret = "xyz" 83 | grant_type = "authorization_code" 84 | 85 | request_mock = Mock(spec=Request) 86 | request_mock.post_param.return_value = grant_type 87 | 88 | self.source_mock.return_value = (client_id, client_secret) 89 | 90 | self.client_store_mock.fetch_by_client_id.return_value = self.client 91 | 92 | self.authenticator.by_identifier_secret(request=request_mock) 93 | self.client_store_mock.fetch_by_client_id.\ 94 | assert_called_with(client_id) 95 | 96 | def test_by_identifier_secret_unknown_client(self): 97 | client_id = "def" 98 | client_secret = "uvw" 99 | 100 | self.source_mock.return_value = (client_id, client_secret) 101 | 102 | request_mock = Mock(spec=Request) 103 | 104 | self.client_store_mock.fetch_by_client_id.\ 105 | side_effect = ClientNotFoundError 106 | 107 | with self.assertRaises(OAuthInvalidError) as expected: 108 | self.authenticator.by_identifier_secret(request_mock) 109 | 110 | self.assertEqual(expected.exception.error, "invalid_client") 111 | 112 | def test_by_identifier_secret_client_not_authorized(self): 113 | client_id = "abc" 114 | client_secret = "xyz" 115 | grant_type = "client_credentials" 116 | 117 | self.source_mock.return_value = (client_id, client_secret) 118 | 119 | request_mock = Mock(spec=Request) 120 | request_mock.post_param.return_value = grant_type 121 | 122 | self.client_store_mock.fetch_by_client_id.return_value = self.client 123 | 124 | with self.assertRaises(OAuthInvalidError) as expected: 125 | self.authenticator.by_identifier_secret(request_mock) 126 | 127 | self.assertEqual(expected.exception.error, "unauthorized_client") 128 | 129 | def test_by_identifier_secret_wrong_secret(self): 130 | client_id = "abc" 131 | client_secret = "uvw" 132 | grant_type = "authorization_code" 133 | 134 | self.source_mock.return_value = (client_id, client_secret) 135 | 136 | request_mock = Mock(spec=Request) 137 | request_mock.post_param.return_value = grant_type 138 | 139 | self.client_store_mock.fetch_by_client_id.return_value = self.client 140 | 141 | with self.assertRaises(OAuthInvalidError) as expected: 142 | self.authenticator.by_identifier_secret(request_mock) 143 | 144 | self.assertEqual(expected.exception.error, "invalid_client") 145 | 146 | 147 | class RequestBodyTestCase(unittest.TestCase): 148 | def test_valid(self): 149 | client_id = "abc" 150 | client_secret = "secret" 151 | 152 | request_mock = Mock(spec=Request) 153 | request_mock.post_param.side_effect = [client_id, client_secret] 154 | 155 | result = request_body(request_mock) 156 | 157 | self.assertEqual(result[0], client_id) 158 | self.assertEqual(result[1], client_secret) 159 | 160 | def test_no_client_id(self): 161 | request_mock = Mock(spec=Request) 162 | request_mock.post_param.return_value = None 163 | 164 | with self.assertRaises(OAuthInvalidError) as expected: 165 | request_body(request_mock) 166 | 167 | self.assertEqual(expected.exception.error, "invalid_request") 168 | 169 | def test_no_client_secret(self): 170 | request_mock = Mock(spec=Request) 171 | request_mock.post_param.side_effect = ["abc", None] 172 | 173 | with self.assertRaises(OAuthInvalidError) as expected: 174 | request_body(request_mock) 175 | 176 | self.assertEqual(expected.exception.error, "invalid_request") 177 | 178 | 179 | class HttpBasicAuthTestCase(unittest.TestCase): 180 | def test_valid(self): 181 | client_id = "testclient" 182 | client_secret = "secret" 183 | 184 | credentials = "{0}:{1}".format(client_id, client_secret) 185 | 186 | encoded = b64encode(credentials.encode("latin1")) 187 | 188 | request_mock = Mock(spec=Request) 189 | request_mock.header.return_value = "Basic {0}".\ 190 | format(encoded.decode("latin1")) 191 | 192 | result_client_id, result_client_secret = http_basic_auth(request=request_mock) 193 | 194 | request_mock.header.assert_called_with("authorization") 195 | 196 | self.assertEqual(result_client_id, client_id) 197 | self.assertEqual(result_client_secret, client_secret) 198 | 199 | def test_header_not_present(self): 200 | request_mock = Mock(spec=Request) 201 | request_mock.header.return_value = None 202 | 203 | with self.assertRaises(OAuthInvalidError) as expected: 204 | http_basic_auth(request=request_mock) 205 | 206 | self.assertEqual(expected.exception.error, "invalid_request") 207 | 208 | def test_invalid_authorization_header(self): 209 | request_mock = Mock(spec=Request) 210 | request_mock.header.return_value = "some-data" 211 | 212 | with self.assertRaises(OAuthInvalidError) as expected: 213 | http_basic_auth(request=request_mock) 214 | 215 | self.assertEqual(expected.exception.error, "invalid_request") 216 | -------------------------------------------------------------------------------- /oauth2/test/test_datatype.py: -------------------------------------------------------------------------------- 1 | from oauth2.test import unittest 2 | from mock import patch 3 | from oauth2.datatype import AccessToken, Client 4 | from oauth2.error import RedirectUriUnknown 5 | 6 | 7 | def mock_time(): 8 | return 1000 9 | 10 | 11 | class AccessTokenTestCase(unittest.TestCase): 12 | @patch("time.time", mock_time) 13 | def test_expires_in_expired(self): 14 | access_token = AccessToken(client_id="abc", 15 | grant_type="client_credentials", 16 | token="def", expires_at=999) 17 | 18 | self.assertEqual(access_token.expires_in, 0) 19 | 20 | @patch("time.time", mock_time) 21 | def test_expires_in_not_expired(self): 22 | access_token = AccessToken(client_id="abc", 23 | grant_type="client_credentials", 24 | token="def", expires_at=1100) 25 | 26 | self.assertEqual(access_token.expires_in, 100) 27 | 28 | def test_is_expired_expired_at_not_set(self): 29 | access_token = AccessToken(client_id="abc", 30 | grant_type="client_credentials", 31 | token="def") 32 | 33 | self.assertFalse(access_token.is_expired()) 34 | 35 | 36 | class ClientTestCase(unittest.TestCase): 37 | def test_redirect_uri(self): 38 | client = Client(identifier="abc", secret="xyz", 39 | redirect_uris=["http://callback"]) 40 | 41 | self.assertEqual(client.redirect_uri, "http://callback") 42 | client.redirect_uri = "http://callback" 43 | self.assertEqual(client.redirect_uri, "http://callback") 44 | 45 | with self.assertRaises(RedirectUriUnknown): 46 | client.redirect_uri = "http://another.callback" 47 | 48 | def test_response_type_supported(self): 49 | client = Client(identifier="abc", secret="xyz", 50 | authorized_grants=["test_grant"]) 51 | 52 | self.assertTrue(client.grant_type_supported("test_grant")) 53 | self.assertFalse(client.grant_type_supported("unknown_grant")) 54 | 55 | def test_response_type_supported(self): 56 | client = Client(identifier="abc", secret="xyz", 57 | authorized_response_types=["test_response_type"]) 58 | 59 | self.assertTrue(client.response_type_supported("test_response_type")) 60 | self.assertFalse(client.response_type_supported("unknown")) 61 | -------------------------------------------------------------------------------- /oauth2/test/test_oauth2.py: -------------------------------------------------------------------------------- 1 | import json 2 | from mock import Mock 3 | from oauth2.error import OAuthInvalidNoRedirectError, OAuthInvalidError 4 | from oauth2.test import unittest 5 | from oauth2 import Provider 6 | from oauth2.store import ClientStore 7 | from oauth2.web import Response, AuthorizationCodeGrantSiteAdapter, \ 8 | ResourceOwnerGrantSiteAdapter 9 | from oauth2.web.wsgi import Request 10 | from oauth2.grant import RefreshToken, AuthorizationCodeGrant, GrantHandler, \ 11 | ResourceOwnerGrant 12 | 13 | 14 | class ProviderTestCase(unittest.TestCase): 15 | def setUp(self): 16 | self.client_store_mock = Mock(spec=ClientStore) 17 | self.token_generator_mock = Mock() 18 | 19 | self.response_mock = Mock(spec=Response) 20 | self.response_mock.body = "" 21 | response_class_mock = Mock(return_value=self.response_mock) 22 | 23 | self.token_generator_mock.expires_in = {} 24 | self.token_generator_mock.refresh_expires_in = 0 25 | 26 | self.auth_server = Provider(access_token_store=Mock(), 27 | auth_code_store=Mock(), 28 | client_store=self.client_store_mock, 29 | token_generator=self.token_generator_mock, 30 | response_class=response_class_mock) 31 | 32 | def test_add_grant_set_expire_time(self): 33 | """ 34 | Provider.add_grant() should set the expiration time on the instance of TokenGenerator 35 | """ 36 | self.auth_server.add_grant( 37 | AuthorizationCodeGrant( 38 | expires_in=400, 39 | site_adapter=Mock(spec=AuthorizationCodeGrantSiteAdapter) 40 | ) 41 | ) 42 | self.auth_server.add_grant( 43 | ResourceOwnerGrant( 44 | expires_in=500, 45 | site_adapter=Mock(spec=ResourceOwnerGrantSiteAdapter) 46 | ) 47 | ) 48 | self.auth_server.add_grant(RefreshToken(expires_in=1200)) 49 | 50 | self.assertEqual(self.token_generator_mock.expires_in[AuthorizationCodeGrant.grant_type], 400) 51 | self.assertEqual(self.token_generator_mock.expires_in[ResourceOwnerGrant.grant_type], 500) 52 | self.assertEqual(self.token_generator_mock.refresh_expires_in, 1200) 53 | 54 | def test_dispatch(self): 55 | environ = {"session": "data"} 56 | process_result = "response" 57 | 58 | request_mock = Mock(spec=Request) 59 | 60 | grant_handler_mock = Mock(spec=["process", "read_validate_params"]) 61 | grant_handler_mock.process.return_value = process_result 62 | 63 | grant_factory_mock = Mock(return_value=grant_handler_mock) 64 | 65 | self.auth_server.site_adapter = Mock( 66 | spec=AuthorizationCodeGrantSiteAdapter 67 | ) 68 | self.auth_server.add_grant(grant_factory_mock) 69 | result = self.auth_server.dispatch(request_mock, environ) 70 | 71 | grant_factory_mock.assert_called_with(request_mock, self.auth_server) 72 | grant_handler_mock.read_validate_params.\ 73 | assert_called_with(request_mock) 74 | grant_handler_mock.process.assert_called_with(request_mock, 75 | self.response_mock, 76 | environ) 77 | self.assertEqual(result, process_result) 78 | 79 | def test_dispatch_no_grant_type_found(self): 80 | error_body = { 81 | "error": "unsupported_response_type", 82 | "error_description": "Grant not supported" 83 | } 84 | 85 | request_mock = Mock(spec=Request) 86 | 87 | result = self.auth_server.dispatch(request_mock, {}) 88 | 89 | self.response_mock.add_header.assert_called_with("Content-Type", 90 | "application/json") 91 | self.assertEqual(self.response_mock.status_code, 400) 92 | self.assertEqual(self.response_mock.body, json.dumps(error_body)) 93 | self.assertEqual(result, self.response_mock) 94 | 95 | def test_dispatch_no_client_found(self): 96 | request_mock = Mock(spec=Request) 97 | 98 | grant_handler_mock = Mock(spec=GrantHandler) 99 | grant_handler_mock.process.side_effect = OAuthInvalidNoRedirectError( 100 | error="") 101 | 102 | grant_factory_mock = Mock(return_value=grant_handler_mock) 103 | 104 | self.auth_server.add_grant(grant_factory_mock) 105 | self.auth_server.dispatch(request_mock, {}) 106 | 107 | self.response_mock.add_header.assert_called_with("Content-Type", 108 | "application/json") 109 | self.assertEqual(self.response_mock.status_code, 400) 110 | self.assertEqual(json.loads(self.response_mock.body)["error"], "invalid_redirect_uri") 111 | 112 | def test_dispatch_general_exception(self): 113 | request_mock = Mock(spec=Request) 114 | 115 | grant_handler_mock = Mock(spec=GrantHandler) 116 | grant_handler_mock.process.side_effect = KeyError 117 | 118 | grant_factory_mock = Mock(return_value=grant_handler_mock) 119 | 120 | self.auth_server.add_grant(grant_factory_mock) 121 | self.auth_server.dispatch(request_mock, {}) 122 | 123 | self.assertTrue(grant_handler_mock.handle_error.called) 124 | -------------------------------------------------------------------------------- /oauth2/test/test_tokengenerator.py: -------------------------------------------------------------------------------- 1 | import re 2 | from oauth2.test import unittest 3 | from oauth2.tokengenerator import URandomTokenGenerator, Uuid4 4 | 5 | class URandomTokenGeneratorTestCase(unittest.TestCase): 6 | def test_generate(self): 7 | length = 20 8 | 9 | generator = URandomTokenGenerator(length=length) 10 | 11 | result = generator.generate() 12 | 13 | self.assertTrue(isinstance(result, str)) 14 | self.assertEqual(len(result), length) 15 | 16 | class Uuid4TestCase(unittest.TestCase): 17 | def setUp(self): 18 | self.uuid_regex = r"^[a-z0-9]{8}\-[a-z0-9]{4}\-[a-z0-9]{4}\-[a-z0-9]{4}-[a-z0-9]{12}$" 19 | 20 | def test_create_access_token_data_no_expiration(self): 21 | generator = Uuid4() 22 | 23 | result = generator.create_access_token_data('test_grant_type') 24 | 25 | self.assertRegexpMatches(result["access_token"], self.uuid_regex) 26 | self.assertEqual(result["token_type"], "Bearer") 27 | 28 | def test_create_access_token_data_with_expiration(self): 29 | generator = Uuid4() 30 | generator.expires_in = {'test_grant_type':600} 31 | 32 | result = generator.create_access_token_data('test_grant_type') 33 | 34 | self.assertRegexpMatches(result["access_token"], self.uuid_regex) 35 | self.assertEqual(result["token_type"], "Bearer") 36 | self.assertRegexpMatches(result["refresh_token"], self.uuid_regex) 37 | self.assertEqual(result["expires_in"], 600) 38 | 39 | def test_generate(self): 40 | generator = Uuid4() 41 | 42 | result = generator.generate() 43 | 44 | regex = re.compile(self.uuid_regex) 45 | 46 | match = regex.match(result) 47 | 48 | self.assertEqual(result, match.group()) 49 | 50 | if __name__ == "__main__": 51 | unittest.main() 52 | -------------------------------------------------------------------------------- /oauth2/test/test_web.py: -------------------------------------------------------------------------------- 1 | from oauth2.test import unittest 2 | from mock import Mock 3 | from oauth2.web import Response 4 | from oauth2.web.wsgi import Request, Application 5 | from oauth2 import Provider 6 | 7 | 8 | class RequestTestCase(unittest.TestCase): 9 | def test_initialization_no_post_data(self): 10 | request_method = "TEST" 11 | query_string = "foo=bar&baz=buz" 12 | 13 | environment = {"REQUEST_METHOD": request_method, 14 | "QUERY_STRING": query_string, 15 | "PATH_INFO": "/"} 16 | 17 | request = Request(environment) 18 | 19 | self.assertEqual(request.method, request_method) 20 | self.assertEqual(request.query_params, {"foo": "bar", "baz": "buz"}) 21 | self.assertEqual(request.query_string, query_string) 22 | self.assertEqual(request.post_params, {}) 23 | 24 | def test_initialization_with_post_data(self): 25 | content_length = "42" 26 | request_method = "POST" 27 | query_string = "" 28 | content = "foo=bar&baz=buz".encode('utf-8') 29 | 30 | wsgi_input_mock = Mock(spec=["read"]) 31 | wsgi_input_mock.read.return_value = content 32 | 33 | environment = {"CONTENT_LENGTH": content_length, 34 | "CONTENT_TYPE": "application/x-www-form-urlencoded", 35 | "REQUEST_METHOD": request_method, 36 | "QUERY_STRING": query_string, 37 | "PATH_INFO": "/", 38 | "wsgi.input": wsgi_input_mock} 39 | 40 | request = Request(environment) 41 | 42 | wsgi_input_mock.read.assert_called_with(int(content_length)) 43 | self.assertEqual(request.method, request_method) 44 | self.assertEqual(request.query_params, {}) 45 | self.assertEqual(request.query_string, query_string) 46 | self.assertEqual(request.post_params, {"foo": "bar", "baz": "buz"}) 47 | 48 | def test_get_param(self): 49 | request_method = "TEST" 50 | query_string = "foo=bar&baz=buz" 51 | 52 | environment = {"REQUEST_METHOD": request_method, 53 | "QUERY_STRING": query_string, 54 | "PATH_INFO": "/a-url"} 55 | 56 | request = Request(environment) 57 | 58 | result = request.get_param("foo") 59 | 60 | self.assertEqual(result, "bar") 61 | 62 | result_default = request.get_param("na") 63 | 64 | self.assertEqual(result_default, None) 65 | 66 | def test_post_param(self): 67 | content_length = "42" 68 | request_method = "POST" 69 | query_string = "" 70 | content = "foo=bar&baz=buz".encode('utf-8') 71 | 72 | wsgi_input_mock = Mock(spec=["read"]) 73 | wsgi_input_mock.read.return_value = content 74 | 75 | environment = {"CONTENT_LENGTH": content_length, 76 | "CONTENT_TYPE": "application/x-www-form-urlencoded", 77 | "REQUEST_METHOD": request_method, 78 | "QUERY_STRING": query_string, 79 | "PATH_INFO": "/", 80 | "wsgi.input": wsgi_input_mock} 81 | 82 | request = Request(environment) 83 | 84 | result = request.post_param("foo") 85 | 86 | self.assertEqual(result, "bar") 87 | 88 | result_default = request.post_param("na") 89 | 90 | self.assertEqual(result_default, None) 91 | 92 | wsgi_input_mock.read.assert_called_with(int(content_length)) 93 | 94 | def test_header(self): 95 | environment = {"REQUEST_METHOD": "GET", 96 | "QUERY_STRING": "", 97 | "PATH_INFO": "/", 98 | "HTTP_AUTHORIZATION": "Basic abcd"} 99 | 100 | request = Request(env=environment) 101 | 102 | self.assertEqual(request.header("authorization"), "Basic abcd") 103 | self.assertIsNone(request.header("unknown")) 104 | self.assertEqual(request.header("unknown", default=0), 0) 105 | 106 | 107 | class ServerTestCase(unittest.TestCase): 108 | def test_call(self): 109 | body = "body" 110 | headers = {"header": "value"} 111 | path = "/authorize" 112 | status_code = 200 113 | http_code = "200 OK" 114 | 115 | environment = {"PATH_INFO": path, "myvar": "value"} 116 | 117 | request_mock = Mock(spec=Request) 118 | request_class_mock = Mock(return_value=request_mock) 119 | 120 | response_mock = Mock(spec=Response) 121 | response_mock.body = body 122 | response_mock.headers = headers 123 | response_mock.status_code = status_code 124 | 125 | provider_mock = Mock(spec=Provider) 126 | provider_mock.dispatch.return_value = response_mock 127 | 128 | start_response_mock = Mock() 129 | 130 | wsgi = Application(provider=provider_mock, authorize_uri=path, 131 | request_class=request_class_mock, 132 | env_vars=["myvar"]) 133 | result = wsgi(environment, start_response_mock) 134 | 135 | request_class_mock.assert_called_with(environment) 136 | provider_mock.dispatch.assert_called_with(request_mock, 137 | {"myvar": "value"}) 138 | start_response_mock.assert_called_with(http_code, 139 | list(headers.items())) 140 | self.assertEqual(result, [body.encode('utf-8')]) 141 | -------------------------------------------------------------------------------- /oauth2/tokengenerator.py: -------------------------------------------------------------------------------- 1 | """ 2 | Provides various implementations of algorithms to generate an Access Token or 3 | Refresh Token. 4 | """ 5 | 6 | import hashlib 7 | import os 8 | import uuid 9 | 10 | 11 | class TokenGenerator(object): 12 | """ 13 | Base class of every token generator. 14 | """ 15 | def __init__(self): 16 | """ 17 | Create a new instance of a token generator. 18 | """ 19 | self.expires_in = {} 20 | self.refresh_expires_in = 0 21 | 22 | def create_access_token_data(self, grant_type): 23 | """ 24 | Create data needed by an access token. 25 | 26 | :param grant_type: 27 | :type grant_type: str 28 | 29 | :return: A ``dict`` containing he ``access_token`` and the 30 | ``token_type``. If the value of ``TokenGenerator.expires_in`` 31 | is larger than 0, a ``refresh_token`` will be generated too. 32 | :rtype: dict 33 | """ 34 | result = {"access_token": self.generate(), "token_type": "Bearer"} 35 | 36 | if self.expires_in.get(grant_type, 0) > 0: 37 | result["refresh_token"] = self.generate() 38 | 39 | result["expires_in"] = self.expires_in[grant_type] 40 | 41 | return result 42 | 43 | def generate(self): 44 | """ 45 | Implemented by generators extending this base class. 46 | 47 | :raises NotImplementedError: 48 | """ 49 | raise NotImplementedError 50 | 51 | 52 | class URandomTokenGenerator(TokenGenerator): 53 | """ 54 | Create a token using ``os.urandom()``. 55 | """ 56 | def __init__(self, length=40): 57 | self.token_length = length 58 | TokenGenerator.__init__(self) 59 | 60 | def generate(self): 61 | """ 62 | :return: A new token 63 | :rtype: str 64 | """ 65 | random_data = os.urandom(100) 66 | 67 | hash_gen = hashlib.new("sha512") 68 | hash_gen.update(random_data) 69 | 70 | return hash_gen.hexdigest()[:self.token_length] 71 | 72 | 73 | class Uuid4(TokenGenerator): 74 | """ 75 | Generate a token using uuid4. 76 | """ 77 | def generate(self): 78 | """ 79 | :return: A new token 80 | :rtype: str 81 | """ 82 | return str(uuid.uuid4()) 83 | -------------------------------------------------------------------------------- /oauth2/web/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | 5 | class AuthenticatingSiteAdapter(object): 6 | """ 7 | Extended by site adapters that need to authenticate the user. 8 | """ 9 | def authenticate(self, request, environ, scopes, client): 10 | """ 11 | Authenticates a user and checks if she has authorized access. 12 | 13 | :param request: Incoming request data. 14 | :type request: oauth2.web.Request 15 | 16 | :param environ: Environment variables of the request. 17 | :type environ: dict 18 | 19 | :param scopes: A list of strings with each string being one requested 20 | scope. 21 | :type scopes: list 22 | 23 | :param client: The client that initiated the authorization process 24 | :type client: oauth2.datatype.Client 25 | 26 | :return: A ``dict`` containing arbitrary data that will be passed to 27 | the current storage adapter and saved with auth code and 28 | access token. Return a tuple in the form 29 | `(additional_data, user_id)` if you want to use 30 | :doc:`unique_token`. 31 | :rtype: dict 32 | 33 | :raises oauth2.error.UserNotAuthenticated: If the user could not be 34 | authenticated. 35 | """ 36 | raise NotImplementedError 37 | 38 | 39 | class UserFacingSiteAdapter(object): 40 | """ 41 | Extended by site adapters that need to interact with the user. 42 | 43 | Display HTML or redirect the user agent to another page of your website 44 | where she can do something before being returned to the OAuth 2.0 server. 45 | """ 46 | def render_auth_page(self, request, response, environ, scopes, client): 47 | """ 48 | Defines how to display a confirmation page to the user. 49 | 50 | :param request: Incoming request data. 51 | :type request: oauth2.web.Request 52 | 53 | :param response: Response to return to a client. 54 | :type response: oauth2.web.Response 55 | 56 | :param environ: Environment variables of the request. 57 | :type environ: dict 58 | 59 | :param scopes: A list of strings with each string being one requested 60 | scope. 61 | :type scopes: list 62 | 63 | :param client: The client that initiated the authorization process 64 | :type client: oauth2.datatype.Client 65 | 66 | :return: The response passed in as a parameter. 67 | It can contain HTML or issue a redirect. 68 | :rtype: oauth2.web.Response 69 | """ 70 | raise NotImplementedError 71 | 72 | def user_has_denied_access(self, request): 73 | """ 74 | Checks if the user has denied access. This will lead to python-oauth2 75 | returning a "acess_denied" response to the requesting client app. 76 | 77 | :param request: Incoming request data. 78 | :type request: oauth2.web.Request 79 | 80 | :return: Return ``True`` if the user has denied access. 81 | :rtype: bool 82 | """ 83 | raise NotImplementedError 84 | 85 | 86 | class AuthorizationCodeGrantSiteAdapter(UserFacingSiteAdapter, 87 | AuthenticatingSiteAdapter): 88 | """ 89 | Definition of a site adapter as required by 90 | :class:`oauth2.grant.AuthorizationCodeGrant`. 91 | """ 92 | pass 93 | 94 | 95 | class ImplicitGrantSiteAdapter(UserFacingSiteAdapter, 96 | AuthenticatingSiteAdapter): 97 | """ 98 | Definition of a site adapter as required by 99 | :class:`oauth2.grant.ImplicitGrant`. 100 | """ 101 | pass 102 | 103 | 104 | class ResourceOwnerGrantSiteAdapter(AuthenticatingSiteAdapter): 105 | """ 106 | Definition of a site adapter as required by 107 | :class:`oauth2.grant.ResourceOwnerGrant`. 108 | """ 109 | pass 110 | 111 | 112 | class Request(object): 113 | """ 114 | Base class defining the interface of a request. 115 | """ 116 | @property 117 | def method(self): 118 | """ 119 | Returns the HTTP method of the request. 120 | """ 121 | raise NotImplementedError 122 | 123 | @property 124 | def path(self): 125 | """ 126 | Returns the current path portion of the current uri. 127 | 128 | Used by some grants to determine which action to take. 129 | """ 130 | raise NotImplementedError 131 | 132 | def get_param(self, name, default=None): 133 | """ 134 | Retrieve a parameter from the query string of the request. 135 | """ 136 | raise NotImplementedError 137 | 138 | def header(self, name, default=None): 139 | """ 140 | Retrieve a header of the request. 141 | """ 142 | raise NotImplementedError 143 | 144 | def post_param(self, name, default=None): 145 | """ 146 | Retrieve a parameter from the body of the request. 147 | """ 148 | raise NotImplementedError 149 | 150 | 151 | class Response(object): 152 | """ 153 | Contains data returned to the requesting user agent. 154 | """ 155 | def __init__(self): 156 | self.status_code = 200 157 | self._headers = {"Content-Type": "text/html"} 158 | self.body = "" 159 | 160 | @property 161 | def headers(self): 162 | return self._headers 163 | 164 | def add_header(self, header, value): 165 | """ 166 | Add a header to the response. 167 | """ 168 | self._headers[header] = str(value) 169 | -------------------------------------------------------------------------------- /oauth2/web/tornado.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | .. warning:: 6 | 7 | Tornado support is currently experimental. 8 | 9 | Use Tornado to serve token requests: 10 | 11 | .. literalinclude:: examples/tornado_server.py 12 | """ 13 | 14 | from __future__ import absolute_import 15 | 16 | from tornado.web import RequestHandler 17 | 18 | 19 | class Request(object): 20 | def __init__(self, handler): 21 | """ 22 | :param handler: Handler of the current request 23 | :type handler: :class:`tornado.web.RequestHandler` 24 | """ 25 | self.handler = handler 26 | 27 | @property 28 | def method(self): 29 | return self.handler.request.method 30 | 31 | @property 32 | def path(self): 33 | return self.handler.request.path 34 | 35 | @property 36 | def query_string(self): 37 | return self.handler.request.query 38 | 39 | def get_param(self, name, default=None): 40 | return self.handler.get_query_argument(name=name, default=default) 41 | 42 | def header(self, name, default=None): 43 | return self.handler.request.headers[name] 44 | 45 | def post_param(self, name, default=None): 46 | return self.handler.get_body_argument(name=name, default=default) 47 | 48 | 49 | class OAuth2Handler(RequestHandler): 50 | def initialize(self, provider): 51 | """ 52 | :type provider: :class:`oauth2.Provider` 53 | """ 54 | self.provider = provider 55 | 56 | def get(self): 57 | response = self._dispatch_request() 58 | 59 | self._map_response(response) 60 | 61 | def post(self): 62 | response = self._dispatch_request() 63 | 64 | self._map_response(response) 65 | 66 | def _dispatch_request(self): 67 | return self.provider.dispatch(request=Request(handler=self), 68 | environ=dict()) 69 | 70 | def _map_response(self, response): 71 | for name, value in list(response.headers.items()): 72 | self.set_header(name, value) 73 | 74 | self.set_status(response.status_code) 75 | self.write(response.body) 76 | -------------------------------------------------------------------------------- /oauth2/web/wsgi.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Classes for handling a HTTP request/response flow. 6 | 7 | .. versionchanged:: 1.0.0 8 | Moved from package ``oauth2.web`` to ``oauth2.web.wsgi``. 9 | """ 10 | 11 | from oauth2.compatibility import parse_qs 12 | 13 | 14 | class Request(object): 15 | """ 16 | Contains data of the current HTTP request. 17 | """ 18 | def __init__(self, env): 19 | """ 20 | :param env: Wsgi environment 21 | """ 22 | self.method = env["REQUEST_METHOD"] 23 | self.query_params = {} 24 | self.query_string = env["QUERY_STRING"] 25 | self.path = env["PATH_INFO"] 26 | self.post_params = {} 27 | self.env_raw = env 28 | 29 | for param, value in parse_qs(env["QUERY_STRING"]).items(): 30 | self.query_params[param] = value[0] 31 | 32 | if (self.method == "POST" 33 | and env["CONTENT_TYPE"].startswith("application/x-www-form-urlencoded")): 34 | self.post_params = {} 35 | content = env['wsgi.input'].read(int(env['CONTENT_LENGTH'])) 36 | post_params = parse_qs(content) 37 | 38 | for param, value in post_params.items(): 39 | decoded_param = param.decode('utf-8') 40 | decoded_value = value[0].decode('utf-8') 41 | self.post_params[decoded_param] = decoded_value 42 | 43 | def get_param(self, name, default=None): 44 | """ 45 | Returns a param of a GET request identified by its name. 46 | """ 47 | try: 48 | return self.query_params[name] 49 | except KeyError: 50 | return default 51 | 52 | def post_param(self, name, default=None): 53 | """ 54 | Returns a param of a POST request identified by its name. 55 | """ 56 | try: 57 | return self.post_params[name] 58 | except KeyError: 59 | return default 60 | 61 | def header(self, name, default=None): 62 | """ 63 | Returns the value of the HTTP header identified by `name`. 64 | """ 65 | wsgi_header = "HTTP_{0}".format(name.upper()) 66 | 67 | try: 68 | return self.env_raw[wsgi_header] 69 | except KeyError: 70 | return default 71 | 72 | 73 | class Application(object): 74 | """ 75 | Implements WSGI. 76 | 77 | .. versionchanged:: 1.0.0 78 | Renamed from ``Server`` to ``Application``. 79 | """ 80 | HTTP_CODES = {200: "200 OK", 81 | 301: "301 Moved Permanently", 82 | 302: "302 Found", 83 | 400: "400 Bad Request", 84 | 401: "401 Unauthorized", 85 | 404: "404 Not Found"} 86 | 87 | def __init__(self, provider, authorize_uri="/authorize", env_vars=None, 88 | request_class=Request, token_uri="/token"): 89 | self.authorize_uri = authorize_uri 90 | self.env_vars = env_vars 91 | self.request_class = request_class 92 | self.provider = provider 93 | self.token_uri = token_uri 94 | 95 | self.provider.authorize_path = authorize_uri 96 | self.provider.token_path = token_uri 97 | 98 | def __call__(self, env, start_response): 99 | environ = {} 100 | 101 | if (env["PATH_INFO"] != self.authorize_uri 102 | and env["PATH_INFO"] != self.token_uri): 103 | start_response("404 Not Found", 104 | [('Content-type', 'text/html')]) 105 | return [b"Not Found"] 106 | 107 | request = self.request_class(env) 108 | 109 | if isinstance(self.env_vars, list): 110 | for varname in self.env_vars: 111 | if varname in env: 112 | environ[varname] = env[varname] 113 | 114 | response = self.provider.dispatch(request, environ) 115 | 116 | start_response(self.HTTP_CODES[response.status_code], 117 | list(response.headers.items())) 118 | 119 | return [response.body.encode('utf-8')] 120 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mock 2 | nose 3 | pymongo 4 | python-memcached 5 | redis 6 | tornado 7 | http://dev.mysql.com/get/Downloads/Connector-Python/mysql-connector-python-1.1.7.tar.gz 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from setuptools import setup 4 | from oauth2 import VERSION 5 | 6 | setup(name="python-oauth2", 7 | version=VERSION, 8 | description="OAuth 2.0 provider for python", 9 | long_description=open("README.rst").read(), 10 | author="Markus Meyer", 11 | author_email="hydrantanderwand@gmail.com", 12 | url="https://github.com/wndhydrnt/python-oauth2", 13 | packages=[d[0].replace("/", ".") for d in os.walk("oauth2") if not d[0].endswith("__pycache__")], 14 | extras_require={ 15 | "memcache": ["python-memcached"], 16 | "mongodb": ["pymongo"], 17 | "redis": ["redis"] 18 | }, 19 | classifiers=[ 20 | "Development Status :: 4 - Beta", 21 | "License :: OSI Approved :: MIT License", 22 | "Programming Language :: Python :: 2", 23 | "Programming Language :: Python :: 2.7", 24 | "Programming Language :: Python :: 3", 25 | "Programming Language :: Python :: 3.4", 26 | "Programming Language :: Python :: 3.5", 27 | "Programming Language :: Python :: 3.6", 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /vagrant/Berksfile: -------------------------------------------------------------------------------- 1 | source "http://api.berkshelf.com" 2 | 3 | cookbook 'memcached' 4 | cookbook 'mongodb' 5 | cookbook 'mysql' 6 | cookbook 'redisio' 7 | cookbook 'ulimit' 8 | -------------------------------------------------------------------------------- /vagrant/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'berkshelf' 4 | 5 | -------------------------------------------------------------------------------- /vagrant/README.md: -------------------------------------------------------------------------------- 1 | Development VM 2 | ============== 3 | 4 | The development VM helps to quickly set up a dev environment. 5 | It uses VirtualBox and Vagrant to create the virtual machine. 6 | The VM uses the ``precise64`` image provided by Vagrant. 7 | All data is stored in a local mongodb which is installed automatically. 8 | 9 | Requirements 10 | ------------ 11 | 12 | - [VirtualBox](https://www.virtualbox.org/wiki/Downloads) to create the VM. 13 | - [Vagrant](http://downloads.vagrantup.com/) to set up the VM. 14 | - [vagrant-omnibus](https://github.com/schisamo/vagrant-omnibus) to keep Chef up-to-date. 15 | - [Bundler](http://bundler.io/) to install gems 16 | 17 | Setup 18 | ----- 19 | 20 | Go into the ``/vagrant`` sub-directory and use vagrant to boot up the VM: 21 | 22 | $ cd ./vagrant 23 | $ bundle install // install gems. this will install berkshelf. 24 | $ bundle exec berks vendor ./cookbooks // install chef cookbooks 25 | $ vagrant up 26 | 27 | Creating the VM can take several minutes. 28 | 29 | Starting the oauth2 server 30 | ------------------------ 31 | 32 | After the VM has booted up, you can start the oauth2 server: 33 | 34 | $ vagrant ssh 35 | vagrant@precise64$ python /vagrant/start_provider.py 36 | 37 | The server listens on port ``8888``. 38 | You can now start to obtain tokens: 39 | 40 | $ curl "http://127.0.0.1:8888/authorize?response_type=code&client_id=tc&state=xyz" --verbose 41 | -------------------------------------------------------------------------------- /vagrant/Vagrantfile: -------------------------------------------------------------------------------- 1 | # -*- mode: ruby -*- 2 | # vi: set ft=ruby : 3 | 4 | Vagrant.configure("2") do |config| 5 | # All Vagrant configuration is done here. The most common configuration 6 | # options are documented and commented below. For a complete reference, 7 | # please see the online documentation at vagrantup.com. 8 | 9 | # Every Vagrant virtual environment requires a box to build off of. 10 | config.vm.box = "precise64" 11 | 12 | # The url from where the 'config.vm.box' box will be fetched if it 13 | # doesn't already exist on the user's system. 14 | config.vm.box_url = "http://files.vagrantup.com/precise64.box" 15 | 16 | # Create a forwarded port mapping which allows access to a specific port 17 | # within the machine from a port on the host machine. In the example below, 18 | # accessing "localhost:8080" will access port 80 on the guest machine. 19 | config.vm.network :forwarded_port, guest: 8888, host: 8888 20 | 21 | # Create a private network, which allows host-only access to the machine 22 | # using a specific IP. 23 | # config.vm.network :private_network, ip: "192.168.33.10" 24 | 25 | # Create a public network, which generally matched to bridged network. 26 | # Bridged networks make the machine appear as another physical device on 27 | # your network. 28 | # config.vm.network :public_network 29 | 30 | # Share an additional folder to the guest VM. The first argument is 31 | # the path on the host to the actual folder. The second argument is 32 | # the path on the guest to mount the folder. And the optional third 33 | # argument is a set of non-required options. 34 | # config.vm.synced_folder "../data", "/vagrant_data" 35 | config.vm.synced_folder "../", "/opt/python-oauth2" 36 | 37 | # Provider-specific configuration so you can fine-tune various 38 | # backing providers for Vagrant. These expose provider-specific options. 39 | # Example for VirtualBox: 40 | # 41 | config.vm.provider :virtualbox do |vb| 42 | # Use VBoxManage to customize the VM. For example to change memory: 43 | vb.customize ["modifyvm", :id, "--memory", "512"] 44 | end 45 | # 46 | # View the documentation for the provider you're using for more 47 | # information on available options. 48 | 49 | # Enable provisioning with chef solo, specifying a cookbooks path, roles 50 | # path, and data_bags path (all relative to this Vagrantfile), and adding 51 | # some recipes and/or roles. 52 | # 53 | # config.vm.provision "shell", inline: "wget -O - https://www.opscode.com/chef/install.sh | sudo bash" 54 | 55 | config.omnibus.chef_version = :latest 56 | 57 | config.vm.provision :chef_solo do |chef| 58 | chef.cookbooks_path = "cookbooks" 59 | 60 | chef.add_recipe "apt" 61 | chef.add_recipe "memcached" 62 | chef.add_recipe "mongodb::10gen_repo" 63 | chef.add_recipe "mongodb" 64 | chef.add_recipe "mysql::server" 65 | chef.add_recipe "redisio::install" 66 | chef.add_recipe "redisio::enable" 67 | 68 | # You may also specify custom JSON attributes: 69 | chef.json = { 70 | 'mysql' => { 71 | 'server_root_password' => '' 72 | } 73 | } 74 | end 75 | 76 | config.vm.provision "shell", path: "setup.sh" 77 | end 78 | -------------------------------------------------------------------------------- /vagrant/create_testclient.py: -------------------------------------------------------------------------------- 1 | import mysql.connector 2 | from pymongo import MongoClient 3 | 4 | client_id = "tc" 5 | client_secret = "abc" 6 | authorized_grants = ["authorization_code", "client_credentials", "password", 7 | "refresh_token"] 8 | authorized_response_types = ["code", "token"] 9 | redirect_uris = ["http://127.0.0.1/index.html"] 10 | 11 | 12 | def create_in_mongodb(): 13 | client = MongoClient() 14 | 15 | db = client.testdb 16 | 17 | clients = db.clients 18 | 19 | client = clients.find_one({"identifier": client_id}) 20 | 21 | if client is None: 22 | print("Creating test client in mongodb...") 23 | clients.insert({"identifier": client_id, "secret": client_secret, 24 | "authorized_grants": authorized_grants, 25 | "authorized_response_types": authorized_response_types, 26 | "redirect_uris": redirect_uris}) 27 | 28 | 29 | def create_in_mysql(): 30 | connection = mysql.connector.connect(host="127.0.0.1", user="root", 31 | passwd="", db="testdb") 32 | 33 | check_client = connection.cursor() 34 | check_client.execute("SELECT * FROM clients WHERE identifier = %s", (client_id,)) 35 | client_data = check_client.fetchone() 36 | check_client.close() 37 | 38 | if client_data is None: 39 | print("Creating client in mysql...") 40 | create_client = connection.cursor() 41 | 42 | create_client.execute(""" 43 | INSERT INTO clients ( 44 | identifier, secret 45 | ) VALUES ( 46 | %s, %s 47 | )""", (client_id, client_secret)) 48 | 49 | client_id_in_mysql = create_client.lastrowid 50 | 51 | connection.commit() 52 | 53 | create_client.close() 54 | 55 | for authorized_grant in authorized_grants: 56 | create_grant = connection.cursor() 57 | 58 | create_grant.execute(""" 59 | INSERT INTO client_grants ( 60 | name, client_id 61 | ) VALUES ( 62 | %s, %s 63 | )""", (authorized_grant, client_id_in_mysql)) 64 | 65 | connection.commit() 66 | 67 | create_grant.close() 68 | 69 | for response_type in authorized_response_types: 70 | create_response_type = connection.cursor() 71 | 72 | create_response_type.execute(""" 73 | INSERT INTO client_response_types ( 74 | response_type, client_id 75 | ) VALUES ( 76 | %s, %s 77 | )""", (response_type, client_id_in_mysql)) 78 | 79 | connection.commit() 80 | 81 | create_response_type.close() 82 | 83 | for redirect_uri in redirect_uris: 84 | create_redirect_uri = connection.cursor() 85 | 86 | create_redirect_uri.execute(""" 87 | INSERT INTO client_redirect_uris ( 88 | redirect_uri, client_id 89 | ) VALUES ( 90 | %s, %s 91 | )""", (redirect_uri, client_id_in_mysql)) 92 | 93 | connection.commit() 94 | 95 | create_redirect_uri.close() 96 | 97 | 98 | create_in_mysql() 99 | 100 | create_in_mongodb() 101 | -------------------------------------------------------------------------------- /vagrant/mysql-schema.sql: -------------------------------------------------------------------------------- 1 | -- MySQL Script generated by MySQL Workbench 2 | -- Sun May 11 20:26:06 2014 3 | -- Model: New Model Version: 1.0 4 | SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0; 5 | SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0; 6 | SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='TRADITIONAL,ALLOW_INVALID_DATES'; 7 | 8 | -- ----------------------------------------------------- 9 | -- Schema testdb 10 | -- ----------------------------------------------------- 11 | CREATE SCHEMA IF NOT EXISTS `testdb` DEFAULT CHARACTER SET utf8 COLLATE utf8_general_ci ; 12 | USE `testdb` ; 13 | 14 | -- ----------------------------------------------------- 15 | -- Table `testdb`.`access_tokens` 16 | -- ----------------------------------------------------- 17 | CREATE TABLE IF NOT EXISTS `testdb`.`access_tokens` ( 18 | `id` INT NOT NULL AUTO_INCREMENT COMMENT 'Unique identifier', 19 | `client_id` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client. Assuming it is an arbitrary text which is a maximum of 32 characters long.', 20 | `grant_type` ENUM('authorization_code', 'implicit', 'password', 'client_credentials', 'refresh_token') NOT NULL COMMENT 'The type of a grant for which a token has been issued.', 21 | `token` CHAR(36) NOT NULL COMMENT 'The access token.', 22 | `expires_at` TIMESTAMP NULL COMMENT 'The timestamp at which the token expires.', 23 | `refresh_token` CHAR(36) NULL COMMENT 'The refresh token.', 24 | `refresh_expires_at` TIMESTAMP NULL COMMENT 'The timestamp at which the refresh token expires.', 25 | `user_id` INT NULL COMMENT 'The identifier of the user this token belongs to.', 26 | PRIMARY KEY (`id`), 27 | INDEX `fetch_by_refresh_token` (`refresh_token` ASC), 28 | INDEX `fetch_existing_token_of_user` (`client_id` ASC, `grant_type` ASC, `user_id` ASC)) 29 | ENGINE = InnoDB; 30 | 31 | 32 | -- ----------------------------------------------------- 33 | -- Table `testdb`.`access_token_scopes` 34 | -- ----------------------------------------------------- 35 | CREATE TABLE IF NOT EXISTS `testdb`.`access_token_scopes` ( 36 | `id` INT NOT NULL AUTO_INCREMENT, 37 | `name` VARCHAR(32) NOT NULL COMMENT 'The name of scope.', 38 | `access_token_id` INT NOT NULL COMMENT 'The unique identifier of the access token this scope belongs to.', 39 | PRIMARY KEY (`id`)) 40 | ENGINE = InnoDB; 41 | 42 | 43 | -- ----------------------------------------------------- 44 | -- Table `testdb`.`access_token_data` 45 | -- ----------------------------------------------------- 46 | CREATE TABLE IF NOT EXISTS `testdb`.`access_token_data` ( 47 | `id` INT NOT NULL AUTO_INCREMENT, 48 | `key` VARCHAR(32) NOT NULL COMMENT 'The key of an entry converted to the key in a Python dict.', 49 | `value` VARCHAR(32) NOT NULL COMMENT 'The value of an entry converted to the value in a Python dict.', 50 | `access_token_id` INT NOT NULL COMMENT 'The unique identifier of the access token a row belongs to.', 51 | PRIMARY KEY (`id`)) 52 | ENGINE = InnoDB; 53 | 54 | 55 | -- ----------------------------------------------------- 56 | -- Table `testdb`.`auth_codes` 57 | -- ----------------------------------------------------- 58 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_codes` ( 59 | `id` INT NOT NULL AUTO_INCREMENT, 60 | `client_id` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client. Assuming it is an arbitrary text which is a maximum of 32 characters long.', 61 | `code` CHAR(36) NOT NULL COMMENT 'The authorisation code.', 62 | `expires_at` TIMESTAMP NOT NULL COMMENT 'The timestamp at which the token expires.', 63 | `redirect_uri` VARCHAR(128) NULL COMMENT 'The redirect URI send by the client during the request of an authorisation code.', 64 | `user_id` INT NULL COMMENT 'The identifier of the user this authorisation code belongs to.', 65 | PRIMARY KEY (`id`), 66 | INDEX `fetch_code` (`code` ASC)) 67 | ENGINE = InnoDB; 68 | 69 | 70 | -- ----------------------------------------------------- 71 | -- Table `testdb`.`auth_code_data` 72 | -- ----------------------------------------------------- 73 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_code_data` ( 74 | `id` INT NOT NULL AUTO_INCREMENT, 75 | `key` VARCHAR(32) NOT NULL COMMENT 'The key of an entry converted to the key in a Python dict.', 76 | `value` VARCHAR(32) NOT NULL COMMENT 'The value of an entry converted to the value in a Python dict.', 77 | `auth_code_id` INT NOT NULL COMMENT 'The identifier of the authorisation code that this row belongs to.', 78 | PRIMARY KEY (`id`)) 79 | ENGINE = InnoDB; 80 | 81 | 82 | -- ----------------------------------------------------- 83 | -- Table `testdb`.`auth_code_scopes` 84 | -- ----------------------------------------------------- 85 | CREATE TABLE IF NOT EXISTS `testdb`.`auth_code_scopes` ( 86 | `id` INT NOT NULL AUTO_INCREMENT, 87 | `name` VARCHAR(32) NOT NULL, 88 | `auth_code_id` INT NOT NULL, 89 | PRIMARY KEY (`id`)) 90 | ENGINE = InnoDB; 91 | 92 | 93 | -- ----------------------------------------------------- 94 | -- Table `testdb`.`clients` 95 | -- ----------------------------------------------------- 96 | CREATE TABLE IF NOT EXISTS `testdb`.`clients` ( 97 | `id` INT NOT NULL AUTO_INCREMENT, 98 | `identifier` VARCHAR(32) NOT NULL COMMENT 'The identifier of a client.', 99 | `secret` VARCHAR(32) NOT NULL COMMENT 'The secret of a client.', 100 | PRIMARY KEY (`id`)) 101 | ENGINE = InnoDB; 102 | 103 | 104 | -- ----------------------------------------------------- 105 | -- Table `testdb`.`client_grants` 106 | -- ----------------------------------------------------- 107 | CREATE TABLE IF NOT EXISTS `testdb`.`client_grants` ( 108 | `id` INT NOT NULL AUTO_INCREMENT, 109 | `name` VARCHAR(32) NOT NULL, 110 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 111 | PRIMARY KEY (`id`)) 112 | ENGINE = InnoDB; 113 | 114 | 115 | -- ----------------------------------------------------- 116 | -- Table `testdb`.`client_redirect_uris` 117 | -- ----------------------------------------------------- 118 | CREATE TABLE IF NOT EXISTS `testdb`.`client_redirect_uris` ( 119 | `id` INT NOT NULL AUTO_INCREMENT, 120 | `redirect_uri` VARCHAR(128) NOT NULL COMMENT 'A URI of a client.', 121 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 122 | PRIMARY KEY (`id`)) 123 | ENGINE = InnoDB; 124 | 125 | 126 | -- ----------------------------------------------------- 127 | -- Table `testdb`.`client_response_types` 128 | -- ----------------------------------------------------- 129 | CREATE TABLE IF NOT EXISTS `testdb`.`client_response_types` ( 130 | `id` INT NOT NULL AUTO_INCREMENT, 131 | `response_type` VARCHAR(32) NOT NULL COMMENT 'The response type that a client can use.', 132 | `client_id` INT NOT NULL COMMENT 'The id of the client a row belongs to.', 133 | PRIMARY KEY (`id`)) 134 | ENGINE = InnoDB; 135 | 136 | 137 | SET SQL_MODE=@OLD_SQL_MODE; 138 | SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS; 139 | SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS; 140 | -------------------------------------------------------------------------------- /vagrant/setup.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install pip and python development libs 4 | apt-get -y install python-pip python-dev libmysqlclient-dev make 5 | # Install pythonb libs 6 | pip install -r /opt/python-oauth2/requirements.txt 7 | # Make python-oauth2 available for python 8 | if ! grep -Fxq "export PYTHONPATH=/opt/python-oauth2" /home/vagrant/.bashrc 9 | then 10 | echo "export PYTHONPATH=/opt/python-oauth2" >> /home/vagrant/.bashrc 11 | fi 12 | # Create the testdb database in mysql 13 | mysql -uroot < /vagrant/mysql-schema.sql 14 | # Execute script to create a testclient entry in mongodb 15 | python /vagrant/create_testclient.py 16 | -------------------------------------------------------------------------------- /vagrant/start_provider.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import mysql.connector 3 | from pymongo import MongoClient 4 | 5 | from wsgiref.simple_server import make_server 6 | 7 | from oauth2 import Provider 8 | from oauth2.store.dbapi.mysql import MysqlAccessTokenStore, MysqlAuthCodeStore, \ 9 | MysqlClientStore 10 | from oauth2.store.mongodb import AccessTokenStore, AuthCodeStore, ClientStore 11 | from oauth2.tokengenerator import Uuid4 12 | from oauth2.web import SiteAdapter, Wsgi 13 | from oauth2.grant import AuthorizationCodeGrant, ImplicitGrant, ResourceOwnerGrant,\ 14 | RefreshToken, ClientCredentialsGrant 15 | 16 | 17 | class TestSiteAdapter(SiteAdapter): 18 | def authenticate(self, request, environ, response): 19 | return {}, 123 20 | 21 | def user_has_denied_access(self, request): 22 | return False 23 | 24 | 25 | def main(): 26 | parser = argparse.ArgumentParser(description="python-oauth2 test provider") 27 | parser.add_argument("--store", dest="store", type=str, default="mongodb", 28 | help="The store adapter to use. Can one of 'mongodb'"\ 29 | "(default), 'mysql'") 30 | args = parser.parse_args() 31 | 32 | if args.store == "mongodb": 33 | print("Using mongodb stores...") 34 | client = MongoClient() 35 | 36 | db = client.testdb 37 | 38 | access_token_store = AccessTokenStore(collection=db["access_tokens"]) 39 | auth_code_store = AuthCodeStore(collection=db["auth_codes"]) 40 | client_store = ClientStore(collection=db["clients"]) 41 | elif args.store == "mysql": 42 | print("Using mysql stores...") 43 | connection = mysql.connector.connect(host="127.0.0.1", user="root", 44 | passwd="", db="testdb") 45 | 46 | access_token_store = MysqlAccessTokenStore(connection=connection) 47 | auth_code_store = MysqlAuthCodeStore(connection=connection) 48 | client_store = MysqlClientStore(connection=connection) 49 | else: 50 | raise Exception("Unknown store") 51 | 52 | provider = Provider(access_token_store=access_token_store, 53 | auth_code_store=auth_code_store, 54 | client_store=client_store, 55 | site_adapter=TestSiteAdapter(), 56 | token_generator=Uuid4()) 57 | 58 | provider.add_grant(AuthorizationCodeGrant(expires_in=120)) 59 | provider.add_grant(ImplicitGrant()) 60 | provider.add_grant(ResourceOwnerGrant()) 61 | provider.add_grant(ClientCredentialsGrant()) 62 | provider.add_grant(RefreshToken(expires_in=60)) 63 | 64 | app = Wsgi(server=provider) 65 | 66 | try: 67 | httpd = make_server('', 8888, app) 68 | print("Starting test auth server on port 8888...") 69 | httpd.serve_forever() 70 | except KeyboardInterrupt: 71 | httpd.server_close() 72 | 73 | if __name__ == "__main__": 74 | main() 75 | --------------------------------------------------------------------------------